diff --git a/src/pages/student/Home.tsx b/src/pages/student/Home.tsx index aa3df36..4f379f8 100644 --- a/src/pages/student/Home.tsx +++ b/src/pages/student/Home.tsx @@ -17,6 +17,7 @@ import { DrawerContent, DrawerTrigger, } from "../../components/ui/drawer"; +import { useExamConfigStore } from "../../stores/useExamConfigStore"; // ─── Shared blob/dot background (same as break/results screens) ──────────────── const DOTS = [ @@ -332,6 +333,7 @@ const PAGE_SIZE = 2; export const Home = () => { const user = useAuthStore((state) => state.user); const navigate = useNavigate(); + const { userMetrics } = useExamConfigStore(); const [practiceSheets, setPracticeSheets] = useState([]); const [notStartedSheets, setNotStartedSheets] = useState([]); @@ -465,7 +467,7 @@ export const Home = () => { {/* Streak chip */}
- 5 + {userMetrics.streak}
{/* Score chip */} diff --git a/src/pages/student/Profile.tsx b/src/pages/student/Profile.tsx index 1aec53a..8b80eaf 100644 --- a/src/pages/student/Profile.tsx +++ b/src/pages/student/Profile.tsx @@ -120,6 +120,7 @@ const STYLES = ` background: white; border: none; width: 100%; text-align: left; transition: background 0.15s ease; border-bottom: 2px solid #f9fafb; + opacity: 0.5; } .pf-row:last-child { border-bottom: none; } .pf-row:hover { background: #fafaf9; } diff --git a/src/pages/student/Rewards.tsx b/src/pages/student/Rewards.tsx index b9e7038..d12fc74 100644 --- a/src/pages/student/Rewards.tsx +++ b/src/pages/student/Rewards.tsx @@ -3,7 +3,7 @@ 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 { formatTimeFilter, getRandomColor } from "../../lib/utils"; +import { getRandomColor } from "../../lib/utils"; import { Avatar, AvatarFallback, @@ -12,7 +12,6 @@ import { import { Flame, LucideBadgeQuestionMark, Zap, ChevronDown } from "lucide-react"; import type { Leaderboard } from "../../types/leaderboard"; import { api } from "../../utils/api"; -import { LeaderboardRowSkeleton } from "../../components/LeaderboardSkeleton"; import { useExamConfigStore } from "../../stores/useExamConfigStore"; import { DropdownMenu, @@ -42,10 +41,9 @@ const STYLES = ` display: flex; flex-direction: column; 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-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; } @@ -66,88 +64,76 @@ const STYLES = ` 50%{transform:translateY(-12px) rotate(180deg);} } - /* Sticky top wrapper */ .rw-sticky-top { - position: relative; z-index: 2; - background: #fffbf4; - flex-shrink: 0; - padding: 2rem 1.25rem 0; + position:relative;z-index:2; + background:#fffbf4; + 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; + max-width:580px;margin:0 auto; + display:flex;flex-direction:column;gap:1.25rem; + padding-bottom:1rem; + border-bottom:2px solid #f3f4f6; } - /* Scrollable list area */ .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; + 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; } @keyframes rwPopIn { - from { opacity:0; transform: scale(0.92) translateY(12px); } - to { opacity:1; transform: scale(1) translateY(0); } + 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-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; } - /* Header */ .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; } - /* 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; } + .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); + 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-tab-btn.active { background:#1e1b4b;border-color:#1e1b4b;color:white;box-shadow:0 4px 0 #1e1b4b55; } - /* Time filter button */ .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; + 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-filter-btn:hover { border-color:#c4b5fd;color:#7c3aed; } - /* Leaderboard list */ .rw-list { display:flex;flex-direction:column;gap:0.6rem; } - /* Each row */ .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; + 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: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; } @@ -156,146 +142,11 @@ const STYLES = ` .rw-row-right { display:flex;align-items:center;gap:0.35rem; } .rw-score { font-size:0.9rem;font-weight:900;color:#1e1b4b; } - /* XP chip color variants */ .rw-xp-chip { color:#84cc16; } .rw-q-chip { color:#0891b2; } .rw-fire-chip { color:#ef4444; } - /* Skeleton */ .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 { display:flex;align-items:center;justify-content:space-between; 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-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;} + @keyframes rwShimmer { 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 = [ @@ -316,13 +273,65 @@ const TABS = [ label: "Questions", icon: , }, - { id: "streak", label: "Streak", icon: }, + { id: "streaks", label: "Streaks", icon: }, ] as const; 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 === "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 ───────────────────────────────── +// Each tab shows 3 contextually relevant stats in the expanded card. +const getIslandStats = ( + ur: Record | 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 = () => (
{Array.from({ length: 7 }).map((_, i) => ( @@ -343,68 +352,85 @@ const SkeletonRows = () => ( export const Rewards = () => { const user = useAuthStore((state) => state.user); - const [time, setTime] = useState("bottom"); + const [time, setTime] = useState("today"); const [activeTab, setActiveTab] = useState("xp"); - const [leaderboard, setLeaderboard] = useState(); + const [leaderboard, setLeaderboard] = useState(); const [loading, setLoading] = useState(false); const [islandOpen, setIslandOpen] = useState(false); - const { setUserXp } = useExamConfigStore(); + const { setUserMetrics } = useExamConfigStore(); + + const TIME_MAP: Record = { + today: "daily", + week: "weekly", + month: "monthly", + alltime: "all_time", + }; useEffect(() => { - const fetch = async () => { + const fetchData = async () => { 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 { setLoading(true); - const authStorage = localStorage.getItem("auth-storage"); - if (!authStorage) return; - const { - state: { token }, - } = JSON.parse(authStorage) as { state?: { token?: string } }; - if (!token) return; - const response = await api.fetchLeaderboard(token); + const response = await api.fetchLeaderboard( + token, + activeTab, + TIME_MAP[time] ?? "daily", + ); setLeaderboard(response); - setUserXp(response.user_rank.total_xp); + setUserMetrics({ + xp: response.user_rank.score, + questions: 0, + streak: response.user_rank.streak, + }); } catch (e) { console.error(e); } finally { setLoading(false); } }; - fetch(); - }, [user]); + fetchData(); + }, [user, activeTab, time]); - const isTopThree = (leaderboard?.user_rank?.rank ?? Infinity) <= 3; - - const metricValue = (u: (typeof leaderboard.top_users)[0]) => - activeTab === "xp" ? u.total_xp : activeTab === "questions" ? "—" : "—"; - - const metricIcon = () => + const metricIcon = (size = 17) => activeTab === "xp" ? ( - + ) : activeTab === "questions" ? ( - + ) : ( - + ); - const userMetric = - activeTab === "xp" - ? leaderboard?.user_rank.total_xp - : activeTab === "questions" - ? "23" - : "5"; + const metricIconWhite = (size = 15) => + activeTab === "xp" ? ( + + ) : activeTab === "questions" ? ( + + ) : ( + + ); + + const ur = leaderboard?.user_rank as Record | 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 (
- {/* Blobs */}
- {/* Dots */} {DOTS.map((d, i) => (
{ /> ))} - {/* Sticky top: header + controls */} + {/* Sticky header */}
- {/* Header */}

🏆 Leaderboard

{loading ? ( @@ -442,13 +467,11 @@ export const Rewards = () => { /> ) : (

- You're #{leaderboard?.user_rank.rank} — keep - grinding! + You're #{ur?.rank ?? "—"} — keep grinding!

)}
- {/* Controls */}
{TABS.map((tab) => ( @@ -465,7 +488,7 @@ export const Rewards = () => { {
- {/* end rw-sticky-top-inner */}
- {/* end rw-sticky-top */} {/* Scrollable list */}
@@ -503,14 +524,15 @@ export const Rewards = () => { {loading ? ( ) : ( - leaderboard?.top_users.map((u, index) => { - const top = index < 3; + 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 ( -
+
- {top ? ( + {index < 3 ? ( {`#${index { )}
- + { fontSize: "0.9rem", }} > - {u.name.slice(0, 1).toUpperCase()} + {String(row.name ?? "?") + .slice(0, 1) + .toUpperCase()} - {u.name} + + {String(row.name ?? "")} +
- {metricValue(u)} + {score} {metricIcon()}
@@ -547,78 +573,49 @@ export const Rewards = () => { )}
- {/* end rw-scroll-inner */}
- {/* end rw-scroll-area */} - {/* ── Floating island pill ── */} + {/* Floating island */}
- {/* Expanded info card */} + {/* Expanded card — stats change with active tab */}
-
- - #{leaderboard?.user_rank?.rank ?? "—"} - - Rank -
-
- - {leaderboard?.user_rank?.total_xp ?? "—"} - - Total XP -
-
- - {leaderboard?.user_rank?.current_level ?? "—"} - - Level -
+ {islandStats.map((s) => ( +
+ {s.val} + {s.label} +
+ ))}
- {/* Pill button */} + {/* Pill — metric updates with active tab */}
!loading && setIslandOpen((o) => !o)} > - {/* Avatar */} -
- {leaderboard?.user_rank?.avatar_url ? ( +
+
- {leaderboard?.user_rank?.name?.slice(0, 1).toUpperCase() ?? "?"} + {String(ur?.name ?? user?.name ?? "?") + .slice(0, 1) + .toUpperCase()} - ) : ( - - {leaderboard?.user_rank?.name?.slice(0, 1).toUpperCase() ?? "?"} +
+
+ You + + {loading ? "Loading…" : String(ur?.name ?? user?.name ?? "—")} - )} -
-
- You - - {loading ? "Loading..." : (leaderboard?.user_rank?.name ?? "—")} - +
- {/* Name */} - - {/* Live metric */} -
- {userMetric} - {activeTab === "xp" ? ( - - ) : activeTab === "questions" ? ( - - ) : ( - - )} -
- - {/* Chevron */} -
- +
+
+ {userMetric} + {metricIconWhite()} +
+
+ +
diff --git a/src/pages/student/practice/Test.tsx b/src/pages/student/practice/Test.tsx index 03fe386..1c0e2a6 100644 --- a/src/pages/student/practice/Test.tsx +++ b/src/pages/student/practice/Test.tsx @@ -11,6 +11,11 @@ import { ChevronLeft, Menu, X, + Bookmark, + BookMarked, + BookOpen, + ZoomIn, + ZoomOut, } from "lucide-react"; import { api } from "../../../utils/api"; @@ -88,367 +93,161 @@ const GLOBAL_STYLES = ` overflow-x: hidden; } - /* ── Blobs ── */ - .t-blob { - position: fixed; - pointer-events: none; - z-index: 0; - filter: blur(48px); - opacity: 0.35; - } - .t-blob-1 { - width: 240px; height: 240px; - background: ${COLORS.blob1}; - top: -80px; left: -80px; - border-radius: 60% 40% 70% 30% / 50% 60% 40% 50%; - animation: tWobble1 14s ease-in-out infinite; - } - .t-blob-2 { - width: 190px; height: 190px; - background: ${COLORS.blob2}; - bottom: -50px; left: 6%; - border-radius: 40% 60% 30% 70% / 60% 40% 60% 40%; - animation: tWobble2 16s ease-in-out infinite; - } - .t-blob-3 { - width: 210px; height: 210px; - background: ${COLORS.blob3}; - top: 15%; right: -60px; - border-radius: 70% 30% 50% 50% / 40% 60% 40% 60%; - animation: tWobble1 18s ease-in-out infinite reverse; - } - .t-blob-4 { - width: 150px; height: 150px; - background: ${COLORS.blob4}; - bottom: 12%; right: 2%; - border-radius: 50% 50% 30% 70% / 60% 40% 60% 40%; - animation: tWobble2 12s ease-in-out infinite; - } + .t-blob { position:fixed;pointer-events:none;z-index:0;filter:blur(48px);opacity:0.35; } + .t-blob-1 { width:240px;height:240px;background:${COLORS.blob1};top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:tWobble1 14s ease-in-out infinite; } + .t-blob-2 { width:190px;height:190px;background:${COLORS.blob2};bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:tWobble2 16s ease-in-out infinite; } + .t-blob-3 { width:210px;height:210px;background:${COLORS.blob3};top:15%;right:-60px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:tWobble1 18s ease-in-out infinite reverse; } + .t-blob-4 { width:150px;height:150px;background:${COLORS.blob4};bottom:12%;right:2%;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:tWobble2 12s ease-in-out infinite; } @keyframes tWobble1 { - 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); } + 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 tWobble2 { - 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); } + 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);} } - /* ── Floating dots ── */ - .t-dot { - position: fixed; - border-radius: 50%; - pointer-events: none; - z-index: 0; - opacity: 0.3; - animation: tFloat 6s ease-in-out infinite; - } - @keyframes tFloat { - 0%, 100% { transform: translateY(0) rotate(0deg); } - 50% { transform: translateY(-14px) rotate(180deg); } - } + .t-dot { position:fixed;border-radius:50%;pointer-events:none;z-index:0;opacity:0.3;animation:tFloat 6s ease-in-out infinite; } + @keyframes tFloat { 0%,100%{transform:translateY(0) rotate(0deg);}50%{transform:translateY(-14px) rotate(180deg);} } - /* ── Animations ── */ @keyframes tPopIn { - from { opacity: 0; transform: scale(0.92) translateY(10px); } - to { opacity: 1; transform: scale(1) translateY(0); } + from{opacity:0;transform:scale(0.92) translateY(10px);} + to{opacity:1;transform:scale(1) translateY(0);} } - .t-anim { animation: tPopIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both; } - .t-anim-1 { animation-delay: 0.05s; } - .t-anim-2 { animation-delay: 0.1s; } - .t-anim-3 { animation-delay: 0.15s; } - .t-anim-4 { animation-delay: 0.2s; } - .t-anim-5 { animation-delay: 0.25s; } + .t-anim { animation:tPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; } + .t-anim-1 { animation-delay:0.05s; } + .t-anim-2 { animation-delay:0.1s; } + .t-anim-3 { animation-delay:0.15s; } - /* ── 3D Button Styles ── */ - .t-btn-3d { - font-family: 'Nunito', sans-serif; - font-weight: 800; - border: none; - border-radius: 100px; - cursor: pointer; - transition: transform 0.1s ease, box-shadow 0.1s ease; - } - .t-btn-3d:hover { transform: translateY(-2px); } - .t-btn-3d:active { transform: translateY(2px); } + /* 3D buttons */ + .t-btn-3d { font-family:'Nunito',sans-serif;font-weight:800;border:none;border-radius:100px;cursor:pointer;transition:transform 0.1s ease,box-shadow 0.1s ease; } + .t-btn-3d:hover { transform:translateY(-2px); } + .t-btn-3d:active { transform:translateY(2px); } - .t-btn-primary { - background: ${COLORS.primary}; - color: white; - box-shadow: 0 4px 0 ${COLORS.primaryDark}, 0 6px 16px rgba(168, 85, 247, 0.3); - } - .t-btn-primary:hover { - box-shadow: 0 6px 0 ${COLORS.primaryDark}, 0 10px 20px rgba(168, 85, 247, 0.35); - } - .t-btn-primary:active { - box-shadow: 0 2px 0 ${COLORS.primaryDark}, 0 3px 8px rgba(168, 85, 247, 0.2); - } + .t-btn-primary { background:${COLORS.primary};color:white;box-shadow:0 4px 0 ${COLORS.primaryDark},0 6px 16px rgba(168,85,247,0.3); } + .t-btn-primary:hover { box-shadow:0 6px 0 ${COLORS.primaryDark},0 10px 20px rgba(168,85,247,0.35); } + .t-btn-primary:active { box-shadow:0 2px 0 ${COLORS.primaryDark},0 3px 8px rgba(168,85,247,0.2); } - .t-btn-accent { - background: ${COLORS.accent}; - color: white; - box-shadow: 0 4px 0 ${COLORS.accentDark}, 0 6px 16px rgba(249, 115, 22, 0.3); - } - .t-btn-accent:hover { - box-shadow: 0 6px 0 ${COLORS.accentDark}, 0 10px 20px rgba(249, 115, 22, 0.35); - } - .t-btn-accent:active { - box-shadow: 0 2px 0 ${COLORS.accentDark}, 0 3px 8px rgba(249, 115, 22, 0.2); - } + .t-btn-accent { background:${COLORS.accent};color:white;box-shadow:0 4px 0 ${COLORS.accentDark},0 6px 16px rgba(249,115,22,0.3); } + .t-btn-accent:hover { box-shadow:0 6px 0 ${COLORS.accentDark},0 10px 20px rgba(249,115,22,0.35); } + .t-btn-accent:active { box-shadow:0 2px 0 ${COLORS.accentDark},0 3px 8px rgba(249,115,22,0.2); } - .t-btn-outline { - background: white; - color: ${COLORS.text}; - border: 2.5px solid ${COLORS.border}; - box-shadow: 0 3px 10px rgba(0,0,0,0.06); - } - .t-btn-outline:hover { - border-color: ${COLORS.borderPurple}; - box-shadow: 0 6px 14px rgba(0,0,0,0.08); - } + .t-btn-outline { background:white;color:${COLORS.text};border:2.5px solid ${COLORS.border};box-shadow:0 3px 10px rgba(0,0,0,0.06); } + .t-btn-outline:hover { border-color:${COLORS.borderPurple};box-shadow:0 6px 14px rgba(0,0,0,0.08); } - /* ── Cards ── */ - .t-card { - background: white; - border: 2.5px solid ${COLORS.border}; - border-radius: 22px; - box-shadow: 0 4px 14px rgba(0,0,0,0.05); + /* Bookmark button */ + .t-bookmark-btn { + display:flex;align-items:center;justify-content:center; + width:44px;height:44px;border-radius:50%;border:2.5px solid ${COLORS.border}; + background:white;cursor:pointer; + box-shadow:0 3px 10px rgba(0,0,0,0.06); + transition:all 0.15s cubic-bezier(0.34,1.56,0.64,1); + flex-shrink:0; } - - .t-card-purple { - border-color: ${COLORS.borderPurple}; - box-shadow: 0 4px 16px rgba(167, 139, 250, 0.12); + .t-bookmark-btn:hover { border-color:#fde68a;background:#fffbeb;transform:scale(1.08); } + .t-bookmark-btn:active { transform:scale(0.92); } + .t-bookmark-btn.marked { + background:linear-gradient(135deg,#fbbf24,#f59e0b); + border-color:#f59e0b; + box-shadow:0 4px 0 #d97706,0 6px 14px rgba(251,191,36,0.35); } + .t-bookmark-btn.marked:hover { box-shadow:0 6px 0 #d97706,0 8px 18px rgba(251,191,36,0.4); } - /* ── Section Title ── */ - .t-section-title { - font-size: 1.2rem; - font-weight: 900; - color: ${COLORS.text}; - letter-spacing: -0.01em; - } + /* Cards */ + .t-card { background:white;border:2.5px solid ${COLORS.border};border-radius:22px;box-shadow:0 4px 14px rgba(0,0,0,0.05); } + .t-card-purple { border-color:${COLORS.borderPurple};box-shadow:0 4px 16px rgba(167,139,250,0.12); } - /* ── Timer Display ── */ - .t-timer { - font-size: 2.5rem; - font-weight: 900; - color: ${COLORS.text}; - letter-spacing: -0.03em; - line-height: 1; - } + .t-section-title { font-size:1.2rem;font-weight:900;color:${COLORS.text};letter-spacing:-0.01em; } + .t-timer { font-size:2.5rem;font-weight:900;color:${COLORS.text};letter-spacing:-0.03em;line-height:1; } + .t-q-badge { background:linear-gradient(135deg,${COLORS.primary},${COLORS.primaryDark});color:white;font-weight:800;font-size:0.75rem;padding:0.4rem 0.9rem;border-radius:100px; } - /* ── Question Number Badge ── */ - .t-q-badge { - background: linear-gradient(135deg, ${COLORS.primary}, ${COLORS.primaryDark}); - color: white; - font-weight: 800; - font-size: 0.75rem; - padding: 0.4rem 0.9rem; - border-radius: 100px; - } + /* Mark-for-review badge on q-badge */ + .t-q-badge.reviewing { background:linear-gradient(135deg,#fbbf24,#f59e0b); } - /* ── Option Buttons ── */ + /* Options */ .t-option { - width: 100%; - text-align: start; - font-family: 'Nunito', sans-serif; - font-weight: 700; - font-size: 1rem; - padding: 1rem 1.25rem; - background: white; - border: 2.5px solid ${COLORS.border}; - border-radius: 18px; - cursor: pointer; - transition: all 0.15s ease; - display: flex; - align-items: center; - gap: 0.75rem; + width:100%;text-align:start;font-family:'Nunito',sans-serif;font-weight:700;font-size:1rem; + padding:1rem 1.25rem;background:white;border:2.5px solid ${COLORS.border};border-radius:18px; + cursor:pointer;transition:all 0.15s ease;display:flex;align-items:center;gap:0.75rem; } - .t-option:hover:not(:disabled) { - border-color: ${COLORS.borderPurple}; - transform: translateY(-1px); - box-shadow: 0 4px 12px rgba(0,0,0,0.06); - } - .t-option.selected { - background: linear-gradient(135deg, ${COLORS.primary}, ${COLORS.primaryDark}); - border-color: ${COLORS.primaryDark}; - color: white; - } - .t-option.correct { - background: linear-gradient(135deg, #22c55e, #16a34a); - border-color: #16a34a; - color: white; - } - .t-option.incorrect { - background: linear-gradient(135deg, #ef4444, #dc2626); - border-color: #dc2626; - color: white; - } - .t-option.eliminated { - opacity: 0.5; - text-decoration: line-through; + .t-option:hover:not(:disabled) { border-color:${COLORS.borderPurple};transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,0.06); } + .t-option.selected { background:linear-gradient(135deg,${COLORS.primary},${COLORS.primaryDark});border-color:${COLORS.primaryDark};color:white; } + .t-option.correct { background:linear-gradient(135deg,#22c55e,#16a34a);border-color:#16a34a;color:white; } + .t-option.incorrect { background:linear-gradient(135deg,#ef4444,#dc2626);border-color:#dc2626;color:white; } + .t-option.eliminated { opacity:0.5;text-decoration:line-through; } + + .t-option-letter { width:28px;height:28px;display:flex;align-items:center;justify-content:center;border-radius:50%;font-weight:800;font-size:0.85rem;flex-shrink:0; } + .t-option:not(.selected) .t-option-letter { background:${COLORS.primary};color:white; } + .t-option.selected .t-option-letter { background:white;color:${COLORS.primary}; } + + .t-eliminate-btn { width:32px;height:32px;border-radius:50%;border:2.5px solid ${COLORS.border};background:white;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:all 0.15s ease;flex-shrink:0; } + .t-eliminate-btn:hover { border-color:#ef4444;background:#fef2f2; } + .t-eliminate-btn.active { background:#ef4444;border-color:#ef4444;color:white; } + + .t-textarea { width:100%;min-height:120px;padding:1rem 1.25rem;background:white;border:2.5px solid ${COLORS.border};border-radius:18px;font-family:'Nunito',sans-serif;font-size:1rem;font-weight:600;color:${COLORS.text};resize:vertical;transition:border-color 0.2s ease,box-shadow 0.2s ease; } + .t-textarea:focus { outline:none;border-color:${COLORS.borderPurple};box-shadow:0 4px 16px rgba(167,139,250,0.15); } + .t-textarea::placeholder { color:${COLORS.textLight}; } + + /* Retry banner */ + .t-retry-banner { background:linear-gradient(90deg,${COLORS.accent},#ea580c);position:fixed;width:100%;color:white;padding:0.75rem 1rem;font-weight:700;font-size:0.9rem;display:flex;align-items:center;justify-content:center;gap:0.5rem; } + + /* XP badge */ + .t-xp-badge { position:absolute;top:-8px;right:-8px;background:linear-gradient(135deg,#fbbf24,#f59e0b);color:white;font-size:0.7rem;font-weight:800;padding:0.25rem 0.6rem;border-radius:100px;box-shadow:0 2px 8px rgba(251,191,36,0.4);animation:tBounce 0.5s ease; } + @keyframes tBounce { 0%,100%{transform:scale(1);}50%{transform:scale(1.2);} } + + /* Vaul drawer */ + [data-vaul-drawer] { background:white !important;border-top:2.5px solid ${COLORS.border} !important;border-radius:24px 24px 0 0 !important; } + [data-vaul-drawer-handle] { background:${COLORS.border} !important;width:48px !important;height:5px !important;border-radius:3px !important; } + + /* Navigator grid */ + .t-nav-grid { display:grid;grid-template-columns:repeat(5,1fr);gap:0.5rem; } + .t-nav-item { aspect-ratio:1;display:flex;align-items:center;justify-content:center;border-radius:12px;font-weight:800;font-size:0.9rem;border:2.5px solid ${COLORS.border};background:white;cursor:pointer;transition:all 0.15s ease;position:relative; } + .t-nav-item:hover { border-color:${COLORS.borderPurple};transform:scale(1.05); } + .t-nav-item.current { background:linear-gradient(135deg,${COLORS.primary},${COLORS.primaryDark});border-color:${COLORS.primaryDark};color:white; } + .t-nav-item.answered { background:#dcfce7;border-color:#22c55e;color:#16a34a; } + .t-nav-item.marked { background:#fffbeb;border-color:#f59e0b;color:#d97706; } + .t-nav-item.current.marked { background:linear-gradient(135deg,#fbbf24,#f59e0b);border-color:#d97706;color:white; } + + /* Bookmark dot indicator on nav item */ + .t-nav-bookmark-dot { + position:absolute;top:-4px;right:-4px; + width:10px;height:10px;border-radius:50%; + background:#f59e0b;border:2px solid white; } - .t-option-letter { - width: 28px; - height: 28px; - display: flex; - align-items: center; - justify-content: center; - border-radius: 50%; - font-weight: 800; - font-size: 0.85rem; - flex-shrink: 0; - } - .t-option:not(.selected) .t-option-letter { - background: ${COLORS.primary}; - color: white; - } - .t-option.selected .t-option-letter { - background: white; - color: ${COLORS.primary}; + /* Review warning dialog custom */ + .t-review-warning { + background:linear-gradient(135deg,#fffbeb,white); + border:2.5px solid #fde68a; } - /* ── Eliminate Button ── */ - .t-eliminate-btn { - width: 32px; - height: 32px; - border-radius: 50%; - border: 2.5px solid ${COLORS.border}; - background: white; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - transition: all 0.15s ease; - flex-shrink: 0; + /* Reference sheet modal */ + .t-ref-modal-img { + width:100%;border-radius:16px; + border:2.5px solid ${COLORS.border}; + user-select:none; + transition:transform 0.2s ease; } - .t-eliminate-btn:hover { - border-color: #ef4444; - background: #fef2f2; + .t-ref-zoom-bar { + display:flex;align-items:center;justify-content:center;gap:0.75rem; + padding:0.65rem;background:#f9fafb;border-top:2px solid ${COLORS.border}; + flex-shrink:0; } - .t-eliminate-btn.active { - background: #ef4444; - border-color: #ef4444; - color: white; + .t-ref-zoom-btn { + width:34px;height:34px;border-radius:50%;border:2.5px solid ${COLORS.border}; + background:white;cursor:pointer;display:flex;align-items:center;justify-content:center; + font-weight:800;transition:all 0.15s ease;box-shadow:0 2px 6px rgba(0,0,0,0.05); } + .t-ref-zoom-btn:hover { border-color:${COLORS.borderPurple};background:#fdf4ff; } + .t-ref-zoom-label { font-family:'Nunito',sans-serif;font-size:0.75rem;font-weight:800;color:#9ca3af;min-width:36px;text-align:center; } - /* ── Textarea ── */ - .t-textarea { - width: 100%; - min-height: 120px; - padding: 1rem 1.25rem; - background: white; - border: 2.5px solid ${COLORS.border}; - border-radius: 18px; - font-family: 'Nunito', sans-serif; - font-size: 1rem; - font-weight: 600; - color: ${COLORS.text}; - resize: vertical; - transition: border-color 0.2s ease, box-shadow 0.2s ease; - } - .t-textarea:focus { - outline: none; - border-color: ${COLORS.borderPurple}; - box-shadow: 0 4px 16px rgba(167, 139, 250, 0.15); - } - .t-textarea::placeholder { - color: ${COLORS.textLight}; - } - - /* ── Retry Banner ── */ - .t-retry-banner { - background: linear-gradient(90deg, ${COLORS.accent}, #ea580c); - position: fixed; - width: 100%; - color: white; - padding: 0.75rem 1rem; - font-weight: 700; - font-size: 0.9rem; - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - } - - /* ── XP Badge ── */ - .t-xp-badge { - position: absolute; - top: -8px; - right: -8px; - background: linear-gradient(135deg, #fbbf24, #f59e0b); - color: white; - font-size: 0.7rem; - font-weight: 800; - padding: 0.25rem 0.6rem; - border-radius: 100px; - box-shadow: 0 2px 8px rgba(251, 191, 36, 0.4); - animation: tBounce 0.5s ease; - } - @keyframes tBounce { - 0%, 100% { transform: scale(1); } - 50% { transform: scale(1.2); } - } - - /* ── Drawer Custom Styles ── */ - [data-vaul-drawer] { - background: white !important; - border-top: 2.5px solid ${COLORS.border} !important; - border-radius: 24px 24px 0 0 !important; - } - [data-vaul-drawer-handle] { - background: ${COLORS.border} !important; - width: 48px !important; - height: 5px !important; - border-radius: 3px !important; - } - - /* ── Navigator Grid ── */ - .t-nav-grid { - display: grid; - grid-template-columns: repeat(5, 1fr); - gap: 0.5rem; - } - .t-nav-item { - aspect-ratio: 1; - display: flex; - align-items: center; - justify-content: center; - border-radius: 12px; - font-weight: 800; - font-size: 0.9rem; - border: 2.5px solid ${COLORS.border}; - background: white; - cursor: pointer; - transition: all 0.15s ease; - } - .t-nav-item:hover { - border-color: ${COLORS.borderPurple}; - transform: scale(1.05); - } - .t-nav-item.current { - background: linear-gradient(135deg, ${COLORS.primary}, ${COLORS.primaryDark}); - border-color: ${COLORS.primaryDark}; - color: white; - } - .t-nav-item.answered { - background: #dcfce7; - border-color: #22c55e; - color: #16a34a; - } - - /* ── Incorrect Flash ── */ - @keyframes tFlashRed { - 0%, 100% { background: transparent; } - 50% { background: rgba(239, 68, 68, 0.15); } - } - .t-flash-red { - animation: tFlashRed 0.6s ease; - } + /* Incorrect flash */ + @keyframes tFlashRed { 0%,100%{background:transparent;}50%{background:rgba(239,68,68,0.15);} } + .t-flash-red { animation:tFlashRed 0.6s ease; } `; -// ─── Confetti particle type ─────────────────────────────────────────────────── +// ─── Confetti ───────────────────────────────────────────────────────────────── interface ConfettiParticle { id: number; x: number; @@ -459,24 +258,19 @@ interface ConfettiParticle { rotation: number; } -// ─── Answer feedback state ──────────────────────────────────────────────────── type FeedbackState = { show: boolean; correct: boolean; xpGained?: number; selectedOptionId?: string; } | null; - -// ─── Targeted incorrect queue ───────────────────────────────────────────────── interface IncorrectEntry { questionId: string; originalIndex: number; } -// ─── Confetti Component ─────────────────────────────────────────────────────── const Confetti = ({ active }: { active: boolean }) => { const [particles, setParticles] = useState([]); - useEffect(() => { if (!active) { setParticles([]); @@ -491,9 +285,8 @@ const Confetti = ({ active }: { active: boolean }) => { "#f97316", "#eab308", ]; - const newParticles: ConfettiParticle[] = Array.from( - { length: 60 }, - (_, i) => ({ + setParticles( + Array.from({ length: 60 }, (_, i) => ({ id: i, x: Math.random() * 100, color: colors[Math.floor(Math.random() * colors.length)], @@ -501,25 +294,13 @@ const Confetti = ({ active }: { active: boolean }) => { delay: Math.random() * 0.5, duration: Math.random() * 1.5 + 1.5, rotation: Math.random() * 360, - }), + })), ); - setParticles(newParticles); }, [active]); - - if (!active || particles.length === 0) return null; - + if (!active || !particles.length) return null; return (
- + {particles.map((p) => (
{ ); }; -// ─── XP Popup Component ─────────────────────────────────────────────────────── const XPPopup = ({ xp, show }: { xp: number; show: boolean }) => { if (!show) return null; return (
- +
+{xp} XP @@ -568,55 +340,309 @@ const XPPopup = ({ xp, show }: { xp: number; show: boolean }) => { ); }; +// ─── Reference Sheet Modal ──────────────────────────────────────────────────── +// Placeholder image — swap the src for a real URL or imported asset when ready +const REFERENCE_SHEET_URL = + "https://placehold.co/800x1000/fffbf4/1e1b4b?text=SAT+Reference+Sheet"; + +const ReferenceSheetModal = ({ + open, + onClose, +}: { + open: boolean; + onClose: () => void; +}) => { + const [zoom, setZoom] = useState(100); + if (!open) return null; + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+
+

+ Reference Sheet +

+

+ SAT Math formulas & constants +

+
+
+ +
+ + {/* Scrollable image area */} +
+ SAT Reference Sheet +
+ + {/* Zoom controls */} +
+ + {zoom}% + + +
+
+
+ ); +}; + +// ─── Review Warning Dialog ──────────────────────────────────────────────────── +const ReviewWarningDialog = ({ + open, + markedCount, + onReview, + onFinish, +}: { + open: boolean; + markedCount: number; + onReview: () => void; + onFinish: () => void; +}) => { + if (!open) return null; + return ( +
+
+
+ + 🔖 + +

+ {markedCount} question{markedCount !== 1 ? "s" : ""} marked for + review +

+

+ You've bookmarked questions you wanted to revisit. Would you like to + go back and review them, or finish the exam now? +

+
+
+ + +
+
+
+ ); +}; + +// ─── Shared Background ──────────────────────────────────────────────────────── +const BgLayer = () => ( + <> +
+
+
+
+ {DOTS.map((d, i) => ( +
+ ))} + +); + // ─── Main Component ─────────────────────────────────────────────────────────── export const Test = () => { const sheetId = localStorage.getItem("activePracticeSheetId"); const blocker = useExamNavigationGuard(); - const [eliminated, setEliminated] = useState>>({}); - const [showExitDialog, setShowExitDialog] = useState(false); - const [error, setError] = useState(null); - const [calcOpen, setCalcOpen] = useState(false); - const [menuOpen, setMenuOpen] = useState(false); - - useEffect(() => { - if (blocker.state === "blocked") setShowExitDialog(true); - }, [blocker.state]); - const navigate = useNavigate(); const { user } = useAuthStore(); const token = useAuthToken(); + + // ── Core state ────────────────────────────────────────────────────────────── + const [eliminated, setEliminated] = useState>>({}); + const [markedForReview, setMarkedForReview] = useState>( + new Set(), + ); const [answers, setAnswers] = useState>({}); - const [showNavigator, setShowNavigator] = useState(false); + const [showNavigator, setShowNavigator] = useState(false); + const [showExitDialog, setShowExitDialog] = useState(false); + const [showRefSheet, setShowRefSheet] = useState(false); + const [showReviewWarning, setShowReviewWarning] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [sessionId, setSessionId] = useState(null); - - const examMode = useExamConfigStore((s) => s.payload?.mode); - const isTargeted = examMode === "TARGETED"; - + const [error, setError] = useState(null); + const [calcOpen, setCalcOpen] = useState(false); + const [menuOpen, setMenuOpen] = useState(false); const [feedback, setFeedback] = useState(null); const [showConfetti, setShowConfetti] = useState(false); const [showIncorrectFlash, setShowIncorrectFlash] = useState(false); + // ── Retry / targeted state ────────────────────────────────────────────────── const incorrectQueueRef = useRef([]); const correctedRef = useRef>(new Set()); - const [retryMode, setRetryMode] = useState(false); const [retryQueue, setRetryQueue] = useState([]); const [retryIndex, setRetryIndex] = useState(0); + // ── Exam store ────────────────────────────────────────────────────────────── + const examMode = useExamConfigStore((s) => s.payload?.mode); + const isTargeted = examMode === "TARGETED"; const time = useSatTimer(); const phase = useSatExam((s) => s.phase); const currentModule = useSatExam((s) => s.currentModuleQuestions); const questionIndex = useSatExam((s) => s.questionIndex); - - const currentQuestion = retryMode - ? currentModule?.questions[retryQueue[retryIndex]?.originalIndex] - : currentModule?.questions[questionIndex]; - - const currentAnswer = currentQuestion - ? (answers[currentQuestion.id] ?? "") - : ""; - const resetExam = useSatExam((s) => s.resetExam); const nextQuestion = useSatExam((s) => s.nextQuestion); const prevQuestion = useSatExam((s) => s.prevQuestion); @@ -625,55 +651,162 @@ export const Test = () => { const quitExam = useSatExam((s) => s.quitExam); const setResults = useResults((s) => s.setResults); - const startExam = async () => { - console.log("startExam called", { user, sheetId }); - if (!user) { - console.warn("Missing user or sheetId"); - return; + const currentQuestion = retryMode + ? currentModule?.questions[retryQueue[retryIndex]?.originalIndex] + : currentModule?.questions[questionIndex]; + + const currentAnswer = currentQuestion + ? (answers[currentQuestion.id] ?? "") + : ""; + const isCurrentMarked = + !!currentQuestion && markedForReview.has(currentQuestion.id); + const isMCQ = !!currentQuestion?.options?.length; + const isFirstQuestion = retryMode ? true : questionIndex === 0; + const isLastQuestion = + !retryMode && questionIndex === (currentModule?.questions.length ?? 1) - 1; + + // ── Blocker ───────────────────────────────────────────────────────────────── + useEffect(() => { + if (blocker.state === "blocked") setShowExitDialog(true); + }, [blocker.state]); + useEffect(() => { + return () => { + resetExam(); + }; + }, []); + useEffect(() => { + if (phase === "FINISHED") { + const t = setTimeout( + () => + navigate(`/student/practice/${sheetId}/test/results`, { + replace: true, + }), + 3000, + ); + return () => clearTimeout(t); } + }, [phase]); + + // ── Exam start ────────────────────────────────────────────────────────────── + const startExam = async () => { + if (!user) return; const payload = useExamConfigStore.getState().payload; - console.log("payload", payload); try { const response = await api.startSession(token as string, payload); - console.log("session started", response); setSessionId(response.id); await loadSessionQuestions(response.id); useSatExam.getState().startExam(); - } catch (error) { - setError(`Failed to start exam session: ${error}`); - } - }; - - const loadSessionQuestions = async (sessionId: string) => { - if (!token) return; - try { - const data = await api.fetchSessionQuestions(token, sessionId); - const module: SessionModuleQuestions = { - module_id: data.module_id, - module_title: data.module_title, - time_limit_minutes: data.time_limit_minutes * 60, - questions: data.questions.map((q) => ({ - id: q.id, - text: q.text, - context: q.context, - context_image_url: q.context_image_url, - type: q.type, - section: q.section, - image_url: q.image_url, - index: q.index, - difficulty: q.difficulty, - correct_answer: q.correct_answer, - explanation: q.explanation, - topics: q.topics, - options: q.options, - })), - }; - useSatExam.getState().setModuleQuestions(module); } catch (err) { - console.error("Failed to load session questions:", err); + setError(`Failed to start exam session: ${err}`); } }; + const loadSessionQuestions = async (sid: string) => { + if (!token) return; + const data = await api.fetchSessionQuestions(token, sid); + const module: SessionModuleQuestions = { + module_id: data.module_id, + module_title: data.module_title, + time_limit_minutes: data.time_limit_minutes * 60, + questions: data.questions.map((q: Question) => ({ + id: q.id, + text: q.text, + context: q.context, + context_image_url: q.context_image_url, + type: q.type, + section: q.section, + image_url: q.image_url, + index: q.index, + difficulty: q.difficulty, + correct_answer: q.correct_answer, + explanation: q.explanation, + topics: q.topics, + options: q.options, + })), + }; + useSatExam.getState().setModuleQuestions(module); + }; + + // ── Toggle bookmark ────────────────────────────────────────────────────────── + const toggleMark = () => { + if (!currentQuestion) return; + setMarkedForReview((prev) => { + const next = new Set(prev); + next.has(currentQuestion.id) + ? next.delete(currentQuestion.id) + : next.add(currentQuestion.id); + return next; + }); + }; + + // ── Handle Next ────────────────────────────────────────────────────────────── + const handleNext = async () => { + if (isTargeted) { + await submitTargetedAnswer(); + return; + } + if (!currentQuestion || !sessionId) return; + + // If last question and there are marked questions, warn first + if (isLastQuestion && markedForReview.size > 0) { + setShowReviewWarning(true); + return; + } + await submitAndAdvance(); + }; + + const submitAndAdvance = async () => { + if (!currentQuestion || !sessionId) return; + const userAnswer = answers[currentQuestion.id] ?? ""; + const answerText = currentQuestion.options?.length + ? (currentQuestion.options.find((o) => o.id === userAnswer)?.text ?? "") + : userAnswer; + const payload: SubmitAnswer = { + question_id: currentQuestion.id, + answer_text: answerText, + time_spent_seconds: 3, + }; + try { + setIsSubmitting(true); + await api.submitAnswer(token!, sessionId, payload); + } catch (err) { + console.error(err); + } finally { + setIsSubmitting(false); + } + + if (!isLastQuestion) { + nextQuestion(); + return; + } + const next = await api.fetchNextModule(token!, sessionId); + if (next?.status === "COMPLETED") { + setResults(next.results); + finishExam(); + } else { + await loadSessionQuestions(sessionId); + useSatExam.getState().startBreak(); + } + }; + + // ── Review warning actions ─────────────────────────────────────────────────── + const handleGoReview = () => { + setShowReviewWarning(false); + // Jump to first marked question + const firstMarked = currentModule?.questions.findIndex((q) => + markedForReview.has(q.id), + ); + if (firstMarked !== undefined && firstMarked >= 0) + goToQuestion(firstMarked); + setShowNavigator(true); + }; + const handleFinishAnyway = async () => { + setShowReviewWarning(false); + setMarkedForReview(new Set()); // clear marks so we don't re-trigger + await submitAndAdvance(); + }; + + // ── Targeted submit ────────────────────────────────────────────────────────── const showAnswerFeedback = ( correct: boolean, xpGained?: number, @@ -691,41 +824,34 @@ export const Test = () => { const advanceRetry = async () => { setFeedback(null); - const nextRetryIndex = retryIndex + 1; const remaining = retryQueue.filter( (e) => !correctedRef.current.has(e.questionId), ); - if (remaining.length === 0) { + if (!remaining.length) { const next = await api.fetchNextModule(token!, sessionId); if (next.status === "COMPLETED") finishExam(); return; } - if (nextRetryIndex >= retryQueue.length) { - const stillIncorrect = incorrectQueueRef.current.filter( + const nextIdx = retryIndex + 1; + if (nextIdx >= retryQueue.length) { + const still = incorrectQueueRef.current.filter( (e) => !correctedRef.current.has(e.questionId), ); - setRetryQueue(stillIncorrect); + setRetryQueue(still); setRetryIndex(0); - goToQuestion(stillIncorrect[0].originalIndex); + goToQuestion(still[0].originalIndex); } else { - const nextEntry = retryQueue[nextRetryIndex]; - setRetryIndex(nextRetryIndex); - goToQuestion(nextEntry.originalIndex); + setRetryIndex(nextIdx); + goToQuestion(retryQueue[nextIdx].originalIndex); } }; const submitTargetedAnswer = async () => { if (!currentQuestion || !sessionId) return; const userAnswer = answers[currentQuestion.id] ?? ""; - let answerText = ""; - if (currentQuestion.options?.length) { - const selected = currentQuestion.options.find( - (opt) => opt.id === userAnswer, - ); - answerText = selected?.text ?? ""; - } else { - answerText = userAnswer; - } + const answerText = currentQuestion.options?.length + ? (currentQuestion.options.find((o) => o.id === userAnswer)?.text ?? "") + : userAnswer; const payload: SubmitAnswer = { question_id: currentQuestion.id, answer_text: answerText, @@ -734,19 +860,19 @@ export const Test = () => { try { setIsSubmitting(true); const result = await api.submitAnswer(token!, sessionId, payload); - const isCorrect: boolean = result?.feedback.is_correct ?? false; - const xpGained: number | undefined = 2; - showAnswerFeedback(isCorrect, xpGained, userAnswer); + const isCorrect = result?.feedback.is_correct ?? false; + showAnswerFeedback(isCorrect, 2, userAnswer); if (isCorrect) { correctedRef.current.add(currentQuestion.id); incorrectQueueRef.current = incorrectQueueRef.current.filter( (e) => e.questionId !== currentQuestion.id, ); } else { - const alreadyTracked = incorrectQueueRef.current.some( - (e) => e.questionId === currentQuestion.id, - ); - if (!alreadyTracked) { + if ( + !incorrectQueueRef.current.some( + (e) => e.questionId === currentQuestion.id, + ) + ) { incorrectQueueRef.current.push({ questionId: currentQuestion.id, originalIndex: retryMode @@ -756,31 +882,32 @@ export const Test = () => { } correctedRef.current.delete(currentQuestion.id); } - const delay = isCorrect ? 2200 : 1500; - setTimeout(async () => { - setFeedback(null); - if (retryMode) { - advanceRetry(); - return; - } - const isLastQuestion = - questionIndex === currentModule!.questions.length - 1; - if (!isLastQuestion) { - nextQuestion(); - return; - } - if (incorrectQueueRef.current.length > 0) { - const queue = [...incorrectQueueRef.current]; - setRetryQueue(queue); - setRetryIndex(0); - setRetryMode(true); - goToQuestion(queue[0].originalIndex); - return; - } - await proceedAfterLastQuestion(); - }, delay); + setTimeout( + async () => { + setFeedback(null); + if (retryMode) { + advanceRetry(); + return; + } + const isLast = questionIndex === currentModule!.questions.length - 1; + if (!isLast) { + nextQuestion(); + return; + } + if (incorrectQueueRef.current.length > 0) { + const queue = [...incorrectQueueRef.current]; + setRetryQueue(queue); + setRetryIndex(0); + setRetryMode(true); + goToQuestion(queue[0].originalIndex); + return; + } + await proceedAfterLastQuestion(); + }, + isCorrect ? 2200 : 1500, + ); } catch (err) { - console.error("Failed to submit answer:", err); + console.error(err); } finally { setIsSubmitting(false); } @@ -798,88 +925,23 @@ export const Test = () => { } }; - const handleNext = async () => { - if (isTargeted) { - await submitTargetedAnswer(); - return; - } - if (!currentQuestion || !sessionId) return; - const userAnswer = answers[currentQuestion.id] ?? ""; - let answerText = ""; - if (currentQuestion.options?.length) { - const selected = currentQuestion.options.find( - (opt) => opt.id === userAnswer, - ); - answerText = selected?.text ?? ""; - } else { - answerText = userAnswer; - } - const payload: SubmitAnswer = { - question_id: currentQuestion.id, - answer_text: answerText, - time_spent_seconds: 3, - }; - try { - setIsSubmitting(true); - await api.submitAnswer(token!, sessionId, payload); - } catch (err) { - console.error("Failed to submit answer:", err); - } finally { - setIsSubmitting(false); - } - const isLastQuestion = - questionIndex === currentModule!.questions.length - 1; - if (!isLastQuestion) { - nextQuestion(); - return; - } - const next = await api.fetchNextModule(token!, sessionId); - if (next?.status === "COMPLETED") { - setResults(next.results); - finishExam(); - } else { - await loadSessionQuestions(sessionId); - useSatExam.getState().startBreak(); - } - }; - const handleQuitExam = () => { useExamConfigStore.getState().clearPayload(); quitExam(); }; - useEffect(() => { - return () => { - resetExam(); - }; - }, []); - - useEffect(() => { - if (phase === "FINISHED") { - const timer = setTimeout(() => { - navigate(`/student/practice/${sheetId}/test/results`, { - replace: true, - }); - }, 3000); - return () => clearTimeout(timer); - } - }, [phase]); - - useEffect(() => { - if (!user) return; - }, [sheetId]); - - const isFirstQuestion = retryMode ? true : questionIndex === 0; - const toggleEliminate = (questionId: string, optionId: string) => { setEliminated((prev) => { - const current = new Set(prev[questionId] ?? []); - current.has(optionId) ? current.delete(optionId) : current.add(optionId); - return { ...prev, [questionId]: current }; + const cur = new Set(prev[questionId] ?? []); + cur.has(optionId) ? cur.delete(optionId) : cur.add(optionId); + return { ...prev, [questionId]: cur }; }); }; - // ─── Render MCQ options for the drawer ──────────────────────────────────── + const formatTime = (s: number) => + `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; + + // ── Render helpers ─────────────────────────────────────────────────────────── const renderOptions = (question?: Question) => { if (!question?.options?.length) return null; const eliminatedSet = eliminated[question.id] ?? new Set(); @@ -889,14 +951,11 @@ export const Test = () => { const isSelected = currentAnswer === option.id; const isEliminated = eliminatedSet.has(option.id); const feedbackLocked = isTargeted && !!feedback?.show; - - let optionClass = "t-option"; - if (isSelected) optionClass += " selected"; - if (isEliminated && !feedbackLocked) optionClass += " eliminated"; - if (feedbackLocked && isSelected) { - optionClass += feedback.correct ? " correct" : " incorrect"; - } - + let cls = "t-option"; + if (isSelected) cls += " selected"; + if (isEliminated && !feedbackLocked) cls += " eliminated"; + if (feedbackLocked && isSelected) + cls += feedback!.correct ? " correct" : " incorrect"; return (