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
This commit is contained in:
Yamen Ahmad
2026-04-10 17:26:42 +04:00
parent a3e12f62fa
commit f1c4953a63
731 changed files with 67205 additions and 139 deletions

View File

@@ -0,0 +1,291 @@
import { useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import {
BookOpen,
CheckCircle2,
CircleDot,
Lock,
Sparkles,
TrendingUp,
Clock,
Percent,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { useAiCourse, useAiCourseTracks, useCreateEnglishCourse } from "@/hooks/queries/useAiCourse";
import { queryKeys } from "@/hooks/queries/keys";
import type { AICourseTrack, AIGenerationStep, AITrackModule } from "@/types";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
} from "recharts";
import { toast } from "sonner";
const DEFAULT_STEPS: AIGenerationStep[] = [
{ name: "Profile Analysis", status: "complete" },
{ name: "Reading Content", status: "in_progress" },
{ name: "Grammar Exercises", status: "pending" },
{ name: "Speaking Prompts", status: "pending" },
{ name: "Vocabulary Sets", status: "pending" },
];
const COLORS = ["hsl(var(--primary))", "hsl(var(--chart-2))", "hsl(var(--chart-3))"];
export default function AiEnglishCourse() {
const { courseId: cid } = useParams<{ courseId: string }>();
const courseId = Number(cid);
const qc = useQueryClient();
const { data: course, isLoading } = useAiCourse(Number.isFinite(courseId) ? courseId : undefined);
const { data: tracksRaw } = useAiCourseTracks(Number.isFinite(courseId) ? courseId : undefined);
const createEnglish = useCreateEnglishCourse();
const [target, setTarget] = useState("");
const [learningStyle, setLearningStyle] = useState("");
const [levelUpOpen, setLevelUpOpen] = useState(false);
const steps = course?.generation_steps?.length ? course.generation_steps : DEFAULT_STEPS;
const stepProgress = useMemo(() => {
const done = steps.filter((s) => s.status === "complete").length;
return Math.round((done / Math.max(steps.length, 1)) * 100);
}, [steps]);
const tracks: AICourseTrack[] = useMemo(() => {
if (tracksRaw?.length) return tracksRaw;
return [
{ name: "Grammar Track", progress_percent: 40, modules: placeholderModules("grammar") },
{ name: "Skills Track", progress_percent: 55, modules: placeholderModules("skills") },
{ name: "Vocabulary Track", progress_percent: 20, modules: placeholderModules("vocab") },
];
}, [tracksRaw]);
const cefrChart = [
{ label: "Start", value: 3.2 },
{ label: "Now", value: 4.1 },
{ label: "Target", value: 5.0 },
];
const timePerTrack = tracks.map((t) => ({ name: t.name.replace(" Track", ""), hours: t.progress_percent / 10 }));
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[320px] text-muted-foreground">
Loading course
</div>
);
}
const ls = learningStyle || course?.learning_style?.join(", ") || "visual, mixed";
const tgt = target || course?.target_level || "B2";
return (
<div className="space-y-8 p-6 max-w-7xl mx-auto">
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">AI English course</h1>
<p className="text-muted-foreground text-sm">Personalized pathway to your target level</p>
</div>
<Button onClick={() => setLevelUpOpen(true)} variant="secondary" size="sm" className="gap-2">
<Sparkles className="h-4 w-4" />
Preview celebration
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Profile confirmation</CardTitle>
<CardDescription>Adjust targets before continuing.</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div>
<Label className="text-muted-foreground">Current CEFR</Label>
<p className="text-lg font-semibold mt-1">{course?.current_level ?? "B1"}</p>
</div>
<div className="space-y-2">
<Label htmlFor="target">Target CEFR</Label>
<Input id="target" value={tgt} onChange={(e) => setTarget(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="style">Learning style</Label>
<Input id="style" value={ls} onChange={(e) => setLearningStyle(e.target.value)} />
</div>
<div>
<Label className="text-muted-foreground">Duration (weeks)</Label>
<p className="text-lg font-semibold mt-1">{course?.estimated_duration_weeks ?? 8}</p>
</div>
</CardContent>
<CardContent className="pt-0">
<Button
disabled={createEnglish.isPending}
onClick={() => {
if (!course) return;
const styles =
ls.split(",")
.map((x) => x.trim())
.filter(Boolean).length > 0
? ls.split(",")
.map((x) => x.trim())
.filter(Boolean)
: course?.learning_style ?? ["visual"];
createEnglish.mutate(
{ current_level: course?.current_level ?? "B1", target_level: tgt, learning_style: styles },
{
onSuccess: () => {
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.course(courseId) });
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.tracks(courseId) });
toast.success("AI course started.");
},
onError: (e) => toast.error(e instanceof Error ? e.message : "Could not start"),
},
);
}}
>
Start AI Course
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Generation progress</CardTitle>
<CardDescription>Content pipeline for this course</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between text-sm">
<span>Overall</span>
<span>{stepProgress}%</span>
</div>
<Progress value={stepProgress} />
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
{steps.map((s) => (
<div key={s.name} className="rounded-lg border p-3 text-sm">
<div className="font-medium leading-tight">{s.name}</div>
<div className="text-muted-foreground capitalize mt-1">{s.status.replace("_", " ")}</div>
</div>
))}
</div>
</CardContent>
</Card>
<div>
<h2 className="text-lg font-semibold mb-4">Learning tracks</h2>
<div className="grid gap-4 lg:grid-cols-3">
{tracks.map((track) => (
<Card key={track.name}>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<BookOpen className="h-4 w-4" />
{track.name}
</CardTitle>
<CardDescription>{track.progress_percent}% complete</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Progress value={track.progress_percent} />
<Separator className="my-2" />
<ul className="space-y-2">
{track.modules.map((m) => (
<li key={m.id} className="flex items-start gap-2 text-sm">
<ModuleIcon status={m.status} />
<div>
<div className="font-medium">{m.title}</div>
<div className="text-xs text-muted-foreground">~{m.estimated_hours}h</div>
</div>
</li>
))}
</ul>
</CardContent>
</Card>
))}
</div>
</div>
<Card>
<CardHeader>
<CardTitle>Progress report</CardTitle>
<CardDescription>CEFR trajectory and effort</CardDescription>
</CardHeader>
<CardContent className="grid gap-8 lg:grid-cols-2">
<div className="h-64">
<div className="flex items-center gap-2 text-sm font-medium mb-2">
<TrendingUp className="h-4 w-4" />
CEFR gain
</div>
<ResponsiveContainer width="100%" height="100%">
<BarChart data={cefrChart}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="label" />
<YAxis domain={[0, 6]} />
<Tooltip />
<Bar dataKey="value" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="h-64">
<div className="flex items-center gap-2 text-sm font-medium mb-2">
<Clock className="h-4 w-4" />
Time per track (relative)
</div>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie data={timePerTrack} dataKey="hours" nameKey="name" cx="50%" cy="50%" outerRadius={80}>
{timePerTrack.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip />
</PieChart>
</ResponsiveContainer>
</div>
<div className="lg:col-span-2 flex items-center gap-2 text-sm text-muted-foreground">
<Percent className="h-4 w-4" />
Completion rates follow each track&apos;s module checklist above.
</div>
</CardContent>
</Card>
<Dialog open={levelUpOpen} onOpenChange={setLevelUpOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-amber-500" />
Level up!
</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
You reached <strong>{course?.target_level ?? tgt}</strong>. The next modules unlock with richer tasks and feedback.
</p>
<DialogFooter>
<Button onClick={() => setLevelUpOpen(false)}>Continue</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function ModuleIcon({ status }: { status: AITrackModule["status"] }) {
if (status === "completed") return <CheckCircle2 className="h-4 w-4 text-green-600 shrink-0 mt-0.5" />;
if (status === "in_progress") return <CircleDot className="h-4 w-4 text-primary shrink-0 mt-0.5" />;
return <Lock className="h-4 w-4 text-muted-foreground shrink-0 mt-0.5" />;
}
function placeholderModules(prefix: string): AITrackModule[] {
return [
{ id: 1, title: `${prefix} — Foundations`, status: "completed", estimated_hours: 2 },
{ id: 2, title: `${prefix} — Practice set`, status: "in_progress", estimated_hours: 3 },
{ id: 3, title: `${prefix} — Mastery`, status: "locked", estimated_hours: 4 },
];
}

View File

@@ -0,0 +1,317 @@
import { useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useQueryClient } from "@tanstack/react-query";
import {
Headphones,
Mic,
BookMarked,
PenLine,
CheckCircle2,
CircleDot,
Lock,
ClipboardCheck,
Award,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Separator } from "@/components/ui/separator";
import { useAiCourse, useAiCourseTracks, useCreateIeltsCourse } from "@/hooks/queries/useAiCourse";
import { queryKeys } from "@/hooks/queries/keys";
import type { AICourseTrack, AIGenerationStep, AITrackModule } from "@/types";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
LineChart,
Line,
} from "recharts";
import { toast } from "sonner";
const SKILL_ICONS = [PenLine, BookMarked, Mic, Headphones];
export default function AiIeltsCourse() {
const { courseId: cid } = useParams<{ courseId: string }>();
const courseId = Number(cid);
const qc = useQueryClient();
const { data: course, isLoading } = useAiCourse(Number.isFinite(courseId) ? courseId : undefined);
const { data: tracksRaw } = useAiCourseTracks(Number.isFinite(courseId) ? courseId : undefined);
const createIelts = useCreateIeltsCourse();
const [targetBand, setTargetBand] = useState("");
const [readinessOpen, setReadinessOpen] = useState(false);
const skillsRanked =
course?.skills_ranked_by_gap && course.skills_ranked_by_gap.length > 0
? course.skills_ranked_by_gap
: [
{ skill: "Writing", gap: 1.2 },
{ skill: "Speaking", gap: 0.9 },
{ skill: "Reading", gap: 0.4 },
{ skill: "Listening", gap: 0.3 },
];
const weakTypes = course?.weak_question_types ?? {
Writing: ["Task 2 argument depth"],
Reading: ["True/False/Not Given"],
Speaking: ["Part 2 long turn"],
Listening: ["Form completion"],
};
const skillPanels: { skill: string; steps: AIGenerationStep[]; progress_percent: number }[] =
course?.ielts_generation_by_skill?.length
? course.ielts_generation_by_skill
: ["Writing", "Reading", "Speaking", "Listening"].map((skill, i) => ({
skill,
steps: [
{ name: "Blueprint", status: i === 0 ? ("in_progress" as const) : ("pending" as const) },
{ name: "Items", status: "pending" as const },
{ name: "Rubric alignment", status: "pending" as const },
],
progress_percent: [35, 20, 10, 5][i] ?? 0,
}));
const tracks: AICourseTrack[] = useMemo(() => {
if (tracksRaw?.length) return tracksRaw;
return ["Writing", "Reading", "Speaking", "Listening"].map((name, ti) => ({
name: `${name} skill`,
progress_percent: 15 + ti * 8,
modules: ieltsModules(name),
}));
}, [tracksRaw]);
const bandSeries = [
{ label: "L", v: course?.current_bands?.Listening ?? 6.0 },
{ label: "R", v: course?.current_bands?.Reading ?? 6.5 },
{ label: "W", v: course?.current_bands?.Writing ?? 5.5 },
{ label: "S", v: course?.current_bands?.Speaking ?? 6.0 },
];
if (isLoading) {
return <div className="flex justify-center py-24 text-muted-foreground">Loading</div>;
}
const tgt = targetBand || String(course?.target_level ?? "6.5");
return (
<div className="space-y-8 p-6 max-w-7xl mx-auto">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-2xl font-semibold tracking-tight">AI IELTS course</h1>
<p className="text-muted-foreground text-sm">Band-focused practice generated for you</p>
</div>
<Button variant="secondary" size="sm" onClick={() => setReadinessOpen(true)}>
<ClipboardCheck className="h-4 w-4 mr-2" />
Practice exam readiness
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>IELTS gap profile</CardTitle>
<CardDescription>Exam context and targets</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div>
<Label className="text-muted-foreground">Exam type</Label>
<p className="text-lg font-semibold mt-1">{course?.exam_type ?? "Academic"}</p>
</div>
<div>
<Label className="text-muted-foreground">Current bands</Label>
<p className="text-sm mt-1">
{Object.entries(course?.current_bands ?? { L: 6, R: 6.5, W: 5.5, S: 6 })
.map(([k, v]) => `${k}: ${v}`)
.join(" · ")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="tgt">Target band</Label>
<Input id="tgt" value={tgt} onChange={(e) => setTargetBand(e.target.value)} />
</div>
<div>
<Label className="text-muted-foreground">Ranked by gap</Label>
<ol className="mt-2 text-sm list-decimal list-inside space-y-1">
{skillsRanked.map((s) => (
<li key={s.skill}>
{s.skill} (Δ {s.gap.toFixed(1)})
</li>
))}
</ol>
</div>
</CardContent>
<CardContent className="pt-0 space-y-3">
<Label className="text-muted-foreground">Weak question types</Label>
<div className="grid sm:grid-cols-2 gap-3 text-sm">
{Object.entries(weakTypes).map(([skill, items]) => (
<div key={skill} className="rounded-md border p-3">
<div className="font-medium mb-1">{skill}</div>
<ul className="list-disc list-inside text-muted-foreground">
{items.map((x) => (
<li key={x}>{x}</li>
))}
</ul>
</div>
))}
</div>
<Button
disabled={createIelts.isPending}
onClick={() => {
const band = Number(targetBand || course?.target_level || 7);
createIelts.mutate(
{
exam_type: course?.exam_type ?? "academic",
target_band: Number.isFinite(band) ? band : 7,
skills: skillsRanked.map((s) => s.skill),
},
{
onSuccess: () => {
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.course(courseId) });
qc.invalidateQueries({ queryKey: queryKeys.aiCourse.tracks(courseId) });
toast.success("AI IELTS course started.");
},
onError: (e) => toast.error(e instanceof Error ? e.message : "Could not start"),
},
);
}}
>
Start AI IELTS Course
</Button>
</CardContent>
</Card>
<div>
<h2 className="text-lg font-semibold mb-4">Per-skill generation</h2>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{skillPanels.map((panel, idx) => {
const Icon = SKILL_ICONS[idx % SKILL_ICONS.length];
return (
<Card key={panel.skill}>
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Icon className="h-4 w-4" />
{panel.skill}
</CardTitle>
<CardDescription>{panel.progress_percent}%</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Progress value={panel.progress_percent} />
<ul className="text-sm space-y-1">
{panel.steps.map((s) => (
<li key={s.name} className="flex justify-between gap-2">
<span>{s.name}</span>
<span className="text-muted-foreground capitalize">{s.status.replace("_", " ")}</span>
</li>
))}
</ul>
</CardContent>
</Card>
);
})}
</div>
</div>
<div>
<h2 className="text-lg font-semibold mb-4">Skill columns</h2>
<div className="grid gap-4 lg:grid-cols-4">
{tracks.map((track) => (
<Card key={track.name}>
<CardHeader className="pb-2">
<CardTitle className="text-sm">{track.name}</CardTitle>
<CardDescription>{track.progress_percent}%</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
<Progress value={track.progress_percent} />
<Separator />
<ul className="space-y-2 text-sm">
{track.modules.map((m) => (
<li key={m.id} className="flex gap-2">
<ModIcon st={m.status} />
<span>{m.title}</span>
</li>
))}
</ul>
</CardContent>
</Card>
))}
</div>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Award className="h-5 w-5" />
Band improvement report
</CardTitle>
<CardDescription>Current skill snapshot vs trajectory</CardDescription>
</CardHeader>
<CardContent className="grid gap-8 lg:grid-cols-2">
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={bandSeries}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="label" />
<YAxis domain={[0, 9]} />
<Tooltip />
<Bar dataKey="v" fill="hsl(var(--primary))" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={[
{ w: 1, b: 5.6 },
{ w: 2, b: 6.1 },
{ w: 3, b: 6.4 },
{ w: 4, b: 6.8 },
]}
>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis dataKey="w" />
<YAxis domain={[5, 8]} />
<Tooltip />
<Line type="monotone" dataKey="b" stroke="hsl(var(--primary))" strokeWidth={2} dot />
</LineChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Dialog open={readinessOpen} onOpenChange={setReadinessOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Practice exam readiness</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
When each skill column reaches the practice threshold, you unlock a full timed paper with authentic pacing and
AI feedback.
</p>
<DialogFooter>
<Button onClick={() => setReadinessOpen(false)}>Got it</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function ModIcon({ st }: { st: AITrackModule["status"] }) {
if (st === "completed") return <CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />;
if (st === "in_progress") return <CircleDot className="h-4 w-4 text-primary shrink-0" />;
return <Lock className="h-4 w-4 text-muted-foreground shrink-0" />;
}
function ieltsModules(skill: string): AITrackModule[] {
return [
{ id: 1, title: `${skill} — Drill set A`, status: "completed", estimated_hours: 2 },
{ id: 2, title: `${skill} — Drill set B`, status: "in_progress", estimated_hours: 2 },
{ id: 3, title: `${skill} — Exam-style pack`, status: "locked", estimated_hours: 3 },
];
}

View File

@@ -0,0 +1,204 @@
import { useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useCourseDetail, useTrackProgress } from "@/hooks/queries/useCourseGeneration";
import type { ModuleConfig, ModuleResource } from "@/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
BookOpen,
Check,
ChevronDown,
FileText,
Headphones,
Lock,
Sparkles,
Video,
} from "lucide-react";
import { cn } from "@/lib/utils";
function ResourceIcon({ type }: { type: ModuleResource["type"] }) {
switch (type) {
case "pdf":
return <FileText className="h-4 w-4" />;
case "video":
return <Video className="h-4 w-4" />;
case "audio":
return <Headphones className="h-4 w-4" />;
default:
return <BookOpen className="h-4 w-4" />;
}
}
export default function CourseDelivery() {
const { courseId: courseIdParam } = useParams();
const courseId = Number(courseIdParam);
const { data: course, isLoading } = useCourseDetail(courseId);
const track = useTrackProgress();
const [activeResource, setActiveResource] = useState<{ module: ModuleConfig; resource: ModuleResource } | null>(null);
const bySkill = useMemo(() => {
const map = new Map<string, ModuleConfig[]>();
course?.modules.forEach((m) => {
const k = m.skill || "General";
if (!map.has(k)) map.set(k, []);
map.get(k)!.push(m);
});
return map;
}, [course]);
const overallProgress = useMemo(() => {
if (!course?.modules.length) return 0;
const sum = course.modules.reduce((s, m) => s + (m.progress_percent ?? 0), 0);
return Math.round(sum / course.modules.length);
}, [course]);
const weeksLeft = course ? Math.max(0, course.duration_weeks - 2) : 0;
const markResource = (mod: ModuleConfig, res: ModuleResource) => {
track.mutate({
courseId,
progress: {
resource_id: res.id,
completion_percent: 100,
time_spent_ms: 60000,
},
});
setActiveResource({ module: mod, resource: res });
};
if (isLoading) {
return (
<div className="p-6">
<Skeleton className="mb-4 h-12 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
return (
<div className="mx-auto max-w-5xl space-y-8 p-6">
<Card>
<CardHeader>
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<CardTitle className="text-2xl">{course?.title ?? `Course #${courseId}`}</CardTitle>
<CardDescription>
{course?.exam_type ?? "IELTS"} · Target {course?.target_band ?? course?.target_level ?? "—"}
</CardDescription>
</div>
<Badge variant="secondary">{course?.progression_model ?? "linear"}</Badge>
</div>
<div className="mt-4 space-y-2">
<div className="flex justify-between text-sm">
<span>Overall progress</span>
<span>{overallProgress}%</span>
</div>
<Progress value={overallProgress} />
<p className="text-sm text-muted-foreground">About {weeksLeft} weeks remaining at your current pace</p>
</div>
</CardHeader>
</Card>
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-4">
{Array.from(bySkill.entries()).map(([skill, mods]) => (
<Collapsible key={skill} defaultOpen>
<Card>
<CollapsibleTrigger asChild>
<CardHeader className="flex cursor-pointer flex-row items-center justify-between py-3">
<CardTitle className="text-base">{skill}</CardTitle>
<ChevronDown className="h-5 w-5" />
</CardHeader>
</CollapsibleTrigger>
<CollapsibleContent>
<CardContent className="space-y-4 pt-0">
{mods.map((mod) => (
<div key={mod.id} className="rounded-lg border p-4">
<div className="mb-2 flex flex-wrap items-center gap-2">
<h3 className="font-semibold">{mod.title}</h3>
{mod.status === "locked" ? <Lock className="h-4 w-4 text-muted-foreground" /> : null}
{mod.id === course?.modules.find((m) => m.status === "in_progress")?.id ? (
<Badge>Recommended Next</Badge>
) : null}
{mod.status === "skipped" ? <Badge variant="outline">Skipped</Badge> : null}
</div>
<div className="mb-2 text-xs text-muted-foreground">
{mod.estimated_hours}h · {mod.cefr_target ?? "CEFR target"}
</div>
<Progress value={mod.progress_percent ?? 0} className="mb-3 h-2" />
<div className="space-y-2">
{mod.resources.map((r) => (
<button
key={r.id}
type="button"
onClick={() => markResource(mod, r)}
className="flex w-full items-center justify-between rounded-md border bg-background px-3 py-2 text-left text-sm hover:bg-muted/50"
>
<span className="flex items-center gap-2">
<ResourceIcon type={r.type} />
{r.title}
</span>
{r.completed ? <Check className="h-4 w-4 text-green-600" /> : null}
</button>
))}
</div>
<div className="mt-3 flex gap-2">
<Button type="button" size="sm" disabled={mod.status === "locked"}>
{mod.status === "in_progress" || mod.progress_percent ? "Continue" : "Start"}
</Button>
</div>
</div>
))}
</CardContent>
</CollapsibleContent>
</Card>
</Collapsible>
))}
</div>
<Card className="min-h-[320px]">
<CardHeader>
<CardTitle className="text-base">Resource viewer</CardTitle>
<CardDescription>
{activeResource ? activeResource.resource.title : "Select a resource to open the viewer"}
</CardDescription>
</CardHeader>
<CardContent>
{!activeResource ? (
<p className="text-sm text-muted-foreground">PDF, video, audio, and exercise viewers render here.</p>
) : (
<div className="space-y-3">
<div
className={cn(
"flex min-h-[200px] items-center justify-center rounded-lg border bg-muted/30 p-6 text-center text-sm text-muted-foreground",
)}
>
{activeResource.resource.type === "pdf" && "PDF viewer placeholder"}
{activeResource.resource.type === "video" && "Video player placeholder"}
{activeResource.resource.type === "audio" && "Audio player placeholder"}
{activeResource.resource.type === "exercise" && "Interactive exercise placeholder"}
{activeResource.resource.type === "ai_generated" && (
<span className="flex items-center gap-2">
<Sparkles className="h-4 w-4" /> AI-generated content placeholder
</span>
)}
</div>
<Separator />
<ScrollArea className="h-32 text-xs text-muted-foreground">
Module: {activeResource.module.title}. Completion: {activeResource.module.completion_criteria}.
</ScrollArea>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,127 @@
import { useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { Loader2, CheckCircle2, ArrowRight, Clock } from "lucide-react";
import { useStartDiagnostic, useAnswerDiagnostic } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { DiagnosticQuestion } from "@/types";
export default function DiagnosticTest() {
const { subjectId } = useParams<{ subjectId: string }>();
const navigate = useNavigate();
const { toast } = useToast();
const startMutation = useStartDiagnostic();
const answerMutation = useAnswerDiagnostic();
const [sessionId, setSessionId] = useState<number | null>(null);
const [currentQuestion, setCurrentQuestion] = useState<DiagnosticQuestion | null>(null);
const [selectedAnswer, setSelectedAnswer] = useState("");
const [questionIndex, setQuestionIndex] = useState(0);
const [totalQuestions, setTotalQuestions] = useState(0);
const [completed, setCompleted] = useState(false);
const handleStart = () => {
startMutation.mutate(Number(subjectId), {
onSuccess: (data) => {
setSessionId(data.session.id);
setCurrentQuestion(data.first_question);
setTotalQuestions(data.session.total_questions);
setQuestionIndex(1);
},
onError: () => toast({ title: "Error", description: "Failed to start diagnostic", variant: "destructive" }),
});
};
const handleAnswer = () => {
if (!sessionId || !currentQuestion || !selectedAnswer) return;
answerMutation.mutate(
{ session_id: sessionId, question_id: currentQuestion.id, answer: selectedAnswer },
{
onSuccess: (data) => {
setSelectedAnswer("");
if (data.completed) {
setCompleted(true);
} else if (data.next_question) {
setCurrentQuestion(data.next_question);
setQuestionIndex((i) => i + 1);
}
},
onError: () => toast({ title: "Error", description: "Failed to submit answer", variant: "destructive" }),
}
);
};
if (completed) {
return (
<div className="max-w-lg mx-auto text-center py-16 space-y-6">
<CheckCircle2 className="h-16 w-16 text-green-500 mx-auto" />
<h1 className="text-2xl font-bold">Diagnostic Complete!</h1>
<p className="text-muted-foreground">Your proficiency profile has been generated.</p>
<div className="flex gap-3 justify-center">
<Button onClick={() => navigate(`/student/proficiency/${subjectId}`)}>View Proficiency</Button>
<Button variant="outline" onClick={() => navigate(`/student/plan/${subjectId}`)}>See Learning Plan</Button>
</div>
</div>
);
}
if (!sessionId) {
return (
<div className="max-w-lg mx-auto text-center py-16 space-y-6">
<Clock className="h-16 w-16 text-primary mx-auto opacity-60" />
<h1 className="text-2xl font-bold">Diagnostic Assessment</h1>
<p className="text-muted-foreground">This adaptive test will assess your current knowledge across all topics. Questions adapt to your performance in real time.</p>
<Button size="lg" onClick={handleStart} disabled={startMutation.isPending}>
{startMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Begin Assessment
</Button>
</div>
);
}
return (
<div className="max-w-2xl mx-auto space-y-6">
<div className="space-y-2">
<div className="flex justify-between text-sm text-muted-foreground">
<span>Question {questionIndex} of ~{totalQuestions}</span>
<Badge variant="outline">{currentQuestion?.difficulty}</Badge>
</div>
<Progress value={(questionIndex / totalQuestions) * 100} className="h-2" />
</div>
{currentQuestion && (
<Card>
<CardHeader>
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-2">
<span>{currentQuestion.domain_name}</span>
<span>·</span>
<span>{currentQuestion.topic_name}</span>
</div>
<CardTitle className="text-lg">{currentQuestion.question_text}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{currentQuestion.options && (
<RadioGroup value={selectedAnswer} onValueChange={setSelectedAnswer}>
{currentQuestion.options.map((opt, i) => (
<div key={i} className="flex items-center space-x-3 rounded-lg border p-3 hover:bg-accent/50 transition-colors">
<RadioGroupItem value={opt} id={`opt-${i}`} />
<Label htmlFor={`opt-${i}`} className="flex-1 cursor-pointer">{opt}</Label>
</div>
))}
</RadioGroup>
)}
<Button className="w-full" onClick={handleAnswer} disabled={!selectedAnswer || answerMutation.isPending}>
{answerMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Submit Answer <ArrowRight className="ml-1 h-4 w-4" />
</Button>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,178 @@
import { Link, useParams, useSearchParams } from "react-router-dom";
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";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import {
Radar,
RadarChart,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
ResponsiveContainer,
} from "recharts";
import { Award, BookOpen, Download, RefreshCw } from "lucide-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 },
];
const RADAR_DATA = SKILLS.map((s) => ({ skill: s.skill, band: s.band }));
export default function ExamResults() {
const { examId } = useParams();
const [searchParams] = useSearchParams();
const practice = searchParams.get("mode") === "practice";
const overall = 7.5;
const cefr = "C1";
const passed = overall >= 7;
return (
<div className="mx-auto max-w-5xl space-y-8 p-6">
<div className="text-center">
<p className="text-sm text-muted-foreground">Your overall result</p>
<div className="mt-2 flex items-center justify-center gap-3">
<Award className="h-12 w-12 text-primary" />
<span className="text-5xl font-bold tabular-nums">{overall}</span>
<Badge variant="secondary" className="text-lg">
Band
</Badge>
</div>
<p className="mt-2 text-lg text-muted-foreground">CEFR equivalent: {cefr}</p>
{practice ? <Badge className="mt-2">Practice mode</Badge> : null}
</div>
<Card>
<CardHeader>
<CardTitle>Skill profile</CardTitle>
<CardDescription>Per-skill performance vs maximum scale</CardDescription>
</CardHeader>
<CardContent>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={RADAR_DATA} cx="50%" cy="50%" outerRadius="80%">
<PolarGrid />
<PolarAngleAxis dataKey="skill" />
<PolarRadiusAxis angle={30} domain={[0, 9]} tickCount={6} />
<Radar name="Band" dataKey="band" stroke="hsl(var(--primary))" fill="hsl(var(--primary))" fillOpacity={0.35} />
</RadarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Skills breakdown</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Skill</TableHead>
<TableHead>Band</TableHead>
<TableHead>CEFR</TableHead>
<TableHead>Gap to Target</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{SKILLS.map((s) => (
<TableRow key={s.skill}>
<TableCell className="font-medium">{s.skill}</TableCell>
<TableCell>{s.band}</TableCell>
<TableCell>{s.cefr}</TableCell>
<TableCell>{s.gap > 0 ? `${s.gap}` : "—"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<div>
<h2 className="mb-3 text-lg font-semibold">Section feedback</h2>
<Accordion type="multiple" className="w-full">
{["Listening", "Reading", "Writing", "Speaking"].map((name) => (
<AccordionItem key={name} value={name}>
<AccordionTrigger>{name}</AccordionTrigger>
<AccordionContent className="text-muted-foreground">
Detailed feedback for {name} will appear here once released by your instructor.
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
<Card>
<CardHeader>
<CardTitle>Areas to improve</CardTitle>
<CardDescription>Focus recommendations based on this attempt</CardDescription>
</CardHeader>
<CardContent>
<ul className="list-inside list-disc space-y-2 text-sm">
<li>Strengthen task response structure in Writing Task 2.</li>
<li>Extend range of cohesive devices in argumentative essays.</li>
<li>Maintain fluency while reducing hesitation in Speaking Part 2.</li>
</ul>
</CardContent>
</Card>
<div className="flex flex-wrap gap-3">
<Button type="button" variant="outline">
<Download className="mr-2 h-4 w-4" />
Download PDF Report
</Button>
<Button type="button" asChild>
<Link to={`/student/course/generate?from=exam&examId=${examId ?? ""}`}>
<BookOpen className="mr-2 h-4 w-4" />
View Recommended Course
</Link>
</Button>
<Button type="button" variant="secondary" asChild>
<Link to={`/student/exam/${examId}/session?mode=practice`}>
<RefreshCw className="mr-2 h-4 w-4" />
Retake Exam
</Link>
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2">
<Card className={passed ? "border-green-600/40 bg-green-500/5" : ""}>
<CardHeader>
<CardTitle>Pass path</CardTitle>
<CardDescription>Congratulations you are on track for your goal.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm">Consider registering for the official test while skills are strong.</p>
<Button type="button" size="sm">
Register for official exam
</Button>
</CardContent>
</Card>
<Card className={!passed ? "border-amber-600/40 bg-amber-500/5" : ""}>
<CardHeader>
<CardTitle>Below threshold</CardTitle>
<CardDescription>Close the gap with targeted training.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm">Start adaptive training tailored to your weakest skills.</p>
<Button type="button" size="sm" variant="secondary" asChild>
<Link to={`/student/course/generate?from=exam&examId=${examId ?? ""}`}>Start adaptive training</Link>
</Button>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,458 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useExamSession, useExamAutoSave, useExamSubmit } from "@/hooks/queries/useExamSession";
import type { ExamAnswer, ExamQuestion, ExamSessionSection } from "@/types";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { Checkbox } from "@/components/ui/checkbox";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Flag, ChevronLeft, ChevronRight, Pause, Play } from "lucide-react";
import { cn } from "@/lib/utils";
function normalizeType(t: string) {
return t.toLowerCase().replace(/\s+/g, "_");
}
function countWords(s: string) {
return s.trim() ? s.trim().split(/\s+/).length : 0;
}
function buildAnswerMap(sections: ExamSessionSection[]) {
const map = new Map<number, ExamAnswer>();
for (const sec of sections) {
for (const q of sec.questions) {
map.set(q.id, {
question_id: q.id,
answer: null,
flagged: false,
time_spent_ms: 0,
});
}
}
return map;
}
export default function ExamSession() {
const { examId: examIdParam } = useParams();
const examId = Number(examIdParam);
const navigate = useNavigate();
const { data: session, isLoading, isError } = useExamSession(examId);
const autoSave = useExamAutoSave();
const submitMut = useExamSubmit();
const [sectionIdx, setSectionIdx] = useState(0);
const [questionIdx, setQuestionIdx] = useState(0);
const [answers, setAnswers] = useState<Map<number, ExamAnswer>>(new Map());
const [sectionGate, setSectionGate] = useState<{ open: boolean; sec: number; remaining: number } | null>(null);
const [reviewOpen, setReviewOpen] = useState(false);
const [playing, setPlaying] = useState(false);
const totalSecRef = useRef(0);
const qStartRef = useRef(Date.now());
useEffect(() => {
if (!session) return;
setAnswers(buildAnswerMap(session.sections));
totalSecRef.current = session.total_time_min * 60;
setTick(0);
}, [session]);
const section = session?.sections[sectionIdx];
const question = section?.questions[questionIdx];
const totalQuestions = useMemo(
() => session?.sections.reduce((n, s) => n + s.questions.length, 0) ?? 0,
[session],
);
const [tick, setTick] = useState(0);
useEffect(() => {
const id = window.setInterval(() => setTick((t) => t + 1), 1000);
return () => window.clearInterval(id);
}, []);
useEffect(() => {
qStartRef.current = Date.now();
}, [question?.id]);
const answeredCount = useMemo(() => {
let n = 0;
answers.forEach((a) => {
if (a.answer === null || a.answer === "") return;
if (Array.isArray(a.answer) && a.answer.length === 0) return;
n += 1;
});
return n;
}, [answers]);
const progressPct = totalQuestions ? (answeredCount / totalQuestions) * 100 : 0;
const updateAnswer = useCallback(
(questionId: number, patch: Partial<ExamAnswer>) => {
setAnswers((prev) => {
const next = new Map(prev);
const cur = next.get(questionId);
if (!cur) return prev;
const spent = Date.now() - qStartRef.current;
next.set(questionId, { ...cur, ...patch, time_spent_ms: cur.time_spent_ms + spent });
qStartRef.current = Date.now();
return next;
});
},
[],
);
const currentSectionAnswers = useCallback((): ExamAnswer[] => {
if (!section) return [];
return section.questions.map((q) => answers.get(q.id)!).filter(Boolean);
}, [section, answers]);
useEffect(() => {
if (!session || !section) return;
const id = window.setInterval(() => {
autoSave.mutate({
examId,
payload: { section_id: section.id, answers: currentSectionAnswers() },
});
}, 10000);
return () => window.clearInterval(id);
}, [session, section, examId, autoSave, currentSectionAnswers]);
const handleFlag = () => {
if (!question) return;
const cur = answers.get(question.id);
if (!cur) return;
updateAnswer(question.id, { flagged: !cur.flagged });
};
const goNext = () => {
if (!session || !section) return;
if (questionIdx < section.questions.length - 1) {
setQuestionIdx((i) => i + 1);
return;
}
if (sectionIdx < session.sections.length - 1) {
setSectionGate({ open: true, sec: sectionIdx + 1, remaining: 5 });
return;
}
setReviewOpen(true);
};
const goPrev = () => {
if (questionIdx > 0) {
setQuestionIdx((i) => i - 1);
return;
}
if (sectionIdx > 0) {
const prevSec = session!.sections[sectionIdx - 1];
setSectionIdx((s) => s - 1);
setQuestionIdx(prevSec.questions.length - 1);
}
};
useEffect(() => {
if (!sectionGate?.open) return;
if (sectionGate.remaining <= 0) {
setSectionIdx(sectionGate.sec);
setQuestionIdx(0);
setSectionGate(null);
return;
}
const t = window.setTimeout(
() => setSectionGate((g) => (g ? { ...g, remaining: g.remaining - 1 } : null)),
1000,
);
return () => window.clearTimeout(t);
}, [sectionGate]);
const summary = useMemo(() => {
let answered = 0;
let unanswered = 0;
let flagged = 0;
session?.sections.forEach((sec) => {
sec.questions.forEach((q) => {
const a = answers.get(q.id);
if (!a) {
unanswered += 1;
return;
}
if (a.flagged) flagged += 1;
const empty =
a.answer === null ||
a.answer === "" ||
(Array.isArray(a.answer) && a.answer.length === 0);
if (empty) unanswered += 1;
else answered += 1;
});
});
return { answered, unanswered, flagged, total: totalQuestions };
}, [session, answers, totalQuestions]);
const renderQuestion = (q: ExamQuestion) => {
const a = answers.get(q.id);
if (!a) return null;
const nt = normalizeType(q.type);
if (nt.includes("listen") || q.audio_url) {
return (
<div className="space-y-4">
<div className="flex items-center gap-3 rounded-lg border bg-muted/40 p-4">
<Button type="button" variant="outline" size="icon" onClick={() => setPlaying((p) => !p)}>
{playing ? <Pause className="h-5 w-5" /> : <Play className="h-5 w-5" />}
</Button>
<div className="h-2 flex-1 rounded-full bg-muted">
<div className="h-2 w-1/3 rounded-full bg-primary" />
</div>
<span className="text-sm text-muted-foreground">0:00 / 3:42</span>
</div>
{q.options?.length ? (
<RadioGroup
value={typeof a.answer === "string" ? a.answer : ""}
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
className="space-y-2"
>
{q.options.map((o) => (
<div key={o.label} className="flex items-center space-x-2">
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
</div>
))}
</RadioGroup>
) : null}
</div>
);
}
if (nt.includes("multi") || nt === "mcq_multi") {
const selected = Array.isArray(a.answer) ? a.answer : [];
return (
<div className="space-y-3">
{q.options?.map((o) => (
<div key={o.label} className="flex items-center space-x-2">
<Checkbox
id={`${q.id}-${o.label}`}
checked={selected.includes(o.label)}
onCheckedChange={(ck) => {
const on = ck === true;
const next = on ? [...selected, o.label] : selected.filter((x) => x !== o.label);
updateAnswer(q.id, { answer: next });
}}
/>
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
</div>
))}
</div>
);
}
if (nt.includes("gap")) {
return (
<Input
value={typeof a.answer === "string" ? a.answer : ""}
onChange={(e) => updateAnswer(q.id, { answer: e.target.value })}
placeholder="Your answer"
className="max-w-md"
/>
);
}
if (nt.includes("true") || nt.includes("tfng") || nt === "yes_no_not_given") {
const opts = q.options?.length
? q.options
: [
{ label: "T", text: "True" },
{ label: "F", text: "False" },
{ label: "NG", text: "Not Given" },
];
return (
<RadioGroup
value={typeof a.answer === "string" ? a.answer : ""}
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
className="space-y-2"
>
{opts.map((o) => (
<div key={o.label} className="flex items-center space-x-2">
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
</div>
))}
</RadioGroup>
);
}
if (nt.includes("writing") || nt.includes("essay")) {
const text = typeof a.answer === "string" ? a.answer : "";
const wc = countWords(text);
const min = q.min_words ?? 150;
return (
<div className="space-y-2">
<Textarea
value={text}
onChange={(e) => updateAnswer(q.id, { answer: e.target.value })}
className="min-h-[200px]"
/>
<p className={cn("text-sm", wc < min ? "text-amber-600" : "text-muted-foreground")}>
{wc} words · minimum {min}
</p>
</div>
);
}
if (nt.includes("speak") || nt.includes("record") || nt.includes("audio")) {
return (
<div className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
Recording interface will appear here.
</div>
);
}
return (
<RadioGroup
value={typeof a.answer === "string" ? a.answer : ""}
onValueChange={(v) => updateAnswer(q.id, { answer: v })}
className="space-y-2"
>
{q.options?.map((o) => (
<div key={o.label} className="flex items-center space-x-2">
<RadioGroupItem value={o.label} id={`${q.id}-${o.label}`} />
<Label htmlFor={`${q.id}-${o.label}`}>{o.text}</Label>
</div>
))}
</RadioGroup>
);
};
if (isLoading) {
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-background">
<div className="h-10 w-10 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
);
}
if (isError || !session) {
return (
<div className="fixed inset-0 z-[100] flex flex-col items-center justify-center gap-4 bg-background p-6">
<p className="text-destructive text-lg font-medium">Unable to load this exam session.</p>
<p className="text-muted-foreground text-sm">The exam may not exist or the server is unreachable.</p>
<Button variant="outline" onClick={() => navigate(-1)}>Go Back</Button>
</div>
);
}
const remainOverall = Math.max(0, totalSecRef.current - tick);
const mm = Math.floor(remainOverall / 60);
const ss = remainOverall % 60;
return (
<div className="fixed inset-0 z-[100] flex flex-col bg-background">
<header className="flex flex-wrap items-center justify-between gap-4 border-b px-6 py-3">
<div>
<h1 className="text-lg font-semibold leading-tight">{session.title}</h1>
<p className="text-sm text-muted-foreground">{section?.title}</p>
</div>
<div className="flex flex-wrap items-center gap-6">
<span className="font-mono text-lg tabular-nums">
{String(mm).padStart(2, "0")}:{String(ss).padStart(2, "0")}
</span>
<div className="w-48 space-y-1">
<Progress value={progressPct} className="h-2" />
<p className="text-xs text-muted-foreground">Overall progress</p>
</div>
</div>
</header>
<div className="flex flex-1 min-h-0 flex-col md:flex-row">
<ScrollArea className="flex-1 p-6">
{question ? (
<Card className="border-none shadow-none">
<CardContent className="space-y-6 p-0">
{question.passage_text ? (
<div className="rounded-lg bg-muted/50 p-4 text-sm leading-relaxed">{question.passage_text}</div>
) : null}
<div className="prose prose-sm dark:prose-invert max-w-none">
<p className="font-medium">{question.stem}</p>
</div>
{renderQuestion(question)}
</CardContent>
</Card>
) : null}
</ScrollArea>
</div>
<footer className="flex flex-wrap items-center justify-between gap-3 border-t px-6 py-4">
<div className="flex gap-2">
<Button type="button" variant="outline" onClick={goPrev} disabled={sectionIdx === 0 && questionIdx === 0}>
<ChevronLeft className="mr-1 h-4 w-4" />
Previous
</Button>
<Button type="button" variant="outline" onClick={handleFlag}>
<Flag className="mr-1 h-4 w-4" />
Flag for Review
</Button>
</div>
<div className="flex gap-2">
<Button type="button" onClick={goNext}>
Next
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
</footer>
<Dialog open={!!sectionGate?.open} onOpenChange={() => setSectionGate(null)}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Next section</DialogTitle>
<DialogDescription>
The next section begins in{" "}
<span className="font-mono font-semibold">{sectionGate?.remaining ?? 0}</span>s.
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
<Dialog open={reviewOpen} onOpenChange={setReviewOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Review and submit</DialogTitle>
<DialogDescription>
{summary.answered} answered · {summary.unanswered} unanswered · {summary.flagged} flagged ·{" "}
{summary.total} total
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2 sm:justify-end">
<Button type="button" variant="outline" onClick={() => setReviewOpen(false)}>
Back
</Button>
<Button
type="button"
onClick={() => {
submitMut.mutate(examId, {
onSuccess: () => {
setReviewOpen(false);
navigate(`/student/exam/${examId}/status`);
},
});
}}
disabled={submitMut.isPending}
>
Submit exam
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { useExamStatus } from "@/hooks/queries/useExamSession";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
export default function ExamStatus() {
const { examId: examIdParam } = useParams();
const examId = Number(examIdParam);
const navigate = useNavigate();
const { data, isLoading, isFetching } = useExamStatus(examId, true);
useEffect(() => {
if (!data) return;
const s = (data.status ?? "").toLowerCase();
if (data.scores_available || s.includes("complete") || s.includes("released")) {
navigate(`/student/exam/${examId}/results`, { replace: true });
}
}, [data, examId, navigate]);
if (isLoading && !data) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 p-6">
<div className="h-12 w-12 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<p className="text-muted-foreground">Loading status</p>
</div>
);
}
const status = (data?.status ?? "").toLowerCase();
let view: "scoring" | "partial" | "manual" | "generic" = "generic";
if (status.includes("scoring") || status === "processing") view = "scoring";
else if (status.includes("manual") || status.includes("approval") || status.includes("under_review"))
view = "manual";
else if (
status.includes("partial") ||
status.includes("pending_ws") ||
status.includes("lr_scored") ||
(status.includes("listening") && status.includes("reading") && status.includes("pending"))
)
view = "partial";
return (
<div className="mx-auto flex min-h-screen max-w-lg flex-col justify-center gap-6 p-6">
{view === "scoring" ? (
<Card>
<CardHeader className="text-center">
<div className="mx-auto mb-4 h-12 w-12 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<CardTitle>Scores are being calculated</CardTitle>
<CardDescription>This page refreshes every few seconds.</CardDescription>
</CardHeader>
<CardContent className="text-center text-xs text-muted-foreground">
{isFetching ? "Checking…" : "Waiting for next update…"}
</CardContent>
</Card>
) : null}
{view === "partial" ? (
<Card>
<CardHeader>
<CardTitle>Partial results</CardTitle>
<CardDescription>Listening and Reading are scored; Writing and Speaking are still pending review.</CardDescription>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span>Listening</span>
<Badge variant="secondary">Band 7.5</Badge>
</div>
<div className="flex justify-between">
<span>Reading</span>
<Badge variant="secondary">Band 8.0</Badge>
</div>
<div className="flex justify-between">
<span>Writing</span>
<Badge>Pending Review</Badge>
</div>
<div className="flex justify-between">
<span>Speaking</span>
<Badge>Pending Review</Badge>
</div>
</CardContent>
</Card>
) : null}
{view === "manual" ? (
<Card>
<CardHeader>
<CardTitle>Results under review</CardTitle>
<CardDescription>Your institution will release Writing and Speaking results after approval.</CardDescription>
</CardHeader>
<CardContent>
<Button type="button" variant="outline" onClick={() => navigate("/student/dashboard")}>
Back to dashboard
</Button>
</CardContent>
</Card>
) : null}
{view === "generic" ? (
<Card>
<CardHeader>
<CardTitle>Status</CardTitle>
<CardDescription>{data?.status ?? "Unknown"}</CardDescription>
</CardHeader>
</Card>
) : null}
</div>
);
}

View File

@@ -0,0 +1,210 @@
import { useMemo } from "react";
import { useSearchParams } from "react-router-dom";
import { useAuth } from "@/contexts/AuthContext";
import { useGapAnalysis, useAutoGenerateCourse } from "@/hooks/queries/useCourseGeneration";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion";
import { Skeleton } from "@/components/ui/skeleton";
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
export default function GapAnalysis() {
const [searchParams] = useSearchParams();
const { user } = useAuth();
const fromParam = searchParams.get("from");
const source = fromParam === "placement" ? "placement" : "exam";
const examId = Number(searchParams.get("examId"));
const sessionId = Number(searchParams.get("sessionId"));
const sourceId = source === "exam" ? examId : sessionId;
const entityStudent = searchParams.get("entity") === "1";
const enabled = source === "exam" ? Number.isFinite(examId) && examId > 0 : Number.isFinite(sessionId) && sessionId > 0;
const { data, isLoading, isError } = useGapAnalysis(source, sourceId);
const generate = useAutoGenerateCourse();
const chartData = useMemo(() => {
if (!data?.skill_gaps?.length) return [];
return [...data.skill_gaps]
.sort((a, b) => b.gap - a.gap)
.map((g) => ({ skill: g.skill, gap: g.gap }));
}, [data]);
const canGenerate = user?.user_type === "student" && !entityStudent;
return (
<div className="mx-auto max-w-5xl space-y-8 p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold">Skill gap analysis</h1>
<p className="text-muted-foreground">
{source === "exam" ? `From exam ${examId || "—"}` : `Placement session ${sessionId || "—"}`}
</p>
</div>
{canGenerate ? (
<Button
type="button"
onClick={() => generate.mutate(sourceId)}
disabled={!enabled || generate.isPending}
>
Generate Course
</Button>
) : null}
</div>
{entityStudent ? (
<Alert>
<AlertTitle>Entity-managed students</AlertTitle>
<AlertDescription>
Gap analysis is view-only for your organization. Contact your coordinator to assign a course.
</AlertDescription>
</Alert>
) : null}
{!enabled ? (
<Alert variant="destructive">
<AlertTitle>Missing source</AlertTitle>
<AlertDescription>
Add <code className="rounded bg-muted px-1">?from=exam&amp;examId=</code> or{" "}
<code className="rounded bg-muted px-1">?from=placement&amp;sessionId=</code> to the URL.
</AlertDescription>
</Alert>
) : null}
{isLoading ? (
<Skeleton className="h-96 w-full" />
) : isError || !data ? (
<p className="text-destructive">Could not load gap analysis.</p>
) : (
<>
<Card>
<CardHeader>
<CardTitle>Skills ranked by gap</CardTitle>
<CardDescription>Larger bars need more attention</CardDescription>
</CardHeader>
<CardContent>
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData} layout="vertical" margin={{ left: 16 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis type="number" domain={[0, "dataMax"]} />
<YAxis type="category" dataKey="skill" width={100} tickLine={false} axisLine={false} />
<Tooltip />
<Bar dataKey="gap" fill="hsl(var(--primary))" radius={[0, 4, 4, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Gap table</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Skill</TableHead>
<TableHead>Current</TableHead>
<TableHead>Target</TableHead>
<TableHead>Gap</TableHead>
<TableHead>Priority</TableHead>
<TableHead>Est. Hours</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{data.skill_gaps.map((g) => (
<TableRow key={g.skill}>
<TableCell className="font-medium">{g.skill}</TableCell>
<TableCell>{g.current_band ?? g.current_level}</TableCell>
<TableCell>{g.target_band ?? g.target_level}</TableCell>
<TableCell>{g.gap}</TableCell>
<TableCell className="capitalize">{g.priority}</TableCell>
<TableCell>{g.estimated_hours}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<div>
<h2 className="mb-3 text-lg font-semibold">Question-type weaknesses</h2>
<Accordion type="multiple" className="w-full">
{Object.entries(data.question_type_weaknesses).map(([skill, rows]) => (
<AccordionItem key={skill} value={skill}>
<AccordionTrigger>{skill}</AccordionTrigger>
<AccordionContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Type</TableHead>
<TableHead>Accuracy</TableHead>
<TableHead>Correct</TableHead>
<TableHead>Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow key={r.question_type}>
<TableCell>{r.question_type}</TableCell>
<TableCell>{Math.round(r.accuracy * 100)}%</TableCell>
<TableCell>{r.correct}</TableCell>
<TableCell>{r.total}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
<div>
<h2 className="mb-3 text-lg font-semibold">Topic weaknesses</h2>
<div className="space-y-4">
{Object.entries(data.topic_weaknesses).map(([cat, topics]) => (
<Card key={cat}>
<CardHeader className="py-3">
<CardTitle className="text-base">{cat}</CardTitle>
</CardHeader>
<CardContent className="pt-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Topic</TableHead>
<TableHead>Errors</TableHead>
<TableHead>Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{topics.map((t) => (
<TableRow key={t.topic}>
<TableCell>{t.topic}</TableCell>
<TableCell>{t.errors}</TableCell>
<TableCell>{t.total}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
))}
</div>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,122 @@
import type { ReactNode } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { Loader2, Lock, CheckCircle2, PlayCircle, Circle, ArrowRight, Sparkles } from "lucide-react";
import { useLearningPlan, useGenerateLearningPlan } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { PlanItemStatus } from "@/types";
const statusIcons: Record<PlanItemStatus, ReactNode> = {
locked: <Lock className="h-4 w-4 text-muted-foreground" />,
available: <Circle className="h-4 w-4 text-blue-500" />,
in_progress: <PlayCircle className="h-4 w-4 text-yellow-500" />,
completed: <CheckCircle2 className="h-4 w-4 text-green-500" />,
};
const statusColors: Record<PlanItemStatus, string> = {
locked: "bg-muted text-muted-foreground",
available: "bg-blue-50 text-blue-700 border-blue-200",
in_progress: "bg-yellow-50 text-yellow-700 border-yellow-200",
completed: "bg-green-50 text-green-700 border-green-200",
};
export default function LearningPlanPage() {
const { subjectId } = useParams<{ subjectId: string }>();
const navigate = useNavigate();
const { toast } = useToast();
const { data: plan, isLoading } = useLearningPlan({ subject_id: Number(subjectId) });
const generateMutation = useGenerateLearningPlan();
const handleGenerate = () => {
generateMutation.mutate(
{ subject_id: Number(subjectId) },
{
onSuccess: () => toast({ title: "Plan Generated", description: "Your personalized learning plan is ready." }),
onError: () => toast({ title: "Error", description: "Failed to generate plan", variant: "destructive" }),
}
);
};
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
if (!plan) {
return (
<div className="max-w-lg mx-auto text-center py-16 space-y-6">
<Sparkles className="h-16 w-16 text-primary mx-auto opacity-60" />
<h1 className="text-2xl font-bold">No Learning Plan Yet</h1>
<p className="text-muted-foreground">Complete a diagnostic assessment first, then generate your personalized learning plan.</p>
<div className="flex gap-3 justify-center">
<Button variant="outline" onClick={() => navigate(`/student/diagnostic/${subjectId}`)}>Take Diagnostic</Button>
<Button onClick={handleGenerate} disabled={generateMutation.isPending}>
{generateMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Generate Plan
</Button>
</div>
</div>
);
}
const items = plan.items.sort((a, b) => a.sequence - b.sequence);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Learning Plan: {plan.subject_name}</h1>
<p className="text-muted-foreground">{plan.ai_summary}</p>
</div>
<Badge variant={plan.status === "active" ? "default" : "secondary"}>{plan.status}</Badge>
</div>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-muted-foreground">Overall Progress</span>
<span className="text-sm font-medium">{Math.round(plan.overall_progress)}%</span>
</div>
<Progress value={plan.overall_progress} className="h-3" />
</CardContent>
</Card>
<div className="space-y-3">
{items.map((item, index) => (
<Card key={item.id} className={`border ${statusColors[item.status]}`}>
<CardContent className="py-4">
<div className="flex items-center gap-4">
<div className="flex flex-col items-center gap-1">
<div className="text-xs text-muted-foreground font-mono">{index + 1}</div>
{statusIcons[item.status]}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium">{item.topic_name}</span>
<Badge variant="outline" className="text-xs">{item.domain_name}</Badge>
</div>
<div className="flex items-center gap-4 mt-1 text-xs text-muted-foreground">
<span>~{item.estimated_hours}h estimated</span>
{item.actual_hours > 0 && <span>{item.actual_hours}h spent</span>}
<span>Mastery: {Math.round(item.mastery)}%</span>
</div>
{item.status !== "locked" && (
<Progress value={item.mastery} className="h-1 mt-2" />
)}
</div>
{(item.status === "available" || item.status === "in_progress") && (
<Button size="sm" onClick={() => navigate(`/student/topic/${item.topic_id}`)}>
{item.status === "in_progress" ? "Continue" : "Start"} <ArrowRight className="ml-1 h-3 w-3" />
</Button>
)}
{item.status === "completed" && (
<Button variant="ghost" size="sm" onClick={() => navigate(`/student/topic/${item.topic_id}`)}>Review</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,135 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Check, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { placementService } from "@/services/placement.service";
import { ApiError } from "@/lib/api-client";
const fullFeatures = [
"Unlimited adaptive lessons",
"Speaking feedback & analytics",
"Full placement history",
"Priority instructor Q&A",
];
const trialFeatures = ["7 days full access", "Placement results saved", "Email reminders before trial ends"];
const skipFeatures = ["Dashboard access", "Limited preview content", "Upgrade any time"];
export default function PlacementAccess() {
const navigate = useNavigate();
const [trialLoading, setTrialLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const startTrial = async () => {
setError(null);
setTrialLoading(true);
try {
await placementService.startTrial();
navigate("/student/dashboard");
} catch (e) {
setError(e instanceof ApiError ? e.message : "Could not start trial");
} finally {
setTrialLoading(false);
}
};
return (
<div className="max-w-6xl mx-auto py-12 px-4">
<div className="text-center mb-10 space-y-2">
<h1 className="text-3xl font-bold tracking-tight">Choose how you want to continue</h1>
<p className="text-muted-foreground max-w-2xl mx-auto">
Unlock the full adaptive pathway or explore on your own terms.
</p>
</div>
{error && (
<p className="text-center text-sm text-destructive mb-6 bg-destructive/10 rounded-lg py-2 px-4 max-w-xl mx-auto">
{error}
</p>
)}
<div className="grid gap-6 md:grid-cols-3 items-stretch">
<Card className="border-2 border-primary shadow-lg flex flex-col">
<CardHeader>
<CardTitle className="text-xl">Full access</CardTitle>
<CardDescription>Best value for serious exam prep</CardDescription>
</CardHeader>
<CardContent className="flex-1 space-y-4">
<ul className="space-y-2">
{fullFeatures.map((f) => (
<li key={f} className="flex gap-2 text-sm">
<Check className="h-4 w-4 shrink-0 text-primary mt-0.5" />
<span>{f}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter>
<Button className="w-full" size="lg" onClick={() => navigate("/student/subscription")}>
Subscribe now
</Button>
</CardFooter>
</Card>
<Card className="border-0 shadow-md bg-muted/40 flex flex-col">
<CardHeader>
<CardTitle className="text-xl">Free trial</CardTitle>
<CardDescription>Try everything for seven days</CardDescription>
</CardHeader>
<CardContent className="flex-1 space-y-4">
<ul className="space-y-2">
{trialFeatures.map((f) => (
<li key={f} className="flex gap-2 text-sm">
<Check className="h-4 w-4 shrink-0 text-primary mt-0.5" />
<span>{f}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter>
<Button
className="w-full"
size="lg"
variant="secondary"
disabled={trialLoading}
onClick={() => void startTrial()}
>
{trialLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Starting
</>
) : (
"Try free for 7 days"
)}
</Button>
</CardFooter>
</Card>
<Card className="border border-dashed flex flex-col">
<CardHeader>
<CardTitle className="text-xl">Skip for now</CardTitle>
<CardDescription>Head straight to your dashboard</CardDescription>
</CardHeader>
<CardContent className="flex-1 space-y-4">
<ul className="space-y-2">
{skipFeatures.map((f) => (
<li key={f} className="flex gap-2 text-sm text-muted-foreground">
<Check className="h-4 w-4 shrink-0 mt-0.5" />
<span>{f}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter>
<Button className="w-full" size="lg" variant="outline" onClick={() => navigate("/student/dashboard")}>
Continue to dashboard
</Button>
</CardFooter>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,132 @@
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Badge } from "@/components/ui/badge";
import { Loader2, Clock, Layers, Sparkles } from "lucide-react";
import { usePlacementStart } from "@/hooks/queries/usePlacement";
import type { PlacementSubject } from "@/types";
import { ApiError } from "@/lib/api-client";
import { useState } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
const dimensions = [
{ name: "Grammar", detail: "~30 MCQ", time: "~8 min" },
{ name: "Vocabulary", detail: "~20 items", time: "~5 min" },
{ name: "Reading", detail: "~10 Q / 2 passages", time: "~10 min" },
{ name: "Speaking", detail: "3 prompts", time: "~5 min" },
];
export default function PlacementBriefing() {
const navigate = useNavigate();
const start = usePlacementStart();
const [error, setError] = useState<string | null>(null);
const handleBegin = async () => {
setError(null);
const subject: PlacementSubject = "english";
try {
const res = await start.mutateAsync(subject);
sessionStorage.setItem(
`placement_session_${res.session_id}`,
JSON.stringify({ question: res.first_question }),
);
navigate(`/student/placement/test?session=${encodeURIComponent(res.session_id)}`, {
state: { question: res.first_question, sessionId: res.session_id },
});
} catch (e) {
setError(e instanceof ApiError ? e.message : "Could not start the test");
}
};
return (
<div className="space-y-8 max-w-4xl mx-auto py-6 px-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Placement test</h1>
<p className="text-muted-foreground mt-1">Adaptive English placement across four skills.</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
<Card className="border-0 shadow-md bg-gradient-to-br from-primary/10 to-transparent">
<CardHeader className="pb-2">
<Clock className="h-5 w-5 text-primary mb-1" />
<CardTitle className="text-base">Duration</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold tabular-nums">2030 min</p>
<p className="text-xs text-muted-foreground mt-1">Pace yourself; timer tracks elapsed time.</p>
</CardContent>
</Card>
<Card className="border-0 shadow-md">
<CardHeader className="pb-2">
<Layers className="h-5 w-5 text-primary mb-1" />
<CardTitle className="text-base">Sections</CardTitle>
</CardHeader>
<CardContent>
<p className="text-2xl font-semibold">4</p>
<p className="text-xs text-muted-foreground mt-1">Grammar, vocabulary, reading, speaking.</p>
</CardContent>
</Card>
<Card className="border-0 shadow-md">
<CardHeader className="pb-2">
<Sparkles className="h-5 w-5 text-primary mb-1" />
<CardTitle className="text-base">Adaptive</CardTitle>
</CardHeader>
<CardContent>
<Badge variant="secondary" className="mb-1">
CAT-style
</Badge>
<p className="text-xs text-muted-foreground">Difficulty adjusts based on your responses.</p>
</CardContent>
</Card>
</div>
<Card className="border-0 shadow-lg">
<CardHeader>
<CardTitle>What to expect</CardTitle>
<CardDescription>Approximate structure per dimension</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Dimension</TableHead>
<TableHead>Format</TableHead>
<TableHead className="text-right">Time</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{dimensions.map((row) => (
<TableRow key={row.name}>
<TableCell className="font-medium">{row.name}</TableCell>
<TableCell className="text-muted-foreground">{row.detail}</TableCell>
<TableCell className="text-right tabular-nums">{row.time}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{error && (
<Alert variant="destructive">
<AlertTitle>Error</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<div className="flex justify-end">
<Button size="lg" className="min-w-[160px]" onClick={() => void handleBegin()} disabled={start.isPending}>
{start.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Starting
</>
) : (
"Begin Test"
)}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,281 @@
import { useMemo } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import {
Radar,
RadarChart,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
ResponsiveContainer,
} from "recharts";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Skeleton } from "@/components/ui/skeleton";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
import { ChevronDown, Loader2 } from "lucide-react";
import { usePlacementResults, useLearningPath, useSpeakingStatus } from "@/hooks/queries/usePlacement";
import type { CEFRLevel } from "@/types";
import { cn } from "@/lib/utils";
function cefrBadgeClass(level: CEFRLevel): string {
switch (level) {
case "c2":
case "c1":
return "bg-violet-600 hover:bg-violet-600 text-white border-0";
case "b2":
case "b1":
return "bg-emerald-600 hover:bg-emerald-600 text-white border-0";
case "a2":
case "a1":
case "pre_a1":
return "bg-amber-600 hover:bg-amber-600 text-white border-0";
default:
return "";
}
}
const CEFR_LABEL: Record<CEFRLevel, string> = {
pre_a1: "Pre-A1",
a1: "A1",
a2: "A2",
b1: "B1",
b2: "B2",
c1: "C1",
c2: "C2",
};
function formatCefr(level: CEFRLevel): string {
return CEFR_LABEL[level] ?? level;
}
export default function PlacementResults() {
const [searchParams] = useSearchParams();
const sessionId = searchParams.get("session") || "";
const navigate = useNavigate();
const results = usePlacementResults(sessionId);
const learningPath = useLearningPath(sessionId);
const speakingPending = results.data?.speaking_status === "pending";
const speakingPoll = useSpeakingStatus(sessionId, speakingPending);
const radarData = useMemo(() => {
if (!results.data?.dimensions.length) return [];
return results.data.dimensions.map((d) => ({
dimension: d.dimension,
score: d.score,
fullMark: 100,
}));
}, [results.data]);
if (!sessionId) {
return (
<div className="max-w-lg mx-auto py-16 px-4 text-center">
<p className="text-muted-foreground mb-4">Missing session.</p>
<Button variant="outline" onClick={() => navigate("/student/placement")}>
Back to placement
</Button>
</div>
);
}
return (
<div className="max-w-5xl mx-auto py-8 px-4 space-y-10">
<div>
<h1 className="text-3xl font-bold tracking-tight">Your placement results</h1>
<p className="text-muted-foreground mt-2">Personalized snapshot before your course begins.</p>
</div>
{results.isLoading && (
<div className="space-y-6">
<Skeleton className="h-40 w-full rounded-xl" />
<Skeleton className="h-64 w-full rounded-xl" />
</div>
)}
{results.error && (
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="pt-6">
<p className="text-destructive text-sm">{results.error.message}</p>
<Button className="mt-4" variant="outline" onClick={() => results.refetch()}>
Retry
</Button>
</CardContent>
</Card>
)}
{results.data && (
<>
<Card className="border-0 shadow-lg overflow-hidden">
<div className="bg-gradient-to-r from-primary/15 via-primary/5 to-transparent p-8 flex flex-col md:flex-row md:items-center md:justify-between gap-6">
<div>
<p className="text-sm font-medium text-muted-foreground uppercase tracking-wide">Overall level</p>
<p className="text-4xl md:text-5xl font-bold mt-2 tabular-nums">
{formatCefr(results.data.overall_cefr)}
</p>
</div>
<Badge className={cn("text-lg px-4 py-2", cefrBadgeClass(results.data.overall_cefr))}>
CEFR {formatCefr(results.data.overall_cefr)}
</Badge>
</div>
</Card>
<div className="grid gap-8 lg:grid-cols-2">
<Card className="border-0 shadow-md">
<CardHeader>
<CardTitle>Skill profile</CardTitle>
<CardDescription>Relative strength across dimensions</CardDescription>
</CardHeader>
<CardContent className="h-[320px]">
{radarData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<RadarChart data={radarData}>
<PolarGrid />
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 11 }} />
<PolarRadiusAxis angle={90} domain={[0, 100]} tick={false} />
<Radar
name="Score"
dataKey="score"
stroke="hsl(var(--primary))"
fill="hsl(var(--primary))"
fillOpacity={0.35}
/>
</RadarChart>
</ResponsiveContainer>
) : (
<p className="text-sm text-muted-foreground">No dimension data.</p>
)}
</CardContent>
</Card>
<Card className="border-0 shadow-md">
<CardHeader>
<CardTitle>Detailed scores</CardTitle>
<CardDescription>Gap to target highlights where to focus first</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Dimension</TableHead>
<TableHead className="text-right">Score</TableHead>
<TableHead>CEFR</TableHead>
<TableHead>Gap</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{results.data.dimensions.map((row) => (
<TableRow key={row.dimension}>
<TableCell className="font-medium">{row.dimension}</TableCell>
<TableCell className="text-right tabular-nums">{row.score.toFixed(1)}</TableCell>
<TableCell>
{row.cefr_level ? (
<Badge variant="outline">{formatCefr(row.cefr_level)}</Badge>
) : (
"—"
)}
</TableCell>
<TableCell className="text-muted-foreground text-sm">{row.gap_to_target}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="mt-4 flex items-center gap-2 text-sm">
<span className="text-muted-foreground">Speaking:</span>
{(speakingPoll.data?.status === "scored" || results.data.speaking_status === "scored") && (
<Badge variant="secondary">Scored</Badge>
)}
{speakingPending && speakingPoll.data?.status !== "scored" && (
<Badge variant="outline" className="gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
Pending
</Badge>
)}
</div>
</CardContent>
</Card>
</div>
<Card className="border-0 shadow-lg">
<CardHeader>
<CardTitle>Learning path preview</CardTitle>
<CardDescription>Aligned modules based on your gaps</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{learningPath.isLoading && (
<div className="space-y-2">
<Skeleton className="h-6 w-2/3" />
<Skeleton className="h-20 w-full" />
</div>
)}
{learningPath.error && (
<p className="text-sm text-destructive">{learningPath.error.message}</p>
)}
{learningPath.data && (
<>
<div className="rounded-xl border bg-muted/30 p-6 space-y-2">
<h3 className="text-lg font-semibold">{learningPath.data.course_title}</h3>
<p className="text-sm text-muted-foreground">
~{learningPath.data.estimated_duration_weeks} weeks · {learningPath.data.module_count} modules ·{" "}
{learningPath.data.study_hours_per_week} h/week suggested
</p>
</div>
<div>
<h4 className="font-medium mb-3">Skill gap modules</h4>
<ul className="space-y-3">
{[...learningPath.data.modules]
.sort((a, b) => {
const pr = { high: 0, medium: 1, low: 2 };
return pr[a.priority] - pr[b.priority];
})
.map((m) => (
<li
key={m.name}
className="flex flex-wrap items-baseline justify-between gap-2 rounded-lg border p-3"
>
<span className="font-medium">{m.name}</span>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Badge
variant={
m.priority === "high" ? "destructive" : m.priority === "medium" ? "default" : "secondary"
}
>
{m.priority}
</Badge>
<span>{m.estimated_hours}h</span>
<span className="capitalize">{m.difficulty}</span>
</div>
</li>
))}
</ul>
</div>
<Collapsible>
<CollapsibleTrigger className="flex items-center gap-2 text-sm font-medium text-primary hover:underline">
<ChevronDown className="h-4 w-4" />
View full course plan
</CollapsibleTrigger>
<CollapsibleContent className="mt-4 rounded-lg border bg-card p-4 text-sm text-muted-foreground leading-relaxed">
{learningPath.data.modules.map((m) => (
<p key={`${m.name}-detail`} className="mb-2 last:mb-0">
<span className="font-medium text-foreground">{m.name}</span> {m.resource_types.join(", ")}
</p>
))}
</CollapsibleContent>
</Collapsible>
</>
)}
</CardContent>
</Card>
<div className="flex justify-end">
<Button size="lg" onClick={() => navigate("/student/placement/access")}>
Continue
</Button>
</div>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,364 @@
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>
);
}

View File

@@ -0,0 +1,117 @@
import { useParams, useNavigate } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { ArrowRight, Target, TrendingUp } from "lucide-react";
import { useProficiency } from "@/hooks/queries";
function getMasteryColor(mastery: number): string {
if (mastery >= 80) return "text-green-600";
if (mastery >= 60) return "text-blue-600";
if (mastery >= 40) return "text-yellow-600";
if (mastery >= 20) return "text-orange-600";
return "text-red-600";
}
function getMasteryBg(mastery: number): string {
if (mastery >= 80) return "bg-green-100";
if (mastery >= 60) return "bg-blue-100";
if (mastery >= 40) return "bg-yellow-100";
if (mastery >= 20) return "bg-orange-100";
return "bg-red-100";
}
export default function ProficiencyProfile() {
const { subjectId } = useParams<{ subjectId: string }>();
const navigate = useNavigate();
const { data: proficiencies = [], isLoading } = useProficiency({ subject_id: Number(subjectId) });
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
const domains = [...new Set(proficiencies.map(p => p.domain_name))];
const overallMastery = proficiencies.length > 0 ? proficiencies.reduce((sum, p) => sum + p.mastery, 0) / proficiencies.length : 0;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Proficiency Profile</h1>
<p className="text-muted-foreground">Your knowledge map across all topics.</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => navigate(`/student/diagnostic/${subjectId}`)}>Retake Diagnostic</Button>
<Button onClick={() => navigate(`/student/plan/${subjectId}`)}>Learning Plan <ArrowRight className="ml-1 h-4 w-4" /></Button>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="pt-6 text-center">
<Target className="h-8 w-8 mx-auto text-primary mb-2" />
<div className="text-3xl font-bold">{Math.round(overallMastery)}%</div>
<p className="text-sm text-muted-foreground">Overall Mastery</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<TrendingUp className="h-8 w-8 mx-auto text-green-500 mb-2" />
<div className="text-3xl font-bold">{proficiencies.filter(p => p.mastery >= 80).length}</div>
<p className="text-sm text-muted-foreground">Topics Mastered</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6 text-center">
<div className="h-8 w-8 mx-auto text-orange-500 mb-2 text-2xl font-bold">{proficiencies.filter(p => p.mastery < 40).length}</div>
<p className="text-sm text-muted-foreground">Topics Needing Work</p>
</CardContent>
</Card>
</div>
{domains.map((domain) => {
const topics = proficiencies.filter(p => p.domain_name === domain);
const domainMastery = topics.reduce((s, t) => s + t.mastery, 0) / topics.length;
return (
<Card key={domain}>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{domain}</CardTitle>
<Badge variant="outline" className={getMasteryColor(domainMastery)}>{Math.round(domainMastery)}%</Badge>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{topics.map((topic) => (
<div
key={topic.topic_id}
className={`p-3 rounded-lg border ${getMasteryBg(topic.mastery)} cursor-pointer hover:shadow-sm transition-shadow`}
onClick={() => navigate(`/student/topic/${topic.topic_id}`)}
>
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium truncate">{topic.topic_name}</span>
<span className={`text-sm font-semibold ${getMasteryColor(topic.mastery)}`}>{Math.round(topic.mastery)}%</span>
</div>
<Progress value={topic.mastery} className="h-1.5" />
<div className="flex items-center justify-between mt-1">
<Badge variant="secondary" className="text-xs">{topic.mastery_level}</Badge>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
})}
{proficiencies.length === 0 && (
<Card>
<CardContent className="text-center py-12 text-muted-foreground">
<p>No proficiency data yet. Take a diagnostic assessment first.</p>
<Button className="mt-4" onClick={() => navigate(`/student/diagnostic/${subjectId}`)}>Start Diagnostic</Button>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,65 @@
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Megaphone, AlertTriangle, AlertCircle, Info } from "lucide-react";
import { useAnnouncements } from "@/hooks/queries";
import type { Announcement } from "@/types/communication";
const priorityConfig: Record<Announcement["priority"], { variant: "destructive" | "default" | "secondary"; icon: React.ReactNode }> = {
urgent: { variant: "destructive", icon: <AlertTriangle className="h-4 w-4" /> },
important: { variant: "default", icon: <AlertCircle className="h-4 w-4" /> },
normal: { variant: "secondary", icon: <Info className="h-4 w-4" /> },
};
export default function StudentAnnouncements() {
const { data: announcements = [], isLoading } = useAnnouncements();
const sorted = [...announcements]
.filter(a => a.is_published)
.sort((a, b) => new Date(b.published_at ?? b.expires_at ?? "").getTime() - new Date(a.published_at ?? a.expires_at ?? "").getTime());
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Announcements</h1>
<p className="text-muted-foreground">Stay up to date with the latest announcements from your courses.</p>
</div>
<div className="space-y-3">
{sorted.map(a => {
const cfg = priorityConfig[a.priority];
return (
<Card key={a.id} className={a.priority === "urgent" ? "border-destructive/30" : ""}>
<CardContent className="flex items-start gap-4 py-4">
<div className={`h-10 w-10 rounded-lg flex items-center justify-center shrink-0 ${a.priority === "urgent" ? "bg-destructive/10 text-destructive" : a.priority === "important" ? "bg-yellow-500/10 text-yellow-600" : "bg-primary/10 text-primary"}`}>
{cfg.icon}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{a.title}</span>
<Badge variant={cfg.variant}>{a.priority}</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">{a.content}</p>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
<span>{a.author_name}</span>
{a.course_name && <><span>·</span><span>{a.course_name}</span></>}
{a.published_at && <><span>·</span><span>{new Date(a.published_at).toLocaleDateString()}</span></>}
</div>
</div>
</CardContent>
</Card>
);
})}
{sorted.length === 0 && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
<Megaphone className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p>No announcements right now.</p>
</CardContent>
</Card>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
import { useState } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { useAssignments } from "@/hooks/queries";
import { Upload } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
import AiWritingHelper from "@/components/ai/AiWritingHelper";
import AiTipBanner from "@/components/ai/AiTipBanner";
export default function StudentAssignments() {
const [submitOpen, setSubmitOpen] = useState(false);
const [selectedAssignment, setSelectedAssignment] = useState<string | null>(null);
const [draftText, setDraftText] = useState("");
const { toast } = useToast();
const { data: assignmentsData, isLoading } = useAssignments();
const assignments = assignmentsData?.items ?? [];
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
const handleSubmit = () => {
setSubmitOpen(false);
toast({ title: "Assignment Submitted", description: "Your work has been submitted successfully." });
};
const statusFilter = (status: string) => assignments.filter(a => status === "all" ? true : a.status === status);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Assignments</h1>
<p className="text-muted-foreground">View and submit your assignments.</p>
</div>
<AiTipBanner context="student-assignments" variant="recommendation" />
<Tabs defaultValue="all">
<TabsList>
<TabsTrigger value="all">All ({assignments.length})</TabsTrigger>
<TabsTrigger value="pending">Pending ({statusFilter("pending").length})</TabsTrigger>
<TabsTrigger value="submitted">Submitted ({statusFilter("submitted").length})</TabsTrigger>
<TabsTrigger value="graded">Graded ({statusFilter("graded").length})</TabsTrigger>
</TabsList>
{["all", "pending", "submitted", "graded", "overdue"].map(tab => (
<TabsContent key={tab} value={tab} className="mt-4 space-y-3">
{statusFilter(tab).map(a => (
<Card key={a.id}>
<CardContent className="pt-4">
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className="font-medium text-sm">{a.title}</p>
<p className="text-xs text-muted-foreground">{a.courseName} · Due: {a.dueDate}</p>
{a.grade !== undefined && <p className="text-xs font-medium text-primary mt-1">Grade: {a.grade}/{a.maxGrade}</p>}
{a.feedback && <p className="text-xs text-muted-foreground mt-0.5">"{a.feedback}"</p>}
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant={a.status === "graded" ? "default" : a.status === "overdue" ? "destructive" : "secondary"}>{a.status}</Badge>
{a.status === "pending" && (
<Button size="sm" onClick={() => { setSelectedAssignment(a.id); setSubmitOpen(true); }}>Submit</Button>
)}
</div>
</div>
</CardContent>
</Card>
))}
</TabsContent>
))}
</Tabs>
<Dialog open={submitOpen} onOpenChange={setSubmitOpen}>
<DialogContent>
<DialogHeader><DialogTitle>Submit Assignment</DialogTitle></DialogHeader>
<div className="space-y-4">
<div className="border-2 border-dashed rounded-lg p-8 text-center text-muted-foreground">
<Upload className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">Drag & drop your file here or click to browse</p>
<p className="text-xs mt-1">PDF, DOC, DOCX up to 10MB</p>
</div>
<Textarea placeholder="Add a comment (optional)..." rows={3} value={draftText} onChange={(e) => setDraftText(e.target.value)} />
<AiWritingHelper text={draftText} task_type="ielts_writing" />
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSubmitOpen(false)}>Cancel</Button>
<Button onClick={handleSubmit}>Submit</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useAttendance } from "@/hooks/queries";
import { PieChart, Pie, Cell, ResponsiveContainer, Legend, Tooltip } from "recharts";
import AiTipBanner from "@/components/ai/AiTipBanner";
const badgeVariant = (s: string) => s === "present" ? "default" as const : s === "absent" ? "destructive" as const : "secondary" as const;
export default function StudentAttendance() {
const { data: attendanceRecords = [], isLoading } = useAttendance();
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
const counts = { present: 0, absent: 0, late: 0, excused: 0 };
attendanceRecords.forEach(a => counts[a.status]++);
const total = attendanceRecords.length;
const rate = total > 0 ? ((counts.present + counts.late) / total * 100).toFixed(1) : "0";
const pieData = [
{ name: "Present", value: counts.present, color: "hsl(142, 71%, 45%)" },
{ name: "Late", value: counts.late, color: "hsl(38, 92%, 50%)" },
{ name: "Absent", value: counts.absent, color: "hsl(0, 72%, 51%)" },
{ name: "Excused", value: counts.excused, color: "hsl(199, 89%, 48%)" },
].filter(d => d.value > 0);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Attendance</h1>
<p className="text-muted-foreground">Your attendance record across all courses.</p>
</div>
<AiTipBanner context="student-attendance" variant="insight" />
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card className="lg:col-span-1">
<CardHeader><CardTitle className="text-lg">Summary</CardTitle></CardHeader>
<CardContent>
<div className="text-center mb-4">
<p className="text-4xl font-bold text-primary">{rate}%</p>
<p className="text-sm text-muted-foreground">Attendance Rate</p>
</div>
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={pieData} dataKey="value" cx="50%" cy="50%" innerRadius={50} outerRadius={80}>
{pieData.map((d, i) => <Cell key={i} fill={d.color} />)}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card className="lg:col-span-2">
<CardHeader><CardTitle className="text-lg">Attendance Records</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader><TableRow><TableHead>Date</TableHead><TableHead>Course</TableHead><TableHead>Status</TableHead></TableRow></TableHeader>
<TableBody>
{attendanceRecords.map(a => (
<TableRow key={a.id}>
<TableCell>{a.date}</TableCell>
<TableCell>{a.courseName}</TableCell>
<TableCell><Badge variant={badgeVariant(a.status)} className="capitalize">{a.status}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,126 @@
import { useParams } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { FileText, Video, Image, Link2, Music, Download, CheckCircle2, Loader2 } from "lucide-react";
import { useChapter, useChapterMaterials, useChapterProgress, useCompleteChapter, useMarkMaterialViewed } from "@/hooks/queries";
import { coursewareService } from "@/services/courseware.service";
import { useToast } from "@/hooks/use-toast";
import type { MaterialType } from "@/types/courseware";
const typeIcons: Record<MaterialType, React.ReactNode> = {
pdf: <FileText className="h-5 w-5" />,
document: <FileText className="h-5 w-5" />,
video: <Video className="h-5 w-5" />,
audio: <Music className="h-5 w-5" />,
image: <Image className="h-5 w-5" />,
link: <Link2 className="h-5 w-5" />,
article: <FileText className="h-5 w-5" />,
};
export default function StudentChapterView() {
const { courseId, chapterId } = useParams<{ courseId: string; chapterId: string }>();
const chId = Number(chapterId);
const { toast } = useToast();
const { data: chapter, isLoading: lc } = useChapter(chId);
const { data: materials = [], isLoading: lm } = useChapterMaterials(chId);
const { data: progress, isLoading: lp } = useChapterProgress(chId);
const completeChapter = useCompleteChapter();
const markViewed = useMarkMaterialViewed();
const completionPct = progress ? Math.round((progress.materials_completed / Math.max(progress.materials_total, 1)) * 100) : 0;
const handleDownload = async (materialId: number, name: string) => {
try {
const blob = await coursewareService.downloadMaterial(materialId);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
a.click();
URL.revokeObjectURL(url);
} catch {
toast({ title: "Error", description: "Download failed", variant: "destructive" });
}
};
const handleMarkComplete = () => {
completeChapter.mutate(chId, {
onSuccess: () => toast({ title: "Chapter Completed!" }),
onError: () => toast({ title: "Error", description: "Failed to mark complete", variant: "destructive" }),
});
};
const handleOpenMaterial = (materialId: number) => {
markViewed.mutate({ id: materialId, chapterId: chId });
};
if (lc || lm || lp) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
{chapter && (
<div>
<h1 className="text-2xl font-bold">{chapter.name}</h1>
{chapter.description && <p className="text-muted-foreground mt-1">{chapter.description}</p>}
</div>
)}
{progress && (
<Card>
<CardContent className="py-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium">Progress</span>
<span className="text-sm text-muted-foreground">{progress.materials_completed} / {progress.materials_total} materials</span>
</div>
<Progress value={completionPct} className="h-2" />
<div className="flex justify-between items-center mt-3">
<span className="text-sm text-muted-foreground">{completionPct}% complete</span>
{progress.status !== "completed" && (
<Button size="sm" onClick={handleMarkComplete} disabled={completeChapter.isPending}>
{completeChapter.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<CheckCircle2 className="mr-2 h-4 w-4" /> Mark Complete
</Button>
)}
{progress.status === "completed" && (
<Badge variant="default"><CheckCircle2 className="mr-1 h-3 w-3" /> Completed</Badge>
)}
</div>
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{materials
.sort((a, b) => a.sequence - b.sequence)
.map(m => (
<Card key={m.id} className="hover:shadow-md transition-shadow cursor-pointer" onClick={() => handleOpenMaterial(m.id)}>
<CardContent className="py-4">
<div className="flex items-start gap-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0 text-primary">
{typeIcons[m.type]}
</div>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{m.name}</p>
<Badge variant="outline" className="mt-1 text-xs">{m.type}</Badge>
</div>
{m.allow_download && (
<Button variant="ghost" size="icon" className="shrink-0" onClick={(e) => { e.stopPropagation(); handleDownload(m.id, m.name); }}>
<Download className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
))}
{materials.length === 0 && (
<div className="col-span-full text-center py-12 text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p>No materials available in this chapter.</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,126 @@
import { useParams, Link } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { useCourses, useAssignments, useAttendance, useGrades } from "@/hooks/queries";
import { ArrowLeft, CheckCircle, Circle, FileText, Video, HelpCircle } from "lucide-react";
const iconMap = { video: Video, reading: FileText, quiz: HelpCircle };
export default function StudentCourseDetail() {
const { id } = useParams();
const { data: coursesData, isLoading: lc } = useCourses();
const { data: assignmentsData, isLoading: la } = useAssignments();
const { data: attendanceData, isLoading: lat } = useAttendance();
const { data: gradesData, isLoading: lg } = useGrades();
const courses = coursesData?.items ?? [];
const assignments = assignmentsData?.items ?? [];
const attendanceRecords = attendanceData ?? [];
const gradeRecords = gradesData ?? [];
if (lc || la || lat || lg) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
const course = courses.find(c => c.id === id);
if (!course) return <div className="p-8 text-center text-muted-foreground">Course not found.</div>;
const courseAssignments = assignments.filter(a => a.courseId === id);
const courseAttendance = attendanceRecords.filter(a => a.courseId === id);
const courseGrades = gradeRecords.filter(g => g.courseId === id);
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild><Link to="/student/courses"><ArrowLeft className="h-4 w-4" /></Link></Button>
<div>
<h1 className="text-2xl font-bold">{course.title}</h1>
<p className="text-muted-foreground">{course.instructor} · {course.code}</p>
</div>
</div>
<Card>
<CardContent className="pt-6">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-muted-foreground">Overall Progress</span>
<span className="text-sm font-bold">{course.progress}%</span>
</div>
<Progress value={course.progress} className="h-3" />
</CardContent>
</Card>
<Tabs defaultValue="materials">
<TabsList>
<TabsTrigger value="materials">Materials</TabsTrigger>
<TabsTrigger value="assignments">Assignments ({courseAssignments.length})</TabsTrigger>
<TabsTrigger value="attendance">Attendance</TabsTrigger>
<TabsTrigger value="grades">Grades</TabsTrigger>
</TabsList>
<TabsContent value="materials" className="space-y-4 mt-4">
{course.modules.length === 0 ? (
<p className="text-muted-foreground text-sm py-8 text-center">No modules available yet.</p>
) : course.modules.map(mod => (
<Card key={mod.id}>
<CardHeader className="pb-2">
<CardTitle className="text-base">{mod.title}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{mod.lessons.map(lesson => {
const Icon = iconMap[lesson.type];
return (
<div key={lesson.id} className="flex items-center gap-3 p-2 rounded hover:bg-muted/50">
{lesson.completed ? <CheckCircle className="h-4 w-4 text-success shrink-0" /> : <Circle className="h-4 w-4 text-muted-foreground shrink-0" />}
<Icon className="h-4 w-4 text-muted-foreground shrink-0" />
<span className="text-sm flex-1">{lesson.title}</span>
<span className="text-xs text-muted-foreground">{lesson.duration}</span>
</div>
);
})}
</CardContent>
</Card>
))}
</TabsContent>
<TabsContent value="assignments" className="mt-4 space-y-3">
{courseAssignments.map(a => (
<Card key={a.id}>
<CardContent className="pt-4 flex items-center justify-between">
<div>
<p className="font-medium text-sm">{a.title}</p>
<p className="text-xs text-muted-foreground">Due: {a.dueDate}</p>
</div>
<Badge variant={a.status === "graded" ? "default" : a.status === "overdue" ? "destructive" : "secondary"}>
{a.status}
</Badge>
</CardContent>
</Card>
))}
</TabsContent>
<TabsContent value="attendance" className="mt-4 space-y-3">
{courseAttendance.map(a => (
<div key={a.id} className="flex items-center justify-between p-3 rounded border">
<span className="text-sm">{a.date}</span>
<Badge variant={a.status === "present" ? "default" : a.status === "absent" ? "destructive" : "secondary"}>
{a.status}
</Badge>
</div>
))}
</TabsContent>
<TabsContent value="grades" className="mt-4 space-y-3">
{courseGrades.map(g => (
<div key={g.id} className="flex items-center justify-between p-3 rounded border">
<div>
<p className="text-sm font-medium">{g.assignmentTitle}</p>
<p className="text-xs text-muted-foreground">{g.date}</p>
</div>
<span className="font-bold text-primary">{g.grade}/{g.maxGrade}</span>
</div>
))}
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,58 @@
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { Link } from "react-router-dom";
import { useCourses } from "@/hooks/queries";
import { BookOpen, Users, Clock } from "lucide-react";
import AiTipBanner from "@/components/ai/AiTipBanner";
export default function StudentCourses() {
const { data: coursesData, isLoading } = useCourses();
const courses = coursesData?.items ?? [];
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
const myCourses = courses.filter(c => ["c1", "c2", "c5", "c8"].includes(c.id));
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">My Courses</h1>
<p className="text-muted-foreground">Browse and continue your enrolled courses.</p>
</div>
<AiTipBanner context="student-courses" variant="recommendation" />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{myCourses.map((c) => (
<Link to={`/student/courses/${c.id}`} key={c.id}>
<Card className="hover:shadow-md transition-shadow h-full">
<div className="h-2 rounded-t-lg bg-primary" />
<CardContent className="pt-5 space-y-4">
<div>
<div className="flex items-center justify-between mb-1">
<Badge variant="secondary" className="text-xs">{c.level}</Badge>
<Badge variant="outline" className="text-xs">{c.code}</Badge>
</div>
<h3 className="font-semibold mt-2">{c.title}</h3>
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">{c.description}</p>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1"><BookOpen className="h-3 w-3" />{c.modules.length} modules</span>
<span className="flex items-center gap-1"><Users className="h-3 w-3" />{c.enrolled} students</span>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">Progress</span>
<span className="font-medium">{c.progress}%</span>
</div>
<Progress value={c.progress} className="h-2" />
</div>
<p className="text-xs text-muted-foreground flex items-center gap-1"><Clock className="h-3 w-3" /> {c.instructor}</p>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,115 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { BookOpen, ClipboardList, BarChart3, Calendar, ArrowRight } from "lucide-react";
import { Link } from "react-router-dom";
import { useCourses, useAssignments, useGrades } from "@/hooks/queries";
import AiStudyCoach from "@/components/ai/AiStudyCoach";
import AiTipBanner from "@/components/ai/AiTipBanner";
export default function StudentDashboard() {
const { data: coursesData, isLoading: lc } = useCourses();
const { data: assignmentsData, isLoading: la } = useAssignments();
const { data: gradesData, isLoading: lg } = useGrades();
const courses = coursesData?.items ?? [];
const assignments = assignmentsData?.items ?? [];
const gradeRecords = gradesData ?? [];
if (lc || la || lg) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
const myCourses = courses.filter(c => ["c1", "c2", "c5", "c8"].includes(c.id));
const upcomingAssignments = assignments.filter(a => a.status === "pending").slice(0, 3);
const recentGrades = gradeRecords.slice(0, 3);
const stats = [
{ label: "Enrolled Courses", value: String(myCourses.length), icon: BookOpen, color: "text-primary" },
{ label: "Pending Assignments", value: String(assignments.filter(a => a.status === "pending").length), icon: ClipboardList, color: "text-warning" },
{ label: "Average Grade", value: "78%", icon: BarChart3, color: "text-success" },
{ label: "Attendance Rate", value: "94%", icon: Calendar, color: "text-info" },
];
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Welcome back, Sarah!</h1>
<p className="text-muted-foreground">Here's an overview of your learning progress.</p>
</div>
<AiTipBanner context="student-dashboard" variant="tip" />
<AiStudyCoach />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{stats.map((s) => (
<Card key={s.label}>
<CardContent className="pt-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">{s.label}</p>
<p className="text-2xl font-bold">{s.value}</p>
</div>
<s.icon className={`h-8 w-8 ${s.color} opacity-80`} />
</div>
</CardContent>
</Card>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg">My Courses</CardTitle>
<Button variant="ghost" size="sm" asChild><Link to="/student/courses">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button>
</CardHeader>
<CardContent className="space-y-4">
{myCourses.map((c) => (
<Link to={`/student/courses/${c.id}`} key={c.id} className="block">
<div className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors">
<div className="min-w-0">
<p className="font-medium text-sm truncate">{c.title}</p>
<p className="text-xs text-muted-foreground">{c.instructor}</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<div className="w-24"><Progress value={c.progress} className="h-2" /></div>
<span className="text-xs font-medium w-8 text-right">{c.progress}%</span>
</div>
</div>
</Link>
))}
</CardContent>
</Card>
<div className="space-y-6">
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg">Upcoming Assignments</CardTitle>
<Button variant="ghost" size="sm" asChild><Link to="/student/assignments">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button>
</CardHeader>
<CardContent className="space-y-3">
{upcomingAssignments.map((a) => (
<div key={a.id} className="flex items-center justify-between p-2 rounded border">
<div><p className="text-sm font-medium">{a.title}</p><p className="text-xs text-muted-foreground">{a.courseName}</p></div>
<Badge variant="outline" className="text-xs">{a.dueDate}</Badge>
</div>
))}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-lg">Recent Grades</CardTitle>
<Button variant="ghost" size="sm" asChild><Link to="/student/grades">View All <ArrowRight className="ml-1 h-3 w-3" /></Link></Button>
</CardHeader>
<CardContent className="space-y-3">
{recentGrades.map((g) => (
<div key={g.id} className="flex items-center justify-between p-2 rounded border">
<div><p className="text-sm font-medium">{g.assignmentTitle}</p><p className="text-xs text-muted-foreground">{g.courseName}</p></div>
<span className="text-sm font-bold text-primary">{g.grade}/{g.maxGrade}</span>
</div>
))}
</CardContent>
</Card>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,176 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { MessageSquare, Pin, CheckCircle2, Loader2, ArrowLeft, Send, Users } from "lucide-react";
import { useDiscussionBoards, usePosts, useCreatePost } from "@/hooks/queries";
import { useToast } from "@/hooks/use-toast";
import type { DiscussionPost } from "@/types/communication";
function PostItem({ post, boardId }: { post: DiscussionPost; boardId: number }) {
const [showReply, setShowReply] = useState(false);
const [replyContent, setReplyContent] = useState("");
const createPost = useCreatePost();
const { toast } = useToast();
const handleReply = () => {
createPost.mutate(
{ boardId, data: { board_id: boardId, parent_id: post.id, content: replyContent } },
{
onSuccess: () => { setShowReply(false); setReplyContent(""); },
onError: () => toast({ title: "Error", description: "Failed to reply", variant: "destructive" }),
},
);
};
return (
<div className="space-y-2">
<div className={`p-4 rounded-lg border ${post.is_pinned ? "border-primary/30 bg-primary/5" : ""}`}>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
{post.title && <p className="font-medium">{post.title}</p>}
<p className="text-sm mt-1">{post.content}</p>
<div className="flex items-center gap-2 mt-2 text-xs text-muted-foreground">
<span>{post.author_name}</span>
<span>·</span>
<Badge variant="outline" className="text-xs">{post.author_role}</Badge>
<span>·</span>
<span>{new Date(post.created_at).toLocaleDateString()}</span>
{post.is_pinned && <Badge variant="secondary" className="text-xs"><Pin className="h-3 w-3 mr-1" />Pinned</Badge>}
{post.is_resolved && <Badge variant="default" className="text-xs"><CheckCircle2 className="h-3 w-3 mr-1" />Resolved</Badge>}
</div>
</div>
<Button variant="ghost" size="sm" onClick={() => setShowReply(!showReply)}>Reply</Button>
</div>
{showReply && (
<div className="flex gap-2 mt-3 pt-3 border-t">
<Input placeholder="Write a reply..." value={replyContent} onChange={e => setReplyContent(e.target.value)} className="flex-1" />
<Button size="sm" onClick={handleReply} disabled={createPost.isPending || !replyContent}>
{createPost.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <Send className="h-4 w-4" />}
</Button>
</div>
)}
</div>
{post.replies && post.replies.length > 0 && (
<div className="ml-6 space-y-2">
{post.replies.map(reply => (
<PostItem key={reply.id} post={reply} boardId={boardId} />
))}
</div>
)}
</div>
);
}
export default function StudentDiscussionBoard() {
const { toast } = useToast();
const { data: boards = [], isLoading } = useDiscussionBoards();
const [selectedBoardId, setSelectedBoardId] = useState<number | null>(null);
const { data: postsData, isLoading: loadingPosts } = usePosts(selectedBoardId ?? 0);
const createPost = useCreatePost();
const [showNewPost, setShowNewPost] = useState(false);
const [newTitle, setNewTitle] = useState("");
const [newContent, setNewContent] = useState("");
const posts = postsData?.results ?? [];
const handleCreatePost = () => {
if (!selectedBoardId) return;
createPost.mutate(
{ boardId: selectedBoardId, data: { board_id: selectedBoardId, title: newTitle, content: newContent } },
{
onSuccess: () => { toast({ title: "Post Created" }); setShowNewPost(false); setNewTitle(""); setNewContent(""); },
onError: () => toast({ title: "Error", description: "Failed to create post", variant: "destructive" }),
},
);
};
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Discussion Boards</h1>
<p className="text-muted-foreground">Participate in course discussions with your classmates.</p>
</div>
{!selectedBoardId ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{boards.map(board => (
<Card key={board.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setSelectedBoardId(board.id)}>
<CardHeader>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
<MessageSquare className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-base">{board.name}</CardTitle>
<p className="text-xs text-muted-foreground">{board.course_name}</p>
</div>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<span>{board.post_count} posts</span>
<Users className="h-4 w-4" />
</div>
</CardContent>
</Card>
))}
{boards.length === 0 && (
<div className="col-span-full text-center py-12 text-muted-foreground">
<MessageSquare className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p>No discussion boards available for your courses.</p>
</div>
)}
</div>
) : (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Button variant="ghost" onClick={() => setSelectedBoardId(null)}>
<ArrowLeft className="mr-2 h-4 w-4" /> Back to Boards
</Button>
<Dialog open={showNewPost} onOpenChange={setShowNewPost}>
<Button onClick={() => setShowNewPost(true)}><MessageSquare className="mr-2 h-4 w-4" /> New Post</Button>
<DialogContent>
<DialogHeader><DialogTitle>Create Post</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Title</Label>
<Input placeholder="Post title" value={newTitle} onChange={e => setNewTitle(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Content</Label>
<Textarea placeholder="Write your post..." rows={4} value={newContent} onChange={e => setNewContent(e.target.value)} />
</div>
<Button className="w-full" onClick={handleCreatePost} disabled={createPost.isPending || !newContent}>
{createPost.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Post
</Button>
</div>
</DialogContent>
</Dialog>
</div>
{loadingPosts ? (
<div className="flex items-center justify-center min-h-[200px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>
) : (
<div className="space-y-3">
{posts.map(post => (
<PostItem key={post.id} post={post} boardId={selectedBoardId} />
))}
{posts.length === 0 && (
<Card><CardContent className="py-8 text-center text-muted-foreground">No posts yet. Start the conversation!</CardContent></Card>
)}
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { useGrades } from "@/hooks/queries";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
import AiGradeExplainer from "@/components/ai/AiGradeExplainer";
import AiReportNarrative from "@/components/ai/AiReportNarrative";
export default function StudentGrades() {
const { data: gradeRecords = [], isLoading } = useGrades();
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
const avgGrade = gradeRecords.length
? (gradeRecords.reduce((sum, g) => sum + (g.grade / g.maxGrade) * 100, 0) / gradeRecords.length).toFixed(1)
: "0";
const highest = gradeRecords.length
? Math.max(...gradeRecords.map(g => (g.grade / g.maxGrade) * 100)).toFixed(1)
: "0";
const chartData = gradeRecords.map(g => ({
name: g.assignmentTitle.length > 15 ? g.assignmentTitle.slice(0, 15) + "…" : g.assignmentTitle,
grade: Math.round((g.grade / g.maxGrade) * 100),
}));
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Grades</h1>
<p className="text-muted-foreground">Track your academic performance.</p>
</div>
<AiReportNarrative narrative={`Your average grade is ${avgGrade}%. Your strongest area is essay writing with consistent scores above 80%. Focus on improving speaking scores — your last mock test scored 72%, which is below your average. AI recommends practicing with the IELTS Speaking Masterclass materials.`} />
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card><CardContent className="pt-6 text-center"><p className="text-sm text-muted-foreground">Average</p><p className="text-3xl font-bold text-primary">{avgGrade}%</p></CardContent></Card>
<Card><CardContent className="pt-6 text-center"><p className="text-sm text-muted-foreground">Highest</p><p className="text-3xl font-bold text-success">{highest}%</p></CardContent></Card>
<Card><CardContent className="pt-6 text-center"><p className="text-sm text-muted-foreground">Graded Items</p><p className="text-3xl font-bold">{gradeRecords.length}</p></CardContent></Card>
</div>
<Card>
<CardHeader><CardTitle className="text-lg">Grade Distribution</CardTitle></CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={250}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
<XAxis dataKey="name" tick={{ fontSize: 11 }} className="fill-muted-foreground" />
<YAxis domain={[0, 100]} tick={{ fontSize: 11 }} className="fill-muted-foreground" />
<Tooltip />
<Bar dataKey="grade" fill="hsl(192, 82%, 32%)" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-lg">All Grades</CardTitle></CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Assignment</TableHead>
<TableHead>Course</TableHead>
<TableHead>Type</TableHead>
<TableHead>Date</TableHead>
<TableHead className="text-right">Grade</TableHead>
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{gradeRecords.map(g => (
<TableRow key={g.id}>
<TableCell className="font-medium">{g.assignmentTitle}</TableCell>
<TableCell>{g.courseName}</TableCell>
<TableCell><Badge variant="outline" className="text-xs capitalize">{g.type}</Badge></TableCell>
<TableCell>{g.date}</TableCell>
<TableCell className="text-right font-bold">{g.grade}/{g.maxGrade}</TableCell>
<TableCell><AiGradeExplainer studentName="Sarah Johnson" /></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,111 @@
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Progress } from "@/components/ui/progress";
import { BookOpen, TrendingUp, Activity, CheckCircle2, FileText, ClipboardList } from "lucide-react";
import { useProficiencySummary } from "@/hooks/queries";
import { analyticsService } from "@/services/analytics.service";
import { adaptiveService } from "@/services/adaptive.service";
import { useQuery } from "@tanstack/react-query";
export default function StudentJourney() {
const { data: summaries = [], isLoading: ls } = useProficiencySummary();
const { data: analyticsData, isLoading: la } = useQuery({
queryKey: ["analytics", "student", "journey"],
queryFn: () => analyticsService.getStudentAnalytics(),
});
const { data: planData, isLoading: lp } = useQuery({
queryKey: ["adaptive", "plan", "all"],
queryFn: () => adaptiveService.getLearningPlan({}),
});
const analytics = analyticsData as { recent_activity?: { action: string; description: string; date: string }[] } | undefined;
const recentActivity = analytics?.recent_activity?.slice(0, 10) ?? [];
if (ls || la || lp) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
const activityIcons: Record<string, React.ReactNode> = {
exam: <ClipboardList className="h-4 w-4" />,
assignment: <FileText className="h-4 w-4" />,
chapter: <BookOpen className="h-4 w-4" />,
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">My Learning Journey</h1>
<p className="text-muted-foreground">Track your progress and proficiency across all subjects.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{summaries.map(s => {
const mastery = Math.round(s.overall_mastery ?? 0);
return (
<Card key={s.subject_id} className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
<BookOpen className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-base">{s.subject_name ?? `Subject ${s.subject_id}`}</CardTitle>
<CardDescription>{s.topics_mastered ?? 0} / {s.topics_total ?? 0} topics mastered</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Overall Mastery</span>
<span className="font-medium">{mastery}%</span>
</div>
<Progress value={mastery} className="h-2" />
</div>
<Badge variant={mastery >= 80 ? "default" : mastery >= 50 ? "secondary" : "outline"}>
{mastery >= 80 ? "Proficient" : mastery >= 50 ? "Developing" : "Beginner"}
</Badge>
</CardContent>
</Card>
);
})}
{summaries.length === 0 && (
<div className="col-span-full text-center py-12 text-muted-foreground">
<TrendingUp className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p>Take a diagnostic assessment to start tracking your journey.</p>
</div>
)}
</div>
<Card>
<CardHeader>
<div className="flex items-center gap-2">
<Activity className="h-5 w-5 text-primary" />
<CardTitle>Recent Activity</CardTitle>
</div>
<CardDescription>Your last 10 actions across all courses.</CardDescription>
</CardHeader>
<CardContent>
{recentActivity.length > 0 ? (
<div className="space-y-3">
{recentActivity.map((act, i) => (
<div key={i} className="flex items-center gap-3 p-3 rounded-lg border">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary shrink-0">
{activityIcons[act.action] ?? <CheckCircle2 className="h-4 w-4" />}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{act.description}</p>
<p className="text-xs text-muted-foreground">{new Date(act.date).toLocaleDateString()}</p>
</div>
<Badge variant="outline" className="text-xs shrink-0">{act.action}</Badge>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-6">No recent activity to show.</p>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,171 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Mail, Send, Loader2, Inbox, PenSquare } from "lucide-react";
import { useMessages, useSentMessages, useUnreadMessageCount, useSendMessage } from "@/hooks/queries";
import { communicationService } from "@/services/communication.service";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useToast } from "@/hooks/use-toast";
import type { Message } from "@/types/communication";
export default function StudentMessages() {
const { toast } = useToast();
const qc = useQueryClient();
const { data: inboxData, isLoading: li } = useMessages();
const { data: sentData, isLoading: ls } = useSentMessages();
const { data: unreadData } = useUnreadMessageCount();
const sendMessage = useSendMessage();
const inbox = inboxData?.results ?? [];
const sent = sentData?.results ?? [];
const unreadCount = unreadData?.count ?? 0;
const [showCompose, setShowCompose] = useState(false);
const [recipientId, setRecipientId] = useState("");
const [subject, setSubject] = useState("");
const [content, setContent] = useState("");
const [selectedMessage, setSelectedMessage] = useState<Message | null>(null);
const [showDetail, setShowDetail] = useState(false);
const markRead = useMutation({
mutationFn: (id: number) => communicationService.getMessage(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["messages"] });
qc.invalidateQueries({ queryKey: ["messages", "unread-count"] });
},
});
const handleOpenMessage = (msg: Message) => {
setSelectedMessage(msg);
setShowDetail(true);
if (!msg.is_read) markRead.mutate(msg.id);
};
const handleSend = () => {
sendMessage.mutate(
{ recipient_id: Number(recipientId), subject, content },
{
onSuccess: () => { toast({ title: "Message Sent" }); setShowCompose(false); setRecipientId(""); setSubject(""); setContent(""); },
onError: () => toast({ title: "Error", description: "Failed to send message", variant: "destructive" }),
},
);
};
if (li || ls) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Messages</h1>
<p className="text-muted-foreground">Communicate with your classmates and teachers.</p>
</div>
<Button onClick={() => setShowCompose(true)}>
<PenSquare className="mr-2 h-4 w-4" /> New Message
</Button>
</div>
<Tabs defaultValue="inbox">
<TabsList>
<TabsTrigger value="inbox" className="gap-2">
<Inbox className="h-4 w-4" /> Inbox
{unreadCount > 0 && <Badge variant="destructive" className="ml-1 text-xs px-1.5">{unreadCount}</Badge>}
</TabsTrigger>
<TabsTrigger value="sent" className="gap-2">
<Send className="h-4 w-4" /> Sent
</TabsTrigger>
</TabsList>
<TabsContent value="inbox" className="space-y-2 mt-4">
{inbox.map(msg => (
<Card key={msg.id} className={`cursor-pointer hover:shadow-sm transition-shadow ${!msg.is_read ? "border-primary/30 bg-primary/5" : ""}`} onClick={() => handleOpenMessage(msg)}>
<CardContent className="flex items-center gap-4 py-3">
<Mail className={`h-5 w-5 shrink-0 ${!msg.is_read ? "text-primary" : "text-muted-foreground"}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className={`text-sm truncate ${!msg.is_read ? "font-semibold" : ""}`}>{msg.subject}</span>
{!msg.is_read && <Badge variant="default" className="text-xs">New</Badge>}
</div>
<p className="text-xs text-muted-foreground mt-0.5">From: {msg.sender_name}</p>
</div>
<span className="text-xs text-muted-foreground shrink-0">{new Date(msg.created_at).toLocaleDateString()}</span>
</CardContent>
</Card>
))}
{inbox.length === 0 && (
<Card><CardContent className="py-8 text-center text-muted-foreground">Your inbox is empty.</CardContent></Card>
)}
</TabsContent>
<TabsContent value="sent" className="space-y-2 mt-4">
{sent.map(msg => (
<Card key={msg.id} className="cursor-pointer hover:shadow-sm transition-shadow" onClick={() => handleOpenMessage(msg)}>
<CardContent className="flex items-center gap-4 py-3">
<Send className="h-5 w-5 shrink-0 text-muted-foreground" />
<div className="flex-1 min-w-0">
<span className="text-sm truncate">{msg.subject}</span>
<p className="text-xs text-muted-foreground mt-0.5">To: {msg.recipient_name}</p>
</div>
<span className="text-xs text-muted-foreground shrink-0">{new Date(msg.created_at).toLocaleDateString()}</span>
</CardContent>
</Card>
))}
{sent.length === 0 && (
<Card><CardContent className="py-8 text-center text-muted-foreground">No sent messages.</CardContent></Card>
)}
</TabsContent>
</Tabs>
<Sheet open={showDetail} onOpenChange={setShowDetail}>
<SheetContent className="sm:max-w-lg">
{selectedMessage && (
<>
<SheetHeader>
<SheetTitle>{selectedMessage.subject}</SheetTitle>
</SheetHeader>
<div className="space-y-4 mt-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>From: {selectedMessage.sender_name}</span>
<span>·</span>
<span>To: {selectedMessage.recipient_name}</span>
</div>
<span className="text-xs text-muted-foreground">{new Date(selectedMessage.created_at).toLocaleString()}</span>
<div className="p-4 rounded-lg border bg-muted/30 text-sm whitespace-pre-wrap">{selectedMessage.content}</div>
</div>
</>
)}
</SheetContent>
</Sheet>
<Dialog open={showCompose} onOpenChange={setShowCompose}>
<DialogContent>
<DialogHeader><DialogTitle>New Message</DialogTitle></DialogHeader>
<div className="space-y-4 pt-4">
<div className="space-y-2">
<Label>Recipient ID</Label>
<Input type="number" placeholder="Enter recipient user ID" value={recipientId} onChange={e => setRecipientId(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Subject</Label>
<Input placeholder="Message subject" value={subject} onChange={e => setSubject(e.target.value)} />
</div>
<div className="space-y-2">
<Label>Content</Label>
<Textarea placeholder="Write your message..." rows={4} value={content} onChange={e => setContent(e.target.value)} />
</div>
<Button className="w-full" onClick={handleSend} disabled={sendMessage.isPending || !recipientId || !subject || !content}>
{sendMessage.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
<Send className="mr-2 h-4 w-4" /> Send
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { User } from "lucide-react";
import { useAuth } from "@/contexts/AuthContext";
import { useToast } from "@/hooks/use-toast";
export default function StudentProfile() {
const { user } = useAuth();
const { toast } = useToast();
return (
<div className="space-y-6 max-w-2xl">
<div>
<h1 className="text-2xl font-bold">Profile</h1>
<p className="text-muted-foreground">Manage your account information.</p>
</div>
<Card>
<CardHeader><CardTitle className="text-lg">Personal Information</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4 mb-4">
<div className="h-16 w-16 rounded-full bg-primary/10 flex items-center justify-center">
<span className="text-lg font-bold text-primary">{(user?.name?.split(" ") ?? []).map(w => w[0]).join("").slice(0, 2).toUpperCase() || "??"}</span>
</div>
<Button variant="outline" size="sm">Change Avatar</Button>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>First Name</Label>
<Input defaultValue={user?.name.split(" ")[0]} />
</div>
<div className="space-y-2">
<Label>Last Name</Label>
<Input defaultValue={user?.name.split(" ")[1]} />
</div>
</div>
<div className="space-y-2">
<Label>Email</Label>
<Input defaultValue={user?.email} type="email" />
</div>
<Button onClick={() => toast({ title: "Profile Updated", description: "Your information has been saved." })}>
Save Changes
</Button>
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-lg">Change Password</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2"><Label>Current Password</Label><Input type="password" /></div>
<div className="space-y-2"><Label>New Password</Label><Input type="password" /></div>
<div className="space-y-2"><Label>Confirm Password</Label><Input type="password" /></div>
<Button onClick={() => toast({ title: "Password Updated" })}>Update Password</Button>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,74 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useTimetable } from "@/hooks/queries";
const days = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"] as const;
const hours = ["08:00", "09:00", "10:00", "11:00", "12:00", "13:00", "14:00", "15:00", "16:00", "17:00"];
export default function StudentTimetable() {
const { data: timetableSessions = [], isLoading } = useTimetable();
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
const mySessions = timetableSessions.filter(s => ["c1", "c2", "c5", "c8"].includes(s.courseId));
function getSessionAt(day: string, hour: string) {
return mySessions.find(s => s.day === day && s.startTime <= hour && s.endTime > hour);
}
function isStart(day: string, hour: string) {
return mySessions.find(s => s.day === day && s.startTime === hour);
}
function getSpan(session: (typeof mySessions)[0]) {
const start = parseInt(session.startTime.split(":")[0]);
const end = parseInt(session.endTime.split(":")[0]);
const startMin = parseInt(session.startTime.split(":")[1]);
const endMin = parseInt(session.endTime.split(":")[1]);
return (end - start) + (endMin > 0 ? 0.5 : 0) - (startMin > 0 ? 0.5 : 0);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Timetable</h1>
<p className="text-muted-foreground">Your weekly class schedule.</p>
</div>
<Card>
<CardContent className="pt-6 overflow-x-auto">
<div className="min-w-[700px]">
<div className="grid grid-cols-6 gap-1">
<div className="text-xs font-medium text-muted-foreground p-2" />
{days.map(d => (
<div key={d} className="text-xs font-semibold text-center p-2 bg-muted rounded">{d}</div>
))}
</div>
{hours.map(hour => (
<div key={hour} className="grid grid-cols-6 gap-1 min-h-[48px]">
<div className="text-xs text-muted-foreground p-2 flex items-start">{hour}</div>
{days.map(day => {
const session = isStart(day, hour);
const occupied = getSessionAt(day, hour);
if (session) {
return (
<div
key={day}
className="rounded-md p-2 text-xs text-white"
style={{ backgroundColor: session.color, minHeight: `${getSpan(session) * 48}px` }}
>
<p className="font-semibold">{session.courseName}</p>
<p className="opacity-80">{session.startTime}-{session.endTime}</p>
<p className="opacity-70">{session.room}</p>
</div>
);
}
if (occupied && !session) return <div key={day} />;
return <div key={day} className="border border-dashed border-border/50 rounded-md" />;
})}
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,145 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Checkbox } from "@/components/ui/checkbox";
import { Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { institutionalExamService } from "@/services/institutional-exam.service";
import { useToast } from "@/hooks/use-toast";
import type { SubjectRegistration } from "@/types/institutional-exam";
const stateBadgeVariant: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
draft: "outline",
confirm: "default",
reject: "destructive",
done: "secondary",
};
export default function SubjectRegistrationPage() {
const { toast } = useToast();
const qc = useQueryClient();
const [selectedSubjects, setSelectedSubjects] = useState<number[]>([]);
const { data: registrationsData, isLoading } = useQuery({
queryKey: ["subject-registrations", "list"],
queryFn: () => institutionalExamService.listSubjectRegistrations(),
});
const registrations = registrationsData?.items ?? [];
const { data: available = [], isLoading: loadingAvailable } = useQuery({
queryKey: ["subject-registrations", "available"],
queryFn: () => institutionalExamService.getAvailableSubjects(),
});
const createMutation = useMutation({
mutationFn: () => institutionalExamService.createSubjectRegistration({
subject_ids: selectedSubjects,
} as Parameters<typeof institutionalExamService.createSubjectRegistration>[0]),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["subject-registrations"] });
toast({ title: "Registration submitted" });
setSelectedSubjects([]);
},
onError: () => toast({ title: "Error", description: "Registration failed", variant: "destructive" }),
});
const toggleSubject = (id: number) => {
setSelectedSubjects(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id]);
};
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Subject Registration</h1>
<p className="text-muted-foreground">Register for subjects within your enrolled courses.</p>
</div>
<Card>
<CardHeader>
<CardTitle>Available Subjects</CardTitle>
<CardDescription>Select the subjects you want to register for this term.</CardDescription>
</CardHeader>
<CardContent>
{loadingAvailable ? (
<div className="flex justify-center py-8"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>
) : available.length > 0 ? (
<div className="space-y-4">
<div className="space-y-2">
{available.map((subj: SubjectRegistration) => (
<label
key={subj.id}
className="flex items-center gap-3 p-3 rounded-lg border hover:bg-accent/50 cursor-pointer"
>
<Checkbox
checked={selectedSubjects.includes(subj.id)}
onCheckedChange={() => toggleSubject(subj.id)}
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{subj.subject_names?.join(", ") || subj.course_name}</p>
<p className="text-xs text-muted-foreground">
{subj.course_name} · {subj.batch_name}
{subj.min_unit_load > 0 && ` · Load: ${subj.min_unit_load}${subj.max_unit_load}`}
</p>
</div>
</label>
))}
</div>
<Button onClick={() => createMutation.mutate()} disabled={createMutation.isPending || selectedSubjects.length === 0}>
{createMutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Submit Registration ({selectedSubjects.length} selected)
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground text-center py-4">No subjects available for registration.</p>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>My Registrations</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Course</TableHead>
<TableHead>Batch</TableHead>
<TableHead>Subjects</TableHead>
<TableHead>State</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{registrations.map((reg: SubjectRegistration) => (
<TableRow key={reg.id}>
<TableCell className="font-medium">{reg.course_name}</TableCell>
<TableCell className="text-muted-foreground">{reg.batch_name}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{reg.subject_names.slice(0, 3).map((name, i) => (
<Badge key={i} variant="secondary" className="text-xs">{name}</Badge>
))}
{reg.subject_names.length > 3 && <Badge variant="secondary" className="text-xs">+{reg.subject_names.length - 3}</Badge>}
</div>
</TableCell>
<TableCell>
<Badge variant={stateBadgeVariant[reg.state] ?? "outline"} className="capitalize">{reg.state}</Badge>
</TableCell>
</TableRow>
))}
{registrations.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-8 text-muted-foreground">No registrations yet.</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { useNavigate } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { BookOpen, ArrowRight } from "lucide-react";
import { useSubjects, useProficiencySummary } from "@/hooks/queries";
export default function SubjectSelection() {
const navigate = useNavigate();
const { data: subjects = [], isLoading: ls } = useSubjects();
const { data: summaries = [], isLoading: lp } = useProficiencySummary();
if (ls || lp) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
const getSummary = (subjectId: number) => summaries.find(s => s.subject_id === subjectId);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">My Subjects</h1>
<p className="text-muted-foreground">Select a subject to view your progress or start a diagnostic assessment.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{subjects.map((subject) => {
const summary = getSummary(subject.id);
const mastery = summary?.overall_mastery ?? 0;
const hasTaken = !!summary;
return (
<Card key={subject.id} className="hover:shadow-md transition-shadow">
<CardHeader>
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-primary/10 flex items-center justify-center">
<BookOpen className="h-5 w-5 text-primary" />
</div>
<div>
<CardTitle className="text-lg">{subject.name}</CardTitle>
<CardDescription>{subject.code}</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{hasTaken ? (
<>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Overall Mastery</span>
<span className="font-medium">{Math.round(mastery)}%</span>
</div>
<Progress value={mastery} className="h-2" />
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>{summary?.topics_mastered ?? 0} / {summary?.topics_total ?? 0} topics mastered</span>
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1" onClick={() => navigate(`/student/proficiency/${subject.id}`)}>
View Profile
</Button>
<Button size="sm" className="flex-1" onClick={() => navigate(`/student/plan/${subject.id}`)}>
Learning Plan <ArrowRight className="ml-1 h-3 w-3" />
</Button>
</div>
</>
) : (
<>
<p className="text-sm text-muted-foreground">Take a diagnostic assessment to discover your proficiency level and get a personalized learning plan.</p>
<Button className="w-full" onClick={() => navigate(`/student/diagnostic/${subject.id}`)}>
Start Diagnostic <ArrowRight className="ml-1 h-4 w-4" />
</Button>
</>
)}
</CardContent>
</Card>
);
})}
{subjects.length === 0 && (
<div className="col-span-full text-center py-12 text-muted-foreground">
<BookOpen className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p>No subjects available yet.</p>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,126 @@
import type { ReactNode } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Separator } from "@/components/ui/separator";
import { FileText, Video, Link2, BookOpen, MessageSquare, Trophy, ExternalLink } from "lucide-react";
import { useTopicContent } from "@/hooks/queries";
import AiStudyCoach from "@/components/ai/AiStudyCoach";
const resourceIcons: Record<string, ReactNode> = {
pdf: <FileText className="h-4 w-4" />,
video: <Video className="h-4 w-4" />,
link: <Link2 className="h-4 w-4" />,
document: <BookOpen className="h-4 w-4" />,
interactive: <MessageSquare className="h-4 w-4" />,
};
export default function TopicLearning() {
const { topicId } = useParams<{ topicId: string }>();
const navigate = useNavigate();
const { data: topicContent, isLoading } = useTopicContent(Number(topicId));
if (isLoading) return <div className="flex items-center justify-center min-h-[400px]"><div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /></div>;
if (!topicContent) {
return (
<div className="text-center py-16 text-muted-foreground">
<BookOpen className="h-12 w-12 mx-auto mb-3 opacity-40" />
<p>Topic content not found.</p>
<Button variant="outline" className="mt-4" onClick={() => navigate(-1)}>Go Back</Button>
</div>
);
}
return (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6">
<div>
<h1 className="text-2xl font-bold">{topicContent.topic_name}</h1>
{topicContent.has_content_gap && (
<Badge variant="secondary" className="mt-2">AI-supplemented content</Badge>
)}
</div>
{topicContent.ai_content && (
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-primary" />
AI Explanation
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm leading-relaxed">{topicContent.ai_content.explanation}</p>
{topicContent.ai_content.key_points.length > 0 && (
<>
<h4 className="font-medium text-sm">Key Points</h4>
<ul className="list-disc pl-5 space-y-1 text-sm text-muted-foreground">
{topicContent.ai_content.key_points.map((point, i) => (
<li key={i}>{point}</li>
))}
</ul>
</>
)}
{topicContent.ai_content.examples.length > 0 && (
<>
<h4 className="font-medium text-sm">Examples</h4>
{topicContent.ai_content.examples.map((ex, i) => (
<div key={i} className="p-3 bg-accent/50 rounded-lg text-sm">{ex}</div>
))}
</>
)}
</CardContent>
</Card>
)}
{topicContent.resources.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Resources</CardTitle>
<CardDescription>{topicContent.resources.length} resources available</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{topicContent.resources.map((resource) => (
<div key={resource.id} className="flex items-center justify-between p-3 rounded-lg border hover:bg-accent/50 transition-colors">
<div className="flex items-center gap-3">
{resourceIcons[resource.resource_type] ?? <FileText className="h-4 w-4" />}
<div>
<p className="text-sm font-medium">{resource.name}</p>
<p className="text-xs text-muted-foreground capitalize">{resource.resource_type}</p>
</div>
</div>
{resource.url && (
<Button variant="ghost" size="sm" asChild>
<a href={resource.url} target="_blank" rel="noopener noreferrer">
Open <ExternalLink className="ml-1 h-3 w-3" />
</a>
</Button>
)}
</div>
))}
</div>
</CardContent>
</Card>
)}
<Separator />
<div className="flex gap-3">
<Button variant="outline" className="flex-1">
<BookOpen className="mr-2 h-4 w-4" /> Practice Questions
</Button>
<Button className="flex-1">
<Trophy className="mr-2 h-4 w-4" /> Mastery Quiz
</Button>
</div>
</div>
<div className="space-y-6">
<AiStudyCoach />
</div>
</div>
);
}