HextorUI
block

Storefront — Checkout

Contact, shipping address, delivery method and payment, beside a live order summary.

npx shadcn@latest add @hextor/store-checkout

Source

"use client";

import { useState, type FormEvent } from "react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { CheckoutSteps, type CheckoutStepInfo } from "@/registry/hextor/components/store/storefront/checkout-steps";
import { DeliveryOptions, type DeliveryOption } from "@/registry/hextor/components/store/storefront/delivery-options";
import { FieldGroup } from "@/registry/hextor/components/store/storefront/field-group";
import { OrderSummary } from "@/registry/hextor/components/store/storefront/order-summary";
import { Price } from "@/registry/hextor/components/store/storefront/price";
import {
  StoreHeader,
  type StoreHeaderProps,
} from "@/registry/hextor/components/store/storefront/store-header";
import { cn } from "@/registry/hextor/lib/utils";
import { lineTotal, type Address, type Cart, type CartTotals } from "@/registry/hextor/lib/store";
import {
  resolveLabels,
  type CheckoutLabels,
  type CheckoutLabelsOverrides,
} from "@/registry/hextor/components/store/storefront/checkout-labels";
import {
  hasFieldErrors,
  validateCardCvc,
  validateCardExpiry,
  validateCardName,
  validateCardNumber,
  validateCity,
  validateCountry,
  validateEmail,
  validateLine1,
  validateName,
  validatePhone,
  validatePostalCode,
  type FieldErrors,
} from "@/registry/hextor/components/store/storefront/checkout-validation";

export type { DeliveryOption };
/** @deprecated use `DeliveryOption` — kept as an alias so an existing import doesn't break on upgrade. */
export type CheckoutDeliveryOption = DeliveryOption;

export type AccountMode = "guest" | "sign-in";

export interface CheckoutCardValues {
  name: string;
  number: string;
  expiry: string;
  cvc: string;
}

export interface CheckoutFormValues {
  accountMode: AccountMode;
  email: string;
  marketingOptIn: boolean;
  shippingAddress: Address;
  deliveryOptionId: string;
  card: CheckoutCardValues;
}

export interface CheckoutPageProps {
  cart: Cart;
  totals: CartTotals;
  deliveryOptions: DeliveryOption[];
  initialValues?: Partial<CheckoutFormValues>;
  /** Options for the country select. A template default — swap for a real list. */
  countries?: string[];
  /**
   * Reference clock for every date computed on this page: the delivery
   * arrival date, the order-by cutoff countdown, and whether an entered
   * card's expiry has already passed. A required prop, never `new Date()`
   * inside render — this page can be server-rendered, and a live clock read
   * during render would make the server's HTML and the client's first paint
   * disagree by however many milliseconds passed in between, which React
   * surfaces as a hydration mismatch.
   */
  now: Date;
  /**
   * Whether to show the guest-vs-sign-in step at all. A store with no
   * account system has nothing to offer as the alternative to guest
   * checkout, so forcing shoppers through a step that only ever has one
   * real answer is friction with no payoff — set this to `false` and the
   * flow starts directly on Shipping.
   */
  showAccountStep?: boolean;
  onSubmit?: (values: CheckoutFormValues) => void;
  /** Called when "Sign in instead" is chosen. A template has no auth backend to hand off to, so this is the seam a real app wires up. */
  onSignIn?: () => void;
  header: StoreHeaderProps;
  labels?: CheckoutLabelsOverrides;
  className?: string;
}

const DEFAULT_COUNTRIES = [
  "Thailand",
  "Singapore",
  "United States",
  "United Kingdom",
  "Australia",
  "Japan",
];

const EMPTY_ADDRESS: Address = {
  name: "",
  line1: "",
  line2: "",
  city: "",
  province: "",
  postalCode: "",
  country: "",
  phone: "",
};

const EMPTY_CARD: CheckoutCardValues = { name: "", number: "", expiry: "", cvc: "" };

/**
 * A four-step checkout — Account, Shipping, Delivery, Payment — walked
 * through with a persistent progress indicator and an order summary that
 * never leaves the screen. Payment fields are decorative — wiring a real
 * processor is the one part of checkout that has to be app-specific, so
 * this template stops at the fields a payment SDK would mount into.
 *
 * It is a real multi-step flow rather than one long scroll on purpose: a
 * progress indicator only means something if there is somewhere left to go
 * (item 9 of the checkout audit this page was rewritten against), and
 * gating each step's required fields on "Continue" is what makes the
 * adaptive validation below ever actually run before the final submit.
 */
export default function CheckoutPage({
  cart,
  totals,
  deliveryOptions,
  initialValues,
  countries = DEFAULT_COUNTRIES,
  now,
  showAccountStep = true,
  onSubmit,
  onSignIn,
  header,
  labels,
  className,
}: CheckoutPageProps) {
  const copy = resolveLabels(labels);

  const [accountMode, setAccountMode] = useState<AccountMode | undefined>(
    showAccountStep ? initialValues?.accountMode : "guest"
  );
  const [email, setEmail] = useState(initialValues?.email ?? "");
  const [marketingOptIn, setMarketingOptIn] = useState(initialValues?.marketingOptIn ?? false);
  const [address, setAddress] = useState<Address>({
    ...EMPTY_ADDRESS,
    ...initialValues?.shippingAddress,
    country: initialValues?.shippingAddress?.country ?? countries[0] ?? "",
  });
  // The apartment/suite field starts collapsed behind a link (item 4) unless
  // there's already a value for it — Baymard measures a 5-30% checkout
  // slowdown from showing a field that most orders never need.
  const [showLine2, setShowLine2] = useState(Boolean(initialValues?.shippingAddress?.line2));
  const [deliveryOptionId, setDeliveryOptionId] = useState(
    initialValues?.deliveryOptionId ?? deliveryOptions[0]?.id ?? ""
  );
  const [card, setCard] = useState<CheckoutCardValues>({ ...EMPTY_CARD, ...initialValues?.card });
  const [errors, setErrors] = useState<FieldErrors>({});

  const steps: CheckoutStepInfo[] = [
    ...(showAccountStep ? [{ id: "account", label: copy.progress.account }] : []),
    { id: "shipping", label: copy.progress.shipping },
    { id: "delivery", label: copy.progress.delivery },
    { id: "payment", label: copy.progress.payment },
  ];
  const stepIds = steps.map((s) => s.id);
  const [currentStepIndex, setCurrentStepIndex] = useState(0);
  const currentStepId = stepIds[currentStepIndex] ?? stepIds[0];
  const isFirstStep = currentStepIndex === 0;
  const isLastStep = currentStepIndex === stepIds.length - 1;
  const completedStepIds = stepIds.slice(0, currentStepIndex);

  const goNext = () => setCurrentStepIndex((i) => Math.min(i + 1, stepIds.length - 1));
  const goBack = () => setCurrentStepIndex((i) => Math.max(i - 1, 0));

  const updateAddress = <K extends keyof Address>(field: K, value: Address[K]) => {
    setAddress((prev) => ({ ...prev, [field]: value }));
  };

  // Re-validate on change ONLY once a field has already errored — otherwise
  // every keystroke would flag a field before the shopper has finished
  // typing into it, which is its own Baymard complaint about over-eager
  // validation. `handleBlur` below is what puts the first error on screen.
  const reviseIfErrored = <T,>(
    key: keyof FieldErrors,
    validate: (value: T) => string | undefined,
    value: T
  ) => {
    setErrors((prev) => (prev[key] ? { ...prev, [key]: validate(value) } : prev));
  };

  const handleAddressChange = <K extends keyof Address>(field: K, value: Address[K]) => {
    updateAddress(field, value);
    if (field === "name") reviseIfErrored("name", (v: string) => validateName(v, copy.errors), value as string);
    if (field === "line1") reviseIfErrored("line1", (v: string) => validateLine1(v, copy.errors), value as string);
    if (field === "city") reviseIfErrored("city", (v: string) => validateCity(v, copy.errors), value as string);
    if (field === "postalCode") {
      reviseIfErrored(
        "postalCode",
        (v: string) => validatePostalCode(v, address.country, copy.errors),
        value as string
      );
    }
    if (field === "country") {
      reviseIfErrored("country", (v: string) => validateCountry(v, copy.errors), value as string);
      // Postal-code length rules are per-country — if the code was already
      // flagged, switching country can turn that error on or off.
      reviseIfErrored(
        "postalCode",
        (v: string) => validatePostalCode(address.postalCode, v as string, copy.errors),
        value as string
      );
    }
    if (field === "phone") reviseIfErrored("phone", (v: string) => validatePhone(v, copy.errors), value as string);
  };

  const handleCardChange = <K extends keyof CheckoutCardValues>(field: K, value: string) => {
    setCard((prev) => ({ ...prev, [field]: value }));
    if (field === "name") reviseIfErrored("cardName", (v: string) => validateCardName(v, copy.errors), value);
    if (field === "number") reviseIfErrored("cardNumber", (v: string) => validateCardNumber(v, copy.errors), value);
    if (field === "expiry") {
      reviseIfErrored("cardExpiry", (v: string) => validateCardExpiry(v, now, copy.errors), value);
    }
    if (field === "cvc") reviseIfErrored("cardCvc", (v: string) => validateCardCvc(v, copy.errors), value);
  };

  const handleEmailChange = (value: string) => {
    setEmail(value);
    reviseIfErrored("email", (v: string) => validateEmail(v, copy.errors), value);
  };

  const validateShippingStep = (): FieldErrors => ({
    email: validateEmail(email, copy.errors),
    name: validateName(address.name, copy.errors),
    line1: validateLine1(address.line1, copy.errors),
    city: validateCity(address.city, copy.errors),
    postalCode: validatePostalCode(address.postalCode, address.country, copy.errors),
    country: validateCountry(address.country, copy.errors),
    phone: validatePhone(address.phone ?? "", copy.errors),
  });

  const validatePaymentStep = (): FieldErrors => ({
    cardName: validateCardName(card.name, copy.errors),
    cardNumber: validateCardNumber(card.number, copy.errors),
    cardExpiry: validateCardExpiry(card.expiry, now, copy.errors),
    cardCvc: validateCardCvc(card.cvc, copy.errors),
  });

  const handleContinue = () => {
    if (currentStepId === "shipping") {
      const stepErrors = validateShippingStep();
      setErrors((prev) => ({ ...prev, ...stepErrors }));
      if (hasFieldErrors(stepErrors)) return;
    }
    goNext();
  };

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    // The whole wizard lives in one <form>, so pressing Enter on an earlier
    // step would otherwise submit it immediately — treat that as "advance
    // this step" instead, and only actually submit from the last one.
    if (!isLastStep) {
      handleContinue();
      return;
    }
    const stepErrors = validatePaymentStep();
    setErrors((prev) => ({ ...prev, ...stepErrors }));
    if (hasFieldErrors(stepErrors)) return;
    onSubmit?.({
      accountMode: accountMode ?? "guest",
      email,
      marketingOptIn,
      shippingAddress: address,
      deliveryOptionId,
      card,
    });
  };

  return (
    <div className={cn("flex min-h-screen flex-col", className)}>
      <StoreHeader {...header} showSearch={false} nav={[]} />

      <main className="mx-auto w-full max-w-5xl flex-1 px-4 py-8 sm:px-6">
        <CheckoutSteps steps={steps} currentStepId={currentStepId} completedStepIds={completedStepIds} className="mb-8" />

        <form onSubmit={handleSubmit} className="grid grid-cols-1 gap-10 lg:grid-cols-[1fr_360px]">
          <div className="flex flex-col gap-8">
            {currentStepId === "account" && (
              <AccountStep copy={copy} onContinueAsGuest={() => { setAccountMode("guest"); goNext(); }} onSignIn={onSignIn} />
            )}

            {currentStepId === "shipping" && (
              <ShippingStep
                copy={copy}
                countries={countries}
                email={email}
                onEmailChange={handleEmailChange}
                onEmailBlur={() => setErrors((prev) => ({ ...prev, email: validateEmail(email, copy.errors) }))}
                marketingOptIn={marketingOptIn}
                onMarketingOptInChange={setMarketingOptIn}
                address={address}
                onAddressChange={handleAddressChange}
                onAddressBlur={(field) => {
                  const message =
                    field === "name"
                      ? validateName(address.name, copy.errors)
                      : field === "line1"
                        ? validateLine1(address.line1, copy.errors)
                        : field === "city"
                          ? validateCity(address.city, copy.errors)
                          : field === "postalCode"
                            ? validatePostalCode(address.postalCode, address.country, copy.errors)
                            : field === "country"
                              ? validateCountry(address.country, copy.errors)
                              : field === "phone"
                                ? validatePhone(address.phone ?? "", copy.errors)
                                : undefined;
                  setErrors((prev) => ({ ...prev, [field]: message }));
                }}
                showLine2={showLine2}
                onShowLine2={() => setShowLine2(true)}
                errors={errors}
              />
            )}

            {currentStepId === "delivery" && (
              <section className="flex flex-col gap-3">
                <h2 className="font-heading text-lg font-medium">{copy.delivery.heading}</h2>
                <DeliveryOptions
                  options={deliveryOptions}
                  value={deliveryOptionId}
                  onChange={setDeliveryOptionId}
                  now={now}
                  arrivesLabel={copy.delivery.arrivesLabel}
                  pickupReadyLabel={copy.delivery.pickupReadyLabel}
                  pickupAtLabel={copy.delivery.pickupAtLabel}
                  cutoffLabel={copy.delivery.cutoffLabel}
                  cutoffPassedLabel={copy.delivery.cutoffPassedLabel}
                  freeLabel={copy.delivery.freeLabel}
                />
              </section>
            )}

            {currentStepId === "payment" && (
              <PaymentStep
                copy={copy}
                card={card}
                onCardChange={handleCardChange}
                onCardBlur={(field) => {
                  const message =
                    field === "name"
                      ? validateCardName(card.name, copy.errors)
                      : field === "number"
                        ? validateCardNumber(card.number, copy.errors)
                        : field === "expiry"
                          ? validateCardExpiry(card.expiry, now, copy.errors)
                          : validateCardCvc(card.cvc, copy.errors);
                  const key = field === "name" ? "cardName" : field === "number" ? "cardNumber" : field === "expiry" ? "cardExpiry" : "cardCvc";
                  setErrors((prev) => ({ ...prev, [key]: message }));
                }}
                errors={errors}
              />
            )}

            <div className="flex items-center justify-between pt-2">
              {!isFirstStep ? (
                <Button type="button" variant="ghost" onClick={goBack}>
                  {copy.progress.back}
                </Button>
              ) : (
                <span />
              )}
              {currentStepId !== "account" &&
                (isLastStep ? (
                  <Button type="submit" size="lg">
                    {copy.payNow}
                  </Button>
                ) : (
                  <Button type="button" size="lg" onClick={handleContinue}>
                    {copy.progress.continueButton}
                  </Button>
                ))}
            </div>
          </div>

          <div className="flex flex-col gap-4">
            <div className="flex flex-col gap-3 rounded-2xl p-4 ring-1 ring-foreground/10">
              {cart.lines.map((line) => (
                <div key={line.id} className="flex items-center gap-3">
                  <div className="relative size-12 shrink-0 overflow-hidden rounded-md bg-muted ring-1 ring-foreground/10">
                    {line.image && (
                      <Image src={line.image.url} alt={line.image.alt} fill sizes="48px" className="object-cover" />
                    )}
                    <span className="absolute -top-1.5 -right-1.5 flex size-4 items-center justify-center rounded-full bg-foreground text-[10px] text-background">
                      {line.quantity}
                    </span>
                  </div>
                  <div className="flex flex-1 flex-col">
                    <span className="text-sm">{line.title}</span>
                    {line.variantTitle && (
                      <span className="text-xs text-muted-foreground">{line.variantTitle}</span>
                    )}
                  </div>
                  <Price price={lineTotal(line)} size="sm" />
                </div>
              ))}
            </div>

            <span className="font-heading text-sm font-medium">{copy.summaryHeading}</span>
            {/*
             * Totals render on every step, not only the last one — shipping,
             * tax and discount are all real numbers from the first screen
             * onward. Baymard's complaint is a total that changes ONE step
             * before payment; showing the same `OrderSummary` throughout
             * makes that impossible by construction.
             */}
            <OrderSummary totals={totals} />
          </div>
        </form>
      </main>
    </div>
  );
}

/**
 * Item 1 of the checkout audit: guest checkout is the visually dominant
 * primary action (a full-width filled button, first, with its own
 * supporting line), and signing in is a secondary, still-clearly-a-button
 * option below a divider — never a small text link easy to miss. Baymard
 * measures 18% of users abandoning checkout specifically to avoid being
 * forced into an account; this step exists so nobody here has to find out
 * whether that's true of this store.
 */
function AccountStep({
  copy,
  onContinueAsGuest,
  onSignIn,
}: {
  copy: CheckoutLabels;
  onContinueAsGuest: () => void;
  onSignIn?: () => void;
}) {
  const [signInChosen, setSignInChosen] = useState(false);
  return (
    <section className="flex flex-col gap-4">
      <div>
        <h2 className="font-heading text-lg font-medium">{copy.account.heading}</h2>
        <p className="text-sm text-muted-foreground">{copy.account.description}</p>
      </div>
      <div className="flex flex-col gap-2">
        <Button type="button" size="lg" className="w-full" onClick={onContinueAsGuest}>
          {copy.account.guestCta}
        </Button>
        <p className="text-xs text-muted-foreground">{copy.account.guestDescription}</p>
      </div>
      <div className="flex items-center gap-3" aria-hidden="true">
        <Separator className="flex-1" />
        <span className="text-xs text-muted-foreground">or</span>
        <Separator className="flex-1" />
      </div>
      <div className="flex flex-col gap-2">
        <Button
          type="button"
          variant="outline"
          className="w-full"
          onClick={() => {
            setSignInChosen(true);
            onSignIn?.();
          }}
        >
          {copy.account.signInCta}
        </Button>
        {signInChosen && <p className="text-xs text-muted-foreground">{copy.account.signInNotice}</p>}
      </div>
    </section>
  );
}

function ShippingStep({
  copy,
  countries,
  email,
  onEmailChange,
  onEmailBlur,
  marketingOptIn,
  onMarketingOptInChange,
  address,
  onAddressChange,
  onAddressBlur,
  showLine2,
  onShowLine2,
  errors,
}: {
  copy: CheckoutLabels;
  countries: string[];
  email: string;
  onEmailChange: (value: string) => void;
  onEmailBlur: () => void;
  marketingOptIn: boolean;
  onMarketingOptInChange: (value: boolean) => void;
  address: Address;
  onAddressChange: <K extends keyof Address>(field: K, value: Address[K]) => void;
  onAddressBlur: (field: keyof Address) => void;
  showLine2: boolean;
  onShowLine2: () => void;
  errors: FieldErrors;
}) {
  return (
    <>
      <section className="flex flex-col gap-3">
        <h2 className="font-heading text-lg font-medium">{copy.contact.heading}</h2>
        <FieldGroup label={copy.contact.emailLabel} htmlFor="checkout-email" required error={errors.email}>
          <Input
            id="checkout-email"
            type="email"
            value={email}
            onChange={(e) => onEmailChange(e.target.value)}
            onBlur={onEmailBlur}
            aria-invalid={Boolean(errors.email)}
            aria-describedby={errors.email ? "checkout-email-error" : undefined}
          />
        </FieldGroup>
        <div className="flex items-center gap-2">
          <Checkbox
            id="checkout-marketing"
            checked={marketingOptIn}
            onCheckedChange={(checked) => onMarketingOptInChange(checked === true)}
          />
          <Label htmlFor="checkout-marketing" className="font-normal">
            {copy.contact.marketingOptIn}
          </Label>
        </div>
      </section>

      <Separator />

      <section className="flex flex-col gap-3">
        <h2 className="font-heading text-lg font-medium">{copy.shipping.heading}</h2>
        <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
          <FieldGroup
            label={copy.shipping.nameLabel}
            htmlFor="checkout-name"
            required
            error={errors.name}
            className="sm:col-span-2"
          >
            <Input
              id="checkout-name"
              value={address.name}
              onChange={(e) => onAddressChange("name", e.target.value)}
              onBlur={() => onAddressBlur("name")}
              aria-invalid={Boolean(errors.name)}
              aria-describedby={errors.name ? "checkout-name-error" : undefined}
            />
          </FieldGroup>
          <FieldGroup
            label={copy.shipping.addressLine1Label}
            htmlFor="checkout-line1"
            required
            error={errors.line1}
            className="sm:col-span-2"
          >
            <Input
              id="checkout-line1"
              value={address.line1}
              onChange={(e) => onAddressChange("line1", e.target.value)}
              onBlur={() => onAddressBlur("line1")}
              aria-invalid={Boolean(errors.line1)}
              aria-describedby={errors.line1 ? "checkout-line1-error" : undefined}
            />
          </FieldGroup>

          {showLine2 ? (
            <FieldGroup
              label={copy.shipping.addressLine2Label}
              htmlFor="checkout-line2"
              className="sm:col-span-2"
            >
              <Input
                id="checkout-line2"
                value={address.line2 ?? ""}
                onChange={(e) => onAddressChange("line2", e.target.value)}
                autoFocus
              />
            </FieldGroup>
          ) : (
            <button
              type="button"
              onClick={onShowLine2}
              className="text-left text-sm text-muted-foreground underline underline-offset-4 hover:text-foreground sm:col-span-2"
            >
              {copy.shipping.addAddressLine2}
            </button>
          )}

          <FieldGroup label={copy.shipping.cityLabel} htmlFor="checkout-city" required error={errors.city}>
            <Input
              id="checkout-city"
              value={address.city}
              onChange={(e) => onAddressChange("city", e.target.value)}
              onBlur={() => onAddressBlur("city")}
              aria-invalid={Boolean(errors.city)}
              aria-describedby={errors.city ? "checkout-city-error" : undefined}
            />
          </FieldGroup>
          <FieldGroup label={copy.shipping.provinceLabel} htmlFor="checkout-province">
            <Input
              id="checkout-province"
              value={address.province ?? ""}
              onChange={(e) => onAddressChange("province", e.target.value)}
            />
          </FieldGroup>
          <FieldGroup
            label={copy.shipping.postalCodeLabel}
            htmlFor="checkout-postal"
            required
            error={errors.postalCode}
          >
            <Input
              id="checkout-postal"
              value={address.postalCode}
              onChange={(e) => onAddressChange("postalCode", e.target.value)}
              onBlur={() => onAddressBlur("postalCode")}
              aria-invalid={Boolean(errors.postalCode)}
              aria-describedby={errors.postalCode ? "checkout-postal-error" : undefined}
            />
          </FieldGroup>
          <FieldGroup
            label={copy.shipping.countryLabel}
            htmlFor="checkout-country"
            required
            error={errors.country}
          >
            <Select
              value={address.country}
              onValueChange={(next) => {
                onAddressChange("country", next as string);
                onAddressBlur("country");
              }}
            >
              <SelectTrigger
                id="checkout-country"
                className="w-full"
                aria-invalid={Boolean(errors.country)}
                aria-describedby={errors.country ? "checkout-country-error" : undefined}
              >
                <SelectValue placeholder={copy.shipping.countryLabel} />
              </SelectTrigger>
              <SelectContent>
                {countries.map((country) => (
                  <SelectItem key={country} value={country}>
                    {country}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </FieldGroup>
          <FieldGroup
            label={copy.shipping.phoneLabel}
            htmlFor="checkout-phone"
            hint={copy.shipping.phoneHint}
            error={errors.phone}
            className="sm:col-span-2"
          >
            <Input
              id="checkout-phone"
              type="tel"
              value={address.phone ?? ""}
              onChange={(e) => onAddressChange("phone", e.target.value)}
              onBlur={() => onAddressBlur("phone")}
              aria-invalid={Boolean(errors.phone)}
              aria-describedby={errors.phone ? "checkout-phone-error" : "checkout-phone-hint"}
            />
          </FieldGroup>
        </div>
      </section>
    </>
  );
}

function PaymentStep({
  copy,
  card,
  onCardChange,
  onCardBlur,
  errors,
}: {
  copy: CheckoutLabels;
  card: CheckoutCardValues;
  onCardChange: <K extends keyof CheckoutCardValues>(field: K, value: string) => void;
  onCardBlur: (field: keyof CheckoutCardValues) => void;
  errors: FieldErrors;
}) {
  return (
    <section className="flex flex-col gap-3">
      <h2 className="font-heading text-lg font-medium">{copy.payment.heading}</h2>
      <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
        <FieldGroup
          label={copy.payment.cardNameLabel}
          htmlFor="checkout-card-name"
          required
          error={errors.cardName}
          className="sm:col-span-2"
        >
          <Input
            id="checkout-card-name"
            autoComplete="cc-name"
            value={card.name}
            onChange={(e) => onCardChange("name", e.target.value)}
            onBlur={() => onCardBlur("name")}
            aria-invalid={Boolean(errors.cardName)}
            aria-describedby={errors.cardName ? "checkout-card-name-error" : undefined}
          />
        </FieldGroup>
        <FieldGroup
          label={copy.payment.cardNumberLabel}
          htmlFor="checkout-card-number"
          required
          error={errors.cardNumber}
          className="sm:col-span-2"
        >
          <Input
            id="checkout-card-number"
            inputMode="numeric"
            autoComplete="cc-number"
            value={card.number}
            onChange={(e) => onCardChange("number", e.target.value)}
            onBlur={() => onCardBlur("number")}
            aria-invalid={Boolean(errors.cardNumber)}
            aria-describedby={errors.cardNumber ? "checkout-card-number-error" : undefined}
          />
        </FieldGroup>
        <FieldGroup
          label={copy.payment.cardExpiryLabel}
          htmlFor="checkout-card-expiry"
          required
          error={errors.cardExpiry}
        >
          <Input
            id="checkout-card-expiry"
            placeholder="MM / YY"
            autoComplete="cc-exp"
            value={card.expiry}
            onChange={(e) => onCardChange("expiry", e.target.value)}
            onBlur={() => onCardBlur("expiry")}
            aria-invalid={Boolean(errors.cardExpiry)}
            aria-describedby={errors.cardExpiry ? "checkout-card-expiry-error" : undefined}
          />
        </FieldGroup>
        <FieldGroup label={copy.payment.cardCvcLabel} htmlFor="checkout-card-cvc" required error={errors.cardCvc}>
          <Input
            id="checkout-card-cvc"
            inputMode="numeric"
            autoComplete="cc-csc"
            value={card.cvc}
            onChange={(e) => onCardChange("cvc", e.target.value)}
            onBlur={() => onCardBlur("cvc")}
            aria-invalid={Boolean(errors.cardCvc)}
            aria-describedby={errors.cardCvc ? "checkout-card-cvc-error" : undefined}
          />
        </FieldGroup>
      </div>
    </section>
  );
}

Docs

No footer, and the header loses its nav and search. Checkout strips chrome on purpose — every link out of this page is a way to not finish paying.

Delivery rates in the route file are a template default. Real rates depend on destination, weight and carrier, which is a backend's job; a template that invented them would quote the wrong price everywhere it shipped.

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