"use client";
import * as React from "react";
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
import { Check, ChevronLeft, ChevronRight, Eye, Minus, Plus, ShoppingBag, Star, Truck, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { ProductArt, type ArtKind } from "./product-art";
export type QvColor = { name: string; hex: string; accent?: string };
export type QvOption = { label: string; priceDelta?: number; soldOut?: boolean };
export type QvProduct = {
id: string;
name: string;
brand: string;
kind: ArtKind;
price: number;
compareAt?: number;
rating: number;
reviews: number;
blurb: string;
colors: QvColor[];
optionLabel?: string;
options?: QvOption[];
tag?: string;
};
export type QvAddPayload = { product: QvProduct; color: QvColor; option?: QvOption; qty: number; total: number };
export interface QuickViewModalProps {
title?: string;
eyebrow?: string;
products?: QvProduct[];
currency?: string;
locale?: string;
onAddToCart?: (payload: QvAddPayload) => void;
onViewDetails?: (p: QvProduct) => void;
className?: string;
}
const DEFAULT_PRODUCTS: QvProduct[] = [
{
id: "h1",
name: "Aura Studio Headphones",
brand: "Orbit Audio",
kind: "headphones",
price: 249,
compareAt: 299,
rating: 4.8,
reviews: 1240,
blurb: "Adaptive noise cancelling, 40-hour battery and plush memory-foam cushions tuned for long listening sessions.",
colors: [
{ name: "Graphite", hex: "#334155", accent: "#d6d3d1" },
{ name: "Sand", hex: "#a8a29e", accent: "#fafaf9" },
{ name: "Ocean", hex: "#1d4ed8", accent: "#bfdbfe" },
],
optionLabel: "Edition",
options: [{ label: "Standard" }, { label: "Pro · lossless", priceDelta: 50 }],
tag: "Bestseller",
},
{
id: "w1",
name: "Pulse Field Watch",
brand: "Northwind",
kind: "watch",
price: 189,
rating: 4.6,
reviews: 532,
blurb: "Sapphire glass, 10 ATM water resistance and a week of battery. Tracks sleep, heart rate and 60+ sports.",
colors: [
{ name: "Lime", hex: "#475569", accent: "#a3e635" },
{ name: "Coral", hex: "#1f2937", accent: "#fb7185" },
{ name: "Ivory", hex: "#78716c", accent: "#f5f5f4" },
],
optionLabel: "Case",
options: [{ label: "40 mm" }, { label: "44 mm", priceDelta: 20 }, { label: "48 mm", soldOut: true }],
tag: "New",
},
{
id: "s1",
name: "Drift Portable Speaker",
brand: "Lumen",
kind: "speaker",
price: 129,
rating: 4.7,
reviews: 890,
blurb: "360° sound, IP67 dust and waterproof, and it floats. Pair two for stereo in one tap.",
colors: [
{ name: "Lagoon", hex: "#0f766e", accent: "#fde68a" },
{ name: "Ember", hex: "#c2410c", accent: "#fef3c7" },
{ name: "Night", hex: "#1e293b", accent: "#94a3b8" },
],
},
{
id: "k1",
name: "Stride Runner 2",
brand: "Acme",
kind: "sneaker",
price: 139,
compareAt: 159,
rating: 4.5,
reviews: 2210,
blurb: "Responsive foam midsole, breathable knit upper and a grippy recycled-rubber outsole.",
colors: [
{ name: "Crimson", hex: "#e11d48", accent: "#f8fafc" },
{ name: "Volt", hex: "#65a30d", accent: "#1c1917" },
{ name: "Slate", hex: "#475569", accent: "#f1f5f9" },
],
optionLabel: "Size (EU)",
options: [{ label: "40" }, { label: "41" }, { label: "42", soldOut: true }, { label: "43" }, { label: "44" }],
},
{
id: "b1",
name: "Transit Daypack 22L",
brand: "Orbit",
kind: "backpack",
price: 95,
rating: 4.9,
reviews: 318,
blurb: "Weatherproof shell, padded 16″ laptop sleeve and a luggage pass-through for travel days.",
colors: [
{ name: "Navy", hex: "#1e3a8a", accent: "#fb923c" },
{ name: "Olive", hex: "#4d7c0f", accent: "#fef08a" },
{ name: "Black", hex: "#27272a", accent: "#a1a1aa" },
],
tag: "Eco",
},
{
id: "m1",
name: "Morning Ceramic Mug",
brand: "Lumen Home",
kind: "mug",
price: 24,
rating: 4.4,
reviews: 146,
blurb: "Hand-glazed stoneware, 350 ml. Dishwasher and microwave safe — and it keeps coffee warm longer.",
colors: [
{ name: "Tangerine", hex: "#f97316", accent: "#fff7ed" },
{ name: "Sage", hex: "#65a30d", accent: "#f7fee7" },
{ name: "Plum", hex: "#7e22ce", accent: "#faf5ff" },
],
optionLabel: "Pack",
options: [{ label: "Single" }, { label: "Pair", priceDelta: 20 }],
},
];
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 VIEWS = [
{ tilt: 0, zoom: 1 },
{ tilt: -12, zoom: 1.08 },
{ tilt: 8, zoom: 1.35 },
];
export function QuickViewModal({
title = "New in tech & lifestyle",
eyebrow = "Just landed",
products = DEFAULT_PRODUCTS,
currency = "EUR",
locale = "en-IE",
onAddToCart,
onViewDetails,
className,
}: QuickViewModalProps) {
const fmt = React.useMemo(() => new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 0 }), [locale, currency]);
const [openId, setOpenId] = React.useState<string | null>(null);
const [cardColor, setCardColor] = React.useState<Record<string, number>>({});
const [toast, setToast] = React.useState<string | null>(null);
const triggers = React.useRef<Record<string, HTMLButtonElement | null>>({});
const active = products.find((p) => p.id === openId) ?? null;
const close = React.useCallback(() => {
const id = openId;
setOpenId(null);
if (id) window.setTimeout(() => triggers.current[id]?.focus(), 0);
}, [openId]);
const step = (dir: 1 | -1) => {
if (!active) return;
const i = products.findIndex((p) => p.id === active.id);
setOpenId(products[(i + dir + products.length) % products.length].id);
};
React.useEffect(() => {
if (!toast) return;
const t = window.setTimeout(() => setToast(null), 2600);
return () => window.clearTimeout(t);
}, [toast]);
return (
<section className={cn("relative w-full bg-background py-10 text-foreground sm:py-14", className)}>
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<div className="mb-8 flex items-end justify-between gap-4">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-primary">{eyebrow}</p>
<h2 className="mt-2 text-2xl font-semibold tracking-tight sm:text-3xl">{title}</h2>
</div>
<a href="#" onClick={(e) => e.preventDefault()} className={cn("hidden rounded text-sm font-medium underline-offset-4 hover:underline sm:block", ring)}>
Shop all
</a>
</div>
<LayoutGroup>
<ul className="grid grid-cols-2 gap-x-3 gap-y-8 sm:gap-x-5 lg:grid-cols-3">
{products.map((p) => {
const ci = cardColor[p.id] ?? 0;
const c = p.colors[ci];
const isOpen = openId === p.id;
return (
<li key={p.id} className="group">
<div className="relative aspect-square overflow-hidden rounded-3xl bg-muted/70 ring-1 ring-border/60">
{!isOpen ? (
<motion.div layoutId={`qv-art-${p.id}`} className="absolute inset-[12%] transition-transform duration-500 group-hover:scale-105" transition={{ type: "spring", stiffness: 300, damping: 32 }}>
<ProductArt kind={p.kind} color={c.hex} accent={c.accent} label={p.brand.split(" ")[0]} />
</motion.div>
) : (
<div className="absolute inset-[12%]" />
)}
{p.tag && <span className="absolute left-3 top-3 rounded-full bg-background/90 px-2.5 py-1 text-[11px] font-semibold shadow-sm backdrop-blur">{p.tag}</span>}
<button
ref={(el) => {
triggers.current[p.id] = el;
}}
type="button"
onClick={() => setOpenId(p.id)}
aria-haspopup="dialog"
aria-label={`Quick view ${p.name}`}
className={cn(
"absolute bottom-3 left-1/2 flex h-10 -translate-x-1/2 items-center gap-2 whitespace-nowrap rounded-full bg-foreground px-4 text-sm font-medium text-background shadow-xl transition duration-300",
"sm:translate-y-4 sm:opacity-0 sm:group-hover:translate-y-0 sm:group-hover:opacity-100 sm:focus-visible:translate-y-0 sm:focus-visible:opacity-100",
ring,
)}
>
<Eye className="size-4" aria-hidden /> <span className="hidden sm:inline">Quick view</span>
<span className="sm:hidden">View</span>
</button>
</div>
<div className="mt-3 flex items-start justify-between gap-2 px-0.5">
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{p.brand}</p>
<h3 className="truncate text-sm font-medium">{p.name}</h3>
</div>
<p className="shrink-0 text-right text-sm font-semibold tabular-nums">
{fmt.format(p.price)}
{p.compareAt && <s className="block text-xs font-normal text-muted-foreground">{fmt.format(p.compareAt)}</s>}
</p>
</div>
<div className="mt-2 flex gap-1.5 px-0.5" role="radiogroup" aria-label={`${p.name} colour`}>
{p.colors.map((col, i) => (
<button
key={col.name}
type="button"
role="radio"
aria-checked={ci === i}
aria-label={col.name}
onClick={() => setCardColor((s) => ({ ...s, [p.id]: i }))}
className={cn("size-4 rounded-full ring-offset-2 ring-offset-background transition", ci === i ? "ring-2 ring-foreground" : "ring-1 ring-border", ring)}
style={{ background: `linear-gradient(135deg, ${col.hex} 50%, ${col.accent ?? col.hex} 50%)` }}
/>
))}
</div>
</li>
);
})}
</ul>
<AnimatePresence>
{active && (
<Dialog
key="dialog"
product={active}
initialColor={cardColor[active.id] ?? 0}
fmt={fmt}
onClose={close}
onStep={step}
onViewDetails={onViewDetails}
onAdd={(payload) => {
onAddToCart?.(payload);
setCardColor((s) => ({ ...s, [payload.product.id]: payload.product.colors.indexOf(payload.color) }));
setToast(`${payload.qty} × ${payload.product.name} added to cart`);
}}
/>
)}
</AnimatePresence>
</LayoutGroup>
</div>
<div className="pointer-events-none fixed inset-x-0 bottom-4 z-[60] flex justify-center px-4" aria-live="polite">
<AnimatePresence>
{toast && (
<motion.div
initial={{ y: 40, opacity: 0, scale: 0.95 }}
animate={{ y: 0, opacity: 1, scale: 1 }}
exit={{ y: 20, opacity: 0 }}
className="pointer-events-auto flex items-center gap-3 rounded-2xl bg-foreground px-4 py-3 text-sm text-background shadow-2xl"
>
<span className="grid size-6 place-items-center rounded-full bg-emerald-500 text-white">
<Check className="size-3.5" aria-hidden />
</span>
{toast}
</motion.div>
)}
</AnimatePresence>
</div>
</section>
);
}
function Dialog({
product: p,
initialColor,
fmt,
onClose,
onStep,
onAdd,
onViewDetails,
}: {
product: QvProduct;
initialColor: number;
fmt: Intl.NumberFormat;
onClose: () => void;
onStep: (d: 1 | -1) => void;
onAdd: (payload: QvAddPayload) => void;
onViewDetails?: (p: QvProduct) => void;
}) {
const reduce = useReducedMotion();
const ref = React.useRef<HTMLDivElement>(null);
const [color, setColor] = React.useState(initialColor);
const [view, setView] = React.useState(0);
const [opt, setOpt] = React.useState(() => Math.max(0, p.options?.findIndex((o) => !o.soldOut) ?? 0));
const [qty, setQty] = React.useState(1);
const [state, setState] = React.useState<"idle" | "done">("idle");
const [prevId, setPrevId] = React.useState(p.id);
if (prevId !== p.id) {
setPrevId(p.id);
setColor(initialColor);
setView(0);
setOpt(Math.max(0, p.options?.findIndex((o) => !o.soldOut) ?? 0));
setQty(1);
setState("idle");
}
const c = p.colors[color] ?? p.colors[0];
const option = p.options?.[opt];
const unit = p.price + (option?.priceDelta ?? 0);
const titleId = `qv-title-${p.id}`;
React.useEffect(() => {
const el = ref.current;
el?.querySelector<HTMLElement>("[data-autofocus]")?.focus();
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = prev;
};
}, []);
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const el = ref.current;
if (e.key === "Escape") {
e.preventDefault();
onClose();
}
if ((e.key === "ArrowRight" || e.key === "ArrowLeft") && e.altKey) {
onStep(e.key === "ArrowRight" ? 1 : -1);
}
if (e.key === "Tab" && el) {
const f = [...el.querySelectorAll<HTMLElement>("button:not([disabled]), input, a[href]")];
if (!f.length) return;
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);
}, [onClose, onStep]);
const add = () => {
if (option?.soldOut) return;
setState("done");
onAdd({ product: p, color: c, option, qty, total: unit * qty });
window.setTimeout(() => setState("idle"), 1600);
};
return (
<div className="fixed inset-0 z-50 flex items-end justify-center sm:items-center sm:p-6">
<motion.div className="absolute inset-0 bg-black/55 backdrop-blur-sm" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={onClose} aria-hidden />
<motion.div
ref={ref}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 40, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={reduce ? { opacity: 0 } : { opacity: 0, y: 30, scale: 0.97 }}
transition={{ type: "spring", stiffness: 340, damping: 32 }}
className="relative grid max-h-[92dvh] w-full max-w-4xl overflow-y-auto rounded-t-3xl border bg-background shadow-2xl sm:grid-cols-2 sm:rounded-3xl"
>
{/* Gallery */}
<div className="relative bg-muted/60 p-4 sm:p-6">
<div className="relative mx-auto aspect-square w-full max-w-[250px] sm:max-w-none">
<div aria-hidden className="absolute inset-[18%] rounded-full opacity-40 blur-3xl transition-colors duration-500" style={{ background: c.hex }} />
<motion.div layoutId={`qv-art-${p.id}`} className="absolute inset-[6%]" transition={{ type: "spring", stiffness: 300, damping: 32 }}>
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={`${view}-${color}`}
className="absolute inset-0"
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.04 }}
transition={{ duration: 0.3 }}
>
<ProductArt kind={p.kind} color={c.hex} accent={c.accent} label={p.brand.split(" ")[0]} tilt={VIEWS[view].tilt} zoom={VIEWS[view].zoom} />
</motion.div>
</AnimatePresence>
</motion.div>
</div>
<div className="mt-3 flex justify-center gap-2" role="tablist" aria-label="Images">
{VIEWS.map((v, i) => (
<button
key={i}
type="button"
role="tab"
aria-selected={view === i}
aria-label={`Image ${i + 1}`}
onClick={() => setView(i)}
className={cn("size-14 rounded-xl border bg-background/70 p-1 transition", view === i ? "border-foreground" : "border-transparent opacity-60 hover:opacity-100", ring)}
>
<ProductArt kind={p.kind} color={c.hex} accent={c.accent} tilt={v.tilt} zoom={v.zoom} />
</button>
))}
</div>
<div className="absolute left-3 top-3 flex gap-1.5 sm:left-4 sm:top-4">
<button type="button" onClick={() => onStep(-1)} aria-label="Previous product" className={cn("grid size-9 place-items-center rounded-full bg-background/80 shadow-sm backdrop-blur hover:bg-background", ring)}>
<ChevronLeft className="size-4" />
</button>
<button type="button" onClick={() => onStep(1)} aria-label="Next product" className={cn("grid size-9 place-items-center rounded-full bg-background/80 shadow-sm backdrop-blur hover:bg-background", ring)}>
<ChevronRight className="size-4" />
</button>
</div>
</div>
{/* Details */}
<div className="flex flex-col p-5 sm:p-7">
<button type="button" data-autofocus onClick={onClose} aria-label="Close quick view" className={cn("absolute right-3 top-3 grid size-9 place-items-center rounded-full bg-background/80 hover:bg-muted sm:right-4 sm:top-4", ring)}>
<X className="size-5" />
</button>
<p className="text-sm text-muted-foreground">{p.brand}</p>
<h2 id={titleId} className="mt-1 pr-8 text-xl font-semibold tracking-tight sm:text-2xl">
{p.name}
</h2>
<div className="mt-2 flex items-center gap-2 text-sm">
<span className="flex" aria-label={`${p.rating} out of 5 stars`}>
{[0, 1, 2, 3, 4].map((i) => (
<Star key={i} className={cn("size-4", i < Math.round(p.rating) ? "fill-amber-400 text-amber-400" : "text-muted-foreground/40")} aria-hidden />
))}
</span>
<span className="text-muted-foreground">
{p.rating} · {p.reviews.toLocaleString()} reviews
</span>
</div>
<div className="mt-4 flex items-baseline gap-2">
<motion.span key={unit} initial={reduce ? false : { opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} className="text-2xl font-semibold tabular-nums">
{fmt.format(unit)}
</motion.span>
{p.compareAt && <s className="text-sm text-muted-foreground">{fmt.format(p.compareAt + (option?.priceDelta ?? 0))}</s>}
</div>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">{p.blurb}</p>
<fieldset className="mt-5">
<legend className="text-sm">
<span className="font-medium">Colour:</span> <span className="text-muted-foreground">{c.name}</span>
</legend>
<div className="mt-2 flex gap-2.5">
{p.colors.map((col, i) => (
<label key={col.name} className="relative cursor-pointer">
<input type="radio" name={`qv-color-${p.id}`} className="peer sr-only" checked={color === i} onChange={() => setColor(i)} aria-label={col.name} />
<span className="block size-8 rounded-full border border-black/10 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, ${col.hex} 50%, ${col.accent ?? col.hex} 50%)` }} />
{color === i && <motion.span layoutId="qv-swatch-ring" className="pointer-events-none absolute -inset-1 rounded-full border-2 border-foreground" />}
</label>
))}
</div>
</fieldset>
{p.options && (
<fieldset className="mt-5">
<legend className="text-sm font-medium">{p.optionLabel ?? "Option"}</legend>
<div className="mt-2 flex flex-wrap gap-2">
{p.options.map((o, i) => (
<label key={o.label} className={cn("relative", o.soldOut ? "cursor-not-allowed" : "cursor-pointer")}>
<input type="radio" name={`qv-opt-${p.id}`} className="peer sr-only" checked={opt === i} disabled={o.soldOut} onChange={() => setOpt(i)} />
<span
className={cn(
"flex h-10 min-w-12 items-center justify-center rounded-xl border px-3 text-sm transition peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background",
opt === i ? "border-foreground bg-foreground text-background" : "hover:border-foreground/40",
o.soldOut && "border-dashed text-muted-foreground line-through",
)}
>
{o.label}
{o.priceDelta ? <span className={cn("ml-1.5 text-xs", opt === i ? "text-background/70" : "text-muted-foreground")}>+{fmt.format(o.priceDelta)}</span> : null}
</span>
</label>
))}
</div>
</fieldset>
)}
<div className="mt-6 flex gap-3 sm:mt-auto sm:pt-6">
<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) => q - 1)} className={cn("grid h-full w-10 place-items-center rounded-l-xl hover:bg-muted disabled:opacity-40", ring)}>
<Minus className="size-4" />
</button>
<span className="w-7 text-center text-sm font-semibold tabular-nums">{qty}</span>
<button type="button" aria-label="Increase quantity" disabled={qty >= 9} onClick={() => setQty((q) => q + 1)} className={cn("grid h-full w-10 place-items-center rounded-r-xl hover:bg-muted disabled:opacity-40", ring)}>
<Plus className="size-4" />
</button>
</div>
<button
type="button"
onClick={add}
className={cn(
"relative flex h-12 flex-1 items-center justify-center gap-2 overflow-hidden rounded-xl text-sm font-semibold transition-colors",
state === "done" ? "bg-emerald-600 text-white" : "bg-primary text-primary-foreground hover:bg-primary/90",
ring,
)}
>
<AnimatePresence mode="wait" initial={false}>
<motion.span key={state} initial={{ y: 14, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: -14, opacity: 0 }} className="flex items-center gap-2">
{state === "done" ? <Check className="size-4" /> : <ShoppingBag className="size-4" />}
{state === "done" ? "Added" : `Add · ${fmt.format(unit * qty)}`}
</motion.span>
</AnimatePresence>
</button>
</div>
<div className="mt-4 flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1.5">
<Truck className="size-3.5" aria-hidden /> Free delivery in 2–3 days
</span>
<button type="button" onClick={() => onViewDetails?.(p)} className={cn("rounded font-medium text-foreground underline underline-offset-4", ring)}>
View full details
</button>
</div>
</div>
</motion.div>
</div>
);
}