"use client";
import * as React from "react";
import { AnimatePresence, LayoutGroup, motion, MotionConfig, useReducedMotion } from "motion/react";
import { ChevronDown, GitCompareArrows, Grape, ShoppingBag, SlidersHorizontal, Store, Utensils } from "lucide-react";
import { cn } from "@/lib/utils";
import { CompareDrawer, COMPARE_COLORS } from "./compare-drawer";
import { AXES, DISHES, NEUTRAL_PROFILE, SUGGESTIONS, TYPE_LABELS, WINES } from "./data";
import { axesIn, isAsyncIterable, matchDish, rank, reasoningText, scriptedStream, type Match } from "./engine";
import { FlavorRadar } from "./flavor-radar";
import { OptionRow, PickCard, focusRing } from "./pick-card";
import { PromptBar } from "./prompt-bar";
import { ReasoningStream } from "./reasoning-stream";
import type { Axis, AxisKey, ChatMessage, Dish, Profile, Scored, Wine, WineType } from "./types";
export type { Wine, Dish, Profile, Axis };
export type SommelierAiAppProps = {
wines?: Wine[];
dishes?: Dish[];
axes?: Axis[];
assistantName?: string;
currency?: string;
locale?: string;
/** Plug in a real model. Return streamed text (one reasoning step per line) or a full string. Default: scripted engine. */
onAsk?: (query: string) => AsyncIterable<string> | Promise<string>;
onAddToCart?: (wine: Wine) => void;
/** Small "18+ only. Drink responsibly." footer. */
showAgeNotice?: boolean;
/** Budget slider bounds. */
budgetRange?: [number, number];
className?: string;
};
type Active = { match: Match; messageId: string };
let seq = 0;
const nid = (p: string) => `${p}${++seq}`;
export function SommelierAiApp({
wines = WINES,
dishes = DISHES,
axes = AXES,
assistantName = "Vinea Sommelier",
currency = "PLN",
locale = "pl-PL",
onAsk,
onAddToCart,
showAgeNotice = true,
budgetRange = [20, 300],
className,
}: SommelierAiAppProps) {
const reduced = useReducedMotion() ?? false;
const money = React.useMemo(() => {
const f = new Intl.NumberFormat(locale, { style: "currency", currency, maximumFractionDigits: 2, minimumFractionDigits: 0 });
return (v: number) => f.format(v);
}, [locale, currency]);
const [messages, setMessages] = React.useState<ChatMessage[]>([]);
const [active, setActive] = React.useState<Active | null>(null);
const [live, setLive] = React.useState<{ profile: Profile; axes: AxisKey[]; thinking: boolean }>({ profile: NEUTRAL_PROFILE, axes: [], thinking: false });
const [busy, setBusy] = React.useState(false);
const [hoverId, setHoverId] = React.useState<string | null>(null);
const [budget, setBudget] = React.useState<[number, number]>(budgetRange);
const [types, setTypes] = React.useState<WineType[]>([]);
const [inStore, setInStore] = React.useState(false);
const [compare, setCompare] = React.useState<string[]>([]);
const [compareOpen, setCompareOpen] = React.useState(false);
const [basket, setBasket] = React.useState<Record<string, number>>({});
const [bump, setBump] = React.useState(0);
const [tasteOpen, setTasteOpen] = React.useState(true);
const [announce, setAnnounce] = React.useState("");
const runRef = React.useRef(0);
const logRef = React.useRef<HTMLDivElement>(null);
const picksRef = React.useRef<HTMLDivElement>(null);
const promptRef = React.useRef<HTMLInputElement>(null);
const promptMobileRef = React.useRef<HTMLInputElement>(null);
const patchMsg = (id: string, patch: Partial<Extract<ChatMessage, { role: "assistant" }>>) =>
setMessages((ms) => ms.map((m) => (m.id === id && m.role === "assistant" ? { ...m, ...patch } : m)));
/* ------------------------------ ranking ------------------------------ */
const target = active?.match.profile ?? null;
const ranked = React.useMemo(() => (target ? rank(wines, target, active?.match.dish ?? null, axes) : []), [wines, target, active, axes]);
const passes = React.useCallback(
(s: Scored, withBudget = true) =>
(!withBudget || (s.wine.price >= budget[0] && s.wine.price <= budget[1])) && (types.length === 0 || types.includes(s.wine.type)) && (!inStore || s.wine.inStock),
[budget, types, inStore],
);
const filtered = ranked.filter((s) => passes(s));
const top = filtered.slice(0, 3);
const more = filtered.slice(3, 8);
const shownIds = new Set([...top, ...more].map((s) => s.wine.id));
const naPick = ranked.find((s) => s.wine.type === "non-alcoholic" && (!inStore || s.wine.inStock));
const showNa = Boolean(naPick && !shownIds.has(naPick.wine.id));
const closest = filtered.length === 0 ? ranked.find((s) => passes(s, false)) : undefined;
const weakBest = filtered.length > 0 && filtered[0].score < 60 ? ranked.find((s) => passes(s, false) && s.score >= 70) : undefined;
const hovered = hoverId ? ranked.find((s) => s.wine.id === hoverId) : undefined;
const hoverAxes = hovered?.why.map((w) => w.axis) ?? [];
const radarAxes = busy ? live.axes : hoverAxes;
const radarProfile = busy || !target ? live.profile : target;
/* ------------------------------- asking ------------------------------ */
const run = async (query: string, opts: { dishId?: string; short?: boolean; userText?: string | null } = {}) => {
const runId = ++runRef.current;
let match: Match;
if (opts.dishId) {
const d = dishes.find((x) => x.id === opts.dishId);
match = d ? { dish: d, profile: d.profile, label: d.label } : matchDish(query, dishes);
} else match = matchDish(query, dishes);
const aid = nid("a");
setMessages((ms) => [
...ms,
...(opts.userText === null ? [] : [{ id: nid("u"), role: "user" as const, text: opts.userText ?? query }]),
{ id: aid, role: "assistant", query, dishId: match.dish?.id ?? null, note: match.note, steps: [], streaming: false, thinking: true, short: opts.short },
]);
setBusy(true);
setHoverId(null);
const startProfile = live.profile;
setLive({ profile: startProfile, axes: [], thinking: true });
const topPick = rank(wines, match.profile, match.dish, axes).find((s) => passes(s));
let source: AsyncIterable<string>;
try {
if (onAsk) {
const r = onAsk(query);
source = isAsyncIterable(r)
? r
: (async function* () {
yield await r;
})();
} else source = scriptedStream(reasoningText(match, Boolean(opts.short), topPick), { whole: reduced });
let text = "";
const lit = new Set<AxisKey>();
let first = true;
for await (const chunk of source) {
if (runRef.current !== runId) return;
text += chunk;
const steps = text
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
const latest = steps[steps.length - 1] ?? "";
const now = axesIn(latest, axes);
now.forEach((k) => lit.add(k));
// Morph: axes the reasoning has touched move to the dish's value; the rest ease halfway.
const profile = Object.fromEntries(
axes.map((a) => [a.key, lit.has(a.key) ? match.profile[a.key] : startProfile[a.key] + (match.profile[a.key] - startProfile[a.key]) * 0.35]),
) as Profile;
setLive({ profile, axes: now, thinking: false });
patchMsg(aid, { steps, thinking: false, streaming: true });
if (first) {
first = false;
setAnnounce(`${assistantName} is answering`);
}
}
if (runRef.current !== runId) return;
const steps = text
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
patchMsg(aid, { steps, thinking: false, streaming: false, clarify: match.dish?.clarify });
setLive({ profile: match.profile, axes: [], thinking: false });
setActive({ match, messageId: aid });
setBusy(false);
setAnnounce(`${steps.join(". ")}. ${match.dish?.clarify ? match.dish.clarify.question : "Top picks updated."}`);
if (typeof window !== "undefined" && window.matchMedia("(max-width: 1023px)").matches && !match.dish?.clarify) {
window.setTimeout(() => picksRef.current?.scrollIntoView({ behavior: reduced ? "auto" : "smooth", block: "start" }), 250);
}
} catch (err) {
if (runRef.current !== runId) return;
patchMsg(aid, { thinking: false, streaming: false, error: err instanceof Error ? err.message : "Something went wrong." });
setLive((l) => ({ ...l, thinking: false, axes: [] }));
setBusy(false);
setAnnounce("The sommelier could not answer. Retry is available.");
}
};
const stop = () => {
runRef.current++;
setBusy(false);
setMessages((ms) => ms.map((m) => (m.role === "assistant" && (m.streaming || m.thinking) ? { ...m, streaming: false, thinking: false, steps: m.steps.length ? m.steps : ["Stopped."] } : m)));
setLive((l) => ({ ...l, thinking: false, axes: [] }));
};
const explain = (messageId: string) => {
const m = messages.find((x) => x.id === messageId);
if (!m || m.role !== "assistant" || busy) return;
void run(m.query, { dishId: m.dishId ?? undefined, short: !m.short, userText: null });
};
// Keep the chat scrolled to the newest message (desktop column).
React.useEffect(() => {
const el = logRef.current;
if (el && el.scrollHeight > el.clientHeight) el.scrollTo({ top: el.scrollHeight, behavior: "auto" });
}, [messages]);
// "/" focuses the prompt.
React.useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const t = e.target as HTMLElement;
if (e.key !== "/" || t.closest("input,textarea,select,[contenteditable]")) return;
e.preventDefault();
const el = promptRef.current?.offsetParent ? promptRef.current : promptMobileRef.current;
el?.focus();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, []);
/* ------------------------------ actions ------------------------------ */
const addToCart = (w: Wine) => {
setBasket((b) => ({ ...b, [w.id]: (b[w.id] ?? 0) + 1 }));
setBump((n) => n + 1);
setAnnounce(`${w.producer} ${w.name} added to basket`);
onAddToCart?.(w);
};
const basketCount = Object.values(basket).reduce((a, b) => a + b, 0);
const toggleCompare = (id: string, on: boolean) => setCompare((c) => (on ? (c.includes(id) ? c : [...c, id].slice(0, 3)) : c.filter((x) => x !== id)));
const compareItems = compare.map((id) => ranked.find((s) => s.wine.id === id)).filter((s): s is Scored => Boolean(s));
const overlays = hovered ? [{ id: hovered.wine.id, profile: hovered.wine.profile, color: hovered.wine.type === "white" || hovered.wine.type === "sparkling" ? "#ca8a04" : hovered.wine.color, label: hovered.wine.name }] : [];
const targetLabel = active?.match.label ?? "No dish yet";
const latestAssistant = [...messages].reverse().find((m) => m.role === "assistant")?.id ?? null;
const empty = messages.length === 0;
const lastUser = [...messages].reverse().find((m) => m.role === "user");
const lastUserText = lastUser && lastUser.role === "user" ? lastUser.text.toLowerCase() : "";
const promptCommon = { busy, onSubmit: (t: string) => void run(t), onStop: stop };
return (
<MotionConfig reducedMotion="user">
<div className={cn("relative isolate flex h-[760px] w-full flex-col overflow-hidden bg-background text-foreground antialiased", className)}>
<div inert={compareOpen ? true : undefined} className="flex min-h-0 flex-1 flex-col">
{/* Header */}
<header className="flex h-14 shrink-0 items-center gap-3 border-b px-4">
<span aria-hidden className="grid size-8 place-items-center rounded-xl bg-gradient-to-br from-rose-500 via-fuchsia-600 to-violet-600 text-white shadow-md shadow-fuchsia-600/25">
<Grape className="size-4.5" />
</span>
<div className="min-w-0 leading-tight">
<h1 className="truncate text-sm font-semibold tracking-tight">{assistantName}</h1>
<p className="truncate text-[11px] text-muted-foreground">Food & wine pairing assistant</p>
</div>
<div className="ml-auto flex items-center gap-1.5">
<AnimatePresence>
{compare.length > 0 && (
<motion.button
type="button"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
onClick={() => setCompareOpen(true)}
disabled={compare.length < 2}
title={compare.length < 2 ? "Pick at least 2 bottles" : undefined}
aria-label={compare.length < 2 ? `Compare: ${compare.length} of 3 selected, pick at least 2` : `Compare ${compare.length} bottles`}
className={cn("inline-flex h-9 items-center gap-1.5 rounded-xl border px-3 text-[13px] font-medium hover:bg-muted disabled:opacity-60", focusRing)}
>
<GitCompareArrows className="size-4" aria-hidden />
<span className="hidden sm:inline">Compare</span>
<span className="flex -space-x-1" aria-hidden>
{compare.map((id, i) => (
<span key={id} className="size-2.5 rounded-full ring-2 ring-background" style={{ background: COMPARE_COLORS[i] }} />
))}
</span>
<span className="tabular-nums">{compare.length}/3</span>
</motion.button>
)}
</AnimatePresence>
<span className={cn("relative inline-flex h-9 items-center gap-1.5 rounded-xl px-2.5 text-[13px] font-medium", basketCount ? "bg-primary/10 text-foreground" : "text-muted-foreground")} aria-label={`Basket, ${basketCount} items`} role="status">
<ShoppingBag className="size-4" aria-hidden />
<motion.span key={bump} initial={bump ? { scale: 1.6, color: "var(--color-primary)" } : false} animate={{ scale: 1, color: "currentColor" }} className="tabular-nums" aria-hidden>
{basketCount}
</motion.span>
</span>
</div>
</header>
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain lg:grid lg:grid-cols-[minmax(0,4fr)_minmax(0,7fr)] lg:overflow-hidden">
{/* Conversation */}
<section aria-label="Conversation" className="flex flex-col lg:min-h-0 lg:border-r">
<div ref={logRef} role="log" aria-live="polite" aria-relevant="additions" aria-label="Sommelier conversation" className="p-4 lg:min-h-0 lg:flex-1 lg:overflow-y-auto">
{empty ? (
<Intro assistantName={assistantName} onPick={(s) => void run(s)} dishCount={dishes.length} wineCount={wines.length} />
) : (
<>
<ReasoningStream
messages={messages}
axes={axes}
assistantName={assistantName}
latestId={latestAssistant}
onClarify={(label, dishId) => void run(label, { dishId, userText: label })}
onRetry={(q) => void run(q, { userText: null })}
onExplain={explain}
/>
{!busy && (
<div className="mt-4 flex flex-wrap gap-1.5" aria-label="Try another dish">
{SUGGESTIONS.filter((s) => s.toLowerCase() !== lastUserText)
.slice(0, 4)
.map((s) => (
<Chip key={s} onClick={() => void run(s)}>
{s}
</Chip>
))}
</div>
)}
</>
)}
</div>
<div className="hidden shrink-0 border-t p-3 lg:block">
<PromptBar id="som-prompt" inputRef={promptRef} {...promptCommon} />
{showAgeNotice && <AgeNotice />}
</div>
</section>
{/* Profile + picks */}
<section aria-label="Recommendations" className="space-y-4 border-t p-4 lg:min-h-0 lg:overflow-y-auto lg:border-t-0">
<Filters
budget={budget}
range={budgetRange}
onBudget={setBudget}
types={types}
onTypes={setTypes}
inStore={inStore}
onInStore={setInStore}
money={money}
/>
<div className="rounded-2xl border bg-card">
<button
type="button"
onClick={() => setTasteOpen((o) => !o)}
aria-expanded={tasteOpen}
aria-controls="som-taste"
className={cn("flex w-full items-center gap-2 rounded-2xl px-4 py-3 text-left lg:hidden", focusRing)}
>
<Utensils className="size-4 text-muted-foreground" aria-hidden />
<span className="text-[13px] font-semibold">Taste profile</span>
<span className="truncate text-[12px] text-muted-foreground">· {targetLabel}</span>
<ChevronDown className={cn("ml-auto size-4 text-muted-foreground transition-transform", tasteOpen && "rotate-180")} aria-hidden />
</button>
<h2 className="hidden items-center gap-2 px-4 pt-3 lg:flex">
<Utensils className="size-4 text-muted-foreground" aria-hidden />
<span className="text-[13px] font-semibold">Taste profile</span>
<span className="truncate text-[12px] font-normal text-muted-foreground">· {targetLabel}</span>
</h2>
<div id="som-taste" className={cn("grid gap-3 px-4 pb-4 lg:pt-1 sm:grid-cols-[minmax(0,230px)_minmax(0,1fr)] sm:items-center", !tasteOpen && "hidden lg:grid")}>
<FlavorRadar
axes={axes}
target={radarProfile}
targetLabel={targetLabel}
overlays={overlays}
activeAxes={radarAxes}
thinking={live.thinking}
className="mx-auto w-full max-w-[230px]"
/>
<ProfileBars axes={axes} target={radarProfile} hovered={hovered?.wine} active={radarAxes} />
</div>
</div>
<div ref={picksRef} className="scroll-mt-4">
<div className="mb-2 flex items-baseline justify-between">
<h2 className="text-[13px] font-semibold">Top picks</h2>
{target && (
<p className="text-[11.5px] text-muted-foreground" aria-live="polite">
{filtered.length} of {wines.length} bottles match your filters
</p>
)}
</div>
{!target ? (
<PicksPlaceholder thinking={live.thinking} />
) : filtered.length === 0 ? (
<div className="rounded-2xl border border-dashed p-5 text-center">
<p className="text-sm font-medium">
Nothing between {money(budget[0])} and {money(budget[1])} fits
{types.length ? ` in ${types.map((t) => TYPE_LABELS[t]).join(" / ")}` : ""}.
</p>
{closest ? (
<>
<p className="mt-1 text-[13px] text-muted-foreground">
Closest match is {closest.wine.producer} {closest.wine.name} at {money(closest.wine.price)} ({closest.score}%).
</p>
<button
type="button"
onClick={() => setBudget([Math.min(budget[0], closest.wine.price), Math.max(budget[1], closest.wine.price)])}
className={cn("mt-3 h-10 rounded-xl bg-primary px-4 text-[13px] font-semibold text-primary-foreground", focusRing)}
>
Widen budget to {money(closest.wine.price)}
</button>
</>
) : (
<button type="button" onClick={() => setTypes([])} className={cn("mt-3 h-10 rounded-xl border px-4 text-[13px] font-semibold", focusRing)}>
Clear type filter
</button>
)}
</div>
) : (
<LayoutGroup id="som-picks">
{weakBest && (
<p className="mb-2 rounded-xl bg-amber-500/10 px-3 py-2 text-[12px] text-amber-800 dark:text-amber-300">
Best fit in budget is only {filtered[0].score}%. {weakBest.wine.name} ({money(weakBest.wine.price)}) scores {weakBest.score}%.{" "}
<button type="button" className="font-semibold underline underline-offset-2" onClick={() => setBudget([Math.min(budget[0], weakBest.wine.price), Math.max(budget[1], weakBest.wine.price)])}>
Widen budget
</button>
</p>
)}
<div className="-mx-4 flex snap-x snap-mandatory scroll-px-4 gap-3 overflow-x-auto px-4 pb-1 [scrollbar-width:none] lg:mx-0 lg:grid lg:grid-cols-3 lg:overflow-visible lg:px-0" aria-label="Top picks carousel">
<AnimatePresence mode="popLayout" initial={true}>
{top.map((s, i) => (
<PickCard
key={`${s.wine.id}`}
s={s}
rankIndex={i}
money={money}
compared={compare.includes(s.wine.id)}
compareDisabled={compare.length >= 3}
inBasket={basket[s.wine.id] ?? 0}
highlightAxes={hoverId === s.wine.id ? hoverAxes : []}
onHover={setHoverId}
onCompare={toggleCompare}
onAdd={addToCart}
delay={i * 0.09}
/>
))}
</AnimatePresence>
<div aria-hidden className="w-1 shrink-0 lg:hidden" />
</div>
{(more.length > 0 || showNa) && (
<div className="mt-4">
<h3 className="mb-1.5 text-[12px] font-semibold text-muted-foreground">More options</h3>
<ul className="divide-y rounded-2xl border bg-card">
<AnimatePresence initial={false}>
{showNa && naPick && (
<OptionRow key={`na-${naPick.wine.id}`} s={naPick} money={money} onHover={setHoverId} onAdd={addToCart} inBasket={basket[naPick.wine.id] ?? 0} badge="Alcohol-free pick" />
)}
{more.map((s) => (
<OptionRow
key={s.wine.id}
s={s}
money={money}
onHover={setHoverId}
onAdd={addToCart}
inBasket={basket[s.wine.id] ?? 0}
badge={s.wine.type === "non-alcoholic" ? "Alcohol-free" : undefined}
/>
))}
</AnimatePresence>
</ul>
</div>
)}
</LayoutGroup>
)}
</div>
{showAgeNotice && <AgeNotice className="lg:hidden" />}
</section>
</div>
{/* Mobile prompt */}
<div className="shrink-0 border-t bg-background/95 p-3 backdrop-blur lg:hidden">
<PromptBar id="som-prompt-m" inputRef={promptMobileRef} {...promptCommon} placeholder="Describe a dish…" />
</div>
</div>
<p className="sr-only" aria-live="polite">
{announce}
</p>
<CompareDrawer
open={compareOpen}
items={compareItems}
axes={axes}
target={target ?? NEUTRAL_PROFILE}
targetLabel={targetLabel}
money={money}
onClose={() => setCompareOpen(false)}
onRemove={(id) => {
const next = compare.filter((x) => x !== id);
setCompare(next);
if (next.length < 2) setCompareOpen(false);
}}
onAdd={addToCart}
/>
</div>
</MotionConfig>
);
}
/* ------------------------------------------------------------------ */
function Chip({ children, onClick }: { children: React.ReactNode; onClick: () => void }) {
return (
<motion.button
type="button"
whileTap={{ scale: 0.96 }}
onClick={onClick}
className={cn("h-9 rounded-full border bg-card px-3.5 text-[12.5px] font-medium shadow-sm transition hover:border-primary/40 hover:bg-primary/5", focusRing)}
>
{children}
</motion.button>
);
}
function AgeNotice({ className }: { className?: string }) {
return <p className={cn("mt-2 text-center text-[11px] text-muted-foreground", className)}>18+ only. Drink responsibly. Alcohol-free picks are always included.</p>;
}
function Intro({ assistantName, onPick, dishCount, wineCount }: { assistantName: string; onPick: (s: string) => void; dishCount: number; wineCount: number }) {
return (
<div className="flex flex-col items-start py-2 lg:py-6">
<div className="relative">
<motion.div
aria-hidden
className="absolute -inset-3 rounded-full bg-gradient-to-br from-rose-500/30 via-fuchsia-500/20 to-violet-500/30 blur-xl"
animate={{ opacity: [0.5, 0.9, 0.5] }}
transition={{ duration: 3, repeat: Infinity }}
/>
<span className="relative grid size-12 place-items-center rounded-2xl bg-gradient-to-br from-rose-500 via-fuchsia-600 to-violet-600 text-white shadow-lg">
<Grape className="size-6" aria-hidden />
</span>
</div>
<h2 className="mt-4 text-xl font-semibold tracking-tight">What's for dinner?</h2>
<p className="mt-1 max-w-sm text-[13.5px] text-muted-foreground">
Tell {assistantName} what you're eating. It reads the dish, explains its reasoning and ranks {wineCount} bottles by how well they fit. {dishCount} dishes known, and it asks when it isn't sure.
</p>
<p className="mt-5 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">Try one</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{SUGGESTIONS.map((s) => (
<Chip key={s} onClick={() => onPick(s)}>
{s}
</Chip>
))}
</div>
</div>
);
}
function ProfileBars({ axes, target, hovered, active }: { axes: Axis[]; target: Profile; hovered?: Wine; active: AxisKey[] }) {
const on = new Set(active);
return (
<div>
<div className="mb-2 flex items-center gap-3 text-[11px] text-muted-foreground">
<span className="inline-flex items-center gap-1.5">
<span className="h-2.5 w-4 rounded-sm bg-primary/40 ring-1 ring-primary" aria-hidden /> Dish wants
</span>
{hovered ? (
<span className="inline-flex min-w-0 items-center gap-1.5">
<span className="h-0 w-4 border-t-2 border-dashed border-amber-500" aria-hidden />
<span className="truncate">{hovered.name}</span>
</span>
) : (
<span>Hover a bottle to overlay it</span>
)}
</div>
<dl className="space-y-1.5">
{axes.map((a) => (
<div key={a.key} className="grid grid-cols-[72px_minmax(0,1fr)_28px] items-center gap-2">
<dt className={cn("text-[12px] transition-colors", on.has(a.key) ? "font-semibold text-primary" : "text-muted-foreground")}>{a.label}</dt>
<dd className="relative h-2 rounded-full bg-muted">
<motion.span className="absolute inset-y-0 left-0 rounded-full bg-primary/70" initial={false} animate={{ width: `${(target[a.key] ?? 0) * 100}%` }} transition={{ type: "spring", stiffness: 140, damping: 20 }} />
{hovered && (
<motion.span
className="absolute -top-1 h-4 w-1 rounded-full bg-amber-500 ring-2 ring-card"
initial={false}
animate={{ left: `calc(${hovered.profile[a.key] * 100}% - 2px)` }}
transition={{ type: "spring", stiffness: 300, damping: 26 }}
/>
)}
</dd>
<dd className="text-right text-[11px] tabular-nums text-muted-foreground">{Math.round((target[a.key] ?? 0) * 10)}</dd>
</div>
))}
</dl>
</div>
);
}
function PicksPlaceholder({ thinking }: { thinking: boolean }) {
return (
<div className="-mx-4 flex gap-3 overflow-hidden px-4 lg:mx-0 lg:grid lg:grid-cols-3 lg:px-0" aria-hidden>
{[0, 1, 2].map((i) => (
<motion.div
key={i}
animate={thinking ? { opacity: [0.5, 1, 0.5] } : { opacity: 1 }}
transition={thinking ? { duration: 1.2, repeat: Infinity, delay: i * 0.15 } : undefined}
className="flex h-[250px] w-[78%] shrink-0 flex-col rounded-2xl border border-dashed p-3 sm:w-[46%] lg:w-auto"
>
<div className="flex gap-3">
<div className="h-24 w-12 rounded-xl bg-muted" />
<div className="flex-1 space-y-2 pt-1">
<div className="h-2.5 w-1/2 rounded bg-muted" />
<div className="h-3.5 w-4/5 rounded bg-muted" />
<div className="h-2.5 w-2/3 rounded bg-muted" />
</div>
</div>
<div className="mt-4 space-y-1.5">
<div className="h-5 w-4/5 rounded-lg bg-muted" />
<div className="h-5 w-3/5 rounded-lg bg-muted" />
</div>
<div className="mt-auto h-10 rounded-xl bg-muted" />
</motion.div>
))}
</div>
);
}
function Filters({
budget,
range,
onBudget,
types,
onTypes,
inStore,
onInStore,
money,
}: {
budget: [number, number];
range: [number, number];
onBudget: (b: [number, number]) => void;
types: WineType[];
onTypes: (t: WineType[]) => void;
inStore: boolean;
onInStore: (v: boolean) => void;
money: (v: number) => string;
}) {
const [lo, hi] = range;
const pct = (v: number) => ((v - lo) / (hi - lo)) * 100;
const thumb =
"pointer-events-none absolute inset-0 h-full w-full appearance-none bg-transparent outline-none [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:size-5 [&::-moz-range-thumb]:cursor-grab [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-primary [&::-moz-range-thumb]:bg-background [&::-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-primary [&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:shadow-md focus-visible:[&::-webkit-slider-thumb]:ring-4 focus-visible:[&::-webkit-slider-thumb]:ring-primary/30";
const allTypes: WineType[] = ["red", "white", "rose", "sparkling", "non-alcoholic"];
return (
<div className="flex flex-wrap items-center gap-x-3 gap-y-3 rounded-2xl border bg-card px-3.5 py-2.5">
<SlidersHorizontal className="hidden size-4 text-muted-foreground xl:block" aria-hidden />
<fieldset className="flex min-w-[200px] flex-1 items-center gap-2.5 sm:flex-none">
<legend className="sr-only">Budget</legend>
<span className="text-[12px] font-medium text-muted-foreground">Budget</span>
<div className="relative h-8 w-full min-w-[120px] sm:w-32">
<div className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-muted" aria-hidden />
<div className="absolute top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-primary" style={{ left: `${pct(budget[0])}%`, right: `${100 - pct(budget[1])}%` }} aria-hidden />
<label htmlFor="som-min" className="sr-only">
Minimum price
</label>
<input id="som-min" type="range" min={lo} max={hi} step={5} value={budget[0]} onChange={(e) => onBudget([Math.min(Number(e.target.value), budget[1] - 10), budget[1]])} className={thumb} aria-valuetext={money(budget[0])} />
<label htmlFor="som-max" className="sr-only">
Maximum price
</label>
<input id="som-max" type="range" min={lo} max={hi} step={5} value={budget[1]} onChange={(e) => onBudget([budget[0], Math.max(Number(e.target.value), budget[0] + 10)])} className={thumb} aria-valuetext={money(budget[1])} />
</div>
<span className="shrink-0 whitespace-nowrap text-[12px] font-semibold tabular-nums">
{money(budget[0])}–{money(budget[1])}
</span>
</fieldset>
<fieldset className="-mx-1 flex max-w-full items-center gap-1 overflow-x-auto px-1 [scrollbar-width:none]">
<legend className="sr-only">Wine type</legend>
{allTypes.map((t) => {
const on = types.includes(t);
return (
<button
key={t}
type="button"
aria-pressed={on}
onClick={() => onTypes(on ? types.filter((x) => x !== t) : [...types, t])}
className={cn(
"h-8 shrink-0 rounded-full border px-3 text-[12px] font-medium transition",
on ? "border-primary bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground",
focusRing,
)}
>
{TYPE_LABELS[t]}
</button>
);
})}
</fieldset>
<label className="ml-auto inline-flex cursor-pointer items-center gap-2 text-[12px] font-medium">
<Store className="size-3.5 text-muted-foreground" aria-hidden />
In store
<input type="checkbox" role="switch" checked={inStore} onChange={(e) => onInStore(e.target.checked)} className="peer sr-only" />
<span aria-hidden className="relative h-5 w-9 rounded-full bg-muted-foreground/30 transition peer-checked:bg-primary peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-background after:absolute after:left-0.5 after:top-0.5 after:size-4 after:rounded-full after:bg-white after:shadow after:transition-transform peer-checked:after:translate-x-4" />
</label>
</div>
);
}
export default SommelierAiApp;