"use client"; import * as React from "react"; import Link from "next/link"; import { AlertTriangle, FlaskConical } from "lucide-react"; import { Button } from "@/components/ui"; import { api, ApiClientError } from "@/lib/api"; import { toast } from "@/lib/store"; import { GAME_LIFECYCLE, type GameLifecycle } from "@spinza/shared"; import { useAdminQuery } from "@/components/admin/use-query"; import type { GameListRow, GamesResponse } from "@/components/admin/types"; import { PageHeader, RefreshButton, Panel, DataTable, ErrorState, TableSkeleton, LifecyclePill, Toggle, DenseSelect, Pill, StatGrid, StatTile, InlineError, type Column } from "@/components/admin/primitives"; import { describeError, int, pct } from "@/components/admin/format"; import { cn } from "@/lib/utils"; export default function AdminGamesPage() { const q = useAdminQuery("/api/admin/games"); const d = q.data; const [busyKey, setBusyKey] = React.useState(null); const [lifecycleError, setLifecycleError] = React.useState<{ slug: string; message: string } | null>(null); const patchGame = React.useCallback( (slug: string, patch: Partial) => { q.mutate((prev) => ({ games: prev.games.map((g) => (g.slug === slug ? { ...g, ...patch } : g)) })); }, [q], ); async function setEnabled(g: GameListRow, enabled: boolean) { setBusyKey(`${g.slug}:enabled`); patchGame(g.slug, { enabled }); try { await api(`/api/admin/flags/game.${g.slug}.enabled`, { json: { enabled } }); toast({ title: `${g.name} ${enabled ? "enabled" : "disabled"}`, tone: enabled ? "success" : "default" }); } catch (e) { patchGame(g.slug, { enabled: !enabled }); toast({ title: "Flag update failed", description: describeError(e), tone: "danger" }); } finally { setBusyKey(null); } } async function setGameFlag(g: GameListRow, key: "isFeatured" | "isNew", value: boolean) { setBusyKey(`${g.slug}:${key}`); patchGame(g.slug, { [key]: value }); try { await api(`/api/admin/games/${g.slug}/flags`, { json: { [key]: value } }); } catch (e) { patchGame(g.slug, { [key]: !value }); toast({ title: "Update failed", description: describeError(e), tone: "danger" }); } finally { setBusyKey(null); } } async function setLifecycle(g: GameListRow, lifecycle: GameLifecycle) { if (lifecycle === g.lifecycle) return; setBusyKey(`${g.slug}:lifecycle`); setLifecycleError(null); const prev = g.lifecycle; patchGame(g.slug, { lifecycle }); try { await api(`/api/admin/games/${g.slug}/lifecycle`, { json: { lifecycle } }); toast({ title: `${g.name} → ${lifecycle}`, tone: lifecycle === "published" ? "success" : "default" }); void q.refresh(); } catch (e) { patchGame(g.slug, { lifecycle: prev }); const msg = e instanceof ApiClientError && e.code === "NOT_CERTIFIED" ? `NOT_CERTIFIED — ${e.message}` : describeError(e); setLifecycleError({ slug: g.slug, message: msg }); toast({ title: "Lifecycle change rejected", description: msg, tone: "danger" }); } finally { setBusyKey(null); } } const cols: Column[] = [ { key: "name", header: "Game", render: (g) => (
{g.name}
{g.slug} · v{g.version}
), }, { key: "lifecycle", header: "Lifecycle", render: (g) => (
void setLifecycle(g, e.target.value as GameLifecycle)} aria-label={`Lifecycle of ${g.name}`} className="w-[130px]"> {GAME_LIFECYCLE.map((l) => ( ))}
), }, { key: "enabled", header: "Enabled", align: "center", render: (g) => void setEnabled(g, v)} label={`Enable ${g.name}`} /> }, { key: "featured", header: "Featured", align: "center", render: (g) => void setGameFlag(g, "isFeatured", v)} label={`Feature ${g.name}`} /> }, { key: "new", header: "New", align: "center", render: (g) => void setGameFlag(g, "isNew", v)} label={`Mark ${g.name} as new`} /> }, { key: "rtp", header: "RTP target / effective", align: "right", render: (g) => { const eff = g.stats?.effectiveRtp ?? null; const dev = eff !== null && g.rtp !== null ? eff - g.rtp : null; return ( {pct(g.rtp)} / 0.03 ? "text-[#ffc46b]" : "")}>{pct(eff)} ); }, }, { key: "spins", header: "Spins", align: "right", render: (g) => int(g.stats?.spins ?? 0) }, { key: "validation", header: "Validation", align: "center", render: (g) => { const errs = g.validation.filter((v) => v.level === "error").length; const warns = g.validation.length - errs; if (!g.validation.length) return ok; return ( `${v.level}: ${v.message}`).join("\n")}> {errs ? {errs} err : null} {warns ? {warns} warn : null} ); }, }, { key: "actions", header: "", align: "right", render: (g) => ( ), }, ]; const summary = d ? { total: d.games.length, published: d.games.filter((g) => g.lifecycle === "published").length, disabled: d.games.filter((g) => !g.enabled || g.lifecycle === "disabled").length, issues: d.games.filter((g) => g.validation.some((v) => v.level === "error")).length, } : null; return ( <> void q.refresh()} loading={q.refreshing} />} /> {summary ? ( ) : null} {lifecycleError ? (
{lifecycleError.slug}: {lifecycleError.message}
) : null} {q.error && !d ? (
void q.refresh()} />
) : !d ? ( ) : ( g.id} dense stale={q.stale} empty="No games synced. Run the database seed." rowClassName={(g) => (lifecycleError?.slug === g.slug ? "bg-danger/5" : undefined)} /> )}

“Enabled” is the runtime kill-switch (feature flag game.<slug>.enabled); lifecycle controls catalogue visibility.

); }