"use client"; import { CheckIcon, ChevronDownIcon, LockIcon, MinusIcon, NfcIcon, PackageIcon, PlusIcon, RabbitIcon, TagIcon, TruckIcon, Undo2Icon, XIcon, ZapIcon, } from "lucide-react"; import { AnimatePresence, animate, motion, useMotionValue, useReducedMotion, useTransform, } from "motion/react"; import { type FormEvent, type ReactNode, useEffect, useId, useRef, useState } from "react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Radio, RadioGroup } from "@/components/ui/radio-group"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "@/components/ui/select"; export type CheckoutArt = "mug" | "tote" | "candle" | "notebook"; export interface CheckoutItem { id: string; name: string; variant: string; price: number; qty: number; art: CheckoutArt; } export interface CheckoutShipping { id: string; label: string; eta: string; price: number; icon: "standard" | "express" | "next"; } export interface CheckoutProps { className?: string; storeName?: string; items?: CheckoutItem[]; shippingOptions?: CheckoutShipping[]; taxRate?: number; promoCodes?: Record; testCard?: string; onTrackOrder?: (orderNo: string) => void; } type Step = 0 | 1 | 2 | 3; type Values = { email: string; news: boolean; first: string; last: string; line1: string; line2: string; city: string; country: string; region: string; postcode: string; shipping: string; card: string; expiry: string; cvc: string; holder: string; sameBilling: boolean; }; type Errors = Partial>; const easeOut = [0.23, 1, 0.32, 1] as const; const press = "transition-[color,background-color,box-shadow,scale,opacity] duration-150 ease-out active:scale-[0.97]"; const defaultItems: CheckoutItem[] = [ { id: "i1", name: "Speckled stoneware mug", variant: "Oat · 350 ml", price: 28, qty: 2, art: "mug" }, { id: "i2", name: "Waxed canvas tote", variant: "Moss", price: 64, qty: 1, art: "tote" }, { id: "i3", name: "Cedar & smoke candle", variant: "220 g", price: 32, qty: 1, art: "candle" }, ]; const defaultShipping: CheckoutShipping[] = [ { id: "standard", label: "Standard", eta: "Thu 29 – Mon 2 Nov", price: 0, icon: "standard" }, { id: "express", label: "Express", eta: "Tue 27 Oct", price: 9, icon: "express" }, { id: "next", label: "Next day", eta: "Mon 26 Oct, order in 3h", price: 18, icon: "next" }, ]; const countries: { value: string; label: string; regions?: string[]; regionLabel?: string; post: RegExp; postLabel: string; postHint: string }[] = [ { value: "US", label: "United States", regions: ["California", "Colorado", "Illinois", "Massachusetts", "New York", "Oregon", "Texas", "Washington"], regionLabel: "State", post: /^\d{5}(-\d{4})?$/, postLabel: "ZIP code", postHint: "5 digits, like 97205", }, { value: "CA", label: "Canada", regions: ["Alberta", "British Columbia", "Manitoba", "Nova Scotia", "Ontario", "Quebec"], regionLabel: "Province", post: /^[A-Za-z]\d[A-Za-z] ?\d[A-Za-z]\d$/, postLabel: "Postal code", postHint: "Like K1A 0B1", }, { value: "GB", label: "United Kingdom", post: /^[A-Za-z]{1,2}\d[A-Za-z\d]? ?\d[A-Za-z]{2}$/, postLabel: "Postcode", postHint: "Like SW1A 1AA", }, { value: "AU", label: "Australia", regions: ["New South Wales", "Queensland", "South Australia", "Tasmania", "Victoria", "Western Australia"], regionLabel: "State", post: /^\d{4}$/, postLabel: "Postcode", postHint: "4 digits", }, ]; const steps = ["Contact", "Shipping address", "Delivery", "Payment"] as const; const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" }); const money = (n: number) => usd.format(n); const digits = (s: string) => s.replace(/\D/g, ""); const formatCard = (s: string) => digits(s).slice(0, 16).replace(/(\d{4})(?=\d)/g, "$1 "); const formatExpiry = (s: string, prev: string) => { const d = digits(s).slice(0, 4); if (s.length < prev.length && prev.endsWith(" / ") && d.length <= 2) return d.slice(0, 1); if (d.length >= 3) return `${d.slice(0, 2)} / ${d.slice(2)}`; if (d.length === 2) return `${d} / `; return d; }; function luhn(num: string) { let sum = 0; let alt = false; for (let i = num.length - 1; i >= 0; i--) { let n = Number(num[i]); if (alt) { n *= 2; if (n > 9) n -= 9; } sum += n; alt = !alt; } return num.length >= 13 && sum % 10 === 0; } function validate(step: Step, v: Values): Errors { const e: Errors = {}; if (step === 0) { if (!v.email.trim()) e.email = "Enter your email so we can send the receipt."; else if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v.email.trim())) e.email = "That email looks incomplete."; } if (step === 1) { const c = countries.find((x) => x.value === v.country); if (!v.first.trim()) e.first = "Required"; if (!v.last.trim()) e.last = "Required"; if (!v.line1.trim()) e.line1 = "Enter a street address."; if (!v.city.trim()) e.city = "Required"; if (c?.regions && !v.region) e.region = `Pick a ${c.regionLabel?.toLowerCase()}.`; if (!v.postcode.trim()) e.postcode = "Required"; else if (c && !c.post.test(v.postcode.trim())) e.postcode = c.postHint; } if (step === 3) { const d = digits(v.card); if (!d) e.card = "Enter a card number."; else if (!luhn(d)) e.card = "That number doesn’t look right."; const [mm, yy] = v.expiry.split(" / ").map(Number); if (!v.expiry) e.expiry = "Required"; else if (!mm || mm > 12 || !yy || v.expiry.length < 7) e.expiry = "Use MM / YY"; else if (2000 + yy < 2026 || (2000 + yy === 2026 && mm < 10)) e.expiry = "This card has expired."; if (!/^\d{3,4}$/.test(v.cvc)) e.cvc = "3 or 4 digits"; if (!v.holder.trim()) e.holder = "Required"; } return e; } function Tween({ value }: { value: number }) { const reduce = useReducedMotion(); const mv = useMotionValue(value); const text = useTransform(mv, money); useEffect(() => { if (reduce) { mv.set(value); return; } const controls = animate(mv, value, { type: "spring", stiffness: 200, damping: 30 }); return () => controls.stop(); }, [value, reduce, mv]); return {text}; } function ProductArt({ art, className }: { art: CheckoutArt; className?: string }) { const tile: Record = { mug: "bg-amber-500/14 dark:bg-amber-400/12", tote: "bg-emerald-600/14 dark:bg-emerald-400/12", candle: "bg-rose-500/12 dark:bg-rose-400/12", notebook: "bg-sky-500/12 dark:bg-sky-400/12", }; return ( {art === "mug" && ( <> {[ [17, 24], [24, 29], [28, 22], [20, 34], [29, 34], [15, 30], ].map(([x, y]) => ( ))} )} {art === "tote" && ( <> )} {art === "candle" && ( <> )} {art === "notebook" && ( <> )} ); } function TextField({ id, label, error, hint, optional, mono, className, ...props }: { id: string; label: string; error?: string; hint?: string; optional?: boolean; mono?: boolean; className?: string; } & Omit, "id" | "className">) { return (
); } function FieldNote({ id, error, hint }: { id: string; error?: string; hint?: string }) { return ( {error ? ( {error} ) : hint ? (

{hint}

) : null}
); } export function Checkout({ className, storeName = "Hearth Goods", items: initialItems = defaultItems, shippingOptions = defaultShipping, taxRate = 0.0825, promoCodes = { HEARTH10: 0.1 }, testCard = "4242 4242 4242 4242", onTrackOrder, }: CheckoutProps) { const reduce = useReducedMotion(); const uid = useId().replace(/:/g, ""); const [items, setItems] = useState(initialItems); const [open, setOpen] = useState(0); const [done, setDone] = useState([]); const [errors, setErrors] = useState({}); const [submittedSteps, setSubmittedSteps] = useState([]); const [status, setStatus] = useState<"form" | "paying" | "placed">("form"); const [summaryOpen, setSummaryOpen] = useState(false); const [flip, setFlip] = useState(false); const [promo, setPromo] = useState(""); const [promoApplied, setPromoApplied] = useState(null); const [promoError, setPromoError] = useState(null); const formRef = useRef(null); const timer = useRef | null>(null); const [v, setV] = useState({ email: "", news: true, first: "", last: "", line1: "", line2: "", city: "", country: "US", region: "", postcode: "", shipping: shippingOptions[0]?.id ?? "", card: "", expiry: "", cvc: "", holder: "", sameBilling: true, }); useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []); const set = (k: K, value: Values[K]) => { setV((cur) => { const next = { ...cur, [k]: value }; if (submittedSteps.includes(open)) setErrors(validate(open, next)); return next; }); }; const country = countries.find((c) => c.value === v.country) ?? countries[0]; const ship = shippingOptions.find((s) => s.id === v.shipping); const subtotal = items.reduce((s, i) => s + i.price * i.qty, 0); const discount = promoApplied ? subtotal * (promoCodes[promoApplied] ?? 0) : 0; const shippingCost = done.includes(2) || open >= 2 ? ship?.price ?? 0 : null; const tax = (subtotal - discount) * taxRate; const total = subtotal - discount + (shippingCost ?? 0) + tax; const count = items.reduce((s, i) => s + i.qty, 0); const focusFirstError = (errs: Errors) => { const first = Object.keys(errs)[0]; if (!first) return; requestAnimationFrame(() => { const el = formRef.current?.querySelector(`[id="${uid}-${first}"]`); el?.focus(); }); }; const submitStep = (step: Step) => (e: FormEvent) => { e.preventDefault(); const errs = validate(step, v); setSubmittedSteps((t) => (t.includes(step) ? t : [...t, step])); setErrors(errs); if (Object.keys(errs).length) { focusFirstError(errs); return; } setDone((d) => (d.includes(step) ? d : [...d, step])); if (step < 3) { const next = ([0, 1, 2, 3] as Step[]).find((s) => s > step && !done.includes(s)) ?? ((step + 1) as Step); setOpen(next); setErrors({}); return; } setStatus("paying"); timer.current = setTimeout(() => setStatus("placed"), 1400); }; const applyPromo = () => { const code = promo.trim().toUpperCase(); if (!code) return; if (promoCodes[code] !== undefined) { setPromoApplied(code); setPromoError(null); setPromo(""); } else { setPromoError("That code isn’t valid. Check for typos."); } }; const reset = () => { setStatus("form"); setOpen(0); setDone([]); setSubmittedSteps([]); setErrors({}); setV((cur) => ({ ...cur, card: "", expiry: "", cvc: "", holder: "" })); }; const summaryLine = (step: Step): ReactNode => { if (step === 0) return v.email; if (step === 1) return `${v.first} ${v.last}, ${v.line1}${v.line2 ? `, ${v.line2}` : ""}, ${v.city}${v.region ? `, ${v.region}` : ""} ${v.postcode.toUpperCase()}`; if (step === 2) return ship ? `${ship.label} · ${ship.price ? money(ship.price) : "Free"} · ${ship.eta}` : ""; return `Card ending ${digits(v.card).slice(-4)}`; }; const sectionBody = (step: Step) => { if (step === 0) return (
set("email", e.target.value)} placeholder="you@example.com" type="email" value={v.email} /> Continue to shipping ); if (step === 1) return (
set("first", e.target.value)} value={v.first} /> set("last", e.target.value)} value={v.last} /> set("line1", e.target.value)} placeholder="Street and number" value={v.line1} /> set("line2", e.target.value)} optional value={v.line2} /> set("city", e.target.value)} value={v.city} /> {country.regions && (
)} set("postcode", e.target.value)} value={v.postcode} />
Continue to delivery
); if (step === 2) return (
set("shipping", val as string)} value={v.shipping} > {shippingOptions.map((s) => { const Icon = s.icon === "next" ? ZapIcon : s.icon === "express" ? RabbitIcon : TruckIcon; const on = v.shipping === s.id; return ( ); })} Continue to payment
); return (
Test mode No real charge. Use any future date and any CVC.
set("card", formatCard(e.target.value))} placeholder="1234 1234 1234 1234" value={v.card} /> set("expiry", formatExpiry(e.target.value, v.expiry))} placeholder="MM / YY" value={v.expiry} /> setFlip(false)} onChange={(e) => set("cvc", digits(e.target.value).slice(0, 4))} onFocus={() => setFlip(true)} placeholder="123" value={v.cvc} /> set("holder", e.target.value)} value={v.holder} />
); }; const summary = (
    {items.map((i) => (
    {i.name} {i.variant}
    {i.qty}
    {money(i.price * i.qty)}
    ))}
{promoApplied ? ( {promoApplied} {Math.round((promoCodes[promoApplied] ?? 0) * 100)}% off your order ) : ( { e.preventDefault(); applyPromo(); }} transition={{ duration: 0.15 }} > { setPromo(e.target.value); setPromoError(null); }} placeholder="Discount code" value={promo} /> )} {promoError && (

{promoError}

)}
Subtotal · {count} items
{discount > 0 && (
Discount
−
)}
Shipping
{shippingCost === null ? "Next step" : shippingCost === 0 ? "Free" : }
Estimated tax
Total
USD

Free returns within 30 days of delivery.

); const orderNo = "HG-48213"; return (
{storeName} Secure checkout
Secure checkout
{summaryOpen && ( <> setSummaryOpen(false)} transition={{ duration: 0.2 }} /> {summary} )}
{status === "placed" ? ( onTrackOrder(orderNo) : undefined} orderNo={orderNo} address={summaryLine(1)} paid={`${money(total)} · ${summaryLine(3)}`} /> ) : (

Checkout

Step {Math.min(open + 1, 4)} of 4 · {steps[open]}

{([0, 1, 2, 3] as Step[]).map((s) => { const isOpen = open === s; const isDone = done.includes(s) && !isOpen; const reachable = s === 0 || done.includes((s - 1) as Step) || done.includes(s); return (
{isDone ? ( ) : ( {s + 1} )}

{steps[s]}

{isDone &&

{summaryLine(s)}

}
{isDone && reachable && status === "form" && ( )}
{isOpen && (
{sectionBody(s)}
)}
); })}

Payments are encrypted and processed securely.

)}
); } function StepButton({ children }: { children: ReactNode }) { return ( ); } function CardPreview({ number, holder, expiry, cvc, flipped, }: { number: string; holder: string; expiry: string; cvc: string; flipped: boolean; }) { const reduce = useReducedMotion(); const d = digits(number).padEnd(16, "•"); const groups = [d.slice(0, 4), d.slice(4, 8), d.slice(8, 12), d.slice(12, 16)]; return (
{groups.map((g, i) => ( {g} ))}
Card holder {holder || "Your name"} Expires {expiry || "MM / YY"}
{cvc || "•••"}
); } function Placed({ orderNo, email, eta, address, paid, onReset, onTrack, }: { orderNo: string; email: string; eta: string; address: ReactNode; paid: string; onReset: () => void; onTrack?: () => void; }) { const reduce = useReducedMotion(); const rise = (i: number) => ({ initial: reduce ? { opacity: 0 } : { opacity: 0, y: 8 }, animate: { opacity: 1, y: 0 }, transition: { duration: 0.3, ease: easeOut, delay: 0.25 + i * 0.04 }, }); return (

Order {orderNo}

Thank you, it’s on its way soon

A receipt is heading to {email}. We’ll send tracking as soon as your parcel leaves the workshop.

Paid
{paid}
Shipping to
{address}
{[ { title: "Order placed", meta: "Just now", done: true }, { title: "Packed at the workshop", meta: "Usually within a day" }, { title: "Handed to the courier", meta: "Tracking link by email" }, { title: "Delivered", meta: `Expected ${eta}` }, ].map((step, i, all) => (
  • {i < all.length - 1 && ( )} {step.done && } {step.title} {step.meta}
  • ))}
    {onTrack && ( )}
    ); }