TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import * as React from "react";4import { CheckCircle2, XCircle } from "lucide-react";5import { TRANSACTION_TYPES } from "@spinza/shared";6import { useAdminQuery } from "@/components/admin/use-query";7import type { EconomyResponse } from "@/components/admin/types";8import { PageHeader, RefreshButton, Panel, DataTable, ErrorState, TileSkeleton, TableSkeleton, StatGrid, StatTile, SegmentedControl, Pill, type Column } from "@/components/admin/primitives";9import { Bars, ChartFrame, CHART, Histogram, type Series } from "@/components/admin/charts";10import { compact, int, isoDay, num, pct, sc, shortDate, signedSC } from "@/components/admin/format";11import { cn } from "@/lib/utils";1213const DAYS = [7, 30, 90] as const;1415/** 9 ledger types folded into 5 legible series (the table below keeps every type). */16const GROUPS: { key: string; label: string; color: string; types: string[] }[] = [17 { key: "bet", label: "Bets", color: CHART.grey2, types: ["BET"] },18 { key: "win", label: "Wins", color: CHART.accent, types: ["WIN"] },19 { key: "rewards", label: "Rewards", color: CHART.info, types: ["DAILY_REWARD", "ACHIEVEMENT", "MISSION", "LEVEL_UP"] },20 { key: "grants", label: "Grants", color: CHART.success, types: ["INITIAL_GRANT", "RESCUE_CREDITS"] },21 { key: "admin", label: "Admin", color: CHART.danger, types: ["ADMIN_ADJUSTMENT"] },22];23const SERIES: Series[] = GROUPS.map((g) => ({ key: g.key, label: g.label, color: g.color }));24const groupOf = (type: string) => GROUPS.find((g) => g.types.includes(type))?.key ?? "grants";2526const TYPE_TONE: Record<string, "muted" | "accent" | "info" | "success" | "warn" | "neutral"> = { BET: "muted", WIN: "accent", DAILY_REWARD: "info", ACHIEVEMENT: "info", MISSION: "info", LEVEL_UP: "info", INITIAL_GRANT: "success", RESCUE_CREDITS: "success", ADMIN_ADJUSTMENT: "warn" };2728interface TypeRow {29 type: string;30 n: number;31 total: number;32}3334const typeCols: Column<TypeRow>[] = [35 { key: "type", header: "Type", render: (r) => <Pill tone={TYPE_TONE[r.type] ?? "neutral"}>{r.type}</Pill> },36 { key: "group", header: "Group", render: (r) => <span className="text-fg-3">{GROUPS.find((g) => g.key === groupOf(r.type))?.label}</span> },37 { key: "n", header: "Transactions", align: "right", render: (r) => int(r.n) },38 { key: "total", header: "Signed total", align: "right", render: (r) => <span className={cn("font-medium", r.total > 0 ? "text-success" : r.total < 0 ? "text-fg-2" : "text-fg-3")}>{signedSC(r.total)}</span> },39 { key: "avg", header: "Avg / txn", align: "right", render: (r) => (r.n ? signedSC(Math.round(r.total / r.n)) : "—") },40];4142export default function AdminEconomyPage() {43 const [days, setDays] = React.useState<(typeof DAYS)[number]>(30);44 const q = useAdminQuery<EconomyResponse>(`/api/admin/economy?days=${days}`);45 const d = q.data;4647 const byType = React.useMemo<TypeRow[]>(() => {48 const m = new Map((d?.byType ?? []).map((r) => [r.type, { type: r.type, n: num(r.n), total: num(r.total) }]));49 return TRANSACTION_TYPES.map((t) => m.get(t) ?? { type: t, n: 0, total: 0 });50 }, [d]);5152 const anchor = q.updatedAt;53 const daily = React.useMemo(() => {54 const rows = new Map<string, Record<string, number | string>>();55 for (let i = days - 1; i >= 0; i--) {56 const day = new Date(anchor - i * 86400_000).toISOString().slice(0, 10);57 rows.set(day, { day, bet: 0, win: 0, rewards: 0, grants: 0, admin: 0 });58 }59 for (const r of d?.daily ?? []) {60 const day = isoDay(r.day);61 const row = rows.get(day);62 if (!row) continue;63 const k = groupOf(r.type);64 row[k] = num(row[k]) + num(r.total);65 }66 return Array.from(rows.values());67 }, [d, days, anchor]);6869 const net = byType.reduce((a, r) => a + r.total, 0);70 const mismatches = d ? num(d.invariant.mismatches) : 0;7172 const distribution = React.useMemo(() => {73 const m = new Map((d?.balanceDistribution ?? []).map((r) => [num(r.bucket), num(r.n)]));74 return Array.from({ length: 11 }, (_, i) => {75 const b = i + 1;76 const label = b === 11 ? "100K+" : `${(b - 1) * 10}K–${b * 10}K`;77 return { bucket: b, label, n: m.get(b) ?? 0 };78 });79 }, [d]);8081 return (82 <>83 <PageHeader84 title="Economy"85 description="Supply of Spinza Credits, ledger flows by type and the wallet invariant. Every figure is fictional currency."86 actions={87 <>88 <SegmentedControl value={days} onChange={setDays} items={DAYS.map((v) => ({ value: v, label: `${v}d` }))} />89 <RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />90 </>91 }92 />9394 {q.error && !d ? (95 <ErrorState error={q.error} onRetry={() => void q.refresh()} />96 ) : !d ? (97 <div className="space-y-4">98 <TileSkeleton count={5} cols={5} />99 <Panel>100 <TableSkeleton rows={9} cols={5} />101 </Panel>102 </div>103 ) : (104 <div className={cn("space-y-4", q.stale && "opacity-70")}>105 <StatGrid cols={5}>106 <StatTile label="Circulating supply" value={compact(d.supply.circulating)} sub={sc(d.supply.circulating)} tone="accent" />107 <StatTile label="Lifetime granted" value={compact(d.supply.granted)} sub={sc(d.supply.granted)} />108 <StatTile label="Lifetime wagered" value={compact(d.supply.wagered)} sub={sc(d.supply.wagered)} />109 <StatTile label="Lifetime won" value={compact(d.supply.won)} sub={`${sc(d.supply.won)} · RTP ${pct(num(d.supply.wagered) ? num(d.supply.won) / num(d.supply.wagered) : null)}`} />110 <StatTile111 label="Wallet invariant"112 value={113 <span className="inline-flex items-center gap-1.5">114 {mismatches === 0 ? <CheckCircle2 className="h-5 w-5" /> : <XCircle className="h-5 w-5" />}115 {mismatches === 0 ? "OK" : `${int(mismatches)} off`}116 </span>117 }118 tone={mismatches === 0 ? "success" : "danger"}119 sub={mismatches === 0 ? "Σ ledger = balance for every wallet" : "wallets whose ledger ≠ balance"}120 />121 </StatGrid>122123 <div className="grid gap-4 xl:grid-cols-[1.4fr_1fr]">124 <Panel title={`Daily ledger flow · last ${days} days`} description="Signed SC per day; bets sit below the baseline">125 <ChartFrame series={SERIES} height={260}>126 <Bars data={daily} x="day" series={SERIES} stacked xFormat={(v) => shortDate(String(v))} yFormat={(v) => compact(v)} barSize={days > 30 ? 6 : 14} tooltipLabel={(l) => shortDate(String(l))} />127 </ChartFrame>128 </Panel>129 <Panel title="Balance distribution" description="Wallets per 10K SC band">130 <ChartFrame height={260}>131 <Histogram data={distribution} x="label" y="n" yFormat={(v) => int(v)} />132 </ChartFrame>133 </Panel>134 </div>135136 <Panel title={`Ledger by type · last ${days} days`} description={`Net flow ${signedSC(net)} across ${int(byType.reduce((a, r) => a + r.n, 0))} transactions`} padded={false}>137 <DataTable138 columns={typeCols}139 rows={byType}140 rowKey={(r) => r.type}141 dense142 footer={143 <tr className="border-t border-line text-[13px] font-semibold">144 <td className="px-3 py-2" colSpan={2}>145 Net146 </td>147 <td className="px-3 py-2 text-right tabular">{int(byType.reduce((a, r) => a + r.n, 0))}</td>148 <td className={cn("px-3 py-2 text-right tabular", net >= 0 ? "text-success" : "text-danger")}>{signedSC(net)}</td>149 <td />150 </tr>151 }152 />153 </Panel>154 </div>155 )}156 </>157 );158}159