"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Award, Check, Minus, Plus, ShoppingBag, Star, Trophy, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { ProductArt, type ArtKind } from "./product-art";
export type SpecValue = string | number | boolean;
export type CompareProduct = {
id: string;
name: string;
brand: string;
kind: ArtKind;
hex: string;
accent?: string;
price: number;
rating: number;
specs: Record<string, SpecValue>;
};
export type SpecRow = { key: string; label: string; unit?: string; better?: "higher" | "lower" };
export type SpecGroup = { title: string; rows: SpecRow[] };
export interface ProductCompareProps {
title?: string;
catalogue?: CompareProduct[];
initialIds?: string[];
groups?: SpecGroup[];
max?: number;
currency?: string;
locale?: string;
/** Override the best-value pick. Defaults to highest rating² / price. */
bestValue?: (items: CompareProduct[]) => string | undefined;
onAddToCart?: (p: CompareProduct) => void;
onChange?: (ids: string[]) => void;
className?: string;
}
const H = (id: string, name: string, brand: string, kind: ArtKind, hex: string, accent: string, price: number, rating: number, specs: Record<string, SpecValue>): CompareProduct => ({ id, name, brand, kind, hex, accent, price, rating, specs });
const CATALOGUE: CompareProduct[] = [
H("aura", "Aura Studio", "Orbit Audio", "headphones", "#334155", "#d6d3d1", 249, 4.8, { weight: 254, battery: 40, anc: true, driver: 40, codec: "LDAC, AAC", bt: "5.3", multipoint: true, wireless: false, ip: "—", warranty: 2, fold: true }),
H("drift", "Drift Mini", "Lumen", "speaker", "#0f766e", "#fde68a", 129, 4.7, { weight: 540, battery: 18, anc: false, driver: 48, codec: "AAC", bt: "5.3", multipoint: true, wireless: true, ip: "IP67", warranty: 2, fold: false }),
H("halo", "Halo Air", "Northwind", "headphones", "#9f1239", "#fecdd3", 179, 4.5, { weight: 228, battery: 32, anc: true, driver: 38, codec: "AAC", bt: "5.2", multipoint: false, wireless: false, ip: "IPX4", warranty: 1, fold: true }),
H("pulse", "Pulse Pro Max", "Acme Sound", "headphones", "#1e293b", "#fbbf24", 349, 4.9, { weight: 312, battery: 30, anc: true, driver: 45, codec: "LDAC, aptX, AAC", bt: "5.4", multipoint: true, wireless: true, ip: "—", warranty: 3, fold: false }),
H("echo", "Echo Lite", "Orbit Audio", "headphones", "#a8a29e", "#fafaf9", 89, 4.2, { weight: 196, battery: 50, anc: false, driver: 32, codec: "SBC, AAC", bt: "5.3", multipoint: false, wireless: false, ip: "IPX4", warranty: 1, fold: true }),
H("boom", "Boom Stage", "Lumen", "speaker", "#c2410c", "#fef3c7", 219, 4.6, { weight: 1350, battery: 24, anc: false, driver: 64, codec: "AAC, aptX", bt: "5.3", multipoint: true, wireless: false, ip: "IP67", warranty: 2, fold: false }),
];
const GROUPS: SpecGroup[] = [
{
title: "Overview",
rows: [
{ key: "$price", label: "Price", better: "lower" },
{ key: "$rating", label: "Customer rating", better: "higher" },
{ key: "weight", label: "Weight", unit: "g", better: "lower" },
{ key: "warranty", label: "Warranty", unit: "yr", better: "higher" },
],
},
{
title: "Sound & battery",
rows: [
{ key: "driver", label: "Driver size", unit: "mm", better: "higher" },
{ key: "battery", label: "Battery life", unit: "h", better: "higher" },
{ key: "codec", label: "Codecs" },
{ key: "bt", label: "Bluetooth" },
],
},
{
title: "Features",
rows: [
{ key: "anc", label: "Noise cancelling" },
{ key: "multipoint", label: "Multipoint pairing" },
{ key: "wireless", label: "Wireless charging" },
{ key: "fold", label: "Foldable" },
{ key: "ip", label: "Water resistance" },
],
},
];
const ring = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background";
function valueOf(p: CompareProduct, key: string): SpecValue | undefined {
if (key === "$price") return p.price;
if (key === "$rating") return p.rating;
return p.specs[key];
}
export function ProductCompare({
title = "Compare headphones & speakers",
catalogue = CATALOGUE,
initialIds = ["aura", "halo", "pulse"],
groups = GROUPS,
max = 4,
currency = "EUR",
locale = "en-IE",
bestValue = (items) => [...items].sort((a, b) => (b.rating ** 2) / b.price - (a.rating ** 2) / a.price)[0]?.id,
onAddToCart,
onChange,
className,
}: ProductCompareProps) {
const reduce = useReducedMotion();
const fmt = React.useMemo(() => new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 0 }), [locale, currency]);
const [ids, setIds] = React.useState<string[]>(initialIds.slice(0, max));
const [highlight, setHighlight] = React.useState(true);
const [onlyDiff, setOnlyDiff] = React.useState(false);
const [picker, setPicker] = React.useState(false);
const [added, setAdded] = React.useState<string | null>(null);
const items = ids.map((id) => catalogue.find((p) => p.id === id)).filter((p): p is CompareProduct => !!p);
const best = items.length > 1 ? bestValue(items) : undefined;
const available = catalogue.filter((p) => !ids.includes(p.id));
const slots = Math.min(max, Math.max(items.length + (available.length ? 1 : 0), 2));
const set = (next: string[]) => {
setIds(next);
onChange?.(next);
};
const fmtVal = (row: SpecRow, v: SpecValue | undefined) => {
if (v === undefined) return <span className="text-muted-foreground">—</span>;
if (typeof v === "boolean")
return v ? (
<span className="inline-grid size-6 place-items-center rounded-full bg-emerald-500/15 text-emerald-600 dark:text-emerald-400">
<Check className="size-3.5" strokeWidth={3} aria-label="Yes" />
</span>
) : (
<span className="inline-grid size-6 place-items-center rounded-full bg-muted text-muted-foreground">
<Minus className="size-3.5" aria-label="No" />
</span>
);
if (row.key === "$price") return fmt.format(v as number);
if (row.key === "$rating")
return (
<span className="inline-flex items-center gap-1">
<Star className="size-3.5 fill-amber-400 text-amber-400" aria-hidden />
{(v as number).toFixed(1)}
</span>
);
return `${v}${row.unit ? ` ${row.unit}` : ""}`;
};
const winner = (row: SpecRow) => {
if (!row.better || items.length < 2) return undefined;
const vals = items.map((p) => valueOf(p, row.key)).filter((v): v is number => typeof v === "number");
if (vals.length < 2) return undefined;
const target = row.better === "higher" ? Math.max(...vals) : Math.min(...vals);
if (vals.every((v) => v === target)) return undefined;
return target;
};
const differs = (row: SpecRow) => new Set(items.map((p) => String(valueOf(p, row.key)))).size > 1;
return (
<section className={cn("w-full bg-background py-8 text-foreground sm:py-12", className)}>
<div className="mx-auto max-w-6xl px-4 sm:px-6">
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<h2 className="text-2xl font-semibold tracking-tight sm:text-3xl">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">
Comparing {items.length} of up to {max} products
</p>
</div>
<div className="flex flex-wrap gap-x-5 gap-y-2">
<Switch label="Highlight differences" checked={highlight} onChange={setHighlight} />
<Switch label="Only show differences" checked={onlyDiff} onChange={setOnlyDiff} />
</div>
</div>
<div className="relative mt-6 max-h-[620px] overflow-auto rounded-2xl border bg-card [scrollbar-width:thin]">
<table className="w-full border-separate border-spacing-0 text-sm" style={{ minWidth: 140 + slots * 170 }}>
<caption className="sr-only">Product comparison</caption>
<thead>
<tr>
<th scope="col" className="sticky left-0 top-0 z-30 w-[120px] border-b bg-card p-3 text-left align-bottom sm:w-[180px] sm:p-4">
<span className="text-xs font-medium text-muted-foreground">{items.length} selected</span>
{items.length > 0 && (
<button type="button" onClick={() => set([])} className={cn("mt-1 block rounded text-xs font-medium underline underline-offset-4", ring)}>
Clear all
</button>
)}
</th>
{Array.from({ length: slots }, (_, i) => {
const p = items[i];
return (
<th key={p?.id ?? `empty-${i}`} scope="col" className="sticky top-0 z-20 min-w-[170px] border-b border-l bg-card p-3 text-left align-top font-normal sm:p-4">
<AnimatePresence mode="popLayout" initial={false}>
{p ? (
<motion.div key={p.id} initial={reduce ? false : { opacity: 0, y: 12, scale: 0.96 }} animate={{ opacity: 1, y: 0, scale: 1 }} exit={{ opacity: 0, scale: 0.9 }} className="relative">
<button
type="button"
onClick={() => set(ids.filter((x) => x !== p.id))}
aria-label={`Remove ${p.name}`}
className={cn("absolute -right-1 -top-1 z-10 grid size-7 place-items-center rounded-full border bg-background text-muted-foreground shadow-sm transition hover:text-foreground", ring)}
>
<X className="size-3.5" />
</button>
<div className="relative h-24 rounded-xl bg-muted/70 sm:h-28">
<div className="absolute inset-2">
<ProductArt kind={p.kind} color={p.hex} accent={p.accent} />
</div>
{best === p.id && (
<motion.span
layoutId="cmp-best"
className="absolute bottom-1.5 left-1.5 flex items-center gap-1 rounded-full bg-gradient-to-r from-amber-400 to-orange-500 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide text-white shadow"
>
<Award className="size-3" aria-hidden /> Best value
</motion.span>
)}
</div>
<p className="mt-2.5 text-xs text-muted-foreground">{p.brand}</p>
<p className="font-semibold leading-tight">{p.name}</p>
<p className="mt-1 font-semibold tabular-nums">{fmt.format(p.price)}</p>
<button
type="button"
onClick={() => {
onAddToCart?.(p);
setAdded(p.id);
window.setTimeout(() => setAdded((a) => (a === p.id ? null : a)), 1400);
}}
className={cn(
"mt-2.5 flex h-9 w-full items-center justify-center gap-1.5 rounded-lg text-xs font-semibold transition-colors",
added === p.id ? "bg-emerald-600 text-white" : "bg-foreground text-background hover:bg-foreground/85",
ring,
)}
>
{added === p.id ? <Check className="size-3.5" /> : <ShoppingBag className="size-3.5" />}
{added === p.id ? "Added" : "Add to cart"}
</button>
</motion.div>
) : (
<motion.div key="empty" initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="relative h-full">
<AddSlot open={picker} setOpen={setPicker} available={available} disabled={items.length >= max} fmt={fmt} onPick={(id) => set([...ids, id])} />
</motion.div>
)}
</AnimatePresence>
</th>
);
})}
</tr>
</thead>
{groups.map((g) => {
const rows = g.rows.filter((r) => !onlyDiff || differs(r));
if (!rows.length) return null;
return (
<tbody key={g.title}>
<tr>
<th colSpan={slots + 1} scope="colgroup" className="sticky left-0 bg-muted/50 px-3 py-2 text-left text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-foreground sm:px-4">
{g.title}
</th>
</tr>
{rows.map((r) => {
const diff = differs(r);
const win = winner(r);
const lit = highlight && diff && items.length > 1;
return (
<tr key={r.key} className="group">
<th scope="row" className={cn("sticky left-0 z-10 border-b bg-card p-3 text-left text-xs font-medium text-muted-foreground transition-colors sm:px-4 sm:text-sm", lit && "text-foreground")}>
<span className="flex items-center gap-2">
{lit && <motion.span initial={{ scale: 0 }} animate={{ scale: 1 }} className="size-1.5 shrink-0 rounded-full bg-primary" aria-hidden />}
{r.label}
</span>
</th>
{Array.from({ length: slots }, (_, i) => {
const p = items[i];
const v = p ? valueOf(p, r.key) : undefined;
const isWin = p && win !== undefined && v === win;
return (
<td key={p?.id ?? `e-${i}`} className={cn("relative border-b border-l p-3 transition-colors duration-300 sm:px-4", lit && "bg-primary/[0.06]")}>
{p ? (
<span className={cn("inline-flex items-center gap-1.5 tabular-nums", isWin && "font-semibold text-foreground")}>
{fmtVal(r, v)}
{isWin && highlight && <Trophy className="size-3.5 text-amber-500" aria-label="Best in row" />}
</span>
) : null}
</td>
);
})}
</tr>
);
})}
</tbody>
);
})}
</table>
{items.length === 0 && (
<p className="p-8 text-center text-sm text-muted-foreground">Add products to start comparing.</p>
)}
</div>
<p className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
<span className="size-1.5 rounded-full bg-primary" aria-hidden /> Rows that differ
<Trophy className="ml-3 size-3.5 text-amber-500" aria-hidden /> Best in row
</p>
</div>
</section>
);
}
function Switch({ label, checked, onChange }: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
return (
<label className="flex cursor-pointer items-center gap-2.5 text-sm">
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn("relative h-6 w-10 shrink-0 rounded-full transition-colors", checked ? "bg-primary" : "bg-muted-foreground/30", ring)}
>
<motion.span className="absolute top-0.5 size-5 rounded-full bg-white shadow" animate={{ left: checked ? 18 : 2 }} transition={{ type: "spring", stiffness: 600, damping: 35 }} />
</button>
<span>{label}</span>
</label>
);
}
function AddSlot({
open,
setOpen,
available,
disabled,
fmt,
onPick,
}: {
open: boolean;
setOpen: (v: boolean) => void;
available: CompareProduct[];
disabled: boolean;
fmt: Intl.NumberFormat;
onPick: (id: string) => void;
}) {
const btn = React.useRef<HTMLButtonElement>(null);
const menu = React.useRef<HTMLUListElement>(null);
React.useEffect(() => {
if (!open) return;
menu.current?.querySelector<HTMLElement>("button")?.focus();
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setOpen(false);
btn.current?.focus();
}
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const list = [...(menu.current?.querySelectorAll<HTMLElement>("button") ?? [])];
const i = list.indexOf(document.activeElement as HTMLElement);
list[(i + (e.key === "ArrowDown" ? 1 : -1) + list.length) % list.length]?.focus();
}
};
const onDown = (e: MouseEvent) => {
if (!menu.current?.contains(e.target as Node) && !btn.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("keydown", onKey);
document.addEventListener("mousedown", onDown);
return () => {
document.removeEventListener("keydown", onKey);
document.removeEventListener("mousedown", onDown);
};
}, [open, setOpen]);
return (
<>
<button
ref={btn}
type="button"
disabled={disabled || !available.length}
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen(!open)}
className={cn(
"flex h-full min-h-[210px] w-full flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed text-sm font-medium text-muted-foreground transition hover:border-foreground/40 hover:text-foreground disabled:opacity-50",
ring,
)}
>
<span className="grid size-10 place-items-center rounded-full bg-muted">
<Plus className="size-5" aria-hidden />
</span>
Add product
</button>
<AnimatePresence>
{open && (
<motion.ul
ref={menu}
role="menu"
initial={{ opacity: 0, y: -6, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -6, scale: 0.97 }}
className="absolute left-0 right-0 top-12 z-40 max-h-64 overflow-auto rounded-xl border bg-popover p-1.5 text-popover-foreground shadow-2xl"
>
{available.map((p) => (
<li key={p.id} role="none">
<button
type="button"
role="menuitem"
onClick={() => {
onPick(p.id);
setOpen(false);
}}
className="flex w-full items-center gap-2.5 rounded-lg p-1.5 text-left text-sm outline-none hover:bg-muted focus-visible:bg-muted"
>
<span className="size-9 shrink-0 rounded-md bg-muted p-0.5">
<ProductArt kind={p.kind} color={p.hex} accent={p.accent} />
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">{p.name}</span>
<span className="text-xs text-muted-foreground">{fmt.format(p.price)}</span>
</span>
</button>
</li>
))}
</motion.ul>
)}
</AnimatePresence>
</>
);
}