"use client"; import { Lock, Minus, Plus, Tag, Truck, Zap } from "lucide-react"; import { AnimatePresence, animate, motion, useReducedMotion, } from "motion/react"; import { type ReactNode, useCallback, useId, useMemo, useRef, useState } from "react"; import { Badge } from "@/components/motion/badge"; import { Button, StatefulButton } from "@/components/motion/button"; import { Input } from "@/components/motion/input"; import { NumberTicker } from "@/components/motion/number-ticker"; import { Stepper, StepperIndicator, StepperNext, StepperPanels, StepperPrevious, type StepperStep, useStepper, } from "@/components/motion/stepper"; import { EASE_OUT, SPRING_LAYOUT, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type CheckoutItem = { id: string; name: string; /** Secondary line — size, color, plan. */ variant?: string; /** Unit price in major units (12.5 = $12.50). */ price: number; quantity: number; /** Thumbnail node; defaults to a tinted swatch. */ image?: ReactNode; }; export type CheckoutPromo = { code: string; label: string; /** Percentage off the subtotal (0–100). */ percentOff?: number; /** Fixed amount off, in major units. */ amountOff?: number; }; export type CheckoutShipping = { name: string; address: string; city: string; zip: string; }; export type CheckoutDelivery = "standard" | "express"; export type CheckoutTotals = { subtotal: number; discount: number; shipping: number; tax: number; total: number; }; export type CheckoutOrder = { items: CheckoutItem[]; shipping: CheckoutShipping; delivery: CheckoutDelivery; promo: CheckoutPromo | null; totals: CheckoutTotals; }; export interface CheckoutProps { items?: CheckoutItem[]; defaultItems?: CheckoutItem[]; onItemsChange?: (items: CheckoutItem[]) => void; /** Known codes (case-insensitive) when `onApplyPromo` isn't given. */ promoCodes?: CheckoutPromo[]; /** Async promo check. Resolve with the promo, reject to mark the code invalid. */ onApplyPromo?: (code: string) => Promise; /** * Places the order. Card fields are UI only and never included — wire a * payment provider's hosted fields for real card entry. Resolve with an * order reference to show it; reject to show the message on the review step. */ onComplete?: (order: CheckoutOrder) => Promise | string | undefined; /** Sales tax rate (0.08 = 8%). Default 0.08. */ taxRate?: number; /** Express delivery price in major units. Default 15. */ expressPrice?: number; currency?: string; locale?: string; className?: string; } const STEPS: StepperStep[] = [ { id: "shipping", title: "Shipping" }, { id: "payment", title: "Payment" }, { id: "review", title: "Review" }, ]; const SWATCHES = ["bg-chart-1", "bg-chart-2", "bg-chart-3", "bg-chart-4", "bg-chart-5"]; const SHAKE = { x: [0, -6, 6, -4, 4, -2, 0] }; const EMPTY_SHIPPING: CheckoutShipping = { name: "", address: "", city: "", zip: "" }; const cents = (major: number) => Math.round(major * 100); function digits(value: string, max: number) { return value.replace(/\D/g, "").slice(0, max); } function messageOf(reason: unknown, fallback: string) { if (reason instanceof Error && reason.message) return reason.message; if (typeof reason === "string" && reason) return reason; return fallback; } /** Money that rolls when it changes. Values are cents so decimals survive `NumberTicker`'s rounding. */ function Money({ value, format, className, }: { value: number; format: (cents: number) => string; className?: string; }) { return ( ); } /** * Compact quantity control. `NumberStepper`'s 48px editable field is too big * for a summary line, so this keeps two pressable buttons around a rolling * digit and exposes the same spinbutton semantics. */ function Quantity({ value, onChange, label, min = 1, max = 9, }: { value: number; onChange: (next: number) => void; label: string; min?: number; max?: number; }) { const reduce = useReducedMotion(); const button = (dir: -1 | 1) => { const Icon = dir < 0 ? Minus : Plus; const blocked = dir < 0 ? value <= min : value >= max; return ( onChange(value + dir)} className="grid size-6 place-items-center rounded-full text-foreground outline-none transition-colors hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:text-muted-foreground/50 disabled:hover:bg-transparent" > ); }; return (
{button(-1)} { if (event.key === "ArrowUp" && value < max) { event.preventDefault(); onChange(value + 1); } else if (event.key === "ArrowDown" && value > min) { event.preventDefault(); onChange(value - 1); } }} className="min-w-4 rounded text-center text-xs font-semibold outline-none focus-visible:ring-2 focus-visible:ring-ring" > {button(1)}
); } /** Finish button that shows the total and the stepper's pending state. */ function PlaceOrder({ label }: { label: string }) { const { isLast, pending } = useStepper(); return ( Continue ); } /** * Checkout block: `Stepper`-driven Shipping → Payment → Review with guarded * steps that shake when incomplete, an order summary with compact quantity * controls, a promo field that shakes on an invalid code and pops a removable * `Badge` on a valid one, and totals that roll through `NumberTicker`. Card * inputs are display-only placeholders and are never passed to `onComplete`. */ export function Checkout({ items: itemsProp, defaultItems = [], onItemsChange, promoCodes = [], onApplyPromo, onComplete, taxRate = 0.08, expressPrice = 15, currency = "USD", locale = "en-US", className, }: CheckoutProps) { const reduce = useReducedMotion(); const baseId = useId(); const [internalItems, setInternalItems] = useState(defaultItems); const items = itemsProp ?? internalItems; const setQuantity = (id: string, quantity: number) => { const next = items.map((item) => (item.id === id ? { ...item, quantity } : item)); if (itemsProp === undefined) setInternalItems(next); onItemsChange?.(next); }; const [shipping, setShipping] = useState(EMPTY_SHIPPING); const [delivery, setDelivery] = useState("standard"); // UI-only card fields: local state, never leaves this component. const [card, setCard] = useState({ number: "", expiry: "", cvc: "" }); const [touched, setTouched] = useState<{ shipping?: boolean; payment?: boolean }>({}); const [promoInput, setPromoInput] = useState(""); const [promo, setPromo] = useState(null); const [promoState, setPromoState] = useState<"idle" | "loading" | "success" | "error">( "idle", ); const [promoError, setPromoError] = useState(); const promoRef = useRef(null); const [placed, setPlaced] = useState<{ reference?: string } | null>(null); const [orderError, setOrderError] = useState(); const formatter = useMemo( () => new Intl.NumberFormat(locale, { style: "currency", currency }), [locale, currency], ); const format = useCallback((c: number) => formatter.format(c / 100), [formatter]); const totals = useMemo(() => { const subtotal = items.reduce((sum, item) => sum + cents(item.price) * item.quantity, 0); const discount = promo ? Math.min( subtotal, promo.percentOff ? Math.round((subtotal * promo.percentOff) / 100) : cents(promo.amountOff ?? 0), ) : 0; const ship = delivery === "express" ? cents(expressPrice) : 0; const tax = Math.round((subtotal - discount) * taxRate); return { subtotal, discount, shipping: ship, tax, total: subtotal - discount + ship + tax }; }, [items, promo, delivery, expressPrice, taxRate]); const shippingErrors = { name: shipping.name.trim() ? undefined : "Required", address: shipping.address.trim() ? undefined : "Required", city: shipping.city.trim() ? undefined : "Required", zip: /^[A-Za-z0-9 -]{3,10}$/.test(shipping.zip.trim()) ? undefined : "Invalid", }; const [mm, yy] = card.expiry.split("/"); const paymentErrors = { number: digits(card.number, 16).length === 16 ? undefined : "Enter 16 digits", expiry: Number(mm) >= 1 && Number(mm) <= 12 && (yy ?? "").length === 2 ? undefined : "MM/YY", cvc: /^\d{3,4}$/.test(card.cvc) ? undefined : "3–4 digits", }; const hasErrors = (errors: Record) => Object.values(errors).some(Boolean); const canAdvance = (index: number) => { if (index === 0) { setTouched((t) => ({ ...t, shipping: true })); return !hasErrors(shippingErrors); } if (index === 1) { setTouched((t) => ({ ...t, payment: true })); return !hasErrors(paymentErrors); } return items.length > 0; }; const complete = async () => { setOrderError(undefined); try { const reference = await onComplete?.({ items, shipping, delivery, promo, totals: { subtotal: totals.subtotal / 100, discount: totals.discount / 100, shipping: totals.shipping / 100, tax: totals.tax / 100, total: totals.total / 100, }, }); setPlaced({ reference: reference ?? undefined }); } catch (reason) { setOrderError(messageOf(reason, "Payment didn't go through. Try again.")); } }; const applyPromo = async () => { const code = promoInput.trim(); if (!code) return; setPromoState("loading"); setPromoError(undefined); try { let result: CheckoutPromo | undefined; if (onApplyPromo) result = await onApplyPromo(code); else result = promoCodes.find((p) => p.code.toLowerCase() === code.toLowerCase()); if (!result) throw new Error("That code isn't valid."); setPromo(result); setPromoInput(""); setPromoState("idle"); } catch (reason) { setPromoState("idle"); setPromoError(messageOf(reason, "That code isn't valid.")); if (!reduce && promoRef.current) { animate(promoRef.current, SHAKE, { duration: 0.42, ease: EASE_OUT }); } } }; const fieldError = (show: boolean | undefined, error: string | undefined) => show ? error : undefined; const shippingPanel = (
setShipping((s) => ({ ...s, name }))} error={fieldError(touched.shipping, shippingErrors.name) && true} /> setShipping((s) => ({ ...s, address }))} error={fieldError(touched.shipping, shippingErrors.address) && true} />
setShipping((s) => ({ ...s, city }))} error={fieldError(touched.shipping, shippingErrors.city) && true} /> setShipping((s) => ({ ...s, zip }))} error={fieldError(touched.shipping, shippingErrors.zip) && true} />
Delivery speed {( [ { id: "standard", label: "Standard", note: "Free · 5–7 days", icon: Truck }, { id: "express", label: "Express", note: `${format(cents(expressPrice))} · 1–2 days`, icon: Zap, }, ] as const ).map((option) => { const active = delivery === option.id; const Icon = option.icon; return ( ); })}
); const paymentPanel = (
{/* Placeholders only: autocomplete is off so no saved card is ever offered, and these values never leave the component. */} } value={digits(card.number, 16).replace(/(\d{4})(?=\d)/g, "$1 ")} onChange={(number) => setCard((c) => ({ ...c, number: digits(number, 16) }))} error={fieldError(touched.payment, paymentErrors.number) && true} />
{ const d = digits(next, 4); setCard((c) => ({ ...c, expiry: d.length > 2 ? `${d.slice(0, 2)}/${d.slice(2)}` : d })); }} error={fieldError(touched.payment, paymentErrors.expiry) && true} /> setCard((c) => ({ ...c, cvc: digits(cvc, 4) }))} error={fieldError(touched.payment, paymentErrors.cvc) && true} />

Demo fields — no card data is stored or sent.

); const reviewRow = (label: string, value: ReactNode, step: number) => ( {value} ); const reviewPanel = (
{reviewRow( "Ship to", <> {shipping.name}, {shipping.address}, {shipping.city} {shipping.zip} , 0, )} {reviewRow( "Delivery", delivery === "express" ? "Express · 1–2 days" : "Standard · 5–7 days", 0, )} {reviewRow("Payment", `Card ending ${digits(card.number, 16).slice(-4)}`, 1)} {orderError ? ( {orderError} ) : null}
); const summary = ( ); return (
{placed ? (

Order placed

{placed.reference ? ( <> Order {placed.reference}{" "} is confirmed.{" "} ) : null} A receipt is on its way to you, {shipping.name.split(" ")[0]}.

) : ( {shippingPanel} {paymentPanel} {reviewPanel}
)}
{summary}
); } function ReviewRow({ label, step, children, }: { label: string; step: number; children: ReactNode; }) { const { goTo } = useStepper(); return (

{label}

{children}

); }