TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import { useCallback, useEffect, useState } from "react";4import Link from "next/link";5import { ArrowLeft, History, Copy, Check, ChevronRight } from "lucide-react";6import type { RoundHistoryEntry } from "@spinza/shared";7import { formatMultiplier, formatSC, classifyWin, WIN_CLASSES } from "@spinza/shared";8import { api, ApiClientError } from "@/lib/api";9import { toast } from "@/lib/store";10import { cn } from "@/lib/utils";11import { Badge, Button, Card, Empty, Sheet, Skeleton } from "@/components/ui";12import { AppShell } from "@/components/shell/app-shell";13import { RequireAuth } from "@/components/shell/require-auth";14import { ApiErrorState } from "@/components/shell/api-error";15import { useRouter } from "next/navigation";1617type Entry = RoundHistoryEntry & { features: string[] };18interface Page {19 entries: Entry[];20 nextBefore: string | null;21}22interface RoundDetail {23 roundId: string;24 game: string;25 version: string;26 bet: number;27 win: number;28 multiplier: number;29 balanceAfter: number;30 createdAt: string;31 result: unknown;32 features: string[];33}3435const LIMIT = 40;3637function fmtDate(iso: string) {38 const d = new Date(iso);39 return d.toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });40}4142function WinTag({ multiplier }: { multiplier: number }) {43 const cls = classifyWin(multiplier);44 if (cls === "none" || cls === "regular") return null;45 const label = WIN_CLASSES.find((w) => w.id === cls)?.label ?? "";46 return <Badge tone={cls === "legendary" || cls === "epic" ? "accent" : cls === "mega" ? "new" : "success"}>{label}</Badge>;47}4849function RoundSheet({ roundId, onClose }: { roundId: string | null; onClose: () => void }) {50 const [detail, setDetail] = useState<RoundDetail | null>(null);51 const [error, setError] = useState<unknown>(null);52 const [copied, setCopied] = useState(false);5354 // Reset when a different round is opened (adjust-state-from-props), then fetch.55 const [seen, setSeen] = useState(roundId);56 if (roundId !== seen) {57 setSeen(roundId);58 setDetail(null);59 setError(null);60 }61 useEffect(() => {62 if (!roundId) return;63 let alive = true;64 api<RoundDetail>(`/api/user/rounds/${roundId}`)65 .then((d) => alive && setDetail(d))66 .catch((e: unknown) => alive && setError(e));67 return () => {68 alive = false;69 };70 }, [roundId]);7172 const copy = async () => {73 if (!roundId) return;74 try {75 await navigator.clipboard.writeText(roundId);76 setCopied(true);77 setTimeout(() => setCopied(false), 1500);78 } catch {79 toast({ title: "Copy unavailable", tone: "danger" });80 }81 };8283 const gameName = (detail?.game ?? "").replace(/-/g, " ");84 const resultObj = detail && detail.result && typeof detail.result === "object" ? (detail.result as Record<string, unknown>) : null;85 const summary = resultObj ? Object.entries(resultObj).filter(([, v]) => typeof v === "number" || typeof v === "string" || typeof v === "boolean") : [];8687 return (88 <Sheet open={!!roundId} onClose={onClose} title="Round details">89 {error ? (90 <ApiErrorState error={error} compact />91 ) : !detail ? (92 <div className="space-y-3" aria-busy>93 <Skeleton className="h-6 w-1/2" />94 <Skeleton className="h-24" />95 <Skeleton className="h-16" />96 </div>97 ) : (98 <div className="space-y-5">99 <div>100 <div className="flex items-center gap-2">101 <Link href={`/games/${detail.game}`} className="text-lg font-semibold capitalize tracking-tight hover:text-accent-2" onClick={onClose}>102 {gameName}103 </Link>104 <WinTag multiplier={detail.multiplier} />105 </div>106 <div className="text-[13px] text-fg-3">107 {fmtDate(detail.createdAt)} · v{detail.version}108 </div>109 </div>110 <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">111 {[112 { l: "Bet", v: formatSC(detail.bet) },113 { l: "Win", v: <span className={detail.win > 0 ? "text-credit" : ""}>{formatSC(detail.win)}</span> },114 { l: "Multiplier", v: formatMultiplier(detail.multiplier) },115 { l: "Balance after", v: formatSC(detail.balanceAfter) },116 ].map((x) => (117 <div key={x.l} className="surface rounded-md p-3">118 <div className="eyebrow">{x.l}</div>119 <div className="mt-1 text-[15px] font-semibold tabular">{x.v}</div>120 </div>121 ))}122 </div>123 <div>124 <div className="eyebrow mb-2">Features triggered</div>125 {detail.features.length ? (126 <div className="flex flex-wrap gap-1.5">127 {detail.features.map((f) => (128 <Badge key={f} tone="accent">129 {f.replace(/[_-]/g, " ")}130 </Badge>131 ))}132 </div>133 ) : (134 <p className="text-sm text-fg-3">Base game only — no features this round.</p>135 )}136 </div>137 {summary.length ? (138 <div>139 <div className="eyebrow mb-2">Outcome</div>140 <dl className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-[13px]">141 {summary.slice(0, 12).map(([k, v]) => (142 <div key={k} className="flex justify-between gap-2 border-b border-line py-1">143 <dt className="truncate capitalize text-fg-3">{k.replace(/([A-Z])/g, " $1").replace(/[_-]/g, " ")}</dt>144 <dd className="tabular font-medium">{String(v)}</dd>145 </div>146 ))}147 </dl>148 </div>149 ) : null}150 <div>151 <div className="eyebrow mb-2">Round ID</div>152 <div className="flex items-center gap-2">153 <code className="flex-1 truncate rounded-md bg-bg-1 px-3 py-2 font-mono text-[12px] text-fg-2">{detail.roundId}</code>154 <Button variant="secondary" size="icon" onClick={copy} aria-label="Copy round ID">155 {copied ? <Check className="h-4 w-4 text-success" /> : <Copy className="h-4 w-4" />}156 </Button>157 </div>158 <p className="mt-2 text-[12px] text-fg-4">Every round is recorded server-side. Quote this ID if you ever need to reference a specific spin.</p>159 </div>160 </div>161 )}162 </Sheet>163 );164}165166function fetchPage(before: string | null): Promise<Page> {167 const q = new URLSearchParams({ limit: String(LIMIT) });168 if (before) q.set("before", before);169 return api<Page>(`/api/user/history?${q}`);170}171172function HistoryBody() {173 const router = useRouter();174 const [entries, setEntries] = useState<Entry[] | null>(null); // null = first page loading175 const [nextBefore, setNextBefore] = useState<string | null>(null);176 const [more, setMore] = useState(false);177 const [error, setError] = useState<unknown>(null);178 const [open, setOpen] = useState<string | null>(null);179 const [tick, setTick] = useState(0);180181 const fail = useCallback(182 (e: unknown) => {183 if (e instanceof ApiClientError && e.status === 401) {184 router.replace("/login?next=%2Fprofile%2Fhistory&reason=expired");185 return;186 }187 setError(e);188 },189 [router],190 );191192 useEffect(() => {193 let alive = true;194 fetchPage(null)195 .then((page) => {196 if (!alive) return;197 setEntries(page.entries);198 setNextBefore(page.nextBefore);199 setError(null);200 })201 .catch((e: unknown) => alive && fail(e));202 return () => {203 alive = false;204 };205 }, [tick, fail]);206207 const retry = () => {208 setEntries(null);209 setError(null);210 setTick((t) => t + 1);211 };212213 const loadMore = async () => {214 if (!nextBefore || more) return;215 setMore(true);216 try {217 const page = await fetchPage(nextBefore);218 setEntries((cur) => [...(cur ?? []), ...page.entries]);219 setNextBefore(page.nextBefore);220 } catch (e) {221 fail(e);222 toast({ title: "Could not load more", tone: "danger" });223 } finally {224 setMore(false);225 }226 };227228 if (error && !entries?.length) return <ApiErrorState error={error} retry={retry} />;229 if (entries === null) {230 return (231 <div className="space-y-2" aria-busy>232 {Array.from({ length: 8 }).map((_, i) => (233 <Skeleton key={i} className="h-14 rounded-md" />234 ))}235 </div>236 );237 }238 if (!entries.length) return <Empty title="No rounds yet" description="Your spins will be listed here with bet, win, multiplier and balance." icon={<History className="h-5 w-5" />} action={<Button href="/games">Play a game</Button>} />;239240 return (241 <>242 <Card className="overflow-hidden">243 <div className="hidden grid-cols-[150px_1fr_110px_120px_90px_130px_28px] gap-3 border-b border-line px-4 py-2.5 text-[11px] font-semibold uppercase tracking-wider text-fg-3 md:grid">244 <span>Time</span>245 <span>Game</span>246 <span className="text-right">Bet</span>247 <span className="text-right">Win</span>248 <span className="text-right">Multi</span>249 <span className="text-right">Balance after</span>250 <span />251 </div>252 <ul className="divide-y divide-line">253 {entries.map((e) => (254 <li key={e.roundId}>255 <button onClick={() => setOpen(e.roundId)} className="grid w-full grid-cols-[1fr_auto] items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-surface-2 focus-ring md:grid-cols-[150px_1fr_110px_120px_90px_130px_28px]">256 <span className="text-[12px] tabular text-fg-3 md:text-[13px]">{fmtDate(e.createdAt)}</span>257 <span className="col-start-1 row-start-2 flex items-center gap-2 md:col-auto md:row-auto">258 <span className="truncate text-[15px] font-medium">{e.gameName}</span>259 <WinTag multiplier={e.multiplier} />260 </span>261 <span className="hidden text-right text-sm tabular text-fg-2 md:block">{formatSC(e.bet)}</span>262 <span className={cn("col-start-2 row-start-1 text-right text-sm font-semibold tabular md:col-auto md:row-auto", e.win > 0 ? "text-credit" : "text-fg-3")}>{e.win > 0 ? `+${formatSC(e.win)}` : formatSC(0)}</span>263 <span className="col-start-2 row-start-2 text-right text-[12px] tabular text-fg-3 md:col-auto md:row-auto md:text-sm">264 <span className="md:hidden">Bet {formatSC(e.bet, { unit: false })} · </span>265 {formatMultiplier(e.multiplier)}266 </span>267 <span className="hidden text-right text-sm tabular text-fg-2 md:block">{formatSC(e.balanceAfter)}</span>268 <ChevronRight className="hidden h-4 w-4 text-fg-4 md:block" />269 </button>270 </li>271 ))}272 </ul>273 </Card>274 {nextBefore ? (275 <div className="mt-4 flex justify-center">276 <Button variant="secondary" onClick={loadMore} loading={more}>277 Load more278 </Button>279 </div>280 ) : (281 <p className="mt-4 text-center text-[12px] text-fg-4">That's every round on record.</p>282 )}283 <RoundSheet roundId={open} onClose={() => setOpen(null)} />284 </>285 );286}287288export default function HistoryPage() {289 return (290 <AppShell>291 <RequireAuth>292 <div className="mb-6">293 <Link href="/profile" className="tap -ml-1 inline-flex h-9 min-h-0 items-center gap-1 text-sm text-fg-3 hover:text-fg">294 <ArrowLeft className="h-4 w-4" /> Profile295 </Link>296 <h1 className="mt-1 text-3xl font-semibold tracking-tight sm:text-4xl">Round history</h1>297 <p className="mt-2 max-w-xl text-sm text-fg-3">Every spin, newest first. Tap a round for the full result and the features it triggered.</p>298 </div>299 <HistoryBody />300 </RequireAuth>301 </AppShell>302 );303}304