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