From 7f82e640e0d414e198783d4f7859a06d46026a99 Mon Sep 17 00:00:00 2001 From: shafin-r Date: Tue, 10 Feb 2026 19:32:46 +0600 Subject: [PATCH] feat(results): add resutls page fix(leaderboard): fix leaderboard fetch logic fix(test): fix navigation bug upon test quit --- index.html | 1 + src/components/CircularLevelProgress.tsx | 145 +++++++++++++++++ src/components/ConfettiBurst.tsx | 44 ++++++ src/components/GeoGebraGraph.tsx | 93 +++++++++++ src/index.css | 35 +++++ src/pages/student/Home.tsx | 8 +- src/pages/student/Practice.tsx | 1 + src/pages/student/Rewards.tsx | 7 +- src/pages/student/practice/Pretest.tsx | 2 +- src/pages/student/practice/Results.tsx | 155 ++++++++++++++++++- src/pages/student/practice/Test.tsx | 24 ++- src/pages/student/targeted-practice/page.tsx | 44 +----- src/stores/useExamConfigStore.ts | 5 +- src/stores/useResults.ts | 16 ++ src/stores/useSatExam.ts | 48 +++--- src/types/sheet.ts | 2 +- src/types/test.ts | 12 ++ 17 files changed, 560 insertions(+), 82 deletions(-) create mode 100644 src/components/CircularLevelProgress.tsx create mode 100644 src/components/ConfettiBurst.tsx create mode 100644 src/components/GeoGebraGraph.tsx create mode 100644 src/stores/useResults.ts diff --git a/index.html b/index.html index f79fac2..6e195c5 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,7 @@ + Edbridge Scholars diff --git a/src/components/CircularLevelProgress.tsx b/src/components/CircularLevelProgress.tsx new file mode 100644 index 0000000..f080c0a --- /dev/null +++ b/src/components/CircularLevelProgress.tsx @@ -0,0 +1,145 @@ +import { useEffect, useState } from "react"; +import { ConfettiBurst } from "./ConfettiBurst"; + +type Props = { + size?: number; + strokeWidth?: number; + previousXP: number; + gainedXP: number; + levelMinXP: number; + levelMaxXP: number; + level: number; +}; + +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 [currentLevel, setCurrentLevel] = useState(level); + const [showLevelUp, setShowLevelUp] = useState(false); + const [showThresholdText, setShowThresholdText] = useState(false); + + useEffect(() => { + let animationFrame: number; + let start: number | null = null; + + const availableXP = previousXP + gainedXP; + const crossesLevel = availableXP >= levelMaxXP; + + const phase1Target = crossesLevel ? 1 : normalize(previousXP + gainedXP); + + 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( + normalize(previousXP) + t * (phase1Target - normalize(previousXP)), + ); + + if (t < 1) { + animationFrame = requestAnimationFrame(animatePhase1); + } else if (crossesLevel) { + setShowLevelUp(true); + setTimeout(startPhase2, 1200); + } else { + setShowThresholdText(true); + } + }; + + const startPhase2 = () => { + start = null; + setShowLevelUp(false); + setCurrentLevel((l) => l + 1); + setProgress(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); + + if (t < 1) { + animationFrame = requestAnimationFrame(animatePhase2); + } else { + setShowThresholdText(true); + } + }; + + animationFrame = requestAnimationFrame(animatePhase2); + }; + + animationFrame = requestAnimationFrame(animatePhase1); + + return () => cancelAnimationFrame(animationFrame); + }, []); + + const offset = circumference * (1 - progress); + + return ( +
+ {showLevelUp && } +
+ + + + + + + {currentLevel} + + {showThresholdText && ( + + Total XP: {previousXP + gainedXP} + + )} + + {showLevelUp && ( + + 🎉 You leveled up! + + )} + +
+
+ ); +}; diff --git a/src/components/ConfettiBurst.tsx b/src/components/ConfettiBurst.tsx new file mode 100644 index 0000000..8c709d5 --- /dev/null +++ b/src/components/ConfettiBurst.tsx @@ -0,0 +1,44 @@ +import { useEffect } from "react"; + +type ConfettiBurstProps = { + count?: number; +}; + +export const ConfettiBurst = ({ count = 30 }: ConfettiBurstProps) => { + useEffect(() => { + const timeout = setTimeout(() => { + const container = document.getElementById("confetti-container"); + if (container) container.innerHTML = ""; + }, 1200); + + return () => clearTimeout(timeout); + }, []); + + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + + ))} +
+ ); +}; + +const CONFETTI_COLORS = [ + "#a855f7", // purple + "#6366f1", // indigo + "#ec4899", // pink + "#22c55e", // green + "#facc15", // yellow +]; diff --git a/src/components/GeoGebraGraph.tsx b/src/components/GeoGebraGraph.tsx new file mode 100644 index 0000000..a89fc61 --- /dev/null +++ b/src/components/GeoGebraGraph.tsx @@ -0,0 +1,93 @@ +import { useEffect, useRef } from "react"; + +declare global { + interface Window { + GGBApplet: any; + } +} + +interface GraphProps { + width?: string; + height?: string; + commands?: string[]; + defaultZoom?: number; +} + +export function Graph({ + width = "w-full", + height = "h-30", + commands = [], + defaultZoom = 1, +}: GraphProps) { + const containerRef = useRef(null); + const appRef = useRef(null); + + useEffect(() => { + if (!(window as any).GGBApplet) { + console.error("GeoGebra library not loaded"); + return; + } + + const applet = new window.GGBApplet( + { + appName: "graphing", + width: 480, + height: 320, + scale: 1.4, + + showToolBar: false, + showAlgebraInput: false, + showMenuBar: false, + showResetIcon: false, + + enableRightClick: false, + enableLabelDrags: false, + enableShiftDragZoom: true, + showZoomButtons: true, + + appletOnLoad(api: any) { + appRef.current = api; + + api.setPerspective("G"); + api.setMode(0); + api.setAxesVisible(true, true); + api.setGridVisible(true); + + api.setCoordSystem(-5, 5, -5, 5); + + commands.forEach((command, i) => { + const name = `f${i}`; + api.evalCommand(`${name}: ${command}`); + api.setFixed(name, true); + }); + + // Inside appletOnLoad: + }, + }, + true, + ); + + applet.inject("ggb-container"); + }, [commands, defaultZoom]); + + useEffect(() => { + const resize = () => { + if (!containerRef.current || !appRef.current) return; + appRef.current.setSize( + containerRef.current.offsetWidth, + containerRef.current.offsetHeight, + ); + }; + + window.addEventListener("resize", resize); + resize(); // initial resize + + return () => window.removeEventListener("resize", resize); + }, []); + + return ( +
+
+
+ ); +} diff --git a/src/index.css b/src/index.css index 37a196a..15edf4e 100644 --- a/src/index.css +++ b/src/index.css @@ -281,3 +281,38 @@ @apply bg-background text-foreground; } } + +@keyframes fade-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.animate-fade-in { + animation: fade-in 300ms ease-out forwards; +} +.confetti { + position: absolute; + top: -10px; + width: 8px; + height: 14px; + opacity: 0.9; + animation: confetti-fall 1.2s ease-out forwards; +} + +@keyframes confetti-fall { + 0% { + transform: translateY(0) rotate(0deg); + opacity: 1; + } + 100% { + transform: translateY(220px) rotate(720deg); + opacity: 0; + } +} + diff --git a/src/pages/student/Home.tsx b/src/pages/student/Home.tsx index 20cf011..d204916 100644 --- a/src/pages/student/Home.tsx +++ b/src/pages/student/Home.tsx @@ -35,8 +35,6 @@ export const Home = () => { const user = useAuthStore((state) => state.user); const navigate = useNavigate(); - // const logout = useAuthStore((state) => state.logout); - // const navigate = useNavigate(); const [practiceSheets, setPracticeSheets] = useState([]); const [notStartedSheets, setNotStartedSheets] = useState([]); const [inProgressSheets, setInProgressSheets] = useState([]); @@ -92,7 +90,7 @@ export const Home = () => { }; return ( -
+

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

@@ -103,7 +101,7 @@ export const Home = () => { your scores now!

*/} - + {/*
@@ -142,7 +140,7 @@ export const Home = () => {
- + */}

What are you looking for?

diff --git a/src/pages/student/Practice.tsx b/src/pages/student/Practice.tsx index 732e201..ac5fcaf 100644 --- a/src/pages/student/Practice.tsx +++ b/src/pages/student/Practice.tsx @@ -24,6 +24,7 @@ export const Practice = () => { const navigate = useNavigate(); const userXp = useExamConfigStore.getState().userXp; + console.log(userXp); return (
diff --git a/src/pages/student/Rewards.tsx b/src/pages/student/Rewards.tsx index 5ea3366..3cd200e 100644 --- a/src/pages/student/Rewards.tsx +++ b/src/pages/student/Rewards.tsx @@ -67,6 +67,7 @@ export const Rewards = () => { const response = await api.fetchLeaderboard(token); setLeaderboard(response); + setUserXp(response.user_rank.total_xp); setLoading(false); } catch (error) { @@ -320,13 +321,15 @@ export const Rewards = () => {
{isTopThree ? ( {`trophy_${leaderboard?.user_rank?.rank ) : ( - {leaderboard?.user_rank?.rank ?? Infinity} + {(leaderboard?.user_rank?.rank ?? Infinity) - 1} )} diff --git a/src/pages/student/practice/Pretest.tsx b/src/pages/student/practice/Pretest.tsx index d28a07d..50a5936 100644 --- a/src/pages/student/practice/Pretest.tsx +++ b/src/pages/student/practice/Pretest.tsx @@ -13,11 +13,11 @@ import { import { Button } from "../../../components/ui/button"; import { useNavigate } from "react-router-dom"; import { useExamConfigStore } from "../../../stores/useExamConfigStore"; +import { useSatExam } from "../../../stores/useSatExam"; export const Pretest = () => { const { setSheetId, setMode, storeDuration, setQuestionCount } = useExamConfigStore(); - const user = useAuthStore((state) => state.user); const { sheetId } = useParams<{ sheetId: string }>(); const [carouselApi, setCarouselApi] = useState(); diff --git a/src/pages/student/practice/Results.tsx b/src/pages/student/practice/Results.tsx index 915fc97..8abf690 100644 --- a/src/pages/student/practice/Results.tsx +++ b/src/pages/student/practice/Results.tsx @@ -1,12 +1,159 @@ import { useNavigate } from "react-router-dom"; import { Button } from "../../../components/ui/button"; +import { useResults } from "../../../stores/useResults"; +import { useSatExam } from "../../../stores/useSatExam"; +import { LucideArrowLeft } from "lucide-react"; +import { + Card, + CardAction, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../../../components/ui/card"; +import { Progress } from "../../../components/ui/progress"; +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); + + useEffect(() => { + if (!results?.xp_gained) return; + + let startTime: number | null = null; + const duration = 800; // ms + + 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); + }; + + requestAnimationFrame(animate); + }, [results?.xp_gained]); + + return ( + + + XP + How much did you improve? + +

+{displayXP} XP

+
+
+
+ ); +}; export const Results = () => { const navigate = useNavigate(); + const results = useResults((s) => s.results); + const clearResults = useResults((s) => s.clearResults); + + const { setUserXp } = useExamConfigStore(); + + useEffect(() => { + if (results) setUserXp(results?.total_xp); + }, [results]); + + function handleFinishExam() { + clearResults(); + navigate(`/student/home`); + } + + // const [displayXP, setDisplayXP] = useState(0); + + // useEffect(() => { + // if (!results?.score) return; + // let start = 0; + // const duration = 600; + // const startTime = performance.now(); + + // const animate = (time: number) => { + // const t = Math.min((time - startTime) / duration, 1); + // setDisplayXP(Math.floor(t * results.score)); + // if (t < 1) requestAnimationFrame(animate); + // }; + + // requestAnimationFrame(animate); + // }, [results?.score]); + + const previousXP = results ? results.total_xp - results.xp_gained : 0; + return ( -
- Your results go here - -
+
+
+ +

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! + + + +
); }; diff --git a/src/pages/student/practice/Test.tsx b/src/pages/student/practice/Test.tsx index d220802..d963987 100644 --- a/src/pages/student/practice/Test.tsx +++ b/src/pages/student/practice/Test.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { useNavigate } from "react-router-dom"; +import { Navigate, useNavigate } from "react-router-dom"; import { Card, CardContent, @@ -39,6 +39,7 @@ import { } from "../../../components/ui/dialog"; import { useExamNavigationGuard } from "../../../hooks/useExamNavGuard"; import { useExamConfigStore } from "../../../stores/useExamConfigStore"; +import { useResults } from "../../../stores/useResults"; export const Test = () => { const sheetId = localStorage.getItem("activePracticeSheetId"); @@ -79,6 +80,8 @@ export const Test = () => { const goToQuestion = useSatExam((s) => s.goToQuestion); const finishExam = useSatExam((s) => s.finishExam); + const quitExam = useSatExam((s) => s.quitExam); + const setResults = useResults((s) => s.setResults); const startExam = async () => { if (!user || !sheetId) return; @@ -181,6 +184,11 @@ export const Test = () => { if (next?.status === "COMPLETED") { useExamConfigStore.getState().clearPayload(); + console.log(next.results); + setResults(next.results); + + // ✅ Store results first + finishExam(); } else { await loadSessionQuestions(sessionId); @@ -190,14 +198,20 @@ export const Test = () => { const handleQuitExam = () => { useExamConfigStore.getState().clearPayload(); - finishExam(); - navigate("/student/home"); + + quitExam(); }; useEffect(() => { resetExam(); // ✅ important }, [sheetId]); + useEffect(() => { + return () => { + resetExam(); + }; + }, []); + useEffect(() => { if (phase === "FINISHED") { const timer = setTimeout(() => { @@ -402,7 +416,7 @@ export const Test = () => { {currentQuestion?.context && (

- {currentQuestion?.context} + {renderQuestionText(currentQuestion?.context)}

)} @@ -611,6 +625,8 @@ export const Test = () => {

Redirecting to results...

); + case "QUIT": + return ; default: return null; diff --git a/src/pages/student/targeted-practice/page.tsx b/src/pages/student/targeted-practice/page.tsx index da56d5a..fd8aa27 100644 --- a/src/pages/student/targeted-practice/page.tsx +++ b/src/pages/student/targeted-practice/page.tsx @@ -17,8 +17,8 @@ export const TargetedPractice = () => { const navigate = useNavigate(); const { storeTopics, - setDifficulty: storeDifficulty, storeDuration, + setDifficulty: storeDifficulty, setMode, setQuestionCount, } = useExamConfigStore(); @@ -34,7 +34,6 @@ export const TargetedPractice = () => { const [difficulty, setDifficulty] = useState< "EASY" | "MEDIUM" | "HARD" | null >(null); - const [duration, setDuration] = useState(null); const [search, setSearch] = useState(""); const [loading, setLoading] = useState(false); @@ -43,8 +42,6 @@ export const TargetedPractice = () => { const difficulties = ["EASY", "MEDIUM", "HARD"] as const; - const durations = [10, 20, 30, 45]; - const toggleTopic = (topic: Topic) => { setSelectedTopics((prev) => { const exists = prev.some((t) => t.id === topic.id); @@ -58,7 +55,9 @@ export const TargetedPractice = () => { }; async function handleStartTargetedPractice() { - if (!user || !token || !topics || !difficulty || !duration) return; + if (!user || !token || !topics || !difficulty) return; + + storeDuration(10); navigate(`/student/practice/${topics[0].id}/test`, { replace: true }); } @@ -193,36 +192,6 @@ export const TargetedPractice = () => { setDifficulty(d); // local UI storeDifficulty(d); // ✅ STORE setDirection(1); - setStep("duration"); - }} - /> - ))} -
- - )} - - {step === "duration" && ( - -

Select duration

- -
- {durations.map((d) => ( - { - setDuration(d); - storeDuration(d); // ✅ STORE - setDirection(1); setStep("review"); }} /> @@ -252,9 +221,6 @@ export const TargetedPractice = () => {

Difficulty: {difficulty}

-

- Duration: {duration} minutes -

)} @@ -263,7 +229,7 @@ export const TargetedPractice = () => {