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:
317
frontend/src/pages/student/AiIeltsCourse.tsx
Normal file
317
frontend/src/pages/student/AiIeltsCourse.tsx
Normal 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 },
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user