diff --git a/src/pages/student/Rewards.tsx b/src/pages/student/Rewards.tsx index d12fc74..2ad3c45 100644 --- a/src/pages/student/Rewards.tsx +++ b/src/pages/student/Rewards.tsx @@ -157,6 +157,15 @@ const STYLES = ` .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; @@ -224,11 +233,13 @@ const STYLES = ` 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; + 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; } @@ -281,10 +292,9 @@ type TabId = (typeof TABS)[number]["id"]; const trophies = [firstTrophy, secondTrophy, thirdTrophy]; // ── Score helpers ───────────────────────────────────────────────────────────── -// Tab ID is "streaks" (plural) — match exactly. Try multiple plausible field names. const getScore = (u: Record, tab: TabId): number | string => { - if (tab === "xp" || tab === "questions") - return (u.score ?? u.total_xp ?? u.xp ?? "—") as 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 "—"; @@ -299,12 +309,11 @@ const getUserScore = ( }; // ── Island card config — driven by active tab ───────────────────────────────── -// Each tab shows 3 contextually relevant stats in the expanded card. const getIslandStats = ( ur: Record | undefined, tab: TabId, ) => { - const rank = `#${ur?.rank ?? "—"}`; + 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( @@ -350,6 +359,14 @@ const SkeletonRows = () => ( ); +const EmptyState = () => ( +
+ 🏜️ +

No entries yet

+

Be the first on the board!

+
+); + export const Rewards = () => { const user = useAuthStore((state) => state.user); const [time, setTime] = useState("today"); @@ -383,10 +400,11 @@ export const Rewards = () => { TIME_MAP[time] ?? "daily", ); setLeaderboard(response); + // ✅ FIX 1: Guard against null user_rank before accessing its properties setUserMetrics({ - xp: response.user_rank.score, + xp: response.user_rank?.score ?? 0, questions: 0, - streak: response.user_rank.streak, + streak: response.user_rank?.streak ?? 0, }); } catch (e) { console.error(e); @@ -415,9 +433,14 @@ export const Rewards = () => { ); - const ur = leaderboard?.user_rank as Record | undefined; + // ✅ 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; @@ -466,8 +489,15 @@ export const Rewards = () => { }} /> ) : ( + // ✅ FIX 3: Show a sensible message when user has no rank yet

- You're #{ur?.rank ?? "—"} — keep grinding! + {hasUserRank ? ( + <> + You're #{ur.rank} — keep grinding! + + ) : ( + "No rank yet — start answering questions!" + )}

)} @@ -523,8 +553,11 @@ export const Rewards = () => { > {loading ? ( + ) : !leaderboard?.top_users?.length ? ( + // ✅ FIX 4: Show empty state when top_users is empty or missing + ) : ( - leaderboard?.top_users?.map((u, index) => { + 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); @@ -587,10 +620,19 @@ export const Rewards = () => { ))} - {/* Pill — metric updates with active tab */} + {/* Pill — ✅ FIX 4 cont: disabled + dimmed when no user rank data */}
!loading && setIslandOpen((o) => !o)} + 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); + }} >
@@ -603,7 +645,11 @@ export const Rewards = () => {
You - {loading ? "Loading…" : String(ur?.name ?? user?.name ?? "—")} + {loading + ? "Loading…" + : hasUserRank + ? String(ur.name ?? user?.name ?? "—") + : (user?.name ?? "Not ranked")}
@@ -614,7 +660,8 @@ export const Rewards = () => { {metricIconWhite()}
- + {/* Hide chevron when there's nothing to expand */} + {hasUserRank && }
diff --git a/src/pages/student/practice/Results.tsx b/src/pages/student/practice/Results.tsx index 5be111f..079b36c 100644 --- a/src/pages/student/practice/Results.tsx +++ b/src/pages/student/practice/Results.tsx @@ -261,8 +261,8 @@ const TARGETED_XP = 15; const TARGETED_SCORE = 15; const TargetedResults = ({ onFinish }: { onFinish: () => void }) => { - const { userXp, setUserXp } = useExamConfigStore(); - const previousXP = userXp ?? 0; + const { userMetrics, setUserMetrics } = useExamConfigStore(); + const previousXP = userMetrics.xp ?? 0; const gainedXP = TARGETED_XP; const levelMinXP = Math.floor(previousXP / 100) * 100; const levelMaxXP = levelMinXP + 100; @@ -270,7 +270,11 @@ const TargetedResults = ({ onFinish }: { onFinish: () => void }) => { const displayXP = useCountUp(gainedXP); useEffect(() => { - setUserXp(previousXP); + setUserMetrics({ + xp: previousXP, + questions: 0, + streak: 0, + }); }, []); return ( @@ -393,11 +397,16 @@ export const Results = () => { const navigate = useNavigate(); const results = useResults((s) => s.results); const clearResults = useResults((s) => s.clearResults); - const { setUserXp, payload } = useExamConfigStore(); + const { setUserMetrics, payload } = useExamConfigStore(); const isTargeted = payload?.mode === "TARGETED"; useEffect(() => { - if (results) setUserXp(results.total_xp); + if (results) + setUserMetrics({ + xp: results.total_xp, + questions: results.correct_count, + streak: 0, + }); }, [results]); function handleFinishExam() {