- 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
282 lines
11 KiB
TypeScript
282 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|