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: }, { id: "questions", label: "Questions", icon: , }, { id: "streaks", label: "Streaks", icon: }, ] as const; type TabId = (typeof TABS)[number]["id"]; const trophies = [firstTrophy, secondTrophy, thirdTrophy]; // ── Score helpers ───────────────────────────────────────────────────────────── const getScore = (u: Record, 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 | undefined, tab: TabId, ): number | string => { if (!ur) return "—"; return getScore(ur, tab); }; // ── Island card config — driven by active tab ───────────────────────────────── const getIslandStats = ( ur: Record | 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 = () => ( {Array.from({ length: 7 }).map((_, i) => ( ))} ); const EmptyState = () => ( 🏜️ No entries yet Be the first on the board! ); export const Rewards = () => { const user = useAuthStore((state) => state.user); const token = useAuthStore((state) => state.token); const [time, setTime] = useState("today"); const [activeTab, setActiveTab] = useState("xp"); const [leaderboard, setLeaderboard] = useState(); const [loading, setLoading] = useState(false); const [islandOpen, setIslandOpen] = useState(false); const { setUserMetrics } = useExamConfigStore(); const TIME_MAP: Record = { 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" ? ( ) : activeTab === "questions" ? ( ) : ( ); const metricIconWhite = (size = 15) => activeTab === "xp" ? ( ) : activeTab === "questions" ? ( ) : ( ); // ✅ FIX 2: Safely cast user_rank — null becomes undefined so all optional chaining works const ur = (leaderboard?.user_rank ?? undefined) as | Record | 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 ( {DOTS.map((d, i) => ( ))} {/* Sticky header */} 🏆 Leaderboard {loading ? ( ) : ( // ✅ FIX 3: Show a sensible message when user has no rank yet {hasUserRank ? ( <> You're #{ur.rank} — keep grinding! > ) : ( "No rank yet — start answering questions!" )} )} {TABS.map((tab) => ( setActiveTab(tab.id)} > {tab.icon} {tab.label} ))} {activeTab === "streaks" ? "All Time" : formatTimeLabel(time)}{" "} Today Week Month All Time {/* Scrollable list */} {loading ? ( ) : !leaderboard?.top_users?.length ? ( // ✅ FIX 4: Show empty state when top_users is empty or missing ) : ( leaderboard.top_users.map((u, index) => { const row = u as Record; const rowClass = `rw-row${index === 0 ? " top-1" : index === 1 ? " top-2" : index === 2 ? " top-3" : ""}`; const score = getScore(row, activeTab); return ( {index < 3 ? ( ) : ( {index + 1} )} {String(row.name ?? "?") .slice(0, 1) .toUpperCase()} {String(row.name ?? "")} {score} {metricIcon()} ); }) )} {/* Floating island */} {/* Expanded card — stats change with active tab */} {islandStats.map((s) => ( {s.val} {s.label} ))} {/* Pill — ✅ FIX 4 cont: disabled + dimmed when no user rank data */} { // Only allow expanding if not loading and user rank data exists if (!loading && hasUserRank) setIslandOpen((o) => !o); }} > {String(ur?.name ?? user?.name ?? "?") .slice(0, 1) .toUpperCase()} You {loading ? "Loading…" : hasUserRank ? String(ur.name ?? user?.name ?? "—") : (user?.name ?? "Not ranked")} {userMetric} {metricIconWhite()} {/* Hide chevron when there's nothing to expand */} {hasUserRank && } ); };
No entries yet
Be the first on the board!
{hasUserRank ? ( <> You're #{ur.rank} — keep grinding! > ) : ( "No rank yet — start answering questions!" )}