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%
11.7 KB · 304 lines tsx
Raw Blame History
1"use client";23import { useCallback, useEffect, useState } from "react";4import Link from "next/link";5import { ArrowLeft, History, Copy, Check, ChevronRight } from "lucide-react";6import type { RoundHistoryEntry } from "@spinza/shared";7import { formatMultiplier, formatSC, classifyWin, WIN_CLASSES } from "@spinza/shared";8import { api, ApiClientError } from "@/lib/api";9import { toast } from "@/lib/store";10import { cn } from "@/lib/utils";11import { Badge, Button, Card, Empty, Sheet, Skeleton } from "@/components/ui";12import { AppShell } from "@/components/shell/app-shell";13import { RequireAuth } from "@/components/shell/require-auth";14import { ApiErrorState } from "@/components/shell/api-error";15import { useRouter } from "next/navigation";1617type Entry = RoundHistoryEntry & { features: string[] };18interface Page {19  entries: Entry[];20  nextBefore: string | null;21}22interface RoundDetail {23  roundId: string;24  game: string;25  version: string;26  bet: number;27  win: number;28  multiplier: number;29  balanceAfter: number;30  createdAt: string;31  result: unknown;32  features: string[];33}3435const LIMIT = 40;3637function fmtDate(iso: string) {38  const d = new Date(iso);39  return d.toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });40}4142function WinTag({ multiplier }: { multiplier: number }) {43  const cls = classifyWin(multiplier);44  if (cls === "none" || cls === "regular") return null;45  const label = WIN_CLASSES.find((w) => w.id === cls)?.label ?? "";46  return <Badge tone={cls === "legendary" || cls === "epic" ? "accent" : cls === "mega" ? "new" : "success"}>{label}</Badge>;47}4849function RoundSheet({ roundId, onClose }: { roundId: string | null; onClose: () => void }) {50  const [detail, setDetail] = useState<RoundDetail | null>(null);51  const [error, setError] = useState<unknown>(null);52  const [copied, setCopied] = useState(false);5354  // Reset when a different round is opened (adjust-state-from-props), then fetch.55  const [seen, setSeen] = useState(roundId);56  if (roundId !== seen) {57    setSeen(roundId);58    setDetail(null);59    setError(null);60  }61  useEffect(() => {62    if (!roundId) return;63    let alive = true;64    api<RoundDetail>(`/api/user/rounds/${roundId}`)65      .then((d) => alive && setDetail(d))66      .catch((e: unknown) => alive && setError(e));67    return () => {68      alive = false;69    };70  }, [roundId]);7172  const copy = async () => {73    if (!roundId) return;74    try {75      await navigator.clipboard.writeText(roundId);76      setCopied(true);77      setTimeout(() => setCopied(false), 1500);78    } catch {79      toast({ title: "Copy unavailable", tone: "danger" });80    }81  };8283  const gameName = (detail?.game ?? "").replace(/-/g, " ");84  const resultObj = detail && detail.result && typeof detail.result === "object" ? (detail.result as Record<string, unknown>) : null;85  const summary = resultObj ? Object.entries(resultObj).filter(([, v]) => typeof v === "number" || typeof v === "string" || typeof v === "boolean") : [];8687  return (88    <Sheet open={!!roundId} onClose={onClose} title="Round details">89      {error ? (90        <ApiErrorState error={error} compact />91      ) : !detail ? (92        <div className="space-y-3" aria-busy>93          <Skeleton className="h-6 w-1/2" />94          <Skeleton className="h-24" />95          <Skeleton className="h-16" />96        </div>97      ) : (98        <div className="space-y-5">99          <div>100            <div className="flex items-center gap-2">101              <Link href={`/games/${detail.game}`} className="text-lg font-semibold capitalize tracking-tight hover:text-accent-2" onClick={onClose}>102                {gameName}103              </Link>104              <WinTag multiplier={detail.multiplier} />105            </div>106            <div className="text-[13px] text-fg-3">107              {fmtDate(detail.createdAt)} · v{detail.version}108            </div>109          </div>110          <div className="grid grid-cols-2 gap-2 sm:grid-cols-4">111            {[112              { l: "Bet", v: formatSC(detail.bet) },113              { l: "Win", v: <span className={detail.win > 0 ? "text-credit" : ""}>{formatSC(detail.win)}</span> },114              { l: "Multiplier", v: formatMultiplier(detail.multiplier) },115              { l: "Balance after", v: formatSC(detail.balanceAfter) },116            ].map((x) => (117              <div key={x.l} className="surface rounded-md p-3">118                <div className="eyebrow">{x.l}</div>119                <div className="mt-1 text-[15px] font-semibold tabular">{x.v}</div>120              </div>121            ))}122          </div>123          <div>124            <div className="eyebrow mb-2">Features triggered</div>125            {detail.features.length ? (126              <div className="flex flex-wrap gap-1.5">127                {detail.features.map((f) => (128                  <Badge key={f} tone="accent">129                    {f.replace(/[_-]/g, " ")}130                  </Badge>131                ))}132              </div>133            ) : (134              <p className="text-sm text-fg-3">Base game only — no features this round.</p>135            )}136          </div>137          {summary.length ? (138            <div>139              <div className="eyebrow mb-2">Outcome</div>140              <dl className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-[13px]">141                {summary.slice(0, 12).map(([k, v]) => (142                  <div key={k} className="flex justify-between gap-2 border-b border-line py-1">143                    <dt className="truncate capitalize text-fg-3">{k.replace(/([A-Z])/g, " $1").replace(/[_-]/g, " ")}</dt>144                    <dd className="tabular font-medium">{String(v)}</dd>145                  </div>146                ))}147              </dl>148            </div>149          ) : null}150          <div>151            <div className="eyebrow mb-2">Round ID</div>152            <div className="flex items-center gap-2">153              <code className="flex-1 truncate rounded-md bg-bg-1 px-3 py-2 font-mono text-[12px] text-fg-2">{detail.roundId}</code>154              <Button variant="secondary" size="icon" onClick={copy} aria-label="Copy round ID">155                {copied ? <Check className="h-4 w-4 text-success" /> : <Copy className="h-4 w-4" />}156              </Button>157            </div>158            <p className="mt-2 text-[12px] text-fg-4">Every round is recorded server-side. Quote this ID if you ever need to reference a specific spin.</p>159          </div>160        </div>161      )}162    </Sheet>163  );164}165166function fetchPage(before: string | null): Promise<Page> {167  const q = new URLSearchParams({ limit: String(LIMIT) });168  if (before) q.set("before", before);169  return api<Page>(`/api/user/history?${q}`);170}171172function HistoryBody() {173  const router = useRouter();174  const [entries, setEntries] = useState<Entry[] | null>(null); // null = first page loading175  const [nextBefore, setNextBefore] = useState<string | null>(null);176  const [more, setMore] = useState(false);177  const [error, setError] = useState<unknown>(null);178  const [open, setOpen] = useState<string | null>(null);179  const [tick, setTick] = useState(0);180181  const fail = useCallback(182    (e: unknown) => {183      if (e instanceof ApiClientError && e.status === 401) {184        router.replace("/login?next=%2Fprofile%2Fhistory&reason=expired");185        return;186      }187      setError(e);188    },189    [router],190  );191192  useEffect(() => {193    let alive = true;194    fetchPage(null)195      .then((page) => {196        if (!alive) return;197        setEntries(page.entries);198        setNextBefore(page.nextBefore);199        setError(null);200      })201      .catch((e: unknown) => alive && fail(e));202    return () => {203      alive = false;204    };205  }, [tick, fail]);206207  const retry = () => {208    setEntries(null);209    setError(null);210    setTick((t) => t + 1);211  };212213  const loadMore = async () => {214    if (!nextBefore || more) return;215    setMore(true);216    try {217      const page = await fetchPage(nextBefore);218      setEntries((cur) => [...(cur ?? []), ...page.entries]);219      setNextBefore(page.nextBefore);220    } catch (e) {221      fail(e);222      toast({ title: "Could not load more", tone: "danger" });223    } finally {224      setMore(false);225    }226  };227228  if (error && !entries?.length) return <ApiErrorState error={error} retry={retry} />;229  if (entries === null) {230    return (231      <div className="space-y-2" aria-busy>232        {Array.from({ length: 8 }).map((_, i) => (233          <Skeleton key={i} className="h-14 rounded-md" />234        ))}235      </div>236    );237  }238  if (!entries.length) return <Empty title="No rounds yet" description="Your spins will be listed here with bet, win, multiplier and balance." icon={<History className="h-5 w-5" />} action={<Button href="/games">Play a game</Button>} />;239240  return (241    <>242      <Card className="overflow-hidden">243        <div className="hidden grid-cols-[150px_1fr_110px_120px_90px_130px_28px] gap-3 border-b border-line px-4 py-2.5 text-[11px] font-semibold uppercase tracking-wider text-fg-3 md:grid">244          <span>Time</span>245          <span>Game</span>246          <span className="text-right">Bet</span>247          <span className="text-right">Win</span>248          <span className="text-right">Multi</span>249          <span className="text-right">Balance after</span>250          <span />251        </div>252        <ul className="divide-y divide-line">253          {entries.map((e) => (254            <li key={e.roundId}>255              <button onClick={() => setOpen(e.roundId)} className="grid w-full grid-cols-[1fr_auto] items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-surface-2 focus-ring md:grid-cols-[150px_1fr_110px_120px_90px_130px_28px]">256                <span className="text-[12px] tabular text-fg-3 md:text-[13px]">{fmtDate(e.createdAt)}</span>257                <span className="col-start-1 row-start-2 flex items-center gap-2 md:col-auto md:row-auto">258                  <span className="truncate text-[15px] font-medium">{e.gameName}</span>259                  <WinTag multiplier={e.multiplier} />260                </span>261                <span className="hidden text-right text-sm tabular text-fg-2 md:block">{formatSC(e.bet)}</span>262                <span className={cn("col-start-2 row-start-1 text-right text-sm font-semibold tabular md:col-auto md:row-auto", e.win > 0 ? "text-credit" : "text-fg-3")}>{e.win > 0 ? `+${formatSC(e.win)}` : formatSC(0)}</span>263                <span className="col-start-2 row-start-2 text-right text-[12px] tabular text-fg-3 md:col-auto md:row-auto md:text-sm">264                  <span className="md:hidden">Bet {formatSC(e.bet, { unit: false })} · </span>265                  {formatMultiplier(e.multiplier)}266                </span>267                <span className="hidden text-right text-sm tabular text-fg-2 md:block">{formatSC(e.balanceAfter)}</span>268                <ChevronRight className="hidden h-4 w-4 text-fg-4 md:block" />269              </button>270            </li>271          ))}272        </ul>273      </Card>274      {nextBefore ? (275        <div className="mt-4 flex justify-center">276          <Button variant="secondary" onClick={loadMore} loading={more}>277            Load more278          </Button>279        </div>280      ) : (281        <p className="mt-4 text-center text-[12px] text-fg-4">That&apos;s every round on record.</p>282      )}283      <RoundSheet roundId={open} onClose={() => setOpen(null)} />284    </>285  );286}287288export default function HistoryPage() {289  return (290    <AppShell>291      <RequireAuth>292        <div className="mb-6">293          <Link href="/profile" className="tap -ml-1 inline-flex h-9 min-h-0 items-center gap-1 text-sm text-fg-3 hover:text-fg">294            <ArrowLeft className="h-4 w-4" /> Profile295          </Link>296          <h1 className="mt-1 text-3xl font-semibold tracking-tight sm:text-4xl">Round history</h1>297          <p className="mt-2 max-w-xl text-sm text-fg-3">Every spin, newest first. Tap a round for the full result and the features it triggered.</p>298        </div>299        <HistoryBody />300      </RequireAuth>301    </AppShell>302  );303}304