TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import * as React from "react";4import Link from "next/link";5import { AlertTriangle, FlaskConical } from "lucide-react";6import { Button } from "@/components/ui";7import { api, ApiClientError } from "@/lib/api";8import { toast } from "@/lib/store";9import { GAME_LIFECYCLE, type GameLifecycle } from "@spinza/shared";10import { useAdminQuery } from "@/components/admin/use-query";11import type { GameListRow, GamesResponse } from "@/components/admin/types";12import { PageHeader, RefreshButton, Panel, DataTable, ErrorState, TableSkeleton, LifecyclePill, Toggle, DenseSelect, Pill, StatGrid, StatTile, InlineError, type Column } from "@/components/admin/primitives";13import { describeError, int, pct } from "@/components/admin/format";14import { cn } from "@/lib/utils";1516export default function AdminGamesPage() {17 const q = useAdminQuery<GamesResponse>("/api/admin/games");18 const d = q.data;19 const [busyKey, setBusyKey] = React.useState<string | null>(null);20 const [lifecycleError, setLifecycleError] = React.useState<{ slug: string; message: string } | null>(null);2122 const patchGame = React.useCallback(23 (slug: string, patch: Partial<GameListRow>) => {24 q.mutate((prev) => ({ games: prev.games.map((g) => (g.slug === slug ? { ...g, ...patch } : g)) }));25 },26 [q],27 );2829 async function setEnabled(g: GameListRow, enabled: boolean) {30 setBusyKey(`${g.slug}:enabled`);31 patchGame(g.slug, { enabled });32 try {33 await api(`/api/admin/flags/game.${g.slug}.enabled`, { json: { enabled } });34 toast({ title: `${g.name} ${enabled ? "enabled" : "disabled"}`, tone: enabled ? "success" : "default" });35 } catch (e) {36 patchGame(g.slug, { enabled: !enabled });37 toast({ title: "Flag update failed", description: describeError(e), tone: "danger" });38 } finally {39 setBusyKey(null);40 }41 }4243 async function setGameFlag(g: GameListRow, key: "isFeatured" | "isNew", value: boolean) {44 setBusyKey(`${g.slug}:${key}`);45 patchGame(g.slug, { [key]: value });46 try {47 await api(`/api/admin/games/${g.slug}/flags`, { json: { [key]: value } });48 } catch (e) {49 patchGame(g.slug, { [key]: !value });50 toast({ title: "Update failed", description: describeError(e), tone: "danger" });51 } finally {52 setBusyKey(null);53 }54 }5556 async function setLifecycle(g: GameListRow, lifecycle: GameLifecycle) {57 if (lifecycle === g.lifecycle) return;58 setBusyKey(`${g.slug}:lifecycle`);59 setLifecycleError(null);60 const prev = g.lifecycle;61 patchGame(g.slug, { lifecycle });62 try {63 await api(`/api/admin/games/${g.slug}/lifecycle`, { json: { lifecycle } });64 toast({ title: `${g.name} → ${lifecycle}`, tone: lifecycle === "published" ? "success" : "default" });65 void q.refresh();66 } catch (e) {67 patchGame(g.slug, { lifecycle: prev });68 const msg = e instanceof ApiClientError && e.code === "NOT_CERTIFIED" ? `NOT_CERTIFIED — ${e.message}` : describeError(e);69 setLifecycleError({ slug: g.slug, message: msg });70 toast({ title: "Lifecycle change rejected", description: msg, tone: "danger" });71 } finally {72 setBusyKey(null);73 }74 }7576 const cols: Column<GameListRow>[] = [77 {78 key: "name",79 header: "Game",80 render: (g) => (81 <div className="min-w-0">82 <Link href={`/admin/games/${g.slug}`} className="font-medium text-fg hover:text-accent-2">83 {g.name}84 </Link>85 <div className="font-mono text-[11px] text-fg-4">86 {g.slug} · v{g.version}87 </div>88 </div>89 ),90 },91 {92 key: "lifecycle",93 header: "Lifecycle",94 render: (g) => (95 <div className="flex items-center gap-2">96 <LifecyclePill lifecycle={g.lifecycle} />97 <DenseSelect value={g.lifecycle} disabled={busyKey === `${g.slug}:lifecycle`} onChange={(e) => void setLifecycle(g, e.target.value as GameLifecycle)} aria-label={`Lifecycle of ${g.name}`} className="w-[130px]">98 {GAME_LIFECYCLE.map((l) => (99 <option key={l} value={l}>100 {l}101 </option>102 ))}103 </DenseSelect>104 </div>105 ),106 },107 { key: "enabled", header: "Enabled", align: "center", render: (g) => <Toggle checked={g.enabled} disabled={busyKey === `${g.slug}:enabled`} onChange={(v) => void setEnabled(g, v)} label={`Enable ${g.name}`} /> },108 { key: "featured", header: "Featured", align: "center", render: (g) => <Toggle checked={g.isFeatured} disabled={busyKey === `${g.slug}:isFeatured`} onChange={(v) => void setGameFlag(g, "isFeatured", v)} label={`Feature ${g.name}`} /> },109 { key: "new", header: "New", align: "center", render: (g) => <Toggle checked={g.isNew} disabled={busyKey === `${g.slug}:isNew`} onChange={(v) => void setGameFlag(g, "isNew", v)} label={`Mark ${g.name} as new`} /> },110 {111 key: "rtp",112 header: "RTP target / effective",113 align: "right",114 render: (g) => {115 const eff = g.stats?.effectiveRtp ?? null;116 const dev = eff !== null && g.rtp !== null ? eff - g.rtp : null;117 return (118 <span className="tabular">119 {pct(g.rtp)} <span className="text-fg-4">/</span> <span className={cn(dev !== null && Math.abs(dev) > 0.03 ? "text-[#ffc46b]" : "")}>{pct(eff)}</span>120 </span>121 );122 },123 },124 { key: "spins", header: "Spins", align: "right", render: (g) => int(g.stats?.spins ?? 0) },125 {126 key: "validation",127 header: "Validation",128 align: "center",129 render: (g) => {130 const errs = g.validation.filter((v) => v.level === "error").length;131 const warns = g.validation.length - errs;132 if (!g.validation.length) return <Pill tone="success">ok</Pill>;133 return (134 <span className="inline-flex gap-1" title={g.validation.map((v) => `${v.level}: ${v.message}`).join("\n")}>135 {errs ? <Pill tone="danger">{errs} err</Pill> : null}136 {warns ? <Pill tone="warn">{warns} warn</Pill> : null}137 </span>138 );139 },140 },141 {142 key: "actions",143 header: "",144 align: "right",145 render: (g) => (146 <Button variant="ghost" size="sm" href={`/admin/simulator?slug=${g.slug}`} aria-label={`Simulate ${g.name}`}>147 <FlaskConical className="h-3.5 w-3.5" /> Simulate148 </Button>149 ),150 },151 ];152153 const summary = d154 ? {155 total: d.games.length,156 published: d.games.filter((g) => g.lifecycle === "published").length,157 disabled: d.games.filter((g) => !g.enabled || g.lifecycle === "disabled").length,158 issues: d.games.filter((g) => g.validation.some((v) => v.level === "error")).length,159 }160 : null;161162 return (163 <>164 <PageHeader title="Games" description="Library lifecycle, kill-switches and catalogue flags. Publishing requires a PASS certification for the current version." actions={<RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />} />165166 {summary ? (167 <StatGrid cols={4} className="mb-4">168 <StatTile label="Games in library" value={summary.total} compact />169 <StatTile label="Published" value={summary.published} tone="success" compact />170 <StatTile label="Disabled / off" value={summary.disabled} tone={summary.disabled ? "danger" : "neutral"} compact />171 <StatTile label="Definition errors" value={summary.issues} tone={summary.issues ? "danger" : "success"} compact />172 </StatGrid>173 ) : null}174175 {lifecycleError ? (176 <div className="mb-3">177 <InlineError>178 <span className="font-semibold">{lifecycleError.slug}</span>: {lifecycleError.message}179 </InlineError>180 </div>181 ) : null}182183 <Panel padded={false}>184 {q.error && !d ? (185 <div className="p-4">186 <ErrorState error={q.error} onRetry={() => void q.refresh()} />187 </div>188 ) : !d ? (189 <TableSkeleton rows={10} cols={8} />190 ) : (191 <DataTable columns={cols} rows={d.games} rowKey={(g) => g.id} dense stale={q.stale} empty="No games synced. Run the database seed." rowClassName={(g) => (lifecycleError?.slug === g.slug ? "bg-danger/5" : undefined)} />192 )}193 </Panel>194 <p className="mt-3 flex items-center gap-1.5 text-[12px] text-fg-4">195 <AlertTriangle className="h-3 w-3" /> “Enabled” is the runtime kill-switch (feature flag <code className="font-mono">game.<slug>.enabled</code>); lifecycle controls catalogue visibility.196 </p>197 </>198 );199}200