Files
encoach_backend_new_v2/frontend/src/pages/student/PlacementTest.tsx
Yamen Ahmad f1c4953a63 feat(v3): restructure project + add complete frontend
- 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
2026-04-10 17:26:42 +04:00

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&apos;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>
);
}