Cart
What the shopper is about to buy: the lines, the quantities, the totals and the way to pay.
Cart page with an order summary
cart-01The lines on the left, each with a stepper capped at the stock and a Remove that can be undone; the order summary on the right, with a discount code, the way to free delivery, the tax and the total.
import * as React from "react"
import Link from "next/link"
import { ArrowLeftIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatCurrency } from "@/lib/format"
import { buttonVariants } from "@/components/ui/button"
import { ProductArt } from "@/components/ui/product-art"
import { CartPage } from "./cart-01.client"
import { FREE_DELIVERY_FROM_CENTS, cartLines } from "./cart-01.data"
/**
* The cart page: the lines on the left — each its picture, its department
* and price, the stock when it runs low, a stepper that stops at the shelf
* and a Remove that can be undone — and the order summary on the right, with
* a discount code, the way to free delivery, the tax and the total. Stacked
* on a phone, the summary under the lines.
*
* A server component: it reads the cart through `./cart-01.data`, draws each
* picture here and hands the island the lines with their drawings.
*/
export function Cart01() {
const titleId = React.useId()
const lines = cartLines().map((line) => ({
...line,
art: <ProductArt name={line.name} category={line.category} className="w-20 rounded-md sm:w-24" />,
}))
const freeFrom = formatCurrency(FREE_DELIVERY_FROM_CENTS / 100, "USD", { maximumFractionDigits: 0 })
return (
<section
data-section="cart-01"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="flex flex-wrap items-end justify-between gap-x-6 gap-y-4">
<div className="flex max-w-xl flex-col gap-2">
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
Your cart
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
Standard delivery is free on orders over {freeFrom}, and you have 30 days to send anything back.
</p>
</div>
<Link href="/products" className={cn(buttonVariants({ variant: "outline" }), "group/back")}>
<ArrowLeftIcon
aria-hidden="true"
data-icon="inline-start"
className="transition-transform duration-(--duration-fast) ease-(--ease-standard) group-hover/back:-translate-x-0.5"
/>
Keep shopping
</Link>
</div>
<CartPage lines={lines} checkoutHref="/checkout" shopHref="/products" />
</div>
</section>
)
}
export default Cart01"use client"
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon, MinusIcon, PlusIcon, Trash2Icon, Undo2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
DISCOUNT_CODES,
FREE_DELIVERY_FROM_CENTS,
TAX_PERCENT,
cartTotals,
checkCode,
itemCount,
money,
type DiscountCode,
} from "@/lib/dashboards/ecommerce/checkout"
import { Button, buttonVariants } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Progress } from "@/components/ui/progress"
import { StatusBadge } from "@/components/ui/status-badge"
import type { CartLine } from "./cart-01.data"
/** A line as the island holds it: the row, its drawing from the server, and how many. */
export type CartPageLine = CartLine & { art: React.ReactNode }
type Removed = { line: CartPageLine; index: number }
/** The code the field suggests: the shop's first, a share off everything. */
const WELCOME = DISCOUNT_CODES[0]
/** One line's quantity: two named buttons around the count, capped at one and at the stock on hand. */
function Stepper({ line, onChange }: { line: CartPageLine; onChange: (qty: number) => void }) {
const limitId = React.useId()
const full = line.qty >= line.stock
return (
<div className="flex flex-col gap-1">
<div role="group" aria-label={`Quantity of ${line.name}`} className="flex h-8 w-fit items-center rounded-lg ring-1 ring-border">
<Button
variant="ghost"
size="icon-sm"
aria-label={`Remove one ${line.name}`}
disabled={line.qty <= 1}
focusableWhenDisabled
onClick={() => onChange(line.qty - 1)}
>
<MinusIcon />
</Button>
<span className="min-w-8 text-center text-sm font-medium tabular-nums">{line.qty}</span>
<Button
variant="ghost"
size="icon-sm"
aria-label={`Add one ${line.name}`}
aria-describedby={full ? limitId : undefined}
disabled={full}
focusableWhenDisabled
onClick={() => onChange(line.qty + 1)}
>
<PlusIcon />
</Button>
</div>
{full ? (
<p id={limitId} className="text-xs text-muted-foreground">
All {line.stock} in stock are in your cart
</p>
) : null}
</div>
)
}
/**
* The cart page: every line with its picture, its stock, a stepper that stops
* at what the shelf holds and a Remove that can be undone; beside it, the
* order summary — subtotal, a discount code, delivery (free by standard from
* `FREE_DELIVERY_FROM_CENTS` of goods, with how far there is to go), the tax
* and the total — worked out by the checkout lib on every change. One polite
* status line says what each press did. It sends nothing: the total is what
* the checkout would charge.
*/
export function CartPage({ lines: seed, checkoutHref, shopHref }: { lines: CartPageLine[]; checkoutHref: string; shopHref: string }) {
const [lines, setLines] = React.useState(seed)
const [removed, setRemoved] = React.useState<Removed | null>(null)
const [code, setCode] = React.useState<DiscountCode | null>(null)
const [codeError, setCodeError] = React.useState<string | null>(null)
const [notice, setNotice] = React.useState("")
const undoRef = React.useRef<HTMLButtonElement>(null)
const removeRefs = React.useRef(new Map<string, HTMLButtonElement | null>())
const refocus = React.useRef<string | "undo" | null>(null)
const base = React.useId()
const codeId = `${base}code`
const codeErrorId = `${base}code-error`
const codeHintId = `${base}code-hint`
const meterId = `${base}meter`
const totals = cartTotals(lines, { code })
React.useEffect(() => {
const target = refocus.current
refocus.current = null
if (target === "undo") undoRef.current?.focus()
else if (target) removeRefs.current.get(target)?.focus()
}, [lines])
function setQty(line: CartPageLine, qty: number) {
const next = Math.min(Math.max(qty, 1), line.stock)
setLines((current) => current.map((entry) => (entry.productId === line.productId ? { ...entry, qty: next } : entry)))
setNotice(`${line.name}: ${next} in your cart, ${money(next * line.unitCents)}.`)
}
function remove(line: CartPageLine) {
const index = lines.findIndex((entry) => entry.productId === line.productId)
refocus.current = "undo"
setRemoved({ line, index })
setLines((current) => current.filter((entry) => entry.productId !== line.productId))
setNotice(`Removed ${line.name} from your cart.`)
}
function undo() {
if (!removed) return
const { line, index } = removed
refocus.current = line.productId
setRemoved(null)
setLines((current) => [...current.slice(0, index), line, ...current.slice(index)])
setNotice(`${line.name} is back in your cart.`)
}
function apply(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const input = event.currentTarget.elements.namedItem("code") as HTMLInputElement
const check = checkCode(input.value, totals.subtotalCents)
if (!check.ok) {
setCodeError(check.message)
input.focus()
return
}
setCode(check.code)
setCodeError(null)
input.value = ""
setNotice(`${check.code.code} applied: ${check.code.label}.`)
}
function removeCode() {
if (code) setNotice(`Removed ${code.code}.`)
setCode(null)
}
const lapsed = code?.kind === "amount" && totals.subtotalCents < code.minimumCents
return (
<div className="mt-8 grid gap-8 md:mt-10 lg:grid-cols-[minmax(0,1fr)_22rem] lg:gap-12">
<div className="flex min-w-0 flex-col">
<p className="border-b border-rule pb-3 text-sm text-muted-foreground tabular-nums">{itemCount(totals.units)}</p>
{lines.length === 0 && !removed ? (
<div className="flex flex-col items-start gap-3 py-10">
<p className="text-base font-medium text-foreground">Your cart is empty.</p>
<Link href={shopHref} className={buttonVariants({ variant: "outline" })}>
Browse the shop
</Link>
</div>
) : null}
<ul aria-label="Items in your cart" className="divide-y divide-border">
{lines.map((line, index) => (
<React.Fragment key={line.productId}>
{removed && removed.index === index ? <UndoRow removed={removed} onUndo={undo} undoRef={undoRef} /> : null}
<li className="grid grid-cols-[5rem_minmax(0,1fr)] gap-x-4 gap-y-3 py-5 sm:grid-cols-[6rem_minmax(0,1fr)]">
<div className="row-span-2">{line.art}</div>
<div className="flex min-w-0 items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1">
<Link href={line.href} className="w-fit rounded-sm text-sm font-semibold text-foreground focus-ring hover:underline">
{line.name}
</Link>
<p className="text-xs text-muted-foreground tabular-nums">
{line.category} · {money(line.unitCents)} each
</p>
{line.stockState === "low" ? (
<StatusBadge status="low" variant="warning" label={`Only ${line.stock} left`} className="mt-1 w-fit" />
) : null}
</div>
<p className="shrink-0 text-sm font-semibold tabular-nums">{money(line.qty * line.unitCents)}</p>
</div>
<div className="col-start-2 flex flex-wrap items-start gap-2">
<Stepper line={line} onChange={(qty) => setQty(line, qty)} />
<Button
ref={(node) => {
removeRefs.current.set(line.productId, node)
}}
variant="ghost"
className="text-muted-foreground"
onClick={() => remove(line)}
>
<Trash2Icon data-icon="inline-start" aria-hidden="true" />
Remove <span className="sr-only">{line.name}</span>
</Button>
</div>
</li>
</React.Fragment>
))}
{removed && removed.index >= lines.length ? <UndoRow removed={removed} onUndo={undo} undoRef={undoRef} /> : null}
</ul>
</div>
<Card className={cn("h-fit lg:sticky lg:top-6", lines.length === 0 && "hidden")}>
<CardHeader>
<CardTitle>
<h3>Order summary</h3>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<dl className="flex flex-col gap-2.5 text-sm *:flex *:items-baseline *:justify-between *:gap-4 [&_dd]:tabular-nums">
<div>
<dt className="text-muted-foreground">Subtotal, {itemCount(totals.units)}</dt>
<dd>{money(totals.subtotalCents)}</dd>
</div>
{code ? (
<div>
<dt className="flex items-center gap-2 text-muted-foreground">
Discount <span className="font-mono text-xs text-foreground">{code.code}</span>
<Button variant="link" size="xs" className="h-auto px-0" onClick={removeCode}>
Remove <span className="sr-only">{code.code}</span>
</Button>
</dt>
<dd>−{money(totals.discountCents)}</dd>
</div>
) : null}
<div>
<dt className="text-muted-foreground">Standard delivery</dt>
<dd>{totals.deliveryCents === 0 && totals.units > 0 ? "Free" : money(totals.deliveryCents)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Estimated tax ({TAX_PERCENT}%)</dt>
<dd>{money(totals.taxCents)}</dd>
</div>
<div className="mt-1 border-t border-rule pt-3">
<dt className="font-semibold text-foreground">Total</dt>
<dd className="text-base font-semibold">{money(totals.totalCents)}</dd>
</div>
</dl>
{lapsed && code?.kind === "amount" ? (
<p className="text-xs text-muted-foreground">
{code.code} applies from {money(code.minimumCents)} of goods. Add {money(code.minimumCents - totals.subtotalCents)} to use it.
</p>
) : null}
<div className="flex flex-col gap-2">
<Progress
value={Math.min(100, (totals.goodsCents / FREE_DELIVERY_FROM_CENTS) * 100)}
aria-labelledby={meterId}
aria-valuetext={`${money(totals.goodsCents)} of ${money(FREE_DELIVERY_FROM_CENTS)}`}
/>
<p id={meterId} className="text-xs text-muted-foreground">
{totals.freeDelivery
? "Your order ships free by standard delivery."
: `Add ${money(totals.toFreeDeliveryCents)} for free standard delivery.`}
</p>
</div>
<form noValidate onSubmit={apply} className="flex flex-col gap-2">
<Label htmlFor={codeId}>Discount code</Label>
<div className="flex gap-2">
<Input
id={codeId}
name="code"
autoComplete="off"
spellCheck={false}
aria-invalid={codeError ? true : undefined}
aria-describedby={codeError ? `${codeHintId} ${codeErrorId}` : codeHintId}
className="h-9 font-mono uppercase"
/>
<Button type="submit" variant="outline" size="lg">
Apply
</Button>
</div>
<p id={codeHintId} className="text-xs text-muted-foreground">
New here? <span className="font-mono">{WELCOME.code}</span> takes {WELCOME.label.replace(/ your order$/, "")}.
</p>
{codeError ? <FieldError id={codeErrorId}>{codeError}</FieldError> : null}
</form>
<Link href={checkoutHref} className={cn(buttonVariants({ size: "lg" }), "h-10 w-full")}>
Check out
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
</Link>
</CardContent>
</Card>
<p role="status" aria-live="polite" className="sr-only">
{notice}
</p>
</div>
)
}
/** Where a removed line was: its name and a way to put it back. */
function UndoRow({ removed, onUndo, undoRef }: { removed: Removed; onUndo: () => void; undoRef: React.Ref<HTMLButtonElement> }) {
return (
<li className="flex items-center justify-between gap-3 py-4 text-sm text-muted-foreground">
<span>Removed {removed.line.name}.</span>
<Button ref={undoRef} variant="outline" size="sm" onClick={onUndo}>
<Undo2Icon data-icon="inline-start" aria-hidden="true" />
Undo
</Button>
</li>
)
}/**
* What `cart-01` reads: the shopper's cart (`shopperCart`, from
* checkout-ecommerce) — each line the product as the cart shows it, its list
* price and the stock that caps it. The terms the band quotes and totals by —
* free delivery, the codes, the tax — are the checkout lib's, so the cart adds
* up exactly as the checkout will charge.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes these rows as props; it never imports this module.
*/
import { shopperCart, type CartLine } from "@/lib/dashboards/ecommerce/cart"
import { FREE_DELIVERY_FROM_CENTS } from "@/lib/dashboards/ecommerce/checkout"
export type { CartLine }
/** The lines in the shopper's cart, in the order they were added. */
export function cartLines(): CartLine[] {
return shopperCart()
}
/** From what order total standard delivery is free, in cents: quoted under the heading. */
export { FREE_DELIVERY_FROM_CENTS }npx shadcn@latest add @vibra/cart-01Cart table with save for later
cart-02The cart as a table: a quantity field per line that stops at the stock, a checkbox per line and one over them all, and a selection saved for later or taken out; what is saved waits under the table.
import * as React from "react"
import { ProductArt } from "@/components/ui/product-art"
import { CartTable } from "./cart-02.client"
import { cartLines, savedForLater, type CartLine } from "./cart-02.data"
/** A line with its drawing, sized by the cell it sits in. */
const drawn = (line: CartLine) => ({ ...line, art: <ProductArt name={line.name} category={line.category} className="rounded-md" /> })
/**
* The cart as a table: every line with a quantity field that stops at the
* stock, its price and its total, a checkbox per line and one over them all,
* and what a selection can do — save it for later or take it out. What is set
* aside waits under the table, one press from the cart again. The price
* column folds into the product's line on a phone.
*
* A server component: it reads the cart and the saved lines through
* `./cart-02.data`, draws each picture here and hands the island the lines.
*/
export function Cart02() {
const titleId = React.useId()
return (
<section
data-section="cart-02"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="flex max-w-xl flex-col gap-2">
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
Shopping cart
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
Change a quantity, set a line aside for another day, or clear several at once.
</p>
</div>
<CartTable cart={cartLines().map(drawn)} saved={savedForLater().map(drawn)} checkoutHref="/checkout" />
</div>
</section>
)
}
export default Cart02"use client"
import * as React from "react"
import Link from "next/link"
import { ArrowRightIcon, BookmarkIcon, ShoppingCartIcon, Trash2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
import { cartTotals, itemCount, money } from "@/lib/dashboards/ecommerce/checkout"
import { Button, buttonVariants } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { NumberInput } from "@/components/ui/number-input"
import { StatusBadge } from "@/components/ui/status-badge"
import { Table, TableBody, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@/components/ui/table"
import type { CartLine } from "./cart-02.data"
/** A line as the island holds it: the row and its drawing from the server. */
export type CartTableLine = CartLine & { art: React.ReactNode }
const products = (count: number) => (count === 1 ? "1 product" : `${count} products`)
/**
* The cart as a table: a quantity field per line that takes typing and the
* arrow keys and stops at the stock on hand, a checkbox per line with one
* over them all, and what the selection can do — set it aside for later or
* take it out. What is set aside waits under the table and moves back in with
* one press. One polite status line says what each action did.
*/
export function CartTable({ cart: seedCart, saved: seedSaved, checkoutHref }: { cart: CartTableLine[]; saved: CartTableLine[]; checkoutHref: string }) {
const [cart, setCart] = React.useState(seedCart)
const [saved, setSaved] = React.useState(seedSaved)
const [selected, setSelected] = React.useState<ReadonlySet<string>>(new Set())
const [notice, setNotice] = React.useState("")
const allRef = React.useRef<HTMLButtonElement>(null)
const moveRefs = React.useRef(new Map<string, HTMLButtonElement | null>())
const refocus = React.useRef<string | null>(null)
const savedId = React.useId()
const totals = cartTotals(cart)
// Focus follows what moved: onto the header's checkbox while there are lines
// to select, else onto the first thing saved, so a keyboard never falls to the
// top of the page.
React.useEffect(() => {
const target = refocus.current
refocus.current = null
if (!target) return
if (target !== "all") moveRefs.current.get(target)?.focus()
else if (cart.length > 0) allRef.current?.focus()
else if (saved[0]) moveRefs.current.get(saved[0].productId)?.focus()
}, [cart, saved])
const select = (id: string, on: boolean) =>
setSelected((current) => {
const next = new Set(current)
if (on) next.add(id)
else next.delete(id)
return next
})
function setQty(line: CartTableLine, value: number | null) {
// An emptied field is a number being retyped: the line keeps its count until one arrives.
if (value === null) return
const qty = Math.min(Math.max(Math.round(value), 1), line.stock)
setCart((current) => current.map((entry) => (entry.productId === line.productId ? { ...entry, qty } : entry)))
}
function take(ids: ReadonlySet<string>, to: "saved" | "gone") {
const moving = cart.filter((line) => ids.has(line.productId))
setCart((current) => current.filter((line) => !ids.has(line.productId)))
if (to === "saved") setSaved((current) => [...moving.map((line) => ({ ...line, qty: 1 })), ...current])
setSelected(new Set())
const named = moving.length === 1 ? moving[0].name : products(moving.length)
setNotice(to === "saved" ? `Saved ${named} for later.` : `Removed ${named} from your cart.`)
refocus.current = to === "saved" && moving.length === 1 ? moving[0].productId : "all"
}
function moveToCart(line: CartTableLine) {
setSaved((current) => current.filter((entry) => entry.productId !== line.productId))
setCart((current) => [...current, { ...line, qty: 1 }])
setNotice(`Moved ${line.name} to your cart.`)
refocus.current = "all"
}
const all = cart.length > 0 && selected.size === cart.length
const some = selected.size > 0 && !all
return (
<div className="mt-8 flex flex-col gap-10 md:mt-10">
<div className="flex flex-col gap-3">
<div className="flex min-h-8 flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground tabular-nums">
{selected.size > 0 ? `${products(selected.size)} selected` : `${products(cart.length)}, ${itemCount(totals.units)}`}
</p>
{selected.size > 0 ? (
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => take(selected, "saved")}>
<BookmarkIcon data-icon="inline-start" aria-hidden="true" />
Save for later
</Button>
<Button variant="outline" size="sm" onClick={() => take(selected, "gone")}>
<Trash2Icon data-icon="inline-start" aria-hidden="true" />
Remove
</Button>
</div>
) : null}
</div>
<div className="panel">
<Table>
<TableHeader>
<TableRow className="hover:bg-transparent">
<TableHead className="w-9 ps-3 sm:w-10 sm:ps-4">
<Checkbox
ref={allRef}
aria-label="Select every line"
checked={all}
indeterminate={some}
disabled={cart.length === 0}
onCheckedChange={(on) => setSelected(on ? new Set(cart.map((line) => line.productId)) : new Set())}
/>
</TableHead>
<TableHead>Product</TableHead>
<TableHead className="hidden text-end sm:table-cell">Price</TableHead>
<TableHead className="w-20 sm:w-28">Quantity</TableHead>
<TableHead className="hidden text-end sm:table-cell">Total</TableHead>
<TableHead className="w-9 pe-3 sm:w-20 sm:pe-4">
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{cart.length === 0 ? (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={6} className="py-10 text-center text-muted-foreground">
Your cart is empty.
</TableCell>
</TableRow>
) : null}
{cart.map((line) => (
<TableRow key={line.productId} data-state={selected.has(line.productId) ? "selected" : undefined}>
<TableCell className="ps-3 sm:ps-4">
<Checkbox aria-label={`Select ${line.name}`} checked={selected.has(line.productId)} onCheckedChange={(on) => select(line.productId, on)} />
</TableCell>
<TableCell className="whitespace-normal sm:min-w-44">
<div className="flex items-center gap-2.5 sm:gap-3">
<div className="w-10 shrink-0 sm:w-12">{line.art}</div>
<div className="flex min-w-0 flex-col gap-0.5">
<Link href={line.href} className="w-fit rounded-sm font-medium text-foreground focus-ring hover:underline">
{line.name}
</Link>
<span className="text-xs text-muted-foreground tabular-nums">
{line.category}
<span className="sm:hidden"> · {money(line.unitCents)} each</span>
</span>
{/* The total column folds in here on a phone. */}
<span className="text-sm font-medium tabular-nums sm:hidden">{money(line.qty * line.unitCents)}</span>
</div>
</div>
</TableCell>
<TableCell className="hidden text-end tabular-nums sm:table-cell">{money(line.unitCents)}</TableCell>
<TableCell className="whitespace-normal">
<NumberInput
aria-label={`Quantity of ${line.name}`}
value={line.qty}
min={1}
max={line.stock}
onValueChange={(value) => setQty(line, value)}
// Kit fix pending: the field dims whole when one stepper is off at a bound; only a disabled field should.
className="w-20 has-disabled:opacity-100 has-[input:disabled]:opacity-50 sm:w-24"
/>
{line.qty >= line.stock ? (
<p className="mt-1 text-xs text-muted-foreground">Only {line.stock} in stock</p>
) : line.stockState === "low" ? (
<StatusBadge status="low" variant="warning" label="Low stock" size="sm" className="mt-1" />
) : null}
</TableCell>
<TableCell className="hidden text-end font-medium tabular-nums sm:table-cell">{money(line.qty * line.unitCents)}</TableCell>
<TableCell className="pe-3 sm:pe-4">
<div className="flex flex-col items-end gap-0.5 sm:flex-row sm:justify-end">
<Button variant="ghost" size="icon-sm" aria-label={`Save ${line.name} for later`} onClick={() => take(new Set([line.productId]), "saved")}>
<BookmarkIcon />
</Button>
<Button variant="ghost" size="icon-sm" aria-label={`Remove ${line.name}`} onClick={() => take(new Set([line.productId]), "gone")}>
<Trash2Icon />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
{/* The subtotal sits under the Total column where there is one, and across the row on a phone. */}
<TableRow className="hidden hover:bg-transparent sm:table-row">
<TableCell colSpan={4} className="ps-4 text-muted-foreground">
Subtotal, {itemCount(totals.units)}
</TableCell>
<TableCell className="text-end text-base font-semibold tabular-nums">{money(totals.subtotalCents)}</TableCell>
<TableCell />
</TableRow>
<TableRow className="hover:bg-transparent sm:hidden">
<TableCell colSpan={6} className="px-3">
<div className="flex items-baseline justify-between gap-3">
<span className="text-muted-foreground">Subtotal, {itemCount(totals.units)}</span>
<span className="text-base font-semibold tabular-nums">{money(totals.subtotalCents)}</span>
</div>
</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">Delivery and tax are added at checkout.</p>
<Link href={checkoutHref} className={cn(buttonVariants({ size: "lg" }), "h-10 px-4")}>
Check out
<ArrowRightIcon data-icon="inline-end" aria-hidden="true" />
</Link>
</div>
</div>
<div className="flex flex-col gap-4">
<h3 id={savedId} className="text-base font-semibold">
Saved for later
</h3>
{saved.length === 0 ? <p className="text-sm text-muted-foreground">Nothing saved. Set a line aside and it waits here.</p> : null}
<ul aria-labelledby={savedId} className="grid gap-3 sm:grid-cols-2">
{saved.map((line) => (
<li key={line.productId} className="flex items-center gap-4 panel p-3">
<div className="w-16 shrink-0">{line.art}</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<Link href={line.href} className="w-fit rounded-sm text-sm font-medium text-foreground focus-ring hover:underline">
{line.name}
</Link>
<span className="text-xs text-muted-foreground tabular-nums">
{line.category} · {money(line.unitCents)}
</span>
<div className="mt-1 flex flex-wrap gap-2">
<Button
ref={(node) => {
moveRefs.current.set(line.productId, node)
}}
variant="outline"
size="sm"
disabled={line.stock === 0}
onClick={() => moveToCart(line)}
>
<ShoppingCartIcon data-icon="inline-start" aria-hidden="true" />
{line.stock === 0 ? "Sold out" : "Move to cart"}
<span className="sr-only">: {line.name}</span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSaved((current) => current.filter((entry) => entry.productId !== line.productId))
setNotice(`Removed ${line.name} from saved for later.`)
}}
>
Remove <span className="sr-only">{line.name} from saved for later</span>
</Button>
</div>
</div>
</li>
))}
</ul>
</div>
<p role="status" aria-live="polite" className="sr-only">
{notice}
</p>
</div>
)
}/**
* What `cart-02` reads: the shopper's cart (`shopperCart`, from
* cart-ecommerce), and what they set aside for later. The store keeps no
* saved lists either, so that is a rule over the shelf: the bestseller of
* each department the cart does not reach, two at most — the bigger things a
* shopper tends to leave for another day. Replace `savedForLater` with your
* saved-items store and the band follows.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes these rows as props; it never imports this module.
*/
import { shopperCart, type CartLine } from "@/lib/dashboards/ecommerce/cart"
import { storeShelf } from "@/lib/dashboards/ecommerce/storefront"
export type { CartLine }
/** The lines in the shopper's cart, in the order they were added. */
export function cartLines(): CartLine[] {
return shopperCart()
}
/** What the shopper saved for later: one bestseller from each department the cart has none of, two at most, one of each. */
export function savedForLater(count = 2): CartLine[] {
const departments = new Set(shopperCart().map((line) => line.category))
const saved: CartLine[] = []
for (const product of storeShelf()) {
if (saved.length === count) break
if (departments.has(product.category)) continue
departments.add(product.category)
saved.push({
productId: product.id,
name: product.name,
category: product.category,
href: product.href,
unitCents: product.priceCents,
qty: 1,
stock: product.stock,
stockState: product.stockState,
})
}
return saved
}npx shadcn@latest add @vibra/cart-02Cart drawer
cart-03A bar that says what the cart holds and a Cart button that opens it from the right: how far the goods are from free delivery, each line with a stepper and a Remove, the subtotal and the way on.
import * as React from "react"
import { ProductArt } from "@/components/ui/product-art"
import { CartDrawer } from "./cart-03.client"
import { cartLines } from "./cart-03.data"
export type Cart03Props = {
/** Opens the drawer on the first render — for a preview; false when installed. */
defaultOpen?: boolean
}
/**
* The cart in a drawer: a bar that says what the cart holds and a Cart
* button that opens it from the right — how far the goods are from free
* delivery, each line with a stepper and a Remove, the subtotal, and the way
* to the cart page or straight to the checkout. Full width on a phone.
*
* A server component: it reads the cart through `./cart-03.data`, draws each
* picture here and hands the island the lines.
*/
export function Cart03({ defaultOpen = false }: Cart03Props) {
const titleId = React.useId()
const lines = cartLines().map((line) => ({
...line,
art: <ProductArt name={line.name} category={line.category} className="rounded-md" />,
}))
return (
<section
data-section="cart-03"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<CartDrawer lines={lines} checkoutHref="/checkout" cartHref="/cart" defaultOpen={defaultOpen}>
<h2 id={titleId} className="text-base font-semibold text-foreground">
Your cart is saved
</h2>
</CartDrawer>
</div>
</section>
)
}
export default Cart03"use client"
import * as React from "react"
import Link from "next/link"
import { MinusIcon, PlusIcon, ShoppingBagIcon, Trash2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
import { FREE_DELIVERY_FROM_CENTS, cartTotals, itemCount, money } from "@/lib/dashboards/ecommerce/checkout"
import { Button, buttonVariants } from "@/components/ui/button"
import { Progress } from "@/components/ui/progress"
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet"
import type { CartLine } from "./cart-03.data"
/** A line as the drawer holds it: the row and its drawing from the server. */
export type DrawerLine = CartLine & { art: React.ReactNode }
/**
* The cart in a drawer: the bar's button — its name says how many items are
* in the cart — opens the kit's Sheet from the right, focus inside it. The
* drawer measures the goods against free delivery, lists each line with a
* stepper capped at one and at the stock and a Remove, and ends on the
* subtotal and the two ways on. Escape or Close shut it, focus back on the
* button. `defaultOpen` opens it for a preview, focus on the drawer itself.
*/
export function CartDrawer({
lines: seed,
checkoutHref,
cartHref,
defaultOpen = false,
children,
}: {
lines: DrawerLine[]
checkoutHref: string
cartHref: string
defaultOpen?: boolean
/** The bar's heading; the line under it is the drawer's, so it follows every change. */
children: React.ReactNode
}) {
const [open, setOpen] = React.useState(defaultOpen)
const [lines, setLines] = React.useState(seed)
const [notice, setNotice] = React.useState("")
const openedByDefault = React.useRef(defaultOpen)
const popupRef = React.useRef<HTMLDivElement>(null)
const listRef = React.useRef<HTMLUListElement>(null)
const refocus = React.useRef<number | null>(null)
const meterId = React.useId()
const totals = cartTotals(lines)
// After a Remove, onto the line that took its place, or the one before it;
// onto the drawer's Check out when none is left.
React.useEffect(() => {
const index = refocus.current
refocus.current = null
if (index === null) return
const buttons = listRef.current?.querySelectorAll<HTMLElement>("[data-remove]") ?? []
const target = buttons.length ? buttons[Math.min(index, buttons.length - 1)] : popupRef.current?.querySelector<HTMLElement>("[data-checkout]")
target?.focus()
}, [lines])
function setQty(line: DrawerLine, qty: number) {
const next = Math.min(Math.max(qty, 1), line.stock)
setLines((current) => current.map((entry) => (entry.productId === line.productId ? { ...entry, qty: next } : entry)))
setNotice(`${line.name}: ${next}. Subtotal ${money(cartTotals(lines.map((entry) => (entry === line ? { ...entry, qty: next } : entry))).subtotalCents)}.`)
}
function remove(line: DrawerLine) {
const rest = lines.filter((entry) => entry !== line)
refocus.current = lines.indexOf(line)
setLines(rest)
setNotice(`Removed ${line.name}. ${itemCount(cartTotals(rest).units)} left.`)
}
return (
<Sheet
open={open}
onOpenChange={(next) => {
openedByDefault.current = false
setOpen(next)
}}
>
<div className="flex flex-wrap items-center justify-between gap-4 frame p-4 md:p-5">
<div className="flex min-w-0 flex-col gap-1">
{children}
<p className="text-sm text-muted-foreground tabular-nums">
{lines.length === 0
? "Nothing in it yet."
: `${itemCount(totals.units)}, ${money(totals.subtotalCents)} before delivery. Pick up where you left off.`}
</p>
</div>
<SheetTrigger
render={
<Button size="lg" className="h-10 px-4">
<ShoppingBagIcon data-icon="inline-start" aria-hidden="true" />
Cart
<span aria-hidden="true" className="ml-0.5 min-w-5 rounded-full bg-background/20 px-1.5 text-center text-xs tabular-nums">
{totals.units}
</span>
<span className="sr-only">, {itemCount(totals.units)}</span>
</Button>
}
/>
</div>
<SheetContent
ref={popupRef}
initialFocus={() => (openedByDefault.current ? popupRef.current : true)}
className="gap-0 p-0 outline-none data-[side=right]:w-full data-[side=right]:sm:max-w-md"
>
<SheetHeader className="border-b border-border px-5 py-4 pr-14">
<SheetTitle>Your cart</SheetTitle>
<SheetDescription className="tabular-nums">
{itemCount(totals.units)}, {money(totals.subtotalCents)}
</SheetDescription>
</SheetHeader>
<div className="flex flex-col gap-2 border-b border-border px-5 py-4">
<p id={meterId} className="text-sm text-foreground">
{totals.freeDelivery ? "Your order ships free." : `${money(totals.toFreeDeliveryCents)} away from free delivery.`}
</p>
<Progress
value={Math.min(100, (totals.goodsCents / FREE_DELIVERY_FROM_CENTS) * 100)}
aria-labelledby={meterId}
aria-valuetext={`${money(totals.goodsCents)} of ${money(FREE_DELIVERY_FROM_CENTS)}`}
/>
</div>
<ul ref={listRef} aria-label="Items in your cart" className="flex-1 divide-y divide-border overflow-y-auto px-5">
{lines.length === 0 ? <li className="py-10 text-center text-muted-foreground">Your cart is empty.</li> : null}
{lines.map((line) => (
<li key={line.productId} className="flex gap-4 py-4">
<div className="w-16 shrink-0">{line.art}</div>
<div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<Link href={line.href} className="w-fit rounded-sm font-medium text-foreground focus-ring hover:underline">
{line.name}
</Link>
<span className="text-xs text-muted-foreground tabular-nums">{money(line.unitCents)} each</span>
</div>
<span className="shrink-0 font-medium tabular-nums">{money(line.qty * line.unitCents)}</span>
</div>
<div className="flex items-center justify-between gap-2">
<div role="group" aria-label={`Quantity of ${line.name}`} className="flex h-8 items-center rounded-lg ring-1 ring-border">
<Button variant="ghost" size="icon-sm" aria-label={`Remove one ${line.name}`} disabled={line.qty <= 1} focusableWhenDisabled onClick={() => setQty(line, line.qty - 1)}>
<MinusIcon />
</Button>
<span className="min-w-7 text-center text-sm font-medium tabular-nums">{line.qty}</span>
<Button
variant="ghost"
size="icon-sm"
aria-label={line.qty >= line.stock ? `Add one ${line.name}, all ${line.stock} in stock are in your cart` : `Add one ${line.name}`}
disabled={line.qty >= line.stock}
focusableWhenDisabled
onClick={() => setQty(line, line.qty + 1)}
>
<PlusIcon />
</Button>
</div>
<Button data-remove variant="ghost" size="sm" className="text-muted-foreground" onClick={() => remove(line)}>
<Trash2Icon data-icon="inline-start" aria-hidden="true" />
Remove <span className="sr-only">{line.name}</span>
</Button>
</div>
</div>
</li>
))}
</ul>
<SheetFooter className="gap-3 border-t border-border bg-surface px-5 py-4">
<p className="flex items-baseline justify-between text-sm">
<span className="text-muted-foreground">Subtotal</span>
<span className="text-base font-semibold tabular-nums">{money(totals.subtotalCents)}</span>
</p>
<p className="text-xs text-muted-foreground">Tax and delivery are worked out at checkout.</p>
<div className="grid grid-cols-2 gap-2">
<Link href={cartHref} className={cn(buttonVariants({ variant: "outline", size: "lg" }), "h-10")}>
View cart
</Link>
<Link data-checkout href={checkoutHref} className={cn(buttonVariants({ size: "lg" }), "h-10")}>
Check out
</Link>
</div>
</SheetFooter>
<p role="status" aria-live="polite" className="sr-only">
{notice}
</p>
</SheetContent>
</Sheet>
)
}/**
* What `cart-03` reads: the shopper's cart (`shopperCart`, from
* cart-ecommerce) — the same lines the cart page and the checkout show, so the
* drawer, the page and the charge agree. The drawer measures the goods against
* the checkout terms' free-delivery line itself, as the shopper changes them.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes these rows as props; it never imports this module.
*/
import { shopperCart, type CartLine } from "@/lib/dashboards/ecommerce/cart"
export type { CartLine }
/** The lines in the shopper's cart, in the order they were added. */
export function cartLines(): CartLine[] {
return shopperCart()
}npx shadcn@latest add @vibra/cart-03Gift cart
cart-04The cart sent as a gift: a wrap for any line, who it is for, a message for the packing slip counted against its limit, the prices kept off the slip, and the total with the wrapping in it.
import * as React from "react"
import { formatCurrency } from "@/lib/format"
import { ProductArt } from "@/components/ui/product-art"
import { GiftCart } from "./cart-04.client"
import { GIFT_WRAP_CENTS, MESSAGE_LIMIT, cartLines } from "./cart-04.data"
/**
* The cart sent as a gift: the lines, each of which can be wrapped, beside
* the gift panel — who it is for, a message for the packing slip counted
* against its limit, whether the prices stay off the slip — and the total
* with the wrapping in it. Stacked on a phone, the panel under the lines.
*
* A server component: the cart and the gift terms come from
* `./cart-04.data`; every picture is drawn here.
*/
export function Cart04() {
const titleId = React.useId()
const lines = cartLines().map((line) => ({
...line,
art: <ProductArt name={line.name} category={line.category} className="rounded-md" />,
}))
return (
<section
data-section="cart-04"
data-slot="section"
aria-labelledby={titleId}
className="w-full bg-background px-4 py-16 text-foreground md:px-6 md:py-24"
>
<div className="mx-auto max-w-7xl">
<div className="flex max-w-xl flex-col gap-2">
<h2 id={titleId} className="type-display text-2xl md:text-3xl">
Sending it as a gift
</h2>
<p className="text-sm text-pretty text-muted-foreground md:text-base">
Wrap any item for {formatCurrency(GIFT_WRAP_CENTS / 100, "USD")}, add a message for the packing slip, and
keep the prices off it.
</p>
</div>
<GiftCart lines={lines} wrapCents={GIFT_WRAP_CENTS} limit={MESSAGE_LIMIT} />
</div>
</section>
)
}
export default Cart04"use client"
import * as React from "react"
import Link from "next/link"
import { CircleCheckIcon, GiftIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { TAX_PERCENT, cartTotals, itemCount, money } from "@/lib/dashboards/ecommerce/checkout"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
import { FieldError } from "@/components/ui/field"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
import type { CartLine } from "./cart-04.data"
/** A line as the island holds it: the row and its drawing from the server. */
export type GiftLine = CartLine & { art: React.ReactNode }
type Errors = { recipient?: string; message?: string }
/**
* The cart sent as a gift: each line can be wrapped, at `wrapCents` an item;
* the gift panel takes who it is for, a message for the packing slip with a
* count of what is left of `limit`, and whether the prices stay off the slip.
* Saving checks both fields, refuses in words tied to the field and puts focus
* on the first refused; a saved gift is confirmed in the status line. The
* wrapping is added to the total as it is: not goods, so no tax and no share
* of free delivery. It sends nothing: keep the options with your order.
*/
export function GiftCart({ lines, wrapCents, limit }: { lines: GiftLine[]; wrapCents: number; limit: number }) {
const [gift, setGift] = React.useState(true)
const [wrapped, setWrapped] = React.useState<ReadonlySet<string>>(new Set())
const [message, setMessage] = React.useState("")
const [errors, setErrors] = React.useState<Errors>({})
const [hide, setHide] = React.useState(true)
const [saved, setSaved] = React.useState("")
const base = React.useId()
const ids = { gift: `${base}gift`, recipient: `${base}recipient`, message: `${base}message`, count: `${base}count`, hide: `${base}hide` }
const totals = cartTotals(lines)
const wraps = gift ? lines.filter((line) => wrapped.has(line.productId)).reduce((sum, line) => sum + line.qty, 0) : 0
const left = limit - message.length
function wrap(id: string, on: boolean) {
setWrapped((current) => {
const next = new Set(current)
if (on) next.add(id)
else next.delete(id)
return next
})
}
function save(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const recipient = String(new FormData(form).get("recipient") ?? "").trim()
const next: Errors = {}
if (gift && !recipient) next.recipient = "Say who the gift is for, so the packing slip can name them."
if (gift && left < 0) next.message = `Keep the message to ${limit} characters. It is ${-left} over.`
setErrors(next)
const first = (["recipient", "message"] as const).find((field) => next[field])
if (first) {
setSaved("")
document.getElementById(ids[first])?.focus()
return
}
if (!gift) {
setSaved("Saved: this order is not a gift, so it ships with its receipt.")
return
}
const wrapping = wraps === 0 ? "nothing wrapped" : `${wraps} of ${totals.units} items wrapped`
setSaved(`Saved: a gift for ${recipient}, ${wrapping}, ${hide ? "the prices left off the slip" : "the prices on the slip"}.`)
}
return (
<form noValidate onSubmit={save} className="mt-8 grid gap-8 md:mt-10 lg:grid-cols-[minmax(0,1fr)_24rem] lg:gap-12">
<ul aria-label="Items in your cart" className="h-fit divide-y divide-border border-y border-rule">
{lines.map((line) => (
<li key={line.productId} className="flex items-center gap-4 py-4">
<div className="w-16 shrink-0 sm:w-20">{line.art}</div>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<Link href={line.href} className="w-fit rounded-sm text-sm font-semibold text-foreground focus-ring hover:underline">
{line.name}
</Link>
<span className="text-xs text-muted-foreground tabular-nums">
{line.qty} × {money(line.unitCents)}
</span>
{gift ? (
<div className="mt-1 flex items-center gap-2">
<Checkbox id={`${base}wrap-${line.productId}`} checked={wrapped.has(line.productId)} onCheckedChange={(on) => wrap(line.productId, on)} />
<Label htmlFor={`${base}wrap-${line.productId}`} className="text-xs font-normal text-muted-foreground">
Gift wrap, {money(wrapCents)} each <span className="sr-only">for the {line.name}</span>
</Label>
</div>
) : null}
</div>
<span className="shrink-0 text-sm font-semibold tabular-nums">{money(line.qty * line.unitCents)}</span>
</li>
))}
</ul>
<Card className="h-fit">
<CardHeader>
<CardTitle>
<h3 className="flex items-center gap-2">
<GiftIcon aria-hidden="true" className="size-4 text-muted-foreground" />
Gift options
</h3>
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-5">
<div className="flex items-center justify-between gap-4">
<Label htmlFor={ids.gift}>Send it as a gift</Label>
<Switch
id={ids.gift}
checked={gift}
onCheckedChange={(on) => {
setGift(on)
setErrors({})
}}
/>
</div>
{gift ? (
<>
<div className="flex flex-col gap-2">
<Label htmlFor={ids.recipient}>Who is it for?</Label>
<Input
id={ids.recipient}
name="recipient"
autoComplete="off"
aria-invalid={errors.recipient ? true : undefined}
aria-describedby={errors.recipient ? `${ids.recipient}-error` : undefined}
className="h-9"
/>
{errors.recipient ? <FieldError id={`${ids.recipient}-error`}>{errors.recipient}</FieldError> : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor={ids.message}>Gift message</Label>
<Textarea
id={ids.message}
name="message"
rows={3}
value={message}
onChange={(event) => setMessage(event.target.value)}
aria-invalid={errors.message ? true : undefined}
aria-describedby={errors.message ? `${ids.count} ${ids.message}-error` : ids.count}
className="min-h-20"
/>
<p id={ids.count} className={cn("text-xs tabular-nums", left < 0 ? "text-danger" : "text-muted-foreground")}>
{left < 0 ? `${-left} characters over` : `${left} characters left`}
</p>
{errors.message ? <FieldError id={`${ids.message}-error`}>{errors.message}</FieldError> : null}
</div>
<div className="flex items-center gap-2">
<Checkbox id={ids.hide} checked={hide} onCheckedChange={setHide} />
<Label htmlFor={ids.hide} className="font-normal">
Leave the prices off the packing slip
</Label>
</div>
</>
) : null}
<dl className="flex flex-col gap-2 border-t border-rule pt-4 text-sm *:flex *:justify-between *:gap-4 [&_dd]:tabular-nums">
<div>
<dt className="text-muted-foreground">Goods, {itemCount(totals.units)}</dt>
<dd>{money(totals.subtotalCents)}</dd>
</div>
{gift ? (
<div>
<dt className="text-muted-foreground">Gift wrap, {wraps} × {money(wrapCents)}</dt>
<dd>{money(wraps * wrapCents)}</dd>
</div>
) : null}
<div>
<dt className="text-muted-foreground">Standard delivery</dt>
<dd>{totals.deliveryCents === 0 ? "Free" : money(totals.deliveryCents)}</dd>
</div>
<div>
<dt className="text-muted-foreground">Estimated tax ({TAX_PERCENT}%)</dt>
<dd>{money(totals.taxCents)}</dd>
</div>
<div className="border-t border-rule pt-3 font-semibold">
<dt>Total</dt>
<dd className="text-base">{money(totals.totalCents + wraps * wrapCents)}</dd>
</div>
</dl>
<Button type="submit" size="lg" className="h-10">
Save gift options
</Button>
<p role="status" aria-live="polite" className="min-h-5 text-sm text-foreground">
{saved ? (
<span className="inline-flex items-start gap-2">
<CircleCheckIcon aria-hidden="true" className="mt-0.5 size-4 shrink-0 text-success" />
{saved}
</span>
) : null}
</p>
</CardContent>
</Card>
</form>
)
}/**
* What `cart-04` reads: the shopper's cart (`shopperCart`, from
* cart-ecommerce), and the band's own gift terms — what wrapping costs and how
* long a gift message may run. Wrapping is a service, charged as it is: it is
* not goods, so it neither counts towards free delivery nor carries tax.
*
* Selectors only, reading the store inside functions and run on the server.
* The island takes these rows as props; it never imports this module.
*/
import { shopperCart, type CartLine } from "@/lib/dashboards/ecommerce/cart"
export type { CartLine }
/** What wrapping one item costs, in cents. */
export const GIFT_WRAP_CENTS = 400
/** The longest gift message the packing slip prints, in characters. */
export const MESSAGE_LIMIT = 200
/** The lines in the shopper's cart, in the order they were added. */
export function cartLines(): CartLine[] {
return shopperCart()
}npx shadcn@latest add @vibra/cart-04