feat(exam): add SAT style testing component

This commit is contained in:
shafin-r
2026-01-28 15:22:19 +06:00
parent 61b7c5220e
commit 355ca0c0c4
17 changed files with 1136 additions and 267 deletions

View File

@ -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>

View File

@ -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,21 +51,18 @@ function App() {
{ {
path: "practice/:sheetId", path: "practice/:sheetId",
element: <Pretest />, element: <Pretest />,
children: [
{
path: "test",
element: <Test />,
},
{
path: "results",
element: <Results />,
},
],
}, },
// more student subroutes here
], ],
}, },
// Add more subroutes here as needed
{
path: "practice/:sheetId/test",
element: <Test />,
},
{
path: "practice/:sheetId/test/results",
element: <Results />,
},
], ],
}, },
{ {

View File

26
src/hooks/useAuthToken.ts Normal file
View 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
View 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;
};

View File

@ -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")}`;
};

View File

@ -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,100 +83,99 @@ 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>
<section className="relative w-full">
<input
type="text"
placeholder="Search..."
className="font-satoshi w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
/>
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
<Search size={22} color="gray" />
</div>
</section>
<section className="space-y-4">
<h1 className="font-satoshi-bold text-2xl tracking-tight">
Pick up where you left off
</h1> </h1>
<section className="relative w-full"> {inProgressSheets.length > 0 ? (
<input inProgressSheets.map((sheet) => (
type="text" <Card
placeholder="Search..." key={sheet?.id}
className="font-satoshi w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-purple-500" className="rounded-4xl border bg-purple-50/70 border-purple-500"
/> >
<div className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none"> <CardHeader>
<Search size={22} color="gray" /> <CardTitle className="font-satoshi-medium text-xl">
</div> {sheet?.title}
</section> </CardTitle>
<section className="space-y-4"> <CardDescription className="font-satoshi">
<h1 className="font-satoshi-bold text-2xl tracking-tight"> {sheet?.description}
Pick up where you left off </CardDescription>
</h1> </CardHeader>
</section> <CardContent className="flex justify-between">
<section className="w-full"> <p className="font-satoshi text-sm border px-2 rounded-full bg-purple-500 text-white py-1">
<Tabs defaultValue="all" className="w-full"> {formatStatus(sheet?.user_status)}
<TabsList className="bg-transparent p-0 w-full"> </p>
<TabsTrigger <Badge
value="all" variant="secondary"
className="font-satoshi-regular tracking-wide text-md rounded-none border-b-3 data-[state=active]:font-satoshi-medium data-[state=active]:border-b-purple-800 data-[state=active]:text-purple-800" className="bg-indigo-100 text-indigo-500 font-satoshi tracking-wide"
> >
All {sheet?.modules_count} modules
</TabsTrigger> </Badge>
<TabsTrigger </CardContent>
value="NOT_STARTED" <CardContent>
className="font-satoshi-regular tracking-wide text-md rounded-none border-b-3 data-[state=active]:border-b-purple-800 data-[state=active]:text-purple-800" <p className="font-satoshi text-gray-700">
> {sheet?.time_limit} minutes
Not Started </p>
</TabsTrigger> </CardContent>
<TabsTrigger <CardFooter>
value="COMPLETED" <Button
className="font-satoshi-regular tracking-wide text-md rounded-none border-b-3 data-[state=active]:border-b-purple-800 data-[state=active]:text-purple-800" onClick={() => handleStartPractice(sheet?.id)}
> variant="outline"
Completed className="font-satoshi rounded-3xl w-full text-lg py-6 bg-linear-to-br from-purple-500 to-purple-600 text-white"
</TabsTrigger> >
</TabsList> Resume
<TabsContent value="all" className="pt-6"> </Button>
<div className="space-y-6"> </CardFooter>
{practiceSheets.length > 0 ? ( </Card>
practiceSheets.map((sheet) => ( ))
<Card key={sheet?.id} className="rounded-4xl"> ) : (
<CardHeader> <Card className="flex items-center justify-center py-4 rounded-4xl">
<CardTitle className="font-satoshi-medium text-xl"> <h2 className="text-center font-satoshi text-lg text-gray-800">
{sheet?.title} You don't have any practice sheets in progress. Why not start one?
</CardTitle> </h2>
<CardDescription className="font-satoshi"> </Card>
{sheet?.description} )}
</CardDescription> </section>
</CardHeader> <section className="w-full">
<CardContent className="flex justify-between"> <Tabs defaultValue="all" className="w-full">
<p className="font-satoshi text-gray-500"> <TabsList className="bg-transparent p-0 w-full">
{formatStatus(sheet?.user_status)} <TabsTrigger
</p> value="all"
<Badge className="font-satoshi-regular tracking-wide text-md rounded-none border-b-3 data-[state=active]:font-satoshi-medium data-[state=active]:border-b-purple-800 data-[state=active]:text-purple-800"
variant="secondary" >
className="bg-indigo-100 text-indigo-500 font-satoshi tracking-wide" All
> </TabsTrigger>
{sheet?.modules_count} modules <TabsTrigger
</Badge> value="NOT_STARTED"
</CardContent> className="font-satoshi-regular tracking-wide text-md rounded-none border-b-3 data-[state=active]:border-b-purple-800 data-[state=active]:text-purple-800"
<CardContent> >
<p className="font-satoshi text-gray-700"> Not Started
{sheet?.time_limit} minutes </TabsTrigger>
</p> <TabsTrigger
</CardContent> value="COMPLETED"
<CardFooter> className="font-satoshi-regular tracking-wide text-md rounded-none border-b-3 data-[state=active]:border-b-purple-800 data-[state=active]:text-purple-800"
<Button >
onClick={() => handleStartPractice(sheet?.id)} Completed
variant="outline" </TabsTrigger>
className="font-satoshi rounded-3xl w-full text-lg py-6 bg-linear-to-br from-purple-500 to-purple-600 text-white" </TabsList>
> <TabsContent value="all" className="pt-6">
Start <div className="gap-6 flex flex-col md:grid md:grid-cols-2">
</Button> {practiceSheets.length > 0 ? (
</CardFooter> practiceSheets.map((sheet) => (
</Card>
))
) : (
<div className="flex items-center justify-center py-4 rounded-full">
<h2 className="text-center font-satoshi text-lg text-gray-500">
No Practice Sheets available.
</h2>
</div>
)}
</div>
</TabsContent>
<TabsContent value="NOT_STARTED" className="pt-6">
<div className="space-y-6">
{notStartedSheets.map((sheet) => (
<Card key={sheet?.id} className="rounded-4xl"> <Card key={sheet?.id} className="rounded-4xl">
<CardHeader> <CardHeader>
<CardTitle className="font-satoshi-medium text-xl"> <CardTitle className="font-satoshi-medium text-xl">
@ -187,10 +186,12 @@ export const Home = () => {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex justify-between"> <CardContent className="flex justify-between">
<p className="font-satoshi text-gray-700">Not Started</p> <p className="font-satoshi text-gray-500">
{formatStatus(sheet?.user_status)}
</p>
<Badge <Badge
variant="secondary" variant="secondary"
className="bg-indigo-100 text-indigo-500 font-satoshi tracking-wide " className="bg-indigo-100 text-indigo-500 font-satoshi tracking-wide"
> >
{sheet?.modules_count} modules {sheet?.modules_count} modules
</Badge> </Badge>
@ -202,17 +203,64 @@ export const Home = () => {
</CardContent> </CardContent>
<CardFooter> <CardFooter>
<Button <Button
onClick={() => handleStartPractice(sheet?.id)}
variant="outline" variant="outline"
className="font-satoshi w-full text-lg py-6 bg-linear-to-br from-purple-500 to-purple-600 rounded-3xl text-white" className="font-satoshi rounded-3xl w-full text-lg py-6 bg-linear-to-br from-purple-500 to-purple-600 text-white"
> >
Start Start
</Button> </Button>
</CardFooter> </CardFooter>
</Card> </Card>
))} ))
</div> ) : (
</TabsContent> <div className="flex items-center justify-center py-4 rounded-full">
<TabsContent value="COMPLETED" className="pt-6"> <h2 className="text-center font-satoshi text-lg text-gray-500">
No Practice Sheets available.
</h2>
</div>
)}
</div>
</TabsContent>
<TabsContent value="NOT_STARTED" className="pt-6">
<div className="gap-6 flex flex-col md:grid md:grid-cols-2">
{notStartedSheets.map((sheet) => (
<Card key={sheet?.id} className="rounded-4xl">
<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-gray-700">Not Started</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
variant="outline"
className="font-satoshi w-full text-lg py-6 bg-linear-to-br from-purple-500 to-purple-600 rounded-3xl text-white"
>
Start
</Button>
</CardFooter>
</Card>
))}
</div>
</TabsContent>
<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,46 +305,46 @@ export const Home = () => {
</h2> </h2>
</div> </div>
)} )}
</TabsContent> </div>
</Tabs> </TabsContent>
</Tabs>
</section>
<section className="space-y-4 ">
<h1 className="font-satoshi-bold text-2xl tracking-tight">
SAT Preparation Tips
</h1>
<section className="space-y-4 ">
<div className="flex gap-2">
<CheckCircle size={24} color="#AD45FF" />
<p className="font-satoshi text-md">
Practice regularly with official SAT materials
</p>
</div>
<div className="flex items-center gap-2">
<CheckCircle size={24} color="#AD45FF" />
<p className="font-satoshi text-md">
Review your mistakes and learn from them
</p>
</div>
<div className="flex items-center gap-2">
<CheckCircle size={24} color="#AD45FF" />
<p className="font-satoshi text-md">Focus on your weak areas</p>
</div>
<div className="flex items-center gap-2">
<CheckCircle size={24} color="#AD45FF" />
<p className="font-satoshi text-md">
Take full-length practice tests
</p>
</div>
<div className="flex items-center gap-2">
<CheckCircle size={24} color="#AD45FF" />
<p className="font-satoshi text-md">
Get plenty of rest before the test day
</p>
</div>
</section> </section>
<section className="w-full space-y-4"></section> </section>
<section className="space-y-4"> </main>
<h1 className="font-satoshi-bold text-2xl tracking-tight">
SAT Preparation Tips
</h1>
<section className="space-y-4">
<div className="flex gap-2">
<CheckCircle size={24} color="#AD45FF" />
<p className="font-satoshi text-md">
Practice regularly with official SAT materials
</p>
</div>
<div className="flex items-center gap-2">
<CheckCircle size={24} color="#AD45FF" />
<p className="font-satoshi text-md">
Review your mistakes and learn from them
</p>
</div>
<div className="flex items-center gap-2">
<CheckCircle size={24} color="#AD45FF" />
<p className="font-satoshi text-md">Focus on your weak areas</p>
</div>
<div className="flex items-center gap-2">
<CheckCircle size={24} color="#AD45FF" />
<p className="font-satoshi text-md">
Take full-length practice tests
</p>
</div>
<div className="flex items-center gap-2">
<CheckCircle size={24} color="#AD45FF" />
<p className="font-satoshi text-md">
Get plenty of rest before the test day
</p>
</div>
</section>
</section>
</main>
</div>
); );
}; };

View File

@ -42,19 +42,47 @@ 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">
<CardHeader className="w-full py-0 px-0"> <Card className="py-0 pb-5 rounded-4xl overflow-hidden">
<img <CardHeader className="w-full py-0 px-0">
src="https://placehold.co/600x400" <img
alt="Video Thumbnail" src="https://placehold.co/600x400"
className="w-full h-auto rounded" alt="Video Thumbnail"
/> className="w-full h-auto rounded"
</CardHeader> />
<CardContent className="space-y-2"> </CardHeader>
<CardTitle>Video Title</CardTitle> <CardContent className="space-y-2">
<CardDescription>Video Description</CardDescription> <CardTitle>Video Title</CardTitle>
</CardContent> <CardDescription>Video Description</CardDescription>
</Card> </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>
<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">

View File

@ -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,67 +53,73 @@ 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>
<Card className="rounded-4xl cursor-pointer hover:bg-gray-50 active:bg-gray-50 active:translate-y-1"> <div className="md:grid md:grid-cols-2 md:gap-6 space-y-6 md:space-y-0">
<CardHeader className="space-y-3"> <Card className="rounded-4xl cursor-pointer hover:bg-gray-50 active:bg-gray-50 active:translate-y-1">
<div className="w-fit bg-linear-to-br from-red-400 to-red-500 p-3 rounded-2xl"> <CardHeader className="space-y-3">
<Target size={20} color="white" /> <div className="w-fit bg-linear-to-br from-red-400 to-red-500 p-3 rounded-2xl">
</div> <Target size={20} color="white" />
<div className="space-y-2">
<CardTitle className="font-satoshi">Targeted Practice</CardTitle>
<CardDescription className="font-satoshi">
Focus on what matters
</CardDescription>
</div>
<CardAction>
<div className="w-fit bg-red-100 p-2 rounded-full">
<Loader2 size={30} color="#fa6969" />
</div> </div>
</CardAction> <div className="space-y-2">
</CardHeader> <CardTitle className="font-satoshi">
</Card> Targeted Practice
<Card className="rounded-4xl cursor-pointer hover:bg-gray-50 active:bg-gray-50 active:translate-y-1"> </CardTitle>
<CardHeader className="space-y-3"> <CardDescription className="font-satoshi">
<div className="w-fit bg-linear-to-br from-cyan-400 to-cyan-500 p-3 rounded-2xl"> Focus on what matters
<Zap size={20} color="white" /> </CardDescription>
</div>
<div className="space-y-2">
<CardTitle className="font-satoshi">Drills</CardTitle>
<CardDescription className="font-satoshi">
Train speed and accuracy
</CardDescription>
</div>
<CardAction>
<div className="w-fit bg-cyan-100 p-3 rounded-full">
<Clock size={26} color="oklch(71.5% 0.143 215.221)" />
</div> </div>
</CardAction> <CardAction>
</CardHeader> <div className="w-fit bg-red-100 p-2 rounded-full">
</Card> <Loader2 size={30} color="#fa6969" />
<Card className="rounded-4xl cursor-pointer hover:bg-gray-50 active:bg-gray-50 active:translate-y-1"> </div>
<CardHeader className="space-y-3"> </CardAction>
<div className="w-fit bg-linear-to-br from-lime-400 to-lime-500 p-3 rounded-2xl"> </CardHeader>
<Trophy size={20} color="white" /> </Card>
</div> <Card className="rounded-4xl cursor-pointer hover:bg-gray-50 active:bg-gray-50 active:translate-y-1">
<div className="space-y-2"> <CardHeader className="space-y-3">
<CardTitle className="font-satoshi">Hard Test Modules</CardTitle> <div className="w-fit bg-linear-to-br from-cyan-400 to-cyan-500 p-3 rounded-2xl">
<CardDescription className="font-satoshi"> <Zap size={20} color="white" />
Focus on what matters
</CardDescription>
</div>
<CardAction>
<div className="w-fit bg-lime-100 p-3 rounded-full">
<BookOpen size={26} color="oklch(76.8% 0.233 130.85)" />
</div> </div>
</CardAction> <div className="space-y-2">
</CardHeader> <CardTitle className="font-satoshi">Drills</CardTitle>
</Card> <CardDescription className="font-satoshi">
Train speed and accuracy
</CardDescription>
</div>
<CardAction>
<div className="w-fit bg-cyan-100 p-3 rounded-full">
<Clock size={26} color="oklch(71.5% 0.143 215.221)" />
</div>
</CardAction>
</CardHeader>
</Card>
<Card className="rounded-4xl cursor-pointer hover:bg-gray-50 active:bg-gray-50 active:translate-y-1">
<CardHeader className="space-y-3">
<div className="w-fit bg-linear-to-br from-lime-400 to-lime-500 p-3 rounded-2xl">
<Trophy size={20} color="white" />
</div>
<div className="space-y-2">
<CardTitle className="font-satoshi">
Hard Test Modules
</CardTitle>
<CardDescription className="font-satoshi">
Focus on what matters
</CardDescription>
</div>
<CardAction>
<div className="w-fit bg-lime-100 p-3 rounded-full">
<BookOpen size={26} color="oklch(76.8% 0.233 130.85)" />
</div>
</CardAction>
</CardHeader>
</Card>
</div>
</section> </section>
</main> </main>
); );

View File

@ -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>

View File

@ -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,35 +80,43 @@ export const Pretest = () => {
{practiceSheet?.description} {practiceSheet?.description}
</p> </p>
</header> </header>
<section className="flex flex-col gap-6 rounded-4xl bg-white border p-8"> {practiceSheet ? (
<div className="flex items-center gap-4"> <section className="flex flex-col gap-6 rounded-4xl bg-white border p-8">
<Clock size={65} color="black" /> <div className="flex items-center gap-4">
<div> <Clock size={65} color="black" />
<h3 className="text-3xl font-satoshi-bold "> <div>
{practiceSheet?.time_limit} <h3 className="text-3xl font-satoshi-bold ">
</h3> {practiceSheet?.time_limit}
<p className="text-xl font-satoshi ">Minutes</p> </h3>
<p className="text-xl font-satoshi ">Minutes</p>
</div>
</div> </div>
</div> <div className="flex items-center gap-4">
<div className="flex items-center gap-4"> <Layers size={65} color="black" />
<Layers size={65} color="black" /> <div>
<div> <h3 className="text-3xl font-satoshi-bold ">
<h3 className="text-3xl font-satoshi-bold "> {practiceSheet?.modules.length}
{practiceSheet?.modules.length} </h3>
</h3> <p className="text-xl font-satoshi">Modules</p>
<p className="text-xl font-satoshi">Modules</p> </div>
</div> </div>
</div> <div className="flex items-center gap-4">
<div className="flex items-center gap-4"> <CircleQuestionMark size={65} color="black" />
<CircleQuestionMark size={65} color="black" /> <div>
<div> <h3 className="text-3xl font-satoshi-bold ">
<h3 className="text-3xl font-satoshi-bold "> {practiceSheet?.questions_count}
{practiceSheet?.questions_count} </h3>
</h3> <p className="text-xl font-satoshi ">Questions</p>
<p className="text-xl font-satoshi ">Questions</p> </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>
); );

View File

@ -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>
);
}; };

View File

@ -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">
Times Up!
<p className="text-lg text-gray-500">Redirecting to results...</p>
</div>
);
default:
return null;
}
}; };

127
src/stores/useSatExam.ts Normal file
View 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
View 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[];
}

View File

@ -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;

View File

@ -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);