Files
edbridge-scholars/src/pages/student/practice/Test.tsx

1608 lines
55 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState, useRef } from "react";
import { Navigate, useNavigate } from "react-router-dom";
import {
Binary,
Calculator,
Check,
Loader2,
LogOut,
Unplug,
Play,
ChevronLeft,
Menu,
X,
} from "lucide-react";
import { api } from "../../../utils/api";
import { useAuthStore } from "../../../stores/authStore";
import type { Question } from "../../../types/sheet";
import { useSatExam } from "../../../stores/useSatExam";
import { useSatTimer } from "../../../hooks/useSatTimer";
import type {
SessionModuleQuestions,
SubmitAnswer,
} from "../../../types/session";
import { useAuthToken } from "../../../hooks/useAuthToken";
import { renderQuestionText } from "../../../components/RenderQuestionText";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../../../components/ui/dropdown-menu";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { Drawer } from "vaul";
import { useExamNavigationGuard } from "../../../hooks/useExamNavGuard";
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
import { useResults } from "../../../stores/useResults";
import { GraphCalculatorModal } from "../../../components/Calculator";
// ─── Shared Style Constants ───────────────────────────────────────────────────
const COLORS = {
bg: "#fffbf4",
primary: "#a855f7",
primaryDark: "#7c3aed",
accent: "#f97316",
accentDark: "#c2560e",
text: "#1e1b4b",
textMuted: "#6b7280",
textLight: "#9ca3af",
border: "#f3f4f6",
borderPurple: "#c4b5fd",
success: "#22c55e",
error: "#ef4444",
blob1: "#fde68a",
blob2: "#a5f3c0",
blob3: "#fbcfe8",
blob4: "#bfdbfe",
};
const DOTS = [
{ size: 12, color: "#f97316", top: "8%", left: "6%", delay: "0s" },
{ size: 8, color: "#a855f7", top: "22%", left: "2%", delay: "1s" },
{ size: 10, color: "#22c55e", top: "55%", left: "4%", delay: "0.5s" },
{ size: 14, color: "#3b82f6", top: "10%", right: "5%", delay: "1.5s" },
{ size: 8, color: "#f43f5e", top: "40%", right: "3%", delay: "0.8s" },
{ size: 10, color: "#eab308", top: "70%", right: "7%", delay: "0.3s" },
];
// ─── Global Styles ────────────────────────────────────────────────────────────
const GLOBAL_STYLES = `
@import url('https://fonts.googleapis.com/css2?family=Nunito:wght@400;600;700;800;900&family=Nunito+Sans:wght@400;600;700&display=swap');
.test-screen {
min-height: 100vh;
background: ${COLORS.bg};
font-family: 'Nunito', sans-serif;
position: relative;
overflow-x: hidden;
}
/* ── Blobs ── */
.t-blob {
position: fixed;
pointer-events: none;
z-index: 0;
filter: blur(48px);
opacity: 0.35;
}
.t-blob-1 {
width: 240px; height: 240px;
background: ${COLORS.blob1};
top: -80px; left: -80px;
border-radius: 60% 40% 70% 30% / 50% 60% 40% 50%;
animation: tWobble1 14s ease-in-out infinite;
}
.t-blob-2 {
width: 190px; height: 190px;
background: ${COLORS.blob2};
bottom: -50px; left: 6%;
border-radius: 40% 60% 30% 70% / 60% 40% 60% 40%;
animation: tWobble2 16s ease-in-out infinite;
}
.t-blob-3 {
width: 210px; height: 210px;
background: ${COLORS.blob3};
top: 15%; right: -60px;
border-radius: 70% 30% 50% 50% / 40% 60% 40% 60%;
animation: tWobble1 18s ease-in-out infinite reverse;
}
.t-blob-4 {
width: 150px; height: 150px;
background: ${COLORS.blob4};
bottom: 12%; right: 2%;
border-radius: 50% 50% 30% 70% / 60% 40% 60% 40%;
animation: tWobble2 12s ease-in-out infinite;
}
@keyframes tWobble1 {
0%, 100% { border-radius: 60% 40% 70% 30% / 50% 60% 40% 50%; transform: translate(0,0) rotate(0deg); }
50% { border-radius: 40% 60% 30% 70% / 60% 40% 60% 40%; transform: translate(12px, 16px) rotate(8deg); }
}
@keyframes tWobble2 {
0%, 100% { border-radius: 40% 60% 30% 70% / 60% 40% 60% 40%; transform: translate(0,0) rotate(0deg); }
50% { border-radius: 60% 40% 70% 30% / 40% 60% 40% 60%; transform: translate(-10px, 12px) rotate(-6deg); }
}
/* ── Floating dots ── */
.t-dot {
position: fixed;
border-radius: 50%;
pointer-events: none;
z-index: 0;
opacity: 0.3;
animation: tFloat 6s ease-in-out infinite;
}
@keyframes tFloat {
0%, 100% { transform: translateY(0) rotate(0deg); }
50% { transform: translateY(-14px) rotate(180deg); }
}
/* ── Animations ── */
@keyframes tPopIn {
from { opacity: 0; transform: scale(0.92) translateY(10px); }
to { opacity: 1; transform: scale(1) translateY(0); }
}
.t-anim { animation: tPopIn 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) both; }
.t-anim-1 { animation-delay: 0.05s; }
.t-anim-2 { animation-delay: 0.1s; }
.t-anim-3 { animation-delay: 0.15s; }
.t-anim-4 { animation-delay: 0.2s; }
.t-anim-5 { animation-delay: 0.25s; }
/* ── 3D Button Styles ── */
.t-btn-3d {
font-family: 'Nunito', sans-serif;
font-weight: 800;
border: none;
border-radius: 100px;
cursor: pointer;
transition: transform 0.1s ease, box-shadow 0.1s ease;
}
.t-btn-3d:hover { transform: translateY(-2px); }
.t-btn-3d:active { transform: translateY(2px); }
.t-btn-primary {
background: ${COLORS.primary};
color: white;
box-shadow: 0 4px 0 ${COLORS.primaryDark}, 0 6px 16px rgba(168, 85, 247, 0.3);
}
.t-btn-primary:hover {
box-shadow: 0 6px 0 ${COLORS.primaryDark}, 0 10px 20px rgba(168, 85, 247, 0.35);
}
.t-btn-primary:active {
box-shadow: 0 2px 0 ${COLORS.primaryDark}, 0 3px 8px rgba(168, 85, 247, 0.2);
}
.t-btn-accent {
background: ${COLORS.accent};
color: white;
box-shadow: 0 4px 0 ${COLORS.accentDark}, 0 6px 16px rgba(249, 115, 22, 0.3);
}
.t-btn-accent:hover {
box-shadow: 0 6px 0 ${COLORS.accentDark}, 0 10px 20px rgba(249, 115, 22, 0.35);
}
.t-btn-accent:active {
box-shadow: 0 2px 0 ${COLORS.accentDark}, 0 3px 8px rgba(249, 115, 22, 0.2);
}
.t-btn-outline {
background: white;
color: ${COLORS.text};
border: 2.5px solid ${COLORS.border};
box-shadow: 0 3px 10px rgba(0,0,0,0.06);
}
.t-btn-outline:hover {
border-color: ${COLORS.borderPurple};
box-shadow: 0 6px 14px rgba(0,0,0,0.08);
}
/* ── Cards ── */
.t-card {
background: white;
border: 2.5px solid ${COLORS.border};
border-radius: 22px;
box-shadow: 0 4px 14px rgba(0,0,0,0.05);
}
.t-card-purple {
border-color: ${COLORS.borderPurple};
box-shadow: 0 4px 16px rgba(167, 139, 250, 0.12);
}
/* ── Section Title ── */
.t-section-title {
font-size: 1.2rem;
font-weight: 900;
color: ${COLORS.text};
letter-spacing: -0.01em;
}
/* ── Timer Display ── */
.t-timer {
font-size: 2.5rem;
font-weight: 900;
color: ${COLORS.text};
letter-spacing: -0.03em;
line-height: 1;
}
/* ── Question Number Badge ── */
.t-q-badge {
background: linear-gradient(135deg, ${COLORS.primary}, ${COLORS.primaryDark});
color: white;
font-weight: 800;
font-size: 0.75rem;
padding: 0.4rem 0.9rem;
border-radius: 100px;
}
/* ── Option Buttons ── */
.t-option {
width: 100%;
text-align: start;
font-family: 'Nunito', sans-serif;
font-weight: 700;
font-size: 1rem;
padding: 1rem 1.25rem;
background: white;
border: 2.5px solid ${COLORS.border};
border-radius: 18px;
cursor: pointer;
transition: all 0.15s ease;
display: flex;
align-items: center;
gap: 0.75rem;
}
.t-option:hover:not(:disabled) {
border-color: ${COLORS.borderPurple};
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(0,0,0,0.06);
}
.t-option.selected {
background: linear-gradient(135deg, ${COLORS.primary}, ${COLORS.primaryDark});
border-color: ${COLORS.primaryDark};
color: white;
}
.t-option.correct {
background: linear-gradient(135deg, #22c55e, #16a34a);
border-color: #16a34a;
color: white;
}
.t-option.incorrect {
background: linear-gradient(135deg, #ef4444, #dc2626);
border-color: #dc2626;
color: white;
}
.t-option.eliminated {
opacity: 0.5;
text-decoration: line-through;
}
.t-option-letter {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-weight: 800;
font-size: 0.85rem;
flex-shrink: 0;
}
.t-option:not(.selected) .t-option-letter {
background: ${COLORS.primary};
color: white;
}
.t-option.selected .t-option-letter {
background: white;
color: ${COLORS.primary};
}
/* ── Eliminate Button ── */
.t-eliminate-btn {
width: 32px;
height: 32px;
border-radius: 50%;
border: 2.5px solid ${COLORS.border};
background: white;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.15s ease;
flex-shrink: 0;
}
.t-eliminate-btn:hover {
border-color: #ef4444;
background: #fef2f2;
}
.t-eliminate-btn.active {
background: #ef4444;
border-color: #ef4444;
color: white;
}
/* ── Textarea ── */
.t-textarea {
width: 100%;
min-height: 120px;
padding: 1rem 1.25rem;
background: white;
border: 2.5px solid ${COLORS.border};
border-radius: 18px;
font-family: 'Nunito', sans-serif;
font-size: 1rem;
font-weight: 600;
color: ${COLORS.text};
resize: vertical;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
}
.t-textarea:focus {
outline: none;
border-color: ${COLORS.borderPurple};
box-shadow: 0 4px 16px rgba(167, 139, 250, 0.15);
}
.t-textarea::placeholder {
color: ${COLORS.textLight};
}
/* ── Retry Banner ── */
.t-retry-banner {
background: linear-gradient(90deg, ${COLORS.accent}, #ea580c);
color: white;
padding: 0.75rem 1rem;
font-weight: 700;
font-size: 0.9rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
/* ── XP Badge ── */
.t-xp-badge {
position: absolute;
top: -8px;
right: -8px;
background: linear-gradient(135deg, #fbbf24, #f59e0b);
color: white;
font-size: 0.7rem;
font-weight: 800;
padding: 0.25rem 0.6rem;
border-radius: 100px;
box-shadow: 0 2px 8px rgba(251, 191, 36, 0.4);
animation: tBounce 0.5s ease;
}
@keyframes tBounce {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.2); }
}
/* ── Drawer Custom Styles ── */
[data-vaul-drawer] {
background: white !important;
border-top: 2.5px solid ${COLORS.border} !important;
border-radius: 24px 24px 0 0 !important;
}
[data-vaul-drawer-handle] {
background: ${COLORS.border} !important;
width: 48px !important;
height: 5px !important;
border-radius: 3px !important;
}
/* ── Navigator Grid ── */
.t-nav-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 0.5rem;
}
.t-nav-item {
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
font-weight: 800;
font-size: 0.9rem;
border: 2.5px solid ${COLORS.border};
background: white;
cursor: pointer;
transition: all 0.15s ease;
}
.t-nav-item:hover {
border-color: ${COLORS.borderPurple};
transform: scale(1.05);
}
.t-nav-item.current {
background: linear-gradient(135deg, ${COLORS.primary}, ${COLORS.primaryDark});
border-color: ${COLORS.primaryDark};
color: white;
}
.t-nav-item.answered {
background: #dcfce7;
border-color: #22c55e;
color: #16a34a;
}
/* ── Incorrect Flash ── */
@keyframes tFlashRed {
0%, 100% { background: transparent; }
50% { background: rgba(239, 68, 68, 0.15); }
}
.t-flash-red {
animation: tFlashRed 0.6s ease;
}
`;
// ─── Confetti particle type ───────────────────────────────────────────────────
interface ConfettiParticle {
id: number;
x: number;
color: string;
size: number;
delay: number;
duration: number;
rotation: number;
}
// ─── Answer feedback state ────────────────────────────────────────────────────
type FeedbackState = {
show: boolean;
correct: boolean;
xpGained?: number;
selectedOptionId?: string;
} | null;
// ─── Targeted incorrect queue ─────────────────────────────────────────────────
interface IncorrectEntry {
questionId: string;
originalIndex: number;
}
// ─── Confetti Component ───────────────────────────────────────────────────────
const Confetti = ({ active }: { active: boolean }) => {
const [particles, setParticles] = useState<ConfettiParticle[]>([]);
useEffect(() => {
if (!active) {
setParticles([]);
return;
}
const colors = [
"#a855f7",
"#ec4899",
"#f59e0b",
"#10b981",
"#3b82f6",
"#f97316",
"#eab308",
];
const newParticles: ConfettiParticle[] = Array.from(
{ length: 60 },
(_, i) => ({
id: i,
x: Math.random() * 100,
color: colors[Math.floor(Math.random() * colors.length)],
size: Math.random() * 8 + 4,
delay: Math.random() * 0.5,
duration: Math.random() * 1.5 + 1.5,
rotation: Math.random() * 360,
}),
);
setParticles(newParticles);
}, [active]);
if (!active || particles.length === 0) return null;
return (
<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 snapPoints = [
"190px",
"250px",
"300px",
"350px",
"400px",
"500px",
"600px",
"700px",
];
// ─── XP Popup Component ───────────────────────────────────────────────────────
const XPPopup = ({ xp, show }: { xp: number; show: boolean }) => {
if (!show) return null;
return (
<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>
);
};
// ─── Main Component ───────────────────────────────────────────────────────────
export const Test = () => {
const sheetId = localStorage.getItem("activePracticeSheetId");
const blocker = useExamNavigationGuard();
const [eliminated, setEliminated] = useState<Record<string, Set<string>>>({});
const [showExitDialog, setShowExitDialog] = useState(false);
const [error, setError] = useState<string | null>(null);
const [calcOpen, setCalcOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
useEffect(() => {
if (blocker.state === "blocked") setShowExitDialog(true);
}, [blocker.state]);
const navigate = useNavigate();
const { user } = useAuthStore();
const token = useAuthToken();
const [answers, setAnswers] = useState<Record<string, string>>({});
const [showNavigator, setShowNavigator] = useState<boolean>(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [sessionId, setSessionId] = useState<string | null>(null);
const examMode = useExamConfigStore((s) => s.payload?.mode);
const isTargeted = examMode === "TARGETED";
const [feedback, setFeedback] = useState<FeedbackState>(null);
const [showConfetti, setShowConfetti] = useState(false);
const [showIncorrectFlash, setShowIncorrectFlash] = useState(false);
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);
const time = useSatTimer();
const phase = useSatExam((s) => s.phase);
const currentModule = useSatExam((s) => s.currentModuleQuestions);
const questionIndex = useSatExam((s) => s.questionIndex);
const currentQuestion = retryMode
? currentModule?.questions[retryQueue[retryIndex]?.originalIndex]
: currentModule?.questions[questionIndex];
const currentAnswer = currentQuestion
? (answers[currentQuestion.id] ?? "")
: "";
const resetExam = useSatExam((s) => s.resetExam);
const nextQuestion = useSatExam((s) => s.nextQuestion);
const prevQuestion = useSatExam((s) => s.prevQuestion);
const goToQuestion = useSatExam((s) => s.goToQuestion);
const finishExam = useSatExam((s) => s.finishExam);
const quitExam = useSatExam((s) => s.quitExam);
const setResults = useResults((s) => s.setResults);
const [snap, setSnap] = useState<number | string | null>(snapPoints[0]);
const startExam = async () => {
console.log("startExam called", { user, sheetId });
if (!user) {
console.warn("Missing user or sheetId");
return;
}
const payload = useExamConfigStore.getState().payload;
console.log("payload", payload);
try {
const response = await api.startSession(token as string, payload);
console.log("session started", response);
setSessionId(response.id);
await loadSessionQuestions(response.id);
useSatExam.getState().startExam();
} catch (error) {
setError(`Failed to start exam session: ${error}`);
}
};
const loadSessionQuestions = async (sessionId: string) => {
if (!token) return;
try {
const data = await api.fetchSessionQuestions(token, sessionId);
const module: SessionModuleQuestions = {
module_id: data.module_id,
module_title: data.module_title,
time_limit_minutes: data.time_limit_minutes * 60,
questions: data.questions.map((q) => ({
id: q.id,
text: q.text,
context: q.context,
context_image_url: q.context_image_url,
type: q.type,
section: q.section,
image_url: q.image_url,
index: q.index,
difficulty: q.difficulty,
correct_answer: q.correct_answer,
explanation: q.explanation,
topics: q.topics,
options: q.options,
})),
};
useSatExam.getState().setModuleQuestions(module);
} catch (err) {
console.error("Failed to load session questions:", err);
}
};
const showAnswerFeedback = (
correct: boolean,
xpGained?: number,
selectedOptionId?: string,
) => {
setFeedback({ show: true, correct, xpGained, selectedOptionId });
if (correct) {
setShowConfetti(true);
setTimeout(() => setShowConfetti(false), 2500);
} else {
setShowIncorrectFlash(true);
setTimeout(() => setShowIncorrectFlash(false), 1300);
}
};
const advanceRetry = async () => {
setFeedback(null);
const nextRetryIndex = retryIndex + 1;
const remaining = retryQueue.filter(
(e) => !correctedRef.current.has(e.questionId),
);
if (remaining.length === 0) {
const next = await api.fetchNextModule(token!, sessionId);
if (next.status === "COMPLETED") finishExam();
return;
}
if (nextRetryIndex >= retryQueue.length) {
const stillIncorrect = incorrectQueueRef.current.filter(
(e) => !correctedRef.current.has(e.questionId),
);
setRetryQueue(stillIncorrect);
setRetryIndex(0);
goToQuestion(stillIncorrect[0].originalIndex);
} else {
const nextEntry = retryQueue[nextRetryIndex];
setRetryIndex(nextRetryIndex);
goToQuestion(nextEntry.originalIndex);
}
};
const submitTargetedAnswer = async () => {
if (!currentQuestion || !sessionId) return;
const userAnswer = answers[currentQuestion.id] ?? "";
let answerText = "";
if (currentQuestion.options?.length) {
const selected = currentQuestion.options.find(
(opt) => opt.id === userAnswer,
);
answerText = selected?.text ?? "";
} else {
answerText = userAnswer;
}
const payload: SubmitAnswer = {
question_id: currentQuestion.id,
answer_text: answerText,
time_spent_seconds: 3,
};
try {
setIsSubmitting(true);
const result = await api.submitAnswer(token!, sessionId, payload);
const isCorrect: boolean = result?.feedback.is_correct ?? false;
const xpGained: number | undefined = 2;
showAnswerFeedback(isCorrect, xpGained, userAnswer);
if (isCorrect) {
correctedRef.current.add(currentQuestion.id);
incorrectQueueRef.current = incorrectQueueRef.current.filter(
(e) => e.questionId !== currentQuestion.id,
);
} else {
const alreadyTracked = incorrectQueueRef.current.some(
(e) => e.questionId === currentQuestion.id,
);
if (!alreadyTracked) {
incorrectQueueRef.current.push({
questionId: currentQuestion.id,
originalIndex: retryMode
? retryQueue[retryIndex].originalIndex
: questionIndex,
});
}
correctedRef.current.delete(currentQuestion.id);
}
const delay = isCorrect ? 2200 : 1500;
setTimeout(async () => {
setFeedback(null);
if (retryMode) {
advanceRetry();
return;
}
const isLastQuestion =
questionIndex === currentModule!.questions.length - 1;
if (!isLastQuestion) {
nextQuestion();
return;
}
if (incorrectQueueRef.current.length > 0) {
const queue = [...incorrectQueueRef.current];
setRetryQueue(queue);
setRetryIndex(0);
setRetryMode(true);
goToQuestion(queue[0].originalIndex);
return;
}
await proceedAfterLastQuestion();
}, delay);
} catch (err) {
console.error("Failed to submit answer:", err);
} finally {
setIsSubmitting(false);
}
};
const proceedAfterLastQuestion = async () => {
if (!sessionId) return;
const next = await api.fetchNextModule(token!, sessionId);
if (next?.status === "COMPLETED") {
setResults(next.results);
finishExam();
} else {
await loadSessionQuestions(sessionId);
useSatExam.getState().startBreak();
}
};
const handleNext = async () => {
if (isTargeted) {
await submitTargetedAnswer();
return;
}
if (!currentQuestion || !sessionId) return;
const userAnswer = answers[currentQuestion.id] ?? "";
let answerText = "";
if (currentQuestion.options?.length) {
const selected = currentQuestion.options.find(
(opt) => opt.id === userAnswer,
);
answerText = selected?.text ?? "";
} else {
answerText = userAnswer;
}
const payload: SubmitAnswer = {
question_id: currentQuestion.id,
answer_text: answerText,
time_spent_seconds: 3,
};
try {
setIsSubmitting(true);
await api.submitAnswer(token!, sessionId, payload);
} catch (err) {
console.error("Failed to submit answer:", err);
} finally {
setIsSubmitting(false);
}
const isLastQuestion =
questionIndex === currentModule!.questions.length - 1;
if (!isLastQuestion) {
nextQuestion();
return;
}
const next = await api.fetchNextModule(token!, sessionId);
if (next?.status === "COMPLETED") {
setResults(next.results);
finishExam();
} else {
await loadSessionQuestions(sessionId);
useSatExam.getState().startBreak();
}
};
const handleQuitExam = () => {
useExamConfigStore.getState().clearPayload();
quitExam();
};
useEffect(() => {
return () => {
resetExam();
};
}, []);
useEffect(() => {
if (phase === "FINISHED") {
const timer = setTimeout(() => {
navigate(`/student/practice/${sheetId}/test/results`, {
replace: true,
});
}, 3000);
return () => clearTimeout(timer);
}
}, [phase]);
useEffect(() => {
if (!user) return;
}, [sheetId]);
const isFirstQuestion = retryMode ? true : questionIndex === 0;
const toggleEliminate = (questionId: string, optionId: string) => {
setEliminated((prev) => {
const current = new Set(prev[questionId] ?? []);
current.has(optionId) ? current.delete(optionId) : current.add(optionId);
return { ...prev, [questionId]: current };
});
};
// ─── Render MCQ options for the drawer ────────────────────────────────────
const renderOptions = (question?: Question) => {
if (!question?.options?.length) return null;
const eliminatedSet = eliminated[question.id] ?? new Set();
return (
<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 optionClass = "t-option";
if (isSelected) optionClass += " selected";
if (isEliminated && !feedbackLocked) optionClass += " eliminated";
if (feedbackLocked && isSelected) {
optionClass += 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={optionClass}
onClick={() => {
if (feedbackLocked) return;
setAnswers((prev) => ({
...prev,
[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>
);
};
// ─── Render short-answer (stays inline, not in drawer) ────────────────────
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((prev) => ({ ...prev, [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>
);
};
const isMCQ = !!currentQuestion?.options?.length;
const formatTime = (totalSeconds: number) => {
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
switch (phase) {
case "IDLE":
if (error) {
return (
<div className="test-screen">
<style>{GLOBAL_STYLES}</style>
{/* Blobs */}
<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 */}
{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 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>
{/* Blobs */}
<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 */}
{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 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">
<div 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 flex-shrink-0">
<Check size={20} className="text-purple-600" />
</div>
<span className="font-bold text-gray-700">
Full-screen mode for distraction-free focus
</span>
</div>
<div 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 flex-shrink-0">
<Check size={20} className="text-purple-600" />
</div>
<span className="font-bold text-gray-700">
Press{" "}
<kbd className="px-2 py-1 bg-white rounded-lg border-2 border-gray-200 font-mono text-sm">
Esc
</kbd>{" "}
to exit anytime
</span>
</div>
<div 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 flex-shrink-0">
<Check size={20} className="text-purple-600" />
</div>
<span className="font-bold text-gray-700">
Progress saves automatically
</span>
</div>
{isTargeted && (
<div className="flex items-center gap-4 p-4 bg-gradient-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>
);
case "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 />
{/* Blobs */}
<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" />
<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="max-w-2xl mx-auto">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<span className="t-q-badge">
Q{retryMode ? retryIndex + 1 : questionIndex + 1}
</span>
<span className="text-sm font-bold text-gray-400">
of {currentModule?.questions.length}
</span>
</div>
<div className="t-timer">
{Math.floor(time / 60)}:{String(time % 60).padStart(2, "0")}
</div>
</div>
<h1 className="text-center font-bold text-gray-700">
{currentModule?.module_title}
</h1>
{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" : "pt-28"}`}
>
<div className="max-w-2xl mx-auto space-y-6 pt-4">
{currentQuestion?.context && (
<div className="t-card p-6">
<p className="font-semibold text-gray-700 leading-relaxed">
{renderQuestionText(currentQuestion?.context)}
</p>
</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>
{/* Short answer stays inline */}
{!isMCQ && renderShortAnswer(currentQuestion)}
</div>
</section>
{/* ── Fixed bottom bar ── */}
</section>
<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="max-w-2xl mx-auto flex items-center justify-between gap-3">
{!isTargeted && (
<button
disabled={isFirstQuestion}
onClick={prevQuestion}
className="t-btn-3d t-btn-outline px-5 py-3 disabled:opacity-40"
>
<ChevronLeft size={18} />
</button>
)}
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}>
<DropdownMenuTrigger asChild>
<button className="t-btn-3d t-btn-outline px-5 py-3">
<Menu size={18} />
</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" />
<span>Go To Question</span>
</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" />
<span>Calculator</span>
</DropdownMenuItem>
)}
</DropdownMenuGroup>
{!isTargeted && <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)}
/>
{!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>
<div className="flex items-center gap-4 mb-4 text-sm">
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-green-100 border-2 border-green-400"></div>
<span className="font-semibold text-gray-600">
Answered
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded bg-purple-500 border-2 border-purple-600"></div>
<span className="font-semibold text-gray-600">
Current
</span>
</div>
</div>
<div className="t-nav-grid">
{currentModule?.questions.map((q, idx) => {
const isCurrent = idx === questionIndex;
const isAnswered = !!answers[q.id];
let itemClass = "t-nav-item";
if (isCurrent) itemClass += " current";
else if (isAnswered) itemClass += " answered";
return (
<button
key={q.id}
onClick={() => {
goToQuestion(idx);
setShowNavigator(false);
}}
className={itemClass}
>
{idx + 1}
</button>
);
})}
</div>
</DialogContent>
</Dialog>
)}
<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>
<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 ? (
<>
Submit <Check size={18} />
</>
) : (
<>
Next <ChevronLeft size={18} className="rotate-180" />
</>
)}
</button>
</div>
</section>
{/* ── MCQ Options Drawer ── (outside section to avoid stacking context issues) */}
{isMCQ && (
<Drawer.Root
modal={false}
snapPoints={[0.35, 0.6, 0.85]}
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 }}
>
{/* Drag handle */}
<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>
{/* Scrollable options */}
<div className="flex-1 overflow-y-auto px-5 pb-8">
{renderOptions(currentQuestion)}
</div>
</Drawer.Content>
</Drawer.Portal>
</Drawer.Root>
)}
</div>
);
case "BREAK":
return (
<div className="test-screen">
<style>{GLOBAL_STYLES}</style>
{/* Blobs */}
<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 */}
{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 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-[200px]">
<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">
<span className="bg-white border-2 border-gray-100 rounded-full px-4 py-2 text-sm font-bold text-gray-600 shadow-sm">
💧 Drink water
</span>
<span className="bg-white border-2 border-gray-100 rounded-full px-4 py-2 text-sm font-bold text-gray-600 shadow-sm">
🙆 Stretch
</span>
<span className="bg-white border-2 border-gray-100 rounded-full px-4 py-2 text-sm font-bold text-gray-600 shadow-sm">
😮💨 Breathe
</span>
</div>
<button
onClick={() => useSatExam.getState().skipBreak()}
className="t-btn-3d t-btn-accent px-8 py-4"
>
Skip Break
</button>
</main>
</div>
);
case "FINISHED":
return (
<div className="test-screen">
<style>{GLOBAL_STYLES}</style>
{/* Blobs */}
<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 */}
{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 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">
<div className="t-card p-4 text-center min-w-[100px]">
<span className="text-2xl block mb-1"></span>
<span className="font-black text-lg text-[#1e1b4b]">Done</span>
<span className="text-xs font-bold text-gray-400 uppercase block">
Section
</span>
</div>
<div className="t-card p-4 text-center min-w-[100px]">
<span className="text-2xl block mb-1">🏆</span>
<span className="font-black text-lg text-[#1e1b4b]">Nice!</span>
<span className="text-xs font-bold text-gray-400 uppercase block">
Results
</span>
</div>
<div className="t-card p-4 text-center min-w-[100px]">
<span className="text-2xl block mb-1">🔥</span>
<span className="font-black text-lg text-[#1e1b4b]">100%</span>
<span className="text-xs font-bold text-gray-400 uppercase block">
Effort
</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>
);
case "QUIT":
return <Navigate to="/student/home" replace />;
default:
return null;
}
};