Fazekit

Code

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

More in E-commerce

View all →