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%
7.9 KB · 195 lines tsx
Raw Blame History
1"use client";23import { useCallback, useEffect, useState } from "react";4import Link from "next/link";5import { useRouter } from "next/navigation";6import { ArrowLeft, Gift, Sparkles, Target, Zap, LifeBuoy, Wrench, Dice5, Coins, ReceiptText, type LucideIcon } from "lucide-react";7import type { LedgerEntry, TransactionType } from "@spinza/shared";8import { formatSC } from "@spinza/shared";9import { api, ApiClientError } from "@/lib/api";10import { cn } from "@/lib/utils";11import { Button, Card, Empty, Skeleton, Tabs } from "@/components/ui";12import { AppShell } from "@/components/shell/app-shell";13import { RequireAuth } from "@/components/shell/require-auth";14import { ApiErrorState } from "@/components/shell/api-error";1516const TYPES: Record<TransactionType, { label: string; icon: LucideIcon; tone: string }> = {17  INITIAL_GRANT: { label: "Welcome credits", icon: Sparkles, tone: "text-accent-2 bg-accent-soft" },18  BET: { label: "Bet", icon: Dice5, tone: "text-fg-2 bg-surface-2" },19  WIN: { label: "Win", icon: Coins, tone: "text-credit bg-credit/10" },20  DAILY_REWARD: { label: "Daily reward", icon: Gift, tone: "text-accent-2 bg-accent-soft" },21  ACHIEVEMENT: { label: "Achievement", icon: Sparkles, tone: "text-accent-2 bg-accent-soft" },22  MISSION: { label: "Mission", icon: Target, tone: "text-info bg-info/10" },23  LEVEL_UP: { label: "Level up", icon: Zap, tone: "text-accent-2 bg-accent-soft" },24  RESCUE_CREDITS: { label: "Rescue credits", icon: LifeBuoy, tone: "text-success bg-success/10" },25  ADMIN_ADJUSTMENT: { label: "Adjustment", icon: Wrench, tone: "text-fg-2 bg-surface-2" },26};2728const FILTERS = [29  { value: "", label: "All" },30  { value: "WIN", label: "Wins" },31  { value: "BET", label: "Bets" },32  { value: "rewards", label: "Rewards" },33] as const;34type Filter = (typeof FILTERS)[number]["value"];35const REWARD_TYPES: TransactionType[] = ["DAILY_REWARD", "ACHIEVEMENT", "MISSION", "LEVEL_UP", "RESCUE_CREDITS", "INITIAL_GRANT"];3637interface Page {38  entries: LedgerEntry[];39  nextBefore: string | null;40}41const LIMIT = 50;4243function fmtDate(iso: string) {44  return new Date(iso).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });45}4647async function fetchPage(before: string | null, f: Filter): Promise<{ entries: LedgerEntry[]; nextBefore: string | null }> {48  const q = new URLSearchParams({ limit: String(LIMIT) });49  if (before) q.set("before", before);50  if (f && f !== "rewards") q.set("type", f);51  const page = await api<Page>(`/api/wallet/ledger?${q}`);52  // "Rewards" groups several types; the API filters by a single type, so refine client-side.53  return { entries: f === "rewards" ? page.entries.filter((e) => REWARD_TYPES.includes(e.type)) : page.entries, nextBefore: page.nextBefore };54}5556function LedgerBody() {57  const router = useRouter();58  const [filter, setFilter] = useState<Filter>("");59  const [entries, setEntries] = useState<LedgerEntry[] | null>(null); // null = first page loading60  const [nextBefore, setNextBefore] = useState<string | null>(null);61  const [more, setMore] = useState(false);62  const [error, setError] = useState<unknown>(null);63  const [tick, setTick] = useState(0);6465  // Switching filter restarts from the first page (adjust-state-from-props).66  const [prevFilter, setPrevFilter] = useState(filter);67  if (filter !== prevFilter) {68    setPrevFilter(filter);69    setEntries(null);70    setError(null);71  }7273  const fail = useCallback(74    (e: unknown) => {75      if (e instanceof ApiClientError && e.status === 401) {76        router.replace("/login?next=%2Fprofile%2Fledger&reason=expired");77        return;78      }79      setError(e);80    },81    [router],82  );8384  useEffect(() => {85    let alive = true;86    fetchPage(null, filter)87      .then((page) => {88        if (!alive) return;89        setEntries(page.entries);90        setNextBefore(page.nextBefore);91        setError(null);92      })93      .catch((e: unknown) => alive && fail(e));94    return () => {95      alive = false;96    };97  }, [filter, tick, fail]);9899  const retry = () => {100    setEntries(null);101    setError(null);102    setTick((t) => t + 1);103  };104105  const loadMore = async () => {106    if (!nextBefore || more) return;107    setMore(true);108    try {109      const page = await fetchPage(nextBefore, filter);110      setEntries((cur) => [...(cur ?? []), ...page.entries]);111      setNextBefore(page.nextBefore);112    } catch (e) {113      fail(e);114    } finally {115      setMore(false);116    }117  };118119  return (120    <>121      <div className="mb-4">122        <Tabs<Filter> value={filter} onChange={setFilter} items={FILTERS.map((f) => ({ value: f.value, label: f.label }))} />123      </div>124      {error && !entries?.length ? (125        <ApiErrorState error={error} retry={retry} />126      ) : entries === null ? (127        <div className="space-y-2" aria-busy>128          {Array.from({ length: 8 }).map((_, i) => (129            <Skeleton key={i} className="h-16 rounded-md" />130          ))}131        </div>132      ) : !entries.length ? (133        <Empty title="Nothing here yet" description={filter ? "No transactions of this kind yet." : "Every credit movement — bets, wins, rewards — is recorded here."} icon={<ReceiptText className="h-5 w-5" />} action={filter ? <Button variant="secondary" onClick={() => setFilter("")}>Show all</Button> : <Button href="/games">Play a game</Button>} />134      ) : (135        <>136          <Card as="ul" className="divide-y divide-line overflow-hidden">137            {entries.map((e) => {138              const meta = TYPES[e.type] ?? { label: e.type, icon: Wrench, tone: "text-fg-2 bg-surface-2" };139              const Icon = meta.icon;140              const positive = e.amount > 0;141              return (142                <li key={e.id} className="flex items-center gap-3 px-4 py-3">143                  <span className={cn("grid h-10 w-10 shrink-0 place-items-center rounded-md", meta.tone)}>144                    <Icon className="h-[18px] w-[18px]" />145                  </span>146                  <div className="min-w-0 flex-1">147                    <div className="flex items-center gap-2">148                      <span className="truncate text-[15px] font-medium">{meta.label}</span>149                      {e.reference && !/^(spz_rnd_|daily:|welcome$|rescue$)/.test(e.reference) ? <span className="hidden truncate text-[12px] text-fg-4 sm:inline">{e.reference.replace(/[:_]/g, " · ")}</span> : null}150                    </div>151                    <div className="text-[12px] tabular text-fg-3">{fmtDate(e.createdAt)}</div>152                  </div>153                  <div className="shrink-0 text-right">154                    <div className={cn("text-[15px] font-semibold tabular", positive ? "text-credit" : "text-fg-2")}>155                      {positive ? "+" : e.amount < 0 ? "−" : ""}156                      {formatSC(Math.abs(e.amount))}157                    </div>158                    <div className="text-[11px] tabular text-fg-4">Balance {formatSC(e.balanceAfter, { unit: false })}</div>159                  </div>160                </li>161              );162            })}163          </Card>164          {nextBefore ? (165            <div className="mt-4 flex justify-center">166              <Button variant="secondary" onClick={loadMore} loading={more}>167                Load more168              </Button>169            </div>170          ) : (171            <p className="mt-4 text-center text-[12px] text-fg-4">End of ledger.</p>172          )}173        </>174      )}175    </>176  );177}178179export default function LedgerPage() {180  return (181    <AppShell>182      <RequireAuth>183        <div className="mb-6">184          <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">185            <ArrowLeft className="h-4 w-4" /> Profile186          </Link>187          <h1 className="mt-1 text-3xl font-semibold tracking-tight sm:text-4xl">Credit ledger</h1>188          <p className="mt-2 max-w-xl text-sm text-fg-3">A complete record of your Spinza Credits. All amounts are fictional and have no cash value.</p>189        </div>190        <LedgerBody />191      </RequireAuth>192    </AppShell>193  );194}195