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