diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index b5b764aa9..000000000 --- a/.dockerignore +++ /dev/null @@ -1,10 +0,0 @@ -node_modules -dist -.git -.env -.env.local -.env.production -*.md -docs/ -.vscode/ -coverage/ diff --git a/.gitignore b/.gitignore index f7f99e40f..4899f06f6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,33 @@ +# Dependencies node_modules/ + +# Build output dist/ -.vite/ -*.local +build/ +.next/ +out/ + +# Environment / secrets — never commit .env +.env.* +!.env.example +.env.local +.env.production +.env.*.local + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS .DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 000000000..5d9d33a3b Binary files /dev/null and b/bun.lockb differ diff --git a/src/App.tsx b/src/App.tsx index 0d220518c..6d4adc816 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 000000000..5bb833276 --- /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/GenerationPage.tsx b/src/pages/GenerationPage.tsx index 6cf514cda..02875f13b 100644 --- a/src/pages/GenerationPage.tsx +++ b/src/pages/GenerationPage.tsx @@ -482,12 +482,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 }); + }} /> - diff --git a/src/pages/student/ExamResults.tsx b/src/pages/student/ExamResults.tsx index e81a7fcbb..adf56a7b8 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 ? ( + <> +