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