Fazekit

Code

"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&apos;s for dinner?</h2>
      <p className="mt-1 max-w-sm text-[13.5px] text-muted-foreground">
        Tell {assistantName} what you&apos;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&apos;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;

More in AI

View all →