Files
edbridge-scholars/src/pages/student/Rewards.tsx
pptx704 e4c86d473c fix: resolve bugs and improve frontend performance
- Fix register not resetting isLoading on success (causing login page to hang)
- Fix leaderboard streaks 400 error by forcing all_time timeframe
- Reorder routes so static paths match before dynamic practice/:sheetId
- Lazy-load QuestMap + Three.js (saves ~350KB gzip on initial load)
- Move KaTeX CSS to lazy import (only loads on math pages)
- Remove 28 duplicate Google Font @import lines from component CSS
- Add font preconnect + single stylesheet link in index.html
- Replace 8 unsafe JSON.parse(localStorage) calls with Zustand selectors
- Add global ErrorBoundary to prevent full-app crashes
- Extract arcTheme utilities to break static import cycle with QuestMap
- Merge Three.js + Troika into single chunk to fix circular dependency

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:41:13 +06:00

718 lines
27 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useAuthStore } from "../../stores/authStore";
import firstTrophy from "../../assets/icons/first_trophy.png";
import secondTrophy from "../../assets/icons/second_trophy.png";
import thirdTrophy from "../../assets/icons/third_trophy.png";
import { useEffect, useState } from "react";
import { getRandomColor } from "../../lib/utils";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "../../components/ui/avatar";
import { Flame, LucideBadgeQuestionMark, Zap, ChevronDown } from "lucide-react";
import type { Leaderboard } from "../../types/leaderboard";
import { api } from "../../utils/api";
import { useExamConfigStore } from "../../stores/useExamConfigStore";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuTrigger,
} from "../../components/ui/dropdown-menu";
const DOTS = [
{ size: 10, color: "#f97316", top: "6%", left: "4%", delay: "0s" },
{ size: 7, color: "#a855f7", top: "28%", left: "2%", delay: "1.2s" },
{ size: 9, color: "#22c55e", top: "58%", left: "3%", delay: "0.6s" },
{ size: 12, color: "#3b82f6", top: "10%", right: "4%", delay: "1.8s" },
{ size: 7, color: "#f43f5e", top: "42%", right: "2%", delay: "0.9s" },
{ size: 9, color: "#eab308", top: "72%", right: "5%", delay: "0.4s" },
];
const STYLES = `
:root { --content-max: 1100px; }
.rw-screen {
height: 100vh;
background: #fffbf4;
font-family: 'Nunito', sans-serif;
position: relative;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* On desktop, account for sidebar */
@media (min-width: 768px) {
.rw-screen {
padding-left: calc(17rem + 1.25rem);
}
}
.rw-blob { position:fixed;pointer-events:none;z-index:0;filter:blur(48px);opacity:0.35; }
.rw-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:rwWobble1 14s ease-in-out infinite; }
.rw-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:rwWobble2 16s ease-in-out infinite; }
.rw-blob-3 { width:210px;height:210px;background:#fbcfe8;top:15%;right:-60px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:rwWobble1 18s ease-in-out infinite reverse; }
.rw-blob-4 { width:150px;height:150px;background:#bfdbfe;bottom:12%;right:2%;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:rwWobble2 12s ease-in-out infinite; }
@keyframes rwWobble1 {
0%,100%{border-radius:60% 40% 70% 30%/50% 60% 40% 50%;transform:translate(0,0) rotate(0deg);}
50%{border-radius:40% 60% 30% 70%/60% 40% 60% 40%;transform:translate(12px,16px) rotate(8deg);}
}
@keyframes rwWobble2 {
0%,100%{border-radius:40% 60% 30% 70%/60% 40% 60% 40%;transform:translate(0,0) rotate(0deg);}
50%{border-radius:60% 40% 70% 30%/40% 60% 40% 60%;transform:translate(-10px,12px) rotate(-6deg);}
}
.rw-dot { position:fixed;border-radius:50%;pointer-events:none;z-index:0;opacity:0.3;animation:rwFloat 7s ease-in-out infinite; }
@keyframes rwFloat {
0%,100%{transform:translateY(0) rotate(0deg);}
50%{transform:translateY(-12px) rotate(180deg);}
}
.rw-sticky-top {
position:relative;z-index:2;
flex-shrink:0;
padding:2rem 1.25rem 0;
}
.rw-sticky-top-inner {
max-width:580px;margin:0 auto;
display:flex;flex-direction:column;gap:1.25rem;
padding-bottom:1rem;
border-bottom:2px solid #f3f4f6;
}
.rw-scroll-area {
position:relative;z-index:1;
flex:1;overflow-y:auto;
padding:1rem 1.25rem 10rem;
-webkit-overflow-scrolling:touch;
}
.rw-scroll-inner { max-width:580px;margin:0 auto; }
/* Desktop: wider centered layout */
@media (min-width: 900px) {
.rw-sticky-top-inner { max-width: var(--content-max); padding: 2rem 2rem 1.25rem; }
.rw-scroll-inner { max-width: var(--content-max); }
.rw-scroll-area { padding: 1.5rem 2.5rem 10rem; }
/* Make empty state sit visually centered within larger canvas */
.rw-empty { padding: 5rem 1rem; }
/* Slightly larger island pill on wide screens and rebalance blobs */
.rw-island-wrap { max-width: 420px; left:auto; right:calc((100vw - 256px - var(--content-max)) / 2); top:240px; bottom:auto; transform:none; margin-left:25px; }
.rw-island-card { gap: 0.75rem; }
/* Rebalance decorative blobs on wide screens */
.rw-blob-1 { left: -120px; top: -100px; width:300px; height:300px; }
.rw-blob-2 { left: 6%; bottom: -40px; }
.rw-blob-3 { right: -100px; top: 8%; width:260px; height:260px; }
.rw-blob-4 { right: 2%; bottom: 6%; }
}
@keyframes rwPopIn {
from{opacity:0;transform:scale(0.92) translateY(12px);}
to{opacity:1;transform:scale(1) translateY(0);}
}
.rw-anim { animation:rwPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; }
.rw-anim-1 { animation-delay:0.05s; }
.rw-anim-2 { animation-delay:0.1s; }
.rw-header { display:flex;flex-direction:column;align-items:center;gap:0.4rem;text-align:center; }
.rw-title { font-size:1.9rem;font-weight:900;color:#1e1b4b;letter-spacing:-0.02em; }
.rw-rank-text { font-family:'Nunito Sans',sans-serif;font-size:0.9rem;font-weight:600;color:#9ca3af; }
.rw-rank-text span { color:#a855f7;font-weight:800; }
.rw-controls { display:flex;align-items:center;justify-content:space-between;gap:0.5rem; }
.rw-tabs { display:flex;gap:0.4rem;flex-wrap:wrap; }
.rw-tab-btn {
padding:0.45rem 0.9rem;border-radius:100px;border:none;cursor:pointer;
font-family:'Nunito',sans-serif;font-size:0.78rem;font-weight:800;
display:flex;align-items:center;gap:0.35rem;
transition:all 0.2s ease;
background:white;border:2.5px solid #f3f4f6;color:#9ca3af;
box-shadow:0 2px 8px rgba(0,0,0,0.04);
}
.rw-tab-btn.active { background:#1e1b4b;border-color:#1e1b4b;color:white;box-shadow:0 4px 0 #1e1b4b55; }
.rw-filter-btn {
display:flex;align-items:center;gap:0.35rem;
padding:0.45rem 0.9rem;border-radius:100px;cursor:pointer;
font-family:'Nunito',sans-serif;font-size:0.78rem;font-weight:800;
background:white;border:2.5px solid #f3f4f6;color:#6b7280;
box-shadow:0 2px 8px rgba(0,0,0,0.04);
transition:border-color 0.2s;
}
.rw-filter-btn:hover { border-color:#c4b5fd;color:#7c3aed; }
.rw-list { display:flex;flex-direction:column;gap:0.6rem; }
.rw-row {
display:flex;align-items:center;justify-content:space-between;
background:white;border:2.5px solid #f3f4f6;border-radius:18px;
padding:0.7rem 1rem;
box-shadow:0 3px 10px rgba(0,0,0,0.04);
transition:transform 0.15s ease,box-shadow 0.15s ease;
}
.rw-row:hover { transform:translateY(-1px);box-shadow:0 6px 16px rgba(0,0,0,0.07); }
.rw-row.top-1 { border-color:#fde68a;background:linear-gradient(135deg,#fffbeb,white); }
.rw-row.top-2 { border-color:#e2e8f0;background:linear-gradient(135deg,#f8fafc,white); }
.rw-row.top-3 { border-color:#fecba8;background:linear-gradient(135deg,#fff7ed,white); }
.rw-row-left { display:flex;align-items:center;gap:0.75rem; }
.rw-rank-cell { width:36px;display:flex;align-items:center;justify-content:center; }
.rw-rank-num { font-size:0.9rem;font-weight:900;color:#9ca3af; }
.rw-user-name { font-size:0.88rem;font-weight:800;color:#1e1b4b; }
.rw-row-right { display:flex;align-items:center;gap:0.35rem; }
.rw-score { font-size:0.9rem;font-weight:900;color:#1e1b4b; }
.rw-xp-chip { color:#84cc16; }
.rw-q-chip { color:#0891b2; }
.rw-fire-chip { color:#ef4444; }
.rw-skeleton { display:flex;flex-direction:column;gap:0.6rem; }
.rw-skel-row {
display:flex;align-items:center;justify-content:space-between;
background:white;border:2.5px solid #f3f4f6;border-radius:18px;
padding:0.7rem 1rem;
}
.rw-skel-left { display:flex;align-items:center;gap:0.75rem; }
.rw-skel-circle { border-radius:50%;background:#f3f4f6;animation:rwShimmer 1.5s ease-in-out infinite; }
.rw-skel-line { border-radius:6px;background:#f3f4f6;animation:rwShimmer 1.5s ease-in-out infinite; }
@keyframes rwShimmer { 0%,100%{opacity:1;}50%{opacity:0.4;} }
/* ── Empty state ── */
.rw-empty {
display:flex;flex-direction:column;align-items:center;justify-content:center;
padding:3.5rem 1rem;gap:0.5rem;text-align:center;
}
.rw-empty-emoji { font-size:3rem;line-height:1; }
.rw-empty-title { font-size:1rem;font-weight:800;color:#6b7280;margin:0; }
.rw-empty-sub { font-size:0.82rem;font-weight:600;color:#9ca3af;margin:0; }
/* ── Floating island pill ── */
.rw-island-wrap {
position:fixed;
bottom:calc(1.25rem + 80px + env(safe-area-inset-bottom));
left:50%;
transform:translateX(-50%);
z-index:20;
display:flex;
flex-direction:column;
align-items:center;
gap:0.5rem;
width:auto;
max-width:300px;
top:auto;
}
/* Tablet/small desktop: shift pill right to avoid sidebar overlap */
@media (min-width: 768px) and (max-width: 1200px) {
.rw-island-wrap {
left: calc(17rem + 10rem); /* sidebar width + gap */
transform: none;
align-items: flex-start;
}
}
/* Tablet/small desktop: shift pill right to avoid sidebar overlap */
@media (min-width: 1200px) {
.rw-island-wrap {
left: 50%;
transform: none;
align-items: flex-start;
}
}
.rw-island-card {
background:rgba(255,251,244,0.92);
backdrop-filter:blur(20px);
-webkit-backdrop-filter:blur(20px);
border:1.5px solid rgba(255,255,255,0.8);
border-radius:24px;
box-shadow:0 8px 32px rgba(0,0,0,0.12),inset 0 1px 0 rgba(255,255,255,0.9);
padding:1rem 1.25rem;
display:grid;
grid-template-columns:1fr 1fr 1fr;
gap:0.5rem;
width:100%;
box-sizing:border-box;
opacity:0;
transform:translateY(12px) scale(0.95);
pointer-events:none;
transition:opacity 0.3s cubic-bezier(0.34,1.56,0.64,1),transform 0.35s cubic-bezier(0.34,1.56,0.64,1);
}
.rw-island-card.open { opacity:1;transform:translateY(0) scale(1);pointer-events:auto; }
.rw-island-stat {
display:flex;flex-direction:column;align-items:center;gap:0.2rem;
padding:0.5rem 0.4rem;
background:white;border:2px solid #f3f4f6;border-radius:14px;
}
.rw-island-stat-val { font-family:'Nunito',sans-serif;font-size:1rem;font-weight:900;color:#1e1b4b;line-height:1; }
.rw-island-stat-label { font-family:'Nunito',sans-serif;font-size:0.58rem;font-weight:800;letter-spacing:0.1em;text-transform:uppercase;color:#9ca3af; }
/* stat color variants */
.rw-island-stat.xp { border-color:#d9f99d; }
.rw-island-stat.xp .rw-island-stat-val { color:#16a34a; }
.rw-island-stat.rank { border-color:#e9d5ff; }
.rw-island-stat.rank .rw-island-stat-val { color:#9333ea; }
.rw-island-stat.lvl { border-color:#bfdbfe; }
.rw-island-stat.lvl .rw-island-stat-val { color:#2563eb; }
.rw-island-stat.q { border-color:#bae6fd; }
.rw-island-stat.q .rw-island-stat-val { color:#0891b2; }
.rw-island-stat.streak { border-color:#fecaca; }
.rw-island-stat.streak .rw-island-stat-val { color:#ef4444; }
.rw-island-pill {
width:100%;
display:flex;align-items:center;gap:0.65rem;
background:linear-gradient(135deg,#7c3aed,#a855f7);
border:1.5px solid rgba(255,255,255,0.25);
border-radius:100px;
padding:0.45rem 1rem 0.45rem 0.45rem;
box-shadow:0 6px 20px rgba(124,58,237,0.35),0 2px 6px rgba(124,58,237,0.2),inset 0 1px 0 rgba(255,255,255,0.2);
cursor:pointer;
user-select:none;
-webkit-tap-highlight-color:transparent;
justify-content:space-between;
transition:transform 0.2s cubic-bezier(0.34,1.56,0.64,1),box-shadow 0.2s ease,opacity 0.2s ease;
box-sizing:border-box;
}
.rw-island-pill:active { transform:scale(0.93); }
.rw-island-pill.open { box-shadow:0 10px 28px rgba(124,58,237,0.4),0 4px 10px rgba(124,58,237,0.25),inset 0 1px 0 rgba(255,255,255,0.2); }
/* Visually dim the pill when there's no user rank data to expand */
.rw-island-pill.no-data { opacity:0.65;cursor:default; }
.rw-island-left { display:flex;align-items:center;gap:0.65rem; }
.rw-island-avatar {
width:38px;height:38px;border-radius:50%;
background:rgba(255,255,255,0.25);border:2px solid rgba(255,255,255,0.4);
display:flex;align-items:center;justify-content:center;
font-family:'Nunito',sans-serif;font-size:0.9rem;font-weight:900;color:white;
overflow:hidden;flex-shrink:0;
}
.rw-island-avatar img { width:100%;height:100%;object-fit:cover; }
.rw-island-info { display:flex;flex-direction:column;gap:1px; }
.rw-island-you { font-family:'Nunito',sans-serif;font-size:0.58rem;font-weight:800;letter-spacing:0.12em;text-transform:uppercase;color:rgba(255,255,255,0.6);line-height:1; }
.rw-island-name { font-family:'Nunito',sans-serif;font-size:0.88rem;font-weight:900;color:white;line-height:1.1;white-space:nowrap; }
.rw-island-right { display:flex;align-items:center;gap:0.5rem; }
.rw-island-metric {
display:flex;align-items:center;gap:0.25rem;
padding:0.3rem 0.65rem;
background:rgba(255,255,255,0.15);
border:1.5px solid rgba(255,255,255,0.2);
border-radius:100px;
}
.rw-island-metric-val { font-family:'Nunito',sans-serif;font-size:0.9rem;font-weight:900;color:white; }
.rw-island-chevron {
width:22px;height:22px;border-radius:50%;
background:rgba(255,255,255,0.15);
display:flex;align-items:center;justify-content:center;
flex-shrink:0;
transition:transform 0.3s cubic-bezier(0.34,1.56,0.64,1);
}
.rw-island-pill.open .rw-island-chevron { transform:rotate(180deg); }
`;
const TABS = [
{ id: "xp", label: "XP", icon: <Zap size={13} /> },
{
id: "questions",
label: "Questions",
icon: <LucideBadgeQuestionMark size={13} />,
},
{ id: "streaks", label: "Streaks", icon: <Flame size={13} /> },
] as const;
type TabId = (typeof TABS)[number]["id"];
const trophies = [firstTrophy, secondTrophy, thirdTrophy];
// ── Score helpers ─────────────────────────────────────────────────────────────
const getScore = (u: Record<string, unknown>, tab: TabId): number | string => {
if (tab === "xp") return (u.score ?? "—") as number | string;
if (tab === "questions") return (u.score ?? "—") as number | string;
if (tab === "streaks") return (u.streak ?? "—") as number | string;
return "—";
};
const getUserScore = (
ur: Record<string, unknown> | undefined,
tab: TabId,
): number | string => {
if (!ur) return "—";
return getScore(ur, tab);
};
// ── Island card config — driven by active tab ─────────────────────────────────
const getIslandStats = (
ur: Record<string, unknown> | undefined,
tab: TabId,
) => {
const rank = ur?.rank != null ? `#${ur.rank}` : "—";
const xp = String(ur?.score ?? ur?.total_xp ?? "—");
const level = String(ur?.current_level ?? ur?.level ?? "—");
const qs = String(
ur?.questions_answered ?? ur?.total_questions ?? ur?.questions ?? "—",
);
const str = String(ur?.streaks ?? ur?.streak ?? ur?.current_streak ?? "—");
if (tab === "xp")
return [
{ cls: "rank", val: rank, label: "Rank" },
{ cls: "xp", val: xp, label: "XP" },
{ cls: "lvl", val: level, label: "Level" },
];
if (tab === "questions")
return [
{ cls: "rank", val: rank, label: "Rank" },
{ cls: "q", val: qs, label: "Questions" },
{ cls: "lvl", val: level, label: "Level" },
];
// streaks
return [
{ cls: "rank", val: rank, label: "Rank" },
{ cls: "streak", val: str, label: "Streak" },
{ cls: "lvl", val: level, label: "Level" },
];
};
const SkeletonRows = () => (
<div className="rw-skeleton">
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} className="rw-skel-row">
<div className="rw-skel-left">
<div className="rw-skel-circle" style={{ width: 36, height: 36 }} />
<div className="rw-skel-circle" style={{ width: 40, height: 40 }} />
<div
className="rw-skel-line"
style={{ width: 100 + i * 10, height: 12 }}
/>
</div>
<div className="rw-skel-line" style={{ width: 40, height: 12 }} />
</div>
))}
</div>
);
const EmptyState = () => (
<div className="rw-empty">
<span className="rw-empty-emoji">🏜</span>
<p className="rw-empty-title">No entries yet</p>
<p className="rw-empty-sub">Be the first on the board!</p>
</div>
);
export const Rewards = () => {
const user = useAuthStore((state) => state.user);
const token = useAuthStore((state) => state.token);
const [time, setTime] = useState("today");
const [activeTab, setActiveTab] = useState<TabId>("xp");
const [leaderboard, setLeaderboard] = useState<Leaderboard | undefined>();
const [loading, setLoading] = useState(false);
const [islandOpen, setIslandOpen] = useState(false);
const { setUserMetrics } = useExamConfigStore();
const TIME_MAP: Record<string, string> = {
today: "daily",
week: "weekly",
month: "monthly",
alltime: "all_time",
};
useEffect(() => {
const fetchData = async () => {
if (!user || !token) return;
try {
setLoading(true);
const timeframe =
activeTab === "streaks" ? "all_time" : (TIME_MAP[time] ?? "daily");
const response = await api.fetchLeaderboard(
token,
activeTab,
timeframe,
);
setLeaderboard(response);
// ✅ FIX 1: Guard against null user_rank before accessing its properties
setUserMetrics({
xp: response.user_rank?.score ?? 0,
questions: 0,
streak: response.user_rank?.streak ?? 0,
});
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
fetchData();
}, [user, activeTab, time]);
const metricIcon = (size = 17) =>
activeTab === "xp" ? (
<Zap size={size} className="rw-xp-chip" />
) : activeTab === "questions" ? (
<LucideBadgeQuestionMark size={size} className="rw-q-chip" />
) : (
<Flame size={size} className="rw-fire-chip" />
);
const metricIconWhite = (size = 15) =>
activeTab === "xp" ? (
<Zap size={size} color="rgba(255,255,255,0.85)" />
) : activeTab === "questions" ? (
<LucideBadgeQuestionMark size={size} color="rgba(255,255,255,0.85)" />
) : (
<Flame size={size} color="rgba(255,255,255,0.85)" />
);
// ✅ FIX 2: Safely cast user_rank — null becomes undefined so all optional chaining works
const ur = (leaderboard?.user_rank ?? undefined) as
| Record<string, number>
| undefined;
const islandStats = getIslandStats(ur, activeTab);
const userMetric = getUserScore(ur, activeTab);
const hasUserRank = ur != null;
const formatTimeLabel = (t: string) =>
({ today: "Today", week: "Week", month: "Month", alltime: "All" })[t] ?? t;
return (
<div className="rw-screen">
<style>{STYLES}</style>
<div className="rw-blob rw-blob-1" />
<div className="rw-blob rw-blob-2" />
<div className="rw-blob rw-blob-3" />
<div className="rw-blob rw-blob-4" />
{DOTS.map((d, i) => (
<div
key={i}
className="rw-dot"
style={
{
width: d.size,
height: d.size,
background: d.color,
top: d.top,
left: d.left,
right: d.right,
animationDelay: d.delay,
animationDuration: `${5 + i * 0.5}s`,
} as React.CSSProperties
}
/>
))}
{/* Sticky header */}
<div className="rw-sticky-top">
<div className="rw-sticky-top-inner">
<header className="rw-header rw-anim">
<h1 className="rw-title">🏆 Leaderboard</h1>
{loading ? (
<div
style={{
height: 16,
width: 180,
background: "#f3f4f6",
borderRadius: 8,
animation: "rwShimmer 1.5s ease-in-out infinite",
}}
/>
) : (
// ✅ FIX 3: Show a sensible message when user has no rank yet
<p className="rw-rank-text">
{hasUserRank ? (
<>
You're <span>#{ur.rank}</span> — keep grinding!
</>
) : (
"No rank yet — start answering questions!"
)}
</p>
)}
</header>
<div className="rw-controls rw-anim rw-anim-1">
<div className="rw-tabs">
{TABS.map((tab) => (
<button
key={tab.id}
className={`rw-tab-btn${activeTab === tab.id ? " active" : ""}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.icon} {tab.label}
</button>
))}
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild disabled={activeTab === "streaks"}>
<button
className="rw-filter-btn"
style={activeTab === "streaks" ? { opacity: 0.5, cursor: "not-allowed" } : undefined}
>
{activeTab === "streaks" ? "All Time" : formatTimeLabel(time)}{" "}
<ChevronDown size={13} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent
style={{ fontFamily: "'Nunito',sans-serif", fontWeight: 700 }}
>
<DropdownMenuRadioGroup value={time} onValueChange={setTime}>
<DropdownMenuRadioItem value="today">
Today
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="week">
Week
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="month">
Month
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="alltime">
All Time
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</div>
{/* Scrollable list */}
<div className="rw-scroll-area">
<div className="rw-scroll-inner">
<div
className="rw-list rw-anim rw-anim-2"
style={{ paddingTop: "1rem" }}
>
{loading ? (
<SkeletonRows />
) : !leaderboard?.top_users?.length ? (
// ✅ FIX 4: Show empty state when top_users is empty or missing
<EmptyState />
) : (
leaderboard.top_users.map((u, index) => {
const row = u as Record<string, unknown>;
const rowClass = `rw-row${index === 0 ? " top-1" : index === 1 ? " top-2" : index === 2 ? " top-3" : ""}`;
const score = getScore(row, activeTab);
return (
<div key={String(row.user_id)} className={rowClass}>
<div className="rw-row-left">
<div className="rw-rank-cell">
{index < 3 ? (
<img
src={trophies[index]}
alt={`#${index + 1}`}
style={{ width: 32, height: 32 }}
/>
) : (
<span className="rw-rank-num">{index + 1}</span>
)}
</div>
<Avatar
className={getRandomColor()}
style={{ width: 38, height: 38 }}
>
<AvatarImage src={String(row.avatar_url ?? "")} />
<AvatarFallback
style={{
color: "black",
fontWeight: 900,
fontSize: "0.9rem",
}}
>
{String(row.name ?? "?")
.slice(0, 1)
.toUpperCase()}
</AvatarFallback>
</Avatar>
<span className="rw-user-name">
{String(row.name ?? "")}
</span>
</div>
<div className="rw-row-right">
<span className="rw-score">{score}</span>
{metricIcon()}
</div>
</div>
);
})
)}
</div>
</div>
</div>
{/* Floating island */}
<div className="rw-island-wrap">
{/* Expanded card — stats change with active tab */}
<div className={`rw-island-card${islandOpen ? " open" : ""}`}>
{islandStats.map((s) => (
<div key={s.cls} className={`rw-island-stat ${s.cls}`}>
<span className="rw-island-stat-val">{s.val}</span>
<span className="rw-island-stat-label">{s.label}</span>
</div>
))}
</div>
{/* Pill — ✅ FIX 4 cont: disabled + dimmed when no user rank data */}
<div
className={[
"rw-island-pill",
islandOpen ? "open" : "",
!hasUserRank ? "no-data" : "",
]
.join(" ")
.trim()}
onClick={() => {
// Only allow expanding if not loading and user rank data exists
if (!loading && hasUserRank) setIslandOpen((o) => !o);
}}
>
<div className="rw-island-left">
<div className="rw-island-avatar">
<span>
{String(ur?.name ?? user?.name ?? "?")
.slice(0, 1)
.toUpperCase()}
</span>
</div>
<div className="rw-island-info">
<span className="rw-island-you">You</span>
<span className="rw-island-name">
{loading
? "Loading…"
: hasUserRank
? String(ur.name ?? user?.name ?? "—")
: (user?.name ?? "Not ranked")}
</span>
</div>
</div>
<div className="rw-island-right">
<div className="rw-island-metric">
<span className="rw-island-metric-val">{userMetric}</span>
{metricIconWhite()}
</div>
<div className="rw-island-chevron">
{/* Hide chevron when there's nothing to expand */}
{hasUserRank && <ChevronDown size={13} color="white" />}
</div>
</div>
</div>
</div>
</div>
);
};