SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
8.4 KB · 200 lines tsx
Raw Blame History
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.&lt;slug&gt;.enabled</code>); lifecycle controls catalogue visibility.196      </p>197    </>198  );199}200