TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
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