TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import Link from "next/link";4import { History, ReceiptText, Settings, Trophy, ChevronRight, Sparkles, Zap } from "lucide-react";5import type { PublicUser } from "@spinza/shared";6import { formatMultiplier, formatSC } from "@spinza/shared";7import { useSession } from "@/lib/store";8import { useApi } from "@/lib/use-api";9import { Card, Credits, Progress, Skeleton } from "@/components/ui";10import { AppShell } from "@/components/shell/app-shell";11import { RequireAuth } from "@/components/shell/require-auth";12import { ApiErrorState } from "@/components/shell/api-error";13import { GameArt, type ArtPalette } from "@/components/lobby/game-art";1415interface Profile {16 user: PublicUser;17 wallet: { balance: number; lifetimeWagered: number; lifetimeWon: number };18 stats: { totalSpins: number; gamesPlayed: number; biggestWin: number; biggestMultiplier: number; bonuses: number; achievements: number };19 favoriteGame: { slug: string; name: string; spins: number; palette: ArtPalette } | null;20}2122function Stat({ label, value, sub }: { label: string; value: React.ReactNode; sub?: string }) {23 return (24 <Card className="p-4">25 <div className="eyebrow">{label}</div>26 <div className="mt-2 text-xl font-semibold tabular tracking-tight sm:text-2xl">{value}</div>27 {sub ? <div className="mt-0.5 text-[12px] text-fg-3">{sub}</div> : null}28 </Card>29 );30}3132const LINKS = [33 { href: "/profile/history", label: "Round history", desc: "Every spin, with full results", icon: History },34 { href: "/profile/ledger", label: "Credit ledger", desc: "All credit movements", icon: ReceiptText },35 { href: "/rewards", label: "Achievements", desc: "Milestones and rewards", icon: Trophy },36 { href: "/settings", label: "Settings", desc: "Sound, motion, security", icon: Settings },37];3839function ProfileBody() {40 const { data, error, loading, reload } = useApi<Profile>("/api/user/profile");41 const wallet = useSession((s) => s.wallet);4243 if (error && !data) return <ApiErrorState error={error} retry={reload} />;44 if (loading && !data) {45 return (46 <div className="space-y-4" aria-busy>47 <Skeleton className="h-40 rounded-xl" />48 <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">49 {Array.from({ length: 8 }).map((_, i) => (50 <Skeleton key={i} className="h-24 rounded-lg" />51 ))}52 </div>53 </div>54 );55 }56 if (!data) return null;57 const { user, stats, favoriteGame } = data;58 const balance = wallet?.balance ?? data.wallet.balance;59 const joined = new Date(user.createdAt).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" });6061 return (62 <div className="space-y-8">63 {/* identity */}64 <Card className="relative overflow-hidden p-6 sm:p-8">65 <div className="pointer-events-none absolute inset-0 bg-[radial-gradient(ellipse_at_top_left,rgba(201,169,97,0.14),transparent_55%)]" />66 <div className="relative flex flex-col gap-6 sm:flex-row sm:items-center">67 <span className="grid h-20 w-20 shrink-0 place-items-center rounded-2xl metal text-3xl font-bold uppercase text-accent-2 shadow-glow">{user.username.slice(0, 1)}</span>68 <div className="min-w-0 flex-1">69 <div className="flex flex-wrap items-center gap-2">70 <h1 className="truncate text-2xl font-semibold tracking-tight sm:text-3xl">{user.username}</h1>71 <span className="inline-flex items-center gap-1 rounded-full bg-accent-soft px-2.5 py-1 text-[12px] font-bold text-accent-2">72 <Zap className="h-3 w-3" /> LVL {user.level}73 </span>74 </div>75 <p className="mt-1 text-[13px] text-fg-3">Member since {joined}</p>76 <div className="mt-4 max-w-md">77 <Progress value={user.xpIntoLevel} max={user.xpForNext || 1} />78 <div className="mt-1.5 flex justify-between text-[12px] tabular text-fg-3">79 <span>{user.xpIntoLevel.toLocaleString("en-US")} XP</span>80 <span>{user.xpForNext ? `${user.xpForNext.toLocaleString("en-US")} XP to level ${user.level + 1}` : "Max level"}</span>81 </div>82 </div>83 </div>84 <div className="sm:text-right">85 <div className="eyebrow">Balance</div>86 <Credits amount={balance} size="lg" />87 </div>88 </div>89 </Card>9091 {/* stats */}92 <section aria-labelledby="stats-title">93 <h2 id="stats-title" className="mb-3 text-lg font-semibold tracking-tight">94 Lifetime stats95 </h2>96 <div className="grid grid-cols-2 gap-3 lg:grid-cols-4">97 <Stat label="Total spins" value={stats.totalSpins.toLocaleString("en-US")} sub={`${stats.gamesPlayed} ${stats.gamesPlayed === 1 ? "game" : "games"} played`} />98 <Stat label="Total wagered" value={formatSC(data.wallet.lifetimeWagered)} />99 <Stat label="Total won" value={formatSC(data.wallet.lifetimeWon)} sub={data.wallet.lifetimeWagered ? `${((data.wallet.lifetimeWon / data.wallet.lifetimeWagered) * 100).toFixed(1)}% returned` : undefined} />100 <Stat label="Biggest win" value={<span className="text-credit">{formatSC(stats.biggestWin)}</span>} />101 <Stat label="Biggest multiplier" value={formatMultiplier(stats.biggestMultiplier)} />102 <Stat label="Bonus rounds" value={stats.bonuses.toLocaleString("en-US")} />103 <Stat label="Achievements" value={stats.achievements.toLocaleString("en-US")} sub="unlocked" />104 <Stat label="Level" value={user.level} sub={`${user.xp.toLocaleString("en-US")} XP total`} />105 </div>106 </section>107108 <div className="grid gap-4 lg:grid-cols-[1fr_1.2fr]">109 {/* favourite game */}110 <section aria-labelledby="fav-title">111 <h2 id="fav-title" className="mb-3 text-lg font-semibold tracking-tight">112 Most played113 </h2>114 {favoriteGame ? (115 <Link href={`/games/${favoriteGame.slug}`} className="group relative block overflow-hidden rounded-lg border border-line focus-ring">116 <div className="aspect-[3/1] sm:aspect-[3/1]">117 <GameArt slug={favoriteGame.slug} name={favoriteGame.name} palette={favoriteGame.palette} variant="banner" />118 </div>119 <div className="absolute inset-x-0 bottom-0 flex items-center justify-between p-4">120 <span className="rounded-full glass px-3 py-1 text-[12px] font-semibold tabular">{favoriteGame.spins.toLocaleString("en-US")} spins</span>121 <span className="inline-flex items-center gap-1 rounded-full bg-fg px-3 py-1.5 text-[13px] font-semibold text-bg">Play again</span>122 </div>123 </Link>124 ) : (125 <Card className="flex items-center gap-4 p-5">126 <span className="grid h-11 w-11 place-items-center rounded-md bg-surface-2 text-fg-3">127 <Sparkles className="h-5 w-5" />128 </span>129 <div>130 <div className="font-semibold">No favourite yet</div>131 <p className="text-[13px] text-fg-3">132 Play a few rounds and your most-played game appears here.{" "}133 <Link href="/games" className="text-accent-2 underline-offset-4 hover:underline">134 Browse games135 </Link>136 </p>137 </div>138 </Card>139 )}140 </section>141142 {/* shortcuts */}143 <section aria-labelledby="more-title">144 <h2 id="more-title" className="mb-3 text-lg font-semibold tracking-tight">145 Account146 </h2>147 <Card as="ul" className="divide-y divide-line overflow-hidden">148 {LINKS.map((l) => (149 <li key={l.href}>150 <Link href={l.href} className="flex items-center gap-3 px-4 py-3.5 transition-colors hover:bg-surface-2 focus-ring">151 <span className="grid h-10 w-10 place-items-center rounded-md bg-surface-2 text-fg-2">152 <l.icon className="h-[18px] w-[18px]" />153 </span>154 <span className="min-w-0 flex-1">155 <span className="block text-[15px] font-medium">{l.label}</span>156 <span className="block text-[12px] text-fg-3">{l.desc}</span>157 </span>158 <ChevronRight className="h-4 w-4 text-fg-4" />159 </Link>160 </li>161 ))}162 </Card>163 </section>164 </div>165 </div>166 );167}168169export default function ProfilePage() {170 return (171 <AppShell>172 <RequireAuth>173 <ProfileBody />174 </RequireAuth>175 </AppShell>176 );177}178