"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { AlertCircle, ChevronDown, CreditCard, Lock, MapPin, Package, ShieldCheck, Store, Tag, Truck, Zap, Check, ArrowLeft } from "lucide-react";
import { cn } from "@/lib/utils";
import { ProductArt, type ArtKind } from "./product-art";
export type CheckoutLine = { id: string; name: string; variant?: string; price: number; qty: number; kind: ArtKind; hex: string; accent?: string; label?: string };
export type SeedAddress = { line: string; city: string; postcode: string; country: string };
export type DeliveryOption = { id: string; label: string; detail: string; price: number; icon?: "truck" | "zap" | "store" };
export type Address = { firstName: string; lastName: string; address: string; apt: string; city: string; postcode: string; country: string; phone: string };
export type OrderPayload = {
email: string;
newsletter: boolean;
shipping: Address;
billing: Address | "same";
delivery: DeliveryOption;
card: { last4: string; expiry: string; name: string };
items: CheckoutLine[];
totals: { subtotal: number; discount: number; shipping: number; total: number };
};
export interface CheckoutOnepageProps {
items?: CheckoutLine[];
addresses?: SeedAddress[];
deliveryOptions?: DeliveryOption[];
countries?: string[];
storeName?: string;
currency?: string;
locale?: string;
/** Resolve to an order number. Defaults to a simulated 1.4 s authorisation. */
onPlaceOrder?: (order: OrderPayload) => Promise<string> | string | void;
onExpressPay?: (method: string) => void;
/** Called from the success screen. Defaults to returning to the form. */
onContinueShopping?: () => void;
className?: string;
}
const LINES: CheckoutLine[] = [
{ id: "1", name: "Highland Single Malt 12", variant: "Sherry cask · 70 cl", price: 39.9, qty: 1, kind: "bottle", hex: "#b45309", accent: "#f5efe0", label: "Lumen" },
{ id: "2", name: "Reserva Tinto 2019", variant: "75 cl", price: 18.9, qty: 2, kind: "wine", hex: "#7f1d1d", accent: "#d4af37", label: "Orbit" },
{ id: "3", name: "Soy Wax Candle", variant: "Oak & amber", price: 16, qty: 1, kind: "candle", hex: "#e7d5c0", accent: "#1c1917", label: "Lumen" },
];
const ADDRESSES: SeedAddress[] = [
{ line: "12 Harbour Street", city: "Dublin", postcode: "D02 X285", country: "Ireland" },
{ line: "12 Harbour View Road", city: "Cork", postcode: "T12 R2C4", country: "Ireland" },
{ line: "48 Market Square", city: "Kraków", postcode: "31-042", country: "Poland" },
{ line: "7 Linden Allee", city: "Berlin", postcode: "10117", country: "Germany" },
{ line: "221 Canal Walk", city: "Amsterdam", postcode: "1015 BK", country: "Netherlands" },
{ line: "3 Rue des Vignes", city: "Lyon", postcode: "69002", country: "France" },
{ line: "90 Orchard Lane", city: "Galway", postcode: "H91 E2K3", country: "Ireland" },
];
const OPTIONS: DeliveryOption[] = [
{ id: "std", label: "Standard", detail: "2–3 business days", price: 0, icon: "truck" },
{ id: "exp", label: "Express", detail: "Next day, before 13:00", price: 9.9, icon: "zap" },
{ id: "pick", label: "Store pickup", detail: "Ready in 2 hours · Lumen Dublin", price: 0, icon: "store" },
];
const COUNTRIES = ["Ireland", "Poland", "Germany", "Netherlands", "France"];
const ring = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background";
const emptyAddress: Address = { firstName: "", lastName: "", address: "", apt: "", city: "", postcode: "", country: "Ireland", phone: "" };
function luhn(num: string) {
let sum = 0;
let dbl = false;
for (let i = num.length - 1; i >= 0; i--) {
let d = Number(num[i]);
if (dbl) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
dbl = !dbl;
}
return num.length >= 13 && sum % 10 === 0;
}
const formatCard = (v: string) =>
v
.replace(/\D/g, "")
.slice(0, 19)
.replace(/(.{4})/g, "$1 ")
.trim();
const formatExpiry = (v: string, prev: string) => {
const d = v.replace(/\D/g, "").slice(0, 4);
if (d.length === 1 && Number(d) > 1) return `0${d}/`;
if (d.length >= 3) return `${d.slice(0, 2)}/${d.slice(2)}`;
if (d.length === 2 && prev.length < v.length) return `${d}/`;
return d;
};
export function CheckoutOnepage({
items = LINES,
addresses = ADDRESSES,
deliveryOptions = OPTIONS,
countries = COUNTRIES,
storeName = "Lumen Cellars",
currency = "EUR",
locale = "en-IE",
onPlaceOrder,
onExpressPay,
onContinueShopping,
className,
}: CheckoutOnepageProps) {
const reduce = useReducedMotion();
const fmt = React.useMemo(() => new Intl.NumberFormat(locale, { style: "currency", currency }), [locale, currency]);
const [stage, setStage] = React.useState<"form" | "processing" | "success">("form");
const [orderNo, setOrderNo] = React.useState("");
const [email, setEmail] = React.useState("");
const [newsletter, setNewsletter] = React.useState(true);
const [ship, setShip] = React.useState<Address>(emptyAddress);
const [bill, setBill] = React.useState<Address>(emptyAddress);
const [billSame, setBillSame] = React.useState(true);
const [delivery, setDelivery] = React.useState(deliveryOptions[0]?.id);
const [card, setCard] = React.useState({ number: "", expiry: "", cvc: "", name: "" });
const [touched, setTouched] = React.useState<Record<string, boolean>>({});
const [submitted, setSubmitted] = React.useState(false);
const [now, setNow] = React.useState<{ y: number; m: number } | null>(null);
const [promo, setPromo] = React.useState("");
const [discountRate, setDiscountRate] = React.useState(0);
const [promoMsg, setPromoMsg] = React.useState("");
const [summaryOpen, setSummaryOpen] = React.useState(false);
const formRef = React.useRef<HTMLFormElement>(null);
React.useEffect(() => {
const d = new Date();
setNow({ y: d.getFullYear() % 100, m: d.getMonth() + 1 });
}, []);
const opt = deliveryOptions.find((o) => o.id === delivery) ?? deliveryOptions[0];
const subtotal = items.reduce((s, i) => s + i.price * i.qty, 0);
const discount = Math.round(subtotal * discountRate * 100) / 100;
const shipping = opt?.price ?? 0;
const total = subtotal - discount + shipping;
const pickup = opt?.icon === "store";
const digits = card.number.replace(/\D/g, "");
const [em, ey] = card.expiry.split("/").map((x) => Number(x));
const errors: Record<string, string> = {};
if (!/^\S+@\S+\.\S+$/.test(email)) errors.email = "Enter a valid email address.";
if (!ship.firstName.trim()) errors["ship.firstName"] = "Required";
if (!ship.lastName.trim()) errors["ship.lastName"] = "Required";
if (!pickup) {
if (ship.address.trim().length < 4) errors["ship.address"] = "Enter a street address.";
if (!ship.city.trim()) errors["ship.city"] = "Required";
if (!/^[A-Za-z0-9 -]{3,10}$/.test(ship.postcode.trim())) errors["ship.postcode"] = "Invalid postcode";
}
if (ship.phone && ship.phone.replace(/\D/g, "").length < 7) errors["ship.phone"] = "Phone looks too short.";
if (!luhn(digits)) errors["card.number"] = digits.length < 13 ? "Enter your card number." : "This card number isn't valid.";
if (!(em >= 1 && em <= 12 && ey >= 0 && card.expiry.length === 5)) errors["card.expiry"] = "Use MM/YY";
else if (now && (ey < now.y || (ey === now.y && em < now.m))) errors["card.expiry"] = "Card has expired";
if (!/^\d{3,4}$/.test(card.cvc)) errors["card.cvc"] = "3–4 digits";
if (card.name.trim().length < 2) errors["card.name"] = "Name as shown on card";
if (!billSame) {
if (bill.address.trim().length < 4) errors["bill.address"] = "Enter a street address.";
if (!bill.city.trim()) errors["bill.city"] = "Required";
if (!/^[A-Za-z0-9 -]{3,10}$/.test(bill.postcode.trim())) errors["bill.postcode"] = "Invalid postcode";
}
const show = (k: string) => (submitted || touched[k]) && errors[k];
const errCount = Object.keys(errors).length;
const blur = (k: string) => () => setTouched((t) => ({ ...t, [k]: true }));
const complete = async () => {
const payload: OrderPayload = {
email,
newsletter,
shipping: ship,
billing: billSame ? "same" : bill,
delivery: opt,
card: { last4: digits.slice(-4), expiry: card.expiry, name: card.name },
items,
totals: { subtotal, discount, shipping, total },
};
setStage("processing");
const started = Date.now();
let no: string | void = undefined;
try {
no = await onPlaceOrder?.(payload);
} catch {
setStage("form");
return;
}
const wait = Math.max(0, 1400 - (Date.now() - started));
window.setTimeout(() => {
setOrderNo(no || `LC-${String(started).slice(-6)}`);
setStage("success");
}, wait);
};
const submit = (e: React.FormEvent) => {
e.preventDefault();
setSubmitted(true);
if (errCount) {
const first = Object.keys(errors)[0];
const el = formRef.current?.querySelector<HTMLElement>(`[data-field="${first}"]`);
el?.focus();
el?.scrollIntoView({ block: "center", behavior: reduce ? "auto" : "smooth" });
return;
}
void complete();
};
const express = (m: string) => {
onExpressPay?.(m);
setStage("processing");
window.setTimeout(() => {
setOrderNo(`LC-${String(Date.now()).slice(-6)}`);
setStage("success");
}, 1400);
};
const summary = (
<OrderSummary
items={items}
fmt={fmt}
subtotal={subtotal}
discount={discount}
shipping={shipping}
total={total}
promo={promo}
setPromo={setPromo}
promoMsg={promoMsg}
applyPromo={() => {
if (promo.trim().toUpperCase() === "SAVE10") {
setDiscountRate(0.1);
setPromoMsg("SAVE10 applied — 10% off");
} else {
setDiscountRate(0);
setPromoMsg("Code not recognised");
}
}}
/>
);
return (
<section className={cn("w-full bg-background text-foreground", className)}>
<AnimatePresence mode="wait">
{stage === "success" ? (
<Success key="ok" orderNo={orderNo} email={email || "your inbox"} fmt={fmt} total={total} opt={opt} ship={ship} onBack={() => (onContinueShopping ? onContinueShopping() : setStage("form"))} />
) : (
<motion.div key="form" exit={{ opacity: 0, y: -10 }} className="relative">
{/* Mobile summary toggle */}
<div className="border-b bg-muted/40 lg:hidden">
<button type="button" aria-expanded={summaryOpen} onClick={() => setSummaryOpen((o) => !o)} className={cn("mx-auto flex w-full max-w-6xl items-center justify-between px-4 py-4 text-sm", ring)}>
<span className="flex items-center gap-2 font-medium text-primary">
<Package className="size-4" aria-hidden /> {summaryOpen ? "Hide" : "Show"} order summary
<motion.span animate={{ rotate: summaryOpen ? 180 : 0 }}>
<ChevronDown className="size-4" aria-hidden />
</motion.span>
</span>
<span className="font-semibold tabular-nums">{fmt.format(total)}</span>
</button>
<AnimatePresence initial={false}>
{summaryOpen && (
<motion.div initial={{ height: 0 }} animate={{ height: "auto" }} exit={{ height: 0 }} className="overflow-hidden">
<div className="px-4 pb-5">{summary}</div>
</motion.div>
)}
</AnimatePresence>
</div>
<div className="lg:bg-[linear-gradient(90deg,transparent_calc(50%+156px),color-mix(in_oklab,var(--muted)_30%,transparent)_calc(50%+156px))]">
<div className="mx-auto grid max-w-6xl lg:grid-cols-[1fr_420px]">
<form ref={formRef} onSubmit={submit} noValidate className="min-w-0 px-4 py-8 sm:px-6 lg:border-r lg:py-10 lg:pr-12">
<div className="flex items-center justify-between">
<p className="text-lg font-semibold tracking-tight">{storeName}</p>
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Lock className="size-3.5" aria-hidden /> Secure checkout
</p>
</div>
{/* Express */}
<div className="mt-6">
<p className="text-center text-xs font-medium text-muted-foreground">Express checkout</p>
<div className="mt-3 grid grid-cols-3 gap-2">
{[
{ id: "orbit", label: "Orbit Pay", cls: "bg-zinc-900 text-white dark:bg-white dark:text-zinc-900" },
{ id: "lumen", label: "Lumen Wallet", cls: "bg-indigo-600 text-white" },
{ id: "later", label: "PayLater", cls: "bg-amber-300 text-amber-950" },
].map((x) => (
<motion.button
key={x.id}
type="button"
whileHover={reduce ? undefined : { y: -2 }}
whileTap={reduce ? undefined : { scale: 0.97 }}
onClick={() => express(x.label)}
disabled={stage !== "form"}
className={cn("flex h-11 items-center justify-center gap-1.5 rounded-lg text-sm font-bold tracking-tight shadow-sm", x.cls, ring)}
>
<span className="size-3 rounded-full border-2 border-current" aria-hidden />
<span className="truncate">{x.label}</span>
</motion.button>
))}
</div>
<div className="mt-6 flex items-center gap-3 text-xs text-muted-foreground">
<span className="h-px flex-1 bg-border" /> OR <span className="h-px flex-1 bg-border" />
</div>
</div>
<AnimatePresence>
{submitted && errCount > 0 && (
<motion.div role="alert" initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} className="overflow-hidden">
<p className="mt-6 flex items-center gap-2 rounded-xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
<AlertCircle className="size-4 shrink-0" aria-hidden /> Please fix {errCount} {errCount === 1 ? "field" : "fields"} highlighted below.
</p>
</motion.div>
)}
</AnimatePresence>
<Section n={1} title="Contact">
<Field id="email" label="Email" error={show("email")}>
<input id="email" data-field="email" type="email" autoComplete="email" value={email} onChange={(e) => setEmail(e.target.value)} onBlur={blur("email")} aria-invalid={!!show("email")} className={inputCls} placeholder="[email protected]" />
</Field>
<Check2 checked={newsletter} onChange={setNewsletter}>
Email me with news and offers
</Check2>
</Section>
<Section n={2} title="Delivery method">
<div role="radiogroup" aria-label="Delivery method" className="grid gap-2">
{deliveryOptions.map((o) => {
const Icon = o.icon === "zap" ? Zap : o.icon === "store" ? Store : Truck;
const on = delivery === o.id;
return (
<label key={o.id} className={cn("relative flex cursor-pointer items-center gap-3 rounded-xl border p-4 transition", on ? "border-primary bg-primary/5" : "hover:bg-muted/50")}>
<input type="radio" name="delivery" value={o.id} checked={on} onChange={() => setDelivery(o.id)} className="peer sr-only" />
<span className="grid size-5 shrink-0 place-items-center rounded-full border-2 transition peer-checked:border-primary peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background">
{on && <motion.span layoutId="co-dot" className="size-2.5 rounded-full bg-primary" />}
</span>
<Icon className="size-4 text-muted-foreground" aria-hidden />
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">{o.label}</span>
<span className="block text-xs text-muted-foreground">{o.detail}</span>
</span>
<span className="text-sm font-semibold tabular-nums">{o.price ? fmt.format(o.price) : "Free"}</span>
</label>
);
})}
</div>
</Section>
<Section n={3} title={pickup ? "Pickup contact" : "Shipping address"}>
<div className="grid gap-3 sm:grid-cols-2">
<Field id="ship-first" label="First name" error={show("ship.firstName")}>
<input id="ship-first" data-field="ship.firstName" autoComplete="given-name" value={ship.firstName} onChange={(e) => setShip({ ...ship, firstName: e.target.value })} onBlur={blur("ship.firstName")} aria-invalid={!!show("ship.firstName")} className={inputCls} />
</Field>
<Field id="ship-last" label="Last name" error={show("ship.lastName")}>
<input id="ship-last" data-field="ship.lastName" autoComplete="family-name" value={ship.lastName} onChange={(e) => setShip({ ...ship, lastName: e.target.value })} onBlur={blur("ship.lastName")} aria-invalid={!!show("ship.lastName")} className={inputCls} />
</Field>
</div>
<AnimatePresence initial={false}>
{!pickup && (
<motion.div initial={{ height: 0, opacity: 0, overflow: "hidden" }} animate={{ height: "auto", opacity: 1, transitionEnd: { overflow: "visible" } }} exit={{ height: 0, opacity: 0, overflow: "hidden" }} className="-m-1 p-1">
<AddressFields prefix="ship" value={ship} onChange={setShip} addresses={addresses} countries={countries} show={show} blur={blur} />
</motion.div>
)}
</AnimatePresence>
<Field id="ship-phone" label="Phone (optional)" error={show("ship.phone")} hint="Only used for delivery updates.">
<input id="ship-phone" data-field="ship.phone" type="tel" autoComplete="tel" value={ship.phone} onChange={(e) => setShip({ ...ship, phone: e.target.value })} onBlur={blur("ship.phone")} aria-invalid={!!show("ship.phone")} className={inputCls} />
</Field>
</Section>
<Section n={4} title="Payment">
<p className="-mt-1 mb-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<ShieldCheck className="size-3.5" aria-hidden /> All transactions are secure and encrypted.
</p>
<div className="rounded-2xl border bg-muted/30 p-4">
<Field id="cc-number" label="Card number" error={show("card.number")}>
<div className="relative">
<input
id="cc-number"
data-field="card.number"
inputMode="numeric"
autoComplete="cc-number"
placeholder="1234 1234 1234 1234"
value={card.number}
onChange={(e) => setCard({ ...card, number: formatCard(e.target.value) })}
onBlur={blur("card.number")}
aria-invalid={!!show("card.number")}
className={cn(inputCls, "pr-11 font-mono tracking-wider")}
/>
<span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2">
<AnimatePresence mode="wait" initial={false}>
{luhn(digits) ? (
<motion.span key="ok" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} className="grid size-6 place-items-center rounded-full bg-emerald-500 text-white">
<Check className="size-3.5" aria-hidden />
</motion.span>
) : (
<motion.span key="card" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<CreditCard className="size-5 text-muted-foreground" aria-hidden />
</motion.span>
)}
</AnimatePresence>
</span>
</div>
</Field>
<div className="mt-3 grid grid-cols-2 gap-3">
<Field id="cc-exp" label="Expiry (MM/YY)" error={show("card.expiry")}>
<input id="cc-exp" data-field="card.expiry" inputMode="numeric" autoComplete="cc-exp" placeholder="MM/YY" value={card.expiry} onChange={(e) => setCard({ ...card, expiry: formatExpiry(e.target.value, card.expiry) })} onBlur={blur("card.expiry")} aria-invalid={!!show("card.expiry")} className={cn(inputCls, "font-mono")} />
</Field>
<Field id="cc-cvc" label="Security code" error={show("card.cvc")}>
<input id="cc-cvc" data-field="card.cvc" inputMode="numeric" autoComplete="cc-csc" placeholder="CVC" value={card.cvc} onChange={(e) => setCard({ ...card, cvc: e.target.value.replace(/\D/g, "").slice(0, 4) })} onBlur={blur("card.cvc")} aria-invalid={!!show("card.cvc")} className={cn(inputCls, "font-mono")} />
</Field>
</div>
<div className="mt-3">
<Field id="cc-name" label="Name on card" error={show("card.name")}>
<input id="cc-name" data-field="card.name" autoComplete="cc-name" value={card.name} onChange={(e) => setCard({ ...card, name: e.target.value })} onBlur={blur("card.name")} aria-invalid={!!show("card.name")} className={inputCls} />
</Field>
</div>
</div>
<Check2 checked={billSame} onChange={setBillSame}>
Billing address same as {pickup ? "contact" : "shipping"}
</Check2>
<AnimatePresence initial={false}>
{!billSame && (
<motion.div initial={{ height: 0, opacity: 0, overflow: "hidden" }} animate={{ height: "auto", opacity: 1, transitionEnd: { overflow: "visible" } }} exit={{ height: 0, opacity: 0, overflow: "hidden" }} className="-m-1 p-1">
<p className="mb-1 mt-2 text-sm font-medium">Billing address</p>
<AddressFields prefix="bill" value={bill} onChange={setBill} addresses={addresses} countries={countries} show={show} blur={blur} />
</motion.div>
)}
</AnimatePresence>
</Section>
<motion.button
type="submit"
disabled={stage !== "form"}
whileTap={reduce ? undefined : { scale: 0.985 }}
className={cn("relative mt-8 flex h-14 w-full items-center justify-center gap-2 overflow-hidden rounded-xl bg-primary text-base font-semibold text-primary-foreground shadow-lg shadow-primary/20 transition hover:bg-primary/90", ring)}
>
{stage === "processing" ? (
<>
<span className="size-4 animate-spin rounded-full border-2 border-primary-foreground/40 border-t-primary-foreground" aria-hidden />
Authorising payment…
<motion.span className="absolute inset-x-0 bottom-0 h-1 origin-left bg-primary-foreground/40" initial={{ scaleX: 0 }} animate={{ scaleX: 1 }} transition={{ duration: 1.4, ease: "easeInOut" }} />
</>
) : (
<>
<Lock className="size-4" aria-hidden /> Pay {fmt.format(total)}
</>
)}
</motion.button>
<p className="mt-3 text-center text-xs text-muted-foreground">By placing your order you agree to our terms and confirm you are over 18.</p>
</form>
<aside className="hidden px-6 py-10 lg:block lg:pl-12" aria-label="Order summary">
<div className="sticky top-8">{summary}</div>
</aside>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</section>
);
}
const inputCls = cn("mt-1.5 h-11 w-full rounded-lg border bg-background px-3 text-sm shadow-sm transition aria-[invalid=true]:border-destructive aria-[invalid=true]:ring-destructive/30", ring);
function Section({ n, title, children }: { n: number; title: string; children: React.ReactNode }) {
return (
<fieldset className="mt-8">
<legend className="mb-3 flex items-center gap-2.5 text-base font-semibold">
<span className="grid size-6 place-items-center rounded-full bg-foreground text-xs text-background">{n}</span>
{title}
</legend>
<div className="space-y-3">{children}</div>
</fieldset>
);
}
function Field({ id, label, error, hint, children }: { id: string; label: string; error?: string | false; hint?: string; children: React.ReactNode }) {
return (
<div className="min-w-0">
<label htmlFor={id} className="text-sm font-medium">
{label}
</label>
{children}
<AnimatePresence initial={false}>
{error ? (
<motion.p key="e" id={`${id}-err`} role="alert" initial={{ opacity: 0, y: -3 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="mt-1 text-xs text-destructive">
{error}
</motion.p>
) : hint ? (
<p className="mt-1 text-xs text-muted-foreground">{hint}</p>
) : null}
</AnimatePresence>
</div>
);
}
function Check2({ checked, onChange, children }: { checked: boolean; onChange: (v: boolean) => void; children: React.ReactNode }) {
return (
<label className="flex cursor-pointer items-center gap-2.5 text-sm">
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} className="peer sr-only" />
<span className={cn("grid size-[18px] place-items-center rounded-[5px] border transition peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background", checked && "border-primary bg-primary text-primary-foreground")}>
{checked && <Check className="size-3" strokeWidth={3} aria-hidden />}
</span>
{children}
</label>
);
}
function AddressFields({
prefix,
value,
onChange,
addresses,
countries,
show,
blur,
}: {
prefix: "ship" | "bill";
value: Address;
onChange: (a: Address) => void;
addresses: SeedAddress[];
countries: string[];
show: (k: string) => string | false | undefined;
blur: (k: string) => () => void;
}) {
const [open, setOpen] = React.useState(false);
const [active, setActive] = React.useState(0);
const q = value.address.trim().toLowerCase();
const matches = q.length >= 2 ? addresses.filter((a) => `${a.line} ${a.city} ${a.postcode}`.toLowerCase().includes(q)).slice(0, 5) : [];
const listId = `${prefix}-addr-list`;
const pick = (a: SeedAddress) => {
onChange({ ...value, address: a.line, city: a.city, postcode: a.postcode, country: countries.includes(a.country) ? a.country : value.country });
setOpen(false);
};
const expanded = open && matches.length > 0 && !matches.some((m) => m.line.toLowerCase() === q);
return (
<div className="space-y-3 pt-3">
<div className="relative">
<Field id={`${prefix}-address`} label="Address" error={show(`${prefix}.address`)}>
<div className="relative">
<MapPin className="pointer-events-none absolute left-3 top-[calc(50%+3px)] size-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<input
id={`${prefix}-address`}
data-field={`${prefix}.address`}
role="combobox"
aria-expanded={expanded}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={expanded ? `${listId}-${active}` : undefined}
aria-invalid={!!show(`${prefix}.address`)}
autoComplete="off"
placeholder="Start typing, e.g. 12 Harbour"
value={value.address}
onChange={(e) => {
onChange({ ...value, address: e.target.value });
setOpen(true);
setActive(0);
}}
onFocus={() => setOpen(true)}
onBlur={() => {
window.setTimeout(() => setOpen(false), 120);
blur(`${prefix}.address`)();
}}
onKeyDown={(e) => {
if (!expanded) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setActive((a) => (a + 1) % matches.length);
} else if (e.key === "ArrowUp") {
e.preventDefault();
setActive((a) => (a - 1 + matches.length) % matches.length);
} else if (e.key === "Enter") {
e.preventDefault();
pick(matches[active]);
} else if (e.key === "Escape") {
setOpen(false);
}
}}
className={cn(inputCls, "pl-9")}
/>
</div>
</Field>
<AnimatePresence>
{expanded && (
<motion.ul
id={listId}
role="listbox"
aria-label="Address suggestions"
initial={{ opacity: 0, y: -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -4 }}
transition={{ duration: 0.15 }}
className="absolute inset-x-0 top-full z-20 mt-1 overflow-hidden rounded-xl border bg-popover p-1 text-popover-foreground shadow-xl"
>
{matches.map((m, i) => (
<li
key={`${m.line}-${m.city}`}
id={`${listId}-${i}`}
role="option"
aria-selected={active === i}
onMouseDown={(e) => {
e.preventDefault();
pick(m);
}}
onMouseEnter={() => setActive(i)}
className={cn("flex cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-sm", active === i && "bg-muted")}
>
<MapPin className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0">
<span className="block truncate font-medium">{m.line}</span>
<span className="block truncate text-xs text-muted-foreground">
{m.postcode} {m.city}, {m.country}
</span>
</span>
</li>
))}
<li role="presentation" className="px-3 pb-1 pt-1.5 text-[10px] uppercase tracking-wider text-muted-foreground">
↑↓ to navigate · Enter to select
</li>
</motion.ul>
)}
</AnimatePresence>
</div>
{prefix === "ship" && (
<Field id={`${prefix}-apt`} label="Apartment, suite, etc. (optional)">
<input id={`${prefix}-apt`} value={value.apt} onChange={(e) => onChange({ ...value, apt: e.target.value })} className={inputCls} autoComplete="address-line2" />
</Field>
)}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<Field id={`${prefix}-city`} label="City" error={show(`${prefix}.city`)}>
<input id={`${prefix}-city`} data-field={`${prefix}.city`} value={value.city} onChange={(e) => onChange({ ...value, city: e.target.value })} onBlur={blur(`${prefix}.city`)} aria-invalid={!!show(`${prefix}.city`)} className={inputCls} autoComplete="address-level2" />
</Field>
<Field id={`${prefix}-postcode`} label="Postcode" error={show(`${prefix}.postcode`)}>
<input id={`${prefix}-postcode`} data-field={`${prefix}.postcode`} value={value.postcode} onChange={(e) => onChange({ ...value, postcode: e.target.value })} onBlur={blur(`${prefix}.postcode`)} aria-invalid={!!show(`${prefix}.postcode`)} className={inputCls} autoComplete="postal-code" />
</Field>
<div className="col-span-2 sm:col-span-1">
<Field id={`${prefix}-country`} label="Country">
<div className="relative">
<select id={`${prefix}-country`} value={value.country} onChange={(e) => onChange({ ...value, country: e.target.value })} className={cn(inputCls, "appearance-none pr-9")} autoComplete="country-name">
{countries.map((c) => (
<option key={c}>{c}</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-3 top-[calc(50%+3px)] size-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
</div>
</Field>
</div>
</div>
</div>
);
}
function OrderSummary({
items,
fmt,
subtotal,
discount,
shipping,
total,
promo,
setPromo,
promoMsg,
applyPromo,
}: {
items: CheckoutLine[];
fmt: Intl.NumberFormat;
subtotal: number;
discount: number;
shipping: number;
total: number;
promo: string;
setPromo: (v: string) => void;
promoMsg: string;
applyPromo: () => void;
}) {
const id = React.useId();
return (
<div>
<h2 className="sr-only">Order summary</h2>
<ul className="space-y-4">
{items.map((i) => (
<li key={i.id} className="flex items-center gap-4">
<span className="relative size-16 shrink-0 rounded-xl border bg-background p-1">
<ProductArt kind={i.kind} color={i.hex} accent={i.accent} label={i.label} />
<span className="absolute -right-2 -top-2 grid size-5 place-items-center rounded-full bg-muted-foreground text-[11px] font-semibold text-background">{i.qty}</span>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">{i.name}</span>
{i.variant && <span className="block truncate text-xs text-muted-foreground">{i.variant}</span>}
</span>
<span className="text-sm font-medium tabular-nums">{fmt.format(i.price * i.qty)}</span>
</li>
))}
</ul>
<div className="mt-6 flex gap-2 border-t pt-6">
<label htmlFor={`${id}-promo`} className="sr-only">
Discount code
</label>
<div className="relative min-w-0 flex-1">
<Tag className="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<input
id={`${id}-promo`}
value={promo}
onChange={(e) => setPromo(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applyPromo();
}
}}
placeholder="Discount code (try SAVE10)"
className={cn("h-11 w-full rounded-lg border bg-background pl-9 pr-3 text-sm", ring)}
/>
</div>
<button type="button" onClick={applyPromo} disabled={!promo.trim()} className={cn("h-11 rounded-lg border bg-background px-4 text-sm font-medium transition hover:bg-muted disabled:opacity-50", ring)}>
Apply
</button>
</div>
{promoMsg && (
<p className={cn("mt-2 text-xs", discount ? "text-emerald-600 dark:text-emerald-400" : "text-destructive")} role="status">
{promoMsg}
</p>
)}
<dl className="mt-6 space-y-2 border-t pt-6 text-sm">
<div className="flex justify-between">
<dt className="text-muted-foreground">Subtotal</dt>
<dd className="tabular-nums">{fmt.format(subtotal)}</dd>
</div>
{discount > 0 && (
<div className="flex justify-between text-emerald-600 dark:text-emerald-400">
<dt>Discount</dt>
<dd className="tabular-nums">−{fmt.format(discount)}</dd>
</div>
)}
<div className="flex justify-between">
<dt className="text-muted-foreground">Shipping</dt>
<dd className="tabular-nums">{shipping ? fmt.format(shipping) : "Free"}</dd>
</div>
<div className="flex items-baseline justify-between border-t pt-4">
<dt className="text-base font-semibold">Total</dt>
<dd className="text-right">
<motion.span key={total} initial={{ opacity: 0.4, y: 4 }} animate={{ opacity: 1, y: 0 }} className="inline-block text-2xl font-semibold tabular-nums tracking-tight">
{fmt.format(total)}
</motion.span>
</dd>
</div>
</dl>
</div>
);
}
function Success({ orderNo, email, fmt, total, opt, ship, onBack }: { orderNo: string; email: string; fmt: Intl.NumberFormat; total: number; opt: DeliveryOption; ship: Address; onBack: () => void }) {
const reduce = useReducedMotion();
const ref = React.useRef<HTMLHeadingElement>(null);
React.useEffect(() => ref.current?.focus(), []);
const steps = ["Order placed", "Packing", opt.icon === "store" ? "Ready for pickup" : "On its way", opt.icon === "store" ? "Collected" : "Delivered"];
return (
<motion.div key="success" initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} className="mx-auto flex max-w-xl flex-col items-center px-4 py-14 text-center sm:py-20">
<div className="relative">
{!reduce &&
[0, 1, 2, 3, 4, 5, 6, 7].map((i) => (
<motion.span
key={i}
aria-hidden
className="absolute left-1/2 top-1/2 size-2 rounded-full"
style={{ background: ["#6366f1", "#10b981", "#f59e0b", "#ec4899"][i % 4] }}
initial={{ x: "-50%", y: "-50%", scale: 0 }}
animate={{ x: `calc(-50% + ${Math.cos((i / 8) * Math.PI * 2) * 64}px)`, y: `calc(-50% + ${Math.sin((i / 8) * Math.PI * 2) * 64}px)`, scale: [0, 1.2, 0] }}
transition={{ duration: 0.9, delay: 0.35, ease: "easeOut" }}
/>
))}
<svg viewBox="0 0 80 80" className="size-20" aria-hidden>
<motion.circle cx="40" cy="40" r="36" fill="none" stroke="currentColor" strokeWidth="4" className="text-emerald-500" initial={{ pathLength: reduce ? 1 : 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.5 }} />
<motion.path d="M25 41 L35 51 L56 30" fill="none" stroke="currentColor" strokeWidth="5" strokeLinecap="round" strokeLinejoin="round" className="text-emerald-500" initial={{ pathLength: reduce ? 1 : 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.35, delay: 0.45 }} />
</svg>
</div>
<p className="mt-6 text-sm font-medium text-muted-foreground">Order {orderNo}</p>
<h2 ref={ref} tabIndex={-1} className="mt-1 text-2xl font-semibold tracking-tight outline-none sm:text-3xl">
Thank you{ship.firstName ? `, ${ship.firstName}` : ""}!
</h2>
<p className="mt-2 text-sm text-muted-foreground">
We've emailed a confirmation to <span className="font-medium text-foreground">{email}</span>. You paid <span className="font-medium text-foreground">{fmt.format(total)}</span>.
</p>
<ol className="mt-8 grid w-full grid-cols-4 gap-2">
{steps.map((s, i) => (
<li key={s} className="flex flex-col items-center gap-2 text-[11px] text-muted-foreground sm:text-xs">
<span className="relative h-1.5 w-full overflow-hidden rounded-full bg-muted">
{i === 0 && <motion.span className="absolute inset-0 origin-left rounded-full bg-emerald-500" initial={{ scaleX: 0 }} animate={{ scaleX: 1 }} transition={{ delay: 0.8, duration: 0.6 }} />}
</span>
<span className={cn(i === 0 && "font-medium text-foreground")}>{s}</span>
</li>
))}
</ol>
<div className="mt-8 w-full rounded-2xl border bg-card p-4 text-left text-sm">
<p className="flex items-center gap-2 font-medium">
<Truck className="size-4" aria-hidden /> {opt.label} · {opt.detail}
</p>
{ship.address && (
<p className="mt-1 text-muted-foreground">
{ship.address}
{ship.apt ? `, ${ship.apt}` : ""}, {ship.postcode} {ship.city}, {ship.country}
</p>
)}
</div>
<button type="button" onClick={onBack} className={cn("mt-8 flex h-11 items-center gap-2 rounded-xl border px-5 text-sm font-medium hover:bg-muted", ring)}>
<ArrowLeft className="size-4" aria-hidden /> Continue shopping
</button>
</motion.div>
);
}