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%
4.6 KB · 72 lines tsx
Raw Blame History
1"use client";23import * as React from "react";4import Link from "next/link";5import { useAdminQuery } from "@/components/admin/use-query";6import type { GameAnalyticsResponse, GameAnalyticsRow } from "@/components/admin/types";7import { PageHeader, RefreshButton, Panel, DataTable, ErrorState, TableSkeleton, ChartSkeleton, SegmentedControl, useSort, type Column } from "@/components/admin/primitives";8import { Bars, ChartFrame, CHART } from "@/components/admin/charts";9import { compact, int, ms, multiplier, num, pct, sc } from "@/components/admin/format";10import { cn } from "@/lib/utils";1112const DAYS = [7, 30, 90] as const;1314const cols: Column<GameAnalyticsRow>[] = [15  { key: "game", header: "Game", sortValue: (r) => r.game_slug, render: (r) => <Link href={`/admin/games/${r.game_slug}`} className="font-medium text-fg hover:text-accent-2">{r.game_slug}</Link> },16  { key: "spins", header: "Spins", align: "right", sortValue: (r) => num(r.spins), render: (r) => int(r.spins) },17  { key: "players", header: "Players", align: "right", sortValue: (r) => num(r.players), render: (r) => int(r.players) },18  { key: "launches", header: "Launches", align: "right", sortValue: (r) => num(r.launches), render: (r) => int(r.launches) },19  { key: "wagered", header: "Wagered", align: "right", sortValue: (r) => num(r.wagered), render: (r) => sc(r.wagered) },20  { key: "won", header: "Won", align: "right", sortValue: (r) => num(r.won), render: (r) => sc(r.won) },21  { key: "rtp", header: "RTP", align: "right", sortValue: (r) => r.rtp, render: (r) => <span className={cn(r.rtp !== null && (r.rtp > 1.02 || r.rtp < 0.9) && "text-[#ffc46b]")}>{pct(r.rtp)}</span> },22  { key: "avg_bet", header: "Avg bet", align: "right", sortValue: (r) => num(r.avg_bet), render: (r) => sc(Math.round(num(r.avg_bet))) },23  { key: "bonuses", header: "Bonus rounds", align: "right", sortValue: (r) => num(r.bonuses), render: (r) => <span>{int(r.bonuses)} <span className="text-fg-4">({pct(num(r.spins) ? num(r.bonuses) / num(r.spins) : null, 1)})</span></span> },24  { key: "big_wins", header: "Big wins ≥20×", align: "right", sortValue: (r) => num(r.big_wins), render: (r) => int(r.big_wins) },25  { key: "max", header: "Max win", align: "right", sortValue: (r) => num(r.max_multiplier), render: (r) => <span>{multiplier(r.max_multiplier ?? 0)} <span className="text-fg-4">· {sc(r.max_win)}</span></span> },26  { key: "favorites", header: "♥", align: "right", sortValue: (r) => num(r.favorites), render: (r) => int(r.favorites) },27  { key: "avg_ms", header: "Avg ms", align: "right", sortValue: (r) => num(r.avg_ms), render: (r) => ms(num(r.avg_ms)) },28];2930export default function AdminGameAnalyticsPage() {31  const [days, setDays] = React.useState<(typeof DAYS)[number]>(30);32  const q = useAdminQuery<GameAnalyticsResponse>(`/api/admin/analytics/games?days=${days}`);33  const d = q.data;34  const { sorted, sort, toggle } = useSort(d?.games, cols, { key: "spins", dir: "desc" });3536  const chartData = React.useMemo(() => (d?.games ?? []).slice().sort((a, b) => num(b.spins) - num(a.spins)).map((g) => ({ game: g.game_slug, spins: num(g.spins), players: num(g.players) })), [d]);3738  return (39    <>40      <PageHeader41        title="Game Analytics"42        description="Per-game engagement and payout over the selected window. Launch and favourite counts are lifetime."43        actions={44          <>45            <SegmentedControl value={days} onChange={setDays} items={DAYS.map((v) => ({ value: v, label: `${v}d` }))} />46            <RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />47          </>48        }49      />5051      {q.error && !d ? (52        <ErrorState error={q.error} onRetry={() => void q.refresh()} />53      ) : (54        <div className={cn("space-y-4", q.stale && "opacity-70")}>55          <Panel title={`Spins by game · last ${days} days`}>56            {!d ? (57              <ChartSkeleton height={Math.max(160, 26 * 8)} />58            ) : (59              <ChartFrame height={Math.max(160, 26 * chartData.length + 30)}>60                <Bars data={chartData} x="game" layout="vertical" series={[{ key: "spins", label: "Spins", color: CHART.accent }]} yFormat={(v) => compact(v)} tooltipLabel={(l, row) => `${String(l)} · ${int(row?.players ?? 0)} players`} />61              </ChartFrame>62            )}63          </Panel>64          <Panel title="Per-game table" description="Click a column header to sort" padded={false}>65            {!d ? <TableSkeleton rows={10} cols={8} /> : <DataTable columns={cols} rows={sorted} rowKey={(r) => r.game_slug} dense sort={sort} onSort={toggle} empty={`No rounds recorded in the last ${days} days.`} />}66          </Panel>67        </div>68      )}69    </>70  );71}72