"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
BadgeCheck,
Check,
ChevronDown,
ChevronRight,
Heart,
Leaf,
Loader2,
ZoomIn,
Minus,
Plus,
RotateCcw,
ShieldCheck,
ShoppingBag,
Star,
Truck,
Zap,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { ProductArt, type ArtKind } from "./product-art";
export type PdpColor = { id: string; name: string; hex: string; accent?: string };
export type PdpSize = {
id: string;
label: string;
price: number;
compareAt?: number;
/** Units in stock. 0 = sold out. */
stock: number;
/** Quantity in `unitBase` units, used to compute the unit price (e.g. 0.7 for 700 ml per litre). */
unitQty?: number;
};
export type PdpView = { kind: ArtKind; tilt?: number; zoom?: number; label?: string; caption: string };
export type PdpDetail = { title: string; body: React.ReactNode };
export type PdpSelection = { color: PdpColor; size: PdpSize; qty: number };
export interface ProductDetailBlockProps {
brand?: string;
name?: string;
tagline?: string;
rating?: number;
reviewCount?: number;
breadcrumbs?: string[];
colors?: PdpColor[];
sizes?: PdpSize[];
views?: PdpView[];
details?: PdpDetail[];
colorLabel?: string;
sizeLabel?: string;
/** Unit used for the unit price, e.g. "l" or "kg". */
unitBase?: string;
currency?: string;
locale?: string;
/** Return delivery days for a postcode, or null when not deliverable. */
estimateDelivery?: (postcode: string) => { days: number; express: boolean } | null;
onAddToCart?: (sel: PdpSelection) => void;
onBuyNow?: (sel: PdpSelection) => void;
onWishlist?: (saved: boolean) => void;
className?: string;
}
const DEFAULT_COLORS: PdpColor[] = [
{ id: "sherry", name: "Sherry cask", hex: "#b45309", accent: "#f5efe0" },
{ id: "peat", name: "Peated", hex: "#57534e", accent: "#e7e5e4" },
{ id: "port", name: "Port finish", hex: "#9f1239", accent: "#fdf2f8" },
{ id: "rye", name: "Rye blend", hex: "#ca8a04", accent: "#1c1917" },
];
const DEFAULT_SIZES: PdpSize[] = [
{ id: "5", label: "5 cl", price: 6.9, stock: 40, unitQty: 0.05 },
{ id: "20", label: "20 cl", price: 17.5, stock: 3, unitQty: 0.2 },
{ id: "70", label: "70 cl", price: 39.9, compareAt: 49.9, stock: 18, unitQty: 0.7 },
{ id: "100", label: "1 L", price: 54, stock: 0, unitQty: 1 },
];
const DEFAULT_VIEWS: PdpView[] = [
{ kind: "bottle", caption: "Front", label: "Lumen" },
{ kind: "bottle", caption: "Angle", tilt: -9, zoom: 1.05, label: "Lumen" },
{ kind: "box", caption: "Gift box", label: "Lumen" },
{ kind: "tumbler", caption: "Serve", zoom: 1.08 },
];
const DEFAULT_DETAILS: PdpDetail[] = [
{
title: "Tasting notes",
body: "Nose of baked apple, orange peel and warm oak. Palate of toffee, cinnamon and a whisper of sea salt. Long, gently spiced finish.",
},
{
title: "Product details",
body: (
<dl className="grid grid-cols-2 gap-x-4 gap-y-1.5">
{[
["Region", "Highlands"],
["Age", "12 years"],
["ABV", "46%"],
["Cask", "First-fill"],
["Chill-filtered", "No"],
["Colouring", "Natural"],
].map(([k, v]) => (
<React.Fragment key={k}>
<dt className="text-muted-foreground">{k}</dt>
<dd className="font-medium text-foreground">{v}</dd>
</React.Fragment>
))}
</dl>
),
},
{
title: "Shipping & returns",
body: "Free standard delivery over €75. Unopened bottles can be returned within 30 days. Adult signature (18+) required on delivery.",
},
];
function defaultEstimate(postcode: string) {
const digits = postcode.replace(/\D/g, "");
if (!digits) return null;
const zone = Number(digits[0]);
if (zone === 9) return null;
return { days: 2 + (zone % 3), express: zone < 5 };
}
const focusRing = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background";
export function ProductDetailBlock({
brand = "Lumen Distillery",
name = "Highland Single Malt 12",
tagline = "Slow-matured in first-fill casks, bottled at cask strength without colouring.",
rating = 4.7,
reviewCount = 1284,
breadcrumbs = ["Shop", "Spirits", "Whisky"],
colors = DEFAULT_COLORS,
sizes = DEFAULT_SIZES,
views = DEFAULT_VIEWS,
details = DEFAULT_DETAILS,
colorLabel = "Cask finish",
sizeLabel = "Size",
unitBase = "l",
currency = "EUR",
locale = "en-IE",
estimateDelivery = defaultEstimate,
onAddToCart,
onBuyNow,
onWishlist,
className,
}: ProductDetailBlockProps) {
const reduce = useReducedMotion();
const fmt = React.useMemo(() => new Intl.NumberFormat(locale, { style: "currency", currency }), [locale, currency]);
const [view, setView] = React.useState(0);
const [colorId, setColorId] = React.useState(colors[0]?.id);
const firstInStock = sizes.find((s) => s.stock > 0) ?? sizes[0];
const [sizeId, setSizeId] = React.useState(sizes.find((s) => s.compareAt && s.stock > 0)?.id ?? firstInStock?.id);
const [qty, setQty] = React.useState(1);
const [saved, setSaved] = React.useState(false);
const [cartState, setCartState] = React.useState<"idle" | "loading" | "done">("idle");
const [open, setOpen] = React.useState(0);
const [notify, setNotify] = React.useState<string | null>(null);
const color = colors.find((c) => c.id === colorId) ?? colors[0];
const size = sizes.find((s) => s.id === sizeId) ?? sizes[0];
const off = size.compareAt && size.compareAt > size.price ? Math.round((1 - size.price / size.compareAt) * 100) : 0;
const soldOut = size.stock === 0;
const maxQty = Math.max(1, Math.min(10, size.stock));
const sel: PdpSelection = { color, size, qty };
const addToCart = () => {
if (soldOut || cartState !== "idle") return;
setCartState("loading");
window.setTimeout(() => {
setCartState("done");
onAddToCart?.(sel);
window.setTimeout(() => setCartState("idle"), 1800);
}, 650);
};
const onThumbKey = (e: React.KeyboardEvent) => {
if (e.key !== "ArrowRight" && e.key !== "ArrowLeft") return;
e.preventDefault();
const next = (view + (e.key === "ArrowRight" ? 1 : -1) + views.length) % views.length;
setView(next);
(e.currentTarget.parentElement?.children[next] as HTMLElement | undefined)?.focus();
};
return (
<section className={cn("w-full bg-background text-foreground", className)}>
<div className="mx-auto max-w-6xl px-4 py-6 sm:px-6 lg:py-10">
<nav aria-label="Breadcrumb" className="mb-5 flex items-center gap-1 text-xs text-muted-foreground">
{breadcrumbs.map((b) => (
<React.Fragment key={b}>
<a href="#" className={cn("rounded hover:text-foreground", focusRing)}>
{b}
</a>
<ChevronRight className="size-3" aria-hidden />
</React.Fragment>
))}
<span className="truncate text-foreground" aria-current="page">
{name}
</span>
</nav>
<div className="grid gap-8 lg:grid-cols-[1.1fr_1fr] lg:gap-12">
{/* Gallery */}
<div className="lg:sticky lg:top-6 lg:self-start">
<div className="flex flex-col-reverse gap-3 sm:flex-row">
<div role="tablist" aria-label="Product images" aria-orientation="vertical" className="flex gap-2 sm:flex-col">
{views.map((v, i) => (
<button
key={v.caption}
role="tab"
aria-selected={view === i}
aria-label={v.caption}
tabIndex={view === i ? 0 : -1}
onClick={() => setView(i)}
onKeyDown={onThumbKey}
className={cn(
"relative size-16 shrink-0 overflow-hidden rounded-xl border bg-muted/60 p-1 transition sm:size-[72px]",
view === i ? "border-foreground/60" : "border-transparent opacity-70 hover:opacity-100",
focusRing,
)}
>
<ProductArt kind={v.kind} tilt={v.tilt} zoom={v.zoom} color={color.hex} accent={color.accent} label={v.label} />
{view === i && <motion.span layoutId="pdp-thumb" className="absolute inset-0 rounded-xl ring-2 ring-foreground/70" />}
</button>
))}
</div>
<ZoomStage view={views[view]} color={color} reduce={!!reduce} badge={off ? `-${off}%` : undefined} viewIndex={view} />
</div>
</div>
{/* Buy box */}
<div className="min-w-0">
<p className="text-sm font-medium text-muted-foreground">{brand}</p>
<h1 className="mt-1 text-2xl font-semibold tracking-tight sm:text-3xl">{name}</h1>
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm">
<span className="flex items-center gap-0.5" aria-label={`Rated ${rating} out of 5`}>
{[0, 1, 2, 3, 4].map((i) => (
<Star key={i} className={cn("size-4", i < Math.round(rating) ? "fill-amber-400 text-amber-400" : "text-muted-foreground/40")} aria-hidden />
))}
</span>
<span className="font-medium">{rating.toFixed(1)}</span>
<a href="#reviews" className={cn("rounded text-muted-foreground underline-offset-4 hover:underline", focusRing)}>
{reviewCount.toLocaleString(locale)} reviews
</a>
</div>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">{tagline}</p>
{/* Price */}
<div className="mt-5 flex flex-wrap items-end gap-x-3 gap-y-1">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={size.id}
initial={reduce ? false : { y: 12, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={reduce ? undefined : { y: -12, opacity: 0 }}
className={cn("text-3xl font-semibold tabular-nums tracking-tight", off && "text-rose-600 dark:text-rose-400")}
>
{fmt.format(size.price * qty)}
</motion.span>
</AnimatePresence>
{off > 0 && (
<>
<s className="pb-1 text-base text-muted-foreground tabular-nums">{fmt.format(size.compareAt! * qty)}</s>
<span className="mb-1 rounded-full bg-rose-500/10 px-2 py-0.5 text-xs font-semibold text-rose-600 dark:text-rose-400">Save {fmt.format((size.compareAt! - size.price) * qty)}</span>
</>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground">
{size.unitQty ? `${fmt.format(size.price / size.unitQty)} / ${unitBase} · ` : ""}Incl. VAT
</p>
{/* Colour */}
<fieldset className="mt-6">
<legend className="text-sm">
<span className="font-medium">{colorLabel}:</span> <span className="text-muted-foreground">{color.name}</span>
</legend>
<div className="mt-2.5 flex flex-wrap gap-2.5">
{colors.map((c) => (
<label key={c.id} className="relative cursor-pointer">
<input type="radio" name="pdp-color" value={c.id} checked={c.id === color.id} onChange={() => setColorId(c.id)} className="peer sr-only" aria-label={c.name} />
<span
className="block size-9 rounded-full border border-black/10 shadow-inner transition peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background dark:border-white/15"
style={{ background: `linear-gradient(135deg, ${c.hex}, ${c.hex}cc 60%, ${c.accent ?? c.hex})` }}
/>
{c.id === color.id && (
<motion.span layoutId="pdp-swatch" className="pointer-events-none absolute -inset-1 rounded-full border-2 border-foreground" transition={{ type: "spring", stiffness: 500, damping: 34 }} />
)}
</label>
))}
</div>
</fieldset>
{/* Size */}
<fieldset className="mt-6">
<div className="flex items-center justify-between">
<legend className="text-sm font-medium">{sizeLabel}</legend>
{size.stock > 0 && size.stock <= 5 && (
<motion.span initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="flex items-center gap-1.5 text-xs font-medium text-amber-600 dark:text-amber-400">
<span className="relative flex size-2">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-amber-500 opacity-60" />
<span className="relative inline-flex size-2 rounded-full bg-amber-500" />
</span>
Only {size.stock} left
</motion.span>
)}
</div>
<div className="mt-2.5 grid grid-cols-4 gap-2">
{sizes.map((s) => {
const out = s.stock === 0;
const active = s.id === size.id;
return (
<label key={s.id} className={cn("relative", out ? "cursor-not-allowed" : "cursor-pointer")}>
<input type="radio" name="pdp-size" value={s.id} checked={active} onChange={() => {
setSizeId(s.id);
setQty((q) => Math.max(1, Math.min(q, s.stock)));
}} className="peer sr-only" aria-describedby={out ? `pdp-oos-${s.id}` : undefined} />
<span
className={cn(
"relative flex h-14 flex-col items-center justify-center overflow-hidden rounded-xl border text-sm font-medium transition peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background",
active ? "border-foreground bg-foreground text-background" : "hover:border-foreground/40",
out && !active && "text-muted-foreground",
)}
>
<span className={cn(out && "line-through decoration-1")}>{s.label}</span>
<span className={cn("text-[11px] font-normal tabular-nums", active ? "text-background/70" : "text-muted-foreground")}>{out ? "Sold out" : fmt.format(s.price)}</span>
{out && (
<svg aria-hidden className="absolute inset-0 size-full text-muted-foreground/30" preserveAspectRatio="none" viewBox="0 0 100 100">
<line x1="0" y1="100" x2="100" y2="0" stroke="currentColor" strokeWidth="1" vectorEffect="non-scaling-stroke" />
</svg>
)}
</span>
{out && (
<span id={`pdp-oos-${s.id}`} className="sr-only">
Out of stock
</span>
)}
</label>
);
})}
</div>
</fieldset>
{/* Actions */}
<AnimatePresence mode="wait" initial={false}>
{soldOut ? (
<motion.form
key="notify"
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
onSubmit={(e) => {
e.preventDefault();
const v = new FormData(e.currentTarget).get("email");
setNotify(typeof v === "string" && /\S+@\S+\.\S+/.test(v) ? "We'll email you when it's back." : "Enter a valid email.");
}}
noValidate
className="mt-6 rounded-2xl border border-dashed p-4"
>
<p className="text-sm font-medium">{size.label} is sold out</p>
<p className="text-xs text-muted-foreground">Get a one-time email when it's back in stock.</p>
<div className="mt-3 flex gap-2">
<label htmlFor="pdp-notify" className="sr-only">
Email
</label>
<input id="pdp-notify" name="email" type="email" placeholder="[email protected]" className={cn("h-10 min-w-0 flex-1 rounded-lg border bg-background px-3 text-sm", focusRing)} />
<button className={cn("h-10 rounded-lg bg-foreground px-4 text-sm font-medium text-background", focusRing)}>Notify me</button>
</div>
{notify && <p className="mt-2 text-xs text-muted-foreground" role="status">{notify}</p>}
</motion.form>
) : (
<motion.div key="buy" initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }} className="mt-6 space-y-3">
<div className="flex gap-3">
<div className="flex h-12 items-center rounded-xl border" role="group" aria-label="Quantity">
<button type="button" aria-label="Decrease quantity" disabled={qty <= 1} onClick={() => setQty((q) => Math.max(1, q - 1))} className={cn("grid h-full w-10 place-items-center rounded-l-xl transition hover:bg-muted disabled:opacity-40", focusRing)}>
<Minus className="size-4" />
</button>
<span className="w-8 text-center text-sm font-semibold tabular-nums" aria-live="polite">
{qty}
</span>
<button type="button" aria-label="Increase quantity" disabled={qty >= maxQty} onClick={() => setQty((q) => Math.min(maxQty, q + 1))} className={cn("grid h-full w-10 place-items-center rounded-r-xl transition hover:bg-muted disabled:opacity-40", focusRing)}>
<Plus className="size-4" />
</button>
</div>
<motion.button
type="button"
onClick={addToCart}
whileTap={reduce ? undefined : { scale: 0.98 }}
aria-live="polite"
className={cn(
"relative flex h-12 flex-1 items-center justify-center gap-2 overflow-hidden rounded-xl text-sm font-semibold transition-colors",
cartState === "done" ? "bg-emerald-600 text-white" : "bg-primary text-primary-foreground hover:bg-primary/90",
focusRing,
)}
>
<AnimatePresence mode="wait" initial={false}>
<motion.span key={cartState} initial={{ y: 16, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: -16, opacity: 0 }} className="flex items-center gap-2">
{cartState === "idle" && (
<>
<ShoppingBag className="size-4" /> Add to cart
</>
)}
{cartState === "loading" && (
<>
<Loader2 className="size-4 animate-spin" /> Adding…
</>
)}
{cartState === "done" && (
<>
<Check className="size-4" /> Added to cart
</>
)}
</motion.span>
</AnimatePresence>
</motion.button>
<button
type="button"
aria-pressed={saved}
aria-label={saved ? "Remove from wishlist" : "Add to wishlist"}
onClick={() => {
setSaved((s) => !s);
onWishlist?.(!saved);
}}
className={cn("grid size-12 shrink-0 place-items-center rounded-xl border transition hover:bg-muted", focusRing)}
>
<motion.span key={String(saved)} initial={reduce ? false : { scale: 0.6 }} animate={{ scale: 1 }} transition={{ type: "spring", stiffness: 500, damping: 15 }}>
<Heart className={cn("size-5", saved && "fill-rose-500 text-rose-500")} />
</motion.span>
</button>
</div>
<button
type="button"
onClick={() => onBuyNow?.(sel)}
className={cn("flex h-12 w-full items-center justify-center gap-2 rounded-xl border-2 border-foreground text-sm font-semibold transition hover:bg-foreground hover:text-background", focusRing)}
>
<Zap className="size-4" /> Buy now
</button>
</motion.div>
)}
</AnimatePresence>
<DeliveryEstimator estimate={estimateDelivery} locale={locale} />
{/* Accordion */}
<div className="mt-6 divide-y border-y">
{details.map((d, i) => {
const isOpen = open === i;
return (
<div key={d.title}>
<h3>
<button
type="button"
aria-expanded={isOpen}
aria-controls={`pdp-acc-${i}`}
id={`pdp-acc-btn-${i}`}
onClick={() => setOpen(isOpen ? -1 : i)}
className={cn("flex w-full items-center justify-between py-4 text-left text-sm font-medium", focusRing)}
>
{d.title}
<motion.span animate={{ rotate: isOpen ? 180 : 0 }}>
<ChevronDown className="size-4 text-muted-foreground" />
</motion.span>
</button>
</h3>
<AnimatePresence initial={false}>
{isOpen && (
<motion.div
id={`pdp-acc-${i}`}
role="region"
aria-labelledby={`pdp-acc-btn-${i}`}
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: reduce ? 0 : 0.28, ease: [0.4, 0, 0.2, 1] }}
className="overflow-hidden"
>
<div className="pb-4 text-sm leading-relaxed text-muted-foreground">{d.body}</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
{/* Trust */}
<ul className="mt-6 grid grid-cols-2 gap-3 text-xs">
{[
{ icon: RotateCcw, t: "30-day returns", s: "Unopened items" },
{ icon: ShieldCheck, t: "Secure payment", s: "3-D Secure & SSL" },
{ icon: BadgeCheck, t: "100% authentic", s: "Sourced direct" },
{ icon: Leaf, t: "Carbon-neutral", s: "Plastic-free packing" },
].map(({ icon: Icon, t, s }) => (
<li key={t} className="flex items-center gap-3 rounded-xl bg-muted/60 p-3">
<span className="grid size-9 shrink-0 place-items-center rounded-lg bg-background shadow-sm">
<Icon className="size-4" aria-hidden />
</span>
<span>
<span className="block font-medium">{t}</span>
<span className="text-muted-foreground">{s}</span>
</span>
</li>
))}
</ul>
</div>
</div>
</div>
</section>
);
}
/* ------------------------------------------------------------------ */
function ZoomStage({ view, color, reduce, badge, viewIndex }: { view: PdpView; color: PdpColor; reduce: boolean; badge?: string; viewIndex: number }) {
const ref = React.useRef<HTMLDivElement>(null);
const [lens, setLens] = React.useState<{ x: number; y: number; w: number; h: number } | null>(null);
const Z = 2.4;
const L = 150;
const onMove = (e: React.PointerEvent) => {
if (e.pointerType !== "mouse" || !ref.current) return;
const r = ref.current.getBoundingClientRect();
setLens({ x: e.clientX - r.left, y: e.clientY - r.top, w: r.width, h: r.height });
};
const art = <ProductArt kind={view.kind} tilt={view.tilt} zoom={view.zoom} color={color.hex} accent={color.accent} label={view.label} />;
return (
<div
ref={ref}
onPointerMove={onMove}
onPointerLeave={() => setLens(null)}
className="relative aspect-square min-w-0 flex-1 cursor-crosshair overflow-hidden rounded-3xl bg-gradient-to-br from-muted via-muted/60 to-background ring-1 ring-border"
>
<div aria-hidden className="absolute inset-x-10 bottom-8 top-1/3 rounded-full opacity-40 blur-3xl" style={{ background: color.hex }} />
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={`${viewIndex}-${color.id}`}
initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.94, x: 30 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, x: -30 }}
transition={{ type: "spring", stiffness: 260, damping: 28 }}
className="absolute inset-[8%]"
>
{art}
</motion.div>
</AnimatePresence>
{badge && <span className="absolute left-4 top-4 rounded-full bg-rose-600 px-2.5 py-1 text-xs font-semibold text-white shadow">{badge}</span>}
<span className="absolute right-4 top-4 rounded-full bg-background/80 px-2.5 py-1 text-[11px] font-medium text-muted-foreground backdrop-blur">
{view.caption}
</span>
<span className="pointer-events-none absolute bottom-4 left-1/2 hidden -translate-x-1/2 items-center gap-1.5 rounded-full bg-background/80 px-3 py-1 text-[11px] text-muted-foreground backdrop-blur sm:flex">
<ZoomIn className="size-3" aria-hidden /> Hover to zoom
</span>
<AnimatePresence>
{lens && (
<motion.div
aria-hidden
initial={{ opacity: 0, scale: 0.6 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.6 }}
transition={{ duration: 0.15 }}
className="pointer-events-none absolute overflow-hidden rounded-full border-2 border-background bg-muted shadow-2xl ring-1 ring-black/10"
style={{ width: L, height: L, left: lens.x - L / 2, top: lens.y - L / 2 }}
>
<div
className="absolute bg-gradient-to-br from-muted via-muted/60 to-background"
style={{
width: lens.w,
height: lens.h,
left: L / 2 - lens.x * Z,
top: L / 2 - lens.y * Z,
transform: `scale(${Z})`,
transformOrigin: "0 0",
}}
>
<div className="absolute inset-[8%]">{art}</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
function DeliveryEstimator({ estimate, locale }: { estimate: NonNullable<ProductDetailBlockProps["estimateDelivery"]>; locale: string }) {
const [code, setCode] = React.useState("");
const [state, setState] = React.useState<{ kind: "idle" } | { kind: "error"; msg: string } | { kind: "ok"; date: string; express?: string; code: string }>({ kind: "idle" });
const submit = (e: React.FormEvent) => {
e.preventDefault();
const v = code.trim();
if (!/^[A-Za-z0-9 -]{3,10}$/.test(v)) {
setState({ kind: "error", msg: "Enter a valid postcode." });
return;
}
const r = estimate(v);
if (!r) {
setState({ kind: "error", msg: `Sorry, we don't deliver to ${v} yet.` });
return;
}
const d = new Date();
d.setDate(d.getDate() + r.days);
const f = new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric", month: "short" });
const x = new Date();
x.setDate(x.getDate() + 1);
setState({ kind: "ok", date: f.format(d), express: r.express ? f.format(x) : undefined, code: v.toUpperCase() });
};
return (
<form onSubmit={submit} className="mt-6 rounded-2xl border bg-card p-4" noValidate>
<label htmlFor="pdp-postcode" className="flex items-center gap-2 text-sm font-medium">
<Truck className="size-4" aria-hidden /> Delivery estimate
</label>
<div className="mt-3 flex gap-2">
<input
id="pdp-postcode"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Postcode, e.g. 10-115"
aria-invalid={state.kind === "error"}
aria-describedby="pdp-postcode-msg"
className={cn("h-10 min-w-0 flex-1 rounded-lg border bg-background px-3 text-sm aria-[invalid=true]:border-destructive", focusRing)}
/>
<button className={cn("h-10 rounded-lg bg-secondary px-4 text-sm font-medium text-secondary-foreground transition hover:bg-accent", focusRing)}>Check</button>
</div>
<div id="pdp-postcode-msg" aria-live="polite">
<AnimatePresence mode="wait">
{state.kind === "error" && (
<motion.p key="err" initial={{ opacity: 0, y: -4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="mt-2 text-xs text-destructive">
{state.msg}
</motion.p>
)}
{state.kind === "ok" && (
<motion.ul key={state.code} initial={{ opacity: 0, height: 0 }} animate={{ opacity: 1, height: "auto" }} exit={{ opacity: 0, height: 0 }} className="mt-3 space-y-2 overflow-hidden text-sm">
<li className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">Standard to {state.code}</span>
<span className="font-medium">{state.date}</span>
</li>
{state.express && (
<li className="flex items-center justify-between gap-2">
<span className="flex items-center gap-1.5 text-muted-foreground">
<Zap className="size-3.5 text-amber-500" aria-hidden /> Express (order by 14:00)
</span>
<span className="font-medium text-emerald-600 dark:text-emerald-400">Tomorrow, {state.express}</span>
</li>
)}
</motion.ul>
)}
</AnimatePresence>
</div>
</form>
);
}