diff --git a/src/App.tsx b/src/App.tsx index 0d22051..6d4adc8 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -145,6 +145,7 @@ import FaqPage from "@/pages/FaqPage"; import NotFound from "@/pages/NotFound"; import { queryClient } from "@/lib/query-client"; import { Button } from "@/components/ui/button"; +import { ErrorBoundary } from "@/components/ErrorBoundary"; function StudentSubscriptionPlaceholder() { const navigate = useNavigate(); @@ -157,6 +158,7 @@ function StudentSubscriptionPlaceholder() { } const App = () => ( + @@ -340,6 +342,7 @@ const App = () => ( + ); export default App; diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..5bb8332 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,62 @@ +import { Component, type ErrorInfo, type ReactNode } from "react"; + +interface Props { + children: ReactNode; + fallback?: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +export class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error("ErrorBoundary caught:", error, errorInfo); + } + + render() { + if (this.state.hasError) { + if (this.props.fallback) return this.props.fallback; + + return ( +
+
+
+ + + +
+

Something went wrong

+

+ An unexpected error occurred. Please try refreshing the page. +

+ {this.state.error && ( +
+ Error details +
{this.state.error.message}
+
+ )} + +
+
+ ); + } + + return this.props.children; + } +} diff --git a/src/pages/ExamsListPage.tsx b/src/pages/ExamsListPage.tsx index c3c9611..5066ad4 100644 --- a/src/pages/ExamsListPage.tsx +++ b/src/pages/ExamsListPage.tsx @@ -1,20 +1,48 @@ import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Search } from "lucide-react"; +import { Search, Plus, FileText, Clock } from "lucide-react"; import { useInstitutionalExamSessions } from "@/hooks/queries"; +import { api } from "@/lib/api-client"; +import { Link } from "react-router-dom"; + +interface CustomExam { + id: number; + title: string; + status: string; + total_time_min: number; + sections: { id: number; title: string; skill: string }[]; + teacher_id: number | null; + template_id: number | null; + description: string; +} + +const statusColors: Record = { + published: "default", + draft: "secondary", + archived: "outline", +}; export default function ExamsListPage() { const [search, setSearch] = useState(""); - const sessionsQ = useInstitutionalExamSessions(); - const items = sessionsQ.data?.data ?? sessionsQ.data?.items ?? []; - const sessions = Array.isArray(items) ? items : []; + const [tab, setTab] = useState<"custom" | "sessions">("custom"); + const customQ = useQuery({ + queryKey: ["custom-exams", search], + queryFn: () => api.get<{ items: CustomExam[]; total: number }>(`/exam/custom/list?search=${encodeURIComponent(search)}&per_page=50`), + }); + + const sessionsQ = useInstitutionalExamSessions(); + const sessionItems = sessionsQ.data?.data ?? sessionsQ.data?.items ?? []; + const sessions = Array.isArray(sessionItems) ? sessionItems : []; + + const customExams = customQ.data?.items ?? []; const q = search.toLowerCase(); - const filtered = sessions.filter(s => + const filteredSessions = sessions.filter((s: Record) => s.name?.toLowerCase().includes(q) || s.course_name?.toLowerCase().includes(q), ); @@ -23,8 +51,20 @@ export default function ExamsListPage() {

Exams List

-

Browse and manage all exam sessions.

+

Browse and manage all exams and exam sessions.

+ + + +
+ +
+ +
@@ -32,44 +72,92 @@ export default function ExamsListPage() { setSearch(e.target.value)} />
- {sessionsQ.isLoading ? ( -
- ) : ( - - - - - - # - Name - Course - Batch - Start - End - State - - - - {filtered.length === 0 && ( - No exam sessions found. - )} - {filtered.map((s, i) => ( - - {i + 1} - {s.name} - {s.course_name || "—"} - {s.batch_name || "—"} - {s.start_date || "—"} - {s.end_date || "—"} - - {s.state} - + {tab === "custom" && ( + customQ.isLoading ? ( +
+ ) : ( + + +
+ + + # + Title + Modules + Duration + Status - ))} - -
-
-
+ + + {customExams.length === 0 && ( + No custom exams found. Submit one from the Generation page. + )} + {customExams.map((exam, i) => ( + + {exam.id} + {exam.title} + +
+ {exam.sections.length > 0 + ? exam.sections.map((s) => ( + {s.skill || s.title} + )) + : } +
+
+ {exam.total_time_min ? `${exam.total_time_min} min` : "—"} + + {exam.status} + +
+ ))} +
+ + + + ) + )} + + {tab === "sessions" && ( + sessionsQ.isLoading ? ( +
+ ) : ( + + + + + + # + Name + Course + Batch + Start + End + State + + + + {filteredSessions.length === 0 && ( + No exam sessions found. + )} + {filteredSessions.map((s: Record, i: number) => ( + + {i + 1} + {s.name} + {s.course_name || "—"} + {s.batch_name || "—"} + {s.start_date || "—"} + {s.end_date || "—"} + + {s.state} + + + ))} + +
+
+
+ ) )}
); diff --git a/src/pages/GenerationPage.tsx b/src/pages/GenerationPage.tsx index 6cf514c..da11e82 100644 --- a/src/pages/GenerationPage.tsx +++ b/src/pages/GenerationPage.tsx @@ -1,5 +1,5 @@ -import { useCallback, useState } from "react"; -import { useMutation } from "@tanstack/react-query"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -8,6 +8,13 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Textarea } from "@/components/ui/textarea"; import { Badge } from "@/components/ui/badge"; import { Switch } from "@/components/ui/switch"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Select, SelectContent, @@ -45,6 +52,7 @@ import { import AiTipBanner from "@/components/ai/AiTipBanner"; import { generationService } from "@/services/generation.service"; import { mediaService, type Avatar } from "@/services/media.service"; +import { examsService } from "@/services/exams.service"; import { useToast } from "@/hooks/use-toast"; type ModuleKey = "reading" | "listening" | "writing" | "speaking" | "level" | "industry"; @@ -66,6 +74,7 @@ const MODULES: ModuleInfo[] = [ { key: "industry", label: "Industry", icon: , color: "text-amber-700", bgColor: "bg-amber-50 border-amber-200" }, ]; +const MODULE_KEYS: ModuleKey[] = MODULES.map((m) => m.key); const CEFR_LEVELS = ["A1", "A2", "B1", "B2", "C1", "C2"]; const READING_EXERCISE_TYPES = [ @@ -188,6 +197,13 @@ export default function GenerationPage() { const [selectedModules, setSelectedModules] = useState>(new Set()); const [activeModule, setActiveModule] = useState(null); const [moduleStates, setModuleStates] = useState>({}); + const [previewOpen, setPreviewOpen] = useState(false); + + const structuresQ = useQuery({ + queryKey: ["exam-structures"], + queryFn: () => examsService.listStructures({}), + }); + const structures = structuresQ.data?.items ?? []; const getModuleState = useCallback((mod: ModuleKey): ModuleState => { return moduleStates[mod] ?? defaultModuleState(mod); @@ -263,7 +279,12 @@ export default function GenerationPage() { if (activeModule !== "listening") return; const st = getModuleState("listening"); const sections = [...st.listeningSections]; - sections[vars.sectionIndex] = { ...sections[vars.sectionIndex], audioUrl: res.audio_url || "" }; + let url = res.audio_url || ""; + if (!url && res.audio_base64) { + const ct = res.content_type || "audio/mpeg"; + url = `data:${ct};base64,${res.audio_base64}`; + } + sections[vars.sectionIndex] = { ...sections[vars.sectionIndex], audioUrl: url }; updateModuleState("listening", { listeningSections: sections }); toast({ title: "Audio generated" }); }, @@ -289,10 +310,18 @@ export default function GenerationPage() { mutationFn: (params: { topics: string[]; difficulty: string; partIndex: number }) => generationService.generateSpeakingScript({ topics: params.topics.filter(Boolean), difficulty: params.difficulty, part: "speaking_1" }), onSuccess: (res, vars) => { + const r = res as Record; + if (r.error) { + toast({ variant: "destructive", title: "Script generation failed", description: String(r.error) }); + return; + } const st = getModuleState("speaking"); const parts = [...st.speakingParts]; - const r = res as Record; - const script = (r.script as string) ?? JSON.stringify(r.questions ?? res); + const script = (r.script as string) || ""; + if (!script) { + toast({ variant: "destructive", title: "No script returned", description: "AI returned empty response — try again." }); + return; + } parts[vars.partIndex] = { ...parts[vars.partIndex], script }; updateModuleState("speaking", { speakingParts: parts }); toast({ title: "Script generated" }); @@ -313,6 +342,45 @@ export default function GenerationPage() { onError: (err: Error) => toast({ variant: "destructive", title: "Video generation failed", description: err.message }), }); + const pollTimerRef = useRef | null>(null); + useEffect(() => { + if (activeModule !== "speaking") return; + const st = getModuleState("speaking"); + const pendingParts = st.speakingParts + .map((p, i) => ({ videoUrl: p.videoUrl, index: i })) + .filter((p) => p.videoUrl.startsWith("pending:")); + if (pendingParts.length === 0) { + if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } + return; + } + if (pollTimerRef.current) return; + pollTimerRef.current = setInterval(async () => { + const currentSt = getModuleState("speaking"); + let changed = false; + const updatedParts = [...currentSt.speakingParts]; + for (const pp of pendingParts) { + const part = updatedParts[pp.index]; + if (!part || !part.videoUrl.startsWith("pending:")) continue; + const videoId = part.videoUrl.replace("pending:", ""); + try { + const status = await mediaService.getVideoStatus(videoId); + if (status.status === "done" || status.status === "completed" || status.video_url) { + updatedParts[pp.index] = { ...part, videoUrl: status.video_url || status.url || "" }; + changed = true; + toast({ title: "Video ready!", description: "Avatar video has been generated." }); + } else if (status.status === "error" || status.status === "failed") { + updatedParts[pp.index] = { ...part, videoUrl: "" }; + changed = true; + toast({ variant: "destructive", title: "Video generation failed" }); + } + } catch { /* poll again next interval */ } + } + if (changed) updateModuleState("speaking", { speakingParts: updatedParts }); + }, 15000); + return () => { if (pollTimerRef.current) { clearInterval(pollTimerRef.current); pollTimerRef.current = null; } }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeModule, moduleStates]); + const submitMut = useMutation({ mutationFn: (skipApproval: boolean) => { const modulesPayload: Record = {}; @@ -332,7 +400,11 @@ export default function GenerationPage() { } return generationService.submitExam({ title, label: examLabel, modules: modulesPayload, skip_approval: skipApproval }); }, - onSuccess: (res) => toast({ title: "Exam submitted", description: `Exam #${res.exam_id} created (${res.status})` }), + onSuccess: (res) => toast({ + title: "Exam submitted successfully", + description: `Exam #${res.exam_id} created with status "${res.status}". ${res.status === "published" ? "The exam is now live." : "Pending approval."}`, + duration: 8000, + }), onError: (err: Error) => toast({ variant: "destructive", title: "Submit failed", description: err.message }), }); @@ -482,12 +554,21 @@ export default function GenerationPage() { - - + { + const p = [...st.passages]; p[pi] = { ...p[pi], category: e.target.value }; + updateModuleState("reading", { passages: p }); + }} /> + { + const p = [...st.passages]; p[pi] = { ...p[pi], divider: e.target.value }; + updateModuleState("reading", { passages: p }); + }} /> - {sec.audioUrl &&

Audio ready

} + {sec.audioUrl && ( +
+

Audio ready

+
+ )} @@ -694,14 +784,18 @@ export default function GenerationPage() { Generate Instructions - + { + const tasks = [...st.writingTasks]; tasks[ti] = { ...tasks[ti], category: e.target.value }; + updateModuleState("writing", { writingTasks: tasks }); + }} /> - {part.videoUrl &&

Video: {part.videoUrl.startsWith("pending:") ? "Processing..." : "Ready"}

} + {part.videoUrl && part.videoUrl.startsWith("pending:") && ( +
+ +

Video processing... (polling every 15s)

+
+ )} + {part.videoUrl && !part.videoUrl.startsWith("pending:") && ( +
+

Video ready

+
+ )}
@@ -893,13 +998,27 @@ export default function GenerationPage() {
- { + setExamStructure(val); + const s = structures.find((st) => String(st.id) === val); + if (s) { + const mods = (s as Record).modules as string[] | undefined; + if (Array.isArray(mods) && mods.length) { + const next = new Set(); + mods.forEach((m) => { if (MODULE_KEYS.includes(m as ModuleKey)) next.add(m as ModuleKey); }); + setSelectedModules(next); + if (next.size > 0) setActiveModule([...next][0]); + } + } + }}> - Standard IELTS Academic - Corporate English Assessment - Hospitality English Test - Medical English Proficiency + {structures.map((s) => ( + {s.name} + ))} + {structures.length === 0 && ( + No structures available + )}
@@ -971,11 +1090,107 @@ export default function GenerationPage() { - )} + + + + + {title || "Untitled Exam"} — Preview + + Read-only preview of all configured modules and their content. + + +
+ {[...selectedModules].map((mod) => { + const st = getModuleState(mod); + return ( +
+
+

{mod}

+
+ {st.timer} min + {st.difficulty.map((d) => {d})} + {st.accessType} +
+
+ + {mod === "reading" && st.passages.map((p, i) => ( +
+

Passage {i + 1} {p.category && `(${p.category})`}

+ {p.text ? ( +

{p.text.slice(0, 500)}{p.text.length > 500 ? "…" : ""}

+ ) : ( +

No passage text yet

+ )} + {p.exercises.length > 0 && ( +

{p.exercises.length} exercise(s) generated

+ )} +
+ ))} + + {mod === "listening" && st.listeningSections.map((s, i) => ( +
+

Section {i + 1}: {s.type.replace(/_/g, " ")}

+ {s.context ? ( +

{s.context.slice(0, 500)}{s.context.length > 500 ? "…" : ""}

+ ) : ( +

No context yet

+ )} + {s.audioUrl &&

Audio generated

} + {s.exercises.length > 0 && ( +

{s.exercises.length} exercise(s) generated

+ )} +
+ ))} + + {mod === "writing" && st.writingTasks.map((t, i) => ( +
+
+

Task {i + 1}

+ {t.wordLimit} words · {t.marks} marks +
+ {t.instructions ? ( +

{t.instructions}

+ ) : ( +

No instructions yet

+ )} +
+ ))} + + {mod === "speaking" && st.speakingParts.map((p, i) => ( +
+
+

Part {i + 1}: {p.type.replace(/_/g, " ")}

+ {p.marks} marks +
+ {p.script ? ( +

{p.script.slice(0, 600)}{p.script.length > 600 ? "…" : ""}

+ ) : ( +

No script yet

+ )} + {p.videoUrl && !p.videoUrl.startsWith("pending:") && ( +

Video ready

+ )} + {p.videoUrl && p.videoUrl.startsWith("pending:") && ( +

Video processing…

+ )} +
+ ))} + + {st.shuffling &&

Shuffling enabled

} +
+ ); + })} + {selectedModules.size === 0 && ( +

No modules selected yet.

+ )} +
+
+
); } diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index 89ad8f1..9dc85e3 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -1,17 +1,18 @@ -import { useState } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { Link, useNavigate } from "react-router-dom"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; +import { useQuery } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { Progress } from "@/components/ui/progress"; -import { GraduationCap, Eye, EyeOff, Loader2 } from "lucide-react"; +import { GraduationCap, Eye, EyeOff, Loader2, ShieldCheck } from "lucide-react"; import { useRegister, useCheckEmail } from "@/hooks/queries/useSignup"; -import { ApiError } from "@/lib/api-client"; +import { ApiError, api } from "@/lib/api-client"; import type { RegisterRequest } from "@/types"; import { cn } from "@/lib/utils"; @@ -47,6 +48,41 @@ export default function Register() { const checkEmail = useCheckEmail(); const [showPassword, setShowPassword] = useState(false); const [showConfirm, setShowConfirm] = useState(false); + const [captchaToken, setCaptchaToken] = useState(""); + const captchaContainerRef = useRef(null); + + const { data: captchaConfig } = useQuery({ + queryKey: ["captcha-config"], + queryFn: () => api.get<{ provider: string; site_key: string }>("/config/captcha"), + staleTime: Infinity, + }); + + const loadCaptchaScript = useCallback((provider: string, siteKey: string) => { + if (!siteKey || !provider) return; + const existingScript = document.querySelector(`script[data-captcha-provider="${provider}"]`); + if (existingScript) return; + + const scriptUrls: Record = { + recaptcha: `https://www.google.com/recaptcha/api.js?render=${siteKey}`, + hcaptcha: "https://js.hcaptcha.com/1/api.js", + turnstile: "https://challenges.cloudflare.com/turnstile/v0/api.js", + }; + const url = scriptUrls[provider]; + if (!url) return; + + const script = document.createElement("script"); + script.src = url; + script.async = true; + script.defer = true; + script.setAttribute("data-captcha-provider", provider); + document.head.appendChild(script); + }, []); + + useEffect(() => { + if (captchaConfig?.site_key && captchaConfig?.provider) { + loadCaptchaScript(captchaConfig.provider, captchaConfig.site_key); + } + }, [captchaConfig, loadCaptchaScript]); const form = useForm({ resolver: zodResolver(schema), @@ -69,7 +105,7 @@ export default function Register() { email: values.email.trim(), password: values.password, role: values.role, - captcha_token: "demo-placeholder", + captcha_token: captchaToken || undefined, }; try { await register.mutateAsync(payload); @@ -251,10 +287,26 @@ export default function Register() { />
-

CAPTCHA

-
- Verification widget placeholder +

+ Verification +

+
+ {captchaConfig?.site_key ? ( +
+ ) : ( +

CAPTCHA not configured — registration allowed

+ )}
+ {captchaToken && ( +

+ Verified +

+ )}
{form.formState.errors.root && ( diff --git a/src/pages/RubricsPage.tsx b/src/pages/RubricsPage.tsx index f6f86d3..23c2e79 100644 --- a/src/pages/RubricsPage.tsx +++ b/src/pages/RubricsPage.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import { Card, CardContent } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; @@ -8,11 +9,21 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; -import { Search, Plus } from "lucide-react"; +import { Search, Plus, Loader2 } from "lucide-react"; import AiTipBanner from "@/components/ai/AiTipBanner"; import AiCreationAssistant from "@/components/ai/AiCreationAssistant"; +import { examsService } from "@/services/exams.service"; -const rubrics = [ +interface RubricItem { + id: number; + name: string; + levels: string[]; + criteria: number; + created: string; + skill?: string; +} + +const FALLBACK_RUBRICS: RubricItem[] = [ { id: 1, name: "IELTS Writing Task 2", levels: ["A1","A2","B1","B2","C1","C2"], criteria: 4, created: "2025-01-05" }, { id: 2, name: "Speaking Fluency", levels: ["A1","A2","B1","B2","C1","C2"], criteria: 3, created: "2025-01-10" }, { id: 3, name: "Reading Comprehension", levels: ["A1","A2","B1","B2","C1"], criteria: 5, created: "2025-02-01" }, @@ -26,6 +37,13 @@ const rubricGroups = [ export default function RubricsPage() { const [search, setSearch] = useState(""); + const rubricsQ = useQuery({ + queryKey: ["rubrics"], + queryFn: () => examsService.listRubrics({}), + }); + const backendRubrics = (rubricsQ.data?.items ?? []) as RubricItem[]; + const rubrics = backendRubrics.length > 0 ? backendRubrics : FALLBACK_RUBRICS; + return (
diff --git a/src/pages/admin/CustomExamCreate.tsx b/src/pages/admin/CustomExamCreate.tsx index 633e13a..811c244 100644 --- a/src/pages/admin/CustomExamCreate.tsx +++ b/src/pages/admin/CustomExamCreate.tsx @@ -583,10 +583,27 @@ function NewQuestionInline({ sectionIndex, form }: { sectionIndex: number; form: - diff --git a/src/pages/student/ExamResults.tsx b/src/pages/student/ExamResults.tsx index e81a7fc..adf56a7 100644 --- a/src/pages/student/ExamResults.tsx +++ b/src/pages/student/ExamResults.tsx @@ -1,4 +1,5 @@ import { Link, useParams, useSearchParams } from "react-router-dom"; +import { useQuery } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; @@ -19,25 +20,107 @@ import { PolarRadiusAxis, ResponsiveContainer, } from "recharts"; -import { Award, BookOpen, Download, RefreshCw } from "lucide-react"; +import { Award, BookOpen, Download, RefreshCw, Loader2 } from "lucide-react"; +import { examSessionService } from "@/services/exam-session.service"; +import { reportService } from "@/services/report.service"; +import { useState } from "react"; -const SKILLS = [ - { skill: "Listening", band: 7.5, cefr: "C1", gap: 0.5, target: 8 }, - { skill: "Reading", band: 8, cefr: "C1", gap: 0, target: 8 }, - { skill: "Writing", band: 6.5, cefr: "B2", gap: 1.5, target: 8 }, - { skill: "Speaking", band: 7, cefr: "C1", gap: 1, target: 8 }, -]; +interface ScoreEntry { + skill: string; + band_score: number; + raw_score: number; + max_score: number; + cefr_level: string; +} -const RADAR_DATA = SKILLS.map((s) => ({ skill: s.skill, band: s.band })); +interface FeedbackEntry { + question_id: number | null; + feedback_text: string; + source: string; +} + +interface ExamResultsData { + attempt_id: number; + exam_id: number | null; + status: string; + completed_at: string; + released_at: string; + listening_band: number; + reading_band: number; + writing_band: number; + speaking_band: number; + overall_band: number; + cefr_level: string; + scores: ScoreEntry[]; + feedback: FeedbackEntry[]; +} export default function ExamResults() { const { examId } = useParams(); const [searchParams] = useSearchParams(); const practice = searchParams.get("mode") === "practice"; - const overall = 7.5; - const cefr = "C1"; + const [downloading, setDownloading] = useState(false); + + const { data: results, isLoading, isError } = useQuery({ + queryKey: ["exam-results", examId], + queryFn: () => examSessionService.getResults(Number(examId)), + enabled: !!examId, + }); + + const handleDownloadPdf = async () => { + if (!results?.attempt_id) return; + setDownloading(true); + try { + await reportService.downloadPdf(results.attempt_id); + } catch { + // error handled silently + } finally { + setDownloading(false); + } + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (isError || !results) { + return ( +
+

Results not yet available

+

Your results may still be pending approval or grading.

+ +
+ ); + } + + const overall = results.overall_band; + const cefr = results.cefr_level?.toUpperCase() || "N/A"; const passed = overall >= 7; + const skillScores = results.scores.filter((s) => s.skill !== "overall"); + const SKILLS = skillScores.map((s) => ({ + skill: s.skill.charAt(0).toUpperCase() + s.skill.slice(1), + band: s.band_score, + cefr: s.cefr_level?.toUpperCase() || "N/A", + gap: Math.max(0, 8 - s.band_score), + target: 8, + })); + + const RADAR_DATA = SKILLS.map((s) => ({ skill: s.skill, band: s.band })); + + const feedbackBySkill: Record = {}; + for (const fb of results.feedback) { + const key = "General"; + if (!feedbackBySkill[key]) feedbackBySkill[key] = []; + feedbackBySkill[key].push(fb); + } + return (
@@ -53,24 +136,26 @@ export default function ExamResults() { {practice ? Practice mode : null}
- - - Skill profile - Per-skill performance vs maximum scale - - -
- - - - - - - - -
-
-
+ {RADAR_DATA.length > 0 && ( + + + Skill profile + Per-skill performance vs maximum scale + + +
+ + + + + + + + +
+
+
+ )} @@ -100,19 +185,24 @@ export default function ExamResults() { -
-

Section feedback

- - {["Listening", "Reading", "Writing", "Speaking"].map((name) => ( - - {name} - - Detailed feedback for {name} will appear here once released by your instructor. - - - ))} - -
+ {results.feedback.length > 0 && ( +
+

Feedback

+ + {results.feedback.map((fb, i) => ( + + + {fb.source === "ai" ? "AI Feedback" : fb.source === "teacher" ? "Teacher Feedback" : "Feedback"}{" "} + {fb.question_id ? `(Q${fb.question_id})` : ""} + + + {fb.feedback_text || "No detailed feedback available."} + + + ))} + +
+ )} @@ -121,16 +211,21 @@ export default function ExamResults() {
    -
  • Strengthen task response structure in Writing Task 2.
  • -
  • Extend range of cohesive devices in argumentative essays.
  • -
  • Maintain fluency while reducing hesitation in Speaking Part 2.
  • + {SKILLS.filter((s) => s.gap > 0) + .sort((a, b) => b.gap - a.gap) + .map((s) => ( +
  • + Focus on {s.skill} — current band {s.band}, target {s.target} (gap: {s.gap}). +
  • + ))} + {SKILLS.every((s) => s.gap === 0) &&
  • Excellent performance across all skills!
  • }
- -
-
-
- 0:00 / 3:42 -
+ {q.options?.length ? ( - Recording interface will appear here. -
+ { + const url = URL.createObjectURL(blob); + updateAnswer(q.id, { answer: url }); + }} + /> ); } @@ -481,3 +478,131 @@ export default function ExamSession() {
); } + +function ListeningPlayer({ audioUrl, audioBase64 }: { audioUrl?: string; audioBase64?: string }) { + const audioRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + + const src = audioUrl || (audioBase64 ? `data:audio/mpeg;base64,${audioBase64}` : ""); + + const formatTime = (sec: number) => { + const m = Math.floor(sec / 60); + const s = Math.floor(sec % 60); + return `${m}:${s.toString().padStart(2, "0")}`; + }; + + return ( +
+ {src ? ( + <> +