feat(exam): add SAT style testing component
This commit is contained in:
@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>edbridge-scholars</title>
|
<title>Edbridge Scholars</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
33
src/App.tsx
33
src/App.tsx
@ -1,19 +1,19 @@
|
|||||||
|
import { Home } from "./pages/student/Home";
|
||||||
import {
|
import {
|
||||||
createBrowserRouter,
|
createBrowserRouter,
|
||||||
Navigate,
|
Navigate,
|
||||||
RouterProvider,
|
RouterProvider,
|
||||||
} from "react-router-dom";
|
} from "react-router-dom";
|
||||||
import { Login } from "./pages/auth/Login";
|
|
||||||
import { Home } from "./pages/student/Home";
|
|
||||||
import { Practice } from "./pages/student/Practice";
|
|
||||||
import { Rewards } from "./pages/student/Rewards";
|
|
||||||
import { Profile } from "./pages/student/Profile";
|
|
||||||
import { Lessons } from "./pages/student/Lessons";
|
|
||||||
import { ProtectedRoute } from "./components/ProtectedRoute";
|
import { ProtectedRoute } from "./components/ProtectedRoute";
|
||||||
import { StudentLayout } from "./pages/student/StudentLayout";
|
import { Login } from "./pages/auth/Login";
|
||||||
import { Test } from "./pages/student/practice/Test";
|
import { Lessons } from "./pages/student/Lessons";
|
||||||
import { Results } from "./pages/student/practice/Results";
|
import { Practice } from "./pages/student/Practice";
|
||||||
import { Pretest } from "./pages/student/practice/Pretest";
|
import { Pretest } from "./pages/student/practice/Pretest";
|
||||||
|
import { Results } from "./pages/student/practice/Results";
|
||||||
|
import { Test } from "./pages/student/practice/Test";
|
||||||
|
import { Profile } from "./pages/student/Profile";
|
||||||
|
import { Rewards } from "./pages/student/Rewards";
|
||||||
|
import { StudentLayout } from "./pages/student/StudentLayout";
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
@ -51,23 +51,20 @@ function App() {
|
|||||||
{
|
{
|
||||||
path: "practice/:sheetId",
|
path: "practice/:sheetId",
|
||||||
element: <Pretest />,
|
element: <Pretest />,
|
||||||
children: [
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: "test",
|
path: "practice/:sheetId/test",
|
||||||
element: <Test />,
|
element: <Test />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "results",
|
path: "practice/:sheetId/test/results",
|
||||||
element: <Results />,
|
element: <Results />,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
// more student subroutes here
|
|
||||||
],
|
|
||||||
},
|
|
||||||
// Add more subroutes here as needed
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
element: <Navigate to="/student/home" replace />,
|
element: <Navigate to="/student/home" replace />,
|
||||||
|
|||||||
0
src/components/examTimer.tsx
Normal file
0
src/components/examTimer.tsx
Normal file
26
src/hooks/useAuthToken.ts
Normal file
26
src/hooks/useAuthToken.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
// hooks/useAuthToken.ts
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
type AuthStorage = {
|
||||||
|
state?: {
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAuthToken() {
|
||||||
|
const token = useMemo(() => {
|
||||||
|
const authStorage = localStorage.getItem("auth-storage");
|
||||||
|
|
||||||
|
if (!authStorage) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed: AuthStorage = JSON.parse(authStorage);
|
||||||
|
return parsed?.state?.token ?? null;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to parse auth-storage:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
51
src/hooks/useSatTimer.ts
Normal file
51
src/hooks/useSatTimer.ts
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSatExam } from "../stores/useSatExam";
|
||||||
|
|
||||||
|
export const useSatTimer = () => {
|
||||||
|
const phase = useSatExam((s) => s.phase);
|
||||||
|
const getRemainingTime = useSatExam((s) => s.getRemainingTime);
|
||||||
|
const startBreak = useSatExam((s) => s.startBreak);
|
||||||
|
const skipBreak = useSatExam((s) => s.skipBreak);
|
||||||
|
const finishExam = useSatExam((s) => s.finishExam);
|
||||||
|
|
||||||
|
const currentModule = useSatExam((s) => s.currentModuleQuestions);
|
||||||
|
|
||||||
|
const [time, setTime] = useState(0);
|
||||||
|
|
||||||
|
// ✅ reset timer when phase or module changes
|
||||||
|
useEffect(() => {
|
||||||
|
setTime(getRemainingTime());
|
||||||
|
}, [phase, currentModule?.module_id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase === "IDLE" || phase === "FINISHED") return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const remaining = getRemainingTime();
|
||||||
|
setTime(remaining);
|
||||||
|
|
||||||
|
if (remaining === 0) {
|
||||||
|
clearInterval(interval);
|
||||||
|
|
||||||
|
if (phase === "BREAK") {
|
||||||
|
// ✅ break ended → go back to module
|
||||||
|
skipBreak();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phase === "MODULE") {
|
||||||
|
// ⚠️ IMPORTANT:
|
||||||
|
// Timer should NOT load next module automatically.
|
||||||
|
// Instead, finish exam UI or let backend decide.
|
||||||
|
|
||||||
|
finishExam();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [phase, getRemainingTime, skipBreak, finishExam]);
|
||||||
|
|
||||||
|
return time;
|
||||||
|
};
|
||||||
@ -75,3 +75,9 @@ export function getRandomColor() {
|
|||||||
];
|
];
|
||||||
return colors[Math.floor(Math.random() * colors.length)];
|
return colors[Math.floor(Math.random() * colors.length)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const formatTime = (seconds: number) => {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
||||||
|
};
|
||||||
|
|||||||
@ -39,10 +39,10 @@ export const Home = () => {
|
|||||||
(sheet) => sheet.user_status === "NOT_STARTED",
|
(sheet) => sheet.user_status === "NOT_STARTED",
|
||||||
);
|
);
|
||||||
const inProgress = sheets.filter(
|
const inProgress = sheets.filter(
|
||||||
(sheet) => sheet.user_status === "in-progress",
|
(sheet) => sheet.user_status === "IN_PROGRESS",
|
||||||
);
|
);
|
||||||
const completed = sheets.filter(
|
const completed = sheets.filter(
|
||||||
(sheet) => sheet.user_status === "completed",
|
(sheet) => sheet.user_status === "COMPLETED",
|
||||||
);
|
);
|
||||||
|
|
||||||
setNotStartedSheets(notStarted);
|
setNotStartedSheets(notStarted);
|
||||||
@ -83,8 +83,7 @@ export const Home = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50">
|
<main className="min-h-screen bg-gray-50 flex flex-col gap-12 max-w-full mx-auto px-8 sm:px-6 lg:px-8 py-8">
|
||||||
<main className="flex flex-col gap-12 max-w-full mx-auto px-8 sm:px-6 lg:px-8 py-8">
|
|
||||||
<h1 className="text-4xl font-satoshi-bold tracking-tight text-gray-800 text-center">
|
<h1 className="text-4xl font-satoshi-bold tracking-tight text-gray-800 text-center">
|
||||||
Welcome, {user?.name || "Student"}
|
Welcome, {user?.name || "Student"}
|
||||||
</h1>
|
</h1>
|
||||||
@ -102,6 +101,54 @@ export const Home = () => {
|
|||||||
<h1 className="font-satoshi-bold text-2xl tracking-tight">
|
<h1 className="font-satoshi-bold text-2xl tracking-tight">
|
||||||
Pick up where you left off
|
Pick up where you left off
|
||||||
</h1>
|
</h1>
|
||||||
|
{inProgressSheets.length > 0 ? (
|
||||||
|
inProgressSheets.map((sheet) => (
|
||||||
|
<Card
|
||||||
|
key={sheet?.id}
|
||||||
|
className="rounded-4xl border bg-purple-50/70 border-purple-500"
|
||||||
|
>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="font-satoshi-medium text-xl">
|
||||||
|
{sheet?.title}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription className="font-satoshi">
|
||||||
|
{sheet?.description}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex justify-between">
|
||||||
|
<p className="font-satoshi text-sm border px-2 rounded-full bg-purple-500 text-white py-1">
|
||||||
|
{formatStatus(sheet?.user_status)}
|
||||||
|
</p>
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="bg-indigo-100 text-indigo-500 font-satoshi tracking-wide"
|
||||||
|
>
|
||||||
|
{sheet?.modules_count} modules
|
||||||
|
</Badge>
|
||||||
|
</CardContent>
|
||||||
|
<CardContent>
|
||||||
|
<p className="font-satoshi text-gray-700">
|
||||||
|
{sheet?.time_limit} minutes
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleStartPractice(sheet?.id)}
|
||||||
|
variant="outline"
|
||||||
|
className="font-satoshi rounded-3xl w-full text-lg py-6 bg-linear-to-br from-purple-500 to-purple-600 text-white"
|
||||||
|
>
|
||||||
|
Resume
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Card className="flex items-center justify-center py-4 rounded-4xl">
|
||||||
|
<h2 className="text-center font-satoshi text-lg text-gray-800">
|
||||||
|
You don't have any practice sheets in progress. Why not start one?
|
||||||
|
</h2>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
<section className="w-full">
|
<section className="w-full">
|
||||||
<Tabs defaultValue="all" className="w-full">
|
<Tabs defaultValue="all" className="w-full">
|
||||||
@ -126,7 +173,7 @@ export const Home = () => {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="all" className="pt-6">
|
<TabsContent value="all" className="pt-6">
|
||||||
<div className="space-y-6">
|
<div className="gap-6 flex flex-col md:grid md:grid-cols-2">
|
||||||
{practiceSheets.length > 0 ? (
|
{practiceSheets.length > 0 ? (
|
||||||
practiceSheets.map((sheet) => (
|
practiceSheets.map((sheet) => (
|
||||||
<Card key={sheet?.id} className="rounded-4xl">
|
<Card key={sheet?.id} className="rounded-4xl">
|
||||||
@ -175,7 +222,7 @@ export const Home = () => {
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="NOT_STARTED" className="pt-6">
|
<TabsContent value="NOT_STARTED" className="pt-6">
|
||||||
<div className="space-y-6">
|
<div className="gap-6 flex flex-col md:grid md:grid-cols-2">
|
||||||
{notStartedSheets.map((sheet) => (
|
{notStartedSheets.map((sheet) => (
|
||||||
<Card key={sheet?.id} className="rounded-4xl">
|
<Card key={sheet?.id} className="rounded-4xl">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@ -213,6 +260,7 @@ export const Home = () => {
|
|||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="COMPLETED" className="pt-6">
|
<TabsContent value="COMPLETED" className="pt-6">
|
||||||
|
<div className="gap-6 flex flex-col md:grid md:grid-cols-2">
|
||||||
{completedSheets.length > 0 ? (
|
{completedSheets.length > 0 ? (
|
||||||
completedSheets.map((sheet) => (
|
completedSheets.map((sheet) => (
|
||||||
<Card key={sheet?.id} className="rounded-4xl">
|
<Card key={sheet?.id} className="rounded-4xl">
|
||||||
@ -257,15 +305,16 @@ export const Home = () => {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</section>
|
</section>
|
||||||
<section className="w-full space-y-4"></section>
|
|
||||||
<section className="space-y-4">
|
<section className="space-y-4 ">
|
||||||
<h1 className="font-satoshi-bold text-2xl tracking-tight">
|
<h1 className="font-satoshi-bold text-2xl tracking-tight">
|
||||||
SAT Preparation Tips
|
SAT Preparation Tips
|
||||||
</h1>
|
</h1>
|
||||||
<section className="space-y-4">
|
<section className="space-y-4 ">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<CheckCircle size={24} color="#AD45FF" />
|
<CheckCircle size={24} color="#AD45FF" />
|
||||||
<p className="font-satoshi text-md">
|
<p className="font-satoshi text-md">
|
||||||
@ -297,6 +346,5 @@ export const Home = () => {
|
|||||||
</section>
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -42,7 +42,8 @@ export const Lessons = () => {
|
|||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="rw" className="pt-4">
|
<TabsContent value="rw" className="pt-4">
|
||||||
<Card className="py-0 pb-8 rounded-4xl overflow-hidden">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<Card className="py-0 pb-5 rounded-4xl overflow-hidden">
|
||||||
<CardHeader className="w-full py-0 px-0">
|
<CardHeader className="w-full py-0 px-0">
|
||||||
<img
|
<img
|
||||||
src="https://placehold.co/600x400"
|
src="https://placehold.co/600x400"
|
||||||
@ -55,6 +56,33 @@ export const Lessons = () => {
|
|||||||
<CardDescription>Video Description</CardDescription>
|
<CardDescription>Video Description</CardDescription>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card className="py-0 pb-5 rounded-4xl overflow-hidden">
|
||||||
|
<CardHeader className="w-full py-0 px-0">
|
||||||
|
<img
|
||||||
|
src="https://placehold.co/600x400"
|
||||||
|
alt="Video Thumbnail"
|
||||||
|
className="w-full h-auto rounded"
|
||||||
|
/>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<CardTitle>Video Title</CardTitle>
|
||||||
|
<CardDescription>Video Description</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<Card className="py-0 pb-5 rounded-4xl overflow-hidden">
|
||||||
|
<CardHeader className="w-full py-0 px-0">
|
||||||
|
<img
|
||||||
|
src="https://placehold.co/600x400"
|
||||||
|
alt="Video Thumbnail"
|
||||||
|
className="w-full h-auto rounded"
|
||||||
|
/>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-2">
|
||||||
|
<CardTitle>Video Title</CardTitle>
|
||||||
|
<CardDescription>Video Description</CardDescription>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="math" className="pt-4">
|
<TabsContent value="math" className="pt-4">
|
||||||
<Card className="py-0 pb-8 rounded-4xl overflow-hidden">
|
<Card className="py-0 pb-8 rounded-4xl overflow-hidden">
|
||||||
|
|||||||
@ -36,13 +36,13 @@ export const Practice = () => {
|
|||||||
flex-row"
|
flex-row"
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<CardHeader className="w-[65%]">
|
<CardHeader className="w-[65%] md:w-full">
|
||||||
<CardTitle className="font-satoshi-bold tracking-tight text-3xl text-white">
|
<CardTitle className="font-satoshi-bold tracking-tight text-3xl text-white">
|
||||||
See where you stand
|
See where you stand
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="w-2/3">
|
<CardContent className="w-2/3 md:w-full">
|
||||||
<p className="font-satoshi text-white">
|
<p className="font-satoshi text-white">
|
||||||
Test your knowledge with an adaptive practice test.
|
Test your knowledge with an adaptive practice test.
|
||||||
</p>
|
</p>
|
||||||
@ -53,20 +53,23 @@ export const Practice = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</div>
|
</div>
|
||||||
<div className="overflow-hidden opacity-30 -rotate-45 absolute -top-7 -right-20">
|
<div className="overflow-hidden opacity-30 -rotate-45 absolute -top-10 -right-20">
|
||||||
<DraftingCompass size={300} color="white" />
|
<DraftingCompass size={300} color="white" />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
</section>
|
</section>
|
||||||
<section className="flex flex-col gap-6">
|
<section className="flex flex-col gap-6">
|
||||||
<h1 className="font-satoshi-black text-2xl">Practice your way</h1>
|
<h1 className="font-satoshi-black text-2xl">Practice your way</h1>
|
||||||
|
<div className="md:grid md:grid-cols-2 md:gap-6 space-y-6 md:space-y-0">
|
||||||
<Card className="rounded-4xl cursor-pointer hover:bg-gray-50 active:bg-gray-50 active:translate-y-1">
|
<Card className="rounded-4xl cursor-pointer hover:bg-gray-50 active:bg-gray-50 active:translate-y-1">
|
||||||
<CardHeader className="space-y-3">
|
<CardHeader className="space-y-3">
|
||||||
<div className="w-fit bg-linear-to-br from-red-400 to-red-500 p-3 rounded-2xl">
|
<div className="w-fit bg-linear-to-br from-red-400 to-red-500 p-3 rounded-2xl">
|
||||||
<Target size={20} color="white" />
|
<Target size={20} color="white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<CardTitle className="font-satoshi">Targeted Practice</CardTitle>
|
<CardTitle className="font-satoshi">
|
||||||
|
Targeted Practice
|
||||||
|
</CardTitle>
|
||||||
<CardDescription className="font-satoshi">
|
<CardDescription className="font-satoshi">
|
||||||
Focus on what matters
|
Focus on what matters
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
@ -102,7 +105,9 @@ export const Practice = () => {
|
|||||||
<Trophy size={20} color="white" />
|
<Trophy size={20} color="white" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<CardTitle className="font-satoshi">Hard Test Modules</CardTitle>
|
<CardTitle className="font-satoshi">
|
||||||
|
Hard Test Modules
|
||||||
|
</CardTitle>
|
||||||
<CardDescription className="font-satoshi">
|
<CardDescription className="font-satoshi">
|
||||||
Focus on what matters
|
Focus on what matters
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
@ -114,6 +119,7 @@ export const Practice = () => {
|
|||||||
</CardAction>
|
</CardAction>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
</Card>
|
</Card>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -12,7 +12,7 @@ export const Profile = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen space-y-6 max-w-7xl mx-auto px-8 sm:px-6 lg:px-8 py-8">
|
<main className="min-h-screen space-y-6 max-w-7xl mx-auto px-8 sm:px-6 md:px-26 lg:px-8 py-8">
|
||||||
<h1 className="text-lg font-satoshi-bold text-center">Profile</h1>
|
<h1 className="text-lg font-satoshi-bold text-center">Profile</h1>
|
||||||
<section>
|
<section>
|
||||||
<h3 className="text-2xl font-satoshi-bold">{user?.name}</h3>
|
<h3 className="text-2xl font-satoshi-bold">{user?.name}</h3>
|
||||||
|
|||||||
@ -1,9 +1,16 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { Outlet, replace, useParams } from "react-router-dom";
|
||||||
import { api } from "../../../utils/api";
|
import { api } from "../../../utils/api";
|
||||||
import { useAuthStore } from "../../../stores/authStore";
|
import { useAuthStore } from "../../../stores/authStore";
|
||||||
import type { PracticeSheet } from "../../../types/sheet";
|
import type { PracticeSheet } from "../../../types/sheet";
|
||||||
import { CircleQuestionMark, Clock, Layers, Tag } from "lucide-react";
|
import {
|
||||||
|
CircleQuestionMark,
|
||||||
|
Clock,
|
||||||
|
Layers,
|
||||||
|
Loader,
|
||||||
|
Loader2,
|
||||||
|
Tag,
|
||||||
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
Carousel,
|
Carousel,
|
||||||
CarouselContent,
|
CarouselContent,
|
||||||
@ -11,6 +18,7 @@ import {
|
|||||||
type CarouselApi,
|
type CarouselApi,
|
||||||
} from "../../../components/ui/carousel";
|
} from "../../../components/ui/carousel";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
export const Pretest = () => {
|
export const Pretest = () => {
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
@ -18,13 +26,18 @@ export const Pretest = () => {
|
|||||||
const [carouselApi, setCarouselApi] = useState<CarouselApi>();
|
const [carouselApi, setCarouselApi] = useState<CarouselApi>();
|
||||||
const [current, setCurrent] = useState(0);
|
const [current, setCurrent] = useState(0);
|
||||||
const [count, setCount] = useState(0);
|
const [count, setCount] = useState(0);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [practiceSheet, setPracticeSheet] = useState<PracticeSheet | null>(
|
const [practiceSheet, setPracticeSheet] = useState<PracticeSheet | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
function handleStartTest(sheetId: string) {
|
function handleStartTest(sheetId: string) {
|
||||||
console.log("Starting test for Practice Sheet. ID: ", sheetId);
|
if (!sheetId) {
|
||||||
|
console.error("Sheet ID is required to start the test.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
navigate(`/student/practice/${sheetId}/test`, { replace: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -67,6 +80,7 @@ export const Pretest = () => {
|
|||||||
{practiceSheet?.description}
|
{practiceSheet?.description}
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
|
{practiceSheet ? (
|
||||||
<section className="flex flex-col gap-6 rounded-4xl bg-white border p-8">
|
<section className="flex flex-col gap-6 rounded-4xl bg-white border p-8">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Clock size={65} color="black" />
|
<Clock size={65} color="black" />
|
||||||
@ -96,6 +110,13 @@ export const Pretest = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="flex flex-col items-center gap-6 rounded-4xl bg-white border p-8">
|
||||||
|
<div>
|
||||||
|
<Loader size={30} className="transition animate-spin" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
<Carousel setApi={setCarouselApi}>
|
<Carousel setApi={setCarouselApi}>
|
||||||
<CarouselContent className="">
|
<CarouselContent className="">
|
||||||
{practiceSheet ? (
|
{practiceSheet ? (
|
||||||
@ -161,7 +182,10 @@ export const Pretest = () => {
|
|||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<CarouselItem>
|
<CarouselItem>
|
||||||
<section className="flex flex-col w-full rounded-4xl p-8 bg-yellow-100 border">
|
<section className="flex flex-col w-full rounded-4xl p-8 bg-yellow-100 border items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<Loader size={30} className="transition animate-spin" />
|
||||||
|
</div>
|
||||||
<h1 className="text-center text-xl font-satoshi-bold text-yellow-500">
|
<h1 className="text-center text-xl font-satoshi-bold text-yellow-500">
|
||||||
Loading...
|
Loading...
|
||||||
</h1>
|
</h1>
|
||||||
@ -191,8 +215,15 @@ export const Pretest = () => {
|
|||||||
onClick={() => handleStartTest(practiceSheet?.id!)}
|
onClick={() => handleStartTest(practiceSheet?.id!)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="font-satoshi rounded-3xl w-full text-lg 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 active:translate-y-1"
|
className="font-satoshi rounded-3xl w-full text-lg 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 active:translate-y-1"
|
||||||
|
disabled={!practiceSheet}
|
||||||
>
|
>
|
||||||
Start Test
|
{practiceSheet ? (
|
||||||
|
"Start Test"
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<Loader size={60} className="transition animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,3 +1,12 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Button } from "../../../components/ui/button";
|
||||||
|
|
||||||
export const Results = () => {
|
export const Results = () => {
|
||||||
return <div>Results</div>;
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center text-2xl font-satoshi-bold">
|
||||||
|
Your results go here
|
||||||
|
<Button onClick={() => navigate("/student/home")}>Go to home</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 = () => {
|
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;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
127
src/stores/useSatExam.ts
Normal file
127
src/stores/useSatExam.ts
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
import { type ExamPhase } from "../types/sheet";
|
||||||
|
import type { SessionModuleQuestions } from "../types/session";
|
||||||
|
|
||||||
|
// interface SatExamState {
|
||||||
|
// modules: Module[];
|
||||||
|
// phase: ExamPhase;
|
||||||
|
// moduleIndex: number;
|
||||||
|
// questionIndex: number;
|
||||||
|
// endTime: number | null;
|
||||||
|
|
||||||
|
// startExam: () => void;
|
||||||
|
// setModuleQuestionss: (modules: Module[]) => void;
|
||||||
|
|
||||||
|
// nextQuestion: () => void;
|
||||||
|
// prevQuestion: () => void;
|
||||||
|
// nextModule: () => void;
|
||||||
|
// nextPhase: () => void;
|
||||||
|
// skipBreak: () => void;
|
||||||
|
// getRemainingTime: () => number;
|
||||||
|
// finishExam: () => void;
|
||||||
|
// resetExam: () => void;
|
||||||
|
// replaceModules: (modules: Module[]) => void;
|
||||||
|
// }
|
||||||
|
|
||||||
|
interface SatExamState {
|
||||||
|
currentModuleQuestions: SessionModuleQuestions | null;
|
||||||
|
phase: ExamPhase;
|
||||||
|
questionIndex: number;
|
||||||
|
endTime: number | null;
|
||||||
|
|
||||||
|
setModuleQuestions: (module: SessionModuleQuestions) => void;
|
||||||
|
startExam: () => void;
|
||||||
|
nextQuestion: () => void;
|
||||||
|
prevQuestion: () => void;
|
||||||
|
|
||||||
|
startBreak: () => void;
|
||||||
|
skipBreak: () => void;
|
||||||
|
finishExam: () => void;
|
||||||
|
resetExam: () => void;
|
||||||
|
getRemainingTime: () => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BREAK_DURATION = 30; // seconds
|
||||||
|
|
||||||
|
export const useSatExam = create<SatExamState>()(
|
||||||
|
persist(
|
||||||
|
(set, get) => ({
|
||||||
|
currentModuleQuestions: null,
|
||||||
|
phase: "IDLE",
|
||||||
|
questionIndex: 0,
|
||||||
|
endTime: null,
|
||||||
|
|
||||||
|
setModuleQuestions: (module: SessionModuleQuestions) => {
|
||||||
|
const endTime = Date.now() + module.time_limit_minutes * 1000;
|
||||||
|
|
||||||
|
set({
|
||||||
|
currentModuleQuestions: module,
|
||||||
|
questionIndex: 0,
|
||||||
|
endTime,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
startExam: () => {
|
||||||
|
set({ phase: "MODULE", questionIndex: 0 });
|
||||||
|
},
|
||||||
|
|
||||||
|
nextQuestion: () => {
|
||||||
|
const { currentModuleQuestions, questionIndex } = get();
|
||||||
|
const questions = currentModuleQuestions?.questions ?? [];
|
||||||
|
|
||||||
|
if (questionIndex < questions.length - 1) {
|
||||||
|
set({ questionIndex: questionIndex + 1 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
prevQuestion: () => {
|
||||||
|
const { questionIndex } = get();
|
||||||
|
if (questionIndex > 0) {
|
||||||
|
set({ questionIndex: questionIndex - 1 });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
startBreak: () => {
|
||||||
|
const endTime = Date.now() + BREAK_DURATION * 1000;
|
||||||
|
|
||||||
|
set((state) => ({
|
||||||
|
phase: "BREAK",
|
||||||
|
endTime,
|
||||||
|
questionIndex: 0, // optional: reset question index for next module UX
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
skipBreak: () => {
|
||||||
|
const module = get().currentModuleQuestions;
|
||||||
|
|
||||||
|
if (!module) return;
|
||||||
|
|
||||||
|
const endTime = Date.now() + module.time_limit_minutes * 1000;
|
||||||
|
|
||||||
|
set({
|
||||||
|
phase: "MODULE",
|
||||||
|
endTime,
|
||||||
|
questionIndex: 0,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
finishExam: () => set({ phase: "FINISHED", endTime: null }),
|
||||||
|
|
||||||
|
getRemainingTime: () => {
|
||||||
|
const { endTime } = get();
|
||||||
|
if (!endTime) return 0;
|
||||||
|
return Math.max(0, Math.floor((endTime - Date.now()) / 1000));
|
||||||
|
},
|
||||||
|
|
||||||
|
resetExam: () =>
|
||||||
|
set({
|
||||||
|
currentModuleQuestions: null,
|
||||||
|
phase: "IDLE",
|
||||||
|
questionIndex: 0,
|
||||||
|
endTime: null,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
{ name: "sat-exam-storage" },
|
||||||
|
),
|
||||||
|
);
|
||||||
66
src/types/session.ts
Normal file
66
src/types/session.ts
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import type { Question } from "./sheet";
|
||||||
|
|
||||||
|
type Answer = {
|
||||||
|
id: string;
|
||||||
|
question_id: string;
|
||||||
|
answer_text: string;
|
||||||
|
is_correct: boolean;
|
||||||
|
marked_for_review: false;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SessionRequest interface
|
||||||
|
* `/sessions/`
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface SessionRequest {
|
||||||
|
sheet_id: string;
|
||||||
|
mode: string;
|
||||||
|
topic_ids: string[];
|
||||||
|
difficulty: string;
|
||||||
|
question_count: number;
|
||||||
|
time_limit_minutes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionResponse {
|
||||||
|
id: string;
|
||||||
|
practice_sheet_id: string;
|
||||||
|
status: string;
|
||||||
|
current_module_index: number;
|
||||||
|
current_model_id: string;
|
||||||
|
current_module_title: string;
|
||||||
|
answers: Answer[];
|
||||||
|
started_at: Date;
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SubmitAnswer = {
|
||||||
|
question_id: string;
|
||||||
|
answer_text: string;
|
||||||
|
time_spent_seconds: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SessionAnswerResponse {
|
||||||
|
status: string;
|
||||||
|
feedback: {
|
||||||
|
is_correct: boolean;
|
||||||
|
correct_answer: string;
|
||||||
|
explanation: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionQuestionsResponse {
|
||||||
|
session_id: string;
|
||||||
|
module_id: string;
|
||||||
|
module_title: string;
|
||||||
|
time_limit_minutes: number;
|
||||||
|
questions: Question[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionModuleQuestions {
|
||||||
|
session_id?: string;
|
||||||
|
module_id: string;
|
||||||
|
module_title: string;
|
||||||
|
time_limit_minutes: number;
|
||||||
|
questions: Question[];
|
||||||
|
}
|
||||||
@ -4,6 +4,8 @@ interface CreatedBy {
|
|||||||
email: string;
|
email: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ExamPhase = "IDLE" | "MODULE" | "BREAK" | "FINISHED";
|
||||||
|
|
||||||
export interface Subject {
|
export interface Subject {
|
||||||
name: string;
|
name: string;
|
||||||
section: string;
|
section: string;
|
||||||
@ -13,7 +15,15 @@ export interface Subject {
|
|||||||
parent_name: string;
|
parent_name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Option {
|
||||||
|
text: string;
|
||||||
|
image_url: string;
|
||||||
|
sequence_order: number;
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Question {
|
export interface Question {
|
||||||
|
difficulty: string;
|
||||||
text: string;
|
text: string;
|
||||||
context: string;
|
context: string;
|
||||||
context_image_url: string;
|
context_image_url: string;
|
||||||
@ -22,7 +32,7 @@ export interface Question {
|
|||||||
image_url: string;
|
image_url: string;
|
||||||
index: number;
|
index: number;
|
||||||
id: string;
|
id: string;
|
||||||
options: any[];
|
options: Option[];
|
||||||
topics: Topic[];
|
topics: Topic[];
|
||||||
correct_answer: string;
|
correct_answer: string;
|
||||||
explanation: string;
|
explanation: string;
|
||||||
|
|||||||
@ -1,3 +1,10 @@
|
|||||||
|
import type {
|
||||||
|
SessionAnswerResponse,
|
||||||
|
SessionQuestionsResponse,
|
||||||
|
SessionRequest,
|
||||||
|
SessionResponse,
|
||||||
|
SubmitAnswer,
|
||||||
|
} from "../types/session";
|
||||||
import type { PracticeSheet } from "../types/sheet";
|
import type { PracticeSheet } from "../types/sheet";
|
||||||
|
|
||||||
const API_URL = "https://ed-dev-api.omukk.dev";
|
const API_URL = "https://ed-dev-api.omukk.dev";
|
||||||
@ -121,6 +128,60 @@ class ApiClient {
|
|||||||
token,
|
token,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
async startSession(
|
||||||
|
token: string,
|
||||||
|
sessionData: SessionRequest,
|
||||||
|
): Promise<SessionResponse> {
|
||||||
|
return this.authenticatedRequest<SessionResponse>(`/sessions/`, token, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(sessionData),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchSessionQuestions(
|
||||||
|
token: string,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<SessionQuestionsResponse> {
|
||||||
|
return this.authenticatedRequest<SessionQuestionsResponse>(
|
||||||
|
`/sessions/${sessionId}/questions/`,
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async submitAnswer(
|
||||||
|
token: string,
|
||||||
|
sessionId: string,
|
||||||
|
answerSubmissionData: SubmitAnswer,
|
||||||
|
): Promise<SessionAnswerResponse> {
|
||||||
|
return this.authenticatedRequest<SessionAnswerResponse>(
|
||||||
|
`/sessions/${sessionId}/answer/`,
|
||||||
|
token,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(answerSubmissionData),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchNextModule(token: string, sessionId: string): Promise<any> {
|
||||||
|
return this.authenticatedRequest<any>(
|
||||||
|
`/sessions/${sessionId}/next-module/`,
|
||||||
|
token,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchSessionStateById(
|
||||||
|
token: string,
|
||||||
|
sessionId: string,
|
||||||
|
): Promise<SessionResponse> {
|
||||||
|
return this.authenticatedRequest<SessionResponse>(
|
||||||
|
`/sessions/${sessionId}`,
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
export const api = new ApiClient(API_URL);
|
export const api = new ApiClient(API_URL);
|
||||||
|
|||||||
Reference in New Issue
Block a user