Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, LayoutGroup, animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react";
import { ArrowRight, Bookmark, Check, ChevronDown, Gift, Lock, Minus, Plus, ShoppingCart, Tag, Trash2, Truck, Undo2, X, PartyPopper } from "lucide-react";
import { cn } from "@/lib/utils";
import { ProductArt, type ArtKind } from "./product-art";

export type CartItem = {
  id: string;
  name: string;
  variant?: string;
  price: number;
  compareAt?: number;
  qty: number;
  kind: ArtKind;
  hex: string;
  accent?: string;
  label?: string;
  maxQty?: number;
};
export type PromoRule = { code: string; type: "percent" | "fixed" | "shipping"; value: number; label: string; minSubtotal?: number };
export type ShippingZone = { id: string; label: string; cost: number; eta: string };
export type CheckoutPayload = { items: CartItem[]; subtotal: number; discount: number; shipping: number; tax: number; total: number; promo?: string; giftNote?: string };

export interface CartPageProps {
  initialItems?: CartItem[];
  initialSaved?: CartItem[];
  recommendations?: CartItem[];
  promos?: PromoRule[];
  zones?: ShippingZone[];
  freeShippingAt?: number;
  /** Included VAT rate used for the tax line (0.23 = 23%). */
  taxRate?: number;
  currency?: string;
  locale?: string;
  onCheckout?: (payload: CheckoutPayload) => void;
  onChange?: (items: CartItem[]) => void;
  className?: string;
}

const I = (id: string, name: string, variant: string, price: number, qty: number, kind: ArtKind, hex: string, accent?: string, extra: Partial<CartItem> = {}): CartItem => ({ id, name, variant, price, qty, kind, hex, accent, ...extra });

const ITEMS: CartItem[] = [
  I("c1", "Highland Single Malt 12", "Sherry cask · 70 cl", 39.9, 1, "bottle", "#b45309", "#f5efe0", { compareAt: 49.9, label: "Lumen" }),
  I("c2", "Reserva Tinto 2019", "75 cl · Case of 2", 18.9, 2, "wine", "#7f1d1d", "#d4af37", { label: "Orbit" }),
  I("c3", "Crystal Rocks Tumbler", "Set of 2", 12, 1, "tumbler", "#d97706", "#ffffff"),
];
const SAVED: CartItem[] = [I("s1", "Hazy IPA 4-pack", "4 × 440 ml", 11.9, 1, "can", "#ca8a04", "#1e293b", { label: "Brewlab" })];
const RECS: CartItem[] = [
  I("r1", "Whisky Stones Set", "9 pcs", 19, 1, "box", "#57534e", "#a8a29e"),
  I("r2", "Champagne Brut NV", "75 cl", 42, 1, "wine", "#ca8a04", "#111827", { label: "Orbit" }),
  I("r3", "Soy Wax Candle", "Oak & amber", 16, 1, "candle", "#e7d5c0", "#1c1917", { label: "Lumen" }),
  I("r4", "Coastal Dry Gin", "70 cl", 29.5, 1, "bottle", "#0ea5e9", "#f0f9ff", { label: "Northwind" }),
  I("r5", "Barrel-aged Stout", "440 ml", 6.5, 1, "can", "#292524", "#fbbf24", { label: "Brewlab" }),
];
const PROMOS: PromoRule[] = [
  { code: "SAVE10", type: "percent", value: 10, label: "10% off your order" },
  { code: "WELCOME5", type: "fixed", value: 5, label: "€5 welcome discount", minSubtotal: 30 },
  { code: "FREESHIP", type: "shipping", value: 0, label: "Free shipping" },
];
const ZONES: ShippingZone[] = [
  { id: "std", label: "Standard (2–3 days)", cost: 4.99, eta: "Fri, 27 Sep" },
  { id: "exp", label: "Express (next day)", cost: 9.9, eta: "Tomorrow" },
  { id: "pick", label: "Click & collect", cost: 0, eta: "Ready in 2 hours" },
];

const ring = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background";

function AnimatedMoney({ value, fmt, className }: { value: number; fmt: Intl.NumberFormat; className?: string }) {
  const reduce = useReducedMotion();
  const mv = useMotionValue(value);
  const text = useTransform(mv, (v) => fmt.format(v));
  React.useEffect(() => {
    if (reduce) {
      mv.set(value);
      return;
    }
    const c = animate(mv, value, { duration: 0.5, ease: [0.22, 1, 0.36, 1] });
    return () => c.stop();
  }, [value, mv, reduce]);
  return <motion.span className={cn("tabular-nums", className)}>{text}</motion.span>;
}

export function CartPage({
  initialItems = ITEMS,
  initialSaved = SAVED,
  recommendations = RECS,
  promos = PROMOS,
  zones = ZONES,
  freeShippingAt = 100,
  taxRate = 0.23,
  currency = "EUR",
  locale = "en-IE",
  onCheckout,
  onChange,
  className,
}: CartPageProps) {
  const reduce = useReducedMotion();
  const fmt = React.useMemo(() => new Intl.NumberFormat(locale, { style: "currency", currency }), [locale, currency]);
  const [items, setItems] = React.useState(initialItems);
  const [saved, setSaved] = React.useState(initialSaved);
  const [undo, setUndo] = React.useState<{ item: CartItem; index: number } | null>(null);
  const [code, setCode] = React.useState("");
  const [promo, setPromo] = React.useState<PromoRule | null>(null);
  const [promoMsg, setPromoMsg] = React.useState<{ ok: boolean; text: string } | null>(null);
  const [zone, setZone] = React.useState(zones[0]?.id);
  const [gift, setGift] = React.useState(false);
  const [note, setNote] = React.useState("");
  const [busy, setBusy] = React.useState(false);
  const [recAdded, setRecAdded] = React.useState<string | null>(null);

  React.useEffect(() => onChange?.(items), [items, onChange]);
  React.useEffect(() => {
    if (!undo) return;
    const t = window.setTimeout(() => setUndo(null), 6000);
    return () => window.clearTimeout(t);
  }, [undo]);

  const subtotal = items.reduce((s, i) => s + i.price * i.qty, 0);
  const savings = items.reduce((s, i) => s + (i.compareAt ? (i.compareAt - i.price) * i.qty : 0), 0);
  const promoValid = promo && (!promo.minSubtotal || subtotal >= promo.minSubtotal);
  const discount = promoValid ? (promo.type === "percent" ? Math.round(subtotal * promo.value) / 100 : promo.type === "fixed" ? Math.min(promo.value, subtotal) : 0) : 0;
  const z = zones.find((x) => x.id === zone) ?? zones[0];
  const freeShip = subtotal - discount >= freeShippingAt || (promoValid && promo.type === "shipping");
  const shipping = items.length === 0 ? 0 : freeShip && z.id === zones[0]?.id ? 0 : freeShip ? Math.max(0, z.cost - zones[0].cost) : z.cost;
  const total = Math.max(0, subtotal - discount + shipping);
  const tax = total - total / (1 + taxRate);
  const progress = Math.min(1, (subtotal - discount) / freeShippingAt);
  const count = items.reduce((s, i) => s + i.qty, 0);

  const setQty = (id: string, q: number) => setItems((l) => l.map((i) => (i.id === id ? { ...i, qty: Math.max(1, Math.min(i.maxQty ?? 12, q)) } : i)));
  const remove = (id: string) => {
    const index = items.findIndex((i) => i.id === id);
    if (index < 0) return;
    setUndo({ item: items[index], index });
    setItems((l) => l.filter((i) => i.id !== id));
  };
  const restore = () => {
    if (!undo) return;
    setItems((l) => [...l.slice(0, undo.index), undo.item, ...l.slice(undo.index)]);
    setUndo(null);
  };
  const saveForLater = (id: string) => {
    const it = items.find((i) => i.id === id);
    if (!it) return;
    setItems((l) => l.filter((i) => i.id !== id));
    setSaved((s) => [it, ...s]);
  };
  const moveToCart = (id: string) => {
    const it = saved.find((i) => i.id === id);
    if (!it) return;
    setSaved((s) => s.filter((i) => i.id !== id));
    setItems((l) => [...l, it]);
  };
  const addRec = (r: CartItem) => {
    setItems((l) => (l.some((i) => i.id === r.id) ? l.map((i) => (i.id === r.id ? { ...i, qty: i.qty + 1 } : i)) : [...l, { ...r, qty: 1 }]));
    setRecAdded(r.id);
    window.setTimeout(() => setRecAdded((a) => (a === r.id ? null : a)), 1300);
  };
  const applyPromo = (e: React.FormEvent) => {
    e.preventDefault();
    const rule = promos.find((p) => p.code === code.trim().toUpperCase());
    if (!rule) {
      setPromoMsg({ ok: false, text: "That code isn't valid. Try SAVE10." });
      return;
    }
    if (rule.minSubtotal && subtotal < rule.minSubtotal) {
      setPromoMsg({ ok: false, text: `Spend ${fmt.format(rule.minSubtotal)} to use ${rule.code}.` });
      return;
    }
    setPromo(rule);
    setCode("");
    setPromoMsg({ ok: true, text: `${rule.label} applied` });
  };
  const checkout = () => {
    if (!items.length) return;
    setBusy(true);
    window.setTimeout(() => {
      setBusy(false);
      onCheckout?.({ items, subtotal, discount, shipping, tax, total, promo: promoValid ? promo.code : undefined, giftNote: gift ? note : undefined });
    }, 1200);
  };

  return (
    <section className={cn("w-full bg-background py-8 text-foreground sm:py-12", className)}>
      <div className="mx-auto max-w-6xl px-4 sm:px-6">
        <div className="flex items-baseline justify-between gap-4">
          <h2 className="text-2xl font-semibold tracking-tight sm:text-3xl">Your cart</h2>
          <p className="text-sm text-muted-foreground" aria-live="polite">
            {count} {count === 1 ? "item" : "items"}
          </p>
        </div>

        <div className="mt-6 grid gap-8 lg:grid-cols-[1fr_380px] lg:gap-10">
          <div className="min-w-0">
            {/* Free-shipping meter */}
            <div className="rounded-2xl border bg-card p-4">
              <p className="flex items-center gap-2 text-sm" aria-live="polite">
                {freeShip ? (
                  <>
                    <PartyPopper className="size-4 text-emerald-500" aria-hidden />
                    <span>
                      <span className="font-semibold">You&apos;ve unlocked free standard shipping!</span>
                    </span>
                  </>
                ) : (
                  <>
                    <Truck className="size-4 text-primary" aria-hidden />
                    <span>
                      Add <span className="font-semibold tabular-nums">{fmt.format(Math.max(0, freeShippingAt - subtotal + discount))}</span> more for free shipping
                    </span>
                  </>
                )}
              </p>
              <div className="relative mt-3 h-2 overflow-hidden rounded-full bg-muted" role="progressbar" aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round((freeShip ? 1 : progress) * 100)} aria-label="Progress to free shipping">
                <motion.div
                  className={cn("absolute inset-y-0 left-0 rounded-full", freeShip ? "bg-emerald-500" : "bg-gradient-to-r from-primary/70 to-primary")}
                  animate={{ width: `${(freeShip ? 1 : progress) * 100}%` }}
                  transition={{ type: "spring", stiffness: 120, damping: 20 }}
                />
              </div>
            </div>

            {/* Lines */}
            <LayoutGroup>
              <ul className="mt-4 divide-y rounded-2xl border bg-card">
                <AnimatePresence initial={false} mode="popLayout">
                  {items.map((it) => (
                    <motion.li
                      key={it.id}
                      layout={!reduce}
                      initial={{ opacity: 0, height: 0 }}
                      animate={{ opacity: 1, height: "auto" }}
                      exit={{ opacity: 0, x: reduce ? 0 : -40, transition: { duration: 0.2 } }}
                      className="overflow-hidden"
                    >
                      <div className="flex gap-4 p-4 sm:p-5">
                        <div className="relative size-20 shrink-0 overflow-hidden rounded-xl bg-muted/70 sm:size-24">
                          <div className="absolute inset-1.5">
                            <ProductArt kind={it.kind} color={it.hex} accent={it.accent} label={it.label} />
                          </div>
                        </div>
                        <div className="flex min-w-0 flex-1 flex-col">
                          <div className="flex items-start justify-between gap-3">
                            <div className="min-w-0">
                              <h3 className="truncate text-sm font-semibold sm:text-base">{it.name}</h3>
                              {it.variant && <p className="text-xs text-muted-foreground sm:text-sm">{it.variant}</p>}
                              <p className="mt-1 text-xs text-muted-foreground tabular-nums">
                                {fmt.format(it.price)} each
                                {it.compareAt && <s className="ml-1.5">{fmt.format(it.compareAt)}</s>}
                              </p>
                            </div>
                            <div className="text-right">
                              <AnimatedMoney value={it.price * it.qty} fmt={fmt} className="text-sm font-semibold sm:text-base" />
                              {it.compareAt && <p className="whitespace-nowrap text-[11px] font-medium text-emerald-600 dark:text-emerald-400">Save {fmt.format((it.compareAt - it.price) * it.qty)}</p>}
                            </div>
                          </div>
                          <div className="mt-auto flex flex-wrap items-center gap-x-4 gap-y-2 pt-3">
                            <div className="flex h-9 items-center rounded-lg border" role="group" aria-label={`Quantity for ${it.name}`}>
                              <button type="button" aria-label="Decrease" disabled={it.qty <= 1} onClick={() => setQty(it.id, it.qty - 1)} className={cn("grid h-full w-8 place-items-center rounded-l-lg hover:bg-muted disabled:opacity-40", ring)}>
                                <Minus className="size-3.5" />
                              </button>
                              <label className="sr-only" htmlFor={`qty-${it.id}`}>
                                Quantity
                              </label>
                              <input
                                id={`qty-${it.id}`}
                                inputMode="numeric"
                                value={it.qty}
                                onChange={(e) => {
                                  const n = parseInt(e.target.value.replace(/\D/g, ""), 10);
                                  if (!Number.isNaN(n)) setQty(it.id, n);
                                }}
                                className="h-full w-9 bg-transparent text-center text-sm font-semibold tabular-nums focus:outline-none"
                              />
                              <button type="button" aria-label="Increase" disabled={it.qty >= (it.maxQty ?? 12)} onClick={() => setQty(it.id, it.qty + 1)} className={cn("grid h-full w-8 place-items-center rounded-r-lg hover:bg-muted disabled:opacity-40", ring)}>
                                <Plus className="size-3.5" />
                              </button>
                            </div>
                            <button type="button" onClick={() => saveForLater(it.id)} className={cn("flex items-center gap-1.5 rounded text-xs font-medium text-muted-foreground hover:text-foreground", ring)}>
                              <Bookmark className="size-3.5" aria-hidden /> Save for later
                            </button>
                            <button type="button" onClick={() => remove(it.id)} className={cn("flex items-center gap-1.5 rounded text-xs font-medium text-muted-foreground hover:text-destructive", ring)} aria-label={`Remove ${it.name}`}>
                              <Trash2 className="size-3.5" aria-hidden /> Remove
                            </button>
                          </div>
                        </div>
                      </div>
                    </motion.li>
                  ))}
                </AnimatePresence>
                {items.length === 0 && (
                  <li className="flex flex-col items-center px-6 py-14 text-center">
                    <motion.span initial={{ scale: 0.6, rotate: -10 }} animate={{ scale: 1, rotate: 0 }} className="grid size-14 place-items-center rounded-2xl bg-muted">
                      <ShoppingCart className="size-6 text-muted-foreground" aria-hidden />
                    </motion.span>
                    <p className="mt-4 font-semibold">Your cart is empty</p>
                    <p className="mt-1 text-sm text-muted-foreground">Pick something from the suggestions below.</p>
                  </li>
                )}
              </ul>

              <AnimatePresence>
                {undo && (
                  <motion.div
                    role="status"
                    initial={{ opacity: 0, y: -8, height: 0 }}
                    animate={{ opacity: 1, y: 0, height: "auto" }}
                    exit={{ opacity: 0, height: 0 }}
                    className="overflow-hidden"
                  >
                    <div className="mt-3 flex items-center gap-3 rounded-xl bg-foreground px-4 py-3 text-sm text-background">
                      <Trash2 className="size-4 shrink-0 opacity-70" aria-hidden />
                      <span className="min-w-0 flex-1 truncate">
                        Removed <span className="font-medium">{undo.item.name}</span>
                      </span>
                      <button type="button" onClick={restore} className={cn("flex items-center gap-1.5 rounded-md px-2 py-1 font-semibold hover:bg-background/15", ring)}>
                        <Undo2 className="size-4" aria-hidden /> Undo
                      </button>
                    </div>
                  </motion.div>
                )}
              </AnimatePresence>

              {/* Saved */}
              {saved.length > 0 && (
                <motion.div layout={!reduce} className="mt-8">
                  <h3 className="text-sm font-semibold">Saved for later ({saved.length})</h3>
                  <ul className="mt-3 grid gap-3 sm:grid-cols-2">
                    <AnimatePresence initial={false} mode="popLayout">
                      {saved.map((it) => (
                        <motion.li key={it.id} layout={!reduce} initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.95 }} className="flex items-center gap-3 rounded-2xl border border-dashed p-3">
                          <span className="size-14 shrink-0 rounded-lg bg-muted/70 p-1">
                            <ProductArt kind={it.kind} color={it.hex} accent={it.accent} label={it.label} />
                          </span>
                          <div className="min-w-0 flex-1">
                            <p className="truncate text-sm font-medium">{it.name}</p>
                            <p className="text-xs text-muted-foreground tabular-nums">{fmt.format(it.price)}</p>
                          </div>
                          <button type="button" onClick={() => moveToCart(it.id)} className={cn("h-8 shrink-0 rounded-lg border px-3 text-xs font-medium hover:bg-muted", ring)}>
                            Move to cart
                          </button>
                          <button type="button" onClick={() => setSaved((s) => s.filter((x) => x.id !== it.id))} aria-label={`Delete ${it.name} from saved`} className={cn("grid size-8 shrink-0 place-items-center rounded-lg text-muted-foreground hover:bg-muted hover:text-foreground", ring)}>
                            <X className="size-4" />
                          </button>
                        </motion.li>
                      ))}
                    </AnimatePresence>
                  </ul>
                </motion.div>
              )}

              {/* Recs */}
              <motion.div layout={!reduce} className="mt-10">
                <div className="flex items-baseline justify-between">
                  <h3 className="text-sm font-semibold">Complete your order</h3>
                  <span className="text-xs text-muted-foreground">Swipe for more</span>
                </div>
                <ul className="-mx-4 mt-3 flex snap-x snap-mandatory scroll-px-4 gap-3 overflow-x-auto px-4 pb-2 [scrollbar-width:none] sm:mx-0 sm:scroll-px-0 sm:px-0">
                  {recommendations.map((r) => (
                    <li key={r.id} className="w-40 shrink-0 snap-start">
                      <div className="group relative aspect-square overflow-hidden rounded-2xl bg-muted/70">
                        <div className="absolute inset-3 transition duration-500 group-hover:scale-105">
                          <ProductArt kind={r.kind} color={r.hex} accent={r.accent} label={r.label} />
                        </div>
                        <button
                          type="button"
                          onClick={() => addRec(r)}
                          aria-label={`Add ${r.name} to cart`}
                          className={cn("absolute bottom-2 right-2 grid size-9 place-items-center rounded-full shadow-lg transition", recAdded === r.id ? "bg-emerald-600 text-white" : "bg-background text-foreground hover:scale-110", ring)}
                        >
                          <AnimatePresence mode="wait" initial={false}>
                            <motion.span key={String(recAdded === r.id)} initial={{ scale: 0, rotate: -90 }} animate={{ scale: 1, rotate: 0 }} exit={{ scale: 0 }}>
                              {recAdded === r.id ? <Check className="size-4" /> : <Plus className="size-4" />}
                            </motion.span>
                          </AnimatePresence>
                        </button>
                      </div>
                      <p className="mt-2 truncate text-sm font-medium">{r.name}</p>
                      <p className="text-xs text-muted-foreground tabular-nums">{fmt.format(r.price)}</p>
                    </li>
                  ))}
                </ul>
              </motion.div>
            </LayoutGroup>
          </div>

          {/* Summary */}
          <aside className="lg:sticky lg:top-6 lg:self-start" aria-label="Order summary">
            <div className="rounded-3xl border bg-card p-5 shadow-sm sm:p-6">
              <h3 className="text-lg font-semibold">Order summary</h3>

              <form onSubmit={applyPromo} className="mt-4" noValidate>
                <label htmlFor="cart-promo" className="text-sm font-medium">
                  Promo code
                </label>
                <div className="mt-1.5 flex gap-2">
                  <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="cart-promo"
                      value={code}
                      onChange={(e) => setCode(e.target.value)}
                      placeholder="SAVE10"
                      aria-describedby="cart-promo-msg"
                      className={cn("h-10 w-full rounded-lg border bg-background pl-9 pr-3 text-sm uppercase placeholder:normal-case", ring)}
                    />
                  </div>
                  <button disabled={!code.trim()} className={cn("h-10 rounded-lg bg-secondary px-4 text-sm font-medium text-secondary-foreground transition hover:bg-accent disabled:opacity-50", ring)}>Apply</button>
                </div>
                <div id="cart-promo-msg" aria-live="polite">
                  <AnimatePresence mode="wait">
                    {promoMsg && (
                      <motion.p key={promoMsg.text} initial={{ opacity: 0, y: -4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className={cn("mt-1.5 text-xs", promoMsg.ok ? "text-emerald-600 dark:text-emerald-400" : "text-destructive")}>
                        {promoMsg.text}
                      </motion.p>
                    )}
                  </AnimatePresence>
                </div>
                <AnimatePresence>
                  {promo && (
                    <motion.div initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} className="mt-2 inline-flex items-center gap-1.5 rounded-full border border-dashed border-emerald-500/50 bg-emerald-500/10 py-1 pl-3 pr-1.5 text-xs font-semibold text-emerald-700 dark:text-emerald-300">
                      <Tag className="size-3" aria-hidden /> {promo.code}
                      <button
                        type="button"
                        onClick={() => {
                          setPromo(null);
                          setPromoMsg(null);
                        }}
                        aria-label={`Remove code ${promo.code}`}
                        className={cn("grid size-5 place-items-center rounded-full hover:bg-emerald-500/20", ring)}
                      >
                        <X className="size-3" />
                      </button>
                    </motion.div>
                  )}
                </AnimatePresence>
              </form>

              <div className="mt-5">
                <label htmlFor="cart-zone" className="text-sm font-medium">
                  Delivery
                </label>
                <div className="relative mt-1.5">
                  <select id="cart-zone" value={zone} onChange={(e) => setZone(e.target.value)} className={cn("h-10 w-full appearance-none rounded-lg border bg-background pl-3 pr-9 text-sm", ring)}>
                    {zones.map((x) => (
                      <option key={x.id} value={x.id}>
                        {x.label} — {x.cost ? fmt.format(x.cost) : "Free"}
                      </option>
                    ))}
                  </select>
                  <ChevronDown className="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
                </div>
                <p className="mt-1.5 text-xs text-muted-foreground">Estimated arrival: {z.eta}</p>
              </div>

              <label className="mt-5 flex cursor-pointer items-center gap-2.5 text-sm">
                <input type="checkbox" checked={gift} onChange={(e) => setGift(e.target.checked)} className="size-4 rounded accent-[var(--primary)]" />
                <Gift className="size-4 text-muted-foreground" aria-hidden /> This is a gift (add a note)
              </label>
              <AnimatePresence initial={false}>
                {gift && (
                  <motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} className="overflow-hidden">
                    <label htmlFor="cart-note" className="sr-only">
                      Gift note
                    </label>
                    <textarea id="cart-note" value={note} onChange={(e) => setNote(e.target.value)} rows={2} maxLength={200} placeholder="Happy birthday! Enjoy…" className={cn("mt-2 w-full rounded-lg border bg-background px-3 py-2 text-sm", ring)} />
                  </motion.div>
                )}
              </AnimatePresence>

              <dl className="mt-5 space-y-2.5 border-t pt-5 text-sm">
                <div className="flex justify-between">
                  <dt className="text-muted-foreground">Subtotal</dt>
                  <dd>
                    <AnimatedMoney value={subtotal} fmt={fmt} />
                  </dd>
                </div>
                <AnimatePresence initial={false}>
                  {discount > 0 && (
                    <motion.div key="disc" initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} className="flex justify-between overflow-hidden text-emerald-600 dark:text-emerald-400">
                      <dt>Discount ({promo?.code})</dt>
                      <dd>
                        −<AnimatedMoney value={discount} fmt={fmt} />
                      </dd>
                    </motion.div>
                  )}
                </AnimatePresence>
                <div className="flex justify-between">
                  <dt className="text-muted-foreground">Shipping</dt>
                  <dd className={cn(shipping === 0 && items.length > 0 && "font-medium text-emerald-600 dark:text-emerald-400")}>{items.length === 0 ? "—" : shipping === 0 ? "Free" : <AnimatedMoney value={shipping} fmt={fmt} />}</dd>
                </div>
                <div className="flex justify-between text-xs">
                  <dt className="text-muted-foreground">Incl. VAT ({Math.round(taxRate * 100)}%)</dt>
                  <dd className="text-muted-foreground">
                    <AnimatedMoney value={tax} fmt={fmt} />
                  </dd>
                </div>
                <div className="flex items-baseline justify-between border-t pt-3">
                  <dt className="font-semibold">Total</dt>
                  <dd>
                    <AnimatedMoney value={total} fmt={fmt} className="text-2xl font-semibold tracking-tight" />
                  </dd>
                </div>
                {savings + discount > 0 && <p className="text-right text-xs font-medium text-emerald-600 dark:text-emerald-400">You&apos;re saving {fmt.format(savings + discount)}</p>}
              </dl>

              <motion.button
                type="button"
                onClick={checkout}
                disabled={!items.length || busy}
                whileTap={reduce ? undefined : { scale: 0.98 }}
                className={cn("group relative mt-5 flex h-12 w-full items-center justify-center gap-2 overflow-hidden rounded-xl bg-primary text-sm font-semibold text-primary-foreground transition hover:bg-primary/90 disabled:opacity-60", ring)}
              >
                {!reduce && (
                  <motion.span
                    aria-hidden
                    className="absolute inset-y-0 w-1/3 -skew-x-12 bg-gradient-to-r from-transparent via-white/25 to-transparent"
                    initial={{ left: "-40%" }}
                    animate={{ left: "140%" }}
                    transition={{ duration: 1.6, repeat: Infinity, repeatDelay: 2.5, ease: "easeInOut" }}
                  />
                )}
                {busy ? (
                  <span className="size-4 animate-spin rounded-full border-2 border-primary-foreground/40 border-t-primary-foreground" aria-hidden />
                ) : (
                  <Lock className="size-4" aria-hidden />
                )}
                {busy ? "Redirecting to secure checkout…" : "Secure checkout"}
                {!busy && <ArrowRight className="size-4 transition group-hover:translate-x-0.5" aria-hidden />}
              </motion.button>
              <div className="mt-4 flex items-center justify-center gap-2" aria-label="Accepted payment methods">
                {["Card", "Wallet", "Bank", "Later"].map((m, i) => (
                  <span key={m} className="flex h-7 items-center gap-1 rounded-md border bg-background px-2 text-[10px] font-semibold text-muted-foreground">
                    <span className="size-2.5 rounded-sm" style={{ background: ["#6366f1", "#10b981", "#f59e0b", "#ec4899"][i] }} aria-hidden />
                    {m}
                  </span>
                ))}
              </div>
              <p className="mt-3 flex items-center justify-center gap-1.5 text-center text-[11px] text-muted-foreground">
                <Lock className="size-3" aria-hidden /> 256-bit SSL encryption · 30-day returns
              </p>
            </div>
          </aside>
        </div>
      </div>
    </section>
  );
}

More in E-commerce

View all →