fix(leaderboard): fix leaderboard scheme for questions and streaks

This commit is contained in:
shafin-r
2026-02-22 03:29:01 +06:00
parent a48a50ae77
commit be63ca5ed2
7 changed files with 1454 additions and 1436 deletions

View File

@ -17,6 +17,7 @@ import {
DrawerContent, DrawerContent,
DrawerTrigger, DrawerTrigger,
} from "../../components/ui/drawer"; } from "../../components/ui/drawer";
import { useExamConfigStore } from "../../stores/useExamConfigStore";
// ─── Shared blob/dot background (same as break/results screens) ──────────────── // ─── Shared blob/dot background (same as break/results screens) ────────────────
const DOTS = [ const DOTS = [
@ -332,6 +333,7 @@ const PAGE_SIZE = 2;
export const Home = () => { export const Home = () => {
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const navigate = useNavigate(); const navigate = useNavigate();
const { userMetrics } = useExamConfigStore();
const [practiceSheets, setPracticeSheets] = useState<PracticeSheet[]>([]); const [practiceSheets, setPracticeSheets] = useState<PracticeSheet[]>([]);
const [notStartedSheets, setNotStartedSheets] = useState<PracticeSheet[]>([]); const [notStartedSheets, setNotStartedSheets] = useState<PracticeSheet[]>([]);
@ -465,7 +467,7 @@ export const Home = () => {
{/* Streak chip */} {/* Streak chip */}
<div className="h-chip streak"> <div className="h-chip streak">
<Flame size={18} style={{ fill: "#fca5a5" }} /> <Flame size={18} style={{ fill: "#fca5a5" }} />
<span>5</span> <span>{userMetrics.streak}</span>
</div> </div>
{/* Score chip */} {/* Score chip */}

View File

@ -120,6 +120,7 @@ const STYLES = `
background: white; border: none; width: 100%; text-align: left; background: white; border: none; width: 100%; text-align: left;
transition: background 0.15s ease; transition: background 0.15s ease;
border-bottom: 2px solid #f9fafb; border-bottom: 2px solid #f9fafb;
opacity: 0.5;
} }
.pf-row:last-child { border-bottom: none; } .pf-row:last-child { border-bottom: none; }
.pf-row:hover { background: #fafaf9; } .pf-row:hover { background: #fafaf9; }

View File

@ -3,7 +3,7 @@ import firstTrophy from "../../assets/icons/first_trophy.png";
import secondTrophy from "../../assets/icons/second_trophy.png"; import secondTrophy from "../../assets/icons/second_trophy.png";
import thirdTrophy from "../../assets/icons/third_trophy.png"; import thirdTrophy from "../../assets/icons/third_trophy.png";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { formatTimeFilter, getRandomColor } from "../../lib/utils"; import { getRandomColor } from "../../lib/utils";
import { import {
Avatar, Avatar,
AvatarFallback, AvatarFallback,
@ -12,7 +12,6 @@ import {
import { Flame, LucideBadgeQuestionMark, Zap, ChevronDown } from "lucide-react"; import { Flame, LucideBadgeQuestionMark, Zap, ChevronDown } from "lucide-react";
import type { Leaderboard } from "../../types/leaderboard"; import type { Leaderboard } from "../../types/leaderboard";
import { api } from "../../utils/api"; import { api } from "../../utils/api";
import { LeaderboardRowSkeleton } from "../../components/LeaderboardSkeleton";
import { useExamConfigStore } from "../../stores/useExamConfigStore"; import { useExamConfigStore } from "../../stores/useExamConfigStore";
import { import {
DropdownMenu, DropdownMenu,
@ -42,10 +41,9 @@ const STYLES = `
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
padding-bottom: 0;
} }
.rw-blob { position: fixed; pointer-events: none; z-index: 0; filter: blur(48px); opacity: 0.35; } .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-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-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-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; }
@ -66,88 +64,76 @@ const STYLES = `
50%{transform:translateY(-12px) rotate(180deg);} 50%{transform:translateY(-12px) rotate(180deg);}
} }
/* Sticky top wrapper */
.rw-sticky-top { .rw-sticky-top {
position: relative; z-index: 2; position:relative;z-index:2;
background: #fffbf4; background:#fffbf4;
flex-shrink: 0; flex-shrink:0;
padding: 2rem 1.25rem 0; padding:2rem 1.25rem 0;
} }
.rw-sticky-top-inner { .rw-sticky-top-inner {
max-width: 580px; margin: 0 auto; max-width:580px;margin:0 auto;
display: flex; flex-direction: column; gap: 1.25rem; display:flex;flex-direction:column;gap:1.25rem;
padding-bottom: 1rem; padding-bottom:1rem;
border-bottom: 2px solid #f3f4f6; border-bottom:2px solid #f3f4f6;
} }
/* Scrollable list area */
.rw-scroll-area { .rw-scroll-area {
position: relative; z-index: 1; position:relative;z-index:1;
flex: 1; overflow-y: auto; flex:1;overflow-y:auto;
padding: 1rem 1.25rem 10rem; padding:1rem 1.25rem 10rem;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling:touch;
}
.rw-scroll-inner {
max-width: 580px; margin: 0 auto;
} }
.rw-scroll-inner { max-width:580px;margin:0 auto; }
@keyframes rwPopIn { @keyframes rwPopIn {
from { opacity:0; transform: scale(0.92) translateY(12px); } from{opacity:0;transform:scale(0.92) translateY(12px);}
to { opacity:1; transform: scale(1) translateY(0); } 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 { animation:rwPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; }
.rw-anim-1 { animation-delay: 0.05s; } .rw-anim-1 { animation-delay:0.05s; }
.rw-anim-2 { animation-delay: 0.1s; } .rw-anim-2 { animation-delay:0.1s; }
/* Header */
.rw-header { display:flex;flex-direction:column;align-items:center;gap:0.4rem;text-align:center; } .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-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 { 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-rank-text span { color:#a855f7;font-weight:800; }
/* Controls row */ .rw-controls { display:flex;align-items:center;justify-content:space-between;gap:0.5rem; }
.rw-controls {
display: flex; align-items: center; justify-content: space-between; gap: 0.5rem;
}
/* Tab pills */ .rw-tabs { display:flex;gap:0.4rem;flex-wrap:wrap; }
.rw-tabs { display:flex;gap:0.4rem; }
.rw-tab-btn { .rw-tab-btn {
padding: 0.45rem 0.9rem; border-radius: 100px; border: none; cursor: pointer; padding:0.45rem 0.9rem;border-radius:100px;border:none;cursor:pointer;
font-family: 'Nunito', sans-serif; font-size: 0.78rem; font-weight: 800; font-family:'Nunito',sans-serif;font-size:0.78rem;font-weight:800;
display: flex; align-items: center; gap: 0.35rem; display:flex;align-items:center;gap:0.35rem;
transition: all 0.2s ease; transition:all 0.2s ease;
background: white; border: 2.5px solid #f3f4f6; color: #9ca3af; background:white;border:2.5px solid #f3f4f6;color:#9ca3af;
box-shadow: 0 2px 8px rgba(0,0,0,0.04); 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-tab-btn.active { background:#1e1b4b;border-color:#1e1b4b;color:white;box-shadow:0 4px 0 #1e1b4b55; }
/* Time filter button */
.rw-filter-btn { .rw-filter-btn {
display: flex; align-items: center; gap: 0.35rem; display:flex;align-items:center;gap:0.35rem;
padding: 0.45rem 0.9rem; border-radius: 100px; cursor: pointer; padding:0.45rem 0.9rem;border-radius:100px;cursor:pointer;
font-family: 'Nunito', sans-serif; font-size: 0.78rem; font-weight: 800; font-family:'Nunito',sans-serif;font-size:0.78rem;font-weight:800;
background: white; border: 2.5px solid #f3f4f6; color: #6b7280; background:white;border:2.5px solid #f3f4f6;color:#6b7280;
box-shadow: 0 2px 8px rgba(0,0,0,0.04); box-shadow:0 2px 8px rgba(0,0,0,0.04);
transition: border-color 0.2s; transition:border-color 0.2s;
} }
.rw-filter-btn:hover { border-color: #c4b5fd; color: #7c3aed; } .rw-filter-btn:hover { border-color:#c4b5fd;color:#7c3aed; }
/* Leaderboard list */
.rw-list { display:flex;flex-direction:column;gap:0.6rem; } .rw-list { display:flex;flex-direction:column;gap:0.6rem; }
/* Each row */
.rw-row { .rw-row {
display: flex; align-items: center; justify-content: space-between; display:flex;align-items:center;justify-content:space-between;
background: white; border: 2.5px solid #f3f4f6; border-radius: 18px; background:white;border:2.5px solid #f3f4f6;border-radius:18px;
padding: 0.7rem 1rem; padding:0.7rem 1rem;
box-shadow: 0 3px 10px rgba(0,0,0,0.04); box-shadow:0 3px 10px rgba(0,0,0,0.04);
transition: transform 0.15s ease, box-shadow 0.15s ease; 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: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-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-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.top-3 { border-color:#fecba8;background:linear-gradient(135deg,#fff7ed,white); }
.rw-row-left { display:flex;align-items:center;gap:0.75rem; } .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-cell { width:36px;display:flex;align-items:center;justify-content:center; }
@ -156,146 +142,11 @@ const STYLES = `
.rw-row-right { display:flex;align-items:center;gap:0.35rem; } .rw-row-right { display:flex;align-items:center;gap:0.35rem; }
.rw-score { font-size:0.9rem;font-weight:900;color:#1e1b4b; } .rw-score { font-size:0.9rem;font-weight:900;color:#1e1b4b; }
/* XP chip color variants */
.rw-xp-chip { color:#84cc16; } .rw-xp-chip { color:#84cc16; }
.rw-q-chip { color:#0891b2; } .rw-q-chip { color:#0891b2; }
.rw-fire-chip { color:#ef4444; } .rw-fire-chip { color:#ef4444; }
/* Skeleton */
.rw-skeleton { display:flex;flex-direction:column;gap:0.6rem; } .rw-skeleton { display:flex;flex-direction:column;gap:0.6rem; }
/* ── Floating island you-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;
max-width: 480px
}
/* Expanded info card — slides up */
.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;
min-width: 350px;
/* Hidden by default */
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;
}
.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; }
/* The pill button itself */
.rw-island-pill {
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;
transition: transform 0.2s cubic-bezier(0.34,1.56,0.64,1), box-shadow 0.2s ease;
}
.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);
}
.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-metric {
display: flex; align-items: center; gap: 0.25rem;
padding-left: 0.5rem;
border-left: 1.5px solid rgba(255,255,255,0.2);
}
.rw-island-metric-val {
font-family: 'Nunito', sans-serif;
font-size: 0.95rem; 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); }
/* Shimmer skeleton line */
.rw-skel-row { .rw-skel-row {
display:flex;align-items:center;justify-content:space-between; display:flex;align-items:center;justify-content:space-between;
background:white;border:2.5px solid #f3f4f6;border-radius:18px; background:white;border:2.5px solid #f3f4f6;border-radius:18px;
@ -304,9 +155,115 @@ const STYLES = `
.rw-skel-left { display:flex;align-items:center;gap:0.75rem; } .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-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; } .rw-skel-line { border-radius:6px;background:#f3f4f6;animation:rwShimmer 1.5s ease-in-out infinite; }
@keyframes rwShimmer { @keyframes rwShimmer { 0%,100%{opacity:1;}50%{opacity:0.4;} }
0%,100%{opacity:1;} 50%{opacity:0.4;}
/* ── 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:calc(100% - 2rem);
max-width:300px;
} }
.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;
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); }
.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 = [ const TABS = [
@ -316,13 +273,65 @@ const TABS = [
label: "Questions", label: "Questions",
icon: <LucideBadgeQuestionMark size={13} />, icon: <LucideBadgeQuestionMark size={13} />,
}, },
{ id: "streak", label: "Streak", icon: <Flame size={13} /> }, { id: "streaks", label: "Streaks", icon: <Flame size={13} /> },
] as const; ] as const;
type TabId = (typeof TABS)[number]["id"]; type TabId = (typeof TABS)[number]["id"];
const trophies = [firstTrophy, secondTrophy, thirdTrophy]; const trophies = [firstTrophy, secondTrophy, thirdTrophy];
// ── Score helpers ─────────────────────────────────────────────────────────────
// Tab ID is "streaks" (plural) — match exactly. Try multiple plausible field names.
const getScore = (u: Record<string, unknown>, tab: TabId): number | string => {
if (tab === "xp" || tab === "questions")
return (u.score ?? u.total_xp ?? u.xp ?? "—") 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 ─────────────────────────────────
// Each tab shows 3 contextually relevant stats in the expanded card.
const getIslandStats = (
ur: Record<string, unknown> | undefined,
tab: TabId,
) => {
const rank = `#${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 = () => ( const SkeletonRows = () => (
<div className="rw-skeleton"> <div className="rw-skeleton">
{Array.from({ length: 7 }).map((_, i) => ( {Array.from({ length: 7 }).map((_, i) => (
@ -343,68 +352,85 @@ const SkeletonRows = () => (
export const Rewards = () => { export const Rewards = () => {
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const [time, setTime] = useState("bottom"); const [time, setTime] = useState("today");
const [activeTab, setActiveTab] = useState<TabId>("xp"); const [activeTab, setActiveTab] = useState<TabId>("xp");
const [leaderboard, setLeaderboard] = useState<Leaderboard>(); const [leaderboard, setLeaderboard] = useState<Leaderboard | undefined>();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [islandOpen, setIslandOpen] = useState(false); const [islandOpen, setIslandOpen] = useState(false);
const { setUserXp } = useExamConfigStore(); const { setUserMetrics } = useExamConfigStore();
const TIME_MAP: Record<string, string> = {
today: "daily",
week: "weekly",
month: "monthly",
alltime: "all_time",
};
useEffect(() => { useEffect(() => {
const fetch = async () => { const fetchData = async () => {
if (!user) return; if (!user) return;
const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) return;
const {
state: { token },
} = JSON.parse(authStorage) as { state?: { token?: string } };
if (!token) return;
try { try {
setLoading(true); setLoading(true);
const authStorage = localStorage.getItem("auth-storage"); const response = await api.fetchLeaderboard(
if (!authStorage) return; token,
const { activeTab,
state: { token }, TIME_MAP[time] ?? "daily",
} = JSON.parse(authStorage) as { state?: { token?: string } }; );
if (!token) return;
const response = await api.fetchLeaderboard(token);
setLeaderboard(response); setLeaderboard(response);
setUserXp(response.user_rank.total_xp); setUserMetrics({
xp: response.user_rank.score,
questions: 0,
streak: response.user_rank.streak,
});
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
fetch(); fetchData();
}, [user]); }, [user, activeTab, time]);
const isTopThree = (leaderboard?.user_rank?.rank ?? Infinity) <= 3; const metricIcon = (size = 17) =>
const metricValue = (u: (typeof leaderboard.top_users)[0]) =>
activeTab === "xp" ? u.total_xp : activeTab === "questions" ? "—" : "—";
const metricIcon = () =>
activeTab === "xp" ? ( activeTab === "xp" ? (
<Zap size={17} className="rw-xp-chip" /> <Zap size={size} className="rw-xp-chip" />
) : activeTab === "questions" ? ( ) : activeTab === "questions" ? (
<LucideBadgeQuestionMark size={17} className="rw-q-chip" /> <LucideBadgeQuestionMark size={size} className="rw-q-chip" />
) : ( ) : (
<Flame size={17} className="rw-fire-chip" /> <Flame size={size} className="rw-fire-chip" />
); );
const userMetric = const metricIconWhite = (size = 15) =>
activeTab === "xp" activeTab === "xp" ? (
? leaderboard?.user_rank.total_xp <Zap size={size} color="rgba(255,255,255,0.85)" />
: activeTab === "questions" ) : activeTab === "questions" ? (
? "23" <LucideBadgeQuestionMark size={size} color="rgba(255,255,255,0.85)" />
: "5"; ) : (
<Flame size={size} color="rgba(255,255,255,0.85)" />
);
const ur = leaderboard?.user_rank as Record<string, unknown> | undefined;
const islandStats = getIslandStats(ur, activeTab);
const userMetric = getUserScore(ur, activeTab);
const formatTimeLabel = (t: string) =>
({ today: "Today", week: "Week", month: "Month", alltime: "All" })[t] ?? t;
return ( return (
<div className="rw-screen"> <div className="rw-screen">
<style>{STYLES}</style> <style>{STYLES}</style>
{/* Blobs */}
<div className="rw-blob rw-blob-1" /> <div className="rw-blob rw-blob-1" />
<div className="rw-blob rw-blob-2" /> <div className="rw-blob rw-blob-2" />
<div className="rw-blob rw-blob-3" /> <div className="rw-blob rw-blob-3" />
<div className="rw-blob rw-blob-4" /> <div className="rw-blob rw-blob-4" />
{/* Dots */}
{DOTS.map((d, i) => ( {DOTS.map((d, i) => (
<div <div
key={i} key={i}
@ -424,10 +450,9 @@ export const Rewards = () => {
/> />
))} ))}
{/* Sticky top: header + controls */} {/* Sticky header */}
<div className="rw-sticky-top"> <div className="rw-sticky-top">
<div className="rw-sticky-top-inner"> <div className="rw-sticky-top-inner">
{/* Header */}
<header className="rw-header rw-anim"> <header className="rw-header rw-anim">
<h1 className="rw-title">🏆 Leaderboard</h1> <h1 className="rw-title">🏆 Leaderboard</h1>
{loading ? ( {loading ? (
@ -442,13 +467,11 @@ export const Rewards = () => {
/> />
) : ( ) : (
<p className="rw-rank-text"> <p className="rw-rank-text">
You're <span>#{leaderboard?.user_rank.rank}</span> — keep You're <span>#{ur?.rank ?? "—"}</span> — keep grinding!
grinding!
</p> </p>
)} )}
</header> </header>
{/* Controls */}
<div className="rw-controls rw-anim rw-anim-1"> <div className="rw-controls rw-anim rw-anim-1">
<div className="rw-tabs"> <div className="rw-tabs">
{TABS.map((tab) => ( {TABS.map((tab) => (
@ -465,7 +488,7 @@ export const Rewards = () => {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button className="rw-filter-btn"> <button className="rw-filter-btn">
{formatTimeFilter(time)} <ChevronDown size={13} /> {formatTimeLabel(time)} <ChevronDown size={13} />
</button> </button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
@ -489,9 +512,7 @@ export const Rewards = () => {
</DropdownMenu> </DropdownMenu>
</div> </div>
</div> </div>
{/* end rw-sticky-top-inner */}
</div> </div>
{/* end rw-sticky-top */}
{/* Scrollable list */} {/* Scrollable list */}
<div className="rw-scroll-area"> <div className="rw-scroll-area">
@ -503,14 +524,15 @@ export const Rewards = () => {
{loading ? ( {loading ? (
<SkeletonRows /> <SkeletonRows />
) : ( ) : (
leaderboard?.top_users.map((u, index) => { leaderboard?.top_users?.map((u, index) => {
const top = index < 3; const row = u as Record<string, unknown>;
const rowClass = `rw-row${index === 0 ? " top-1" : index === 1 ? " top-2" : index === 2 ? " top-3" : ""}`; const rowClass = `rw-row${index === 0 ? " top-1" : index === 1 ? " top-2" : index === 2 ? " top-3" : ""}`;
const score = getScore(row, activeTab);
return ( return (
<div key={u.user_id} className={rowClass}> <div key={String(row.user_id)} className={rowClass}>
<div className="rw-row-left"> <div className="rw-row-left">
<div className="rw-rank-cell"> <div className="rw-rank-cell">
{top ? ( {index < 3 ? (
<img <img
src={trophies[index]} src={trophies[index]}
alt={`#${index + 1}`} alt={`#${index + 1}`}
@ -521,10 +543,10 @@ export const Rewards = () => {
)} )}
</div> </div>
<Avatar <Avatar
className={`${getRandomColor()}`} className={getRandomColor()}
style={{ width: 38, height: 38 }} style={{ width: 38, height: 38 }}
> >
<AvatarImage src={u.avatar_url} /> <AvatarImage src={String(row.avatar_url ?? "")} />
<AvatarFallback <AvatarFallback
style={{ style={{
color: "black", color: "black",
@ -532,13 +554,17 @@ export const Rewards = () => {
fontSize: "0.9rem", fontSize: "0.9rem",
}} }}
> >
{u.name.slice(0, 1).toUpperCase()} {String(row.name ?? "?")
.slice(0, 1)
.toUpperCase()}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<span className="rw-user-name">{u.name}</span> <span className="rw-user-name">
{String(row.name ?? "")}
</span>
</div> </div>
<div className="rw-row-right"> <div className="rw-row-right">
<span className="rw-score">{metricValue(u)}</span> <span className="rw-score">{score}</span>
{metricIcon()} {metricIcon()}
</div> </div>
</div> </div>
@ -547,78 +573,49 @@ export const Rewards = () => {
)} )}
</div> </div>
</div> </div>
{/* end rw-scroll-inner */}
</div> </div>
{/* end rw-scroll-area */}
{/* ── Floating island pill ── */} {/* Floating island */}
<div className="rw-island-wrap"> <div className="rw-island-wrap">
{/* Expanded info card */} {/* Expanded card — stats change with active tab */}
<div className={`rw-island-card${islandOpen ? " open" : ""}`}> <div className={`rw-island-card${islandOpen ? " open" : ""}`}>
<div className="rw-island-stat rank"> {islandStats.map((s) => (
<span className="rw-island-stat-val"> <div key={s.cls} className={`rw-island-stat ${s.cls}`}>
#{leaderboard?.user_rank?.rank ?? ""} <span className="rw-island-stat-val">{s.val}</span>
</span> <span className="rw-island-stat-label">{s.label}</span>
<span className="rw-island-stat-label">Rank</span> </div>
</div> ))}
<div className="rw-island-stat xp">
<span className="rw-island-stat-val">
{leaderboard?.user_rank?.total_xp ?? ""}
</span>
<span className="rw-island-stat-label">Total XP</span>
</div>
<div className="rw-island-stat lvl">
<span className="rw-island-stat-val">
{leaderboard?.user_rank?.current_level ?? ""}
</span>
<span className="rw-island-stat-label">Level</span>
</div>
</div> </div>
{/* Pill button */} {/* Pill — metric updates with active tab */}
<div <div
className={`rw-island-pill${islandOpen ? " open" : ""}`} className={`rw-island-pill${islandOpen ? " open" : ""}`}
onClick={() => !loading && setIslandOpen((o) => !o)} onClick={() => !loading && setIslandOpen((o) => !o)}
> >
{/* Avatar */} <div className="rw-island-left">
<div className="rw-island-avatar"> <div className="rw-island-avatar">
{leaderboard?.user_rank?.avatar_url ? (
<span> <span>
{leaderboard?.user_rank?.name?.slice(0, 1).toUpperCase() ?? "?"} {String(ur?.name ?? user?.name ?? "?")
.slice(0, 1)
.toUpperCase()}
</span> </span>
) : ( </div>
<span> <div className="rw-island-info">
{leaderboard?.user_rank?.name?.slice(0, 1).toUpperCase() ?? "?"} <span className="rw-island-you">You</span>
<span className="rw-island-name">
{loading ? "Loading" : String(ur?.name ?? user?.name ?? "")}
</span> </span>
)} </div>
</div>
<div className="rw-island-info">
<span className="rw-island-you">You</span>
<span className="rw-island-name">
{loading ? "Loading..." : (leaderboard?.user_rank?.name ?? "")}
</span>
</div> </div>
{/* Name */} <div className="rw-island-right">
<div className="rw-island-metric">
{/* Live metric */} <span className="rw-island-metric-val">{userMetric}</span>
<div className="rw-island-metric"> {metricIconWhite()}
<span className="rw-island-metric-val">{userMetric}</span> </div>
{activeTab === "xp" ? ( <div className="rw-island-chevron">
<Zap size={15} color="rgba(255,255,255,0.8)" /> <ChevronDown size={13} color="white" />
) : activeTab === "questions" ? ( </div>
<LucideBadgeQuestionMark
size={15}
color="rgba(255,255,255,0.8)"
/>
) : (
<Flame size={15} color="rgba(255,255,255,0.8)" />
)}
</div>
{/* Chevron */}
<div className="rw-island-chevron">
<ChevronDown size={13} color="white" />
</div> </div>
</div> </div>
</div> </div>

File diff suppressed because it is too large Load Diff

View File

@ -3,9 +3,15 @@ import { create } from "zustand";
import { persist } from "zustand/middleware"; import { persist } from "zustand/middleware";
import type { StartExamPayload, ExamMode } from "../types/test"; import type { StartExamPayload, ExamMode } from "../types/test";
type UserMetrics = {
xp: number;
questions: number;
streak: number;
};
interface ExamConfigState { interface ExamConfigState {
payload: StartExamPayload | null; payload: StartExamPayload | null;
userXp: number; userMetrics: UserMetrics;
setSheetId: (id: string) => void; setSheetId: (id: string) => void;
storeTopics: (ids: string[]) => void; storeTopics: (ids: string[]) => void;
@ -15,7 +21,7 @@ interface ExamConfigState {
storeDuration: (minutes: number) => void; storeDuration: (minutes: number) => void;
setMode: (mode: ExamMode) => void; setMode: (mode: ExamMode) => void;
setSection: (section: string) => void; setSection: (section: string) => void;
setUserXp: (section: number) => void; setUserMetrics: (userMetrics: UserMetrics) => void;
clearPayload: () => void; clearPayload: () => void;
} }
@ -24,7 +30,11 @@ export const useExamConfigStore = create<ExamConfigState>()(
persist( persist(
(set, get) => ({ (set, get) => ({
payload: null, payload: null,
userXp: 0, userMetrics: {
xp: 0,
questions: 0,
streak: 0,
},
setSheetId: (sheet_id) => setSheetId: (sheet_id) =>
set({ set({
@ -48,9 +58,9 @@ export const useExamConfigStore = create<ExamConfigState>()(
section, section,
} as StartExamPayload, } as StartExamPayload,
}), }),
setUserXp: (userXp) => setUserMetrics: (userMetrics) =>
set({ set({
userXp: userXp, userMetrics: userMetrics,
}), }),
setDifficulty: (difficulty) => setDifficulty: (difficulty) =>

View File

@ -3,8 +3,9 @@ export type LeaderboardEntry = {
user_id: string; user_id: string;
name: string; name: string;
avatar_url: string; avatar_url: string;
total_xp: number; score: number;
current_level: number; current_level: number;
streak: number;
}; };
export interface Leaderboard { export interface Leaderboard {

View File

@ -224,8 +224,15 @@ class ApiClient {
return this.authenticatedRequest<Topic>(`/topics/${topicId}`, token); return this.authenticatedRequest<Topic>(`/topics/${topicId}`, token);
} }
async fetchLeaderboard(token: string): Promise<Leaderboard> { async fetchLeaderboard(
return this.authenticatedRequest<Leaderboard>(`/leaderboard/`, token); token: string,
metric: string,
timeframe: string,
): Promise<Leaderboard> {
return this.authenticatedRequest<Leaderboard>(
`/leaderboard/?metric=${metric}&timeframe=${timeframe}`,
token,
);
} }
async fetchPredictedScore(token: string): Promise<PredictedScore> { async fetchPredictedScore(token: string): Promise<PredictedScore> {