TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import { useCallback, useEffect, useMemo, useState, type ComponentType } from "react";4import Link from "next/link";5import { useRouter } from "next/navigation";6import { AnimatePresence, motion } from "framer-motion";7import { ArrowLeft, Heart, Info, Minus, Plus, Volume2, VolumeX } from "lucide-react";8import { BET_LEVELS, classifyWin, formatMultiplier, formatSC, WIN_CLASSES, type GameInfo } from "@spinza/shared";9import type { ArcadeGameDefinition } from "@spinza/game-core/client";10import { api } from "@/lib/api";11import { useSession } from "@/lib/store";12import { cn } from "@/lib/utils";13import { Credits, Sheet, Tabs } from "@/components/ui";14import { getSound } from "@/components/game/sound";15import type { ArcadeGameProps } from "./contract";1617/**18 * Shared chrome for the Beyond Slots originals: top bar, bet controls,19 * balance, win banner, info sheet. The game component fills the middle.20 */21export function ArcadeShell({ game, definition, Game }: { game: GameInfo; definition: ArcadeGameDefinition; Game: ComponentType<ArcadeGameProps> }) {22 const router = useRouter();23 const { status, wallet, settings } = useSession();24 const [bet, setBet] = useState(100);25 const [busy, setBusy] = useState(false);26 const [last, setLast] = useState<{ win: number; multiplier: number } | null>(null);27 const [banner, setBanner] = useState<{ label: string; win: number; multiplier: number } | null>(null);28 const [infoSheet, setInfoSheet] = useState(false);29 const [favorite, setFavorite] = useState(!!game.favorite);30 const soundOn = settings?.soundEnabled ?? true;31 const palette = definition.presentation.palette;32 const bets = useMemo(() => (BET_LEVELS as readonly number[]).filter((b) => b >= definition.minBet && b <= definition.maxBet), [definition]);33 const balance = wallet?.balance ?? 0;34 const betIndex = bets.indexOf(bet);3536 useEffect(() => {37 if (status === "guest") router.replace(`/login?next=/games/${game.slug}`);38 }, [status, router, game.slug]);3940 useEffect(() => {41 const s = getSound();42 s.setLevels({ enabled: soundOn, master: settings?.masterVolume ?? 0.8, music: settings?.musicVolume ?? 0.6, effects: settings?.effectsVolume ?? 0.8 });43 s.setAmbience(definition.presentation.ambience);44 api(`/api/games/${game.slug}/launch`, { method: "POST" }).catch(() => {});45 return () => s.destroy();46 }, [soundOn, settings?.masterVolume, settings?.musicVolume, settings?.effectsVolume, definition.presentation.ambience, game.slug]);4748 const sound = useCallback<ArcadeGameProps["sound"]>((name) => {49 const s = getSound();50 s.unlock();51 switch (name) {52 case "click":53 s.click();54 break;55 case "tick":56 s.tick();57 break;58 case "win":59 s.win(2);60 break;61 case "bigWin":62 s.bigWin();63 break;64 case "lose":65 s.error();66 break;67 case "bonus":68 s.bonus();69 break;70 }71 }, []);7273 const onResult = useCallback<ArcadeGameProps["onResult"]>((r) => {74 setLast(r);75 const cls = classifyWin(r.multiplier);76 if (cls === "big" || cls === "mega" || cls === "epic" || cls === "legendary") {77 setBanner({ label: WIN_CLASSES.find((c) => c.id === cls)?.label ?? "WIN", win: r.win, multiplier: r.multiplier });78 setTimeout(() => setBanner(null), 2600);79 }80 }, []);8182 const toggleFavorite = async () => {83 setFavorite((f) => !f);84 try {85 const r = await api<{ favorite: boolean }>(`/api/games/${game.slug}/favorite`, { method: "POST" });86 setFavorite(r.favorite);87 } catch {88 setFavorite((f) => !f);89 }90 };9192 if (status === "guest") return null;9394 return (95 <div className="fixed inset-0 flex flex-col" style={{ background: `radial-gradient(120% 80% at 50% 0%, ${palette.surface} 0%, ${palette.bg} 60%, #050608 100%)` }}>96 <div className="flex items-center justify-between gap-2 px-3 py-2" style={{ paddingTop: "calc(var(--safe-top) + 8px)" }}>97 <div className="flex items-center gap-2">98 <Link href="/" className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Back to lobby">99 <ArrowLeft className="h-5 w-5" />100 </Link>101 <div className="leading-tight">102 <div className="text-[15px] font-semibold tracking-tight">{game.name}</div>103 <div className="text-[11px] text-fg-3">Spinza Original · Beyond Slots</div>104 </div>105 </div>106 <div className="flex items-center gap-1">107 <button onClick={toggleFavorite} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Favourite">108 <Heart className={cn("h-5 w-5", favorite && "fill-danger text-danger")} />109 </button>110 <button onClick={() => setInfoSheet(true)} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Game info">111 <Info className="h-5 w-5" />112 </button>113 <button onClick={() => useSession.getState().setSettings({ soundEnabled: !soundOn })} className="tap grid place-items-center rounded-md text-fg-2 hover:bg-white/10 focus-ring" aria-label="Sound">114 {soundOn ? <Volume2 className="h-5 w-5" /> : <VolumeX className="h-5 w-5" />}115 </button>116 </div>117 </div>118119 <div className="relative flex-1 min-h-0">120 <Game game={game} definition={definition} bet={bet} onBusy={setBusy} onResult={onResult} sound={sound} reduceMotion={settings?.reduceMotion ?? false} />121 <AnimatePresence>122 {banner ? (123 <motion.div key={banner.win} initial={{ opacity: 0, scale: 0.7 }} animate={{ opacity: 1, scale: 1 }} exit={{ opacity: 0 }} className="pointer-events-none absolute inset-0 grid place-items-center bg-black/40" style={{ zIndex: 5 }}>124 <div className="text-center">125 <div className="text-[clamp(34px,9vw,72px)] font-extrabold uppercase tracking-tight shimmer-text">{banner.label}</div>126 <div className="mt-2 text-3xl font-bold tabular text-credit">{formatSC(banner.win)}</div>127 <div className="text-sm text-fg-2">{formatMultiplier(banner.multiplier)} the bet</div>128 </div>129 </motion.div>130 ) : null}131 </AnimatePresence>132 </div>133134 <div className="glass border-x-0 border-b-0 px-3 pt-3" style={{ paddingBottom: "calc(var(--safe-bottom) + 12px)", borderTop: `1px solid ${palette.primary}55` }}>135 <div className="mx-auto flex max-w-3xl items-center justify-between gap-3">136 <div>137 <div className="eyebrow">Balance</div>138 <Credits amount={balance} size="md" />139 </div>140 <div className="flex items-center gap-1">141 <button disabled={busy || betIndex <= 0} onClick={() => setBet(bets[betIndex - 1])} className="tap grid h-10 w-10 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Decrease bet">142 <Minus className="h-4 w-4" />143 </button>144 <div className="flex h-10 min-w-[92px] flex-col items-center justify-center rounded-md surface-2 px-2">145 <span className="text-[10px] uppercase tracking-wider text-fg-3">Bet</span>146 <span className="text-sm font-bold tabular text-fg">{formatSC(bet)}</span>147 </div>148 <button disabled={busy || betIndex >= bets.length - 1} onClick={() => setBet(bets[betIndex + 1])} className="tap grid h-10 w-10 place-items-center rounded-md surface-2 disabled:opacity-40 focus-ring" aria-label="Increase bet">149 <Plus className="h-4 w-4" />150 </button>151 </div>152 <div className="text-right">153 <div className="eyebrow">Last win</div>154 <div className={cn("text-base font-semibold tabular", last && last.win > 0 ? "text-credit" : "text-fg-4")}>{last && last.win > 0 ? `${formatSC(last.win)} · ${formatMultiplier(last.multiplier)}` : "—"}</div>155 </div>156 </div>157 </div>158159 <InfoSheet open={infoSheet} onClose={() => setInfoSheet(false)} game={game} definition={definition} />160 </div>161 );162}163164function InfoSheet({ open, onClose, game, definition }: { open: boolean; onClose: () => void; game: GameInfo; definition: ArcadeGameDefinition }) {165 const [tab, setTab] = useState<"rules" | "about">("rules");166 return (167 <Sheet open={open} onClose={onClose} title={game.name} side="right">168 <Tabs value={tab} onChange={setTab} items={[{ value: "rules", label: "How to play" }, { value: "about", label: "About" }]} className="mb-4" />169 {tab === "rules" ? (170 <ul className="space-y-3 text-sm text-fg-2">171 {game.rules.map((r, i) => (172 <li key={i} className="flex gap-3">173 <span className="mt-0.5 grid h-5 w-5 shrink-0 place-items-center rounded-full bg-surface-2 text-[11px] font-bold text-fg-3">{i + 1}</span>174 <span>{r}</span>175 </li>176 ))}177 </ul>178 ) : (179 <div className="space-y-4 text-sm text-fg-2">180 <p>{game.description}</p>181 <dl className="grid grid-cols-2 gap-3">182 {[183 ["Volatility", game.volatility],184 ["RTP", `${(game.rtp * 100).toFixed(2)}%`],185 ["Max win", formatMultiplier(definition.maxMultiplier)],186 ["Mode", definition.mode === "ladder" ? "Step by step" : "Instant"],187 ].map(([k, v]) => (188 <div key={k} className="surface rounded-md p-3">189 <dt className="eyebrow">{k}</dt>190 <dd className="mt-1 text-sm font-semibold capitalize text-fg">{v}</dd>191 </div>192 ))}193 </dl>194 {game.certification ? <p className="text-[12px] text-fg-3">Certified on {game.certification.spins.toLocaleString("en-US")} simulated rounds · observed RTP {(game.certification.observedRtp * 100).toFixed(2)}%.</p> : null}195 <p className="text-[12px] text-fg-3">Spinza Credits are fictional and have no cash value.</p>196 </div>197 )}198 </Sheet>199 );200}201