2226 lines
84 KiB
TypeScript
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 & 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 & 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;
|
|
};
|