import { useCallback, 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, Square } from "lucide-react"; import { placementService } from "@/services/placement.service"; 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(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([]); const questionStartedAt = useRef(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 (
Session unavailable Return to the briefing and start the test again.
); } if (sectionBridge) { return (
Section complete {formatElapsed(elapsedRef.current)}
{sectionTitle(sectionBridge.from)} complete. Next: {sectionTitle(sectionBridge.to)} Take a short breath — continue when you're ready.
); } return (
{formatElapsed(elapsedRef.current)} {question ? sectionTitle(question.section) : "…"}
Question {displayProgress.current} of ~{displayProgress.estimated_total || "?"}
{initializing || betweenQuestions || !question ? (
) : ( {question.passage_text && (
{question.passage_text}
)}

{question.stem}

{(question.type === "mcq" || question.type === "definition_match") && question.options && ( {question.options.map((o) => ( ))} )} {question.type === "mcq_multi" && question.options && (
{question.options.map((o) => ( ))}
)} {question.type === "gap_fill" && ( setSingleAnswer(e.target.value)} placeholder="Your answer" className="text-base" /> )} {question.type === "tfng" && ( {(question.options?.length ? question.options : [ { label: "T", text: "True" }, { label: "F", text: "False" }, { label: "NG", text: "Not Given" }, ] ).map((o) => ( ))} )} {question.type === "audio_recording" && ( setSingleAnswer("audio_submitted")} /> )}
)}
); } function PlacementSpeakingRecorder({ sessionId, promptId, onUploaded }: { sessionId: string; promptId: number; onUploaded: () => void; }) { const [recording, setRecording] = useState(false); const [uploading, setUploading] = useState(false); const [audioUrl, setAudioUrl] = useState(null); const [elapsed, setElapsed] = useState(0); const mediaRecorderRef = useRef(null); const chunksRef = useRef([]); const timerRef = useRef(0); const startRecording = async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const mr = new MediaRecorder(stream, { mimeType: "audio/webm" }); chunksRef.current = []; mr.ondataavailable = (e) => { if (e.data.size > 0) chunksRef.current.push(e.data); }; mr.onstop = () => { const blob = new Blob(chunksRef.current, { type: "audio/webm" }); setAudioUrl(URL.createObjectURL(blob)); stream.getTracks().forEach((t) => t.stop()); uploadAudio(blob); }; mediaRecorderRef.current = mr; mr.start(); setRecording(true); setElapsed(0); timerRef.current = window.setInterval(() => setElapsed((t) => t + 1), 1000); } catch { // microphone permission denied } }; const stopRecording = () => { mediaRecorderRef.current?.stop(); setRecording(false); window.clearInterval(timerRef.current); }; const uploadAudio = async (blob: Blob) => { setUploading(true); try { const file = new File([blob], "speaking.webm", { type: "audio/webm" }); await placementService.uploadSpeaking(sessionId, promptId, file); onUploaded(); } catch { // upload error handled silently } finally { setUploading(false); } }; const mm = Math.floor(elapsed / 60); const ss = elapsed % 60; return (
{!recording && !audioUrl && ( <>

Click the button below to start recording your speaking response.

)} {recording && (
{String(mm).padStart(2, "0")}:{String(ss).padStart(2, "0")}
)} {audioUrl && (
)}
); }