"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'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's your code — it'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>
);
}