diff --git a/package.json b/package.json index 7b37335..bd0496b 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "react-router-dom": "^7.12.0", "tailwind-merge": "^3.4.0", "tailwindcss": "^4.1.18", + "vaul": "^1.1.2", "zustand": "^5.0.9" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c5aacf..48a0d2c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: tailwindcss: specifier: ^4.1.18 version: 4.1.18 + vaul: + specifier: ^1.1.2 + version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) zustand: specifier: ^5.0.9 version: 5.0.9(@types/react@19.2.7)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) @@ -2221,6 +2224,12 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + vaul@1.1.2: + resolution: {integrity: sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==} + peerDependencies: + react: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc + vite@7.3.0: resolution: {integrity: sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -4385,6 +4394,15 @@ snapshots: dependencies: react: 19.2.3 + vaul@1.1.2(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + '@radix-ui/react-dialog': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + vite@7.3.0(@types/node@24.10.4)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: esbuild: 0.27.2 diff --git a/src/components/CircularLevelProgress.tsx b/src/components/CircularLevelProgress.tsx index f080c0a..0d64073 100644 --- a/src/components/CircularLevelProgress.tsx +++ b/src/components/CircularLevelProgress.tsx @@ -11,26 +11,187 @@ type Props = { level: number; }; +const STYLES = ` + @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600&display=swap'); + + .clp-wrap { + width: 100%; + font-family: 'Nunito', sans-serif; + } + + /* Outer card — full width */ + .clp-card { + width: 100%; + background: white; + border: 2.5px solid #f3f4f6; + border-radius: 24px; + padding: 1.25rem 1.5rem; + box-shadow: 0 6px 24px rgba(0,0,0,0.05); + display: flex; + flex-direction: column; + gap: 0.85rem; + box-sizing: border-box; + } + + /* Top row: level badge + XP gained chip */ + .clp-top-row { + display: flex; + align-items: center; + justify-content: space-between; + } + + .clp-level-badge { + display: flex; + align-items: center; + gap: 0.6rem; + } + + .clp-level-bubble { + width: 52px; height: 52px; + border-radius: 50%; + background: linear-gradient(135deg, #c084fc, #a855f7); + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 4px 0 #7e22ce44; + flex-shrink: 0; + } + + .clp-level-num { + font-size: 1.5rem; + font-weight: 900; + color: white; + line-height: 1; + letter-spacing: -0.02em; + } + + .clp-level-text { + display: flex; + flex-direction: column; + gap: 1px; + } + + .clp-level-word { + font-size: 0.62rem; + font-weight: 800; + letter-spacing: 0.14em; + text-transform: uppercase; + color: #9ca3af; + } + + .clp-level-title { + font-size: 1rem; + font-weight: 900; + color: #1e1b4b; + line-height: 1; + } + + /* XP gained chip */ + .clp-xp-chip { + display: flex; + align-items: center; + gap: 0.35rem; + background: #fff7ed; + border: 2px solid #fed7aa; + border-radius: 100px; + padding: 0.4rem 0.9rem; + font-size: 0.82rem; + font-weight: 800; + color: #f97316; + } + + /* Bar section */ + .clp-bar-wrap { + width: 100%; + display: flex; + flex-direction: column; + gap: 0.4rem; + } + + .clp-bar-labels { + display: flex; + justify-content: space-between; + font-size: 0.66rem; + font-weight: 700; + color: #9ca3af; + } + + .clp-bar-track { + width: 100%; + height: 12px; + background: #f3f4f6; + border-radius: 100px; + overflow: hidden; + } + + .clp-bar-fill { + height: 100%; + border-radius: 100px; + background: linear-gradient(90deg, #c084fc, #f97316); + transition: width 1.2s cubic-bezier(0.4,0,0.2,1); + } + + /* XP total */ + .clp-xp-pill { + display: flex; + align-items: center; + gap: 0.4rem; + font-size: 0.72rem; + font-weight: 700; + color: #9ca3af; + animation: clpFadeUp 0.5s cubic-bezier(0.34,1.56,0.64,1) both; + } + + .clp-xp-pill .xp-dot { + width: 7px; height: 7px; + border-radius: 50%; + background: #f97316; + flex-shrink: 0; + } + + /* Level-up banner */ + .clp-levelup { + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + background: #fdf4ff; + border: 2.5px solid #e9d5ff; + border-radius: 14px; + padding: 0.6rem 1rem; + font-size: 0.85rem; + font-weight: 900; + color: #9333ea; + animation: clpPop 0.45s cubic-bezier(0.34,1.56,0.64,1) both; + box-shadow: 0 4px 12px rgba(147,51,234,0.1); + } + + @keyframes clpPop { + from { opacity:0; transform: scale(0.8); } + to { opacity:1; transform: scale(1); } + } + @keyframes clpFadeUp { + from { opacity:0; transform: translateY(6px); } + to { opacity:1; transform: translateY(0); } + } +`; + export const CircularLevelProgress = ({ - size = 300, - strokeWidth = 16, previousXP, gainedXP, levelMinXP, levelMaxXP, level, }: Props) => { - const radius = (size - strokeWidth) / 2; - const circumference = 2 * Math.PI * radius; const levelRange = levelMaxXP - levelMinXP; const normalize = (xp: number) => Math.min(Math.max(xp - levelMinXP, 0), levelRange) / levelRange; - const [progress, setProgress] = useState(normalize(previousXP)); + const [barProgress, setBarProgress] = useState(normalize(previousXP)); const [currentLevel, setCurrentLevel] = useState(level); const [showLevelUp, setShowLevelUp] = useState(false); - const [showThresholdText, setShowThresholdText] = useState(false); + const [showXPTotal, setShowXPTotal] = useState(false); useEffect(() => { let animationFrame: number; @@ -38,28 +199,23 @@ export const CircularLevelProgress = ({ const availableXP = previousXP + gainedXP; const crossesLevel = availableXP >= levelMaxXP; - - const phase1Target = crossesLevel ? 1 : normalize(previousXP + gainedXP); - + const phase1Target = crossesLevel ? 1 : normalize(availableXP); const leftoverXP = crossesLevel ? availableXP - levelMaxXP : 0; - const duration = 1200; const animatePhase1 = (timestamp: number) => { if (!start) start = timestamp; const t = Math.min((timestamp - start) / duration, 1); - - setProgress( + setBarProgress( normalize(previousXP) + t * (phase1Target - normalize(previousXP)), ); - if (t < 1) { animationFrame = requestAnimationFrame(animatePhase1); } else if (crossesLevel) { setShowLevelUp(true); setTimeout(startPhase2, 1200); } else { - setShowThresholdText(true); + setShowXPTotal(true); } }; @@ -67,78 +223,77 @@ export const CircularLevelProgress = ({ start = null; setShowLevelUp(false); setCurrentLevel((l) => l + 1); - setProgress(0); - + setBarProgress(0); const target = Math.min(leftoverXP / levelRange, 1); const animatePhase2 = (timestamp: number) => { if (!start) start = timestamp; const t = Math.min((timestamp - start) / duration, 1); - - setProgress(t * target); - + setBarProgress(t * target); if (t < 1) { animationFrame = requestAnimationFrame(animatePhase2); } else { - setShowThresholdText(true); + setShowXPTotal(true); } }; - animationFrame = requestAnimationFrame(animatePhase2); }; animationFrame = requestAnimationFrame(animatePhase1); - return () => cancelAnimationFrame(animationFrame); }, []); - const offset = circumference * (1 - progress); + const barPct = Math.round(barProgress * 100); + const totalXP = previousXP + gainedXP; return ( -
+
+ {showLevelUp && } -
- - - - - - {currentLevel} +
+ {/* Top row */} +
+
+
+ {currentLevel} +
+
+ Current Level + Level {currentLevel} +
+
- {showThresholdText && ( - - Total XP: {previousXP + gainedXP} - - )} +
⚡ +{gainedXP} XP
+
- {showLevelUp && ( - - 🎉 You leveled up! - - )} - + {/* Progress bar */} +
+
+ {levelMinXP} XP + {barPct}% + {levelMaxXP} XP +
+
+
+
+
+ + {/* Footer state */} + {showLevelUp && ( +
+ 🎉 You leveled up! Welcome to Level {currentLevel}! +
+ )} + {showXPTotal && !showLevelUp && ( +
+
+ Total XP:{" "} + + {totalXP} + +
+ )}
); diff --git a/src/components/LessonModal.tsx b/src/components/LessonModal.tsx index 24955f7..6ac1a5b 100644 --- a/src/components/LessonModal.tsx +++ b/src/components/LessonModal.tsx @@ -1,13 +1,8 @@ import { useEffect, useState } from "react"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, -} from "../components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader } from "../components/ui/dialog"; import { api } from "../utils/api"; import { useAuthStore } from "../stores/authStore"; +import { Loader, X } from "lucide-react"; interface LessonModalProps { lessonId: string | null; @@ -15,6 +10,107 @@ interface LessonModalProps { onOpenChange: (open: boolean) => void; } +const STYLES = ` + @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); + + /* Override Dialog defaults */ + .lm-content { + font-family: 'Nunito', sans-serif; + background: #fffbf4; + border: 2.5px solid #f3f4f6; + border-radius: 28px !important; + padding: 0; + overflow: hidden; + max-width: 680px; + width: calc(100vw - 2rem); + box-shadow: 0 20px 60px rgba(0,0,0,0.12); + max-height: 90vh; + display: flex; + flex-direction: column; + } + + /* Header bar */ + .lm-header { + display: flex; align-items: flex-start; justify-content: space-between; + padding: 1.25rem 1.5rem 0; + flex-shrink: 0; + gap: 1rem; + } + .lm-title-wrap { display:flex;flex-direction:column;gap:0.2rem; flex:1; } + .lm-eyebrow { + font-size: 0.62rem; font-weight: 800; letter-spacing: 0.16em; + text-transform: uppercase; color: #a855f7; + } + .lm-title { + font-size: 1.2rem; font-weight: 900; color: #1e1b4b; + letter-spacing: -0.01em; line-height: 1.25; + } + .lm-close-btn { + width: 34px; height: 34px; flex-shrink: 0; + border-radius: 50%; border: 2.5px solid #f3f4f6; + background: white; cursor: pointer; + display: flex; align-items: center; justify-content: center; + box-shadow: 0 2px 8px rgba(0,0,0,0.06); + transition: all 0.15s ease; + } + .lm-close-btn:hover { border-color: #fecdd3; background: #fff1f2; } + + /* Scrollable body */ + .lm-body { + overflow-y: auto; + flex: 1; + padding: 1rem 1.5rem 1.5rem; + display: flex; flex-direction: column; gap: 1rem; + -webkit-overflow-scrolling: touch; + } + + /* Video player */ + .lm-video { + width: 100%; border-radius: 18px; + aspect-ratio: 16/9; background: #1e1b4b; + display: block; + } + + /* Topic chip */ + .lm-topic-chip { + display: inline-flex; align-items: center; gap: 0.4rem; + background: #f3e8ff; border: 2px solid #e9d5ff; + border-radius: 100px; padding: 0.3rem 0.8rem; + font-size: 0.7rem; font-weight: 800; + letter-spacing: 0.08em; text-transform: uppercase; + color: #9333ea; width: fit-content; + } + + /* Description & content cards */ + .lm-card { + background: white; border: 2.5px solid #f3f4f6; + border-radius: 18px; padding: 1rem 1.1rem; + box-shadow: 0 3px 10px rgba(0,0,0,0.04); + } + .lm-card-label { + font-size: 0.62rem; font-weight: 800; letter-spacing: 0.14em; + text-transform: uppercase; color: #9ca3af; margin-bottom: 0.4rem; + } + .lm-card-text { + font-family: 'Nunito Sans', sans-serif; + font-size: 0.88rem; font-weight: 600; color: #374151; + line-height: 1.6; + } + + /* Loading state */ + .lm-loading { + display: flex; flex-direction: column; align-items: center; + justify-content: center; gap: 0.75rem; + padding: 3rem 1.5rem; + flex: 1; + } + .lm-loading-spinner { animation: lmSpin 0.8s linear infinite; } + @keyframes lmSpin { to { transform: rotate(360deg); } } + .lm-loading-text { + font-size: 0.85rem; font-weight: 700; color: #9ca3af; + } +`; + export const LessonModal = ({ lessonId, open, @@ -26,21 +122,15 @@ export const LessonModal = ({ useEffect(() => { if (!open || !lessonId || !user) return; - const fetchLesson = async () => { try { setLoading(true); - const authStorage = localStorage.getItem("auth-storage"); if (!authStorage) return; - - const parsed = JSON.parse(authStorage) as { - state?: { token?: string }; - }; - - const token = parsed.state?.token; + const { + state: { token }, + } = JSON.parse(authStorage) as { state?: { token?: string } }; if (!token) return; - const response = await api.fetchLessonById(token, lessonId); setLesson(response); } catch (err) { @@ -49,40 +139,73 @@ export const LessonModal = ({ setLoading(false); } }; - fetchLesson(); }, [open, lessonId, user]); return ( - - {loading && ( -
- Loading lesson... -
- )} - - {lesson ? lesson.title : "Lesson details"} - + + + - {!loading && lesson && ( -
- {lesson.video_url && ( -
diff --git a/src/components/ui/drawer.tsx b/src/components/ui/drawer.tsx new file mode 100644 index 0000000..869955f --- /dev/null +++ b/src/components/ui/drawer.tsx @@ -0,0 +1,133 @@ +import * as React from "react" +import { Drawer as DrawerPrimitive } from "vaul" + +import { cn } from "@/lib/utils" + +function Drawer({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerPortal({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerClose({ + ...props +}: React.ComponentProps) { + return +} + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DrawerContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + +
+ {children} + + + ) +} + +function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function DrawerTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/src/pages/auth/Login.tsx b/src/pages/auth/Login.tsx index a5b07ea..ac9c681 100644 --- a/src/pages/auth/Login.tsx +++ b/src/pages/auth/Login.tsx @@ -120,7 +120,7 @@ export const Login = () => { +
+ ); +}; + +// ─── Tips data ──────────────────────────────────────────────────────────────── +const TIPS = [ + "Practice regularly with official SAT materials", + "Review your mistakes and learn from them", + "Focus on your weak areas first", + "Take full-length practice tests", + "Get plenty of rest before test day", +]; + +// ─── Main component ─────────────────────────────────────────────────────────── export const Home = () => { const user = useAuthStore((state) => state.user); const navigate = useNavigate(); @@ -39,358 +314,244 @@ export const Home = () => { const [notStartedSheets, setNotStartedSheets] = useState([]); const [inProgressSheets, setInProgressSheets] = useState([]); const [completedSheets, setCompletedSheets] = useState([]); - + const [activeTab, setActiveTab] = useState< + "all" | "NOT_STARTED" | "COMPLETED" + >("all"); const [isSearchOpen, setIsSearchOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); useEffect(() => { - const sortPracticeSheets = (sheets: PracticeSheet[]) => { - const notStarted = sheets.filter( - (sheet) => sheet.user_status === "NOT_STARTED", + const sort = (sheets: PracticeSheet[]) => { + setNotStartedSheets( + sheets.filter((s) => s.user_status === "NOT_STARTED"), ); - const inProgress = sheets.filter( - (sheet) => sheet.user_status === "IN_PROGRESS", + setInProgressSheets( + sheets.filter((s) => s.user_status === "IN_PROGRESS"), ); - const completed = sheets.filter( - (sheet) => sheet.user_status === "COMPLETED", - ); - - setNotStartedSheets(notStarted); - setInProgressSheets(inProgress); - setCompletedSheets(completed); + setCompletedSheets(sheets.filter((s) => s.user_status === "COMPLETED")); }; - const fetchPracticeSheets = async () => { + const fetch = async () => { if (!user) return; - try { const authStorage = localStorage.getItem("auth-storage"); - if (!authStorage) { - console.error("authStorage not found in local storage"); - return; - } + if (!authStorage) return; const { state: { token }, } = JSON.parse(authStorage); - if (!token) { - console.error("Token not found in authStorage"); - return; - } + if (!token) return; const sheets = await api.getPracticeSheets(token, 1, 10); setPracticeSheets(sheets.data); - sortPracticeSheets(sheets.data); - } catch (error) { - console.error("Error fetching practice sheets:", error); + sort(sheets.data); + } catch (e) { + console.error(e); } }; - - fetchPracticeSheets(); + fetch(); }, [user]); - const handleStartPracticeSheet = (sheetId: string) => { - navigate(`/student/practice/${sheetId}`); - }; + const handleStart = (id: string) => navigate(`/student/practice/${id}`); + + const tabSheets = + activeTab === "all" + ? practiceSheets + : activeTab === "NOT_STARTED" + ? notStartedSheets + : completedSheets; + + const greeting = + new Date().getHours() < 12 + ? "Good morning" + : new Date().getHours() < 17 + ? "Good afternoon" + : "Good evening"; return ( -
-
-
- - - - {user?.name.slice(0, 1)} - - -
-

- Welcome, {user?.name || "Student"} -

-

- {user?.role === "STUDENT" - ? "Student" - : user?.role === "ADMIN" - ? "Admin" - : "Taecher"} -

-
-
-
-
- +
+ - 5 -
-
- + {/* Blobs */} +
+
+
+
- {userXp} -
-
-
- -

- What are you looking for? -

-
- setIsSearchOpen(true)} - placeholder="Search practice sheets..." - readOnly - className="font-satoshi w-full pl-10 pr-4 py-3 border border-gray-300 rounded-2xl shadow-sm cursor-pointer" + {/* Dots */} + {DOTS.map((d, i) => ( +
-
- -
-
+ ))} -
-

- Pick up where you left off -

- {inProgressSheets.length > 0 ? ( - inProgressSheets.map((sheet) => ( - - - - {sheet?.title} - - - {sheet?.description} - - - -

- {formatStatus(sheet?.user_status)} -

- - {sheet?.modules_count} modules - -
- -

- {sheet?.time_limit} minutes -

-
- - - -
- )) - ) : ( - -

- You don't have any practice sheets in progress. Why not start one? -

-
- )} -
-
- - - - All - - - Not Started - - - Completed - - - -
- {practiceSheets.length > 0 ? ( - practiceSheets.map((sheet) => ( - - - - {sheet?.title} - - - {sheet?.description} - - - -

- {formatStatus(sheet?.user_status)} -

- - {sheet?.modules_count} modules - -
- -

- {sheet?.time_limit} minutes -

-
- - - -
- )) - ) : ( -
-

- No Practice Sheets available. -

-
- )} +
+ {/* ── Header ── */} +
+
+ + + + {user?.name?.slice(0, 1)} + + +
+

+ {greeting}, {user?.name?.split(" ")[0] || "Student"} +

+

+ {user?.role === "STUDENT" + ? "Student" + : user?.role === "ADMIN" + ? "Admin" + : "Teacher"} +

- - -
- {notStartedSheets.map((sheet) => ( - - - - {sheet?.title} - - - {sheet?.description} - - - -

Not Started

- - {sheet?.modules_count} modules - -
- -

- {sheet?.time_limit} minutes -

-
- - - -
+
+ +
+ {/* Streak chip */} +
+ + 5 +
+ + {/* Score chip */} + + +
+ +
+
+ + + +
+
+
+ + {/* ── Search ── */} +
+ + + + setIsSearchOpen(true)} + /> +
+ + {/* ── In progress ── */} +
+

📌 Pick up where you left off

+ {inProgressSheets.length > 0 ? ( +
+ {inProgressSheets.map((sheet) => ( +
handleStart(sheet.id)} + > +
+

{sheet.title}

+ In Progress +
+ +
))}
- - -
- {completedSheets.length > 0 ? ( - completedSheets.map((sheet) => ( - - - - {sheet?.title} - - - {sheet?.description} - - - -

- {formatStatus(sheet?.user_status)} -

- - {sheet?.modules_count} modules - -
- -

- {sheet?.time_limit} minutes -

-
- - - -
- )) - ) : ( -
-

- You have not completed any practice sheets. -

-
- )} + ) : ( +
+ 🎯 + No sheets in progress — start one below!
- - -
+ )} +
-
-

- SAT Preparation Tips -

-
-
- -

- Practice regularly with official SAT materials -

+ {/* ── All sheets with tabs ── */} +
+

📋 Practice Sheets

+ + {/* Tab buttons */} +
+ {(["all", "NOT_STARTED", "COMPLETED"] as const).map((tab) => ( + + ))}
-
- -

- Review your mistakes and learn from them -

-
-
- -

Focus on your weak areas

-
-
- -

- Take full-length practice tests -

-
-
- -

- Get plenty of rest before the test day -

+ + {tabSheets.length > 0 ? ( +
+ {tabSheets.map((sheet) => ( + + ))} +
+ ) : ( +
+ 🔍 + Nothing here yet! +
+ )} +
+ + {/* ── Tips ── */} +
+

💡 SAT Prep Tips

+
+ {TIPS.map((tip, i) => ( +
+ +

{tip}

+
+ ))}
-
+
+ {isSearchOpen && ( { setSearchQuery={setSearchQuery} /> )} - +
); }; diff --git a/src/pages/student/Lessons.tsx b/src/pages/student/Lessons.tsx index 0a949d6..e2bd61f 100644 --- a/src/pages/student/Lessons.tsx +++ b/src/pages/student/Lessons.tsx @@ -1,188 +1,294 @@ import { useAuthStore } from "../../stores/authStore"; import { useEffect, useState } from "react"; -import { - Card, - CardHeader, - CardTitle, - CardDescription, - CardContent, -} from "../../components/ui/card"; -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "../../components/ui/tabs"; import { api } from "../../utils/api"; import { type Lesson } from "../../types/lesson"; import { LessonSkeleton } from "../../components/LessonSkeleton"; import { LessonModal } from "../../components/LessonModal"; +import { BookOpen, Calculator } from "lucide-react"; + +const DOTS = [ + { size: 10, color: "#f97316", top: "6%", left: "4%", delay: "0s" }, + { size: 7, color: "#a855f7", top: "25%", left: "2%", delay: "1.2s" }, + { size: 9, color: "#22c55e", top: "60%", left: "3%", delay: "0.6s" }, + { size: 12, color: "#3b82f6", top: "10%", right: "4%", delay: "1.8s" }, + { size: 7, color: "#f43f5e", top: "45%", right: "2%", delay: "0.9s" }, + { size: 9, color: "#eab308", top: "75%", right: "5%", delay: "0.4s" }, +]; + +const STYLES = ` + @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); + + .ls-screen { + min-height: 100vh; + background: #fffbf4; + font-family: 'Nunito', sans-serif; + position: relative; + overflow-x: hidden; + } + + .ls-blob { position: fixed; pointer-events: none; z-index: 0; filter: blur(48px); opacity: 0.35; } + .ls-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:lsWobble1 14s ease-in-out infinite; } + .ls-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:lsWobble2 16s ease-in-out infinite; } + .ls-blob-3 { width:210px;height:210px;background:#fbcfe8;top:15%;right:-60px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:lsWobble1 18s ease-in-out infinite reverse; } + .ls-blob-4 { width:150px;height:150px;background:#bfdbfe;bottom:12%;right:2%;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:lsWobble2 12s ease-in-out infinite; } + + @keyframes lsWobble1 { + 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 lsWobble2 { + 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);} + } + + .ls-dot { position:fixed;border-radius:50%;pointer-events:none;z-index:0;opacity:0.3;animation:lsFloat 7s ease-in-out infinite; } + @keyframes lsFloat { + 0%,100%{transform:translateY(0) rotate(0deg);} + 50%{transform:translateY(-12px) rotate(180deg);} + } + + .ls-inner { + position: relative; z-index: 1; + max-width: 680px; margin: 0 auto; + padding: 2rem 1.25rem 4rem; + display: flex; flex-direction: column; gap: 1.25rem; + } + + @keyframes lsPopIn { + from { opacity:0; transform: scale(0.92) translateY(12px); } + to { opacity:1; transform: scale(1) translateY(0); } + } + .ls-anim { animation: lsPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; } + .ls-anim-1 { animation-delay: 0.05s; } + .ls-anim-2 { animation-delay: 0.1s; } + .ls-anim-3 { animation-delay: 0.15s; } + + /* Header */ + .ls-header { animation: lsPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; } + .ls-title { font-size: 1.8rem; font-weight: 900; color: #1e1b4b; letter-spacing: -0.02em; } + .ls-sub { + font-family: 'Nunito Sans', sans-serif; + font-size: 0.85rem; font-weight: 600; color: #9ca3af; margin-top: 0.25rem; + line-height: 1.5; max-width: 420px; + } + + /* Tabs */ + .ls-tabs-list { + display: flex; gap: 0.5rem; margin-bottom: 1.25rem; + } + .ls-tab-btn { + display: flex; align-items: center; gap: 0.5rem; + padding: 0.55rem 1.1rem; border-radius: 100px; cursor: pointer; border: none; + font-family: 'Nunito', sans-serif; font-size: 0.82rem; font-weight: 800; + 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); + } + .ls-tab-btn.active { + background: #1e1b4b; border-color: #1e1b4b; color: white; + box-shadow: 0 4px 0 #1e1b4b66; + } + .ls-tab-btn:not(.active):hover { border-color: #c4b5fd; color: #7c3aed; } + + /* Lesson grid */ + .ls-grid { + display: grid; gap: 0.85rem; grid-template-columns: 1fr; + } + @media(min-width: 480px) { .ls-grid { grid-template-columns: 1fr 1fr; } } + + /* Lesson card */ + .ls-card { + background: white; border: 2.5px solid #f3f4f6; border-radius: 22px; + overflow: hidden; cursor: pointer; + box-shadow: 0 4px 14px rgba(0,0,0,0.04); + transition: transform 0.15s ease, box-shadow 0.15s ease; + display: flex; flex-direction: column; + } + .ls-card:hover { transform: translateY(-3px); box-shadow: 0 10px 24px rgba(0,0,0,0.08); } + .ls-card:active { transform: translateY(1px); box-shadow: 0 3px 8px rgba(0,0,0,0.05); } + + .ls-card-thumb { + width: 100%; aspect-ratio: 16/9; object-fit: cover; + display: block; background: #f3f4f6; + } + .ls-card-body { padding: 0.9rem 1rem 1rem; display: flex; flex-direction: column; gap: 0.25rem; flex: 1; } + .ls-card-title { font-size: 0.92rem; font-weight: 900; color: #1e1b4b; line-height: 1.3; } + .ls-card-topic { + font-size: 0.72rem; font-weight: 700; letter-spacing: 0.08em; + text-transform: uppercase; + display: flex; align-items: center; gap: 0.35rem; + margin-top: 0.25rem; + } + .ls-card-topic.rw { color: #a855f7; } + .ls-card-topic.math { color: #0891b2; } + .ls-topic-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; } + .ls-topic-dot.rw { background: #a855f7; } + .ls-topic-dot.math { background: #0891b2; } + + /* Empty / error */ + .ls-empty { + grid-column: 1 / -1; text-align: center; padding: 3rem 1rem; + background: white; border: 2.5px dashed #e5e7eb; border-radius: 22px; + display: flex; flex-direction: column; align-items: center; gap: 0.5rem; + } + .ls-empty-emoji { font-size: 2.5rem; } + .ls-empty-text { font-size: 0.9rem; font-weight: 700; color: #9ca3af; } + + /* Skeleton shimmer override */ + .ls-skeleton-grid { display: grid; gap: 0.85rem; grid-template-columns: 1fr; } + @media(min-width: 480px) { .ls-skeleton-grid { grid-template-columns: 1fr 1fr; } } +`; export const Lessons = () => { const user = useAuthStore((state) => state.user); const [lessons, setLessons] = useState([]); - const [lessonLoading, setLessonlLoading] = useState(true); - + const [lessonLoading, setLessonLoading] = useState(true); + const [activeTab, setActiveTab] = useState<"rw" | "math">("rw"); const [selectedLessonId, setSelectedLessonId] = useState(null); const [isModalOpen, setIsModalOpen] = useState(false); - const handleLessonClick = (lessonId: string) => { - setSelectedLessonId(lessonId); + const handleLessonClick = (id: string) => { + setSelectedLessonId(id); setIsModalOpen(true); }; useEffect(() => { const fetchAllLessons = async () => { if (!user) return; - try { - setLessonlLoading(true); + setLessonLoading(true); const authStorage = localStorage.getItem("auth-storage"); if (!authStorage) return; - - const parsed = JSON.parse(authStorage) as { - state?: { token?: string }; - }; - - const token = parsed.state?.token; + const { + state: { token }, + } = JSON.parse(authStorage) as { state?: { token?: string } }; if (!token) return; - const response = await api.fetchAllLessons(token); - - setLessonlLoading(false); setLessons(response.data); - } catch (error) { - setLessonlLoading(false); - console.error("Error fetching lessons:", error); + } catch (e) { + console.error(e); + } finally { + setLessonLoading(false); } }; - fetchAllLessons(); }, [user]); - return ( -
-
-

Lessons

-

- Browse step-by-step lessons from expert Edbridge tutors and pick up - tips to tackle similar questions with confidence. -

-
-
- - - - Reading & Writing - - - Math - - - -
- {lessonLoading && ( -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
- )} - {!lessonLoading && lessons.length === 0 && ( -
- No lessons available -
- )} - - {!lessonLoading && lessons.length > 0 && ( -
- {lessons.map((lesson) => ( - handleLessonClick(lesson.id)} - className="py-0 pb-5 rounded-4xl overflow-hidden" - > - - {lesson.title} - - - - {lesson.title} - {lesson.topic.name} - - - ))} -
- )} - { - setIsModalOpen(open); - if (!open) setSelectedLessonId(null); - }} - /> -
-
- - {lessonLoading && ( -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
- )} - {!lessonLoading && lessons.length === 0 && ( -
- No lessons available -
- )} - - {!lessonLoading && lessons.length > 0 && ( -
- {lessons.map((lesson) => ( - handleLessonClick(lesson.id)} - className="py-0 pb-5 rounded-4xl overflow-hidden" - > - - {lesson.title} - - - - {lesson.title} - {lesson.topic.name} - - - ))} -
- )} - { - setIsModalOpen(open); - if (!open) setSelectedLessonId(null); - }} + const renderGrid = (variant: "rw" | "math") => { + if (lessonLoading) { + return ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ); + } + if (!lessons.length) { + return ( +
+
+ 📭 +

No lessons available yet.

+
+
+ ); + } + return ( +
+ {lessons.map((lesson) => ( +
handleLessonClick(lesson.id)} + > + {lesson.title} - - -
-
+
+

{lesson.title}

+

+ + {lesson.topic.name} +

+
+
+ ))} +
+ ); + }; + + return ( +
+ + + {/* Blobs */} +
+
+
+
+ + {/* Dots */} + {DOTS.map((d, i) => ( +
+ ))} + +
+ {/* Header */} +
+

📚 Lessons

+

+ Step-by-step lessons from expert Edbridge tutors — pick up tips to + tackle similar questions with confidence. +

+
+ + {/* Tabs + content */} +
+
+ + +
+ + {renderGrid(activeTab)} +
+
+ + { + setIsModalOpen(open); + if (!open) setSelectedLessonId(null); + }} + /> +
); }; diff --git a/src/pages/student/Practice.tsx b/src/pages/student/Practice.tsx index ef00213..a06859b 100644 --- a/src/pages/student/Practice.tsx +++ b/src/pages/student/Practice.tsx @@ -7,134 +7,326 @@ import { Trophy, Zap, } from "lucide-react"; -import { - Card, - CardAction, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "../../components/ui/card"; -import { Button } from "../../components/ui/button"; import { useNavigate } from "react-router-dom"; import { useExamConfigStore } from "../../stores/useExamConfigStore"; +const DOTS = [ + { size: 10, color: "#f97316", top: "8%", left: "5%", delay: "0s" }, + { size: 7, color: "#a855f7", top: "30%", left: "2%", delay: "1.2s" }, + { size: 9, color: "#22c55e", top: "62%", left: "4%", delay: "0.6s" }, + { size: 12, color: "#3b82f6", top: "12%", right: "4%", delay: "1.8s" }, + { size: 7, color: "#f43f5e", top: "48%", right: "3%", delay: "0.9s" }, + { size: 9, color: "#eab308", top: "78%", right: "6%", delay: "0.4s" }, +]; + +const STYLES = ` + @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); + + .pr-screen { + min-height: 100vh; + background: #fffbf4; + font-family: 'Nunito', sans-serif; + position: relative; + overflow-x: hidden; + } + + /* ── Blobs ── */ + .pr-blob { position: fixed; pointer-events: none; z-index: 0; filter: blur(48px); opacity: 0.35; } + .pr-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:prWobble1 14s ease-in-out infinite; } + .pr-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:prWobble2 16s ease-in-out infinite; } + .pr-blob-3 { width:210px;height:210px;background:#fbcfe8;top:15%;right:-60px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:prWobble1 18s ease-in-out infinite reverse; } + .pr-blob-4 { width:150px;height:150px;background:#bfdbfe;bottom:12%;right:2%;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:prWobble2 12s ease-in-out infinite; } + + @keyframes prWobble1 { + 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 prWobble2 { + 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 ── */ + .pr-dot { position:fixed;border-radius:50%;pointer-events:none;z-index:0;opacity:0.3;animation:prFloat 7s ease-in-out infinite; } + @keyframes prFloat { + 0%,100%{transform:translateY(0) rotate(0deg);} + 50%{transform:translateY(-12px) rotate(180deg);} + } + + /* ── Inner container ── */ + .pr-inner { + position: relative; z-index: 1; + max-width: 580px; margin: 0 auto; + padding: 2rem 1.25rem 4rem; + display: flex; flex-direction: column; gap: 1.5rem; + } + + /* ── Animations ── */ + @keyframes prPopIn { + from { opacity:0; transform: scale(0.92) translateY(12px); } + to { opacity:1; transform: scale(1) translateY(0); } + } + .pr-anim { animation: prPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; } + .pr-anim-1 { animation-delay: 0.05s; } + .pr-anim-2 { animation-delay: 0.1s; } + .pr-anim-3 { animation-delay: 0.15s; } + .pr-anim-4 { animation-delay: 0.2s; } + + /* ── Header ── */ + .pr-header { + display: flex; align-items: center; justify-content: space-between; + animation: prPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; + } + .pr-logo-btn { + width: 44px; height: 44px; border-radius: 14px; + background: linear-gradient(135deg, #a855f7, #7c3aed); + display: flex; align-items: center; justify-content: center; + box-shadow: 0 4px 0 #5b21b644; + } + .pr-xp-chip { + display: flex; align-items: center; gap: 0.5rem; + background: white; border: 2.5px solid #e9d5ff; + border-radius: 100px; padding: 0.45rem 1rem; + font-size: 0.85rem; font-weight: 800; color: #7c3aed; + box-shadow: 0 3px 10px rgba(0,0,0,0.05); + } + .pr-xp-dot { + width: 8px; height: 8px; border-radius: 50%; + background: linear-gradient(135deg, #a855f7, #7c3aed); + } + + /* ── Hero banner ── */ + .pr-hero { + border-radius: 24px; + background: linear-gradient(135deg, #7c3aed, #a855f7); + padding: 1.5rem; + position: relative; overflow: hidden; + box-shadow: 0 8px 0 #5b21b644, 0 12px 32px rgba(124,58,237,0.25); + display: flex; flex-direction: column; gap: 0.75rem; + } + .pr-hero-icon-bg { + position: absolute; right: -40px; top: -30px; + opacity: 0.15; transform: rotate(-30deg); + pointer-events: none; + } + .pr-hero-eyebrow { + font-size: 0.65rem; font-weight: 800; letter-spacing: 0.16em; + text-transform: uppercase; color: #e9d5ff; + } + .pr-hero-title { + font-size: clamp(1.6rem, 5vw, 2rem); font-weight: 900; + color: white; letter-spacing: -0.02em; line-height: 1.15; + position: relative; z-index: 1; + } + .pr-hero-sub { + font-family: 'Nunito Sans', sans-serif; + font-size: 0.85rem; font-weight: 600; color: #ddd6fe; + position: relative; z-index: 1; + } + .pr-hero-btn { + display: inline-flex; align-items: center; gap: 0.4rem; + background: white; border: none; border-radius: 100px; + padding: 0.7rem 1.4rem; cursor: pointer; + font-family: 'Nunito', sans-serif; font-size: 0.88rem; font-weight: 800; + color: #7c3aed; + box-shadow: 0 4px 0 rgba(0,0,0,0.15); + transition: transform 0.1s ease, box-shadow 0.1s ease; + width: fit-content; position: relative; z-index: 1; + } + .pr-hero-btn:hover { transform:translateY(-2px); box-shadow:0 6px 0 rgba(0,0,0,0.15); } + .pr-hero-btn:active { transform:translateY(2px); box-shadow:0 2px 0 rgba(0,0,0,0.15); } + + /* ── Section title ── */ + .pr-section-title { + font-size: 1.15rem; font-weight: 900; color: #1e1b4b; + letter-spacing: -0.01em; + } + + /* ── Practice mode grid ── */ + .pr-grid { + display: grid; grid-template-columns: 1fr; + gap: 0.85rem; + } + @media(min-width: 480px){ .pr-grid { grid-template-columns: 1fr 1fr; } } + + /* ── Mode card ── */ + .pr-mode-card { + background: white; border: 2.5px solid #f3f4f6; border-radius: 22px; + padding: 1.1rem 1.25rem; + box-shadow: 0 4px 14px rgba(0,0,0,0.04); + cursor: pointer; display: flex; flex-direction: column; gap: 0.85rem; + transition: transform 0.15s ease, box-shadow 0.15s ease, border-color 0.15s ease; + position: relative; overflow: hidden; + } + .pr-mode-card:hover { + transform: translateY(-3px); + box-shadow: 0 10px 24px rgba(0,0,0,0.08); + } + .pr-mode-card:active { transform: translateY(1px); box-shadow: 0 3px 8px rgba(0,0,0,0.06); } + + .pr-mode-card.red { border-color: #fecaca; } + .pr-mode-card.red:hover { border-color: #fca5a5; } + .pr-mode-card.cyan { border-color: #a5f3fc; } + .pr-mode-card.cyan:hover { border-color: #67e8f9; } + .pr-mode-card.lime { border-color: #d9f99d; } + .pr-mode-card.lime:hover { border-color: #bef264; } + + .pr-mode-top { + display: flex; align-items: flex-start; justify-content: space-between; + } + .pr-mode-icon { + width: 44px; height: 44px; border-radius: 14px; + display: flex; align-items: center; justify-content: center; + } + .pr-mode-icon.red { background: linear-gradient(135deg, #f87171, #ef4444); box-shadow: 0 4px 0 #b91c1c44; } + .pr-mode-icon.cyan { background: linear-gradient(135deg, #22d3ee, #06b6d4); box-shadow: 0 4px 0 #0e7490aa; } + .pr-mode-icon.lime { background: linear-gradient(135deg, #a3e635, #84cc16); box-shadow: 0 4px 0 #4d7c0f44; } + + .pr-mode-badge { + width: 36px; height: 36px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + } + .pr-mode-badge.red { background: #fff5f5; } + .pr-mode-badge.cyan { background: #ecfeff; } + .pr-mode-badge.lime { background: #f7ffe4; } + + .pr-mode-title { + font-size: 1rem; font-weight: 900; color: #1e1b4b; + } + .pr-mode-desc { + font-family: 'Nunito Sans', sans-serif; + font-size: 0.78rem; font-weight: 600; color: #9ca3af; + } + .pr-mode-arrow { + font-size: 0.75rem; font-weight: 800; margin-top: auto; + display: flex; align-items: center; gap: 0.25rem; + transition: gap 0.2s ease; + } + .pr-mode-card:hover .pr-mode-arrow { gap: 0.5rem; } + .pr-mode-arrow.red { color: #ef4444; } + .pr-mode-arrow.cyan { color: #06b6d4; } + .pr-mode-arrow.lime { color: #84cc16; } +`; + +const MODE_CARDS = [ + { + color: "red", + icon: , + badge: , + title: "Targeted Practice", + desc: "Focus on your weak spots and improve fast", + route: "/student/practice/targeted-practice", + arrow: "Practice →", + }, + { + color: "cyan", + icon: , + badge: , + title: "Drills", + desc: "Train speed and accuracy under pressure", + route: "/student/practice/drills", + arrow: "Drill →", + }, + { + color: "lime", + icon: , + badge: , + title: "Hard Modules", + desc: "Push yourself with the toughest questions", + route: "/student/practice/hard-test-modules", + arrow: "Challenge →", + }, +] as const; + export const Practice = () => { const navigate = useNavigate(); - const userXp = useExamConfigStore.getState().userXp; - return ( -
-
-
- -
-
-
- {userXp} -
-
-
- -
- - - See where you stand - - - -

- Test your knowledge with an adaptive practice test. -

-
- - - + return ( +
+ + + {/* Blobs */} +
+
+
+
+ + {/* Dots */} + {DOTS.map((d, i) => ( +
+ ))} + +
+ {/* ── Header ── */} +
+
+
-
- +
+
+ ⚡ {userXp} XP
- -
-
-

Practice your way

-
- navigate("/student/practice/targeted-practice")} - className="rounded-4xl cursor-pointer hover:bg-gray-50 active:bg-gray-50 active:translate-y-1" - > - -
- -
-
- - Targeted Practice - - - Focus on what matters - -
- -
- -
-
-
-
- navigate("/student/practice/drills")} - className="rounded-4xl cursor-pointer hover:bg-gray-50 active:bg-gray-50 active:translate-y-1" - > - -
- -
-
- Drills - - Train speed and accuracy - -
- -
- -
-
-
-
- navigate("/student/practice/hard-test-modules")} - className="rounded-4xl cursor-pointer hover:bg-gray-50 active:bg-gray-50 active:translate-y-1" - > - -
- -
-
- - Hard Test Modules - - - Focus on what matters - -
- -
- -
-
-
-
+ + + {/* ── Hero banner ── */} +
+
+ +
+

🎯 Full Practice Test

+

See where you stand

+

+ Take a full adaptive test and benchmark your SAT readiness. +

+
-
+ + {/* ── Practice modes ── */} +
+

+ Practice your way +

+
+ {MODE_CARDS.map((card) => ( +
navigate(card.route)} + > +
+
+ {card.icon} +
+
+ {card.badge} +
+
+
+

{card.title}

+

{card.desc}

+
+

{card.arrow}

+
+ ))} +
+
+
); }; diff --git a/src/pages/student/Profile.tsx b/src/pages/student/Profile.tsx index b03e638..c9fa851 100644 --- a/src/pages/student/Profile.tsx +++ b/src/pages/student/Profile.tsx @@ -1,6 +1,185 @@ -import { ChevronRight } from "lucide-react"; +import { ChevronRight, LogOut, User } from "lucide-react"; import { useAuthStore } from "../../stores/authStore"; import { useNavigate } from "react-router-dom"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "../../components/ui/avatar"; + +const DOTS = [ + { size: 10, color: "#f97316", top: "6%", left: "4%", delay: "0s" }, + { size: 7, color: "#a855f7", top: "28%", left: "2%", delay: "1.2s" }, + { size: 9, color: "#22c55e", top: "62%", left: "3%", delay: "0.6s" }, + { size: 12, color: "#3b82f6", top: "10%", right: "4%", delay: "1.8s" }, + { size: 7, color: "#f43f5e", top: "48%", right: "2%", delay: "0.9s" }, + { size: 9, color: "#eab308", top: "76%", right: "5%", delay: "0.4s" }, +]; + +const STYLES = ` + @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); + + .pf-screen { + min-height: 100vh; + background: #fffbf4; + font-family: 'Nunito', sans-serif; + position: relative; + overflow-x: hidden; + } + + .pf-blob { position: fixed; pointer-events: none; z-index: 0; filter: blur(48px); opacity: 0.35; } + .pf-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:pfWobble1 14s ease-in-out infinite; } + .pf-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:pfWobble2 16s ease-in-out infinite; } + .pf-blob-3 { width:210px;height:210px;background:#fbcfe8;top:15%;right:-60px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:pfWobble1 18s ease-in-out infinite reverse; } + .pf-blob-4 { width:150px;height:150px;background:#bfdbfe;bottom:12%;right:2%;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:pfWobble2 12s ease-in-out infinite; } + + @keyframes pfWobble1 { + 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 pfWobble2 { + 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);} + } + + .pf-dot { position:fixed;border-radius:50%;pointer-events:none;z-index:0;opacity:0.3;animation:pfFloat 7s ease-in-out infinite; } + @keyframes pfFloat { + 0%,100%{transform:translateY(0) rotate(0deg);} + 50%{transform:translateY(-12px) rotate(180deg);} + } + + .pf-inner { + position: relative; z-index: 1; + max-width: 520px; margin: 0 auto; + padding: 2rem 1.25rem 4rem; + display: flex; flex-direction: column; gap: 1.5rem; + } + + @keyframes pfPopIn { + from { opacity:0; transform: scale(0.92) translateY(12px); } + to { opacity:1; transform: scale(1) translateY(0); } + } + .pf-anim { animation: pfPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; } + .pf-anim-1 { animation-delay: 0.05s; } + .pf-anim-2 { animation-delay: 0.1s; } + .pf-anim-3 { animation-delay: 0.15s; } + .pf-anim-4 { animation-delay: 0.2s; } + .pf-anim-5 { animation-delay: 0.25s; } + + /* Page title */ + .pf-page-title { + font-size: 1.8rem; font-weight: 900; color: #1e1b4b; + letter-spacing: -0.02em; text-align: center; + animation: pfPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; + } + + /* Avatar hero card */ + .pf-hero { + background: white; border: 2.5px solid #f3f4f6; border-radius: 24px; + padding: 1.5rem; box-shadow: 0 6px 20px rgba(0,0,0,0.05); + display: flex; align-items: center; gap: 1rem; + } + .pf-avatar-wrap { + width: 64px; height: 64px; flex-shrink: 0; + border-radius: 50%; + background: linear-gradient(135deg, #a855f7, #7c3aed); + display: flex; align-items: center; justify-content: center; + box-shadow: 0 4px 0 #5b21b655; + } + .pf-user-name { + font-size: 1.15rem; font-weight: 900; color: #1e1b4b; line-height: 1.2; + } + .pf-user-email { + font-family: 'Nunito Sans', sans-serif; + font-size: 0.8rem; font-weight: 600; color: #9ca3af; margin-top: 0.2rem; + } + .pf-user-badge { + display: inline-flex; align-items: center; gap: 0.3rem; + background: #f3e8ff; border: 2px solid #e9d5ff; border-radius: 100px; + padding: 0.2rem 0.6rem; margin-top: 0.35rem; + font-size: 0.62rem; font-weight: 800; letter-spacing: 0.1em; + text-transform: uppercase; color: #9333ea; + width: fit-content; + } + + /* Section */ + .pf-section { display: flex; flex-direction: column; gap: 0.5rem; } + .pf-section-label { + font-size: 0.62rem; font-weight: 800; letter-spacing: 0.18em; + text-transform: uppercase; color: #9ca3af; padding: 0 0.25rem; + } + + /* Settings group card */ + .pf-group { + background: white; border: 2.5px solid #f3f4f6; border-radius: 20px; + overflow: hidden; box-shadow: 0 4px 14px rgba(0,0,0,0.04); + } + .pf-row { + display: flex; align-items: center; justify-content: space-between; + padding: 0.9rem 1.1rem; gap: 0.75rem; cursor: pointer; + background: white; border: none; width: 100%; text-align: left; + transition: background 0.15s ease; + border-bottom: 2px solid #f9fafb; + } + .pf-row:last-child { border-bottom: none; } + .pf-row:hover { background: #fafaf9; } + .pf-row:active { background: #f5f3ef; } + + .pf-row-left { display: flex; align-items: center; gap: 0.75rem; } + .pf-row-icon { + width: 34px; height: 34px; border-radius: 10px; + display: flex; align-items: center; justify-content: center; + flex-shrink: 0; + } + .pf-row-label { + font-size: 0.9rem; font-weight: 800; color: #1e1b4b; + } + .pf-row-sub { + font-family: 'Nunito Sans', sans-serif; + font-size: 0.72rem; font-weight: 600; color: #9ca3af; + } + .pf-chevron { color: #d1d5db; flex-shrink: 0; } + + /* Sign out button */ + .pf-signout-btn { + width: 100%; + background: #f97316; color: white; border: none; + border-radius: 100px; padding: 1rem; + font-family: 'Nunito', sans-serif; font-size: 0.95rem; font-weight: 800; + cursor: pointer; display: flex; align-items: center; justify-content: center; gap: 0.5rem; + box-shadow: 0 6px 0 #c2560e, 0 8px 20px rgba(249,115,22,0.25); + transition: transform 0.1s ease, box-shadow 0.1s ease; + } + .pf-signout-btn:hover { transform:translateY(-2px); box-shadow:0 8px 0 #c2560e,0 12px 24px rgba(249,115,22,0.3); } + .pf-signout-btn:active { transform:translateY(3px); box-shadow:0 3px 0 #c2560e; } +`; + +type RowDef = { + icon: string; + iconBg: string; + label: string; + sub?: string; + onClick?: () => void; +}; + +const SettingsGroup = ({ rows }: { rows: RowDef[] }) => ( +
+ {rows.map((row, i) => ( + + ))} +
+); export const Profile = () => { const user = useAuthStore((state) => state.user); @@ -11,62 +190,142 @@ export const Profile = () => { navigate("/login"); }; + const ACCOUNT_ROWS: RowDef[] = [ + { + icon: "👤", + iconBg: "#f3e8ff", + label: "Account details", + sub: "Name, email, password", + }, + { + icon: "🎟️", + iconBg: "#fff7ed", + label: "Redeem a code", + sub: "Enter your access code", + }, + { + icon: "💳", + iconBg: "#f0fdf4", + label: "Manage subscription", + sub: "Plans & billing", + }, + { + icon: "⚙️", + iconBg: "#f8fafc", + label: "Preferences", + sub: "Notifications & display", + }, + ]; + + const LEGAL_ROWS: RowDef[] = [ + { icon: "📄", iconBg: "#eff6ff", label: "Terms of Use" }, + { icon: "🔒", iconBg: "#fff1f2", label: "Privacy Policy" }, + ]; + + const SUPPORT_ROWS: RowDef[] = [ + { + icon: "💬", + iconBg: "#f0fdf4", + label: "Contact Us", + sub: "We usually respond within 24h", + }, + ]; + return ( -
-

Profile

-
-

{user?.name}

-

{user?.email}

-
-
-
ACCOUNT
-
- - - - +
+ + + {/* Blobs */} +
+
+
+
+ + {/* Dots */} + {DOTS.map((d, i) => ( +
+ ))} + +
+

Profile

+ + {/* Hero card */} +
+ + + + {user?.name?.slice(0, 1).toUpperCase()} + + +
+

{user?.name}

+

{user?.email}

+
+ + {user?.role === "STUDENT" + ? "Student" + : user?.role === "ADMIN" + ? "Admin" + : "Teacher"} +
+
-
-
-
LEGAL
-
- - -
-
-
-
SUPPORT
-
- -
-
- -
+ + {/* Account */} +
+

Account

+ +
+ + {/* Legal */} +
+

Legal

+ +
+ + {/* Support */} +
+

Support

+ +
+ + {/* Sign out */} + +
+
); }; diff --git a/src/pages/student/Rewards.tsx b/src/pages/student/Rewards.tsx index 36ccbba..b9e7038 100644 --- a/src/pages/student/Rewards.tsx +++ b/src/pages/student/Rewards.tsx @@ -2,23 +2,18 @@ import { useAuthStore } from "../../stores/authStore"; import firstTrophy from "../../assets/icons/first_trophy.png"; import secondTrophy from "../../assets/icons/second_trophy.png"; import thirdTrophy from "../../assets/icons/third_trophy.png"; - import { useEffect, useState } from "react"; -// import { -// Card, -// CardHeader, -// CardContent, -// CardTitle, -// CardDescription, -// } from "../../components/ui/card"; +import { formatTimeFilter, getRandomColor } from "../../lib/utils"; import { - Tabs, - TabsContent, - TabsList, - TabsTrigger, -} from "../../components/ui/tabs"; - -import { Button } from "../../components/ui//button"; + Avatar, + AvatarFallback, + AvatarImage, +} from "../../components/ui/avatar"; +import { Flame, LucideBadgeQuestionMark, Zap, ChevronDown } from "lucide-react"; +import type { Leaderboard } from "../../types/leaderboard"; +import { api } from "../../utils/api"; +import { LeaderboardRowSkeleton } from "../../components/LeaderboardSkeleton"; +import { useExamConfigStore } from "../../stores/useExamConfigStore"; import { DropdownMenu, DropdownMenuContent, @@ -26,118 +21,456 @@ import { DropdownMenuRadioItem, DropdownMenuTrigger, } from "../../components/ui/dropdown-menu"; -import { formatTimeFilter, getRandomColor } from "../../lib/utils"; -import { - Avatar, - AvatarFallback, - AvatarImage, -} from "../../components/ui/avatar"; -import { Flame, LucideBadgeQuestionMark, Zap } from "lucide-react"; -import type { Leaderboard } from "../../types/leaderboard"; -import { api } from "../../utils/api"; -import { Card, CardContent } from "../../components/ui/card"; -import { LeaderboardRowSkeleton } from "../../components/LeaderboardSkeleton"; -import { useExamConfigStore } from "../../stores/useExamConfigStore"; + +const DOTS = [ + { size: 10, color: "#f97316", top: "6%", left: "4%", delay: "0s" }, + { size: 7, color: "#a855f7", top: "28%", left: "2%", delay: "1.2s" }, + { size: 9, color: "#22c55e", top: "58%", left: "3%", delay: "0.6s" }, + { size: 12, color: "#3b82f6", top: "10%", right: "4%", delay: "1.8s" }, + { size: 7, color: "#f43f5e", top: "42%", right: "2%", delay: "0.9s" }, + { size: 9, color: "#eab308", top: "72%", right: "5%", delay: "0.4s" }, +]; + +const STYLES = ` + @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); + + .rw-screen { + height: 100vh; + background: #fffbf4; + font-family: 'Nunito', sans-serif; + position: relative; + 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-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:rwWobble1 14s ease-in-out infinite; } + .rw-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:rwWobble2 16s ease-in-out infinite; } + .rw-blob-3 { width:210px;height:210px;background:#fbcfe8;top:15%;right:-60px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:rwWobble1 18s ease-in-out infinite reverse; } + .rw-blob-4 { width:150px;height:150px;background:#bfdbfe;bottom:12%;right:2%;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:rwWobble2 12s ease-in-out infinite; } + + @keyframes rwWobble1 { + 0%,100%{border-radius:60% 40% 70% 30%/50% 60% 40% 50%;transform:translate(0,0) rotate(0deg);} + 50%{border-radius:40% 60% 30% 70%/60% 40% 60% 40%;transform:translate(12px,16px) rotate(8deg);} + } + @keyframes rwWobble2 { + 0%,100%{border-radius:40% 60% 30% 70%/60% 40% 60% 40%;transform:translate(0,0) rotate(0deg);} + 50%{border-radius:60% 40% 70% 30%/40% 60% 40% 60%;transform:translate(-10px,12px) rotate(-6deg);} + } + + .rw-dot { position:fixed;border-radius:50%;pointer-events:none;z-index:0;opacity:0.3;animation:rwFloat 7s ease-in-out infinite; } + @keyframes rwFloat { + 0%,100%{transform:translateY(0) rotate(0deg);} + 50%{transform:translateY(-12px) rotate(180deg);} + } + + /* Sticky top wrapper */ + .rw-sticky-top { + 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; + } + + /* 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; + } + + @keyframes rwPopIn { + from { opacity:0; transform: scale(0.92) translateY(12px); } + to { opacity:1; transform: scale(1) translateY(0); } + } + .rw-anim { animation: rwPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; } + .rw-anim-1 { animation-delay: 0.05s; } + .rw-anim-2 { animation-delay: 0.1s; } + + /* 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; + } + + /* Tab pills */ + .rw-tabs { display:flex;gap:0.4rem; } + .rw-tab-btn { + padding: 0.45rem 0.9rem; border-radius: 100px; border: none; cursor: pointer; + font-family: 'Nunito', sans-serif; font-size: 0.78rem; font-weight: 800; + display: flex; align-items: center; gap: 0.35rem; + transition: all 0.2s ease; + background: white; border: 2.5px solid #f3f4f6; color: #9ca3af; + box-shadow: 0 2px 8px rgba(0,0,0,0.04); + } + .rw-tab-btn.active { background:#1e1b4b; border-color:#1e1b4b; color:white; box-shadow:0 4px 0 #1e1b4b55; } + + /* 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; + } + .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; + } + .rw-row:hover { transform:translateY(-1px); box-shadow:0 6px 16px rgba(0,0,0,0.07); } + .rw-row.top-1 { border-color:#fde68a; background:linear-gradient(135deg,#fffbeb,white); } + .rw-row.top-2 { border-color:#e2e8f0; background:linear-gradient(135deg,#f8fafc,white); } + .rw-row.top-3 { border-color:#fecba8; background:linear-gradient(135deg,#fff7ed,white); } + + .rw-row-left { display:flex;align-items:center;gap:0.75rem; } + .rw-rank-cell { width:36px;display:flex;align-items:center;justify-content:center; } + .rw-rank-num { font-size:0.9rem;font-weight:900;color:#9ca3af; } + .rw-user-name { font-size:0.88rem;font-weight:800;color:#1e1b4b; } + .rw-row-right { display:flex;align-items:center;gap:0.35rem; } + .rw-score { font-size:0.9rem;font-weight:900;color:#1e1b4b; } + + /* 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; + padding:0.7rem 1rem; + } + .rw-skel-left { display:flex;align-items:center;gap:0.75rem; } + .rw-skel-circle { border-radius:50%;background:#f3f4f6;animation:rwShimmer 1.5s ease-in-out infinite; } + .rw-skel-line { border-radius:6px;background:#f3f4f6;animation:rwShimmer 1.5s ease-in-out infinite; } + @keyframes rwShimmer { + 0%,100%{opacity:1;} 50%{opacity:0.4;} + } +`; + +const TABS = [ + { id: "xp", label: "XP", icon: }, + { + id: "questions", + label: "Questions", + icon: , + }, + { id: "streak", label: "Streak", icon: }, +] as const; + +type TabId = (typeof TABS)[number]["id"]; + +const trophies = [firstTrophy, secondTrophy, thirdTrophy]; + +const SkeletonRows = () => ( +
+ {Array.from({ length: 7 }).map((_, i) => ( +
+
+
+
+
+
+
+
+ ))} +
+); export const Rewards = () => { const user = useAuthStore((state) => state.user); const [time, setTime] = useState("bottom"); - const [activeTab, setActiveTab] = useState("xp"); - + const [activeTab, setActiveTab] = useState("xp"); const [leaderboard, setLeaderboard] = useState(); - const [loading, setLoading] = useState(false); - + const [loading, setLoading] = useState(false); + const [islandOpen, setIslandOpen] = useState(false); const { setUserXp } = useExamConfigStore(); useEffect(() => { - const fetchLeaderboard = async () => { + const fetch = async () => { if (!user) return; - try { setLoading(true); const authStorage = localStorage.getItem("auth-storage"); if (!authStorage) return; - - const parsed = JSON.parse(authStorage) as { - state?: { token?: string }; - }; - - const token = parsed.state?.token; + const { + state: { token }, + } = JSON.parse(authStorage) as { state?: { token?: string } }; if (!token) return; - const response = await api.fetchLeaderboard(token); - setLeaderboard(response); - setUserXp(response.user_rank.total_xp); + } catch (e) { + console.error(e); + } finally { setLoading(false); - } catch (error) { - setLoading(false); - console.error("Error fetching leaderboard: " + error); } }; - - fetchLeaderboard(); + fetch(); }, [user]); - const trophies = [firstTrophy, secondTrophy, thirdTrophy]; + const isTopThree = (leaderboard?.user_rank?.rank ?? Infinity) <= 3; - 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 = () => + activeTab === "xp" ? ( + + ) : activeTab === "questions" ? ( + + ) : ( + + ); + + const userMetric = + activeTab === "xp" + ? leaderboard?.user_rank.total_xp + : activeTab === "questions" + ? "23" + : "5"; return ( -
-
-

Leaderboards

- {loading ? ( -
-
-
- ) : ( -

- Don't stop now! You're{" "} - - #{leaderboard?.user_rank.rank} - {" "} - in XP. -

- )} -
-
- - - - XP - - - Questions - - - Streak - - - - + {tab.icon} {tab.label} + + ))} +
+ + + + - + Today @@ -154,219 +487,141 @@ export const Rewards = () => { - - +
+ {/* end rw-sticky-top-inner */} +
+ {/* end rw-sticky-top */} + + {/* Scrollable list */} +
+
+
{loading ? ( -
- {Array.from({ length: 6 }).map((_, i) => ( - - ))} -
+ ) : ( - leaderboard?.top_users.map((user, index) => { - const isTopThree = index < 3; - + leaderboard?.top_users.map((u, index) => { + const top = index < 3; + const rowClass = `rw-row${index === 0 ? " top-1" : index === 1 ? " top-2" : index === 2 ? " top-3" : ""}`; return ( -
-
- {isTopThree ? ( - {`trophy_${index - ) : ( - - {index + 1} - - )} - - - - - {user.name.slice(0, 1).toUpperCase()} +
+
+
+ {top ? ( + {`#${index + ) : ( + {index + 1} + )} +
+ + + + {u.name.slice(0, 1).toUpperCase()} - -

- {user.name} -

+ {u.name}
- -
-

{user.total_xp}

- +
+ {metricValue(u)} + {metricIcon()}
); }) )} - - - {/* {leaderboard.map((user, index) => { - const isTopThree = index < 3; +
+
+ {/* end rw-scroll-inner */} +
+ {/* end rw-scroll-area */} - return ( -
-
- {isTopThree ? ( - {`trophy_${index - ) : ( - - {index + 1} - - )} + {/* ── Floating island pill ── */} +
+ {/* Expanded info card */} +
+
+ + #{leaderboard?.user_rank?.rank ?? "—"} + + Rank +
+
+ + {leaderboard?.user_rank?.total_xp ?? "—"} + + Total XP +
+
+ + {leaderboard?.user_rank?.current_level ?? "—"} + + Level +
+
- - - {user.name.slice(0, 1).toUpperCase()} - - + {/* Pill button */} +
!loading && setIslandOpen((o) => !o)} + > + {/* Avatar */} +
+ {leaderboard?.user_rank?.avatar_url ? ( + + {leaderboard?.user_rank?.name?.slice(0, 1).toUpperCase() ?? "?"} + + ) : ( + + {leaderboard?.user_rank?.name?.slice(0, 1).toUpperCase() ?? "?"} + + )} +
+
+ You + + {loading ? "Loading..." : (leaderboard?.user_rank?.name ?? "—")} + +
-

- {user.name} -

-
+ {/* Name */} -
-

{user.xp}

- -
-
- ); - })} */} - - - {/* {leaderboard.map((user, index) => { - const isTopThree = index < 3; + {/* Live metric */} +
+ {userMetric} + {activeTab === "xp" ? ( + + ) : activeTab === "questions" ? ( + + ) : ( + + )} +
- return ( -
-
- {isTopThree ? ( - {`trophy_${index - ) : ( - - {index + 1} - - )} - - - - {user.name.slice(0, 1).toUpperCase()} - - - -

- {user.name} -

-
- -
-

{user.xp}

- -
-
- ); - })} */} -
- - - - - {loading ? ( -
-
- {/* Rank / Trophy */} -
- - {/* Avatar */} -
- - {/* Name */} -
-
- - {/* XP */} -
-
-
-
-
- ) : ( - <> -
- {isTopThree ? ( - {`trophy_${leaderboard?.user_rank?.rank - ) : ( - - {(leaderboard?.user_rank?.rank ?? Infinity) - 1} - - )} - - - - {leaderboard?.user_rank.name.slice(0, 1).toUpperCase()} - - -

- {leaderboard?.user_rank.name} -

-
- -
-

- {activeTab === "xp" - ? leaderboard?.user_rank.total_xp - : activeTab === "questions" - ? "23" - : "5"} -

- - {activeTab === "xp" ? ( - - ) : activeTab === "questions" ? ( - - ) : ( - - )} -
- - )} - - + {/* Chevron */} +
+ +
+
+
); }; diff --git a/src/pages/student/StudentLayout.tsx b/src/pages/student/StudentLayout.tsx index 293ae96..8ca07d9 100644 --- a/src/pages/student/StudentLayout.tsx +++ b/src/pages/student/StudentLayout.tsx @@ -3,66 +3,174 @@ import { Home, BookOpen, Award, User, Video } from "lucide-react"; import { SidebarProvider, SidebarTrigger } from "../../components/ui/sidebar"; import { AppSidebar } from "../../components/AppSidebar"; -export function StudentLayout() { - const navItems = [ - { to: "/student/home", icon: Home, label: "Home" }, - { to: "/student/practice", icon: BookOpen, label: "Practice" }, - { to: "/student/lessons", icon: Video, label: "Lessons" }, - { to: "/student/rewards", icon: Award, label: "Rewards" }, - { to: "/student/profile", icon: User, label: "Profile" }, - ]; +const NAV_ITEMS = [ + { + to: "/student/home", + icon: Home, + label: "Home", + color: "#f97316", + bg: "rgba(249,115,22,0.12)", + }, + { + to: "/student/practice", + icon: BookOpen, + label: "Practice", + color: "#a855f7", + bg: "rgba(168,85,247,0.12)", + }, + { + to: "/student/lessons", + icon: Video, + label: "Lessons", + color: "#0891b2", + bg: "rgba(8,145,178,0.12)", + }, + { + to: "/student/rewards", + icon: Award, + label: "Rewards", + color: "#16a34a", + bg: "rgba(22,163,74,0.12)", + }, + { + to: "/student/profile", + icon: User, + label: "Profile", + color: "#e11d48", + bg: "rgba(225,29,72,0.12)", + }, +]; +const STYLES = ` + @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@700;800;900&display=swap'); + + /* ── The floating island dock ── */ + .sl-dock-wrap { + position: fixed; + bottom: calc(1.25rem + env(safe-area-inset-bottom)); + left: 50%; + transform: translateX(-50%); + z-index: 20; + + /* Frosted pill */ + background: rgba(255, 251, 244, 0.72); + backdrop-filter: blur(24px) saturate(180%); + -webkit-backdrop-filter: blur(24px) saturate(180%); + border: 1.5px solid rgba(255,255,255,0.7); + border-radius: 100px; + box-shadow: + 0 8px 32px rgba(0,0,0,0.12), + 0 2px 8px rgba(0,0,0,0.06), + inset 0 1px 0 rgba(255,255,255,0.8); + + padding: 0.45rem 0.5rem; + display: flex; + align-items: center; + gap: 0.15rem; + } + + /* ── Each nav item ── */ + .sl-dock-item { + display: flex; + align-items: center; + gap: 0; + border-radius: 100px; + padding: 0.5rem 0.6rem; + text-decoration: none; + border: none; + background: transparent; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + transition: + padding 0.35s cubic-bezier(0.34,1.56,0.64,1), + gap 0.35s cubic-bezier(0.34,1.56,0.64,1), + background 0.25s ease; + white-space: nowrap; + overflow: hidden; + } + .sl-dock-item:active { transform: scale(0.91); } + .sl-dock-item.active { + padding: 0.5rem 1rem 0.5rem 0.75rem; + gap: 0.45rem; + } + + /* ── Icon circle ── */ + .sl-dock-icon { + width: 32px; height: 32px; flex-shrink: 0; + border-radius: 50%; + display: flex; align-items: center; justify-content: center; + background: transparent; + transition: background 0.25s ease, transform 0.35s cubic-bezier(0.34,1.56,0.64,1); + } + .sl-dock-item.active .sl-dock-icon { + transform: scale(1.1); + } + + /* ── Label (only visible when active) ── */ + .sl-dock-label { + font-family: 'Nunito', sans-serif; + font-size: 0.8rem; + font-weight: 900; + letter-spacing: 0.01em; + max-width: 0; + opacity: 0; + overflow: hidden; + transition: + max-width 0.35s cubic-bezier(0.34,1.56,0.64,1), + opacity 0.25s ease 0.05s; + pointer-events: none; + } + .sl-dock-item.active .sl-dock-label { + max-width: 80px; + opacity: 1; + } +`; + +export function StudentLayout() { return ( +
{/* Desktop Sidebar */}
-
+ {/* Extra bottom padding so content clears the floating dock */} +
- {/* Mobile bottom nav */} -
diff --git a/src/pages/student/practice/Pretest.tsx b/src/pages/student/practice/Pretest.tsx index bdcc20a..bd2c71d 100644 --- a/src/pages/student/practice/Pretest.tsx +++ b/src/pages/student/practice/Pretest.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { useParams } from "react-router-dom"; +import { useParams, useNavigate } from "react-router-dom"; import { api } from "../../../utils/api"; import { useAuthStore } from "../../../stores/authStore"; import type { PracticeSheet } from "../../../types/sheet"; @@ -10,223 +10,456 @@ import { CarouselItem, type CarouselApi, } from "../../../components/ui/carousel"; -import { Button } from "../../../components/ui/button"; -import { useNavigate } from "react-router-dom"; import { useExamConfigStore } from "../../../stores/useExamConfigStore"; +// ─── Shared background dots (same subtle config as rest of app) ─────────────── +const DOTS = [ + { size: 10, color: "#f97316", top: "8%", left: "5%", delay: "0s" }, + { size: 7, color: "#a855f7", top: "28%", left: "2%", delay: "1.2s" }, + { size: 9, color: "#22c55e", top: "60%", left: "4%", delay: "0.6s" }, + { size: 12, color: "#3b82f6", top: "12%", right: "4%", delay: "1.8s" }, + { size: 7, color: "#f43f5e", top: "45%", right: "3%", delay: "0.9s" }, + { size: 9, color: "#eab308", top: "75%", right: "6%", delay: "0.4s" }, +]; + +const STYLES = ` + @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); + + .pt-screen { + min-height: 100vh; + background: #fffbf4; + font-family: 'Nunito', sans-serif; + position: relative; + overflow-x: hidden; + } + + /* ── Blobs ── */ + .pt-blob { position: fixed; pointer-events: none; z-index: 0; filter: blur(48px); opacity: 0.35; } + .pt-blob-1 { width:240px;height:240px;background:#fde68a;top:-80px;left:-80px;border-radius:60% 40% 70% 30%/50% 60% 40% 50%;animation:ptWobble1 14s ease-in-out infinite; } + .pt-blob-2 { width:190px;height:190px;background:#a5f3c0;bottom:-50px;left:6%;border-radius:40% 60% 30% 70%/60% 40% 60% 40%;animation:ptWobble2 16s ease-in-out infinite; } + .pt-blob-3 { width:210px;height:210px;background:#fbcfe8;top:15%;right:-60px;border-radius:70% 30% 50% 50%/40% 60% 40% 60%;animation:ptWobble1 18s ease-in-out infinite reverse; } + .pt-blob-4 { width:150px;height:150px;background:#bfdbfe;bottom:12%;right:2%;border-radius:50% 50% 30% 70%/60% 40% 60% 40%;animation:ptWobble2 12s ease-in-out infinite; } + + @keyframes ptWobble1 { + 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 ptWobble2 { + 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 ── */ + .pt-dot { position:fixed;border-radius:50%;pointer-events:none;z-index:0;opacity:0.3;animation:ptFloat 7s ease-in-out infinite; } + @keyframes ptFloat { + 0%,100%{transform:translateY(0) rotate(0deg);} + 50%{transform:translateY(-12px) rotate(180deg);} + } + + /* ── Inner scroll container ── */ + .pt-inner { + position: relative; z-index: 1; + max-width: 580px; margin: 0 auto; + padding: 2rem 1.25rem 4rem; + display: flex; flex-direction: column; gap: 1.25rem; + } + + /* ── Pop-in animation ── */ + @keyframes ptPopIn { + from { opacity:0; transform: scale(0.92) translateY(12px); } + to { opacity:1; transform: scale(1) translateY(0); } + } + .pt-anim { animation: ptPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; } + .pt-anim-1 { animation-delay: 0.05s; } + .pt-anim-2 { animation-delay: 0.1s; } + .pt-anim-3 { animation-delay: 0.15s; } + .pt-anim-4 { animation-delay: 0.2s; } + .pt-anim-5 { animation-delay: 0.25s; } + + /* ── Header ── */ + .pt-header { animation: ptPopIn 0.4s cubic-bezier(0.34,1.56,0.64,1) both; } + .pt-eyebrow { + font-size: 0.65rem; font-weight: 800; letter-spacing: 0.16em; + text-transform: uppercase; color: #a855f7; margin-bottom: 0.3rem; + } + .pt-title { + font-size: clamp(1.6rem, 5vw, 2.2rem); font-weight: 900; + color: #1e1b4b; letter-spacing: -0.02em; line-height: 1.15; + } + .pt-desc { + font-family: 'Nunito Sans', sans-serif; + font-size: 0.9rem; font-weight: 600; color: #9ca3af; margin-top: 0.3rem; + } + + /* ── White card base ── */ + .pt-card { + background: white; border: 2.5px solid #f3f4f6; border-radius: 24px; + padding: 1.25rem 1.5rem; + box-shadow: 0 6px 20px rgba(0,0,0,0.04); + box-sizing: border-box; + } + + /* ── Stats row ── */ + .pt-stats-row { + display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.75rem; + } + .pt-stat { + display: flex; flex-direction: column; align-items: center; + gap: 0.5rem; padding: 0.85rem 0.5rem; + background: white; border: 2.5px solid #f3f4f6; border-radius: 20px; + box-shadow: 0 3px 10px rgba(0,0,0,0.04); + } + .pt-stat-icon { + width: 44px; height: 44px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + } + .pt-stat-icon.cyan { background: #cffafe; } + .pt-stat-icon.purple { background: #f3e8ff; } + .pt-stat-icon.amber { background: #fef3c7; } + .pt-stat-value { font-size: 1.4rem; font-weight: 900; color: #1e1b4b; line-height: 1; } + .pt-stat-label { font-size: 0.65rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: #9ca3af; } + + /* ── Loading skeleton ── */ + .pt-loading { + display: flex; flex-direction: column; align-items: center; + gap: 0.75rem; padding: 2rem; + background: white; border: 2.5px solid #f3f4f6; border-radius: 24px; + box-shadow: 0 6px 20px rgba(0,0,0,0.04); + } + .pt-spinner { animation: ptSpin 0.8s linear infinite; } + @keyframes ptSpin { to { transform: rotate(360deg); } } + .pt-loading-text { + font-size: 0.85rem; font-weight: 700; color: #9ca3af; + } + + /* ── Module carousel card ── */ + .pt-module-card { + background: white; border: 2.5px solid #f3f4f6; border-radius: 24px; + padding: 1.25rem 1.5rem; + box-shadow: 0 6px 20px rgba(0,0,0,0.04); + display: flex; flex-direction: column; gap: 1rem; + } + .pt-module-header { + display: flex; align-items: center; gap: 0.6rem; + } + .pt-section-badge { + font-size: 0.65rem; font-weight: 800; letter-spacing: 0.12em; + text-transform: uppercase; color: #a855f7; + background: #f3e8ff; border-radius: 100px; padding: 0.25rem 0.7rem; + } + .pt-module-title { + font-size: 0.95rem; font-weight: 900; color: #1e1b4b; line-height: 1.3; + } + .pt-module-stats { + display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.6rem; + } + .pt-module-stat { + display: flex; flex-direction: column; align-items: center; gap: 0.4rem; + padding: 0.7rem 0.4rem; + border: 2px solid #f3f4f6; border-radius: 16px; + background: #fafafa; + } + .pt-module-stat-icon { + width: 36px; height: 36px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + } + .pt-module-stat-icon.cyan { background: #cffafe; } + .pt-module-stat-icon.lime { background: #d9f99d; } + .pt-module-stat-icon.amber { background: #fef3c7; } + .pt-module-stat-value { font-size: 1rem; font-weight: 900; color: #1e1b4b; line-height: 1; } + .pt-module-stat-label { font-size: 0.6rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #9ca3af; } + + /* ── Dot indicator ── */ + .pt-dots { + display: flex; align-items: center; justify-content: center; gap: 0.4rem; + margin-top: 0.75rem; + } + .pt-dot-ind { + border-radius: 100px; height: 7px; + transition: width 0.3s ease, background 0.3s ease; + } + .pt-dot-ind.active { width: 20px; background: #a855f7; } + .pt-dot-ind.inactive { width: 7px; background: #e5e7eb; } + + /* ── Tip card ── */ + .pt-tip { + display: flex; align-items: flex-start; gap: 0.75rem; + } + .pt-tip-emoji { font-size: 1.4rem; flex-shrink: 0; margin-top: 2px; } + .pt-tip-text { font-size: 0.88rem; font-weight: 700; color: #374151; line-height: 1.5; } + + /* ── Start button ── */ + .pt-start-btn { + width: 100%; + background: #f97316; color: white; border: none; border-radius: 100px; + padding: 1.1rem; font-family: 'Nunito', sans-serif; + font-size: 1rem; font-weight: 800; cursor: pointer; + box-shadow: 0 6px 0 #c2560e, 0 8px 20px rgba(249,115,22,0.25); + transition: transform 0.1s ease, box-shadow 0.1s ease; + display: flex; align-items: center; justify-content: center; gap: 0.5rem; + } + .pt-start-btn:hover:not(:disabled) { transform:translateY(-2px); box-shadow:0 8px 0 #c2560e,0 12px 24px rgba(249,115,22,0.3); } + .pt-start-btn:active:not(:disabled) { transform:translateY(3px); box-shadow:0 3px 0 #c2560e,0 4px 12px rgba(249,115,22,0.2); } + .pt-start-btn:disabled { opacity:0.6; cursor:not-allowed; box-shadow:none; } + + /* ── Error card ── */ + .pt-error { + background: #fff5f5; border: 2.5px solid #fecaca; border-radius: 24px; + padding: 1.5rem; text-align: center; + font-size: 0.9rem; font-weight: 700; color: #ef4444; + } +`; + export const Pretest = () => { const { setSheetId, setMode, storeDuration, setQuestionCount } = useExamConfigStore(); const user = useAuthStore((state) => state.user); const { sheetId } = useParams<{ sheetId: string }>(); - const [carouselApi, setCarouselApi] = useState(); - const [current, setCurrent] = useState(0); - const [count, setCount] = useState(0); const navigate = useNavigate(); + const [carouselApi, setCarouselApi] = useState(); + const [current, setCurrent] = useState(0); const [practiceSheet, setPracticeSheet] = useState( null, ); - function handleStartTest(sheetId: string) { - if (!sheetId) { - console.error("Sheet ID is required to start the test."); - return; - } - - setSheetId(sheetId); + function handleStartTest(id: string) { + if (!id) return; + setSheetId(id); setMode("SIMULATION"); storeDuration(practiceSheet?.time_limit ?? 0); setQuestionCount(2); - - navigate(`/student/practice/${sheetId}/test`, { replace: true }); + navigate(`/student/practice/${id}/test`, { replace: true }); } useEffect(() => { if (!user) return; - async function fetchPracticeSheet(sheetId: string) { + async function fetchSheet(id: string) { const authStorage = localStorage.getItem("auth-storage"); - if (!authStorage) { - console.error("authStorage not found in local storage"); - return; - } + if (!authStorage) return; const { state: { token }, } = JSON.parse(authStorage); - if (!token) { - console.error("Token not found in authStorage"); - return; - } - const data = await api.getPracticeSheetById(token, sheetId); + if (!token) return; + const data = await api.getPracticeSheetById(token, id); setPracticeSheet(data); } - fetchPracticeSheet(sheetId!); - }, [sheetId]); + fetchSheet(sheetId!); + }, [sheetId, user]); useEffect(() => { - if (!carouselApi) { - return; - } - setCount(carouselApi.scrollSnapList().length); + if (!carouselApi) return; setCurrent(carouselApi.selectedScrollSnap() + 1); - carouselApi.on("select", () => { - setCurrent(carouselApi.selectedScrollSnap() + 1); - }); + carouselApi.on("select", () => + setCurrent(carouselApi.selectedScrollSnap() + 1), + ); }, [carouselApi]); return ( -
-
-

{practiceSheet?.title}

-

- {practiceSheet?.description} -

-
- {practiceSheet ? ( -
-
- -
-

- {practiceSheet?.time_limit} -

-

Minutes

-
-
-
- -
-

- {practiceSheet?.modules.length} -

-

Modules

-
-
-
- -
-

- {practiceSheet?.questions_count} -

-

Questions

-
-
-
- ) : ( -
-
- -
-
- )} - - - {practiceSheet ? ( - practiceSheet.modules.length > 0 ? ( - practiceSheet.modules.map((module, index) => ( - -
-

- Section {Math.floor(index / 2) + 1} -

-

- {module.title} -

-
-
-
- -
-
-

- {module.duration} -

-

Minutes

-
-
-
-
- -
-
-

- {module.questions.length} -

-

Questions

-
-
-
-
- -
-
-

- {module.section} -

-

Type

-
-
-
-
-
- )) - ) : ( - -
-

- No modules available. -

-
-
- ) - ) : ( - -
-
- -
-

- Loading... -

-
-
- )} -
+
+ -
- {practiceSheet?.modules.map((_, index) => ( -
- ))} -
- -
-

- This practice sheet will help you prepare for the SAT. Take your time - and do your best! -

-
- + + {/* ── Module carousel ── */} +
+ + + {practiceSheet ? ( + practiceSheet.modules.length > 0 ? ( + practiceSheet.modules.map((module, index) => ( + +
+
+ + Section {Math.floor(index / 2) + 1} + +
+

{module.title}

+
+
+
+ +
+ + {module.duration} + + Min +
+
+
+ +
+ + {module.questions.length} + + + Questions + +
+
+
+ +
+ + {module.section} + + Type +
+
+
+
+ )) + ) : ( + +
+ 😕 No modules available for this sheet. +
+
+ ) + ) : ( + +
+ +

Loading modules...

+
+
+ )} +
+ + {/* Dot indicator */} + {practiceSheet && practiceSheet.modules.length > 1 && ( +
+ {practiceSheet.modules.map((_, i) => ( +
+ ))} +
+ )} + +
+ + {/* ── Encouragement tip ── */} +
+
+ 💪 +

+ Take your time, read each question carefully, and do your best. + Every practice run brings you closer to your goal! +

+
+
+ + {/* ── Start button ── */} + +
); }; diff --git a/src/pages/student/practice/Results.tsx b/src/pages/student/practice/Results.tsx index e3eade0..5be111f 100644 --- a/src/pages/student/practice/Results.tsx +++ b/src/pages/student/practice/Results.tsx @@ -1,165 +1,390 @@ import { useNavigate } from "react-router-dom"; import { useResults } from "../../../stores/useResults"; import { LucideArrowLeft } from "lucide-react"; -import { - Card, - CardAction, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "../../../components/ui/card"; import { CircularLevelProgress } from "../../../components/CircularLevelProgress"; import { useEffect, useState } from "react"; import { useExamConfigStore } from "../../../stores/useExamConfigStore"; -const XPGainedCard = ({ - results, -}: { - results?: { - xp_gained: number; - total_xp: number; - current_level_start: number; - next_level_threshold: number; - current_level: number; - }; -}) => { - const [displayXP, setDisplayXP] = useState(0); +// ─── Shared styles injected once ───────────────────────────────────────────── +const STYLES = ` + @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;700;800;900&family=Nunito+Sans:wght@400;600&display=swap'); + .results-screen { + min-height: 100vh; + background: #fffbf4; + font-family: 'Nunito', sans-serif; + position: relative; + overflow-x: hidden; + padding: 0 0 3rem 0; + } + + /* ── Blobs ── */ + .r-blob { position: fixed; pointer-events: none; z-index: 0; } + .r-blob-1 { + width: 260px; height: 260px; background: #fde68a; + top: -80px; left: -80px; + border-radius: 60% 40% 70% 30% / 50% 60% 40% 50%; + animation: rWobble1 6s ease-in-out infinite; + } + .r-blob-2 { + width: 200px; height: 200px; background: #a5f3c0; + bottom: -60px; left: 8%; + border-radius: 40% 60% 30% 70% / 60% 40% 60% 40%; + animation: rWobble2 7s ease-in-out infinite; + } + .r-blob-3 { + width: 220px; height: 220px; background: #fbcfe8; + top: 12%; right: -60px; + border-radius: 70% 30% 50% 50% / 40% 60% 40% 60%; + animation: rWobble1 8s ease-in-out infinite reverse; + } + .r-blob-4 { + width: 160px; height: 160px; background: #bfdbfe; + bottom: 15%; right: 3%; + border-radius: 50% 50% 30% 70% / 60% 40% 60% 40%; + animation: rWobble2 5s ease-in-out infinite; + } + @keyframes rWobble1 { + 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 rWobble2 { + 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 ── */ + .r-dot { + position: fixed; border-radius: 50%; pointer-events: none; z-index: 0; + animation: rFloat 4s ease-in-out infinite; + } + @keyframes rFloat { + 0%,100% { transform: translateY(0) rotate(0deg); } + 50% { transform: translateY(-14px) rotate(180deg); } + } + + /* ── Content wrapper ── */ + .results-inner { + position: relative; z-index: 1; + max-width: 520px; + margin: 0 auto; + padding: 2.5rem 1.5rem 2rem; + display: flex; flex-direction: column; gap: 1rem; + } + + /* ── Header ── */ + .results-header { + display: flex; align-items: center; gap: 1rem; + margin-bottom: 0.25rem; + animation: rPopIn 0.45s cubic-bezier(0.34,1.56,0.64,1) both; + } + .results-back-btn { + display: flex; align-items: center; justify-content: center; + width: 40px; height: 40px; border-radius: 50%; + border: 2px solid #c084fc; + background: linear-gradient(135deg, #c084fc, #a855f7); + cursor: pointer; flex-shrink: 0; + box-shadow: 0 4px 0 #7e22ce55; + transition: transform 0.1s ease, box-shadow 0.1s ease; + } + .results-back-btn:hover { transform: translateY(-2px); box-shadow: 0 6px 0 #7e22ce55; } + .results-back-btn:active { transform: translateY(2px); box-shadow: 0 2px 0 #7e22ce55; } + .results-title { + font-size: 2rem; font-weight: 900; color: #1e1b4b; + letter-spacing: -0.02em; + } + + /* ── Mode badge ── */ + .mode-badge { + display: flex; align-items: center; gap: 0.6rem; + background: white; border: 2.5px solid #e9d5ff; + border-radius: 16px; padding: 0.75rem 1.1rem; + box-shadow: 0 2px 8px rgba(0,0,0,0.04); + animation: rPopIn 0.45s cubic-bezier(0.34,1.56,0.64,1) 0.05s both; + } + .mode-badge-text { font-size: 0.85rem; font-weight: 700; color: #7e22ce; } + .mode-badge-sub { font-size: 0.78rem; font-weight: 600; color: #a78bfa; } + + /* ── Hero congratulations banner ── */ + .congrats-banner { + background: white; + border: 2.5px solid #f3f4f6; + border-radius: 24px; + padding: 1.5rem; + text-align: center; + box-shadow: 0 6px 20px rgba(0,0,0,0.05); + animation: rPopIn 0.45s cubic-bezier(0.34,1.56,0.64,1) 0.1s both; + display: flex; flex-direction: column; align-items: center; gap: 0.25rem; + } + .congrats-emoji { + font-size: 3rem; + animation: rBounce 2s ease-in-out infinite; + display: block; margin-bottom: 0.25rem; + } + @keyframes rBounce { + 0%,100% { transform: translateY(0) rotate(-4deg); } + 50% { transform: translateY(-10px) rotate(4deg); } + } + .congrats-title { + font-size: 1.4rem; font-weight: 900; color: #1e1b4b; + } + .congrats-title span { color: #f97316; } + .congrats-sub { + font-family: 'Nunito Sans', sans-serif; + font-size: 0.85rem; font-weight: 600; color: #9ca3af; + } + + /* ── XP ring section ── */ + .xp-section { + display: flex; justify-content: center; + animation: rPopIn 0.45s cubic-bezier(0.34,1.56,0.64,1) 0.15s both; + } + + /* ── Stat cards grid ── */ + .stats-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; + animation: rPopIn 0.45s cubic-bezier(0.34,1.56,0.64,1) 0.2s both; + } + .stat-card { + background: white; + border: 2.5px solid #f3f4f6; + border-radius: 20px; + padding: 1.1rem 1rem; + box-shadow: 0 4px 12px rgba(0,0,0,0.04); + display: flex; flex-direction: column; gap: 0.2rem; + } + .stat-card-label { + font-size: 0.65rem; font-weight: 700; + letter-spacing: 0.12em; text-transform: uppercase; + color: #9ca3af; + display: flex; align-items: center; gap: 0.35rem; + } + .stat-card-value { + font-size: 1.9rem; font-weight: 900; color: #1e1b4b; + line-height: 1; + } + .stat-card-sub { + font-family: 'Nunito Sans', sans-serif; + font-size: 0.75rem; font-weight: 600; color: #d1d5db; + } + /* Accent colours per card */ + .stat-card.orange { border-color: #fed7aa; } + .stat-card.orange .stat-card-value { color: #f97316; } + .stat-card.green { border-color: #bbf7d0; } + .stat-card.green .stat-card-value { color: #16a34a; } + .stat-card.purple { border-color: #e9d5ff; } + .stat-card.purple .stat-card-value { color: #9333ea; } + .stat-card.blue { border-color: #bfdbfe; } + .stat-card.blue .stat-card-value { color: #2563eb; } + + /* ── Improvement tip card ── */ + .tip-card { + background: white; + border: 2.5px solid #f3f4f6; + border-radius: 20px; + padding: 1.1rem 1.25rem; + box-shadow: 0 4px 12px rgba(0,0,0,0.04); + animation: rPopIn 0.45s cubic-bezier(0.34,1.56,0.64,1) 0.25s both; + } + .tip-card-header { + display: flex; align-items: center; gap: 0.5rem; + margin-bottom: 0.6rem; + } + .tip-card-title { + font-size: 0.95rem; font-weight: 900; color: #1e1b4b; + } + .tip-chips { + display: flex; flex-wrap: wrap; gap: 0.5rem; + } + .tip-chip { + background: #fafafa; border: 2px solid #f3f4f6; + border-radius: 100px; padding: 0.35rem 0.85rem; + font-size: 0.75rem; font-weight: 700; color: #374151; + display: flex; align-items: center; gap: 0.3rem; + } + + /* ── Done button ── */ + .done-btn { + width: 100%; + background: #f97316; + color: white; border: none; + border-radius: 100px; + padding: 1rem 2.5rem; + font-family: 'Nunito', sans-serif; + font-size: 1rem; font-weight: 800; + cursor: pointer; + box-shadow: 0 6px 0 #c2560e, 0 8px 20px rgba(249,115,22,0.25); + transition: transform 0.1s ease, box-shadow 0.1s ease; + animation: rPopIn 0.45s cubic-bezier(0.34,1.56,0.64,1) 0.35s both; + letter-spacing: 0.01em; + } + .done-btn:hover { transform: translateY(-2px); box-shadow: 0 8px 0 #c2560e, 0 12px 24px rgba(249,115,22,0.3); } + .done-btn:active { transform: translateY(3px); box-shadow: 0 3px 0 #c2560e, 0 4px 12px rgba(249,115,22,0.2); } + + @keyframes rPopIn { + from { opacity:0; transform: scale(0.88) translateY(12px); } + to { opacity:1; transform: scale(1) translateY(0); } + } +`; + +// ─── Animated counter ───────────────────────────────────────────────────────── +function useCountUp(target: number, duration = 900) { + const [val, setVal] = useState(0); useEffect(() => { - if (!results?.xp_gained) return; - - let startTime: number | null = null; - const duration = 800; - - const animate = (time: number) => { - if (!startTime) startTime = time; - const t = Math.min((time - startTime) / duration, 1); - setDisplayXP(Math.floor(t * results.xp_gained)); - if (t < 1) requestAnimationFrame(animate); + if (!target) return; + let start: number | null = null; + const tick = (t: number) => { + if (!start) start = t; + const p = Math.min((t - start) / duration, 1); + setVal(Math.floor(p * target)); + if (p < 1) requestAnimationFrame(tick); }; + requestAnimationFrame(tick); + }, [target]); + return val; +} - requestAnimationFrame(animate); - }, [results?.xp_gained]); +// ─── Floating dots config (shared) ─────────────────────────────────────────── +const DOTS = [ + { size: 12, color: "#f97316", top: "18%", left: "12%", delay: "0s" }, + { size: 8, color: "#a855f7", top: "33%", left: "5%", delay: "1s" }, + { size: 10, color: "#22c55e", top: "62%", left: "15%", delay: "0.5s" }, + { size: 14, color: "#3b82f6", top: "22%", right: "10%", delay: "1.5s" }, + { size: 8, color: "#f43f5e", top: "52%", right: "6%", delay: "0.8s" }, + { size: 10, color: "#eab308", top: "72%", right: "18%", delay: "0.3s" }, +]; - return ( - - - XP - How much did you improve? - -

+{displayXP} XP

-
-
-
- ); -}; - -// ─── Targeted static results ────────────────────────────────────────────────── +// ─── Targeted results ───────────────────────────────────────────────────────── const TARGETED_XP = 15; const TARGETED_SCORE = 15; const TargetedResults = ({ onFinish }: { onFinish: () => void }) => { const { userXp, setUserXp } = useExamConfigStore(); - - // previousXP is whatever the user had before; we add 15 on top const previousXP = userXp ?? 0; const gainedXP = TARGETED_XP; - const totalXP = previousXP; - - // Sync updated XP back into the store - useEffect(() => { - setUserXp(totalXP); - }, []); - - // Simple level bounds — 0–100 per level so progress is visible - // Adjust these to match your real level thresholds if needed const levelMinXP = Math.floor(previousXP / 100) * 100; const levelMaxXP = levelMinXP + 100; const currentLevel = Math.floor(previousXP / 100) + 1; - - const [displayXP, setDisplayXP] = useState(0); + const displayXP = useCountUp(gainedXP); useEffect(() => { - let startTime: number | null = null; - const duration = 800; - const animate = (time: number) => { - if (!startTime) startTime = time; - const t = Math.min((time - startTime) / duration, 1); - setDisplayXP(Math.floor(t * gainedXP)); - if (t < 1) requestAnimationFrame(animate); - }; - requestAnimationFrame(animate); + setUserXp(previousXP); }, []); return ( -
-
- -

Results

-
+
+ - {/* Targeted mode badge */} -
- 🎯 -

- Targeted Mode Complete! You answered all questions - correctly. -

-
+ {/* Blobs */} +
+
+
+
-
- ( +
-
+ ))} - - - XP - How much did you improve? - -

+{displayXP} XP

-
-
-
+
+ {/* Header */} +
+ +

Results

+
- - - Score - Total score you achieved. - -

{TARGETED_SCORE}

-
-
-
+ {/* Mode badge */} +
+ 🎯 +
+

Targeted Mode Complete!

+

+ You answered all questions correctly. +

+
+
- - - Keep it up! 🚀 - - Great work getting every question right. Keep practicing to level up - faster! - - - + {/* Congrats banner */} +
+ 🏆 +

+ Nailed it, champ! +

+

Perfect run — every question down.

+
- -
+ {/* XP ring */} +
+ +
+ + {/* Stats grid */} +
+
+ ⚡ XP Gained + +{displayXP} + experience points +
+
+ 🎯 Score + {TARGETED_SCORE} + total points +
+
+ ✅ Accuracy + 100% + all correct +
+
+ 🔥 Streak + Perfect + no mistakes +
+
+ + {/* Tip card */} +
+
+ 🚀 + Keep the momentum going! +
+
+
📖 Review mistakes
+
⏱️ Try timed mode
+
🎯 Next topic
+
+
+ + +
+
); }; @@ -168,84 +393,149 @@ export const Results = () => { const navigate = useNavigate(); const results = useResults((s) => s.results); const clearResults = useResults((s) => s.clearResults); - const { setUserXp, payload } = useExamConfigStore(); const isTargeted = payload?.mode === "TARGETED"; useEffect(() => { - if (results) setUserXp(results?.total_xp); + if (results) setUserXp(results.total_xp); }, [results]); function handleFinishExam() { useExamConfigStore.getState().clearPayload(); clearResults(); - navigate(`/student/home`); + navigate("/student/home"); } - // ── Targeted mode: show static screen ────────────────────────────────────── - if (isTargeted) { - return ; - } + if (isTargeted) return ; - // ── Standard mode ────────────────────────────────────────────────────────── + // ── Standard mode values ─────────────────────────────────────────────────── const previousXP = results ? results.total_xp - results.xp_gained : 0; + const accuracy = + results && results.total_questions > 0 + ? Math.round((results.correct_count / results.total_questions) * 100) + : 0; + const displayXP = useCountUp(results?.xp_gained ?? 0); + const displayScore = useCountUp(results?.score ?? 0); + + // Motivational headline based on accuracy + const headline = + accuracy >= 90 + ? { emoji: "🏆", text: "Absolutely crushing it!" } + : accuracy >= 70 + ? { emoji: "🎉", text: "Solid work, keep going!" } + : accuracy >= 50 + ? { emoji: "💪", text: "Good effort, room to grow!" } + : { emoji: "📚", text: "Every attempt makes you better!" }; return ( -
-
- -

Results

-
-
- {results && ( - - )} -
+
+ - - - - Score - Total score you achieved. - -

{results?.score}

-
-
-
- - - Accuracy - How many did you answer correct? - -

- {results && results.total_questions > 0 - ? `${Math.round( - (results.correct_count / results.total_questions) * 100, - )}%` - : "—"} -

-
-
-
- - - How do you improve? - - Your score is good, but you can do better! - - - -
+ {/* Blobs */} +
+
+
+
+ + {/* Dots */} + {DOTS.map((d, i) => ( +
+ ))} + +
+ {/* Header */} +
+ +

Results

+
+ + {/* Congrats banner — dynamic */} +
+ {headline.emoji} +

+ {headline.text.split(" ").slice(0, -1).join(" ")}{" "} + {headline.text.split(" ").slice(-1)} +

+

Here's how you performed today

+
+ + {/* XP ring */} +
+ {results && ( + + )} +
+ + {/* Stats grid */} +
+
+ ⚡ XP Gained + +{displayXP} + experience points +
+
+ 🎯 Score + {displayScore} + total points +
+
= 70 ? "purple" : "blue"}`}> + ✅ Accuracy + {accuracy}% + + {results?.correct_count ?? 0} of {results?.total_questions ?? 0}{" "} + correct + +
+
+ ❌ Missed + + {results ? results.total_questions - results.correct_count : 0} + + questions to review +
+
+ + {/* Tip card */} +
+
+ 💡 + How to improve faster +
+
+
📖 Review wrong answers
+
🔁 Retry missed questions
+
⏱️ Work on pacing
+
📈 Track your trends
+
+
+ + +
+
); }; diff --git a/src/pages/student/practice/Test.tsx b/src/pages/student/practice/Test.tsx index f5b1c97..9e58923 100644 --- a/src/pages/student/practice/Test.tsx +++ b/src/pages/student/practice/Test.tsx @@ -44,16 +44,11 @@ import { DialogTitle, DialogTrigger, } from "../../../components/ui/dialog"; +import { Drawer } from "vaul"; import { useExamNavigationGuard } from "../../../hooks/useExamNavGuard"; import { useExamConfigStore } from "../../../stores/useExamConfigStore"; import { useResults } from "../../../stores/useResults"; -import { - GraphCalculatorButton, - GraphCalculatorModal, -} from "../../../components/Calculator"; - -// Somewhere in your JSX: -; +import { GraphCalculatorModal } from "../../../components/Calculator"; // ─── Confetti particle type ─────────────────────────────────────────────────── interface ConfettiParticle { @@ -77,7 +72,7 @@ type FeedbackState = { // ─── Targeted incorrect queue ───────────────────────────────────────────────── interface IncorrectEntry { questionId: string; - originalIndex: number; // index in currentModule.questions + originalIndex: number; } // ─── Confetti Component ─────────────────────────────────────────────────────── @@ -123,9 +118,7 @@ const Confetti = ({ active }: { active: boolean }) => { 100% { transform: translateY(110vh) rotate(720deg); opacity: 0; } } .confetti-piece { - position: absolute; - top: -20px; - border-radius: 2px; + position: absolute; top: -20px; border-radius: 2px; animation: confetti-fall linear forwards; } `} @@ -148,6 +141,16 @@ const Confetti = ({ active }: { active: boolean }) => { ); }; +const snapPoints = [ + "190px", + "250px", + "300px", + "350px", + "400px", + "500px", + "600px", + "700px", +]; // ─── XP Popup Component ─────────────────────────────────────────────────────── const XPPopup = ({ xp, show }: { xp: number; show: boolean }) => { if (!show) return null; @@ -177,41 +180,6 @@ const XPPopup = ({ xp, show }: { xp: number; show: boolean }) => { ); }; -// ─── Incorrect Flash Overlay ────────────────────────────────────────────────── -const IncorrectFlash = ({ show }: { show: boolean }) => { - if (!show) return null; - return ( -
- -
-
-
-
- ✗ INCORRECT -
-
- Don't worry — you'll see this again! -
-
-
-
- ); -}; - // ─── Main Component ─────────────────────────────────────────────────────────── export const Test = () => { const sheetId = localStorage.getItem("activePracticeSheetId"); @@ -220,11 +188,10 @@ export const Test = () => { 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); - } + if (blocker.state === "blocked") setShowExitDialog(true); }, [blocker.state]); const navigate = useNavigate(); @@ -235,7 +202,6 @@ export const Test = () => { const [isSubmitting, setIsSubmitting] = useState(false); const [sessionId, setSessionId] = useState(null); - // ─── TARGETED mode state ─────────────────────────────────────────────────── const examMode = useExamConfigStore((s) => s.payload?.mode); const isTargeted = examMode === "TARGETED"; @@ -243,12 +209,9 @@ export const Test = () => { const [showConfetti, setShowConfetti] = useState(false); const [showIncorrectFlash, setShowIncorrectFlash] = useState(false); - // Tracks incorrect questions: { questionId, originalIndex }[] const incorrectQueueRef = useRef([]); - // Tracks questions answered correctly on retry so we can filter them out const correctedRef = useRef>(new Set()); - // When in TARGETED retry mode, we only show incorrects in order const [retryMode, setRetryMode] = useState(false); const [retryQueue, setRetryQueue] = useState([]); const [retryIndex, setRetryIndex] = useState(0); @@ -258,7 +221,6 @@ export const Test = () => { const currentModule = useSatExam((s) => s.currentModuleQuestions); const questionIndex = useSatExam((s) => s.questionIndex); - // In retry mode, compute the current question from retry queue const currentQuestion = retryMode ? currentModule?.questions[retryQueue[retryIndex]?.originalIndex] : currentModule?.questions[questionIndex]; @@ -275,6 +237,8 @@ export const Test = () => { const quitExam = useSatExam((s) => s.quitExam); const setResults = useResults((s) => s.setResults); + const [snap, setSnap] = useState(snapPoints[0]); + const startExam = async () => { if (!user || !sheetId) return; const payload = useExamConfigStore.getState().payload; @@ -285,7 +249,6 @@ export const Test = () => { useSatExam.getState().startExam(); } catch (error) { setError(`Failed to start exam session: ${error}`); - console.error("Failed to start exam session:", error); } }; @@ -319,7 +282,6 @@ export const Test = () => { } }; - // ─── Show feedback for TARGETED mode ────────────────────────────────────── const showAnswerFeedback = ( correct: boolean, xpGained?: number, @@ -335,28 +297,18 @@ export const Test = () => { } }; - // ─── Advance in retry mode ───────────────────────────────────────────────── const advanceRetry = async () => { setFeedback(null); const nextRetryIndex = retryIndex + 1; - - // Filter remaining incorrects (remove ones that got corrected) const remaining = retryQueue.filter( (e) => !correctedRef.current.has(e.questionId), ); - if (remaining.length === 0) { - // All correct now — finish exam const next = await api.fetchNextModule(token!, sessionId); - if (next.status === "COMPLETED") { - finishExam(); - } + if (next.status === "COMPLETED") finishExam(); return; } - - // Find position of next in remaining if (nextRetryIndex >= retryQueue.length) { - // Exhausted this pass — start a fresh pass with still-incorrect ones const stillIncorrect = incorrectQueueRef.current.filter( (e) => !correctedRef.current.has(e.questionId), ); @@ -370,10 +322,8 @@ export const Test = () => { } }; - // ─── Submit answer with TARGETED feedback ───────────────────────────────── const submitTargetedAnswer = async () => { if (!currentQuestion || !sessionId) return; - const userAnswer = answers[currentQuestion.id] ?? ""; let answerText = ""; if (currentQuestion.options?.length) { @@ -384,31 +334,23 @@ export const Test = () => { } else { answerText = userAnswer; } - const payload: SubmitAnswer = { question_id: currentQuestion.id, answer_text: answerText, time_spent_seconds: 3, }; - try { setIsSubmitting(true); const result = await api.submitAnswer(token!, sessionId, payload); - const isCorrect: boolean = result?.feedback.is_correct ?? false; const xpGained: number | undefined = 2; - - // Show visual feedback showAnswerFeedback(isCorrect, xpGained, userAnswer); - if (isCorrect) { - // Remove from incorrect tracking if it was previously wrong correctedRef.current.add(currentQuestion.id); incorrectQueueRef.current = incorrectQueueRef.current.filter( (e) => e.questionId !== currentQuestion.id, ); } else { - // Track as incorrect (only if not already tracked) const alreadyTracked = incorrectQueueRef.current.some( (e) => e.questionId === currentQuestion.id, ); @@ -420,31 +362,22 @@ export const Test = () => { : questionIndex, }); } - // Remove from corrected set if they re-answered incorrectly correctedRef.current.delete(currentQuestion.id); } - - // Wait for feedback animation, then advance const delay = isCorrect ? 2200 : 1500; setTimeout(async () => { setFeedback(null); - if (retryMode) { advanceRetry(); return; } - - // Normal forward progression const isLastQuestion = questionIndex === currentModule!.questions.length - 1; if (!isLastQuestion) { nextQuestion(); return; } - - // Last question reached — check for incorrects if (incorrectQueueRef.current.length > 0) { - // Enter retry mode const queue = [...incorrectQueueRef.current]; setRetryQueue(queue); setRetryIndex(0); @@ -452,8 +385,6 @@ export const Test = () => { goToQuestion(queue[0].originalIndex); return; } - - // All correct — proceed to next module or finish await proceedAfterLastQuestion(); }, delay); } catch (err) { @@ -475,13 +406,11 @@ export const Test = () => { } }; - // ─── Standard handleNext (non-TARGETED) ─────────────────────────────────── const handleNext = async () => { if (isTargeted) { await submitTargetedAnswer(); return; } - if (!currentQuestion || !sessionId) return; const userAnswer = answers[currentQuestion.id] ?? ""; let answerText = ""; @@ -513,7 +442,6 @@ export const Test = () => { return; } const next = await api.fetchNextModule(token!, sessionId); - if (next?.status === "COMPLETED") { setResults(next.results); finishExam(); @@ -551,116 +479,98 @@ export const Test = () => { const isFirstQuestion = retryMode ? true : questionIndex === 0; - // ─── MCQ Option styling for TARGETED feedback ────────────────────────────── const getOptionStyle = (optionId: string, questionId: string) => { const base = "w-full text-start font-satoshi-medium text-lg space-x-2 px-4 py-4 border rounded-4xl transition duration-200"; const eliminatedSet = eliminated[questionId] ?? new Set(); const isSelected = currentAnswer === optionId; const isEliminated = eliminatedSet.has(optionId); - if (!isTargeted || !feedback?.show) { - // Normal style if (isSelected) return `${base} bg-linear-to-br from-indigo-400 to-indigo-500 text-white`; if (isEliminated) return `${base} line-through opacity-70`; return base; } - - // Feedback visible — highlight correct/incorrect if (feedback.correct) { - // Correct answer: highlight the selected option green - if (isSelected) { + if (isSelected) return `${base} bg-gradient-to-br from-green-400 to-green-500 text-white ring-2 ring-green-300`; - } return `${base} opacity-40`; } else { - // Incorrect answer: only highlight the selected option red, leave others untouched - if (isSelected) { + if (isSelected) return `${base} bg-gradient-to-br from-red-400 to-red-500 text-white ring-2 ring-red-300`; - } return `${base} opacity-40`; } }; - // Add this state alongside calcOpen - const [menuOpen, setMenuOpen] = useState(false); - - // ─── Render answer input ─────────────────────────────────────────────────── - const renderAnswerInput = (question?: Question) => { - if (!question) return null; - - if (question.options && question.options.length > 0) { - const eliminatedSet = eliminated[question.id] ?? new Set(); - return ( -
- {question.options.map((option, index) => { - const isSelected = currentAnswer === option.id; - const isEliminated = eliminatedSet.has(option.id); - const feedbackLocked = isTargeted && feedback?.show; - - return ( -
- {!feedbackLocked && ( - - )} -
- - - {/* XP badge on correct option after feedback */} - {isTargeted && - feedback?.show && - feedback.correct && - feedback.xpGained && - isSelected && ( -
- +{feedback.xpGained} XP ⭐ -
- )} -
-
- ); - })} -
- ); - } - + // ─── Render MCQ options for the drawer ──────────────────────────────────── + const renderOptions = (question?: Question) => { + if (!question?.options?.length) return null; + const eliminatedSet = eliminated[question.id] ?? new Set(); return ( -
+
+ {question.options.map((option, index) => { + const isSelected = currentAnswer === option.id; + const isEliminated = eliminatedSet.has(option.id); + const feedbackLocked = isTargeted && !!feedback?.show; + return ( +
+ {!feedbackLocked && ( + + )} +
+ + {isTargeted && + feedback?.show && + feedback.correct && + feedback.xpGained && + isSelected && ( +
+ +{feedback.xpGained} XP ⭐ +
+ )} +
+
+ ); + })} +
+ ); + }; + + // ─── Render short-answer (stays inline, not in drawer) ──────────────────── + const renderShortAnswer = (question?: Question) => { + if (!question || question.options?.length) return null; + return ( +