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%
8.5 KB · 178 lines tsx
Raw Blame History
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