Files
edbridge-scholars/src/pages/student/practice/Test.tsx
2026-03-12 02:39:34 +06:00

2226 lines
84 KiB
TypeScript

import { useEffect, useState, useRef } from "react";
import { Navigate, useNavigate } from "react-router-dom";
// @ts-ignore
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 {
MathErrorBoundary,
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<string, HighlightRange[]>;
const Confetti = ({ active }: { active: boolean }) => {
const [particles, setParticles] = useState<ConfettiParticle[]>([]);
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 (
<div className="fixed inset-0 pointer-events-none z-50 overflow-hidden">
<style>{`@keyframes confetti-fall{0%{transform:translateY(-20px) rotate(0deg);opacity:1;}100%{transform:translateY(110vh) rotate(720deg);opacity:0;}}.confetti-piece{position:absolute;top:-20px;border-radius:2px;animation:confetti-fall linear forwards;}`}</style>
{particles.map((p) => (
<div
key={p.id}
className="confetti-piece"
style={{
left: `${p.x}%`,
width: p.size,
height: p.size * 0.6,
backgroundColor: p.color,
animationDuration: `${p.duration}s`,
animationDelay: `${p.delay}s`,
transform: `rotate(${p.rotation}deg)`,
}}
/>
))}
</div>
);
};
const XPPopup = ({ xp, show }: { xp: number; show: boolean }) => {
if (!show) return null;
return (
<div
className="fixed top-1/2 left-1/2 z-50 pointer-events-none"
style={{ transform: "translate(-50%,-60%)" }}
>
<style>{`@keyframes xp-popup{0%{opacity:0;transform:translateY(20px) scale(0.8);}20%{opacity:1;transform:translateY(0px) scale(1.1);}70%{opacity:1;transform:translateY(-10px) scale(1);}100%{opacity:0;transform:translateY(-40px) scale(0.9);}}.xp-popup-anim{animation:xp-popup 2s ease-out forwards;}`}</style>
<div className="xp-popup-anim flex flex-col items-center gap-1">
<div className="text-5xl font-black text-transparent bg-clip-text bg-linear-to-br from-yellow-300 via-amber-400 to-orange-500 drop-shadow-lg">
+{xp} XP
</div>
<div className="text-black font-bold text-lg tracking-widest drop-shadow-md">
🎉 CORRECT!
</div>
</div>
</div>
);
};
// ─── 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 (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: "rgba(0,0,0,0.55)", backdropFilter: "blur(6px)" }}
onClick={onClose}
>
<div
className="t-card flex flex-col"
style={{
width: "100%",
maxWidth: 580,
maxHeight: "90vh",
overflow: "hidden",
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
padding: "1rem 1.25rem",
borderBottom: "2px solid #f3f4f6",
flexShrink: 0,
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "0.6rem" }}>
<div
style={{
width: 34,
height: 34,
borderRadius: 10,
background: "#fdf4ff",
border: "2px solid #e9d5ff",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<BookOpen size={16} color="#a855f7" />
</div>
<div>
<p
style={{
fontWeight: 900,
fontSize: "0.92rem",
color: "#1e1b4b",
lineHeight: 1.1,
}}
>
Reference Sheet
</p>
<p
style={{
fontFamily: "'Nunito Sans',sans-serif",
fontSize: "0.68rem",
fontWeight: 600,
color: "#9ca3af",
}}
>
SAT Math formulas &amp; constants
</p>
</div>
</div>
<button
onClick={onClose}
style={{
width: 30,
height: 30,
borderRadius: "50%",
border: "2.5px solid #f3f4f6",
background: "white",
display: "flex",
alignItems: "center",
justifyContent: "center",
cursor: "pointer",
}}
>
<X size={13} color="#9ca3af" />
</button>
</div>
{/* Scrollable image area */}
<div
style={{
flex: 1,
overflowY: "auto",
overflowX: "auto",
padding: "1rem",
}}
>
<img
src={REFERENCE_SHEET_URL}
alt="SAT Reference Sheet"
className="t-ref-modal-img"
style={{
transform: `scale(${zoom / 100})`,
transformOrigin: "top left",
width: `${10000 / zoom}%`,
}}
draggable={false}
/>
</div>
{/* Zoom controls */}
<div className="t-ref-zoom-bar">
<button
className="t-ref-zoom-btn"
onClick={() => setZoom((z) => Math.max(50, z - 25))}
>
<ZoomOut size={15} color="#6b7280" />
</button>
<span className="t-ref-zoom-label">{zoom}%</span>
<button
className="t-ref-zoom-btn"
onClick={() => setZoom((z) => Math.min(200, z + 25))}
>
<ZoomIn size={15} color="#6b7280" />
</button>
<button
style={{
marginLeft: "0.5rem",
padding: "0.3rem 0.85rem",
borderRadius: 100,
border: "2.5px solid #f3f4f6",
background: "white",
fontFamily: "'Nunito',sans-serif",
fontSize: "0.72rem",
fontWeight: 800,
color: "#9ca3af",
cursor: "pointer",
}}
onClick={() => setZoom(100)}
>
Reset
</button>
</div>
</div>
</div>
);
};
// ─── Review Warning Dialog ────────────────────────────────────────────────────
const ReviewWarningDialog = ({
open,
markedCount,
onReview,
onFinish,
}: {
open: boolean;
markedCount: number;
onReview: () => void;
onFinish: () => void;
}) => {
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: "rgba(0,0,0,0.4)", backdropFilter: "blur(6px)" }}
>
<div
className="t-card t-review-warning p-6 flex flex-col gap-4"
style={{ width: "100%", maxWidth: 360 }}
>
<div style={{ textAlign: "center" }}>
<span
style={{
fontSize: "2.5rem",
display: "block",
marginBottom: "0.5rem",
}}
>
🔖
</span>
<h2
style={{
fontWeight: 900,
fontSize: "1.15rem",
color: "#1e1b4b",
marginBottom: "0.35rem",
}}
>
{markedCount} question{markedCount !== 1 ? "s" : ""} marked for
review
</h2>
<p
style={{
fontFamily: "'Nunito Sans',sans-serif",
fontSize: "0.82rem",
fontWeight: 600,
color: "#9ca3af",
lineHeight: 1.5,
}}
>
You've bookmarked questions you wanted to revisit. Would you like to
go back and review them, or finish the exam now?
</p>
</div>
<div style={{ display: "flex", gap: "0.65rem" }}>
<button
onClick={onReview}
className="t-btn-3d t-btn-outline"
style={{ flex: 1, padding: "0.75rem" }}
>
🔖 Review
</button>
<button
onClick={onFinish}
className="t-btn-3d t-btn-accent"
style={{ flex: 1, padding: "0.75rem" }}
>
Finish Anyway
</button>
</div>
</div>
</div>
);
};
// ─── Shared Background ────────────────────────────────────────────────────────
const BgLayer = () => (
<>
<div className="t-blob t-blob-1" />
<div className="t-blob t-blob-2" />
<div className="t-blob t-blob-3" />
<div className="t-blob t-blob-4" />
{DOTS.map((d, i) => (
<div
key={i}
className="t-dot"
style={
{
width: d.size,
height: d.size,
background: d.color,
top: d.top,
left: d.left,
right: d.right,
animationDelay: d.delay,
animationDuration: `${3.5 + i * 0.4}s`,
} as React.CSSProperties
}
/>
))}
</>
);
// ─── 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<Record<string, Set<string>>>({});
const [markedForReview, setMarkedForReview] = useState<Set<string>>(
new Set(),
);
const [answers, setAnswers] = useState<Record<string, string>>({});
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<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [calcOpen, setCalcOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const [feedback, setFeedback] = useState<FeedbackState>(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<HTMLDivElement | null>(null);
const [isMdUp, setIsMdUp] = useState(false);
const HIGHLIGHTS_STORAGE_KEY = "edbridge_highlights_v1";
const [highlightMode, setHighlightMode] = useState(false);
const [highlightsByField, setHighlightsByField] = useState<HighlightsByField>(
{},
);
const [showIncorrectFlash, setShowIncorrectFlash] = useState(false);
// ── Retry / targeted state ──────────────────────────────────────────────────
const incorrectQueueRef = useRef<IncorrectEntry[]>([]);
const correctedRef = useRef<Set<string>>(new Set());
const [retryMode, setRetryMode] = useState(false);
const [retryQueue, setRetryQueue] = useState<IncorrectEntry[]>([]);
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 {
// @ts-ignore
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) {
// @ts-ignore
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 };
});
};
// Add this above renderQuestionTextWithHighlights
const SafeBlockMath = ({ latex }: { latex: string }) => (
<MathErrorBoundary raw={`$$${latex}$$`}>
<BlockMath>{latex}</BlockMath>
</MathErrorBoundary>
);
const SafeInlineMath = ({ latex }: { latex: string }) => (
<MathErrorBoundary raw={`$${latex}$`}>
<InlineMath>{latex}</InlineMath>
</MathErrorBoundary>
);
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 <span key={key}>{plain}</span>;
}
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(
<span key={`t-${pieceIdx++}`}>
{plain.slice(cursor, localStart)}
</span>,
);
}
nodes.push(
<mark key={`m-${pieceIdx++}`} className="t-highlight">
{plain.slice(localStart, localEnd)}
</mark>,
);
cursor = Math.max(cursor, localEnd);
}
if (cursor < plain.length) {
nodes.push(<span key={`t-${pieceIdx++}`}>{plain.slice(cursor)}</span>);
}
return <span key={`w-${pieceIdx++}`}>{nodes}</span>;
};
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 = <SafeBlockMath key={index} latex={inner} />;
pos += len;
if (!hasOverlap) return node;
return (
<span key={index} className="t-highlight">
{node}
</span>
);
}
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 = <SafeInlineMath key={index} latex={inner} />;
pos += len;
if (!hasOverlap) return node;
return (
<span key={index} className="t-highlight">
{node}
</span>
);
}
const base = pos;
pos += part.length;
return <span key={index}>{renderPlain(part, base)}</span>;
})}
</>
);
};
const HighlightableRichText = ({
fieldKey,
text,
className,
}: {
fieldKey: string;
text: string;
className?: string;
}) => {
const rootRef = useRef<HTMLDivElement | null>(null);
const highlights = highlightsByField[fieldKey] ?? [];
return (
<div
ref={rootRef}
className={className}
onMouseUp={() => {
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)}
</div>
);
};
// ── Render helpers ───────────────────────────────────────────────────────────
const renderOptions = (question?: Question) => {
if (!question?.options?.length) return null;
const eliminatedSet = eliminated[question.id] ?? new Set();
return (
<div className="flex flex-col gap-3 w-full">
{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 (
<div key={option.id} className="flex items-center gap-2">
<button
className={`t-eliminate-btn ${isEliminated ? "active" : ""}`}
onClick={(e) => {
e.stopPropagation();
if (!feedbackLocked) toggleEliminate(question.id, option.id);
}}
disabled={feedbackLocked}
>
<X size={14} />
</button>
<div className="relative flex-1">
<button
className={cls}
onClick={() => {
if (feedbackLocked) return;
setAnswers((p) => ({ ...p, [question.id]: option.id }));
}}
disabled={feedbackLocked}
>
<span className="t-option-letter">{"ABCD"[index]}</span>
<span className="flex-1">
{renderQuestionText(option.text)}
</span>
</button>
{isTargeted &&
feedback?.show &&
feedback.correct &&
feedback.xpGained &&
isSelected && (
<div className="t-xp-badge">+{feedback.xpGained} XP </div>
)}
</div>
</div>
);
})}
</div>
);
};
const renderShortAnswer = (question?: Question) => {
if (!question || question.options?.length) return null;
return (
<div className="flex flex-col gap-3 pt-4">
<textarea
value={currentAnswer}
onChange={(e) =>
setAnswers((p) => ({ ...p, [question.id]: e.target.value }))
}
placeholder="Type your answer here..."
disabled={isTargeted && !!feedback?.show}
className="t-textarea"
/>
</div>
);
};
const RetryBanner = () => {
if (!retryMode) return null;
const remaining = retryQueue.filter(
(e) => !correctedRef.current.has(e.questionId),
).length;
return (
<div className="t-retry-banner">
<span>🔁 Review Mode</span>
<span className="opacity-80"></span>
<span>
{remaining} question{remaining !== 1 ? "s" : ""} left to correct
</span>
<span className="ml-2 bg-white/20 px-2 py-0.5 rounded-full text-xs">
{retryIndex + 1}/{retryQueue.length}
</span>
</div>
);
};
// ─── IDLE ─────────────────────────────────────────────────────────────────────
if (phase === "IDLE") {
if (error)
return (
<div className="test-screen">
<style>{GLOBAL_STYLES}</style>
<BgLayer />
<main className="relative z-10 min-h-screen flex flex-col items-center justify-center px-6 py-8">
<div className="t-card t-card-purple p-8 max-w-md w-full t-anim">
<div className="flex flex-col items-center gap-6 text-center">
<div className="w-20 h-20 rounded-full bg-red-100 flex items-center justify-center">
<Unplug size={40} className="text-red-500" />
</div>
<div>
<h2 className="t-section-title mb-2">Connection Error</h2>
<p className="text-gray-500 font-semibold">{error}</p>
</div>
</div>
</div>
<div className="flex gap-4 mt-6 w-full max-w-md">
<button
onClick={() => {
useExamConfigStore.getState().clearPayload();
navigate("/student/home");
}}
className="t-btn-3d t-btn-outline flex-1 py-4"
>
Go Back
</button>
<button
onClick={startExam}
className="t-btn-3d t-btn-primary flex-1 py-4"
>
Retry
</button>
</div>
</main>
</div>
);
return (
<div className="test-screen">
<style>{GLOBAL_STYLES}</style>
<BgLayer />
<main className="relative z-10 min-h-screen flex flex-col items-center justify-center px-6 py-8">
<div className="t-card p-8 max-w-lg w-full t-anim">
<div className="text-center mb-8">
<span className="text-5xl mb-4 block">🚀</span>
<h1 className="text-3xl font-black text-[#1e1b4b] mb-2">
Ready to begin?
</h1>
<p className="text-gray-500 font-semibold">
Let's crush this practice test!
</p>
</div>
<div className="space-y-4 mb-8">
{[
["Full-screen mode for distraction-free focus"],
[`Press Esc to exit anytime`],
["Progress saves automatically"],
].map(([text], i) => (
<div
key={i}
className="flex items-center gap-4 p-4 bg-gray-50 rounded-2xl border-2 border-gray-100"
>
<div className="w-10 h-10 rounded-full bg-purple-100 flex items-center justify-center shrink-0">
<Check size={20} className="text-purple-600" />
</div>
<span className="font-bold text-gray-700">{text}</span>
</div>
))}
{isTargeted && (
<div className="flex items-center gap-4 p-4 bg-linear-to-r from-purple-50 to-orange-50 rounded-2xl border-2 border-purple-200">
<span className="text-3xl">🎯</span>
<div>
<p className="font-bold text-purple-800">Targeted Mode</p>
<p className="text-sm text-purple-600 font-semibold">
Instant feedback + XP rewards! Incorrect questions loop
until you get them right.
</p>
</div>
</div>
)}
</div>
<button
onClick={startExam}
className="t-btn-3d t-btn-accent w-full py-5 text-lg"
>
<Play size={20} className="inline mr-2" fill="white" />
Start Test
</button>
</div>
</main>
</div>
);
}
// ─── MODULE ───────────────────────────────────────────────────────────────────
if (phase === "MODULE")
return (
<div className={`test-screen ${showIncorrectFlash ? "t-flash-red" : ""}`}>
<style>{GLOBAL_STYLES}</style>
<Confetti active={showConfetti} />
{isTargeted && feedback?.show && feedback.correct && (
<XPPopup xp={feedback.xpGained ?? 0} show={true} />
)}
<RetryBanner />
<BgLayer />
{/* Review warning overlay */}
<ReviewWarningDialog
open={showReviewWarning}
markedCount={markedForReview.size}
onReview={handleGoReview}
onFinish={handleFinishAnyway}
/>
{/* Reference sheet modal */}
<ReferenceSheetModal
open={showRefSheet}
onClose={() => setShowRefSheet(false)}
/>
<section className="relative z-10 flex flex-col min-h-screen">
{/* Fixed header */}
<header
className={`fixed left-0 right-0 bg-white/95 backdrop-blur-sm border-b-2 border-purple-100 px-4 pt-6 pb-4 z-20 ${retryMode ? "top-10" : "top-0"}`}
>
<div className="mx-auto max-w-2xl md:max-w-(--content-max)">
{/* Mobile header (unchanged) */}
<div className="md:hidden">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<span
className={`t-q-badge ${isCurrentMarked ? "reviewing" : ""}`}
>
{isCurrentMarked ? "🔖 " : ""}Q
{retryMode ? retryIndex + 1 : questionIndex + 1}
</span>
<span className="text-sm font-bold text-gray-400">
of {currentModule?.questions.length}
</span>
</div>
<div className="flex flex-col items-center">
<div className="t-timer">
{timerVisible
? `${Math.floor(time / 60)}:${String(time % 60).padStart(2, "0")}`
: "--:--"}
</div>
<button
className="mt-1 text-xs text-gray-500 hover:text-gray-700"
onClick={() => setTimerVisible((v) => !v)}
>
{timerVisible ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<h1 className="text-center font-bold">
{currentModule?.module_title}
</h1>
</div>
{/* Desktop header (web only) */}
<div className="hidden md:flex items-start justify-between gap-6 relative">
{/* Left: section/module + directions */}
<div className="flex flex-col">
<div className="text-sm font-semibold text-gray-700">
{currentQuestion?.section || ""}
</div>
<div className="text-base font-bold text-gray-700">
{currentModule?.module_title}
</div>
<button
className={`mt-2 t-btn-3d t-directions-btn ${showDirections ? "active" : ""}`}
onClick={() => setShowDirections((v) => !v)}
>
Directions
</button>
</div>
{/* Middle: time + hide + question number (perfectly centered) */}
<div className="flex flex-col items-center absolute left-1/2 -translate-x-1/2 top-0">
<div className="flex flex-col items-center">
<div className="t-timer">
{timerVisible
? `${Math.floor(time / 60)}:${String(time % 60).padStart(2, "0")}`
: "--:--"}
</div>
<button
className="mt-1 text-xs text-gray-500 hover:text-gray-700"
onClick={() => setTimerVisible((v) => !v)}
>
{timerVisible ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
<div
className="mt-2 flex items-center gap-2 cursor-pointer"
onClick={() => setShowNavigator(true)}
>
<span
className={`t-q-badge ${isCurrentMarked ? "reviewing" : ""}`}
>
{isCurrentMarked ? "🔖 " : ""}Q
{retryMode ? retryIndex + 1 : questionIndex + 1}
</span>
<span className="text-sm font-bold text-gray-400">
of {currentModule?.questions.length}
</span>
</div>
</div>
{/* Right: highlights */}
<div className="flex items-start">
<button
onClick={() => setHighlightMode((v) => !v)}
className={`t-btn-3d px-5 py-3 flex items-center gap-2 ${
highlightMode ? "t-btn-primary" : "t-btn-outline"
}`}
title="Highlights & Notes"
>
<Highlighter size={18} />
<span className="font-black">Highlights &amp; Notes</span>
</button>
</div>
</div>
{isTargeted && retryMode && (
<p className="text-center text-orange-500 font-bold text-sm mt-2">
🔥 Reviewing incorrect answers — get them right to finish!
</p>
)}
</div>
</header>
{/* Question content */}
<section
className={`flex-1 px-4 ${isMCQ ? "pb-110" : "pb-32"} ${retryMode ? "pt-36 md:pt-52" : "pt-28 md:pt-44"}`}
>
{/* Mobile layout (unchanged) */}
<div className="block md:hidden">
<div className="max-w-2xl mx-auto pt-4">
<div className="space-y-6">
{showDirections ? (
<div className="t-card p-6 flex-1">
<p className="font-semibold text-gray-700 leading-relaxed">
Answer all questions in this section based on the
information provided.
</p>
</div>
) : (
<>
{currentQuestion?.context && (
<div className="t-card p-6">
<p className="font-semibold text-gray-700 leading-relaxed">
{renderQuestionText(currentQuestion.context)}
</p>
</div>
)}
{currentQuestion?.context_image_url &&
currentQuestion.context_image_url !== "NULL" && (
<div className="t-card p-6">
<img
src="https://placehold.co/600x400"
alt="Question context"
className="w-full h-auto rounded-xl"
/>
</div>
)}
<div className="t-card t-card-purple p-6">
<p className="font-bold text-lg text-[#1e1b4b] leading-relaxed">
{currentQuestion?.text &&
renderQuestionText(currentQuestion.text)}
</p>
</div>
{!isMCQ && renderShortAnswer(currentQuestion)}
</>
)}
</div>
</div>
</div>
{/* Desktop layout with draggable separator */}
<div className="hidden md:block">
<div
ref={desktopShellRef}
className="t-desktop-shell pt-8"
style={{ paddingBottom: 160 }}
>
<div className="t-split-layout">
{/* Left column: passage / directions */}
<div
className="t-split-left"
style={{
width: leftColumnWidth,
minWidth: 300,
maxWidth: 600,
}}
>
{showDirections ? (
<div className="t-card p-6 flex-1">
<HighlightableRichText
fieldKey={`${currentQuestion?.id ?? "unknown"}:explanation`}
text={
"Answer all questions in this section based on the information provided."
}
className="font-semibold text-gray-700 leading-relaxed"
/>
</div>
) : (
<>
{currentQuestion?.context_image_url &&
currentQuestion.context_image_url !== "NULL" && (
<div className="t-card p-6">
<img
src="https://placehold.co/600x400"
alt="Question context"
className="w-full h-auto rounded-xl"
/>
</div>
)}
{currentQuestion?.context &&
currentQuestion.context !== "NULL" && (
<div className="t-card p-6">
<HighlightableRichText
fieldKey={`${currentQuestion?.id ?? "unknown"}:context`}
text={currentQuestion.context}
className="font-semibold text-gray-700 leading-relaxed"
/>
</div>
)}
</>
)}
</div>
{/* Draggable divider */}
<div
className="t-split-divider mx-3"
onMouseDown={handleSeparatorMouseDown}
onDragStart={(e) => e.preventDefault()}
>
<div className="t-split-divider-bar" />
<div className="t-split-divider-handle">
<div className="t-split-divider-dots" />
</div>
</div>
{/* Right column: question + options/answer */}
<div className="t-split-right flex-1">
<div className="t-card t-card-purple p-6 mt-4">
{currentQuestion?.text && (
<HighlightableRichText
fieldKey={`${currentQuestion.id}:text`}
text={currentQuestion.text}
className="font-bold text-lg text-[#1e1b4b] leading-relaxed"
/>
)}
</div>
{!isMCQ && renderShortAnswer(currentQuestion)}
{isMCQ && renderOptions(currentQuestion)}
</div>
</div>
</div>
</div>
</section>
</section>
{/* Fixed bottom bar */}
<section
className="fixed bottom-0 left-0 right-0 bg-white border-t-2 border-gray-100 py-4 px-4"
style={{ zIndex: 20 }}
>
<div className="mx-auto max-w-2xl md:max-w-(--content-max) flex items-center justify-between gap-3">
{/* Back button (non-targeted) */}
{!isTargeted && (
<button
disabled={isFirstQuestion}
onClick={prevQuestion}
className="t-btn-3d t-btn-outline px-5 py-3 disabled:opacity-40 flex items-center gap-2"
>
<ChevronLeft size={18} />
<span className="hidden md:inline font-black">Previous</span>
</button>
)}
{/* Menu */}
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<button className="t-btn-3d t-btn-outline px-5 py-3 flex items-center gap-2">
<Menu size={18} />
<span className="hidden md:inline font-black">Menu</span>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent className="rounded-2xl border-2 border-gray-100 p-2">
<DropdownMenuGroup>
{!isTargeted && (
<DropdownMenuItem
className="rounded-xl font-bold py-3 px-4 cursor-pointer"
onClick={() => setShowNavigator(true)}
>
<Binary size={18} className="mr-2" /> Go To Question
</DropdownMenuItem>
)}
{!isTargeted && (
<DropdownMenuItem
className="rounded-xl font-bold py-3 px-4 cursor-pointer"
onSelect={(e) => {
e.preventDefault();
setMenuOpen(false);
setCalcOpen(true);
}}
>
<Calculator size={18} className="mr-2" /> Calculator
</DropdownMenuItem>
)}
{/* Reference Sheet — always available */}
<DropdownMenuItem
className="rounded-xl font-bold py-3 px-4 cursor-pointer"
onSelect={(e) => {
e.preventDefault();
setMenuOpen(false);
setShowRefSheet(true);
}}
>
<BookOpen size={18} className="mr-2" /> Reference Sheet
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<Dialog>
<DialogTrigger asChild>
<DropdownMenuItem
className="rounded-xl font-bold py-3 px-4 cursor-pointer text-red-600 focus:text-red-600 focus:bg-red-50"
onSelect={(e) => e.preventDefault()}
>
<LogOut size={18} className="mr-2" /> Quit Test
</DropdownMenuItem>
</DialogTrigger>
<DialogContent className="rounded-3xl border-2 border-gray-100 max-w-sm">
<DialogHeader>
<DialogTitle className="font-black text-xl text-[#1e1b4b]">
Want to quit?
</DialogTitle>
<DialogDescription className="font-semibold text-gray-500">
Your progress will be saved and you can resume later.
</DialogDescription>
</DialogHeader>
<div className="flex gap-3 mt-6">
<DialogClose asChild>
<button className="t-btn-3d t-btn-outline flex-1 py-3">
Stay
</button>
</DialogClose>
<button
onClick={handleQuitExam}
className="t-btn-3d bg-red-500 text-white flex-1 py-3 shadow-[0_4px_0_#dc2626] hover:shadow-[0_6px_0_#dc2626] active:shadow-[0_2px_0_#dc2626]"
>
Quit
</button>
</div>
</DialogContent>
</Dialog>
</DropdownMenuContent>
</DropdownMenu>
<GraphCalculatorModal
open={calcOpen}
onClose={() => setCalcOpen(false)}
/>
{/* ── Mark for Review button (non-targeted) ── */}
{!isTargeted && (
<>
{/* Mobile: keep existing icon-only button */}
{!isMdUp ? (
<button
className={`t-bookmark-btn ${isCurrentMarked ? "marked" : ""}`}
onClick={toggleMark}
title={
isCurrentMarked ? "Remove review mark" : "Mark for review"
}
>
{isCurrentMarked ? (
<BookMarked size={18} color="white" />
) : (
<Bookmark size={18} color="#9ca3af" />
)}
</button>
) : (
/* Desktop/web: icon + text */
<button
onClick={toggleMark}
className={`t-btn-3d px-5 py-3 flex items-center gap-2 ${
isCurrentMarked ? "t-btn-primary" : "t-btn-outline"
}`}
title={
isCurrentMarked ? "Remove review mark" : "Mark for review"
}
>
{isCurrentMarked ? (
<BookMarked size={18} color="white" />
) : (
<Bookmark size={18} color="#9ca3af" />
)}
<span className="font-black">
{isCurrentMarked
? "Marked for review"
: "Mark for review"}
</span>
</button>
)}
</>
)}
{/* Question navigator dialog */}
{!isTargeted && (
<Dialog open={showNavigator} onOpenChange={setShowNavigator}>
<DialogContent className="rounded-3xl border-2 border-gray-100 max-w-sm max-h-[80vh]">
<DialogHeader>
<DialogTitle className="font-black text-xl text-[#1e1b4b]">
Go to Question
</DialogTitle>
<DialogDescription className="font-semibold text-gray-500">
Select a question to jump to it directly.
</DialogDescription>
</DialogHeader>
{/* Legend */}
<div className="flex items-center gap-3 mb-4 text-xs flex-wrap">
<div className="flex items-center gap-1.5">
<div className="w-4 h-4 rounded bg-purple-500 border-2 border-purple-600" />
<span className="font-semibold text-gray-600">
Current
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-4 h-4 rounded bg-green-100 border-2 border-green-400" />
<span className="font-semibold text-gray-600">
Answered
</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-4 h-4 rounded bg-amber-50 border-2 border-amber-400" />
<span className="font-semibold text-gray-600">
Marked
</span>
</div>
</div>
{/* Marked summary */}
{markedForReview.size > 0 && (
<div
style={{
background: "#fffbeb",
border: "2px solid #fde68a",
borderRadius: 14,
padding: "0.6rem 0.85rem",
marginBottom: "0.75rem",
}}
>
<p
style={{
fontFamily: "'Nunito',sans-serif",
fontSize: "0.75rem",
fontWeight: 800,
color: "#d97706",
}}
>
🔖 {markedForReview.size} question
{markedForReview.size !== 1 ? "s" : ""} marked for
review
</p>
</div>
)}
<div className="t-nav-grid">
{currentModule?.questions.map((q, idx) => {
const isCurrent = idx === questionIndex;
const isAnswered = !!answers[q.id];
const isMarked = markedForReview.has(q.id);
let cls = "t-nav-item";
if (isCurrent && isMarked) cls += " current marked";
else if (isCurrent) cls += " current";
else if (isMarked) cls += " marked";
else if (isAnswered) cls += " answered";
return (
<button
key={q.id}
className={cls}
onClick={() => {
goToQuestion(idx);
setShowNavigator(false);
}}
>
{idx + 1}
{isMarked && !isCurrent && (
<span className="t-nav-bookmark-dot" />
)}
</button>
);
})}
</div>
</DialogContent>
</Dialog>
)}
{/* Exit dialog triggered by navigation blocker */}
<Dialog open={showExitDialog} onOpenChange={setShowExitDialog}>
<DialogContent className="rounded-3xl border-2 border-gray-100 max-w-sm">
<DialogHeader>
<DialogTitle className="font-black text-xl text-[#1e1b4b]">
Want to quit?
</DialogTitle>
<DialogDescription className="font-semibold text-gray-500">
Your progress will be saved and you can resume later.
</DialogDescription>
</DialogHeader>
<div className="flex gap-3 mt-6">
<button
onClick={() => {
setShowExitDialog(false);
blocker.reset?.();
}}
className="t-btn-3d t-btn-outline flex-1 py-3"
>
Stay
</button>
<button
onClick={() => {
finishExam();
blocker.proceed?.();
}}
className="t-btn-3d bg-red-500 text-white flex-1 py-3 shadow-[0_4px_0_#dc2626] hover:shadow-[0_6px_0_#dc2626] active:shadow-[0_2px_0_#dc2626]"
>
Quit Test
</button>
</div>
</DialogContent>
</Dialog>
{/* Next / Submit */}
<button
disabled={
isSubmitting ||
(isTargeted && !!feedback?.show) ||
!currentAnswer
}
onClick={handleNext}
className="t-btn-3d t-btn-primary px-8 py-3 flex items-center gap-2 disabled:opacity-50"
>
{isSubmitting ? (
<Loader2 size={20} className="animate-spin" />
) : isTargeted ? (
<>
<Check size={18} /> Submit
</>
) : isLastQuestion && markedForReview.size > 0 ? (
<>
<BookMarked size={16} /> Finish
</>
) : (
<>
Next <ChevronLeft size={18} className="rotate-180" />
</>
)}
</button>
</div>
</section>
{/* MCQ Options Drawer (mobile only) */}
{isMCQ && !isMdUp && (
<Drawer.Root
modal={false}
snapPoints={[0.35, 0.6, 0.95]}
defaultOpen={true}
>
<Drawer.Portal>
<Drawer.Content
className="fixed flex flex-col bg-white border-t-2 border-gray-100 rounded-t-[24px] bottom-0 left-0 right-0 h-[85vh]"
style={{ zIndex: 10 }}
>
<div className="flex justify-center pt-4 pb-2 shrink-0 cursor-grab active:cursor-grabbing">
<div className="w-12 h-1.5 rounded-full bg-gray-300" />
</div>
<Drawer.Title className="font-bold text-sm text-gray-400 text-center pb-4 pointer-events-none">
Choose your answer
</Drawer.Title>
<div className="flex-1 overflow-y-auto px-5 pb-40">
{renderOptions(currentQuestion)}
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
)}
</div>
);
// ─── BREAK ────────────────────────────────────────────────────────────────────
if (phase === "BREAK")
return (
<div className="test-screen">
<style>{GLOBAL_STYLES}</style>
<BgLayer />
<main className="relative z-10 min-h-screen flex flex-col items-center justify-center px-6 py-8">
<span className="text-6xl mb-4 animate-bounce">🎉</span>
<h1 className="text-4xl font-black text-[#1e1b4b] mb-2 text-center">
You're doing <span className="text-orange-500">great!</span>
</h1>
<p className="text-gray-500 font-semibold mb-8">
Take a breather next module coming up
</p>
<div className="t-card p-6 mb-8 text-center min-w-50">
<p className="text-xs font-bold text-gray-400 uppercase tracking-widest mb-2">
Next module in
</p>
<p className="t-timer">{formatTime(time)}</p>
</div>
<div className="flex gap-3 mb-8 flex-wrap justify-center">
{["💧 Drink water", "🙆 Stretch", "😮‍💨 Breathe"].map((t) => (
<span
key={t}
className="bg-white border-2 border-gray-100 rounded-full px-4 py-2 text-sm font-bold text-gray-600 shadow-sm"
>
{t}
</span>
))}
</div>
<button
onClick={() => useSatExam.getState().skipBreak()}
className="t-btn-3d t-btn-accent px-8 py-4"
>
Skip Break
</button>
</main>
</div>
);
// ─── FINISHED ─────────────────────────────────────────────────────────────────
if (phase === "FINISHED")
return (
<div className="test-screen">
<style>{GLOBAL_STYLES}</style>
<BgLayer />
<main className="relative z-10 min-h-screen flex flex-col items-center justify-center px-6 py-8">
<span className="text-6xl mb-4"></span>
<h1 className="text-4xl font-black text-[#1e1b4b] mb-2 text-center">
Time's <span className="text-orange-500">up!</span>
</h1>
<p className="text-gray-500 font-semibold mb-8">
You crushed it — let's see how you did
</p>
<div className="flex gap-4 mb-8 flex-wrap justify-center">
{[
["✏️", "Done", "Section"],
["🏆", "Nice!", "Results"],
["🔥", "100%", "Effort"],
].map(([e, v, l]) => (
<div key={l} className="t-card p-4 text-center min-w-25">
<span className="text-2xl block mb-1">{e}</span>
<span className="font-black text-lg text-[#1e1b4b]">{v}</span>
<span className="text-xs font-bold text-gray-400 uppercase block">
{l}
</span>
</div>
))}
</div>
<div className="flex items-center gap-3 bg-white border-2 border-gray-100 rounded-full px-6 py-3 shadow-sm">
<Loader2 size={18} className="animate-spin text-orange-500" />
<span className="font-bold text-gray-600">
Redirecting to results...
</span>
</div>
</main>
</div>
);
if (phase === "QUIT") return <Navigate to="/student/home" replace />;
return null;
};