block
Storefront — Cart
Line items with quantity steppers, a discount code field that explains rejections, and a totals summary.
npx shadcn@latest add @hextor/store-cartSource
"use client";
import { useState } from "react";
import Link from "next/link";
import { ShoppingBag } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { CartLineItem } from "@/registry/hextor/components/store/storefront/cart-line-item";
import { EmptyState } from "@/registry/hextor/components/store/storefront/empty-state";
import { OrderSummary } from "@/registry/hextor/components/store/storefront/order-summary";
import {
StoreHeader,
type StoreHeaderProps,
} from "@/registry/hextor/components/store/storefront/store-header";
import {
StoreFooter,
type StoreFooterProps,
} from "@/registry/hextor/components/store/storefront/store-footer";
import { cn } from "@/registry/hextor/lib/utils";
import type { Cart, CartTotals } from "@/registry/hextor/lib/store";
import { resolveLabels, type CartLabels } from "@/registry/hextor/components/store/storefront/cart-labels";
export interface CartPageProps {
cart: Cart;
totals: CartTotals;
onQuantityChange: (lineId: string, quantity: number) => void;
onRemoveLine: (lineId: string) => void;
onApplyDiscount?: (code: string) => void;
discountError?: string;
header: StoreHeaderProps;
footer: StoreFooterProps;
shopHref?: string;
checkoutHref?: string;
labels?: Partial<CartLabels>;
className?: string;
}
/**
* Cart totals (`totals`) are computed by the caller via `cartTotals()`
* rather than here — this page only renders numbers, it does not decide how
* a discount code or shipping rate applies. That keeps the one place a
* refund dispute gets litigated (the arithmetic) out of a template file a
* consumer is expected to reskin.
*
* Two checkout-audit fixes live entirely in what this page already does,
* not in new code:
* - `QuantityStepper` (inside `CartLineItem`) is a +/- control, not a bare
* number input — Baymard finds 97% of sites get this wrong. Left as-is;
* noted here so the next edit doesn't "simplify" it back into an input.
* - `OrderSummary` below renders shipping, tax and discount as soon as the
* cart has a total to show, on THIS page, before the checkout button —
* never introduced for the first time one step later at checkout.
* Baymard's complaint is a total that changes after the shopper already
* committed to checking out; showing the same numbers here removes the
* surprise before it can happen.
*/
export default function CartPage({
cart,
totals,
onQuantityChange,
onRemoveLine,
onApplyDiscount,
discountError,
header,
footer,
shopHref = "/",
checkoutHref = "/checkout",
labels,
className,
}: CartPageProps) {
const copy = resolveLabels(labels);
const [discountCode, setDiscountCode] = useState(cart.discountCode ?? "");
return (
<div className={cn("flex min-h-screen flex-col", className)}>
<StoreHeader {...header} />
<main className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-6 px-4 py-8 sm:px-6">
<h1 className="font-heading text-2xl font-semibold">{copy.heading}</h1>
{cart.lines.length === 0 ? (
<EmptyState
icon={ShoppingBag}
title={copy.emptyTitle}
description={copy.emptyDescription}
action={
<Button render={<Link href={shopHref}>{copy.continueShopping}</Link>} />
}
/>
) : (
<div className="grid grid-cols-1 gap-8 md:grid-cols-[1fr_320px]">
<div className="divide-y divide-border">
{cart.lines.map((line) => (
<CartLineItem
key={line.id}
line={line}
onQuantityChange={onQuantityChange}
onRemove={onRemoveLine}
/>
))}
</div>
<div className="flex flex-col gap-4">
{onApplyDiscount && (
<div className="flex flex-col gap-1.5">
<form
className="flex gap-2"
onSubmit={(e) => {
e.preventDefault();
onApplyDiscount(discountCode);
}}
>
<Input
value={discountCode}
onChange={(e) => setDiscountCode(e.target.value)}
placeholder={copy.discountPlaceholder}
/>
<Button type="submit" variant="secondary">
{copy.applyDiscount}
</Button>
</form>
{discountError && (
// role="alert" so the specific rejection reason (see
// `discountIneligibility` — "below minimum", "expired",
// etc.) is announced, not just styled red.
<span role="alert" className="text-xs text-destructive">
{discountError}
</span>
)}
</div>
)}
<Separator />
<span className="font-heading text-sm font-medium">{copy.summaryHeading}</span>
<OrderSummary totals={totals}>
<Button size="lg" className="w-full" render={<Link href={checkoutHref}>{copy.checkout}</Link>} />
</OrderSummary>
</div>
</div>
)}
</main>
<StoreFooter {...footer} />
</div>
);
}
Docs
The view renders numbers; the route decides what they are. Totals come from cartTotals() in @hextor/store-model so a template file a consumer reskins can never drift from the arithmetic a refund dispute is litigated against.
A rejected code reports why. "That code didn't work" is the message that makes people abandon a cart; "spend ฿200 more" is a different conversation.
Ships with sample data from @hextor/store-fixtures so it renders a real shop the moment it installs — a template you have to wire up before you can look at it is a template nobody evaluates. The route file is the only place that import appears.
Dependencies
@hextor/store-ui@hextor/store-fixtures