import { useEffect, useState, useRef } from "react"; import { Navigate, useNavigate } from "react-router-dom"; import { Binary, Calculator, Check, Loader2, LogOut, Unplug, Play, ChevronLeft, Menu, X, } from "lucide-react"; import { api } from "../../../utils/api"; import { useAuthStore } from "../../../stores/authStore"; import type { Question } from "../../../types/sheet"; import { useSatExam } from "../../../stores/useSatExam"; import { useSatTimer } from "../../../hooks/useSatTimer"; import type { SessionModuleQuestions, SubmitAnswer, } from "../../../types/session"; import { useAuthToken } from "../../../hooks/useAuthToken"; import { renderQuestionText } from "../../../components/RenderQuestionText"; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "../../../components/ui/dropdown-menu"; import { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, 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 { GraphCalculatorModal } from "../../../components/Calculator"; // ─── Shared Style Constants ─────────────────────────────────────────────────── const COLORS = { bg: "#fffbf4", primary: "#a855f7", primaryDark: "#7c3aed", accent: "#f97316", accentDark: "#c2560e", text: "#1e1b4b", textMuted: "#6b7280", textLight: "#9ca3af", border: "#f3f4f6", borderPurple: "#c4b5fd", success: "#22c55e", error: "#ef4444", blob1: "#fde68a", blob2: "#a5f3c0", blob3: "#fbcfe8", blob4: "#bfdbfe", }; const DOTS = [ { size: 12, color: "#f97316", top: "8%", left: "6%", delay: "0s" }, { size: 8, color: "#a855f7", top: "22%", left: "2%", delay: "1s" }, { size: 10, color: "#22c55e", top: "55%", left: "4%", delay: "0.5s" }, { size: 14, color: "#3b82f6", top: "10%", right: "5%", delay: "1.5s" }, { size: 8, color: "#f43f5e", top: "40%", right: "3%", delay: "0.8s" }, { size: 10, color: "#eab308", top: "70%", right: "7%", delay: "0.3s" }, ]; // ─── Global Styles ──────────────────────────────────────────────────────────── const GLOBAL_STYLES = ` @import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap'); .test-screen { min-height: 100vh; background: ${COLORS.bg}; font-family: 'Nunito', sans-serif; position: relative; overflow-x: hidden; } /* ── Blobs ── */ .t-blob { position: fixed; pointer-events: none; z-index: 0; filter: blur(48px); opacity: 0.35; } .t-blob-1 { width: 240px; height: 240px; background: ${COLORS.blob1}; top: -80px; left: -80px; border-radius: 60% 40% 70% 30% / 50% 60% 40% 50%; animation: tWobble1 14s ease-in-out infinite; } .t-blob-2 { width: 190px; height: 190px; background: ${COLORS.blob2}; bottom: -50px; left: 6%; border-radius: 40% 60% 30% 70% / 60% 40% 60% 40%; animation: tWobble2 16s ease-in-out infinite; } .t-blob-3 { width: 210px; height: 210px; background: ${COLORS.blob3}; top: 15%; right: -60px; border-radius: 70% 30% 50% 50% / 40% 60% 40% 60%; animation: tWobble1 18s ease-in-out infinite reverse; } .t-blob-4 { width: 150px; height: 150px; background: ${COLORS.blob4}; bottom: 12%; right: 2%; border-radius: 50% 50% 30% 70% / 60% 40% 60% 40%; animation: tWobble2 12s ease-in-out infinite; } @keyframes tWobble1 { 0%, 100% { border-radius: 60% 40% 70% 30% / 50% 60% 40% 50%; transform: translate(0,0) rotate(0deg); } 50% { border-radius: 40% 60% 30% 70% / 60% 40% 60% 40%; transform: translate(12px, 16px) rotate(8deg); } } @keyframes tWobble2 { 0%, 100% { border-radius: 40% 60% 30% 70% / 60% 40% 60% 40%; transform: translate(0,0) rotate(0deg); } 50% { border-radius: 60% 40% 70% 30% / 40% 60% 40% 60%; transform: translate(-10px, 12px) rotate(-6deg); } } /* ── Floating dots ── */ .t-dot { position: fixed; border-radius: 50%; pointer-events: none; z-index: 0; opacity: 0.3; animation: tFloat 6s ease-in-out infinite; } @keyframes tFloat { 0%, 100% { transform: translateY(0) rotate(0deg); } 50% { transform: translateY(-14px) rotate(180deg); } } /* ── Animations ── */ @keyframes tPopIn { from { opacity: 0; transform: scale(0.92) translateY(10px); } to { opacity: 1; transform: scale(1) translateY(0); } } .t-anim { animation: tPopIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both; } .t-anim-1 { animation-delay: 0.05s; } .t-anim-2 { animation-delay: 0.1s; } .t-anim-3 { animation-delay: 0.15s; } .t-anim-4 { animation-delay: 0.2s; } .t-anim-5 { animation-delay: 0.25s; } /* ── 3D Button Styles ── */ .t-btn-3d { font-family: 'Nunito', sans-serif; font-weight: 800; border: none; border-radius: 100px; cursor: pointer; transition: transform 0.1s ease, box-shadow 0.1s ease; } .t-btn-3d:hover { transform: translateY(-2px); } .t-btn-3d:active { transform: translateY(2px); } .t-btn-primary { background: ${COLORS.primary}; color: white; box-shadow: 0 4px 0 ${COLORS.primaryDark}, 0 6px 16px rgba(168, 85, 247, 0.3); } .t-btn-primary:hover { box-shadow: 0 6px 0 ${COLORS.primaryDark}, 0 10px 20px rgba(168, 85, 247, 0.35); } .t-btn-primary:active { box-shadow: 0 2px 0 ${COLORS.primaryDark}, 0 3px 8px rgba(168, 85, 247, 0.2); } .t-btn-accent { background: ${COLORS.accent}; color: white; box-shadow: 0 4px 0 ${COLORS.accentDark}, 0 6px 16px rgba(249, 115, 22, 0.3); } .t-btn-accent:hover { box-shadow: 0 6px 0 ${COLORS.accentDark}, 0 10px 20px rgba(249, 115, 22, 0.35); } .t-btn-accent:active { box-shadow: 0 2px 0 ${COLORS.accentDark}, 0 3px 8px rgba(249, 115, 22, 0.2); } .t-btn-outline { background: white; color: ${COLORS.text}; border: 2.5px solid ${COLORS.border}; box-shadow: 0 3px 10px rgba(0,0,0,0.06); } .t-btn-outline:hover { border-color: ${COLORS.borderPurple}; box-shadow: 0 6px 14px rgba(0,0,0,0.08); } /* ── Cards ── */ .t-card { background: white; border: 2.5px solid ${COLORS.border}; border-radius: 22px; box-shadow: 0 4px 14px rgba(0,0,0,0.05); } .t-card-purple { border-color: ${COLORS.borderPurple}; box-shadow: 0 4px 16px rgba(167, 139, 250, 0.12); } /* ── Section Title ── */ .t-section-title { font-size: 1.2rem; font-weight: 900; color: ${COLORS.text}; letter-spacing: -0.01em; } /* ── Timer Display ── */ .t-timer { font-size: 2.5rem; font-weight: 900; color: ${COLORS.text}; letter-spacing: -0.03em; line-height: 1; } /* ── Question Number Badge ── */ .t-q-badge { background: linear-gradient(135deg, ${COLORS.primary}, ${COLORS.primaryDark}); color: white; font-weight: 800; font-size: 0.75rem; padding: 0.4rem 0.9rem; border-radius: 100px; } /* ── Option Buttons ── */ .t-option { width: 100%; text-align: start; font-family: 'Nunito', sans-serif; font-weight: 700; font-size: 1rem; padding: 1rem 1.25rem; background: white; border: 2.5px solid ${COLORS.border}; border-radius: 18px; cursor: pointer; transition: all 0.15s ease; display: flex; align-items: center; gap: 0.75rem; } .t-option:hover:not(:disabled) { border-color: ${COLORS.borderPurple}; transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0,0,0,0.06); } .t-option.selected { background: linear-gradient(135deg, ${COLORS.primary}, ${COLORS.primaryDark}); border-color: ${COLORS.primaryDark}; color: white; } .t-option.correct { background: linear-gradient(135deg, #22c55e, #16a34a); border-color: #16a34a; color: white; } .t-option.incorrect { background: linear-gradient(135deg, #ef4444, #dc2626); border-color: #dc2626; color: white; } .t-option.eliminated { opacity: 0.5; text-decoration: line-through; } .t-option-letter { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 50%; font-weight: 800; font-size: 0.85rem; flex-shrink: 0; } .t-option:not(.selected) .t-option-letter { background: ${COLORS.primary}; color: white; } .t-option.selected .t-option-letter { background: white; color: ${COLORS.primary}; } /* ── Eliminate Button ── */ .t-eliminate-btn { width: 32px; height: 32px; border-radius: 50%; border: 2.5px solid ${COLORS.border}; background: white; display: flex; align-items: center; justify-content: center; cursor: pointer; transition: all 0.15s ease; flex-shrink: 0; } .t-eliminate-btn:hover { border-color: #ef4444; background: #fef2f2; } .t-eliminate-btn.active { background: #ef4444; border-color: #ef4444; color: white; } /* ── Textarea ── */ .t-textarea { width: 100%; min-height: 120px; padding: 1rem 1.25rem; background: white; border: 2.5px solid ${COLORS.border}; border-radius: 18px; font-family: 'Nunito', sans-serif; font-size: 1rem; font-weight: 600; color: ${COLORS.text}; resize: vertical; transition: border-color 0.2s ease, box-shadow 0.2s ease; } .t-textarea:focus { outline: none; border-color: ${COLORS.borderPurple}; box-shadow: 0 4px 16px rgba(167, 139, 250, 0.15); } .t-textarea::placeholder { color: ${COLORS.textLight}; } /* ── Retry Banner ── */ .t-retry-banner { background: linear-gradient(90deg, ${COLORS.accent}, #ea580c); color: white; padding: 0.75rem 1rem; font-weight: 700; font-size: 0.9rem; display: flex; align-items: center; justify-content: center; gap: 0.5rem; } /* ── XP Badge ── */ .t-xp-badge { position: absolute; top: -8px; right: -8px; background: linear-gradient(135deg, #fbbf24, #f59e0b); color: white; font-size: 0.7rem; font-weight: 800; padding: 0.25rem 0.6rem; border-radius: 100px; box-shadow: 0 2px 8px rgba(251, 191, 36, 0.4); animation: tBounce 0.5s ease; } @keyframes tBounce { 0%, 100% { transform: scale(1); } 50% { transform: scale(1.2); } } /* ── Drawer Custom Styles ── */ [data-vaul-drawer] { background: white !important; border-top: 2.5px solid ${COLORS.border} !important; border-radius: 24px 24px 0 0 !important; } [data-vaul-drawer-handle] { background: ${COLORS.border} !important; width: 48px !important; height: 5px !important; border-radius: 3px !important; } /* ── Navigator Grid ── */ .t-nav-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 0.5rem; } .t-nav-item { aspect-ratio: 1; display: flex; align-items: center; justify-content: center; border-radius: 12px; font-weight: 800; font-size: 0.9rem; border: 2.5px solid ${COLORS.border}; background: white; cursor: pointer; transition: all 0.15s ease; } .t-nav-item:hover { border-color: ${COLORS.borderPurple}; transform: scale(1.05); } .t-nav-item.current { background: linear-gradient(135deg, ${COLORS.primary}, ${COLORS.primaryDark}); border-color: ${COLORS.primaryDark}; color: white; } .t-nav-item.answered { background: #dcfce7; border-color: #22c55e; color: #16a34a; } /* ── Incorrect Flash ── */ @keyframes tFlashRed { 0%, 100% { background: transparent; } 50% { background: rgba(239, 68, 68, 0.15); } } .t-flash-red { animation: tFlashRed 0.6s ease; } `; // ─── 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; } // ─── 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) => (
))}
); }; 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; return (
+{xp} XP
🎉 CORRECT!
); }; // ─── Main Component ─────────────────────────────────────────────────────────── export const Test = () => { const sheetId = localStorage.getItem("activePracticeSheetId"); const blocker = useExamNavigationGuard(); const [eliminated, setEliminated] = useState>>({}); const [showExitDialog, setShowExitDialog] = useState(false); const [error, setError] = useState(null); const [calcOpen, setCalcOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); useEffect(() => { if (blocker.state === "blocked") setShowExitDialog(true); }, [blocker.state]); const navigate = useNavigate(); const { user } = useAuthStore(); const token = useAuthToken(); const [answers, setAnswers] = useState>({}); const [showNavigator, setShowNavigator] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [sessionId, setSessionId] = useState(null); 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); const incorrectQueueRef = useRef([]); const correctedRef = useRef>(new Set()); 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 = retryMode ? currentModule?.questions[retryQueue[retryIndex]?.originalIndex] : currentModule?.questions[questionIndex]; const currentAnswer = currentQuestion ? (answers[currentQuestion.id] ?? "") : ""; const resetExam = useSatExam((s) => s.resetExam); const nextQuestion = useSatExam((s) => s.nextQuestion); const prevQuestion = useSatExam((s) => s.prevQuestion); 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 [snap, setSnap] = useState(snapPoints[0]); const startExam = async () => { console.log("startExam called", { user, sheetId }); if (!user) { console.warn("Missing user or sheetId"); return; } const payload = useExamConfigStore.getState().payload; console.log("payload", payload); try { const response = await api.startSession(token as string, payload); console.log("session started", response); setSessionId(response.id); await loadSessionQuestions(response.id); useSatExam.getState().startExam(); } catch (error) { setError(`Failed to start exam session: ${error}`); } }; const loadSessionQuestions = async (sessionId: string) => { if (!token) return; try { const data = await api.fetchSessionQuestions(token, sessionId); const module: SessionModuleQuestions = { module_id: data.module_id, module_title: data.module_title, time_limit_minutes: data.time_limit_minutes * 60, questions: data.questions.map((q) => ({ id: q.id, text: q.text, context: q.context, context_image_url: q.context_image_url, type: q.type, section: q.section, image_url: q.image_url, index: q.index, difficulty: q.difficulty, correct_answer: q.correct_answer, explanation: q.explanation, topics: q.topics, options: q.options, })), }; useSatExam.getState().setModuleQuestions(module); } catch (err) { console.error("Failed to load session questions:", err); } }; 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); } }; const advanceRetry = async () => { setFeedback(null); const nextRetryIndex = retryIndex + 1; const remaining = retryQueue.filter( (e) => !correctedRef.current.has(e.questionId), ); if (remaining.length === 0) { const next = await api.fetchNextModule(token!, sessionId); if (next.status === "COMPLETED") finishExam(); return; } if (nextRetryIndex >= retryQueue.length) { 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); } }; const submitTargetedAnswer = async () => { if (!currentQuestion || !sessionId) return; const userAnswer = answers[currentQuestion.id] ?? ""; let answerText = ""; if (currentQuestion.options?.length) { const selected = currentQuestion.options.find( (opt) => opt.id === userAnswer, ); answerText = selected?.text ?? ""; } else { answerText = userAnswer; } const 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; showAnswerFeedback(isCorrect, xpGained, userAnswer); if (isCorrect) { correctedRef.current.add(currentQuestion.id); incorrectQueueRef.current = incorrectQueueRef.current.filter( (e) => e.questionId !== currentQuestion.id, ); } else { const alreadyTracked = incorrectQueueRef.current.some( (e) => e.questionId === currentQuestion.id, ); if (!alreadyTracked) { incorrectQueueRef.current.push({ questionId: currentQuestion.id, originalIndex: retryMode ? retryQueue[retryIndex].originalIndex : questionIndex, }); } correctedRef.current.delete(currentQuestion.id); } const delay = isCorrect ? 2200 : 1500; setTimeout(async () => { setFeedback(null); if (retryMode) { advanceRetry(); return; } const isLastQuestion = questionIndex === currentModule!.questions.length - 1; if (!isLastQuestion) { nextQuestion(); return; } if (incorrectQueueRef.current.length > 0) { const queue = [...incorrectQueueRef.current]; setRetryQueue(queue); setRetryIndex(0); setRetryMode(true); goToQuestion(queue[0].originalIndex); return; } await proceedAfterLastQuestion(); }, delay); } 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(); } }; const handleNext = async () => { if (isTargeted) { await submitTargetedAnswer(); return; } if (!currentQuestion || !sessionId) return; const userAnswer = answers[currentQuestion.id] ?? ""; let answerText = ""; if (currentQuestion.options?.length) { const selected = currentQuestion.options.find( (opt) => opt.id === userAnswer, ); answerText = selected?.text ?? ""; } else { answerText = userAnswer; } const payload: SubmitAnswer = { question_id: currentQuestion.id, answer_text: answerText, time_spent_seconds: 3, }; try { setIsSubmitting(true); await api.submitAnswer(token!, sessionId, payload); } catch (err) { console.error("Failed to submit answer:", err); } finally { setIsSubmitting(false); } const isLastQuestion = questionIndex === currentModule!.questions.length - 1; if (!isLastQuestion) { nextQuestion(); return; } const next = await api.fetchNextModule(token!, sessionId); if (next?.status === "COMPLETED") { setResults(next.results); finishExam(); } else { await loadSessionQuestions(sessionId); useSatExam.getState().startBreak(); } }; const handleQuitExam = () => { useExamConfigStore.getState().clearPayload(); quitExam(); }; useEffect(() => { return () => { resetExam(); }; }, []); useEffect(() => { if (phase === "FINISHED") { const timer = setTimeout(() => { navigate(`/student/practice/${sheetId}/test/results`, { replace: true, }); }, 3000); return () => clearTimeout(timer); } }, [phase]); useEffect(() => { if (!user) return; }, [sheetId]); const isFirstQuestion = retryMode ? true : questionIndex === 0; const toggleEliminate = (questionId: string, optionId: string) => { setEliminated((prev) => { const current = new Set(prev[questionId] ?? []); current.has(optionId) ? current.delete(optionId) : current.add(optionId); return { ...prev, [questionId]: current }; }); }; // ─── 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; let optionClass = "t-option"; if (isSelected) optionClass += " selected"; if (isEliminated && !feedbackLocked) optionClass += " eliminated"; if (feedbackLocked && isSelected) { optionClass += feedback.correct ? " correct" : " incorrect"; } return (
{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 (