HextorUI
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-cart

Source

"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