"use client"; import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { ArrowLeft, Gift, Sparkles, Target, Zap, LifeBuoy, Wrench, Dice5, Coins, ReceiptText, type LucideIcon } from "lucide-react"; import type { LedgerEntry, TransactionType } from "@spinza/shared"; import { formatSC } from "@spinza/shared"; import { api, ApiClientError } from "@/lib/api"; import { cn } from "@/lib/utils"; import { Button, Card, Empty, Skeleton, Tabs } from "@/components/ui"; import { AppShell } from "@/components/shell/app-shell"; import { RequireAuth } from "@/components/shell/require-auth"; import { ApiErrorState } from "@/components/shell/api-error"; const TYPES: Record = { INITIAL_GRANT: { label: "Welcome credits", icon: Sparkles, tone: "text-accent-2 bg-accent-soft" }, BET: { label: "Bet", icon: Dice5, tone: "text-fg-2 bg-surface-2" }, WIN: { label: "Win", icon: Coins, tone: "text-credit bg-credit/10" }, DAILY_REWARD: { label: "Daily reward", icon: Gift, tone: "text-accent-2 bg-accent-soft" }, ACHIEVEMENT: { label: "Achievement", icon: Sparkles, tone: "text-accent-2 bg-accent-soft" }, MISSION: { label: "Mission", icon: Target, tone: "text-info bg-info/10" }, LEVEL_UP: { label: "Level up", icon: Zap, tone: "text-accent-2 bg-accent-soft" }, RESCUE_CREDITS: { label: "Rescue credits", icon: LifeBuoy, tone: "text-success bg-success/10" }, ADMIN_ADJUSTMENT: { label: "Adjustment", icon: Wrench, tone: "text-fg-2 bg-surface-2" }, }; const FILTERS = [ { value: "", label: "All" }, { value: "WIN", label: "Wins" }, { value: "BET", label: "Bets" }, { value: "rewards", label: "Rewards" }, ] as const; type Filter = (typeof FILTERS)[number]["value"]; const REWARD_TYPES: TransactionType[] = ["DAILY_REWARD", "ACHIEVEMENT", "MISSION", "LEVEL_UP", "RESCUE_CREDITS", "INITIAL_GRANT"]; interface Page { entries: LedgerEntry[]; nextBefore: string | null; } const LIMIT = 50; function fmtDate(iso: string) { return new Date(iso).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }); } async function fetchPage(before: string | null, f: Filter): Promise<{ entries: LedgerEntry[]; nextBefore: string | null }> { const q = new URLSearchParams({ limit: String(LIMIT) }); if (before) q.set("before", before); if (f && f !== "rewards") q.set("type", f); const page = await api(`/api/wallet/ledger?${q}`); // "Rewards" groups several types; the API filters by a single type, so refine client-side. return { entries: f === "rewards" ? page.entries.filter((e) => REWARD_TYPES.includes(e.type)) : page.entries, nextBefore: page.nextBefore }; } function LedgerBody() { const router = useRouter(); const [filter, setFilter] = useState(""); const [entries, setEntries] = useState(null); // null = first page loading const [nextBefore, setNextBefore] = useState(null); const [more, setMore] = useState(false); const [error, setError] = useState(null); const [tick, setTick] = useState(0); // Switching filter restarts from the first page (adjust-state-from-props). const [prevFilter, setPrevFilter] = useState(filter); if (filter !== prevFilter) { setPrevFilter(filter); setEntries(null); setError(null); } const fail = useCallback( (e: unknown) => { if (e instanceof ApiClientError && e.status === 401) { router.replace("/login?next=%2Fprofile%2Fledger&reason=expired"); return; } setError(e); }, [router], ); useEffect(() => { let alive = true; fetchPage(null, filter) .then((page) => { if (!alive) return; setEntries(page.entries); setNextBefore(page.nextBefore); setError(null); }) .catch((e: unknown) => alive && fail(e)); return () => { alive = false; }; }, [filter, tick, fail]); const retry = () => { setEntries(null); setError(null); setTick((t) => t + 1); }; const loadMore = async () => { if (!nextBefore || more) return; setMore(true); try { const page = await fetchPage(nextBefore, filter); setEntries((cur) => [...(cur ?? []), ...page.entries]); setNextBefore(page.nextBefore); } catch (e) { fail(e); } finally { setMore(false); } }; return ( <>
value={filter} onChange={setFilter} items={FILTERS.map((f) => ({ value: f.value, label: f.label }))} />
{error && !entries?.length ? ( ) : entries === null ? (
{Array.from({ length: 8 }).map((_, i) => ( ))}
) : !entries.length ? ( } action={filter ? : } /> ) : ( <> {entries.map((e) => { const meta = TYPES[e.type] ?? { label: e.type, icon: Wrench, tone: "text-fg-2 bg-surface-2" }; const Icon = meta.icon; const positive = e.amount > 0; return (
  • {meta.label} {e.reference && !/^(spz_rnd_|daily:|welcome$|rescue$)/.test(e.reference) ? {e.reference.replace(/[:_]/g, " ยท ")} : null}
    {fmtDate(e.createdAt)}
    {positive ? "+" : e.amount < 0 ? "โˆ’" : ""} {formatSC(Math.abs(e.amount))}
    Balance {formatSC(e.balanceAfter, { unit: false })}
  • ); })}
    {nextBefore ? (
    ) : (

    End of ledger.

    )} )} ); } export default function LedgerPage() { return (
    Profile

    Credit ledger

    A complete record of your Spinza Credits. All amounts are fictional and have no cash value.

    ); }