"use client"; import { useCallback, useEffect, useMemo, useState, type ComponentType } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { AnimatePresence, motion } from "framer-motion"; import { ArrowLeft, Heart, Info, Minus, Plus, Volume2, VolumeX } from "lucide-react"; import { BET_LEVELS, classifyWin, formatMultiplier, formatSC, WIN_CLASSES, type GameInfo } from "@spinza/shared"; import type { ArcadeGameDefinition } from "@spinza/game-core/client"; import { api } from "@/lib/api"; import { useSession } from "@/lib/store"; import { cn } from "@/lib/utils"; import { Credits, Sheet, Tabs } from "@/components/ui"; import { getSound } from "@/components/game/sound"; import type { ArcadeGameProps } from "./contract"; /** * Shared chrome for the Beyond Slots originals: top bar, bet controls, * balance, win banner, info sheet. The game component fills the middle. */ export function ArcadeShell({ game, definition, Game }: { game: GameInfo; definition: ArcadeGameDefinition; Game: ComponentType }) { const router = useRouter(); const { status, wallet, settings } = useSession(); const [bet, setBet] = useState(100); const [busy, setBusy] = useState(false); const [last, setLast] = useState<{ win: number; multiplier: number } | null>(null); const [banner, setBanner] = useState<{ label: string; win: number; multiplier: number } | null>(null); const [infoSheet, setInfoSheet] = useState(false); const [favorite, setFavorite] = useState(!!game.favorite); const soundOn = settings?.soundEnabled ?? true; const palette = definition.presentation.palette; const bets = useMemo(() => (BET_LEVELS as readonly number[]).filter((b) => b >= definition.minBet && b <= definition.maxBet), [definition]); const balance = wallet?.balance ?? 0; const betIndex = bets.indexOf(bet); useEffect(() => { if (status === "guest") router.replace(`/login?next=/games/${game.slug}`); }, [status, router, game.slug]); useEffect(() => { const s = getSound(); s.setLevels({ enabled: soundOn, master: settings?.masterVolume ?? 0.8, music: settings?.musicVolume ?? 0.6, effects: settings?.effectsVolume ?? 0.8 }); s.setAmbience(definition.presentation.ambience); api(`/api/games/${game.slug}/launch`, { method: "POST" }).catch(() => {}); return () => s.destroy(); }, [soundOn, settings?.masterVolume, settings?.musicVolume, settings?.effectsVolume, definition.presentation.ambience, game.slug]); const sound = useCallback((name) => { const s = getSound(); s.unlock(); switch (name) { case "click": s.click(); break; case "tick": s.tick(); break; case "win": s.win(2); break; case "bigWin": s.bigWin(); break; case "lose": s.error(); break; case "bonus": s.bonus(); break; } }, []); const onResult = useCallback((r) => { setLast(r); const cls = classifyWin(r.multiplier); if (cls === "big" || cls === "mega" || cls === "epic" || cls === "legendary") { setBanner({ label: WIN_CLASSES.find((c) => c.id === cls)?.label ?? "WIN", win: r.win, multiplier: r.multiplier }); setTimeout(() => setBanner(null), 2600); } }, []); const toggleFavorite = async () => { setFavorite((f) => !f); try { const r = await api<{ favorite: boolean }>(`/api/games/${game.slug}/favorite`, { method: "POST" }); setFavorite(r.favorite); } catch { setFavorite((f) => !f); } }; if (status === "guest") return null; return (
{game.name}
Spinza Original · Beyond Slots
{banner ? (
{banner.label}
{formatSC(banner.win)}
{formatMultiplier(banner.multiplier)} the bet
) : null}
Balance
Bet {formatSC(bet)}
Last win
0 ? "text-credit" : "text-fg-4")}>{last && last.win > 0 ? `${formatSC(last.win)} · ${formatMultiplier(last.multiplier)}` : "—"}
setInfoSheet(false)} game={game} definition={definition} />
); } function InfoSheet({ open, onClose, game, definition }: { open: boolean; onClose: () => void; game: GameInfo; definition: ArcadeGameDefinition }) { const [tab, setTab] = useState<"rules" | "about">("rules"); return ( {tab === "rules" ? (
    {game.rules.map((r, i) => (
  • {i + 1} {r}
  • ))}
) : (

{game.description}

{[ ["Volatility", game.volatility], ["RTP", `${(game.rtp * 100).toFixed(2)}%`], ["Max win", formatMultiplier(definition.maxMultiplier)], ["Mode", definition.mode === "ladder" ? "Step by step" : "Instant"], ].map(([k, v]) => (
{k}
{v}
))}
{game.certification ?

Certified on {game.certification.spins.toLocaleString("en-US")} simulated rounds · observed RTP {(game.certification.observedRtp * 100).toFixed(2)}%.

: null}

Spinza Credits are fictional and have no cash value.

)}
); }