Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Pause, Play, RotateCcw, Sparkles, Volume2, VolumeX, X } from "lucide-react";
import { cn } from "@/lib/utils";
import { BarrelArt, BottleArt, CoinIcon, EstateMark, GrapeIcon, VineArt } from "./art";
import {
  PRESTIGE_AT,
  createState,
  fmtDuration,
  fmtNum,
  parseState,
  reducer,
  simulateOffline,
  terroirGain,
  terroirMult,
  type CellarState,
  type OfflineReport,
} from "./engine";
import { FxLayer, useBlips, useTweenedNumber } from "./fx";
import { CellarPanel, MarketPanel, VineyardPanel } from "./panels";
import { UpgradesPanel } from "./upgrades";

export type { CellarState, OfflineReport } from "./engine";

export interface CellarTycoonTheme {
  /** Wine / primary accent colour */
  wine?: string;
  /** Coin & highlight colour */
  gold?: string;
  /** Vine / growth colour */
  leaf?: string;
  /** Grape colour */
  grape?: string;
}

export interface CellarTycoonProps {
  /** Name shown in the header. */
  estateName?: string;
  /** Start a fresh save at this vintage (each vintage past the first grants 1 terroir). Ignored when a save exists. */
  initialLevel?: number;
  /** localStorage key for the save. Pass `null` to disable persistence. */
  storageKey?: string | null;
  /** Called when a vintage ends (prestige) with the coins earned that vintage. */
  onGameOver?: (score: number) => void;
  /** Called after every prestige with the new vintage number. */
  onPrestige?: (vintage: number, terroir: number) => void;
  theme?: CellarTycoonTheme;
  className?: string;
}

type Tab = "vineyard" | "cellar" | "market" | "upgrades";

const TABS: { id: Tab; label: string }[] = [
  { id: "vineyard", label: "Vineyard" },
  { id: "cellar", label: "Cellar" },
  { id: "market", label: "Market" },
  { id: "upgrades", label: "Upgrades" },
];

function readSave(key: string | null): CellarState | null {
  if (!key) return null;
  try {
    const raw = window.localStorage.getItem(key);
    return raw ? parseState(JSON.parse(raw)) : null;
  } catch {
    return null;
  }
}

function writeSave(key: string | null, s: CellarState) {
  if (!key) return;
  try {
    window.localStorage.setItem(key, JSON.stringify({ ...s, savedAt: Date.now() }));
  } catch {
    /* storage full or blocked */
  }
}

function clearSave(key: string | null) {
  if (!key) return;
  try {
    window.localStorage.removeItem(key);
  } catch {
    /* ignore */
  }
}

export function CellarTycoon({ estateName = "Lumen Estate", initialLevel = 0, storageKey = "fazekit:cellar-tycoon:v1", onGameOver, onPrestige, theme, className }: CellarTycoonProps) {
  const reduce = useReducedMotion();
  const rootRef = React.useRef<HTMLDivElement>(null);
  const [s, dispatch] = React.useReducer(reducer, undefined, () => createState(initialLevel, initialLevel));
  const stateRef = React.useRef(s);
  React.useEffect(() => {
    stateRef.current = s;
  }, [s]);

  const [phase, setPhase] = React.useState<"loading" | "intro" | "playing">("loading");
  const [paused, setPaused] = React.useState(false);
  const [confirmReset, setConfirmReset] = React.useState(false);
  const [welcome, setWelcome] = React.useState<OfflineReport | null>(null);
  const [prestigeOpen, setPrestigeOpen] = React.useState(false);
  const [celebrate, setCelebrate] = React.useState<number | null>(null);
  const [tab, setTab] = React.useState<Tab>("vineyard");
  const [sound, setSound] = React.useState(false);
  const blip = useBlips(sound);
  const [wide, setWide] = React.useState(false);

  React.useEffect(() => {
    const el = rootRef.current;
    if (!el || typeof ResizeObserver === "undefined") return;
    const ro = new ResizeObserver(([e]) => setWide(e.contentRect.width >= 1152));
    ro.observe(el);
    return () => ro.disconnect();
  }, []);

  // ---- load save + offline progress (client only) ----
  React.useEffect(() => {
    const saved = readSave(storageKey);
    if (!saved) {
      setPhase("intro");
      return;
    }
    const away = saved.savedAt ? (Date.now() - saved.savedAt) / 1000 : 0;
    if (away > 5) {
      const { state, report } = simulateOffline(saved, away);
      dispatch({ type: "load", state });
      if (away > 30) setWelcome(report);
    } else {
      dispatch({ type: "load", state: saved });
    }
    setPhase("playing");
  }, [storageKey]);

  const running = phase === "playing" && !paused && !welcome && !prestigeOpen;

  // ---- game loop: 10 Hz ticks driven by rAF ----
  React.useEffect(() => {
    if (!running) return;
    let raf = 0;
    let last = performance.now();
    let acc = 0;
    const loop = (t: number) => {
      acc += Math.min(1, (t - last) / 1000);
      last = t;
      if (acc >= 0.1) {
        dispatch({ type: "tick", dt: acc });
        acc = 0;
      }
      raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(raf);
  }, [running]);

  // ---- autosave + away-time on tab hide ----
  React.useEffect(() => {
    if (phase !== "playing") return;
    const save = () => writeSave(storageKey, stateRef.current);
    const id = window.setInterval(save, 5000);
    let hiddenAt = 0;
    const onVis = () => {
      if (document.hidden) {
        hiddenAt = Date.now();
        save();
      } else if (hiddenAt) {
        const away = (Date.now() - hiddenAt) / 1000;
        hiddenAt = 0;
        if (away > 2 && !paused) {
          const { state, report } = simulateOffline(stateRef.current, away);
          dispatch({ type: "load", state });
          if (away > 60 && report.coins + report.grapes > 0) setWelcome(report);
        }
      }
    };
    document.addEventListener("visibilitychange", onVis);
    window.addEventListener("pagehide", save);
    return () => {
      window.clearInterval(id);
      document.removeEventListener("visibilitychange", onVis);
      window.removeEventListener("pagehide", save);
      save();
    };
  }, [phase, paused, storageKey]);

  // ---- measured income rate (coins/s over the last ~6s) ----
  const [rate, setRate] = React.useState(0);
  const [history, setHistory] = React.useState<number[]>([]);
  React.useEffect(() => {
    if (!running) return;
    const samples: [number, number][] = [];
    const id = window.setInterval(() => {
      samples.push([performance.now(), stateRef.current.runEarned]);
      if (samples.length > 6) samples.shift();
      if (samples.length > 1) {
        const [t0, e0] = samples[0];
        const [t1, e1] = samples[samples.length - 1];
        const r = Math.max(0, (e1 - e0) / ((t1 - t0) / 1000));
        setRate(r);
        setHistory((h) => [...h.slice(-39), r]);
      }
    }, 1000);
    return () => window.clearInterval(id);
  }, [running]);

  // ---- keyboard ----
  React.useEffect(() => {
    const onKey = (e: KeyboardEvent) => {
      const root = rootRef.current;
      const active = document.activeElement;
      if (!root || (active && active !== document.body && !root.contains(active))) return;
      if (e.metaKey || e.ctrlKey || e.altKey) return;
      if (e.key === "Escape") {
        if (confirmReset) setConfirmReset(false);
        else if (prestigeOpen) setPrestigeOpen(false);
        else if (welcome) setWelcome(null);
        else if (phase === "playing") setPaused((p) => !p);
        return;
      }
      if (!running) return;
      const k = e.key.toLowerCase();
      if (k === "p") setPaused(true);
      else if (k === "h") {
        dispatch({ type: "harvest-all" });
        blip("pluck");
      } else if (k === "s") {
        dispatch({ type: "sell" });
        blip("coin");
      } else if (["1", "2", "3", "4"].includes(k)) setTab(TABS[Number(k) - 1].id);
    };
    window.addEventListener("keydown", onKey);
    return () => window.removeEventListener("keydown", onKey);
  }, [running, phase, confirmReset, prestigeOpen, welcome, blip]);

  const coinsShown = useTweenedNumber(s.coins);

  const doPrestige = () => {
    const score = s.runEarned;
    const gain = terroirGain(s);
    if (!gain) return;
    dispatch({ type: "prestige" });
    setPrestigeOpen(false);
    setCelebrate(2026 + s.vintage + 1);
    blip("chime");
    onGameOver?.(score);
    onPrestige?.(s.vintage + 1, s.terroir + gain);
    window.setTimeout(() => setCelebrate(null), 2200);
  };

  const style = {
    "--ct-wine": theme?.wine ?? "#8c2146",
    "--ct-gold": theme?.gold ?? "#e3a92b",
    "--ct-leaf": theme?.leaf ?? "#5b9a3c",
    "--ct-grape": theme?.grape ?? "#6d2f86",
  } as React.CSSProperties;

  const panelProps = { s, dispatch, blip };

  return (
    <div
      ref={rootRef}
      style={style}
      className={cn(
        "@container/ct relative flex h-[700px] w-full flex-col overflow-hidden bg-background text-foreground",
        "[--ct-sky:#e9f3dc] [--ct-field:#cfe3b4] [--ct-stone-top:#f3ece4] [--ct-stone:#e6dbcf] [--ct-ink:#2d3b22] [--ct-wine-ink:var(--ct-wine)]",
        "dark:[--ct-sky:#1d2a1f] dark:[--ct-field:#27392a] dark:[--ct-stone-top:#2a2320] dark:[--ct-stone:#221c1a] dark:[--ct-ink:#e7f2dc] dark:[--ct-wine-ink:#f08aa8]",
        className,
      )}
    >
      {/* ambient backdrop */}
      <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(120%_60%_at_0%_0%,color-mix(in_oklab,var(--ct-leaf)_12%,transparent),transparent_60%),radial-gradient(90%_60%_at_100%_100%,color-mix(in_oklab,var(--ct-wine)_12%,transparent),transparent_60%)]" aria-hidden />

      <FxLayer rootRef={rootRef}>
        {/* header */}
        <header className="relative z-10 flex h-16 shrink-0 items-center gap-3 border-b bg-background/70 px-3 backdrop-blur @3xl/ct:px-5">
          <EstateMark className="size-9 shrink-0" />
          <div className="hidden min-w-0 @xl/ct:block">
            <p className="truncate text-sm font-bold tracking-tight">{estateName}</p>
            <p className="text-[11px] text-muted-foreground">
              Vintage {2026 + s.vintage}
              {s.terroir > 0 && <> · Terroir ×{terroirMult(s).toFixed(2)}</>}
            </p>
          </div>
          <div className="flex min-w-0 items-center gap-2 @xl/ct:ml-6">
            <CoinIcon className="size-7 shrink-0" />
            <div className="min-w-0">
              <p className="text-xl font-extrabold leading-none tracking-tight tabular-nums" aria-live="polite" aria-label={`${fmtNum(s.coins)} coins`}>
                {fmtNum(coinsShown)}
              </p>
              <p className="mt-0.5 text-[11px] tabular-nums text-muted-foreground">+{fmtNum(rate)}/s</p>
            </div>
          </div>
          <div className="ml-auto flex items-center gap-1.5">
            <Chip icon={<GrapeIcon className="size-4" />} label="Grapes" value={s.grapes} />
            <Chip icon={<BottleArt kind="standard" className="h-4 w-auto" />} label="Bottles" value={s.bottles} className="hidden @md/ct:flex" />
            <Chip icon={<BottleArt kind="reserve" className="h-4 w-auto" />} label="Reserve" value={s.reserve} className="hidden @lg/ct:flex" />
            <IconButton label={sound ? "Mute sounds" : "Unmute sounds"} onClick={() => setSound((v) => !v)}>
              {sound ? <Volume2 className="size-4" /> : <VolumeX className="size-4" />}
            </IconButton>
            <IconButton label="Pause" onClick={() => setPaused(true)} disabled={phase !== "playing"}>
              <Pause className="size-4" />
            </IconButton>
          </div>
        </header>

        {/* body */}
        <main className="relative z-0 min-h-0 flex-1 p-2 @3xl/ct:p-3">
          {wide ? (
            <div className="grid h-full grid-cols-[1.15fr_1fr_0.9fr_0.95fr] gap-3">
              <VineyardPanel {...panelProps} />
              <CellarPanel {...panelProps} />
              <MarketPanel {...panelProps} history={history} onPrestige={() => setPrestigeOpen(true)} />
              <UpgradesPanel {...panelProps} />
            </div>
          ) : (
            <AnimatePresence mode="wait" initial={false}>
              <motion.div
                key={tab}
                className="h-full [&>section]:h-full"
                initial={reduce ? false : { opacity: 0, x: 12 }}
                animate={{ opacity: 1, x: 0 }}
                exit={reduce ? { opacity: 0 } : { opacity: 0, x: -12 }}
                transition={{ duration: 0.16 }}
                role="tabpanel"
                id={`ct-panel-${tab}`}
              >
                {tab === "vineyard" && <VineyardPanel {...panelProps} />}
                {tab === "cellar" && <CellarPanel {...panelProps} />}
                {tab === "market" && <MarketPanel {...panelProps} history={history} onPrestige={() => setPrestigeOpen(true)} />}
                {tab === "upgrades" && <UpgradesPanel {...panelProps} />}
              </motion.div>
            </AnimatePresence>
          )}
        </main>

        {/* tab bar (narrow containers) */}
        {!wide && (
        <nav className="relative z-10 grid h-14 shrink-0 grid-cols-4 border-t bg-background/80 backdrop-blur" role="tablist" aria-label="Estate sections">
          {TABS.map((t, i) => {
            const badge = t.id === "vineyard" ? s.plots.filter((p) => p >= 1).length : t.id === "cellar" ? s.barrels.filter((b) => b !== null && b >= 12).length : t.id === "market" ? s.bottles + s.reserve : 0;
            return (
              <button
                key={t.id}
                type="button"
                role="tab"
                aria-selected={tab === t.id}
                aria-controls={`ct-panel-${t.id}`}
                onClick={() => setTab(t.id)}
                className={cn("relative flex flex-col items-center justify-center gap-0.5 text-[11px] font-semibold outline-none transition focus-visible:bg-accent", tab === t.id ? "text-foreground" : "text-muted-foreground")}
              >
                {tab === t.id && <motion.span layoutId="ct-tab" className="absolute inset-x-5 top-0 h-0.5 rounded-full bg-[var(--ct-wine)]" />}
                <span className="relative grid h-6 place-items-center">
                  {t.id === "vineyard" && <VineArt progress={1} className="size-6" />}
                  {t.id === "cellar" && <BarrelArt state="mature" className="h-5 w-auto" />}
                  {t.id === "market" && <BottleArt kind="standard" className="h-6 w-auto" />}
                  {t.id === "upgrades" && <Sparkles className="size-5 text-[var(--ct-gold)]" aria-hidden />}
                  {badge > 0 && <span className="absolute -right-3 -top-1 grid h-4 min-w-4 place-items-center rounded-full bg-[var(--ct-wine)] px-1 text-[9px] font-bold text-white tabular-nums">{fmtNum(badge)}</span>}
                </span>
                {t.label}
                <span className="sr-only">(key {i + 1})</span>
              </button>
            );
          })}
        </nav>
        )}
      </FxLayer>

      {/* ---------- overlays ---------- */}
      <AnimatePresence>
        {phase === "intro" && (
          <Overlay key="intro">
            <p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--ct-wine-ink)]">{estateName}</p>
            <h2 className="mt-2 text-2xl font-extrabold tracking-tight @md/ct:text-3xl">Cellar Tycoon</h2>
            <p className="mt-2 text-sm text-muted-foreground">Grow a vineyard, age barrels in the cool dark, and build a label people travel for.</p>
            <ol className="mt-5 grid grid-cols-4 items-end gap-2 text-[11px] font-medium text-muted-foreground">
              {[
                { art: <VineArt progress={1} className="mx-auto size-12" />, t: "Harvest" },
                { art: <BarrelArt state="young" className="mx-auto h-10 w-auto" />, t: "Age" },
                { art: <BottleArt kind="reserve" className="mx-auto h-11 w-auto" />, t: "Bottle" },
                { art: <CoinIcon className="mx-auto size-9" />, t: "Sell" },
              ].map((x, i) => (
                <motion.li key={x.t} initial={reduce ? false : { opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: 0.1 + i * 0.08 }} className="flex flex-col gap-1.5">
                  {x.art}
                  {x.t}
                </motion.li>
              ))}
            </ol>
            <button type="button" autoFocus onClick={() => setPhase("playing")} className="mt-6 inline-flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-[var(--ct-wine)] text-sm font-semibold text-white outline-none transition hover:brightness-110 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
              <Play className="size-4" /> Open the cellar
            </button>
            <p className="mt-3 text-[11px] text-muted-foreground">Progress saves automatically and keeps running while you&apos;re away.</p>
          </Overlay>
        )}

        {paused && phase === "playing" && (
          <Overlay key="pause" onClose={() => setPaused(false)}>
            <h2 className="text-xl font-extrabold tracking-tight">Paused</h2>
            <p className="mt-1 text-sm text-muted-foreground">The vines wait for you.</p>
            <dl className="mt-4 grid grid-cols-2 gap-2 text-left text-xs">
              {[
                ["This vintage", fmtNum(s.runEarned)],
                ["Best vintage", fmtNum(s.bestRun)],
                ["All-time earned", fmtNum(s.totalEarned)],
                ["Bottles made", fmtNum(s.bottled)],
                ["Bottles sold", fmtNum(s.sold)],
                ["Time played", fmtDuration(s.playTime)],
              ].map(([k, v]) => (
                <div key={k} className="rounded-lg bg-muted/70 px-3 py-2">
                  <dt className="text-muted-foreground">{k}</dt>
                  <dd className="font-semibold tabular-nums">{v}</dd>
                </div>
              ))}
            </dl>
            <button type="button" autoFocus onClick={() => setPaused(false)} className="mt-5 inline-flex h-11 w-full items-center justify-center gap-2 rounded-xl bg-foreground text-sm font-semibold text-background outline-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
              <Play className="size-4" /> Resume
            </button>
            {confirmReset ? (
              <div className="mt-3 flex gap-2">
                <button type="button" onClick={() => setConfirmReset(false)} className="h-9 flex-1 rounded-lg border text-xs font-semibold outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring">
                  Keep playing
                </button>
                <button
                  type="button"
                  onClick={() => {
                    clearSave(storageKey);
                    dispatch({ type: "reset" });
                    setConfirmReset(false);
                    setPaused(false);
                    setTab("vineyard");
                    setPhase("intro");
                  }}
                  className="h-9 flex-1 rounded-lg bg-destructive text-xs font-semibold text-white outline-none hover:opacity-90 focus-visible:ring-2 focus-visible:ring-ring"
                >
                  Erase everything
                </button>
              </div>
            ) : (
              <button type="button" onClick={() => setConfirmReset(true)} className="mt-3 inline-flex h-9 w-full items-center justify-center gap-1.5 rounded-lg text-xs font-semibold text-muted-foreground outline-none hover:text-destructive focus-visible:ring-2 focus-visible:ring-ring">
                <RotateCcw className="size-3.5" /> Reset save
              </button>
            )}
          </Overlay>
        )}

        {welcome && (
          <Overlay key="welcome" onClose={() => setWelcome(null)}>
            <motion.div initial={reduce ? false : { rotate: -8, scale: 0.8 }} animate={{ rotate: 0, scale: 1 }} transition={{ type: "spring", stiffness: 260, damping: 14 }}>
              <BarrelArt state="reserve" className="mx-auto h-16 w-auto" />
            </motion.div>
            <h2 className="mt-2 text-xl font-extrabold tracking-tight">Welcome back</h2>
            <p className="mt-1 text-sm text-muted-foreground">You were away for {fmtDuration(welcome.seconds)}. The estate kept working:</p>
            <ul className="mt-4 grid grid-cols-3 gap-2 text-xs">
              <Stat label="Coins" value={welcome.coins} icon={<CoinIcon className="size-5" />} />
              <Stat label="Grapes" value={welcome.grapes} icon={<GrapeIcon className="size-5" />} />
              <Stat label="Bottled" value={welcome.bottled} icon={<BottleArt kind="standard" className="h-5 w-auto" />} />
            </ul>
            {welcome.coins + welcome.grapes === 0 && <p className="mt-3 text-xs text-muted-foreground">Hire a harvest crew and a cellar hand to keep production running while you&apos;re gone.</p>}
            <button type="button" autoFocus onClick={() => setWelcome(null)} className="mt-5 inline-flex h-11 w-full items-center justify-center rounded-xl bg-[var(--ct-wine)] text-sm font-semibold text-white outline-none hover:brightness-110 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
              Collect
            </button>
          </Overlay>
        )}

        {prestigeOpen && (
          <Overlay key="prestige" onClose={() => setPrestigeOpen(false)}>
            <p className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--ct-wine-ink)]">New vintage</p>
            <h2 className="mt-1 text-2xl font-extrabold tracking-tight">Start vintage {2026 + s.vintage + 1}?</h2>
            <p className="mt-2 text-sm text-muted-foreground">Your vineyard, barrels, stock, coins and upgrades reset. The land remembers: you keep terroir, which boosts every grape and coin forever.</p>
            <div className="mt-4 grid grid-cols-2 gap-2 text-left text-xs">
              <div className="rounded-lg bg-muted/70 px-3 py-2">
                <p className="text-muted-foreground">Earned this vintage</p>
                <p className="font-semibold tabular-nums">{fmtNum(s.runEarned)}</p>
              </div>
              <div className="rounded-lg bg-[color-mix(in_oklab,var(--ct-wine)_12%,transparent)] px-3 py-2">
                <p className="text-muted-foreground">Terroir</p>
                <p className="font-semibold tabular-nums">
                  {s.terroir} → {s.terroir + terroirGain(s)} <span className="font-normal text-muted-foreground">(×{(1 + 0.25 * (s.terroir + terroirGain(s))).toFixed(2)})</span>
                </p>
              </div>
            </div>
            <div className="mt-5 flex gap-2">
              <button type="button" onClick={() => setPrestigeOpen(false)} className="h-11 flex-1 rounded-xl border text-sm font-semibold outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring">
                Not yet
              </button>
              <button type="button" autoFocus onClick={doPrestige} disabled={s.runEarned < PRESTIGE_AT} className="h-11 flex-1 rounded-xl bg-[var(--ct-wine)] text-sm font-semibold text-white outline-none hover:brightness-110 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2">
                Begin vintage
              </button>
            </div>
          </Overlay>
        )}

        {celebrate !== null && (
          <motion.div key="celebrate" className="pointer-events-none absolute inset-0 z-50 grid place-items-center" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, pointerEvents: "none" }} aria-live="assertive">
            <div className="absolute inset-0 bg-background/55 backdrop-blur-[2px]" />
            <div className="absolute inset-0 bg-[radial-gradient(circle_at_center,color-mix(in_oklab,var(--ct-wine)_35%,transparent),transparent_65%)]" />
            {!reduce &&
              Array.from({ length: 18 }, (_, i) => {
                const a = (i / 18) * Math.PI * 2;
                return (
                  <motion.span
                    key={i}
                    className="absolute size-3 rounded-full"
                    style={{ background: i % 3 === 0 ? "var(--ct-gold)" : i % 3 === 1 ? "var(--ct-leaf)" : "var(--ct-grape)" }}
                    initial={{ x: 0, y: 0, scale: 0.4 }}
                    animate={{ x: Math.cos(a) * 220, y: Math.sin(a) * 160, scale: 1, opacity: [1, 1, 0] }}
                    transition={{ duration: 1.6, ease: "easeOut" }}
                  />
                );
              })}
            <motion.div className="relative text-center" initial={{ scale: 0.6, y: 20 }} animate={{ scale: 1, y: 0 }} transition={{ type: "spring", stiffness: 200, damping: 12 }}>
              <p className="text-sm font-semibold uppercase tracking-[0.3em] text-[var(--ct-wine-ink)]">Vintage</p>
              <p className="text-7xl font-black tracking-tighter text-foreground tabular-nums">{celebrate}</p>
              <p className="mt-1 text-sm font-medium text-muted-foreground">Terroir ×{terroirMult(s).toFixed(2)} — the land remembers.</p>
            </motion.div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

function Chip({ icon, label, value, className }: { icon: React.ReactNode; label: string; value: number; className?: string }) {
  return (
    <span className={cn("flex h-9 items-center gap-1.5 rounded-full border bg-card/70 pl-2 pr-3 text-xs font-semibold tabular-nums", className)} title={label}>
      {icon}
      <span className="sr-only">{label}:</span>
      {fmtNum(value)}
    </span>
  );
}

function IconButton({ label, onClick, disabled, children }: { label: string; onClick: () => void; disabled?: boolean; children: React.ReactNode }) {
  return (
    <button type="button" aria-label={label} title={label} onClick={onClick} disabled={disabled} className="grid size-9 place-items-center rounded-full border bg-card/70 text-muted-foreground outline-none transition hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-40">
      {children}
    </button>
  );
}

function Stat({ label, value, icon }: { label: string; value: number; icon: React.ReactNode }) {
  return (
    <li className="flex flex-col items-center gap-1 rounded-lg bg-muted/70 px-2 py-2.5">
      {icon}
      <span className="text-sm font-bold tabular-nums">{fmtNum(value)}</span>
      <span className="text-muted-foreground">{label}</span>
    </li>
  );
}

function Overlay({ children, onClose }: { children: React.ReactNode; onClose?: () => void }) {
  const reduce = useReducedMotion();
  return (
    <motion.div className="absolute inset-0 z-50 grid place-items-center bg-background/60 p-4 backdrop-blur-md" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0, pointerEvents: "none" }}>
      <motion.div
        role="dialog"
        aria-modal="true"
        className="relative w-full max-w-sm rounded-3xl border bg-card p-6 text-center shadow-2xl"
        initial={reduce ? false : { scale: 0.92, y: 14 }}
        animate={{ scale: 1, y: 0 }}
        exit={reduce ? undefined : { scale: 0.95, y: 8 }}
        transition={{ type: "spring", stiffness: 380, damping: 30 }}
      >
        {onClose && (
          <button type="button" aria-label="Close" onClick={onClose} className="absolute right-3 top-3 grid size-8 place-items-center rounded-full text-muted-foreground outline-none hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">
            <X className="size-4" />
          </button>
        )}
        {children}
      </motion.div>
    </motion.div>
  );
}

More in Games

View all →