Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";
import { Check, ChevronDown, Heart, Plus, SlidersHorizontal, Star, X, ShoppingBag, SearchX } from "lucide-react";
import { cn } from "@/lib/utils";
import { ProductArt, type ArtKind } from "./product-art";

export type GridProduct = {
  id: string;
  name: string;
  brand: string;
  category: string;
  price: number;
  compareAt?: number;
  rating: number;
  reviews: number;
  colour: string;
  kind: ArtKind;
  hex: string;
  accent?: string;
  label?: string;
  isNew?: boolean;
  /** Higher = newer; used for the "Newest" sort. */
  added: number;
};

export type SortKey = "featured" | "newest" | "price-asc" | "price-desc" | "rating";
export type GridFilters = { categories: string[]; colours: string[]; price: [number, number]; minRating: number };

export interface ProductGridFiltersProps {
  title?: string;
  subtitle?: string;
  products?: GridProduct[];
  /** Swatch colour for each colour facet value. */
  colourHex?: Record<string, string>;
  pageSize?: number;
  currency?: string;
  locale?: string;
  onAddToCart?: (p: GridProduct) => void;
  onFiltersChange?: (f: GridFilters, sort: SortKey) => void;
  className?: string;
}

const COLOUR_HEX: Record<string, string> = {
  Amber: "#c2410c",
  Ruby: "#9f1239",
  Gold: "#ca8a04",
  Clear: "#e2e8f0",
  Smoke: "#57534e",
  Green: "#15803d",
};

const P = (id: number, name: string, brand: string, category: string, kind: ArtKind, price: number, rating: number, reviews: number, colour: string, extra: Partial<GridProduct> = {}): GridProduct => ({
  id: String(id),
  name,
  brand,
  category,
  kind,
  price,
  rating,
  reviews,
  colour,
  hex: COLOUR_HEX[colour] ?? "#888888",
  accent: "#f5efe0",
  label: brand.split(" ")[0],
  added: id,
  ...extra,
});

const DEFAULT_PRODUCTS: GridProduct[] = [
  P(1, "Highland 12 Single Malt", "Lumen", "Spirits", "bottle", 39.9, 4.8, 412, "Amber", { compareAt: 46 }),
  P(2, "Coastal Dry Gin", "Northwind", "Spirits", "bottle", 29.5, 4.6, 233, "Clear", { hex: "#0ea5e9", accent: "#f0f9ff" }),
  P(3, "Reserva Tinto 2019", "Orbit Estates", "Wine", "wine", 18.9, 4.4, 98, "Ruby", { accent: "#d4af37" }),
  P(4, "Rosé de Provence", "Acme Vineyards", "Wine", "wine", 14.5, 4.1, 64, "Ruby", { hex: "#f472b6", accent: "#fdf2f8", isNew: true }),
  P(5, "Hazy IPA 4-pack", "Brewlab", "Craft beer", "can", 11.9, 4.5, 187, "Gold", { accent: "#1e293b" }),
  P(6, "Crystal Rocks Tumbler", "Lumen Home", "Glassware", "tumbler", 12, 4.9, 51, "Clear", { hex: "#d97706" }),
  P(7, "Smoked Islay 10", "Lumen", "Spirits", "bottle", 44, 4.7, 356, "Smoke", { accent: "#e7e5e4" }),
  P(8, "Tasting Gift Set", "Orbit", "Gift sets", "box", 59, 4.8, 120, "Gold", { accent: "#1c1917", compareAt: 72 }),
  P(9, "Barrel-aged Stout", "Brewlab", "Craft beer", "can", 6.5, 4.3, 45, "Smoke", { accent: "#fbbf24", isNew: true }),
  P(10, "Albariño Rías Baixas", "Acme Vineyards", "Wine", "wine", 16.9, 4.2, 71, "Green", { accent: "#fef3c7" }),
  P(11, "Spiced Caribbean Rum", "Northwind", "Spirits", "bottle", 27, 4.4, 142, "Amber", { hex: "#78350f", accent: "#fde68a" }),
  P(12, "Old Fashioned Kit", "Lumen Home", "Gift sets", "box", 34, 4.6, 88, "Amber", { accent: "#f5f5f4" }),
  P(13, "Pilsner 6-pack", "Brewlab", "Craft beer", "can", 9.9, 4, 210, "Green", { accent: "#fef08a" }),
  P(14, "Champagne Brut NV", "Orbit Estates", "Wine", "wine", 42, 4.7, 164, "Gold", { accent: "#111827", isNew: true }),
  P(15, "Highball Glass Pair", "Lumen Home", "Glassware", "tumbler", 16, 4.5, 39, "Clear", { hex: "#0ea5e9" }),
  P(16, "Añejo Tequila", "Northwind", "Spirits", "bottle", 52, 4.6, 77, "Gold", { accent: "#1c1917" }),
  P(17, "Pinot Noir Reserve", "Acme Vineyards", "Wine", "wine", 24.5, 4.5, 58, "Ruby", { accent: "#e5e7eb", compareAt: 29 }),
  P(18, "Sour Cherry Ale", "Brewlab", "Craft beer", "can", 4.2, 3.8, 33, "Ruby", { accent: "#fdf2f8" }),
  P(19, "Whisky Stones Set", "Lumen Home", "Gift sets", "box", 19, 4.2, 140, "Smoke", { accent: "#a8a29e" }),
  P(20, "Small-batch Vodka", "Northwind", "Spirits", "bottle", 24, 4.1, 96, "Clear", { hex: "#94a3b8", accent: "#f8fafc", isNew: true }),
  P(21, "Nosing Glass Set", "Lumen Home", "Glassware", "tumbler", 28, 4.8, 64, "Amber"),
  P(22, "Orange Liqueur", "Orbit", "Spirits", "bottle", 21, 4.3, 52, "Amber", { hex: "#ea580c", accent: "#fff7ed" }),
  P(23, "Vinho Verde", "Acme Vineyards", "Wine", "wine", 9.9, 3.9, 44, "Green", { accent: "#ecfccb", compareAt: 12.5 }),
  P(24, "Pale Ale Mixed Case", "Brewlab", "Craft beer", "box", 32, 4.4, 71, "Gold", { accent: "#0f172a" }),
];

const SORTS: { key: SortKey; label: string }[] = [
  { key: "featured", label: "Featured" },
  { key: "newest", label: "Newest" },
  { key: "price-asc", label: "Price: low to high" },
  { key: "price-desc", label: "Price: high to low" },
  { key: "rating", label: "Top rated" },
];

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 matches(p: GridProduct, f: GridFilters, skip?: keyof GridFilters) {
  if (skip !== "categories" && f.categories.length && !f.categories.includes(p.category)) return false;
  if (skip !== "colours" && f.colours.length && !f.colours.includes(p.colour)) return false;
  if (skip !== "price" && (p.price < f.price[0] || p.price > f.price[1])) return false;
  if (skip !== "minRating" && p.rating < f.minRating) return false;
  return true;
}

export function ProductGridFilters({
  title = "Spirits, wine & more",
  subtitle = "Hand-picked bottles, glassware and gifts — delivered in 48 hours.",
  products = DEFAULT_PRODUCTS,
  colourHex = COLOUR_HEX,
  pageSize = 9,
  currency = "EUR",
  locale = "en-IE",
  onAddToCart,
  onFiltersChange,
  className,
}: ProductGridFiltersProps) {
  const reduce = useReducedMotion();
  const fmt = React.useMemo(() => new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 2 }), [locale, currency]);
  const priceMax = React.useMemo(() => Math.ceil(Math.max(...products.map((p) => p.price)) / 10) * 10, [products]);
  const initial: GridFilters = React.useMemo(() => ({ categories: [], colours: [], price: [0, priceMax], minRating: 0 }), [priceMax]);
  const [filters, setFilters] = React.useState<GridFilters>(initial);
  const [sort, setSort] = React.useState<SortKey>("featured");
  const [visible, setVisible] = React.useState(pageSize);
  const [sheet, setSheet] = React.useState(false);
  const [cart, setCart] = React.useState(0);
  const [added, setAdded] = React.useState<string | null>(null);
  const [liked, setLiked] = React.useState<Set<string>>(() => new Set());
  const sheetBtn = React.useRef<HTMLButtonElement>(null);

  const update = (patch: Partial<GridFilters>) => {
    setFilters((f) => ({ ...f, ...patch }));
    setVisible(pageSize);
  };
  React.useEffect(() => onFiltersChange?.(filters, sort), [filters, sort, onFiltersChange]);

  const results = React.useMemo(() => {
    const list = products.filter((p) => matches(p, filters));
    const sorted = [...list];
    if (sort === "newest") sorted.sort((a, b) => b.added - a.added);
    if (sort === "price-asc") sorted.sort((a, b) => a.price - b.price);
    if (sort === "price-desc") sorted.sort((a, b) => b.price - a.price);
    if (sort === "rating") sorted.sort((a, b) => b.rating - a.rating);
    return sorted;
  }, [products, filters, sort]);

  const categories = React.useMemo(() => [...new Set(products.map((p) => p.category))], [products]);
  const colours = React.useMemo(() => [...new Set(products.map((p) => p.colour))], [products]);
  const count = (key: "categories" | "colours", value: string) =>
    products.filter((p) => matches(p, filters, key) && (key === "categories" ? p.category === value : p.colour === value)).length;

  const chips: { id: string; label: string; remove: () => void }[] = [
    ...filters.categories.map((c) => ({ id: `c-${c}`, label: c, remove: () => update({ categories: filters.categories.filter((x) => x !== c) }) })),
    ...filters.colours.map((c) => ({ id: `k-${c}`, label: c, remove: () => update({ colours: filters.colours.filter((x) => x !== c) }) })),
    ...(filters.price[0] > 0 || filters.price[1] < priceMax
      ? [{ id: "price", label: `${fmt.format(filters.price[0])} – ${fmt.format(filters.price[1])}`, remove: () => update({ price: [0, priceMax] }) }]
      : []),
    ...(filters.minRating ? [{ id: "rating", label: `${filters.minRating}★ & up`, remove: () => update({ minRating: 0 }) }] : []),
  ];

  const quickAdd = (p: GridProduct) => {
    setCart((c) => c + 1);
    setAdded(p.id);
    onAddToCart?.(p);
    window.setTimeout(() => setAdded((a) => (a === p.id ? null : a)), 1400);
  };

  const panel = (prefix: string) => (
    <FilterPanel
      prefix={prefix}
      filters={filters}
      update={update}
      categories={categories}
      colours={colours}
      colourHex={colourHex}
      count={count}
      priceMax={priceMax}
      fmt={fmt}
    />
  );

  const shown = results.slice(0, visible);

  return (
    <section className={cn("w-full bg-background text-foreground", className)}>
      <div className="mx-auto max-w-7xl px-4 py-8 sm:px-6">
        <header className="flex flex-wrap items-end justify-between gap-4 border-b pb-6">
          <div>
            <h2 className="text-2xl font-semibold tracking-tight sm:text-3xl">{title}</h2>
            <p className="mt-1 text-sm text-muted-foreground">{subtitle}</p>
          </div>
          <div className="flex items-center gap-2 text-sm" aria-live="polite">
            <ShoppingBag className="size-4" aria-hidden />
            <span>Cart</span>
            <motion.span
              key={cart}
              initial={reduce || cart === 0 ? false : { scale: 1.6 }}
              animate={{ scale: 1 }}
              className="grid h-5 min-w-5 place-items-center rounded-full bg-primary px-1.5 text-[11px] font-semibold tabular-nums text-primary-foreground"
            >
              {cart}
            </motion.span>
          </div>
        </header>

        <div className="mt-6 flex gap-8">
          <aside className="hidden w-60 shrink-0 lg:block" aria-label="Filters">
            {panel("side")}
          </aside>

          <div className="min-w-0 flex-1">
            <div className="flex flex-wrap items-center gap-3">
              <button
                ref={sheetBtn}
                type="button"
                onClick={() => setSheet(true)}
                className={cn("flex h-10 items-center gap-2 rounded-full border px-4 text-sm font-medium lg:hidden", ring)}
              >
                <SlidersHorizontal className="size-4" aria-hidden /> Filters
                {chips.length > 0 && <span className="grid size-5 place-items-center rounded-full bg-foreground text-[11px] text-background">{chips.length}</span>}
              </button>
              <p className="order-last w-full text-sm text-muted-foreground sm:order-none sm:w-auto" aria-live="polite">
                <span className="font-semibold text-foreground tabular-nums">{results.length}</span> products
              </p>
              <label className="relative ml-auto flex items-center gap-2 text-sm">
                <span className="hidden text-muted-foreground sm:inline">Sort by</span>
                <select
                  value={sort}
                  onChange={(e) => setSort(e.target.value as SortKey)}
                  className={cn("h-10 appearance-none rounded-full border bg-background pl-4 pr-9 text-sm font-medium", ring)}
                  aria-label="Sort products"
                >
                  {SORTS.map((s) => (
                    <option key={s.key} value={s.key}>
                      {s.label}
                    </option>
                  ))}
                </select>
                <ChevronDown className="pointer-events-none absolute right-3 size-4 text-muted-foreground" aria-hidden />
              </label>
            </div>

            <AnimatePresence initial={false}>
              {chips.length > 0 && (
                <motion.div initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} className="overflow-hidden">
                  <ul className="flex flex-wrap items-center gap-2 pt-4" aria-label="Active filters">
                    <AnimatePresence mode="popLayout">
                      {chips.map((c) => (
                        <motion.li key={c.id} layout initial={{ scale: 0.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0.8, opacity: 0 }}>
                          <button
                            type="button"
                            onClick={c.remove}
                            aria-label={`Remove filter ${c.label}`}
                            className={cn("flex h-8 items-center gap-1.5 rounded-full bg-secondary pl-3 pr-2 text-xs font-medium text-secondary-foreground transition hover:bg-accent", ring)}
                          >
                            {c.label}
                            <X className="size-3.5" aria-hidden />
                          </button>
                        </motion.li>
                      ))}
                      <motion.li layout key="clear">
                        <button type="button" onClick={() => update(initial)} className={cn("h-8 rounded-full px-2 text-xs font-medium text-muted-foreground underline-offset-4 hover:text-foreground hover:underline", ring)}>
                          Clear all
                        </button>
                      </motion.li>
                    </AnimatePresence>
                  </ul>
                </motion.div>
              )}
            </AnimatePresence>

            <LayoutGroup>
              {results.length === 0 ? (
                <motion.div initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} className="mt-10 flex flex-col items-center rounded-3xl border border-dashed py-16 text-center">
                  <SearchX className="size-8 text-muted-foreground" aria-hidden />
                  <p className="mt-3 font-medium">No products match these filters</p>
                  <p className="mt-1 text-sm text-muted-foreground">Try widening the price range or removing a filter.</p>
                  <button type="button" onClick={() => update(initial)} className={cn("mt-5 h-10 rounded-full bg-foreground px-5 text-sm font-medium text-background", ring)}>
                    Reset filters
                  </button>
                </motion.div>
              ) : (
                <ul className="mt-6 grid grid-cols-2 gap-x-3 gap-y-6 sm:gap-x-5 md:grid-cols-3">
                  <AnimatePresence mode="popLayout" initial={false}>
                    {shown.map((p, i) => (
                      <motion.li
                        key={p.id}
                        layout={!reduce}
                        initial={{ opacity: 0, scale: 0.94, y: 12 }}
                        animate={{ opacity: 1, scale: 1, y: 0, transition: { delay: reduce ? 0 : Math.min(i % pageSize, 8) * 0.03 } }}
                        exit={{ opacity: 0, scale: 0.94 }}
                        transition={{ type: "spring", stiffness: 380, damping: 34 }}
                      >
                        <Card
                          p={p}
                          fmt={fmt}
                          added={added === p.id}
                          liked={liked.has(p.id)}
                          onLike={() =>
                            setLiked((s) => {
                              const n = new Set(s);
                              if (n.has(p.id)) n.delete(p.id);
                              else n.add(p.id);
                              return n;
                            })
                          }
                          onAdd={() => quickAdd(p)}
                        />
                      </motion.li>
                    ))}
                  </AnimatePresence>
                </ul>
              )}
            </LayoutGroup>

            {results.length > 0 && (
              <div className="mt-10 flex flex-col items-center gap-3">
                <p className="text-xs text-muted-foreground">
                  Showing {shown.length} of {results.length}
                </p>
                <div className="h-1 w-48 overflow-hidden rounded-full bg-muted">
                  <motion.div className="h-full rounded-full bg-foreground" animate={{ width: `${(shown.length / results.length) * 100}%` }} />
                </div>
                {shown.length < results.length && (
                  <button type="button" onClick={() => setVisible((v) => v + pageSize)} className={cn("mt-2 h-11 rounded-full border px-6 text-sm font-medium transition hover:bg-muted", ring)}>
                    Load more
                  </button>
                )}
              </div>
            )}
          </div>
        </div>
      </div>

      <Sheet
        open={sheet}
        onClose={() => {
          setSheet(false);
          sheetBtn.current?.focus();
        }}
        count={results.length}
        onReset={() => update(initial)}
      >
        {panel("sheet")}
      </Sheet>
    </section>
  );
}

/* ------------------------------------------------------------------ */

function Card({ p, fmt, added, liked, onLike, onAdd }: { p: GridProduct; fmt: Intl.NumberFormat; added: boolean; liked: boolean; onLike: () => void; onAdd: () => void }) {
  const off = p.compareAt ? Math.round((1 - p.price / p.compareAt) * 100) : 0;
  return (
    <article className="group relative">
      <div className="relative aspect-[4/5] overflow-hidden rounded-2xl bg-muted/70 ring-1 ring-border/60">
        <div aria-hidden className="absolute inset-x-8 bottom-6 top-1/2 rounded-full opacity-0 blur-2xl transition duration-500 group-hover:opacity-40" style={{ background: p.hex }} />
        <div className="absolute inset-[10%] transition duration-500 ease-out group-hover:-translate-y-1 group-hover:scale-[1.04]">
          <ProductArt kind={p.kind} color={p.hex} accent={p.accent} label={p.label} />
        </div>
        <div className="absolute left-2.5 top-2.5 flex flex-col gap-1">
          {off > 0 && <span className="rounded-full bg-rose-600 px-2 py-0.5 text-[11px] font-semibold text-white">-{off}%</span>}
          {p.isNew && <span className="rounded-full bg-foreground px-2 py-0.5 text-[11px] font-semibold text-background">New</span>}
        </div>
        <button
          type="button"
          onClick={onLike}
          aria-pressed={liked}
          aria-label={liked ? `Remove ${p.name} from wishlist` : `Save ${p.name} to wishlist`}
          className={cn("absolute right-2.5 top-2.5 grid size-8 place-items-center rounded-full bg-background/80 backdrop-blur transition hover:scale-105", ring)}
        >
          <Heart className={cn("size-4", liked && "fill-rose-500 text-rose-500")} />
        </button>
        <button
          type="button"
          onClick={onAdd}
          aria-label={`Quick add ${p.name}`}
          className={cn(
            "absolute bottom-2.5 right-2.5 flex size-10 items-center justify-center gap-2 rounded-full text-sm font-semibold shadow-lg transition duration-300 sm:left-2.5 sm:h-10 sm:w-auto sm:rounded-xl",
            "translate-y-0 opacity-100 sm:translate-y-3 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",
            added ? "bg-emerald-600 text-white" : "bg-background/95 text-foreground backdrop-blur hover:bg-foreground hover:text-background",
            ring,
          )}
        >
          {added ? <Check className="size-4" aria-hidden /> : <Plus className="size-4" aria-hidden />}
          <span className="hidden sm:inline">{added ? "Added" : "Quick add"}</span>
        </button>
      </div>
      <div className="mt-3 space-y-1 px-0.5">
        <p className="text-xs text-muted-foreground">{p.brand}</p>
        <h3 className="line-clamp-1 text-sm font-medium">
          <a href="#" className={cn("rounded hover:underline underline-offset-4", ring)} onClick={(e) => e.preventDefault()}>
            {p.name}
          </a>
        </h3>
        <div className="flex items-center gap-1 text-xs text-muted-foreground">
          <Star className="size-3.5 fill-amber-400 text-amber-400" aria-hidden />
          <span className="font-medium text-foreground">{p.rating.toFixed(1)}</span>
          <span>({p.reviews})</span>
        </div>
        <p className="flex items-baseline gap-2 text-sm">
          <span className={cn("font-semibold tabular-nums", off && "text-rose-600 dark:text-rose-400")}>{fmt.format(p.price)}</span>
          {p.compareAt && <s className="text-xs text-muted-foreground tabular-nums">{fmt.format(p.compareAt)}</s>}
        </p>
      </div>
    </article>
  );
}

function Group({ title, children, defaultOpen = true }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) {
  const [open, setOpen] = React.useState(defaultOpen);
  const id = React.useId();
  return (
    <div className="border-b py-4 first:pt-0">
      <button type="button" aria-expanded={open} aria-controls={id} onClick={() => setOpen((o) => !o)} className={cn("flex w-full items-center justify-between rounded text-sm font-semibold", ring)}>
        {title}
        <motion.span animate={{ rotate: open ? 180 : 0 }}>
          <ChevronDown className="size-4 text-muted-foreground" aria-hidden />
        </motion.span>
      </button>
      <AnimatePresence initial={false}>
        {open && (
          <motion.div id={id} initial={{ height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} className="overflow-hidden">
            <div className="pt-3">{children}</div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

function FilterPanel({
  prefix,
  filters,
  update,
  categories,
  colours,
  colourHex,
  count,
  priceMax,
  fmt,
}: {
  prefix: string;
  filters: GridFilters;
  update: (p: Partial<GridFilters>) => void;
  categories: string[];
  colours: string[];
  colourHex: Record<string, string>;
  count: (k: "categories" | "colours", v: string) => number;
  priceMax: number;
  fmt: Intl.NumberFormat;
}) {
  const toggle = (key: "categories" | "colours", v: string) => update({ [key]: filters[key].includes(v) ? filters[key].filter((x) => x !== v) : [...filters[key], v] });
  const [lo, hi] = filters.price;
  const thumb =
    "pointer-events-none absolute inset-x-0 top-1/2 h-0 w-full -translate-y-1/2 appearance-none bg-transparent focus-visible:outline-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:size-5 [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-foreground [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-md [&::-webkit-slider-thumb]:transition [&::-webkit-slider-thumb]:active:scale-110 focus-visible:[&::-webkit-slider-thumb]:ring-4 focus-visible:[&::-webkit-slider-thumb]:ring-ring/40 [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-foreground [&::-moz-range-thumb]:bg-background";

  return (
    <div>
      <Group title="Category">
        <ul className="space-y-1">
          {categories.map((c) => {
            const n = count("categories", c);
            const on = filters.categories.includes(c);
            return (
              <li key={c}>
                <label className={cn("flex cursor-pointer items-center gap-3 rounded-lg px-1 py-1.5 text-sm transition hover:bg-muted/60", n === 0 && !on && "opacity-50")}>
                  <input type="checkbox" checked={on} onChange={() => toggle("categories", c)} className="peer sr-only" id={`${prefix}-cat-${c}`} />
                  <span className="grid size-[18px] place-items-center rounded-[5px] border transition peer-checked:border-foreground peer-checked:bg-foreground peer-checked:text-background peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background">
                    {on && <Check className="size-3" strokeWidth={3} aria-hidden />}
                  </span>
                  <span className="flex-1">{c}</span>
                  <span className="text-xs tabular-nums text-muted-foreground">{n}</span>
                </label>
              </li>
            );
          })}
        </ul>
      </Group>

      <Group title="Price">
        <div className="relative mx-2.5 h-5">
          <div className="absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-muted" />
          <div className="absolute top-1/2 h-1 -translate-y-1/2 rounded-full bg-foreground" style={{ left: `${(lo / priceMax) * 100}%`, right: `${100 - (hi / priceMax) * 100}%` }} />
          <input
            type="range"
            aria-label="Minimum price"
            min={0}
            max={priceMax}
            step={1}
            value={lo}
            onChange={(e) => update({ price: [Math.min(Number(e.target.value), hi - 1), hi] })}
            className={thumb}
          />
          <input
            type="range"
            aria-label="Maximum price"
            min={0}
            max={priceMax}
            step={1}
            value={hi}
            onChange={(e) => update({ price: [lo, Math.max(Number(e.target.value), lo + 1)] })}
            className={thumb}
          />
        </div>
        <div className="mt-3 flex items-center justify-between text-xs">
          <span className="rounded-md border px-2 py-1 tabular-nums">{fmt.format(lo)}</span>
          <span className="text-muted-foreground">to</span>
          <span className="rounded-md border px-2 py-1 tabular-nums">{fmt.format(hi)}</span>
        </div>
      </Group>

      <Group title="Colour">
        <div className="flex flex-wrap gap-2">
          {colours.map((c) => {
            const on = filters.colours.includes(c);
            const n = count("colours", c);
            return (
              <button
                key={c}
                type="button"
                aria-pressed={on}
                aria-label={`${c} (${n})`}
                title={`${c} (${n})`}
                onClick={() => toggle("colours", c)}
                className={cn("relative grid size-9 place-items-center rounded-full border border-black/10 transition hover:scale-105 dark:border-white/15", n === 0 && !on && "opacity-40", ring)}
                style={{ background: `radial-gradient(circle at 35% 30%, #ffffff66, transparent 45%), ${colourHex[c] ?? "#999"}` }}
              >
                {on && (
                  <motion.span initial={{ scale: 0 }} animate={{ scale: 1 }} className="grid size-5 place-items-center rounded-full bg-background/90 text-foreground">
                    <Check className="size-3" strokeWidth={3} aria-hidden />
                  </motion.span>
                )}
              </button>
            );
          })}
        </div>
      </Group>

      <Group title="Rating">
        <div role="radiogroup" aria-label="Minimum rating" className="space-y-1">
          {[4.5, 4, 0].map((r) => (
            <label key={r} className="flex cursor-pointer items-center gap-3 rounded-lg px-1 py-1.5 text-sm hover:bg-muted/60">
              <input type="radio" name={`${prefix}-rating`} checked={filters.minRating === r} onChange={() => update({ minRating: r })} className="peer sr-only" />
              <span className="grid size-[18px] place-items-center rounded-full border transition peer-checked:border-[5px] peer-checked:border-foreground peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background" />
              {r ? (
                <span className="flex items-center gap-1">
                  {[0, 1, 2, 3, 4].map((i) => (
                    <Star key={i} className={cn("size-3.5", i < Math.floor(r) ? "fill-amber-400 text-amber-400" : i < r ? "fill-amber-400/50 text-amber-400" : "text-muted-foreground/40")} aria-hidden />
                  ))}
                  <span className="ml-1 text-muted-foreground">{r}+</span>
                </span>
              ) : (
                <span>Any rating</span>
              )}
            </label>
          ))}
        </div>
      </Group>
    </div>
  );
}

function Sheet({ open, onClose, count, onReset, children }: { open: boolean; onClose: () => void; count: number; onReset: () => void; children: React.ReactNode }) {
  const ref = React.useRef<HTMLDivElement>(null);
  React.useEffect(() => {
    if (!open) return;
    const el = ref.current;
    el?.querySelector<HTMLElement>("button, input")?.focus();
    const onKey = (e: KeyboardEvent) => {
      if (e.key === "Escape") onClose();
      if (e.key === "Tab" && el) {
        const f = [...el.querySelectorAll<HTMLElement>("button, input, select, [tabindex]:not([tabindex='-1'])")].filter((x) => !x.hasAttribute("disabled"));
        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);
  }, [open, onClose]);

  return (
    <AnimatePresence>
      {open && (
        <div className="fixed inset-0 z-50 lg:hidden">
          <motion.div className="absolute inset-0 bg-black/50 backdrop-blur-[2px]" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={onClose} />
          <motion.div
            ref={ref}
            role="dialog"
            aria-modal="true"
            aria-label="Filters"
            initial={{ y: "100%" }}
            animate={{ y: 0 }}
            exit={{ y: "100%" }}
            transition={{ type: "spring", stiffness: 380, damping: 38 }}
            className="absolute inset-x-0 bottom-0 flex max-h-[88dvh] flex-col rounded-t-3xl border-t bg-background shadow-2xl"
          >
            <div className="mx-auto mt-2.5 h-1.5 w-10 rounded-full bg-muted" aria-hidden />
            <div className="flex items-center justify-between px-5 py-3">
              <h3 className="text-base font-semibold">Filters</h3>
              <button type="button" onClick={onClose} aria-label="Close filters" className={cn("grid size-9 place-items-center rounded-full hover:bg-muted", ring)}>
                <X className="size-5" />
              </button>
            </div>
            <div className="flex-1 overflow-y-auto px-5 pb-4">{children}</div>
            <div className="flex gap-3 border-t p-4">
              <button type="button" onClick={onReset} className={cn("h-12 flex-1 rounded-xl border text-sm font-medium", ring)}>
                Reset
              </button>
              <button type="button" onClick={onClose} className={cn("h-12 flex-[2] rounded-xl bg-foreground text-sm font-semibold text-background", ring)}>
                Show {count} results
              </button>
            </div>
          </motion.div>
        </div>
      )}
    </AnimatePresence>
  );
}

More in E-commerce

View all →