feat(test): add functionality for drill, hard test module testing

This commit is contained in:
shafin-r
2026-02-07 15:28:43 +06:00
parent 903653a212
commit 02419678b7
9 changed files with 378 additions and 52 deletions

View File

@ -0,0 +1,34 @@
import { Badge } from "./ui/badge";
export const ChoiceCard = ({
label,
selected,
subLabel,
section,
onClick,
}: {
label: string;
selected?: boolean;
subLabel?: string;
section?: string;
onClick: () => void;
}) => (
<button
onClick={onClick}
className={`rounded-2xl border p-4 text-left transition flex flex-col
${selected ? "border-purple-600 bg-purple-50" : "hover:border-gray-300"}`}
>
<div className="flex justify-between">
<span className="font-satoshi-bold text-lg">{label}</span>
{section && (
<Badge
variant={"secondary"}
className={`font-satoshi text-sm ${section === "EBRW" ? "bg-blue-400 text-blue-100" : "bg-red-400 text-red-100"}`}
>
{section}
</Badge>
)}
</div>
{subLabel && <span className="font-satoshi text-md">{subLabel}</span>}
</button>
);

View File

@ -103,19 +103,16 @@ export const Home = () => {
your scores now! your scores now!
</p> </p>
</section> */} </section> */}
<Card <Card className="relative bg-linear-to-br from-red-600 to-red-700 rounded-4xl">
className="relative bg-linear-to-br from-red-600 to-red-700 rounded-4xl
flex-row"
>
<div className="space-y-4"> <div className="space-y-4">
<CardHeader className="w-[200%] md:w-full"> <CardHeader className="">
<CardTitle className="font-satoshi-bold tracking-tight text-3xl text-white"> <CardTitle className="font-satoshi-bold tracking-tight text-3xl text-white">
Your score is low! Your score is low!
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="md:w-full space-y-4"> <CardContent className="space-y-4">
<Field className="w-full max-w-sm"> <Field className="w-full">
<FieldLabel htmlFor="progress-upload"> <FieldLabel htmlFor="progress-upload">
<span className="font-satoshi text-white">Score</span> <span className="font-satoshi text-white">Score</span>
<span className="ml-auto font-satoshi text-white"> <span className="ml-auto font-satoshi text-white">

View File

@ -1,7 +1,212 @@
import { useEffect, useState } from "react";
import { useAuthStore } from "../../../stores/authStore";
import type { Topic } from "../../../types/topic";
import { api } from "../../../utils/api";
import { ChoiceCard } from "../../../components/ChoiceCard";
import { AnimatePresence, motion } from "framer-motion";
import { slideVariants } from "../../../lib/utils";
import { Loader2 } from "lucide-react";
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
import { useNavigate } from "react-router-dom";
type Step = "topic" | "review";
export const Drills = () => { export const Drills = () => {
const user = useAuthStore((state) => state.user);
const navigate = useNavigate();
const [direction, setDirection] = useState<1 | -1>(1);
const [topics, setTopics] = useState<Topic[]>([]);
const [loading, setLoading] = useState<boolean>(false);
const [selectedTopics, setSelectedTopics] = useState<Topic[]>([]);
const [search, setSearch] = useState("");
const [step, setStep] = useState<Step>("topic");
const { storeTopics, setMode, setQuestionCount } = useExamConfigStore();
const toggleTopic = (topic: Topic) => {
setSelectedTopics((prev) => {
const exists = prev.some((t) => t.id === topic.id);
if (exists) {
return prev.filter((t) => t.id !== topic.id);
}
return [...prev, topic];
});
};
function handleStartDrill() {
if (!user || !topics) return;
navigate(`/student/practice/${topics[0].id}/test`, { replace: true });
}
useEffect(() => {
const fetchAllTopics = async () => {
if (!user) return;
try {
setLoading(true);
const authStorage = localStorage.getItem("auth-storage");
if (!authStorage) return;
const parsed = JSON.parse(authStorage) as {
state?: { token?: string };
};
const token = parsed.state?.token;
if (!token) return;
const response = await api.fetchAllTopics(token);
setTopics(response);
setLoading(false);
} catch (error) {
console.error("Failed to load topics. Reason: " + error);
}
};
fetchAllTopics();
}, [user]);
return ( return (
<main className="min-h-screen max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8 space-y-4"> <main className="min-h-screen max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8 space-y-4">
Drills <header className="space-y-2">
<h1 className="font-satoshi-bold text-3xl">Drills</h1>
<p className="font-satoshi text-md text-gray-500">
Train your speed and accuracy with our drill-based testing system.
</p>
</header>
<section>
<div className="relative overflow-hidden">
<AnimatePresence mode="wait">
{step === "topic" && (
<motion.div
custom={direction}
key="topic"
variants={slideVariants}
initial="initial"
animate="animate"
exit="exit"
className="space-y-4"
>
<h2 className="text-xl font-satoshi-bold">Choose a topic</h2>
<input
placeholder="Search topics..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-xl border px-4 py-2"
/>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{loading ? (
<>
<div>
<Loader2
size={30}
color="purple"
className="animate-spin"
/>
</div>
</>
) : (
topics
.filter((t) =>
t.name.toLowerCase().includes(search.toLowerCase()),
)
.map((t) => (
<ChoiceCard
key={t.id}
label={t.name}
subLabel={t.parent_name}
section={t.section}
selected={selectedTopics.some((st) => st.id === t.id)}
onClick={() => toggleTopic(t)}
/>
))
)}
</div>
<button
disabled={selectedTopics.length === 0}
onClick={() => {
// ✅ STORE
storeTopics(selectedTopics.map((t) => t.id)); // ✅ STORE
setMode("DRILL"); // ✅ STORE
setQuestionCount(7); // ✅ STORE
setDirection(1);
setStep("review");
}}
className={`rounded-2xl py-3 px-6 font-satoshi-bold transition
${
selectedTopics.length === 0
? "bg-gray-300 text-gray-500 cursor-not-allowed"
: "bg-linear-to-br from-purple-500 to-purple-600 text-white"
}`}
>
Next
</button>
</motion.div>
)}
{step === "review" && (
<motion.div
custom={direction}
key="review"
variants={slideVariants}
initial="initial"
animate="animate"
exit="exit"
className="space-y-6"
>
<h2 className="text-xl font-satoshi-bold">
Review your choices
</h2>
<div className="rounded-2xl border p-4 space-y-2 font-satoshi">
<p>
<strong>Topics:</strong>{" "}
{selectedTopics.map((t) => t.name).join(", ")}
</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<button
disabled={step === "topic"}
onClick={() => {
const order: Step[] = ["topic", "review"];
setDirection(-1);
setStep(order[order.indexOf(step) - 1]);
}}
className={`absolute bottom-24 left-10 rounded-2xl py-3 px-6 font-satoshi-bold transition
${
step === "topic"
? "opacity-0 pointer-events-none"
: "bg-linear-to-br from-slate-500 to-slate-600 text-white"
}`}
>
Back
</button>
<button
disabled={step !== "review"}
className={`absolute bottom-24 right-10 rounded-2xl py-3 px-6 font-satoshi-bold transition
${
step !== "review"
? "opacity-0 pointer-events-none"
: "bg-linear-to-br from-purple-500 to-purple-600 text-white"
}`}
onClick={() => {
handleStartDrill();
}}
>
Start Test
</button>
</section>
</main> </main>
); );
}; };

View File

@ -1,7 +1,119 @@
import {
DecimalsArrowRight,
Languages,
Percent,
Pilcrow,
Superscript,
WholeWord,
} from "lucide-react";
import { Card, CardContent } from "../../../components/ui/card";
import { useState } from "react";
import { useAuthStore } from "../../../stores/authStore";
import { useNavigate } from "react-router-dom";
import { useExamConfigStore } from "../../../stores/useExamConfigStore";
type Module = "EBRW" | "MATH" | null;
export const HardTestModules = () => { export const HardTestModules = () => {
const user = useAuthStore((state) => state.user);
const navigate = useNavigate();
const [selected, setSelected] = useState<Module>(null);
const { setMode, storeDuration, setSection } = useExamConfigStore();
function handleStartModule() {
if (!user) return;
(setMode("MODULE"), storeDuration(7), setSection(selected));
navigate(`/student/practice/${selected}/test`, { replace: true });
}
return ( return (
<main className="min-h-screen max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8 space-y-4"> <main className="min-h-screen max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8 space-y-4">
HardTestModules <header className="space-y-2">
<h1 className="font-satoshi-bold text-3xl">Hard Test Modules</h1>
<p className="font-satoshi text-md text-gray-500">
Tackle hard practice test modules by selecting a section.
</p>
</header>
<section className="space-y-6">
<Card
onClick={() =>
setSelected((prev) => (prev === "EBRW" ? null : "EBRW"))
}
className={`relative cursor-pointer overflow-hidden transition
${
selected === "EBRW"
? "ring-2 ring-blue-500 scale-[1.02]"
: "hover:scale-[1.01]"
}
bg-linear-to-br from-blue-400 to-blue-600
`}
>
<CardContent className="z-10 flex items-center justify-center py-16 ">
<h1 className="font-satoshi-bold text-2xl text-blue-50">
Reading & Writing
</h1>
</CardContent>
<Languages
size={250}
className="absolute -top-5 -right-10 -rotate-23 text-white opacity-30"
/>
<WholeWord
size={150}
className="absolute -top-10 -left-3 rotate-23 text-white opacity-30"
/>
<Pilcrow
size={150}
className="absolute -bottom-12 left-8 -rotate-23 text-white opacity-30"
/>
</Card>
<Card
onClick={() =>
setSelected((prev) => (prev === "MATH" ? null : "MATH"))
}
className={`relative cursor-pointer overflow-hidden transition
${
selected === "MATH"
? "ring-2 ring-rose-500 scale-[1.02]"
: "hover:scale-[1.01]"
}
bg-linear-to-br from-rose-400 to-rose-600
`}
>
<CardContent className="z-10 flex items-center justify-center py-16 ">
<h1 className="font-satoshi-bold text-2xl text-blue-50">
Mathematics
</h1>
</CardContent>
<DecimalsArrowRight
size={250}
className="absolute -top-5 -right-10 -rotate-23 text-white opacity-30"
/>
<Superscript
size={150}
className="absolute -top-10 -left-3 rotate-23 text-white opacity-30"
/>
<Percent
size={120}
className="absolute -bottom-5 left-8 -rotate-10 text-white opacity-30"
/>
</Card>
</section>
{selected && (
<div className=" bottom-6 left-0 right-0 flex justify-center z-50">
<button
onClick={() => {
handleStartModule();
}}
className="rounded-2xl px-10 py-4 font-satoshi-bold text-lg
bg-linear-to-br from-purple-500 to-purple-600 text-white
shadow-xl animate-in slide-in-from-bottom-4"
>
Start Test
</button>
</div>
)}
</main> </main>
); );
}; };

View File

@ -36,7 +36,7 @@ export const Pretest = () => {
} }
setSheetId(sheetId); setSheetId(sheetId);
setMode("MODULE"); setMode("SIMULATION");
storeDuration(practiceSheet?.time_limit ?? 0); storeDuration(practiceSheet?.time_limit ?? 0);
setQuestionCount(2); setQuestionCount(2);

View File

@ -187,6 +187,7 @@ export const Test = () => {
}; };
const handleQuitExam = () => { const handleQuitExam = () => {
useExamConfigStore.getState().clearPayload();
finishExam(); finishExam();
navigate("/student/home"); navigate("/student/home");
}; };

View File

@ -5,51 +5,14 @@ import { useAuthStore } from "../../../stores/authStore";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { motion, AnimatePresence } from "framer-motion"; import { motion, AnimatePresence } from "framer-motion";
import { slideVariants } from "../../../lib/utils"; import { slideVariants } from "../../../lib/utils";
import { Badge } from "../../../components/ui/badge"; import { ChoiceCard } from "../../../components/ChoiceCard";
import { useAuthToken } from "../../../hooks/useAuthToken"; import { useAuthToken } from "../../../hooks/useAuthToken";
import type {
TargetedSessionRequest,
TargetedSessionResponse,
} from "../../../types/session";
import { useExamConfigStore } from "../../../stores/useExamConfigStore"; import { useExamConfigStore } from "../../../stores/useExamConfigStore";
import { replace, useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
type Step = "topic" | "difficulty" | "duration" | "review"; type Step = "topic" | "difficulty" | "duration" | "review";
const ChoiceCard = ({
label,
selected,
subLabel,
section,
onClick,
}: {
label: string;
selected?: boolean;
subLabel?: string;
section?: string;
onClick: () => void;
}) => (
<button
onClick={onClick}
className={`rounded-2xl border p-4 text-left transition flex flex-col
${selected ? "border-purple-600 bg-purple-50" : "hover:border-gray-300"}`}
>
<div className="flex justify-between">
<span className="font-satoshi-bold text-lg">{label}</span>
{section && (
<Badge
variant={"secondary"}
className={`font-satoshi text-sm ${section === "EBRW" ? "bg-blue-400 text-blue-100" : "bg-red-400 text-red-100"}`}
>
{section}
</Badge>
)}
</div>
{subLabel && <span className="font-satoshi text-md">{subLabel}</span>}
</button>
);
export const TargetedPractice = () => { export const TargetedPractice = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { const {
@ -129,7 +92,13 @@ export const TargetedPractice = () => {
return ( return (
<main className="relative min-h-screen max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8 space-y-4"> <main className="relative min-h-screen max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8 space-y-4">
<h1 className="font-satoshi-bold text-3xl">Targeted Practice</h1> <header className="space-y-2">
<h1 className="font-satoshi-bold text-3xl">Targeted Practice</h1>
<p className="font-satoshi text-md text-gray-500">
Focus on what really matters. Define your own test and get to
practicing what you really need.
</p>
</header>
<div className="relative overflow-hidden"> <div className="relative overflow-hidden">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
@ -183,7 +152,7 @@ export const TargetedPractice = () => {
<button <button
disabled={selectedTopics.length === 0} disabled={selectedTopics.length === 0}
onClick={() => { onClick={() => {
setTopics(selectedTopics.map((t) => t.id)); // ✅ STORE // ✅ STORE
storeTopics(selectedTopics.map((t) => t.id)); // ✅ STORE storeTopics(selectedTopics.map((t) => t.id)); // ✅ STORE
setMode("TARGETED"); // ✅ STORE setMode("TARGETED"); // ✅ STORE
setQuestionCount(7); // ✅ STORE setQuestionCount(7); // ✅ STORE

View File

@ -13,6 +13,7 @@ interface ExamConfigState {
setQuestionCount: (count: number) => void; setQuestionCount: (count: number) => void;
storeDuration: (minutes: number) => void; storeDuration: (minutes: number) => void;
setMode: (mode: ExamMode) => void; setMode: (mode: ExamMode) => void;
setSection: (section: string) => void;
clearPayload: () => void; clearPayload: () => void;
} }
@ -37,6 +38,13 @@ export const useExamConfigStore = create<ExamConfigState>()(
topic_ids, topic_ids,
} as StartExamPayload, } as StartExamPayload,
}), }),
setSection: (section) =>
set({
payload: {
...(get().payload ?? {}),
section,
} as StartExamPayload,
}),
setDifficulty: (difficulty) => setDifficulty: (difficulty) =>
set({ set({

View File

@ -1,5 +1,5 @@
// types/exam.ts // types/exam.ts
export type ExamMode = "MODULE" | "TARGETED" | "SIMULATION" | "DRILLS"; export type ExamMode = "MODULE" | "TARGETED" | "SIMULATION" | "DRILL";
export interface StartExamPayload { export interface StartExamPayload {
sheet_id: string; sheet_id: string;