Fazekit

Code

"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&apos;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>
  );
}

More in E-commerce

View all →