- Restructure: move backend from new_project/ to backend/ - Add full React/TypeScript frontend (37 pages, 17 services, 16 type defs, 11 query hooks) - Add docs/ with SRS specs, user stories, and workflow documentation - Update .gitignore for new directory layout Workflows implemented: WF1 User Signup, WF2 Placement Test, WF3 Exam Configuration, WF4 General English Exam, WF5 Course Generation, WF6 Entity Student Onboarding, AI Course Generation, Adaptive Learning Engine UI, White-Label Branding, Score Release Made-with: Cursor
365 lines
14 KiB
TypeScript
365 lines
14 KiB
TypeScript
import { useEffect, useRef, useState } from "react";
|
|
import { useNavigate, useSearchParams, useLocation } from "react-router-dom";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Progress } from "@/components/ui/progress";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Mic, Loader2 } from "lucide-react";
|
|
import { usePlacementAnswer, usePlacementAutoSave } from "@/hooks/queries/usePlacement";
|
|
import type { CATQuestion, CATSection } from "@/types";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
type LocationState = { question?: CATQuestion; sessionId?: string };
|
|
|
|
function sectionTitle(s: CATSection): string {
|
|
return s.charAt(0).toUpperCase() + s.slice(1);
|
|
}
|
|
|
|
export default function PlacementTest() {
|
|
const [searchParams] = useSearchParams();
|
|
const sessionId = searchParams.get("session") || "";
|
|
const navigate = useNavigate();
|
|
const location = useLocation();
|
|
const locState = location.state as LocationState | undefined;
|
|
|
|
const answerMut = usePlacementAnswer();
|
|
const autoSave = usePlacementAutoSave();
|
|
|
|
const [question, setQuestion] = useState<CATQuestion | null>(null);
|
|
const [initializing, setInitializing] = useState(true);
|
|
const [betweenQuestions, setBetweenQuestions] = useState(false);
|
|
const [progressInfo, setProgressInfo] = useState<{ current: number; estimated_total: number } | null>(null);
|
|
const [sectionBridge, setSectionBridge] = useState<{
|
|
from: CATSection;
|
|
to: CATSection;
|
|
nextQuestion: CATQuestion;
|
|
} | null>(null);
|
|
|
|
const [singleAnswer, setSingleAnswer] = useState("");
|
|
const [multiAnswer, setMultiAnswer] = useState<string[]>([]);
|
|
const questionStartedAt = useRef<number>(Date.now());
|
|
const draftRef = useRef<{ question_id: number; answer: string | string[] } | null>(null);
|
|
|
|
useEffect(() => {
|
|
if (!sessionId) {
|
|
navigate("/student/placement", { replace: true });
|
|
return;
|
|
}
|
|
const fromState = locState?.question;
|
|
const fromStorage = sessionStorage.getItem(`placement_session_${sessionId}`);
|
|
let parsed: CATQuestion | null = null;
|
|
if (fromStorage) {
|
|
try {
|
|
const j = JSON.parse(fromStorage) as { question?: CATQuestion };
|
|
parsed = j.question ?? null;
|
|
} catch {
|
|
parsed = null;
|
|
}
|
|
}
|
|
const q = fromState ?? parsed;
|
|
if (q) {
|
|
setQuestion(q);
|
|
questionStartedAt.current = Date.now();
|
|
}
|
|
setInitializing(false);
|
|
}, [sessionId, navigate, locState]);
|
|
|
|
useEffect(() => {
|
|
if (!question) return;
|
|
setSingleAnswer("");
|
|
setMultiAnswer([]);
|
|
questionStartedAt.current = Date.now();
|
|
}, [question?.id]);
|
|
|
|
useEffect(() => {
|
|
if (!question) return;
|
|
const id = question.id;
|
|
if (question.type === "mcq_multi") {
|
|
draftRef.current = { question_id: id, answer: multiAnswer };
|
|
} else {
|
|
draftRef.current = { question_id: id, answer: singleAnswer };
|
|
}
|
|
}, [question, singleAnswer, multiAnswer]);
|
|
|
|
useEffect(() => {
|
|
if (!sessionId || !draftRef.current) return;
|
|
const tick = window.setInterval(() => {
|
|
const d = draftRef.current;
|
|
if (!d) return;
|
|
void autoSave.mutate({ session_id: sessionId, current_answer: d });
|
|
}, 10000);
|
|
return () => window.clearInterval(tick);
|
|
}, [sessionId, autoSave]);
|
|
|
|
const elapsedRef = useRef(0);
|
|
const [, setTick] = useState(0);
|
|
useEffect(() => {
|
|
const t = window.setInterval(() => {
|
|
elapsedRef.current += 1;
|
|
setTick((x) => x + 1);
|
|
}, 1000);
|
|
return () => window.clearInterval(t);
|
|
}, []);
|
|
|
|
const formatElapsed = (sec: number) => {
|
|
const m = Math.floor(sec / 60);
|
|
const s = sec % 60;
|
|
return `${m}:${s.toString().padStart(2, "0")}`;
|
|
};
|
|
|
|
const buildAnswerPayload = (): string | string[] => {
|
|
if (!question) return "";
|
|
if (question.type === "mcq_multi") return multiAnswer;
|
|
if (question.type === "gap_fill") return singleAnswer.trim();
|
|
return singleAnswer;
|
|
};
|
|
|
|
const submit = async () => {
|
|
if (!question || !sessionId) return;
|
|
const timeSpent = Date.now() - questionStartedAt.current;
|
|
setBetweenQuestions(true);
|
|
try {
|
|
const res = await answerMut.mutateAsync({
|
|
session_id: sessionId,
|
|
question_id: question.id,
|
|
answer: buildAnswerPayload(),
|
|
time_spent_ms: timeSpent,
|
|
});
|
|
if (res.progress) setProgressInfo(res.progress);
|
|
if (res.test_complete) {
|
|
navigate(`/student/placement/results?session=${encodeURIComponent(sessionId)}`);
|
|
return;
|
|
}
|
|
if (res.section_complete && res.next_question) {
|
|
setSectionBridge({
|
|
from: question.section,
|
|
to: res.next_question.section,
|
|
nextQuestion: res.next_question,
|
|
});
|
|
return;
|
|
}
|
|
if (res.next_question) {
|
|
setQuestion(res.next_question);
|
|
sessionStorage.setItem(`placement_session_${sessionId}`, JSON.stringify({ question: res.next_question }));
|
|
}
|
|
} finally {
|
|
setBetweenQuestions(false);
|
|
}
|
|
};
|
|
|
|
const continueAfterSection = () => {
|
|
if (!sectionBridge || !sessionId) return;
|
|
setQuestion(sectionBridge.nextQuestion);
|
|
sessionStorage.setItem(
|
|
`placement_session_${sessionId}`,
|
|
JSON.stringify({ question: sectionBridge.nextQuestion }),
|
|
);
|
|
setSectionBridge(null);
|
|
};
|
|
|
|
const toggleMulti = (label: string) => {
|
|
setMultiAnswer((prev) => (prev.includes(label) ? prev.filter((x) => x !== label) : [...prev, label]));
|
|
};
|
|
|
|
const displayProgress = progressInfo ?? {
|
|
current: question?.estimated_total ? 1 : 0,
|
|
estimated_total: question?.estimated_total ?? 0,
|
|
};
|
|
|
|
const approxFinal =
|
|
displayProgress.estimated_total > 0 && displayProgress.current >= displayProgress.estimated_total;
|
|
|
|
if (!sessionId) return null;
|
|
|
|
if (!initializing && !question && !sectionBridge) {
|
|
return (
|
|
<div className="min-h-screen flex items-center justify-center p-6 bg-background">
|
|
<Card className="max-w-md w-full border-0 shadow-lg">
|
|
<CardHeader>
|
|
<CardTitle>Session unavailable</CardTitle>
|
|
<CardDescription>Return to the briefing and start the test again.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Button onClick={() => navigate("/student/placement")}>Back</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (sectionBridge) {
|
|
return (
|
|
<div className="min-h-screen flex flex-col bg-background">
|
|
<header className="border-b px-6 py-4 flex items-center justify-between bg-card/80 backdrop-blur">
|
|
<span className="text-sm font-medium text-muted-foreground">Section complete</span>
|
|
<span className="text-sm tabular-nums">{formatElapsed(elapsedRef.current)}</span>
|
|
</header>
|
|
<main className="flex-1 flex items-center justify-center p-6">
|
|
<Card className="max-w-lg w-full border-0 shadow-xl text-center">
|
|
<CardHeader>
|
|
<CardTitle>
|
|
{sectionTitle(sectionBridge.from)} complete. Next: {sectionTitle(sectionBridge.to)}
|
|
</CardTitle>
|
|
<CardDescription>Take a short breath — continue when you're ready.</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Button size="lg" className="w-full" onClick={continueAfterSection}>
|
|
Continue
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen flex flex-col bg-gradient-to-b from-muted/20 to-background">
|
|
<header className="sticky top-0 z-10 border-b px-4 md:px-8 py-3 flex flex-wrap items-center gap-4 justify-between bg-card/90 backdrop-blur supports-[backdrop-filter]:bg-card/80">
|
|
<div className="flex items-center gap-4 min-w-0">
|
|
<span className="text-sm font-semibold tabular-nums text-primary">{formatElapsed(elapsedRef.current)}</span>
|
|
<span className="text-sm font-medium truncate">{question ? sectionTitle(question.section) : "…"}</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 flex-1 max-w-md min-w-[200px]">
|
|
<Progress
|
|
value={
|
|
displayProgress.estimated_total
|
|
? Math.min(100, (displayProgress.current / displayProgress.estimated_total) * 100)
|
|
: 0
|
|
}
|
|
className="h-2 flex-1"
|
|
/>
|
|
<span className="text-xs text-muted-foreground whitespace-nowrap tabular-nums">
|
|
Question {displayProgress.current} of ~{displayProgress.estimated_total || "?"}
|
|
</span>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="flex-1 flex items-stretch justify-center p-4 md:p-8">
|
|
<div className="w-full max-w-3xl">
|
|
{initializing || betweenQuestions || !question ? (
|
|
<div className="space-y-4">
|
|
<Skeleton className="h-8 w-3/4" />
|
|
<Skeleton className="h-24 w-full" />
|
|
<Skeleton className="h-10 w-full" />
|
|
</div>
|
|
) : (
|
|
<Card className="border-0 shadow-lg">
|
|
<CardContent className="pt-8 space-y-8">
|
|
{question.passage_text && (
|
|
<div className="rounded-lg bg-muted/50 p-4 text-sm leading-relaxed whitespace-pre-wrap">
|
|
{question.passage_text}
|
|
</div>
|
|
)}
|
|
<p className="text-lg font-medium leading-snug">{question.stem}</p>
|
|
|
|
{(question.type === "mcq" || question.type === "definition_match") && question.options && (
|
|
<RadioGroup value={singleAnswer} onValueChange={setSingleAnswer} className="space-y-3">
|
|
{question.options.map((o) => (
|
|
<label
|
|
key={o.label}
|
|
className={cn(
|
|
"flex cursor-pointer items-center gap-3 rounded-lg border p-4 transition-colors",
|
|
singleAnswer === o.label && "border-primary bg-primary/5",
|
|
)}
|
|
>
|
|
<RadioGroupItem value={o.label} id={`opt-${o.label}`} />
|
|
<span className="text-sm leading-snug">{o.text}</span>
|
|
</label>
|
|
))}
|
|
</RadioGroup>
|
|
)}
|
|
|
|
{question.type === "mcq_multi" && question.options && (
|
|
<div className="space-y-3">
|
|
{question.options.map((o) => (
|
|
<label
|
|
key={o.label}
|
|
className="flex cursor-pointer items-center gap-3 rounded-lg border p-4"
|
|
>
|
|
<Checkbox
|
|
checked={multiAnswer.includes(o.label)}
|
|
onCheckedChange={() => toggleMulti(o.label)}
|
|
/>
|
|
<span className="text-sm">{o.text}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{question.type === "gap_fill" && (
|
|
<Input
|
|
value={singleAnswer}
|
|
onChange={(e) => setSingleAnswer(e.target.value)}
|
|
placeholder="Your answer"
|
|
className="text-base"
|
|
/>
|
|
)}
|
|
|
|
{question.type === "tfng" && (
|
|
<RadioGroup value={singleAnswer} onValueChange={setSingleAnswer} className="space-y-3">
|
|
{(question.options?.length
|
|
? question.options
|
|
: [
|
|
{ label: "T", text: "True" },
|
|
{ label: "F", text: "False" },
|
|
{ label: "NG", text: "Not Given" },
|
|
]
|
|
).map((o) => (
|
|
<label
|
|
key={o.label}
|
|
className={cn(
|
|
"flex cursor-pointer items-center gap-3 rounded-lg border p-4",
|
|
singleAnswer === o.label && "border-primary bg-primary/5",
|
|
)}
|
|
>
|
|
<RadioGroupItem value={o.label} id={`tfng-${o.label}`} />
|
|
<span className="text-sm">{o.text}</span>
|
|
</label>
|
|
))}
|
|
</RadioGroup>
|
|
)}
|
|
|
|
{question.type === "audio_recording" && (
|
|
<div className="flex flex-col items-center gap-4 py-8 rounded-xl border border-dashed bg-muted/30">
|
|
<Mic className="h-12 w-12 text-muted-foreground" />
|
|
<p className="text-sm text-muted-foreground text-center max-w-sm">
|
|
Recording will be available in a future update. Use the button below to proceed for now.
|
|
</p>
|
|
<Button type="button" variant="secondary" size="lg" disabled>
|
|
Record (placeholder)
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="flex justify-end pt-4">
|
|
<Button
|
|
size="lg"
|
|
className="min-w-[160px]"
|
|
disabled={answerMut.isPending || betweenQuestions}
|
|
onClick={() => void submit()}
|
|
>
|
|
{answerMut.isPending || betweenQuestions ? (
|
|
<>
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
|
Saving
|
|
</>
|
|
) : approxFinal ? (
|
|
"Finish Test"
|
|
) : (
|
|
"Submit answer"
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|