block
Storefront — Checkout
Contact, shipping address, delivery method and payment, beside a live order summary.
npx shadcn@latest add @hextor/store-checkoutSource
"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