diff --git a/package.json b/package.json index 159657e..7b37335 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-table": "^8.21.3", + "canvas-confetti": "^1.9.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "embla-carousel-react": "^8.6.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d26eb1f..5c5aacf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + canvas-confetti: + specifier: ^1.9.4 + version: 1.9.4 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -1544,6 +1547,9 @@ packages: caniuse-lite@1.0.30001762: resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==} + canvas-confetti@1.9.4: + resolution: {integrity: sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==} + chalk@4.1.2: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} @@ -3706,6 +3712,8 @@ snapshots: caniuse-lite@1.0.30001762: {} + canvas-confetti@1.9.4: {} + chalk@4.1.2: dependencies: ansi-styles: 4.3.0 diff --git a/src/components/Calculator.tsx b/src/components/Calculator.tsx new file mode 100644 index 0000000..f7b702b --- /dev/null +++ b/src/components/Calculator.tsx @@ -0,0 +1,250 @@ +import { useEffect, useRef, useState, useCallback } from "react"; +import { createPortal } from "react-dom"; +import { X, Calculator, Maximize2, Minimize2 } from "lucide-react"; + +// ─── GeoGebra type shim ─────────────────────────────────────────────────────── +declare global { + interface Window { + GGBApplet: new ( + params: Record, + defer?: boolean, + ) => { + inject: (containerId: string) => void; + }; + ggbApplet?: { + reset: () => void; + setXML: (xml: string) => void; + }; + } +} + +// ─── Hook: load GeoGebra script once ───────────────────────────────────────── +const GEOGEBRA_SCRIPT = "https://www.geogebra.org/apps/deployggb.js"; + +const useGeoGebraScript = () => { + const [ready, setReady] = useState(false); + + useEffect(() => { + if (document.querySelector(`script[src="${GEOGEBRA_SCRIPT}"]`)) { + if (window.GGBApplet) setReady(true); + return; + } + const script = document.createElement("script"); + script.src = GEOGEBRA_SCRIPT; + script.async = true; + script.onload = () => setReady(true); + document.head.appendChild(script); + }, []); + + return ready; +}; + +// ─── GeoGebra Calculator ────────────────────────────────────────────────────── +const GeoGebraCalculator = ({ containerId }: { containerId: string }) => { + const scriptReady = useGeoGebraScript(); + const injected = useRef(false); + const wrapperRef = useRef(null); + const [dims, setDims] = useState<{ w: number; h: number } | null>(null); + + // Measure the wrapper first — GeoGebra needs explicit px dimensions + useEffect(() => { + const el = wrapperRef.current; + if (!el) return; + const ro = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + if (width > 0 && height > 0) { + setDims({ w: Math.floor(width), h: Math.floor(height) }); + } + } + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + useEffect(() => { + if (!scriptReady || !dims || injected.current) return; + injected.current = true; + + const params = { + appName: "graphing", + width: dims.w, + height: dims.h, + showToolBar: true, + showAlgebraInput: true, + showMenuBar: false, + enableLabelDrags: true, + enableShiftDragZoom: true, + enableRightClick: true, + showZoomButtons: true, + capturingThreshold: null, + showFullscreenButton: false, + + scale: 1, + disableAutoScale: false, + allowUpscale: false, + clickToLoad: false, + appletOnLoad: () => {}, + useBrowserForJS: false, + showLogging: false, + errorDialogsActive: true, + showTutorialLink: false, + showSuggestionButtons: false, + language: "en", + id: "ggbApplet", + }; + + try { + const applet = new window.GGBApplet(params, true); + applet.inject(containerId); + } catch (e) { + console.error("GeoGebra init error:", e); + } + }, [scriptReady, dims, containerId]); + + return ( +
+ {!dims && ( +
+ + + + + Loading calculator... +
+ )} +
+
+ ); +}; + +// ─── Modal ──────────────────────────────────────────────────────────────────── +interface GraphCalculatorModalProps { + open: boolean; + onClose: () => void; +} + +const GraphCalculatorModal = ({ open, onClose }: GraphCalculatorModalProps) => { + const [fullscreen, setFullscreen] = useState(false); + const containerId = "geogebra-container"; + + // Trap focus & keyboard dismiss + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, onClose]); + + // Prevent body scroll while open + useEffect(() => { + document.body.style.overflow = open ? "hidden" : ""; + return () => { + document.body.style.overflow = ""; + }; + }, [open]); + + if (!open) return null; + + return createPortal( +
+ {/* Backdrop */} +
+ + {/* Panel */} +
e.stopPropagation()} + > + {/* Header */} +
+
+
+ +
+ + Graph Calculator + + + Powered by GeoGebra + +
+ +
+ + +
+
+ + {/* GeoGebra canvas area */} +
+ +
+
+
, + document.body, + ); +}; + +// ─── Trigger button + modal — drop this wherever you need it ────────────────── +export const GraphCalculatorButton = () => { + const [open, setOpen] = useState(false); + + return ( + <> + + + setOpen(false)} /> + + ); +}; + +// ─── Standalone modal export if you need to control it externally ───────────── +export { GraphCalculatorModal }; diff --git a/src/components/ChoiceCard.tsx b/src/components/ChoiceCard.tsx index 0055a72..651d542 100644 --- a/src/components/ChoiceCard.tsx +++ b/src/components/ChoiceCard.tsx @@ -16,7 +16,7 @@ export const ChoiceCard = ({ + + +
+ + {/* Intersection tooltip */} + {tooltip && ( +
+
+
+ Intersection +
+
+ x ={" "} + + {fmt(tooltip.mathX)} + +
+
+ y ={" "} + + {fmt(tooltip.mathY)} + +
+
+ eq {tooltip.eqA + 1} ∩ eq {tooltip.eqB + 1} +
+
+ {/* Arrow */} +
+
+
+
+ )} + + {/* Dismiss tooltip on background click hint */} + {tooltip && ( +
+ ); +}; diff --git a/src/components/PredictedScoreCard.tsx b/src/components/PredictedScoreCard.tsx new file mode 100644 index 0000000..0fec882 --- /dev/null +++ b/src/components/PredictedScoreCard.tsx @@ -0,0 +1,305 @@ +import { useEffect, useState } from "react"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "../components/ui/card"; +import { api } from "../utils/api"; +import { useAuthToken } from "../hooks/useAuthToken"; +import { + TrendingUp, + BookOpen, + Calculator, + Loader2, + ChevronDown, + ChevronUp, +} from "lucide-react"; + +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface SectionPrediction { + score: number; + range_min: number; + range_max: number; + confidence: string; +} + +interface PredictedScoreResponse { + total_score: number; + math_prediction: SectionPrediction; + rw_prediction: SectionPrediction; +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +const confidenceConfig: Record< + string, + { label: string; color: string; bg: string; dot: string } +> = { + high: { + label: "High confidence", + color: "text-emerald-700", + bg: "bg-emerald-50 border-emerald-200", + dot: "bg-emerald-500", + }, + medium: { + label: "Medium confidence", + color: "text-amber-700", + bg: "bg-amber-50 border-amber-200", + dot: "bg-amber-400", + }, + low: { + label: "Low confidence", + color: "text-rose-700", + bg: "bg-rose-50 border-rose-200", + dot: "bg-rose-400", + }, +}; + +const getConfidenceStyle = (confidence: string) => + confidenceConfig[confidence.toLowerCase()] ?? { + label: confidence, + color: "text-gray-600", + bg: "bg-gray-50 border-gray-200", + dot: "bg-gray-400", + }; + +const useCountUp = (target: number, duration = 900) => { + const [value, setValue] = useState(0); + useEffect(() => { + if (!target) return; + let start: number | null = null; + const step = (ts: number) => { + if (!start) start = ts; + const progress = Math.min((ts - start) / duration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + setValue(Math.floor(eased * target)); + if (progress < 1) requestAnimationFrame(step); + }; + requestAnimationFrame(step); + }, [target, duration]); + return value; +}; + +// ─── Expanded section detail ────────────────────────────────────────────────── + +const SectionDetail = ({ + label, + icon: Icon, + prediction, + accentClass, +}: { + label: string; + icon: React.ElementType; + prediction: SectionPrediction; + accentClass: string; +}) => { + const conf = getConfidenceStyle(prediction.confidence); + return ( +
+
+
+
+ +
+ + {label} + +
+ + + {conf.label} + +
+ +
+ + {prediction.score} + + + Range:{" "} + + {prediction.range_min}–{prediction.range_max} + + +
+ + {/* Range bar */} +
+
+
+
+
+ 200 + 800 +
+
+ ); +}; + +// ─── Main component ─────────────────────────────────────────────────────────── + +export const PredictedScoreCard = () => { + const token = useAuthToken(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expanded, setExpanded] = useState(false); + + useEffect(() => { + if (!token) return; + (async () => { + try { + setLoading(true); + const result = await api.fetchPredictedScore(token); + setData(result); + } catch (err) { + setError("Couldn't load your predicted score."); + console.error(err); + } finally { + setLoading(false); + } + })(); + }, [token]); + + const animatedTotal = useCountUp(data?.total_score ?? 0, 1000); + + return ( + + +
+
+ + Predicted SAT Score + + + Based on your practice performance + +
+
+ +
+
+
+ + + {loading && ( +
+ +
+ )} + + {error && !loading && ( +

+ {error} +

+ )} + + {data && !loading && ( + <> + {/* ── Collapsed view: big numbers only ── */} +
+ {/* Total */} +
+ + Total + + + {animatedTotal} + + + out of 1600 + +
+ +
+ + {/* Math */} +
+
+ + + Math + +
+ + {data.math_prediction.score} + + + out of 800 + +
+ +
+ + {/* R&W */} +
+
+ + + R&W + +
+ + {data.rw_prediction.score} + + + out of 800 + +
+
+ + {/* ── Expand toggle ── */} + + + {/* ── Expanded: range bars + confidence ── */} + {expanded && ( +
+ + +
+ )} + + )} + + + ); +}; diff --git a/src/pages/student/Home.tsx b/src/pages/student/Home.tsx index 9fd7a0c..13bcc35 100644 --- a/src/pages/student/Home.tsx +++ b/src/pages/student/Home.tsx @@ -6,7 +6,7 @@ import { TabsContent, } from "../../components/ui/tabs"; import { useAuthStore } from "../../stores/authStore"; -import { CheckCircle, Search } from "lucide-react"; +import { CheckCircle, Flame, Search, Zap } from "lucide-react"; import { api } from "../../utils/api"; import { Card, @@ -22,10 +22,18 @@ import type { PracticeSheet } from "../../types/sheet"; import { formatStatus } from "../../lib/utils"; import { useNavigate } from "react-router-dom"; import { SearchOverlay } from "../../components/SearchOverlay"; +import { PredictedScoreCard } from "../../components/PredictedScoreCard"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "../../components/ui/avatar"; +import { useExamConfigStore } from "../../stores/useExamConfigStore"; export const Home = () => { const user = useAuthStore((state) => state.user); const navigate = useNavigate(); + const userXp = useExamConfigStore.getState().userXp; const [practiceSheets, setPracticeSheets] = useState([]); const [notStartedSheets, setNotStartedSheets] = useState([]); @@ -84,10 +92,42 @@ export const Home = () => { }; return ( -
-

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

+
+
+
+ + + + {user?.name.slice(0, 1)} + + +
+

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

+

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

+
+
+
+
+ + + 5 +
+
+ + + {userXp} +
+
+
+

What are you looking for?

@@ -111,7 +151,7 @@ export const Home = () => { inProgressSheets.map((sheet) => ( @@ -122,7 +162,7 @@ export const Home = () => { -

+

{formatStatus(sheet?.user_status)}

{ @@ -161,19 +201,19 @@ export const Home = () => { All Not Started Completed @@ -211,7 +251,7 @@ export const Home = () => { @@ -256,7 +296,7 @@ export const Home = () => { @@ -297,7 +337,7 @@ export const Home = () => { @@ -322,29 +362,29 @@ export const Home = () => {
- +

Practice regularly with official SAT materials

- +

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

diff --git a/src/pages/student/Practice.tsx b/src/pages/student/Practice.tsx index 751d0a0..ef00213 100644 --- a/src/pages/student/Practice.tsx +++ b/src/pages/student/Practice.tsx @@ -27,17 +27,17 @@ export const Practice = () => { return (
-
+
-
-
+
+
{userXp}
diff --git a/src/pages/student/Profile.tsx b/src/pages/student/Profile.tsx index b650ba8..b03e638 100644 --- a/src/pages/student/Profile.tsx +++ b/src/pages/student/Profile.tsx @@ -63,7 +63,7 @@ export const Profile = () => {
diff --git a/src/pages/student/Rewards.tsx b/src/pages/student/Rewards.tsx index 6c8bfdc..36ccbba 100644 --- a/src/pages/student/Rewards.tsx +++ b/src/pages/student/Rewards.tsx @@ -32,7 +32,7 @@ import { AvatarFallback, AvatarImage, } from "../../components/ui/avatar"; -import { Zap } from "lucide-react"; +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"; @@ -42,6 +42,7 @@ import { useExamConfigStore } from "../../stores/useExamConfigStore"; export const Rewards = () => { const user = useAuthStore((state) => state.user); const [time, setTime] = useState("bottom"); + const [activeTab, setActiveTab] = useState("xp"); const [leaderboard, setLeaderboard] = useState(); const [loading, setLoading] = useState(false); @@ -94,7 +95,7 @@ export const Rewards = () => { ) : (

Don't stop now! You're{" "} - + #{leaderboard?.user_rank.rank} {" "} in XP. @@ -103,6 +104,8 @@ export const Rewards = () => {

@@ -198,7 +201,7 @@ export const Rewards = () => {

{user.total_xp}

- +
); @@ -295,7 +298,7 @@ export const Rewards = () => {
- + {loading ? (
@@ -332,9 +335,9 @@ export const Rewards = () => { {(leaderboard?.user_rank?.rank ?? Infinity) - 1} )} - + - + {leaderboard?.user_rank.name.slice(0, 1).toUpperCase()} @@ -345,9 +348,20 @@ export const Rewards = () => {

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

- + + {activeTab === "xp" ? ( + + ) : activeTab === "questions" ? ( + + ) : ( + + )}
)} diff --git a/src/pages/student/StudentLayout.tsx b/src/pages/student/StudentLayout.tsx index 112253b..293ae96 100644 --- a/src/pages/student/StudentLayout.tsx +++ b/src/pages/student/StudentLayout.tsx @@ -14,14 +14,18 @@ export function StudentLayout() { return ( -
+
{/* Desktop Sidebar */} -
- - -
+
+ +
+ +
+
+ + {/* Mobile bottom nav */}
+ ); +}; + +// ─── Main Results ───────────────────────────────────────────────────────────── export const Results = () => { const navigate = useNavigate(); const results = useResults((s) => s.results); const clearResults = useResults((s) => s.clearResults); - const { setUserXp } = useExamConfigStore(); + const { setUserXp, payload } = useExamConfigStore(); + const isTargeted = payload?.mode === "TARGETED"; useEffect(() => { if (results) setUserXp(results?.total_xp); }, [results]); function handleFinishExam() { + useExamConfigStore.getState().clearPayload(); 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]); + // ── Targeted mode: show static screen ────────────────────────────────────── + if (isTargeted) { + return ; + } + // ── Standard mode ────────────────────────────────────────────────────────── const previousXP = results ? results.total_xp - results.xp_gained : 0; return ( @@ -107,11 +204,6 @@ export const Results = () => {
{results && ( ; + +// ─── Confetti particle type ─────────────────────────────────────────────────── +interface ConfettiParticle { + id: number; + x: number; + color: string; + size: number; + delay: number; + duration: number; + rotation: number; +} + +// ─── Answer feedback state ──────────────────────────────────────────────────── +type FeedbackState = { + show: boolean; + correct: boolean; + xpGained?: number; + selectedOptionId?: string; +} | null; + +// ─── Targeted incorrect queue ───────────────────────────────────────────────── +interface IncorrectEntry { + questionId: string; + originalIndex: number; // index in currentModule.questions +} + +// ─── Confetti Component ─────────────────────────────────────────────────────── +const Confetti = ({ active }: { active: boolean }) => { + const [particles, setParticles] = useState([]); + + useEffect(() => { + if (!active) { + setParticles([]); + return; + } + const colors = [ + "#a855f7", + "#ec4899", + "#f59e0b", + "#10b981", + "#3b82f6", + "#f97316", + "#eab308", + ]; + const newParticles: ConfettiParticle[] = Array.from( + { length: 60 }, + (_, i) => ({ + id: i, + x: Math.random() * 100, + color: colors[Math.floor(Math.random() * colors.length)], + size: Math.random() * 8 + 4, + delay: Math.random() * 0.5, + duration: Math.random() * 1.5 + 1.5, + rotation: Math.random() * 360, + }), + ); + setParticles(newParticles); + }, [active]); + + if (!active || particles.length === 0) return null; + + return ( +
+ + {particles.map((p) => ( +
+ ))} +
+ ); +}; + +// ─── XP Popup Component ─────────────────────────────────────────────────────── +const XPPopup = ({ xp, show }: { xp: number; show: boolean }) => { + if (!show) return null; + return ( +
+ +
+
+ +{xp} XP +
+
+ 🎉 CORRECT! +
+
+
+ ); +}; + +// ─── 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"); const blocker = useExamNavigationGuard(); const [eliminated, setEliminated] = useState>>({}); - const [showExitDialog, setShowExitDialog] = useState(false); const [error, setError] = useState(null); + const [calcOpen, setCalcOpen] = useState(false); useEffect(() => { if (blocker.state === "blocked") { @@ -60,16 +232,37 @@ export const Test = () => { const token = useAuthToken(); const [answers, setAnswers] = useState>({}); const [showNavigator, setShowNavigator] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); const [sessionId, setSessionId] = useState(null); + // ─── TARGETED mode state ─────────────────────────────────────────────────── + const examMode = useExamConfigStore((s) => s.payload?.mode); + const isTargeted = examMode === "TARGETED"; + + const [feedback, setFeedback] = useState(null); + 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); + const time = useSatTimer(); const phase = useSatExam((s) => s.phase); const currentModule = useSatExam((s) => s.currentModuleQuestions); const questionIndex = useSatExam((s) => s.questionIndex); - const currentQuestion = currentModule?.questions[questionIndex]; + // In retry mode, compute the current question from retry queue + const currentQuestion = retryMode + ? currentModule?.questions[retryQueue[retryIndex]?.originalIndex] + : currentModule?.questions[questionIndex]; + const currentAnswer = currentQuestion ? (answers[currentQuestion.id] ?? "") : ""; @@ -78,24 +271,17 @@ export const Test = () => { const nextQuestion = useSatExam((s) => s.nextQuestion); const prevQuestion = useSatExam((s) => s.prevQuestion); 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; - const payload = useExamConfigStore.getState().payload; - try { const response = await api.startSession(token as string, payload); - setSessionId(response.id); - await loadSessionQuestions(response.id); - - // ✅ NOW start module phase useSatExam.getState().startExam(); } catch (error) { setError(`Failed to start exam session: ${error}`); @@ -105,10 +291,8 @@ export const Test = () => { const loadSessionQuestions = async (sessionId: string) => { if (!token) return; - try { const data = await api.fetchSessionQuestions(token, sessionId); - const module: SessionModuleQuestions = { module_id: data.module_id, module_title: data.module_title, @@ -129,38 +313,191 @@ export const Test = () => { options: q.options, })), }; - useSatExam.getState().setModuleQuestions(module); } catch (err) { console.error("Failed to load session questions:", err); } }; - const handleNext = async () => { + // ─── Show feedback for TARGETED mode ────────────────────────────────────── + const showAnswerFeedback = ( + correct: boolean, + xpGained?: number, + selectedOptionId?: string, + ) => { + setFeedback({ show: true, correct, xpGained, selectedOptionId }); + if (correct) { + setShowConfetti(true); + setTimeout(() => setShowConfetti(false), 2500); + } else { + setShowIncorrectFlash(true); + setTimeout(() => setShowIncorrectFlash(false), 1300); + } + }; + + // ─── 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(); + } + 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), + ); + setRetryQueue(stillIncorrect); + setRetryIndex(0); + goToQuestion(stillIncorrect[0].originalIndex); + } else { + const nextEntry = retryQueue[nextRetryIndex]; + setRetryIndex(nextRetryIndex); + goToQuestion(nextEntry.originalIndex); + } + }; + + // ─── Submit answer with TARGETED feedback ───────────────────────────────── + const submitTargetedAnswer = async () => { if (!currentQuestion || !sessionId) return; const userAnswer = answers[currentQuestion.id] ?? ""; - let answerText = ""; - - // ✅ MCQ case if (currentQuestion.options?.length) { const selected = currentQuestion.options.find( (opt) => opt.id === userAnswer, ); answerText = selected?.text ?? ""; - } - // ✅ Text input case - else { + } else { answerText = userAnswer; } const payload: SubmitAnswer = { question_id: currentQuestion.id, - answer_text: answerText, // ✅ empty string if skipped + 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, + ); + if (!alreadyTracked) { + incorrectQueueRef.current.push({ + questionId: currentQuestion.id, + originalIndex: retryMode + ? retryQueue[retryIndex].originalIndex + : 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); + setRetryMode(true); + goToQuestion(queue[0].originalIndex); + return; + } + + // All correct — proceed to next module or finish + await proceedAfterLastQuestion(); + }, delay); + } catch (err) { + console.error("Failed to submit answer:", err); + } finally { + setIsSubmitting(false); + } + }; + + const proceedAfterLastQuestion = async () => { + if (!sessionId) return; + const next = await api.fetchNextModule(token!, sessionId); + if (next?.status === "COMPLETED") { + setResults(next.results); + finishExam(); + } else { + await loadSessionQuestions(sessionId); + useSatExam.getState().startBreak(); + } + }; + + // ─── Standard handleNext (non-TARGETED) ─────────────────────────────────── + const handleNext = async () => { + if (isTargeted) { + await submitTargetedAnswer(); + return; + } + + if (!currentQuestion || !sessionId) return; + const userAnswer = answers[currentQuestion.id] ?? ""; + let answerText = ""; + if (currentQuestion.options?.length) { + const selected = currentQuestion.options.find( + (opt) => opt.id === userAnswer, + ); + answerText = selected?.text ?? ""; + } else { + answerText = userAnswer; + } + const payload: SubmitAnswer = { + question_id: currentQuestion.id, + answer_text: answerText, + time_spent_seconds: 3, + }; try { setIsSubmitting(true); await api.submitAnswer(token!, sessionId, payload); @@ -169,26 +506,16 @@ export const Test = () => { } finally { setIsSubmitting(false); } - const isLastQuestion = questionIndex === currentModule!.questions.length - 1; - - // ✅ Move to next question if (!isLastQuestion) { nextQuestion(); return; } - - // ✅ Module finished → ask backend for next module const next = await api.fetchNextModule(token!, sessionId); if (next?.status === "COMPLETED") { - useExamConfigStore.getState().clearPayload(); - console.log(next.results); setResults(next.results); - - // ✅ Store results first - finishExam(); } else { await loadSessionQuestions(sessionId); @@ -198,14 +525,9 @@ export const Test = () => { const handleQuitExam = () => { useExamConfigStore.getState().clearPayload(); - quitExam(); }; - useEffect(() => { - resetExam(); // ✅ important - }, [sheetId]); - useEffect(() => { return () => { resetExam(); @@ -219,7 +541,6 @@ export const Test = () => { replace: true, }); }, 3000); - return () => clearTimeout(timer); } }, [phase]); @@ -228,12 +549,47 @@ export const Test = () => { if (!user) return; }, [sheetId]); - const isFirstQuestion = questionIndex === 0; + 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) { + 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) { + 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; - // ✅ MCQ if (question.options && question.options.length > 0) { const eliminatedSet = eliminated[question.id] ?? new Set(); return ( @@ -241,54 +597,61 @@ export const Test = () => { {question.options.map((option, index) => { const isSelected = currentAnswer === option.id; const isEliminated = eliminatedSet.has(option.id); + const feedbackLocked = isTargeted && feedback?.show; return (
- - + ✕ + + )} +
+ + + {/* XP badge on correct option after feedback */} + {isTargeted && + feedback?.show && + feedback.correct && + feedback.xpGained && + isSelected && ( +
+ +{feedback.xpGained} XP ⭐ +
+ )} +
); })} @@ -296,19 +659,16 @@ export const Test = () => { ); } - // ✅ SHORT ANSWER (text input) return (