TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1"use client";23import { useState } from "react";4import Link from "next/link";5import { Crown, Trophy, EyeOff, Info } from "lucide-react";6import type { LeaderboardView, LeaderboardEntry } from "@spinza/shared";7import { formatMultiplier, formatSC } from "@spinza/shared";8import { useSession } from "@/lib/store";9import { useApi } from "@/lib/use-api";10import { cn, timeAgo } from "@/lib/utils";11import { Card, Empty, Skeleton, Tabs } from "@/components/ui";12import { AppShell } from "@/components/shell/app-shell";13import { ApiErrorState } from "@/components/shell/api-error";1415const CATEGORIES = [16 { value: "biggest_win_today", label: "Biggest Win Today", short: "Today" },17 { value: "biggest_win_week", label: "Biggest Win This Week", short: "This Week" },18 { value: "biggest_multiplier", label: "Biggest Multiplier", short: "Multiplier" },19 { value: "most_spins", label: "Most Spins", short: "Spins" },20 { value: "highest_level", label: "Highest Level", short: "Level" },21] as const;22type Category = (typeof CATEGORIES)[number]["value"];2324function formatValue(cat: Category, v: number): string {25 switch (cat) {26 case "biggest_multiplier":27 return formatMultiplier(v);28 case "most_spins":29 return `${Math.round(v).toLocaleString("en-US")} spins`;30 case "highest_level":31 return `Level ${Math.round(v)}`;32 default:33 return formatSC(v);34 }35}3637function Avatar({ name, rank, size = "md" }: { name: string; rank: number; size?: "md" | "lg" }) {38 const ring = rank === 1 ? "ring-2 ring-accent shadow-glow" : rank === 2 ? "ring-2 ring-[#c7ccd8]" : rank === 3 ? "ring-2 ring-[#b87b4b]" : "ring-1 ring-line-2";39 return (40 <span className={cn("grid shrink-0 place-items-center rounded-full metal font-bold uppercase", ring, size === "lg" ? "h-16 w-16 text-xl" : "h-10 w-10 text-sm")} aria-hidden>41 {name.slice(0, 1)}42 </span>43 );44}4546function Podium({ entries, cat }: { entries: LeaderboardEntry[]; cat: Category }) {47 const order = [entries[1], entries[0], entries[2]].filter(Boolean) as LeaderboardEntry[];48 if (!entries.length) return null;49 return (50 <div className="grid grid-cols-3 items-end gap-2 sm:gap-4">51 {order.map((e) => {52 const first = e.rank === 1;53 return (54 <div key={e.rank} className={cn("relative flex flex-col items-center rounded-lg border p-3 pt-6 text-center sm:p-4 sm:pt-8", first ? "surface-2 border-accent/40 bg-[radial-gradient(ellipse_at_top,rgba(201,169,97,0.18),transparent_60%)] min-h-[200px] sm:min-h-[230px]" : "surface min-h-[170px] sm:min-h-[190px]", e.isYou && "outline outline-2 outline-accent/60")}>55 {first ? <Crown className="absolute -top-3 h-6 w-6 text-accent drop-shadow-[0_0_8px_rgba(201,169,97,0.7)]" /> : null}56 <span className={cn("absolute left-2 top-2 grid h-6 w-6 place-items-center rounded-full text-[11px] font-bold", first ? "bg-accent text-bg" : "bg-surface-3 text-fg-2")}>{e.rank}</span>57 <Avatar name={e.username} rank={e.rank} size={first ? "lg" : "md"} />58 <div className="mt-3 w-full truncate text-[14px] font-semibold tracking-tight sm:text-[15px]">{e.isYou ? "You" : e.username}</div>59 <div className="text-[11px] text-fg-3">Level {e.level}</div>60 <div className={cn("mt-2 font-semibold tabular", first ? "text-credit text-base sm:text-lg" : "text-fg text-sm")}>{formatValue(cat, e.value)}</div>61 {e.game ? <div className="mt-0.5 truncate text-[11px] text-fg-4">{e.game}</div> : null}62 </div>63 );64 })}65 </div>66 );67}6869export default function LeaderboardPage() {70 const [cat, setCat] = useState<Category>("biggest_win_today");71 const { data, error, loading, reload } = useApi<LeaderboardView>(`/api/leaderboards?category=${cat}`);72 const status = useSession((s) => s.status);73 const optIn = useSession((s) => s.settings?.leaderboardOptIn ?? true);74 const entries = data?.entries ?? [];75 const rest = entries.slice(3);7677 return (78 <AppShell>79 <div className="mb-6 flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">80 <div>81 <div className="eyebrow mb-1">Leaderboard</div>82 <h1 className="text-3xl font-semibold tracking-tight sm:text-4xl">{data?.label || CATEGORIES.find((c) => c.value === cat)?.label}</h1>83 <p className="mt-2 max-w-xl text-sm text-fg-3">Bragging rights only. All values are in fictional Spinza Credits or gameplay stats — nothing is ever paid out.</p>84 </div>85 <div className="-mx-4 overflow-x-auto px-4 scrollbar-none sm:mx-0 sm:px-0">86 <Tabs<Category> value={cat} onChange={setCat} items={CATEGORIES.map((c) => ({ value: c.value, label: c.short }))} />87 </div>88 </div>8990 {status === "authenticated" && !optIn ? (91 <div className="mb-4 flex items-start gap-3 rounded-md border border-line-2 bg-surface-2 px-4 py-3 text-sm text-fg-2" role="status">92 <EyeOff className="mt-0.5 h-4 w-4 shrink-0 text-fg-3" />93 <p>94 You have opted out of leaderboards, so your results are hidden from other players.{" "}95 <Link href="/settings#leaderboard" className="font-medium text-accent-2 underline-offset-4 hover:underline">96 Change in settings97 </Link>98 </p>99 </div>100 ) : null}101102 {error && !data ? (103 <ApiErrorState error={error} retry={reload} />104 ) : loading && !data ? (105 <div aria-busy>106 <div className="grid grid-cols-3 items-end gap-2 sm:gap-4">107 <Skeleton className="h-[170px] rounded-lg" />108 <Skeleton className="h-[200px] rounded-lg" />109 <Skeleton className="h-[170px] rounded-lg" />110 </div>111 <div className="mt-4 space-y-2">112 {Array.from({ length: 6 }).map((_, i) => (113 <Skeleton key={i} className="h-14 rounded-md" />114 ))}115 </div>116 </div>117 ) : !entries.length ? (118 <Empty title="No entries yet" description={cat.startsWith("biggest_win") ? "Nobody has landed a win in this period yet. Yours could be the first." : "This board fills up as players spin."} icon={<Trophy className="h-5 w-5" />} />119 ) : (120 <div className={cn("transition-opacity", loading && "opacity-60")}>121 <Podium entries={entries.slice(0, 3)} cat={cat} />122 {rest.length ? (123 <Card as="ol" className="mt-4 divide-y divide-line overflow-hidden">124 {rest.map((e) => (125 <li key={`${e.rank}-${e.username}`} className={cn("flex items-center gap-3 px-3 py-2.5 sm:px-4", e.isYou && "bg-accent-soft")}>126 <span className="w-8 shrink-0 text-center text-sm font-semibold tabular text-fg-3">{e.rank}</span>127 <Avatar name={e.username} rank={e.rank} />128 <div className="min-w-0 flex-1">129 <div className="truncate text-[15px] font-medium">{e.isYou ? `${e.username} (you)` : e.username}</div>130 <div className="text-[12px] text-fg-3">131 Level {e.level}132 {e.game ? ` · ${e.game}` : ""}133 </div>134 </div>135 <div className="shrink-0 text-right text-sm font-semibold tabular">{formatValue(cat, e.value)}</div>136 </li>137 ))}138 </Card>139 ) : null}140141 {status === "authenticated" ? (142 data?.you && !entries.some((e) => e.isYou) ? (143 <Card className="mt-4 flex items-center gap-3 border-accent/40 px-3 py-3 sm:px-4">144 <span className="w-8 shrink-0 text-center text-sm font-semibold tabular text-accent-2">{data.you.rank}</span>145 <Avatar name={data.you.username} rank={data.you.rank} />146 <div className="min-w-0 flex-1">147 <div className="truncate text-[15px] font-medium">{data.you.username} (you)</div>148 <div className="text-[12px] text-fg-3">Level {data.you.level}</div>149 </div>150 <div className="shrink-0 text-right text-sm font-semibold tabular">{formatValue(cat, data.you.value)}</div>151 </Card>152 ) : !data?.you ? (153 <p className="mt-4 text-center text-[13px] text-fg-3">{optIn ? "You are not on this board yet. Play a few rounds to enter." : "Opt back in to appear on leaderboards."}</p>154 ) : null155 ) : (156 <p className="mt-4 text-center text-[13px] text-fg-3">157 <Link href="/login?next=%2Fleaderboard" className="font-medium text-accent-2 underline-offset-4 hover:underline">158 Sign in159 </Link>{" "}160 to see where you rank.161 </p>162 )}163 {data?.updatedAt ? <p className="mt-3 text-center text-[11px] text-fg-4">Updated {timeAgo(data.updatedAt)}</p> : null}164 </div>165 )}166167 <div className="mt-10 flex items-start gap-3 rounded-md border border-line p-4 text-[13px] leading-relaxed text-fg-3">168 <Info className="mt-0.5 h-4 w-4 shrink-0" />169 <p>170 Privacy: leaderboards show your username, level and result only. You can leave all leaderboards at any time from{" "}171 <Link href="/settings#leaderboard" className="font-medium text-fg-2 underline-offset-4 hover:underline">172 Settings → Leaderboard participation173 </Link>174 . Spinza never shares data with third parties.175 </p>176 </div>177 </AppShell>178 );179}180