import { useEffect, useState, useRef } from "react"; import { Navigate, useNavigate } from "react-router-dom"; import { BlockMath, InlineMath } from "react-katex"; import { Binary, Calculator, Check, Loader2, LogOut, Unplug, Play, ChevronLeft, Menu, X, Bookmark, BookMarked, BookOpen, ZoomIn, ZoomOut, Eye, EyeOff, Highlighter, } 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'); :root { --content-max: 1100px; } .test-screen { min-height: 100vh; background: ${COLORS.bg}; font-family: 'Nunito', sans-serif; position: relative; overflow-x: hidden; } .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);} } .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);} } @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; } /* 3D buttons */ .t-btn-3d { font-family:'Nunito',sans-serif;font-weight:800;border:none;border-radius:100px;cursor:pointer;transition:transform 0.1s ease,box-shadow 0.1s ease; } .t-btn-3d:hover { transform:translateY(-2px); } .t-btn-3d:active { transform:translateY(2px); } .t-btn-primary { background:${COLORS.primary};color:white;box-shadow:0 4px 0 ${COLORS.primaryDark},0 6px 16px rgba(168,85,247,0.3); } .t-btn-primary:hover { box-shadow:0 6px 0 ${COLORS.primaryDark},0 10px 20px rgba(168,85,247,0.35); } .t-btn-primary:active { box-shadow:0 2px 0 ${COLORS.primaryDark},0 3px 8px rgba(168,85,247,0.2); } .t-btn-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); } /* Bookmark button */ .t-bookmark-btn { display:flex;align-items:center;justify-content:center; width:44px;height:44px;border-radius:50%;border:2.5px solid ${COLORS.border}; background:white;cursor:pointer; box-shadow:0 3px 10px rgba(0,0,0,0.06); transition:all 0.15s cubic-bezier(0.34,1.56,0.64,1); flex-shrink:0; } .t-bookmark-btn:hover { border-color:#fde68a;background:#fffbeb;transform:scale(1.08); } .t-bookmark-btn:active { transform:scale(0.92); } .t-bookmark-btn.marked { background:linear-gradient(135deg,#fbbf24,#f59e0b); border-color:#f59e0b; box-shadow:0 4px 0 #d97706,0 6px 14px rgba(251,191,36,0.35); } .t-bookmark-btn.marked:hover { box-shadow:0 6px 0 #d97706,0 8px 18px rgba(251,191,36,0.4); } /* 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); } .t-section-title { font-size:1.2rem;font-weight:900;color:${COLORS.text};letter-spacing:-0.01em; } .t-timer { font-size:2.5rem;font-weight:900;color:${COLORS.text};letter-spacing:-0.03em;line-height:1; } .t-q-badge { background:linear-gradient(135deg,${COLORS.primary},${COLORS.primaryDark});color:white;font-weight:800;font-size:0.75rem;padding:0.4rem 0.9rem;border-radius:100px; } /* Mark-for-review badge on q-badge */ .t-q-badge.reviewing { background:linear-gradient(135deg,#fbbf24,#f59e0b); } /* Options */ .t-option { width:100%;text-align:start;font-family:'Nunito',sans-serif;font-weight:700;font-size:1rem; padding:1rem 1.25rem;background:white;border:2.5px solid ${COLORS.border};border-radius:18px; cursor:pointer;transition:all 0.15s ease;display:flex;align-items:center;gap:0.75rem; } .t-option:hover:not(:disabled) { border-color:${COLORS.borderPurple};transform:translateY(-1px);box-shadow:0 4px 12px rgba(0,0,0,0.06); } .t-option.selected { background:linear-gradient(135deg,${COLORS.primary},${COLORS.primaryDark});border-color:${COLORS.primaryDark};color:white; } .t-option.correct { background:linear-gradient(135deg,#22c55e,#16a34a);border-color:#16a34a;color:white; } .t-option.incorrect { background:linear-gradient(135deg,#ef4444,#dc2626);border-color:#dc2626;color:white; } .t-option.eliminated { opacity:0.5;text-decoration:line-through; } .t-option-letter { width:28px;height:28px;display:flex;align-items:center;justify-content:center;border-radius:50%;font-weight:800;font-size:0.85rem;flex-shrink:0; } .t-option:not(.selected) .t-option-letter { background:${COLORS.primary};color:white; } .t-option.selected .t-option-letter { background:white;color:${COLORS.primary}; } .t-eliminate-btn { width:32px;height:32px;border-radius:50%;border:2.5px solid ${COLORS.border};background:white;display:flex;align-items:center;justify-content:center;cursor:pointer;transition:all 0.15s ease;flex-shrink:0; } .t-eliminate-btn:hover { border-color:#ef4444;background:#fef2f2; } .t-eliminate-btn.active { background:#ef4444;border-color:#ef4444;color:white; } .t-textarea { width:100%;min-height:120px;padding:1rem 1.25rem;background:white;border:2.5px solid ${COLORS.border};border-radius:18px;font-family:'Nunito',sans-serif;font-size:1rem;font-weight:600;color:${COLORS.text};resize:vertical;transition:border-color 0.2s ease,box-shadow 0.2s ease; } .t-textarea:focus { outline:none;border-color:${COLORS.borderPurple};box-shadow:0 4px 16px rgba(167,139,250,0.15); } .t-textarea::placeholder { color:${COLORS.textLight}; } /* Retry banner */ .t-retry-banner { background:linear-gradient(90deg,${COLORS.accent},#ea580c);position:fixed;width:100%;color:white;padding:0.75rem 1rem;font-weight:700;font-size:0.9rem;display:flex;align-items:center;justify-content:center;gap:0.5rem; } /* XP badge */ .t-xp-badge { position:absolute;top:-8px;right:-8px;background:linear-gradient(135deg,#fbbf24,#f59e0b);color:white;font-size:0.7rem;font-weight:800;padding:0.25rem 0.6rem;border-radius:100px;box-shadow:0 2px 8px rgba(251,191,36,0.4);animation:tBounce 0.5s ease; } @keyframes tBounce { 0%,100%{transform:scale(1);}50%{transform:scale(1.2);} } /* Vaul drawer */ [data-vaul-drawer] { background:white !important;border-top:2.5px solid ${COLORS.border} !important;border-radius:24px 24px 0 0 !important; } [data-vaul-drawer-handle] { background:${COLORS.border} !important;width:48px !important;height:5px !important;border-radius:3px !important; } /* Navigator grid */ .t-nav-grid { display:grid;grid-template-columns:repeat(5,1fr);gap:0.5rem; } .t-nav-item { aspect-ratio:1;display:flex;align-items:center;justify-content:center;border-radius:12px;font-weight:800;font-size:0.9rem;border:2.5px solid ${COLORS.border};background:white;cursor:pointer;transition:all 0.15s ease;position:relative; } .t-nav-item:hover { border-color:${COLORS.borderPurple};transform:scale(1.05); } .t-nav-item.current { background:linear-gradient(135deg,${COLORS.primary},${COLORS.primaryDark});border-color:${COLORS.primaryDark};color:white; } .t-nav-item.answered { background:#dcfce7;border-color:#22c55e;color:#16a34a; } .t-nav-item.marked { background:#fffbeb;border-color:#f59e0b;color:#d97706; } .t-nav-item.current.marked { background:linear-gradient(135deg,#fbbf24,#f59e0b);border-color:#d97706;color:white; } /* Bookmark dot indicator on nav item */ .t-nav-bookmark-dot { position:absolute;top:-4px;right:-4px; width:10px;height:10px;border-radius:50%; background:#f59e0b;border:2px solid white; } /* Review warning dialog custom */ .t-review-warning { background:linear-gradient(135deg,#fffbeb,white); border:2.5px solid #fde68a; } /* Reference sheet modal */ .t-ref-modal-img { width:100%;border-radius:16px; border:2.5px solid ${COLORS.border}; user-select:none; transition:transform 0.2s ease; } .t-ref-zoom-bar { display:flex;align-items:center;justify-content:center;gap:0.75rem; padding:0.65rem;background:#f9fafb;border-top:2px solid ${COLORS.border}; flex-shrink:0; } .t-ref-zoom-btn { width:34px;height:34px;border-radius:50%;border:2.5px solid ${COLORS.border}; background:white;cursor:pointer;display:flex;align-items:center;justify-content:center; font-weight:800;transition:all 0.15s ease;box-shadow:0 2px 6px rgba(0,0,0,0.05); } .t-ref-zoom-btn:hover { border-color:${COLORS.borderPurple};background:#fdf4ff; } .t-ref-zoom-label { font-family:'Nunito',sans-serif;font-size:0.75rem;font-weight:800;color:#9ca3af;min-width:36px;text-align:center; } /* Incorrect flash */ @keyframes tFlashRed { 0%,100%{background:transparent;}50%{background:rgba(239,68,68,0.15);} } .t-flash-red { animation:tFlashRed 0.6s ease; } /* Highlighting */ .t-highlight { background: rgba(251, 191, 36, 0.42); border-radius: 6px; padding: 0 2px; box-shadow: inset 0 -1px 0 rgba(217, 119, 6, 0.25); } /* Desktop / wide tweaks */ @media (min-width: 900px) { .t-blob-1 { left: calc((100vw - var(--content-max)) / 2 - 120px); top: -120px; width: 300px; height: 300px; } .t-blob-2 { left: calc((100vw - var(--content-max)) / 2 + 20px); bottom: -80px; width: 220px; height: 220px; } .t-blob-3 { right: calc((100vw - var(--content-max)) / 2 - 40px); top: 10%; width: 260px; height: 260px; } .t-blob-4 { right: calc((100vw - var(--content-max)) / 2 + 10px); bottom: 6%; width: 180px; height: 180px; } /* Desktop split layout */ .t-desktop-shell { max-width: var(--content-max); margin: 0 auto; } .t-split-layout { display: flex; align-items: stretch; } .t-split-left { display: flex; flex-direction: column; gap: 1.5rem; } .t-split-right { display: flex; flex-direction: column; gap: 1.5rem; min-width: 0; } .t-split-divider { position: relative; width: 10px; cursor: col-resize; flex-shrink: 0; } .t-split-divider-bar { position: absolute; top: 0; bottom: 0; left: 50%; width: 3px; transform: translateX(-50%); border-radius: 999px; background: #e5e7eb; } .t-split-divider-handle { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); width: 26px; height: 26px; border-radius: 999px; border: 2px solid #e5e7eb; background: white; box-shadow: 0 4px 12px rgba(15,23,42,0.12); display: flex; align-items: center; justify-content: center; } .t-split-divider-dots { width: 3px; height: 14px; border-radius: 999px; background: linear-gradient(to bottom, #c4b5fd, #a855f7); } .t-split-divider:hover .t-split-divider-bar { background: #c4b5fd; } .t-split-divider:hover .t-split-divider-handle { border-color: #c4b5fd; box-shadow: 0 6px 18px rgba(129,140,248,0.3); } /* Directions button (desktop only) */ .t-directions-btn { font-weight: 900; font-size: 0.78rem; padding: 0.35rem 0.9rem; border-radius: 999px; border: 2.5px solid ${COLORS.border}; background: white; color: ${COLORS.text}; box-shadow: 0 3px 10px rgba(0,0,0,0.06); } .t-directions-btn:hover { border-color: ${COLORS.borderPurple}; background: #fdf4ff; box-shadow: 0 6px 14px rgba(0,0,0,0.08); transform: translateY(-1px); } .t-directions-btn.active { background: linear-gradient(135deg, ${COLORS.primary}, ${COLORS.primaryDark}); border-color: ${COLORS.primaryDark}; color: white; box-shadow: 0 4px 0 ${COLORS.primaryDark}, 0 8px 18px rgba(168,85,247,0.28); } .t-directions-btn.active:hover { box-shadow: 0 6px 0 ${COLORS.primaryDark}, 0 12px 22px rgba(168,85,247,0.32); transform: translateY(-1px); } } `; // ─── Confetti ───────────────────────────────────────────────────────────────── interface ConfettiParticle { id: number; x: number; color: string; size: number; delay: number; duration: number; rotation: number; } type FeedbackState = { show: boolean; correct: boolean; xpGained?: number; selectedOptionId?: string; } | null; interface IncorrectEntry { questionId: string; originalIndex: number; } type HighlightRange = { start: number; end: number }; type HighlightsByField = Record; const Confetti = ({ active }: { active: boolean }) => { const [particles, setParticles] = useState([]); useEffect(() => { if (!active) { setParticles([]); return; } const colors = [ "#a855f7", "#ec4899", "#f59e0b", "#10b981", "#3b82f6", "#f97316", "#eab308", ]; setParticles( 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, })), ); }, [active]); if (!active || !particles.length) return null; return (
{particles.map((p) => (
))}
); }; const XPPopup = ({ xp, show }: { xp: number; show: boolean }) => { if (!show) return null; return (
+{xp} XP
🎉 CORRECT!
); }; // ─── Reference Sheet Modal ──────────────────────────────────────────────────── // Placeholder image — swap the src for a real URL or imported asset when ready const REFERENCE_SHEET_URL = "https://placehold.co/800x1000/fffbf4/1e1b4b?text=SAT+Reference+Sheet"; const ReferenceSheetModal = ({ open, onClose, }: { open: boolean; onClose: () => void; }) => { const [zoom, setZoom] = useState(100); if (!open) return null; return (
e.stopPropagation()} > {/* Header */}

Reference Sheet

SAT Math formulas & constants

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

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

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

); }; // ─── Shared Background ──────────────────────────────────────────────────────── const BgLayer = () => ( <>
{DOTS.map((d, i) => (
))} ); // ─── Main Component ─────────────────────────────────────────────────────────── export const Test = () => { const sheetId = localStorage.getItem("activePracticeSheetId"); const blocker = useExamNavigationGuard(); const navigate = useNavigate(); const { user } = useAuthStore(); const token = useAuthToken(); // ── Core state ────────────────────────────────────────────────────────────── const [eliminated, setEliminated] = useState>>({}); const [markedForReview, setMarkedForReview] = useState>( new Set(), ); const [answers, setAnswers] = useState>({}); const [showNavigator, setShowNavigator] = useState(false); const [showExitDialog, setShowExitDialog] = useState(false); const [showRefSheet, setShowRefSheet] = useState(false); const [showReviewWarning, setShowReviewWarning] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [sessionId, setSessionId] = useState(null); const [error, setError] = useState(null); const [calcOpen, setCalcOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false); const [feedback, setFeedback] = useState(null); const [showConfetti, setShowConfetti] = useState(false); const [showDirections, setShowDirections] = useState(false); const [timerVisible, setTimerVisible] = useState(true); const [leftColumnWidth, setLeftColumnWidth] = useState(350); // desktop only (re-centered on mount) const dragRef = useRef<{ startX: number; startWidth: number } | null>(null); const [isDraggingDivider, setIsDraggingDivider] = useState(false); const userAdjustedSplitRef = useRef(false); const desktopShellRef = useRef(null); const [isMdUp, setIsMdUp] = useState(false); const HIGHLIGHTS_STORAGE_KEY = "edbridge_highlights_v1"; const [highlightMode, setHighlightMode] = useState(false); const [highlightsByField, setHighlightsByField] = useState( {}, ); const [showIncorrectFlash, setShowIncorrectFlash] = useState(false); // ── Retry / targeted state ────────────────────────────────────────────────── const incorrectQueueRef = useRef([]); const correctedRef = useRef>(new Set()); const [retryMode, setRetryMode] = useState(false); const [retryQueue, setRetryQueue] = useState([]); const [retryIndex, setRetryIndex] = useState(0); // ── Exam store ────────────────────────────────────────────────────────────── const examMode = useExamConfigStore((s) => s.payload?.mode); const isTargeted = examMode === "TARGETED"; const time = useSatTimer(); const phase = useSatExam((s) => s.phase); const currentModule = useSatExam((s) => s.currentModuleQuestions); const questionIndex = useSatExam((s) => s.questionIndex); const 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 currentQuestion = retryMode ? currentModule?.questions[retryQueue[retryIndex]?.originalIndex] : currentModule?.questions[questionIndex]; // close directions when question changes useEffect(() => { setShowDirections(false); }, [currentQuestion?.id]); // Handle separator drag (desktop only) const handleSeparatorMouseDown = (e: React.MouseEvent) => { if (e.button !== 0) return; e.preventDefault(); // prevent text selection kick-off userAdjustedSplitRef.current = true; dragRef.current = { startX: e.clientX, startWidth: leftColumnWidth, }; setIsDraggingDivider(true); }; // Center the splitter on initial desktop load (and on resize until user drags) useEffect(() => { const centerSplit = () => { if (userAdjustedSplitRef.current) return; if (typeof window === "undefined") return; if (window.innerWidth < 768) return; // md breakpoint const shell = desktopShellRef.current; if (!shell) return; const w = shell.getBoundingClientRect().width; // Account for divider+margin so the handle lands near visual center. const target = Math.round((w - 40) / 2); setLeftColumnWidth(Math.max(300, Math.min(600, target))); }; centerSplit(); window.addEventListener("resize", centerSplit); return () => window.removeEventListener("resize", centerSplit); }, []); // True responsive check (portals ignore parent display:none) useEffect(() => { if (typeof window === "undefined") return; const mq = window.matchMedia("(min-width: 768px)"); const update = () => setIsMdUp(mq.matches); update(); mq.addEventListener("change", update); return () => mq.removeEventListener("change", update); }, []); // Highlights persistence (desktop/web feature; safe on mobile too) useEffect(() => { try { const raw = localStorage.getItem(HIGHLIGHTS_STORAGE_KEY); if (!raw) return; const parsed = JSON.parse(raw) as HighlightsByField; if (parsed && typeof parsed === "object") setHighlightsByField(parsed); } catch { // ignore corrupted storage } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); useEffect(() => { try { localStorage.setItem( HIGHLIGHTS_STORAGE_KEY, JSON.stringify(highlightsByField), ); } catch { // ignore quota / private mode issues } }, [HIGHLIGHTS_STORAGE_KEY, highlightsByField]); useEffect(() => { const handleMouseMove = (e: MouseEvent) => { if (!dragRef.current) return; const delta = e.clientX - dragRef.current.startX; const newWidth = Math.max( 300, Math.min(600, dragRef.current.startWidth + delta), ); setLeftColumnWidth(newWidth); }; const handleMouseUp = () => { dragRef.current = null; setIsDraggingDivider(false); }; window.addEventListener("mousemove", handleMouseMove); window.addEventListener("mouseup", handleMouseUp); return () => { window.removeEventListener("mousemove", handleMouseMove); window.removeEventListener("mouseup", handleMouseUp); }; }, []); // Prevent text selection while dragging divider useEffect(() => { if (!isDraggingDivider) return; const prevUserSelect = document.body.style.userSelect; const prevCursor = document.body.style.cursor; document.body.style.userSelect = "none"; document.body.style.cursor = "col-resize"; return () => { document.body.style.userSelect = prevUserSelect; document.body.style.cursor = prevCursor; }; }, [isDraggingDivider]); const currentAnswer = currentQuestion ? (answers[currentQuestion.id] ?? "") : ""; const isCurrentMarked = !!currentQuestion && markedForReview.has(currentQuestion.id); const isMCQ = !!currentQuestion?.options?.length; const isFirstQuestion = retryMode ? true : questionIndex === 0; const isLastQuestion = !retryMode && questionIndex === (currentModule?.questions.length ?? 1) - 1; // ── Blocker ───────────────────────────────────────────────────────────────── useEffect(() => { if (blocker.state === "blocked") setShowExitDialog(true); }, [blocker.state]); useEffect(() => { return () => { resetExam(); }; }, []); useEffect(() => { if (phase === "FINISHED") { const t = setTimeout( () => navigate(`/student/practice/${sheetId}/test/results`, { replace: true, }), 3000, ); return () => clearTimeout(t); } }, [phase]); // ── Exam start ────────────────────────────────────────────────────────────── const startExam = async () => { if (!user) return; const payload = useExamConfigStore.getState().payload; try { const response = await api.startSession(token as string, payload); setSessionId(response.id); await loadSessionQuestions(response.id); useSatExam.getState().startExam(); } catch (err) { setError(`Failed to start exam session: ${err}`); } }; const loadSessionQuestions = async (sid: string) => { if (!token) return; const data = await api.fetchSessionQuestions(token, sid); const module: SessionModuleQuestions = { module_id: data.module_id, module_title: data.module_title, time_limit_minutes: data.time_limit_minutes * 60, questions: data.questions.map((q: Question) => ({ id: q.id, text: q.text, context: q.context, context_image_url: q.context_image_url, type: q.type, section: q.section, image_url: q.image_url, index: q.index, difficulty: q.difficulty, correct_answer: q.correct_answer, explanation: q.explanation, topics: q.topics, options: q.options, })), }; useSatExam.getState().setModuleQuestions(module); }; // ── Toggle bookmark ────────────────────────────────────────────────────────── const toggleMark = () => { if (!currentQuestion) return; setMarkedForReview((prev) => { const next = new Set(prev); next.has(currentQuestion.id) ? next.delete(currentQuestion.id) : next.add(currentQuestion.id); return next; }); }; // ── Handle Next ────────────────────────────────────────────────────────────── const handleNext = async () => { if (isTargeted) { await submitTargetedAnswer(); return; } if (!currentQuestion || !sessionId) return; // If last question and there are marked questions, warn first if (isLastQuestion && markedForReview.size > 0) { setShowReviewWarning(true); return; } await submitAndAdvance(); }; const submitAndAdvance = async () => { if (!currentQuestion || !sessionId) return; const userAnswer = answers[currentQuestion.id] ?? ""; const answerText = currentQuestion.options?.length ? (currentQuestion.options.find((o) => o.id === userAnswer)?.text ?? "") : userAnswer; const payload: SubmitAnswer = { question_id: currentQuestion.id, answer_text: answerText, time_spent_seconds: 3, }; try { setIsSubmitting(true); await api.submitAnswer(token!, sessionId, payload); } catch (err) { console.error(err); } finally { setIsSubmitting(false); } if (!isLastQuestion) { nextQuestion(); return; } const next = await api.fetchNextModule(token!, sessionId); if (next?.status === "COMPLETED") { setResults(next.results); finishExam(); } else { await loadSessionQuestions(sessionId); useSatExam.getState().startBreak(); } }; // ── Review warning actions ─────────────────────────────────────────────────── const handleGoReview = () => { setShowReviewWarning(false); // Jump to first marked question const firstMarked = currentModule?.questions.findIndex((q) => markedForReview.has(q.id), ); if (firstMarked !== undefined && firstMarked >= 0) goToQuestion(firstMarked); setShowNavigator(true); }; const handleFinishAnyway = async () => { setShowReviewWarning(false); setMarkedForReview(new Set()); // clear marks so we don't re-trigger await submitAndAdvance(); }; // ── Targeted submit ────────────────────────────────────────────────────────── const showAnswerFeedback = ( correct: boolean, xpGained?: number, 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 remaining = retryQueue.filter( (e) => !correctedRef.current.has(e.questionId), ); if (!remaining.length) { const next = await api.fetchNextModule(token!, sessionId); if (next.status === "COMPLETED") finishExam(); return; } const nextIdx = retryIndex + 1; if (nextIdx >= retryQueue.length) { const still = incorrectQueueRef.current.filter( (e) => !correctedRef.current.has(e.questionId), ); setRetryQueue(still); setRetryIndex(0); goToQuestion(still[0].originalIndex); } else { setRetryIndex(nextIdx); goToQuestion(retryQueue[nextIdx].originalIndex); } }; const submitTargetedAnswer = async () => { if (!currentQuestion || !sessionId) return; const userAnswer = answers[currentQuestion.id] ?? ""; const answerText = currentQuestion.options?.length ? (currentQuestion.options.find((o) => o.id === userAnswer)?.text ?? "") : userAnswer; const payload: SubmitAnswer = { question_id: currentQuestion.id, answer_text: answerText, time_spent_seconds: 3, }; try { setIsSubmitting(true); const result = await api.submitAnswer(token!, sessionId, payload); const isCorrect = result?.feedback.is_correct ?? false; showAnswerFeedback(isCorrect, 2, userAnswer); if (isCorrect) { correctedRef.current.add(currentQuestion.id); incorrectQueueRef.current = incorrectQueueRef.current.filter( (e) => e.questionId !== currentQuestion.id, ); } else { if ( !incorrectQueueRef.current.some( (e) => e.questionId === currentQuestion.id, ) ) { incorrectQueueRef.current.push({ questionId: currentQuestion.id, originalIndex: retryMode ? retryQueue[retryIndex].originalIndex : questionIndex, }); } correctedRef.current.delete(currentQuestion.id); } setTimeout( async () => { setFeedback(null); if (retryMode) { advanceRetry(); return; } const isLast = questionIndex === currentModule!.questions.length - 1; if (!isLast) { nextQuestion(); return; } if (incorrectQueueRef.current.length > 0) { const queue = [...incorrectQueueRef.current]; setRetryQueue(queue); setRetryIndex(0); setRetryMode(true); goToQuestion(queue[0].originalIndex); return; } await proceedAfterLastQuestion(); }, isCorrect ? 2200 : 1500, ); } catch (err) { console.error(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 handleQuitExam = () => { useExamConfigStore.getState().clearPayload(); quitExam(); }; const toggleEliminate = (questionId: string, optionId: string) => { setEliminated((prev) => { const cur = new Set(prev[questionId] ?? []); cur.has(optionId) ? cur.delete(optionId) : cur.add(optionId); return { ...prev, [questionId]: cur }; }); }; const formatTime = (s: number) => `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; const mergeHighlightRanges = (ranges: HighlightRange[]) => { const sorted = ranges .filter((r) => Number.isFinite(r.start) && Number.isFinite(r.end)) .map((r) => ({ start: Math.max(0, r.start), end: Math.max(0, r.end) })) .filter((r) => r.end > r.start) .sort((a, b) => a.start - b.start); const merged: HighlightRange[] = []; for (const r of sorted) { const last = merged[merged.length - 1]; if (!last || r.start > last.end) merged.push(r); else last.end = Math.max(last.end, r.end); } return merged; }; const addHighlight = (fieldKey: string, start: number, end: number) => { const s = Math.min(start, end); const e = Math.max(start, end); if (!fieldKey || s === e) return; setHighlightsByField((prev) => { const cur = prev[fieldKey] ?? []; const next = mergeHighlightRanges([...cur, { start: s, end: e }]); return { ...prev, [fieldKey]: next }; }); }; const renderQuestionTextWithHighlights = ( text: string, highlights: HighlightRange[], ) => { const merged = mergeHighlightRanges(highlights); const parts = text.split(/(\$\$.*?\$\$|\$.*?\$)/g); let pos = 0; let pieceIdx = 0; const renderPlain = (plain: string, basePos: number) => { const segStart = basePos; const segEnd = basePos + plain.length; const overlapping = merged.filter( (h) => h.end > segStart && h.start < segEnd, ); if (!overlapping.length) { const key = `p-${pieceIdx++}`; return {plain}; } const nodes: React.ReactNode[] = []; let cursor = 0; for (const h of overlapping) { const localStart = Math.max(0, h.start - segStart); const localEnd = Math.min(plain.length, h.end - segStart); if (localStart > cursor) { nodes.push( {plain.slice(cursor, localStart)} , ); } nodes.push( {plain.slice(localStart, localEnd)} , ); cursor = Math.max(cursor, localEnd); } if (cursor < plain.length) { nodes.push({plain.slice(cursor)}); } return {nodes}; }; return ( <> {parts.map((part, index) => { if (part.startsWith("$$")) { const inner = part.slice(2, -2); const len = inner.length; const hasOverlap = merged.some( (h) => h.end > pos && h.start < pos + len, ); const node = {inner}; pos += len; if (!hasOverlap) return node; return ( {node} ); } if (part.startsWith("$")) { const inner = part.slice(1, -1); const len = inner.length; const hasOverlap = merged.some( (h) => h.end > pos && h.start < pos + len, ); const node = {inner}; pos += len; if (!hasOverlap) return node; return ( {node} ); } const base = pos; pos += part.length; return {renderPlain(part, base)}; })} ); }; const HighlightableRichText = ({ fieldKey, text, className, }: { fieldKey: string; text: string; className?: string; }) => { const rootRef = useRef(null); const highlights = highlightsByField[fieldKey] ?? []; return (
{ if (!highlightMode) return; const root = rootRef.current; if (!root) return; const sel = window.getSelection(); if (!sel || sel.rangeCount === 0 || sel.isCollapsed) return; const range = sel.getRangeAt(0); if ( !root.contains(range.startContainer) || !root.contains(range.endContainer) ) return; const selected = range.toString(); if (!selected.trim()) return; const pre = document.createRange(); pre.selectNodeContents(root); pre.setEnd(range.startContainer, range.startOffset); const start = pre.toString().length; addHighlight(fieldKey, start, start + selected.length); }} > {renderQuestionTextWithHighlights(text, highlights)}
); }; // ── Render helpers ─────────────────────────────────────────────────────────── 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 cls = "t-option"; if (isSelected) cls += " selected"; if (isEliminated && !feedbackLocked) cls += " eliminated"; if (feedbackLocked && isSelected) cls += feedback!.correct ? " correct" : " incorrect"; return (
{isTargeted && feedback?.show && feedback.correct && feedback.xpGained && isSelected && (
+{feedback.xpGained} XP ⭐
)}
); })}
); }; const renderShortAnswer = (question?: Question) => { if (!question || question.options?.length) return null; return (