feat(exam): add SAT style testing component
This commit is contained in:
@ -1,3 +1,406 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import {
|
||||
Clock,
|
||||
Layers,
|
||||
CircleQuestionMark,
|
||||
Check,
|
||||
Loader2,
|
||||
} from "lucide-react";
|
||||
import { api } from "../../../utils/api";
|
||||
import { useAuthStore } from "../../../stores/authStore";
|
||||
import type { Option, PracticeSheet } from "../../../types/sheet";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { useSatExam } from "../../../stores/useSatExam";
|
||||
import { useSatTimer } from "../../../hooks/useSatTimer";
|
||||
import type {
|
||||
SessionModuleQuestions,
|
||||
SubmitAnswer,
|
||||
} from "../../../types/session";
|
||||
import { useAuthToken } from "../../../hooks/useAuthToken";
|
||||
|
||||
export const Test = () => {
|
||||
return <div>Test</div>;
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuthStore();
|
||||
const token = useAuthToken();
|
||||
const [practiceSheet, setPracticeSheet] = useState<PracticeSheet | null>(
|
||||
null,
|
||||
);
|
||||
const [answers, setAnswers] = useState<SubmitAnswer[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [sessionId, setSessionId] = useState<string | null>(null);
|
||||
const { sheetId } = useParams<{ sheetId: string }>();
|
||||
|
||||
const time = useSatTimer();
|
||||
const phase = useSatExam((s) => s.phase);
|
||||
// const moduleIndex = useSatExam((s) => s.moduleIndex);
|
||||
const currentModule = useSatExam((s) => s.currentModuleQuestions);
|
||||
const questionIndex = useSatExam((s) => s.questionIndex);
|
||||
|
||||
const currentQuestion = currentModule?.questions[questionIndex];
|
||||
|
||||
const resetExam = useSatExam((s) => s.resetExam);
|
||||
const startSatExam = useSatExam((s) => s.startExam);
|
||||
const nextQuestion = useSatExam((s) => s.nextQuestion);
|
||||
const prevQuestion = useSatExam((s) => s.prevQuestion);
|
||||
const finishExam = useSatExam((s) => s.finishExam);
|
||||
|
||||
const startExam = async () => {
|
||||
if (!user || !sheetId) return;
|
||||
|
||||
try {
|
||||
const response = await api.startSession(token as string, {
|
||||
sheet_id: sheetId,
|
||||
mode: "MODULE",
|
||||
topic_ids: practiceSheet?.topics.map((t) => t.id) ?? [],
|
||||
difficulty: practiceSheet?.difficulty ?? "EASY",
|
||||
question_count: 2,
|
||||
time_limit_minutes: practiceSheet?.time_limit ?? 0,
|
||||
});
|
||||
|
||||
setSessionId(response.id);
|
||||
|
||||
await loadSessionQuestions(response.id);
|
||||
|
||||
// ✅ NOW start module phase
|
||||
useSatExam.getState().startExam();
|
||||
} catch (error) {
|
||||
console.error("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 handleNext = async () => {
|
||||
if (!currentQuestion || !selectedOption || !sessionId) return;
|
||||
|
||||
const selected = currentQuestion.options.find(
|
||||
(opt) => opt.id === selectedOption,
|
||||
);
|
||||
|
||||
if (!selected) return;
|
||||
|
||||
const answerPayload: SubmitAnswer = {
|
||||
question_id: currentQuestion.id,
|
||||
answer_text: selected.text,
|
||||
time_spent_seconds: 3,
|
||||
};
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
await api.submitAnswer(token!, sessionId, answerPayload);
|
||||
|
||||
const isLastQuestion =
|
||||
questionIndex === currentModule!.questions.length - 1;
|
||||
|
||||
// ✅ normal question flow
|
||||
if (!isLastQuestion) {
|
||||
nextQuestion();
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// ✅ ask backend for next module
|
||||
const next = await api.fetchNextModule(token!, sessionId);
|
||||
|
||||
if (next?.finished) {
|
||||
finishExam();
|
||||
} else {
|
||||
await loadSessionQuestions(sessionId);
|
||||
|
||||
// ✅ IMPORTANT: start break AFTER module loads
|
||||
useSatExam.getState().startBreak();
|
||||
}
|
||||
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
resetExam(); // ✅ important
|
||||
}, [sheetId]);
|
||||
|
||||
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 [selectedOption, setSelectedOption] = useState<string | null>(null);
|
||||
|
||||
const isLastQuestion =
|
||||
questionIndex === (currentModule?.questions.length ?? 0) - 1;
|
||||
|
||||
const isFirstQuestion = questionIndex === 0;
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedOption(null);
|
||||
}, [questionIndex, currentModule?.module_id]);
|
||||
|
||||
const renderOptions = (options?: Option[]) => {
|
||||
if (!options || !Array.isArray(options)) {
|
||||
return <p className="text-gray-500 text-20xl">No options available.</p>;
|
||||
}
|
||||
|
||||
const handleOptionClick = (option: Option) => {
|
||||
setSelectedOption(option.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{options.map((option, index) => {
|
||||
const isSelected = selectedOption === option.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.id}
|
||||
className={`text-start font-satoshi-medium text-lg space-x-2 px-4 py-4 border rounded-4xl transition duration-200 ${
|
||||
isSelected
|
||||
? "bg-linear-to-br from-purple-400 to-purple-500 text-white"
|
||||
: ""
|
||||
}`}
|
||||
onClick={() => handleOptionClick(option)}
|
||||
>
|
||||
<span
|
||||
className={`px-2 py-1 rounded-full ${
|
||||
isSelected
|
||||
? "bg-white text-purple-500"
|
||||
: "bg-purple-500 text-white"
|
||||
}`}
|
||||
>
|
||||
{"ABCD"[index]}
|
||||
</span>{" "}
|
||||
<span>{option.text}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
switch (phase) {
|
||||
case "IDLE":
|
||||
return (
|
||||
<main className="min-h-screen px-8 py-8 w-full space-y-6">
|
||||
<Card className="">
|
||||
<CardHeader className="space-y-6">
|
||||
<CardTitle className="font-satoshi text-4xl">
|
||||
Ready to begin your test?
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
<section className="flex justify-between gap-6 px-4">
|
||||
<div className="flex flex-col justify-center items-center gap-4">
|
||||
<div className="w-fit bg-cyan-100 p-2 rounded-full">
|
||||
<Clock size={30} color="oklch(60.9% 0.126 221.723)" />
|
||||
</div>
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<h3 className="text-xl font-satoshi-bold text-black">
|
||||
{practiceSheet?.time_limit}
|
||||
</h3>
|
||||
<p className="text-md font-satoshi ">Minutes</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center items-center gap-4">
|
||||
<div className="w-fit bg-lime-100 p-2 rounded-full">
|
||||
<CircleQuestionMark
|
||||
size={30}
|
||||
color="oklch(64.8% 0.2 131.684)"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<h3 className="text-xl font-satoshi-bold text-black">
|
||||
{practiceSheet?.questions_count}
|
||||
</h3>
|
||||
<p className="text-md font-satoshi ">Questions</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col justify-center items-center gap-4">
|
||||
<div className="w-fit bg-amber-100 p-2 rounded-full">
|
||||
<Layers size={30} color="oklch(66.6% 0.179 58.318)" />
|
||||
</div>
|
||||
<div className="flex flex-col justify-center items-center">
|
||||
<h3 className="text-xl font-satoshi-bold text-black">
|
||||
{practiceSheet?.modules.length}
|
||||
</h3>
|
||||
<p className="text-md font-satoshi ">Modules</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<h2 className="font-satoshi-bold text-2xl">Before you begin:</h2>
|
||||
<div className="flex items-center gap-4">
|
||||
<Check size={30} color="oklch(62.7% 0.265 303.9)" />
|
||||
<span className="font-satoshi">
|
||||
This test will run on full screen mode for a distraction-free
|
||||
experience
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Check size={20} color="oklch(62.7% 0.265 303.9)" />
|
||||
<span className="font-satoshi">
|
||||
You can exit full-screen anytime by pressing <kbd>Esc</kbd>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Check size={18} color="oklch(62.7% 0.265 303.9)" />
|
||||
<span className="font-satoshi">
|
||||
Your progress will be saved automatically
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<Check size={24} color="oklch(62.7% 0.265 303.9)" />
|
||||
<span className="font-satoshi">
|
||||
You can take breaks using the "More" menu in the top right
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => startExam()}
|
||||
variant="outline"
|
||||
className="font-satoshi rounded-3xl text-lg w-full py-8 bg-linear-to-br from-purple-500 to-purple-600 text-white active:bg-linear-to-br active:from-purple-600 active:to-purple-700 "
|
||||
>
|
||||
Start Test
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</main>
|
||||
);
|
||||
case "MODULE":
|
||||
return (
|
||||
<main className="">
|
||||
<section className="w-full flex flex-col space-y-4 min-h-screen">
|
||||
<section className="fixed top-0 left-0 right-0 bg-white border-b border-gray-300 px-8 pt-8 pb-4 space-y-2 z-10">
|
||||
<header className="space-y-2 flex flex-col items-center">
|
||||
<h2 className="font-satoshi-bold text-3xl w-fit">
|
||||
{Math.floor(time / 60)}:{String(time % 60).padStart(2, "0")}
|
||||
</h2>
|
||||
<h1 className="text-lg text-center font-satoshi">
|
||||
{currentModule?.module_title}
|
||||
</h1>
|
||||
{/* <p className="text-sm font-satoshi text-gray-500">
|
||||
{practiceSheet?.modules[0].description}
|
||||
</p> */}
|
||||
</header>
|
||||
</section>
|
||||
<hr className="border-gray-300" />
|
||||
{currentModule?.questions[0]?.context && (
|
||||
<section className="h-100 overflow-y-auto px-10 pt-30">
|
||||
<p className="font-satoshi tracking-wide text-lg">
|
||||
{currentQuestion?.context}
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="border border-gray-300"></div>
|
||||
<section
|
||||
className={`px-10 ${currentQuestion?.context ? "" : "pt-26"}`}
|
||||
>
|
||||
<p className="font-satoshi-medium text-xl">
|
||||
{currentQuestion?.text}
|
||||
</p>
|
||||
</section>
|
||||
<section className="overflow-y-auto px-10 pb-20">
|
||||
{renderOptions(currentQuestion?.options)}
|
||||
</section>
|
||||
<section className="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-300 py-4 flex justify-evenly">
|
||||
<button
|
||||
disabled={isFirstQuestion}
|
||||
onClick={prevQuestion}
|
||||
className="px-8 border rounded-full py-3 font-satoshi-medium text-black disabled:opacity-40"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
|
||||
<button className="px-8 border rounded-full py-3 font-satoshi-medium text-black">
|
||||
Menu
|
||||
</button>
|
||||
|
||||
<button
|
||||
disabled={isSubmitting || !selectedOption}
|
||||
onClick={handleNext}
|
||||
className="px-8 border rounded-full py-3 font-satoshi-medium text-white bg-linear-to-br from-purple-400 to-purple-500 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 size={24} className="animate-spin" />
|
||||
) : (
|
||||
"Next"
|
||||
)}
|
||||
</button>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
case "BREAK":
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col justify-center items-center text-3xl gap-6">
|
||||
🧘 Break Time
|
||||
<p className="text-lg mt-4">Next module starts in {time}s</p>
|
||||
<button
|
||||
onClick={() => useSatExam.getState().skipBreak()}
|
||||
className="px-6 py-3 rounded-full bg-purple-600 text-white text-lg"
|
||||
>
|
||||
Skip Break →
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "FINISHED":
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col justify-center items-center text-4xl gap-4">
|
||||
⏰ Time’s Up!
|
||||
<p className="text-lg text-gray-500">Redirecting to results...</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user