Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowRight, Check, ChevronLeft, ChevronRight, Copy, Gift, Minus, Plus, ShoppingBag, Sparkles, X, Zap } from "lucide-react";
import { cn } from "@/lib/utils";
import { ProductArt, type ArtKind } from "./product-art";

const ring = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background";
const money = (v: number, currency = "EUR", locale = "en-IE") => new Intl.NumberFormat(locale, { style: "currency", currency }).format(v);

export type PromoArt = { kind: ArtKind; hex: string; accent?: string; label?: string };

/* ================================================================== */
/* Announcement bar                                                    */
/* ================================================================== */

export type Announcement = { text: string; cta?: string; href?: string };
export interface AnnouncementBarProps {
  messages?: Announcement[];
  interval?: number;
  onDismiss?: () => void;
  className?: string;
}

export function AnnouncementBar({
  messages = [
    { text: "Free delivery on orders over €75", cta: "Shop now", href: "#" },
    { text: "Autumn tasting week — 20% off single malts", cta: "See deals", href: "#" },
    { text: "Order by 14:00 for next-day delivery", cta: "Details", href: "#" },
  ],
  interval = 4000,
  onDismiss,
  className,
}: AnnouncementBarProps) {
  const reduce = useReducedMotion();
  const [i, setI] = React.useState(0);
  const [dir, setDir] = React.useState(1);
  const [paused, setPaused] = React.useState(false);
  const [open, setOpen] = React.useState(true);

  React.useEffect(() => {
    if (paused || messages.length < 2) return;
    const t = window.setInterval(() => {
      setDir(1);
      setI((x) => (x + 1) % messages.length);
    }, interval);
    return () => window.clearInterval(t);
  }, [paused, interval, messages.length]);

  const go = (d: number) => {
    setDir(d);
    setI((x) => (x + d + messages.length) % messages.length);
  };
  const m = messages[i];

  return (
    <AnimatePresence initial={false}>
      {open && (
        <motion.div
          role="region"
          aria-label="Announcements"
          aria-roledescription="carousel"
          initial={{ height: 0 }}
          animate={{ height: "auto" }}
          exit={{ height: 0 }}
          onMouseEnter={() => setPaused(true)}
          onMouseLeave={() => setPaused(false)}
          onFocus={() => setPaused(true)}
          onBlur={() => setPaused(false)}
          className={cn("relative w-full overflow-hidden bg-gradient-to-r from-zinc-950 via-indigo-950 to-zinc-950 text-white", className)}
        >
          <div aria-hidden className="pointer-events-none absolute inset-0 bg-[radial-gradient(60%_120%_at_50%_0%,rgba(129,140,248,0.35),transparent)]" />
          <div className="relative mx-auto flex h-10 max-w-6xl items-center gap-2 px-2 sm:px-4">
            <button type="button" onClick={() => go(-1)} aria-label="Previous announcement" className={cn("grid size-7 shrink-0 place-items-center rounded-full text-white/70 hover:bg-white/10 hover:text-white", ring)}>
              <ChevronLeft className="size-4" />
            </button>
            <div className="relative h-full min-w-0 flex-1 overflow-hidden" aria-live={paused ? "polite" : "off"}>
              <AnimatePresence initial={false} custom={dir} mode="popLayout">
                <motion.p
                  key={i}
                  custom={dir}
                  initial={reduce ? { opacity: 0 } : { y: dir * 24, opacity: 0 }}
                  animate={{ y: 0, opacity: 1 }}
                  exit={reduce ? { opacity: 0 } : { y: dir * -24, opacity: 0 }}
                  transition={{ type: "spring", stiffness: 300, damping: 30 }}
                  className="absolute inset-0 flex items-center justify-center gap-2 truncate text-center text-xs font-medium sm:text-sm"
                >
                  <Sparkles className="hidden size-3.5 shrink-0 text-indigo-300 sm:block" aria-hidden />
                  <span className="truncate">{m.text}</span>
                  {m.cta && (
                    <a href={m.href ?? "#"} className={cn("hidden shrink-0 items-center gap-1 rounded font-semibold text-indigo-200 underline-offset-4 hover:underline sm:inline-flex", ring)}>
                      {m.cta} <ArrowRight className="size-3.5" aria-hidden />
                    </a>
                  )}
                </motion.p>
              </AnimatePresence>
            </div>
            <button type="button" onClick={() => go(1)} aria-label="Next announcement" className={cn("grid size-7 shrink-0 place-items-center rounded-full text-white/70 hover:bg-white/10 hover:text-white", ring)}>
              <ChevronRight className="size-4" />
            </button>
            <button
              type="button"
              onClick={() => {
                setOpen(false);
                onDismiss?.();
              }}
              aria-label="Dismiss announcements"
              className={cn("grid size-7 shrink-0 place-items-center rounded-full text-white/70 hover:bg-white/10 hover:text-white", ring)}
            >
              <X className="size-3.5" />
            </button>
          </div>
          <div className="absolute inset-x-0 bottom-0 flex justify-center gap-1 pb-1" aria-hidden>
            {messages.map((_, k) => (
              <span key={k} className={cn("h-0.5 rounded-full transition-all", k === i ? "w-4 bg-white/80" : "w-1.5 bg-white/25")} />
            ))}
          </div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

/* ================================================================== */
/* Countdown sale hero                                                 */
/* ================================================================== */

export interface CountdownHeroProps {
  eyebrow?: string;
  title?: string;
  subtitle?: string;
  /** Absolute end time. If omitted, `durationMs` from mount is used. */
  endsAt?: Date | string | number;
  durationMs?: number;
  cta?: string;
  onCta?: () => void;
  art?: PromoArt[];
  className?: string;
}

function Digit({ value }: { value: string }) {
  const reduce = useReducedMotion();
  return (
    <span className="relative inline-block h-[1.1em] w-[0.62em] overflow-hidden text-center">
      <AnimatePresence initial={false} mode="popLayout">
        <motion.span
          key={value}
          initial={reduce ? { opacity: 0 } : { y: "-100%", opacity: 0 }}
          animate={{ y: 0, opacity: 1 }}
          exit={reduce ? { opacity: 0 } : { y: "100%", opacity: 0 }}
          transition={{ type: "spring", stiffness: 400, damping: 32 }}
          className="absolute inset-0"
        >
          {value}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

export const CountdownHero = React.forwardRef<HTMLElement, CountdownHeroProps>(function CountdownHero(
  {
    eyebrow = "Flash sale · 48 hours only",
    title = "Up to 40% off the cellar",
    subtitle = "Rare single malts, grower champagnes and small-batch gins at the lowest prices of the year.",
    endsAt,
    durationMs = ((2 * 24 + 5) * 3600 + 14 * 60 + 9) * 1000,
    cta = "Shop the sale",
    onCta,
    art = [
      { kind: "bottle", hex: "#b45309", accent: "#f5efe0", label: "Lumen" },
      { kind: "wine", hex: "#ca8a04", accent: "#111827", label: "Orbit" },
      { kind: "bottle", hex: "#0ea5e9", accent: "#f0f9ff", label: "Gin" },
    ],
    className,
  },
  ref,
) {
  const reduce = useReducedMotion();
  const [left, setLeft] = React.useState<number | null>(null);
  React.useEffect(() => {
    const end = endsAt !== undefined ? new Date(endsAt).getTime() : Date.now() + durationMs;
    const tick = () => setLeft(Math.max(0, end - Date.now()));
    tick();
    const t = window.setInterval(tick, 1000);
    return () => window.clearInterval(t);
  }, [endsAt, durationMs]);

  const parts = left === null ? null : [Math.floor(left / 864e5), Math.floor(left / 36e5) % 24, Math.floor(left / 6e4) % 60, Math.floor(left / 1e3) % 60];
  const labels = ["Days", "Hours", "Min", "Sec"];

  return (
    <section ref={ref} className={cn("relative w-full overflow-hidden rounded-3xl bg-zinc-950 text-white", className)}>
      <div aria-hidden className="absolute inset-0 bg-[radial-gradient(70%_90%_at_85%_40%,rgba(244,63,94,0.35),transparent_60%),radial-gradient(60%_80%_at_10%_100%,rgba(99,102,241,0.4),transparent_60%)]" />
      <div aria-hidden className="absolute inset-0 opacity-[0.07] [background-image:linear-gradient(white_1px,transparent_1px),linear-gradient(90deg,white_1px,transparent_1px)] [background-size:32px_32px]" />
      <div className="relative grid items-center gap-8 p-6 sm:p-10 md:grid-cols-[1.2fr_1fr]">
        <div>
          <p className="inline-flex items-center gap-2 rounded-full border border-white/15 bg-white/5 px-3 py-1 text-xs font-semibold uppercase tracking-[0.16em] text-rose-200">
            <Zap className="size-3.5" aria-hidden /> {eyebrow}
          </p>
          <h2 className="mt-4 text-3xl font-semibold tracking-tight sm:text-5xl">{title}</h2>
          <p className="mt-3 max-w-md text-sm text-white/70 sm:text-base">{subtitle}</p>
          <div className="mt-6 flex gap-2 sm:gap-3" role="timer" aria-live="off" aria-label={parts ? `${parts[0]} days ${parts[1]} hours ${parts[2]} minutes left` : "Loading countdown"}>
            {labels.map((l, k) => {
              const v = parts ? String(parts[k]).padStart(2, "0") : "--";
              return (
                <div key={l} className="flex min-w-[64px] flex-col items-center rounded-2xl border border-white/10 bg-white/[0.06] px-2 py-3 backdrop-blur sm:min-w-[76px]">
                  <span className="font-mono text-3xl font-semibold tabular-nums sm:text-4xl" aria-hidden>
                    {v.split("").map((d, j) => (
                      <Digit key={j} value={d} />
                    ))}
                  </span>
                  <span className="mt-1 text-[10px] font-medium uppercase tracking-widest text-white/50">{l}</span>
                </div>
              );
            })}
          </div>
          <motion.button
            type="button"
            onClick={onCta}
            whileHover={reduce ? undefined : { scale: 1.03 }}
            whileTap={reduce ? undefined : { scale: 0.97 }}
            className={cn("mt-7 inline-flex h-12 items-center gap-2 rounded-full bg-white px-6 text-sm font-semibold text-zinc-950 shadow-[0_0_40px_-8px_rgba(244,63,94,0.7)]", ring, "focus-visible:ring-offset-zinc-950")}
          >
            {cta} <ArrowRight className="size-4" aria-hidden />
          </motion.button>
        </div>
        <div className="relative mx-auto h-56 w-full max-w-sm sm:h-72" aria-hidden>
          {art.slice(0, 3).map((a, k) => {
            const pos = [
              { left: "30%", top: "0%", rot: 0, scale: 1.05, z: 3 },
              { left: "0%", top: "14%", rot: -10, scale: 0.85, z: 2 },
              { left: "58%", top: "16%", rot: 10, scale: 0.85, z: 1 },
            ][k];
            return (
              <motion.div
                key={k}
                className="absolute h-full w-[44%]"
                style={{ left: pos.left, top: pos.top, zIndex: pos.z }}
                initial={reduce ? false : { y: 40, opacity: 0, rotate: 0 }}
                animate={reduce ? { rotate: pos.rot, scale: pos.scale } : { y: [0, -8, 0], opacity: 1, rotate: pos.rot, scale: pos.scale }}
                transition={reduce ? undefined : { y: { duration: 4 + k, repeat: Infinity, ease: "easeInOut", delay: k * 0.4 }, opacity: { duration: 0.6, delay: k * 0.12 }, rotate: { duration: 0.8 } }}
              >
                <ProductArt kind={a.kind} color={a.hex} accent={a.accent} label={a.label} />
              </motion.div>
            );
          })}
          <motion.span
            className="absolute right-0 top-0 z-10 grid size-20 place-items-center rounded-full bg-rose-500 text-center text-sm font-bold leading-tight text-white shadow-xl"
            initial={reduce ? false : { scale: 0, rotate: -30 }}
            animate={{ scale: 1, rotate: 12 }}
            transition={{ type: "spring", stiffness: 260, damping: 14, delay: 0.5 }}
          >
            -40%
            <br />
            <span className="text-[10px] font-medium">today</span>
          </motion.span>
        </div>
      </div>
    </section>
  );
});

/* ================================================================== */
/* Bundle deal card — buy 2 get 1                                      */
/* ================================================================== */

export type BundleOption = PromoArt & { id: string; name: string; price: number };
export interface BundleDealCardProps {
  title?: string;
  subtitle?: string;
  options?: BundleOption[];
  size?: number;
  currency?: string;
  onAddBundle?: (items: BundleOption[], total: number) => void;
  className?: string;
}

export function BundleDealCard({
  title = "Buy 2, get 1 free",
  subtitle = "Mix any three craft beers — the cheapest one is on us.",
  options = [
    { id: "ipa", name: "Hazy IPA", price: 4.2, kind: "can", hex: "#ca8a04", accent: "#fef3c7", label: "IPA" },
    { id: "stout", name: "Oat Stout", price: 4.8, kind: "can", hex: "#292524", accent: "#fbbf24", label: "Stout" },
    { id: "sour", name: "Cherry Sour", price: 5.1, kind: "can", hex: "#be123c", accent: "#fdf2f8", label: "Sour" },
    { id: "pils", name: "Pilsner", price: 3.6, kind: "can", hex: "#15803d", accent: "#fef08a", label: "Pils" },
    { id: "pale", name: "Pale Ale", price: 3.9, kind: "can", hex: "#ea580c", accent: "#fff7ed", label: "Pale" },
    { id: "wit", name: "Witbier", price: 4.0, kind: "can", hex: "#0ea5e9", accent: "#f0f9ff", label: "Wit" },
  ],
  size = 3,
  currency = "EUR",
  onAddBundle,
  className,
}: BundleDealCardProps) {
  const reduce = useReducedMotion();
  const [picked, setPicked] = React.useState<string[]>(["ipa", "stout"]);
  const [added, setAdded] = React.useState(false);
  const items = picked.map((id) => options.find((o) => o.id === id)).filter((o): o is BundleOption => !!o);
  const full = items.length === size;
  const subtotal = items.reduce((s, i) => s + i.price, 0);
  const free = full ? Math.min(...items.map((i) => i.price)) : 0;
  const freeIndex = full ? items.findIndex((i) => i.price === free) : -1;

  const toggle = (id: string) => {
    setAdded(false);
    setPicked((p) => {
      const idx = p.indexOf(id);
      if (idx >= 0) return p.filter((_, k) => k !== idx);
      if (p.length >= size) return p;
      return [...p, id];
    });
  };

  return (
    <div className={cn("w-full min-w-0 rounded-3xl border bg-card p-5 text-card-foreground sm:p-6", className)}>
      <div className="flex items-start justify-between gap-3">
        <div>
          <p className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/12 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-wider text-emerald-700 dark:text-emerald-300">
            <Gift className="size-3.5" aria-hidden /> Bundle deal
          </p>
          <h3 className="mt-2 text-xl font-semibold tracking-tight">{title}</h3>
          <p className="text-sm text-muted-foreground">{subtitle}</p>
        </div>
      </div>

      <ol className="mt-5 grid grid-cols-3 gap-2 sm:gap-3" aria-label="Your bundle">
        {Array.from({ length: size }, (_, k) => {
          const it = items[k];
          return (
            <li key={k} className={cn("relative aspect-[4/5] rounded-2xl border-2 border-dashed transition-colors", it ? "border-transparent bg-muted/70" : "border-border")}>
              <AnimatePresence mode="popLayout">
                {it ? (
                  <motion.button
                    key={it.id}
                    type="button"
                    onClick={() => toggle(it.id)}
                    aria-label={`Remove ${it.name} from bundle`}
                    initial={reduce ? false : { scale: 0.4, opacity: 0, y: 30 }}
                    animate={{ scale: 1, opacity: 1, y: 0 }}
                    exit={{ scale: 0.6, opacity: 0 }}
                    transition={{ type: "spring", stiffness: 420, damping: 24 }}
                    className={cn("absolute inset-0 rounded-2xl p-2", ring)}
                  >
                    <ProductArt kind={it.kind} color={it.hex} accent={it.accent} label={it.label} />
                    {k === freeIndex && (
                      <motion.span initial={{ scale: 0, rotate: -20 }} animate={{ scale: 1, rotate: -8 }} className="absolute -right-1 -top-1 rounded-full bg-emerald-500 px-2 py-0.5 text-[10px] font-bold uppercase text-white shadow">
                        Free
                      </motion.span>
                    )}
                    <span className="absolute bottom-1.5 right-1.5 grid size-5 place-items-center rounded-full bg-background/90 text-muted-foreground shadow-sm">
                      <X className="size-3" aria-hidden />
                    </span>
                  </motion.button>
                ) : (
                  <motion.span key="empty" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="absolute inset-0 grid place-items-center text-xs font-medium text-muted-foreground">
                    <span className="flex flex-col items-center gap-1">
                      <Plus className="size-4" aria-hidden />
                      Pick {k + 1}
                    </span>
                  </motion.span>
                )}
              </AnimatePresence>
            </li>
          );
        })}
      </ol>

      <div className="mt-4 flex flex-wrap gap-2" role="group" aria-label="Choose beers">
        {options.map((o) => {
          const on = picked.includes(o.id);
          return (
            <button
              key={o.id}
              type="button"
              aria-pressed={on}
              disabled={!on && full}
              onClick={() => toggle(o.id)}
              className={cn("flex shrink-0 items-center gap-2 rounded-full border py-1 pl-1 pr-3 text-xs font-medium transition disabled:opacity-40", on ? "border-foreground bg-foreground text-background" : "hover:bg-muted", ring)}
            >
              <span className="size-7 rounded-full bg-muted p-0.5">
                <ProductArt kind={o.kind} color={o.hex} accent={o.accent} />
              </span>
              {o.name}
              <span className={cn("tabular-nums", on ? "text-background/70" : "text-muted-foreground")}>{money(o.price, currency)}</span>
            </button>
          );
        })}
      </div>

      <div className="mt-5 flex items-end justify-between gap-4 border-t pt-4">
        <div aria-live="polite">
          {full ? (
            <>
              <p className="text-xs text-muted-foreground">
                <s className="tabular-nums">{money(subtotal, currency)}</s> · you save <span className="font-semibold text-emerald-600 dark:text-emerald-400">{money(free, currency)}</span>
              </p>
              <p className="text-2xl font-semibold tabular-nums">{money(subtotal - free, currency)}</p>
            </>
          ) : (
            <>
              <p className="text-xs text-muted-foreground">
                Add {size - items.length} more to unlock the free item
              </p>
              <div className="mt-2 flex gap-1">
                {Array.from({ length: size }, (_, k) => (
                  <motion.span key={k} className="h-1.5 w-8 rounded-full bg-muted" animate={{ backgroundColor: k < items.length ? "var(--primary)" : "var(--muted)" }} />
                ))}
              </div>
            </>
          )}
        </div>
        <button
          type="button"
          disabled={!full}
          onClick={() => {
            setAdded(true);
            onAddBundle?.(items, subtotal - free);
          }}
          className={cn("flex h-11 shrink-0 items-center gap-2 rounded-xl px-5 text-sm font-semibold transition disabled:cursor-not-allowed disabled:opacity-50", added ? "bg-emerald-600 text-white" : "bg-primary text-primary-foreground hover:bg-primary/90", ring)}
        >
          {added ? <Check className="size-4" aria-hidden /> : <ShoppingBag className="size-4" aria-hidden />}
          {added ? "Added" : "Add bundle"}
        </button>
      </div>
    </div>
  );
}

/* ================================================================== */
/* Exit-intent discount modal                                          */
/* ================================================================== */

export interface DiscountModalProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
  /** Also open when the pointer leaves through the top of the window (once per mount). */
  triggerOnExitIntent?: boolean;
  percent?: number;
  code?: string;
  title?: string;
  onSubscribe?: (email: string) => void;
}

export function DiscountModal({ open, onOpenChange, triggerOnExitIntent = false, percent = 15, code = "WELCOME15", title = "Wait — take this with you", onSubscribe }: DiscountModalProps) {
  const reduce = useReducedMotion();
  const ref = React.useRef<HTMLDivElement>(null);
  const [email, setEmail] = React.useState("");
  const [err, setErr] = React.useState("");
  const [revealed, setRevealed] = React.useState(false);
  const [copied, setCopied] = React.useState(false);
  const fired = React.useRef(false);

  React.useEffect(() => {
    if (!triggerOnExitIntent) return;
    const onOut = (e: MouseEvent) => {
      if (!fired.current && !e.relatedTarget && e.clientY <= 0) {
        fired.current = true;
        onOpenChange(true);
      }
    };
    document.addEventListener("mouseout", onOut);
    return () => document.removeEventListener("mouseout", onOut);
  }, [triggerOnExitIntent, onOpenChange]);

  React.useEffect(() => {
    if (!open) return;
    const prev = document.activeElement as HTMLElement | null;
    const el = ref.current;
    window.setTimeout(() => el?.querySelector<HTMLElement>("input, button[data-primary]")?.focus(), 50);
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") onOpenChange(false);
      if (e.key === "Tab" && el) {
        const f = [...el.querySelectorAll<HTMLElement>("button, input")];
        if (e.shiftKey && document.activeElement === f[0]) {
          e.preventDefault();
          f[f.length - 1]?.focus();
        } else if (!e.shiftKey && document.activeElement === f[f.length - 1]) {
          e.preventDefault();
          f[0]?.focus();
        }
      }
    };
    document.addEventListener("keydown", onKey);
    return () => {
      document.removeEventListener("keydown", onKey);
      prev?.focus();
    };
  }, [open, onOpenChange]);

  return (
    <AnimatePresence>
      {open && (
        <div className="fixed inset-0 z-[70] grid place-items-center p-4">
          <motion.div className="absolute inset-0 bg-black/60 backdrop-blur-sm" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => onOpenChange(false)} aria-hidden />
          <motion.div
            ref={ref}
            role="dialog"
            aria-modal="true"
            aria-labelledby="dm-title"
            initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.9, y: 20 }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: 10 }}
            transition={{ type: "spring", stiffness: 320, damping: 26 }}
            className="relative grid w-full max-w-2xl overflow-hidden rounded-3xl border bg-background shadow-2xl sm:grid-cols-[0.9fr_1fr]"
          >
            <div className="relative flex min-h-40 flex-col items-center justify-center overflow-hidden bg-gradient-to-br from-rose-500 via-fuchsia-600 to-indigo-700 p-6 text-white sm:min-h-0">
              <div aria-hidden className="absolute -left-10 -top-10 size-40 rounded-full bg-white/20 blur-2xl" />
              <div aria-hidden className="absolute -bottom-12 -right-6 size-44 rounded-full bg-amber-300/30 blur-2xl" />
              <motion.p initial={reduce ? false : { scale: 0.5, rotate: -8 }} animate={{ scale: 1, rotate: -4 }} transition={{ type: "spring", stiffness: 260, damping: 12, delay: 0.15 }} className="relative text-center">
                <span className="block text-6xl font-black tracking-tighter sm:text-7xl">{percent}%</span>
                <span className="block text-sm font-bold uppercase tracking-[0.3em]">off</span>
              </motion.p>
              <p className="relative mt-3 text-center text-xs text-white/80">your first order</p>
            </div>
            <div className="p-6 sm:p-8">
              <button type="button" onClick={() => onOpenChange(false)} aria-label="Close" className={cn("absolute right-3 top-3 grid size-8 place-items-center rounded-full bg-background/80 text-foreground hover:bg-muted", ring)}>
                <X className="size-4" />
              </button>
              <h3 id="dm-title" className="pr-6 text-xl font-semibold tracking-tight">
                {title}
              </h3>
              <AnimatePresence mode="wait" initial={false}>
                {!revealed ? (
                  <motion.form
                    key="form"
                    exit={{ opacity: 0, y: -8 }}
                    noValidate
                    onSubmit={(e) => {
                      e.preventDefault();
                      if (!/^\S+@\S+\.\S+$/.test(email)) {
                        setErr("Please enter a valid email.");
                        return;
                      }
                      setErr("");
                      onSubscribe?.(email);
                      setRevealed(true);
                    }}
                  >
                    <p className="mt-2 text-sm text-muted-foreground">Join the list for early access to drops and tastings. Unsubscribe anytime.</p>
                    <label htmlFor="dm-email" className="mt-5 block text-sm font-medium">
                      Email
                    </label>
                    <input id="dm-email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} aria-invalid={!!err} aria-describedby="dm-err" placeholder="[email protected]" className={cn("mt-1.5 h-11 w-full rounded-lg border bg-background px-3 text-sm aria-[invalid=true]:border-destructive", ring)} />
                    <p id="dm-err" className="mt-1 min-h-4 text-xs text-destructive" aria-live="polite">
                      {err}
                    </p>
                    <button type="submit" className={cn("mt-2 h-11 w-full rounded-xl bg-foreground text-sm font-semibold text-background hover:bg-foreground/85", ring)}>
                      Reveal my code
                    </button>
                    <button type="button" onClick={() => onOpenChange(false)} className={cn("mt-2 w-full rounded py-1 text-xs text-muted-foreground underline-offset-4 hover:underline", ring)}>
                      No thanks, I&apos;ll pay full price
                    </button>
                  </motion.form>
                ) : (
                  <motion.div key="code" initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }}>
                    <p className="mt-2 text-sm text-muted-foreground">Here&apos;s your code — it&apos;s also on its way to {email}.</p>
                    <div className="mt-5 flex items-center gap-2 rounded-xl border-2 border-dashed border-primary/50 bg-primary/5 p-2 pl-4">
                      <span className="flex-1 font-mono text-lg font-bold tracking-widest">{code}</span>
                      <button
                        type="button"
                        data-primary
                        onClick={() => {
                          void navigator.clipboard?.writeText(code).catch(() => undefined);
                          setCopied(true);
                          window.setTimeout(() => setCopied(false), 1600);
                        }}
                        className={cn("flex h-9 items-center gap-1.5 rounded-lg bg-primary px-3 text-xs font-semibold text-primary-foreground", ring)}
                      >
                        {copied ? <Check className="size-3.5" aria-hidden /> : <Copy className="size-3.5" aria-hidden />}
                        {copied ? "Copied" : "Copy"}
                      </button>
                    </div>
                    <button type="button" onClick={() => onOpenChange(false)} className={cn("mt-4 h-11 w-full rounded-xl bg-foreground text-sm font-semibold text-background", ring)}>
                      Continue shopping
                    </button>
                  </motion.div>
                )}
              </AnimatePresence>
            </div>
          </motion.div>
        </div>
      )}
    </AnimatePresence>
  );
}

/* ================================================================== */
/* Sticky add-to-cart bar                                              */
/* ================================================================== */

export interface StickyAddToCartBarProps {
  name?: string;
  price?: number;
  compareAt?: number;
  art?: PromoArt;
  variants?: string[];
  currency?: string;
  /** Show the bar once this element has scrolled out of view. */
  anchorRef?: React.RefObject<HTMLElement | null>;
  /** Force visibility (ignored when `anchorRef` is set). */
  visible?: boolean;
  onAdd?: (variant: string, qty: number) => void;
  className?: string;
}

export function StickyAddToCartBar({
  name = "Highland Single Malt 12",
  price = 39.9,
  compareAt = 49.9,
  art = { kind: "bottle", hex: "#b45309", accent: "#f5efe0", label: "Lumen" },
  variants = ["70 cl", "20 cl", "5 cl"],
  currency = "EUR",
  anchorRef,
  visible,
  onAdd,
  className,
}: StickyAddToCartBarProps) {
  const reduce = useReducedMotion();
  const [past, setPast] = React.useState(false);
  const [variant, setVariant] = React.useState(variants[0] ?? "");
  const [qty, setQty] = React.useState(1);
  const [added, setAdded] = React.useState(false);

  React.useEffect(() => {
    const el = anchorRef?.current;
    if (!el) return;
    const io = new IntersectionObserver(([e]) => setPast(e.boundingClientRect.top < 0 && e.intersectionRatio < 0.5), { threshold: [0, 0.25, 0.5, 0.75, 1] });
    io.observe(el);
    return () => io.disconnect();
  }, [anchorRef]);

  const show = anchorRef ? past : !!visible;

  return (
    <AnimatePresence>
      {show && (
        <motion.div
          role="region"
          aria-label="Quick add to cart"
          initial={reduce ? { opacity: 0 } : { y: "110%" }}
          animate={{ y: 0, opacity: 1 }}
          exit={reduce ? { opacity: 0 } : { y: "110%" }}
          transition={{ type: "spring", stiffness: 380, damping: 36 }}
          className={cn("fixed inset-x-0 bottom-0 z-40 border-t bg-background/85 shadow-[0_-12px_40px_-12px_rgba(0,0,0,0.25)] backdrop-blur-xl", className)}
        >
          <div className="mx-auto flex max-w-6xl items-center gap-3 px-4 py-3 sm:px-6">
            <span className="size-11 shrink-0 rounded-xl bg-muted p-0.5">
              <ProductArt kind={art.kind} color={art.hex} accent={art.accent} label={art.label} />
            </span>
            <div className="min-w-0 flex-1">
              <p className="truncate text-sm font-semibold">{name}</p>
              <p className="text-xs tabular-nums">
                <span className="font-semibold text-rose-600 dark:text-rose-400">{money(price * qty, currency)}</span>
                {compareAt && <s className="ml-1.5 text-muted-foreground">{money(compareAt * qty, currency)}</s>}
              </p>
            </div>
            {variants.length > 1 && (
              <label className="hidden sm:block">
                <span className="sr-only">Size</span>
                <select value={variant} onChange={(e) => setVariant(e.target.value)} className={cn("h-10 rounded-lg border bg-background px-3 text-sm", ring)}>
                  {variants.map((v) => (
                    <option key={v}>{v}</option>
                  ))}
                </select>
              </label>
            )}
            <div className="hidden h-10 items-center rounded-lg border sm:flex" role="group" aria-label="Quantity">
              <button type="button" aria-label="Decrease" disabled={qty <= 1} onClick={() => setQty((q) => q - 1)} className={cn("grid h-full w-8 place-items-center disabled:opacity-40", ring)}>
                <Minus className="size-3.5" />
              </button>
              <span className="w-6 text-center text-sm font-semibold tabular-nums">{qty}</span>
              <button type="button" aria-label="Increase" onClick={() => setQty((q) => Math.min(9, q + 1))} className={cn("grid h-full w-8 place-items-center", ring)}>
                <Plus className="size-3.5" />
              </button>
            </div>
            <button
              type="button"
              onClick={() => {
                onAdd?.(variant, qty);
                setAdded(true);
                window.setTimeout(() => setAdded(false), 1500);
              }}
              className={cn("flex h-10 shrink-0 items-center gap-2 rounded-lg px-4 text-sm font-semibold transition-colors", added ? "bg-emerald-600 text-white" : "bg-primary text-primary-foreground hover:bg-primary/90", ring)}
            >
              {added ? <Check className="size-4" aria-hidden /> : <ShoppingBag className="size-4" aria-hidden />}
              {added ? "Added" : "Add to cart"}
            </button>
          </div>
        </motion.div>
      )}
    </AnimatePresence>
  );
}

More in E-commerce

View all →