Fazekit

Code

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

More in E-commerce

View all →