Files
full_encoach_platform/frontend/src/pages/student/CourseDelivery.tsx
Yamen Ahmad f1c4953a63 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
2026-04-10 17:26:42 +04:00

205 lines
8.4 KiB
TypeScript

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>
);
}