1608 lines
55 KiB
TypeScript
1608 lines
55 KiB
TypeScript
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;
|
||
}
|
||
};
|