- {q.optionsFr.map((opt, oi) => (
+ {(q as any)[optsField].map((opt: string, oi: number) => (
diff --git a/src/app/[locale]/admin/page.tsx b/src/app/[locale]/admin/page.tsx
index 8d74330a..6909787f 100644
--- a/src/app/[locale]/admin/page.tsx
+++ b/src/app/[locale]/admin/page.tsx
@@ -20,18 +20,20 @@ export default async function AdminDashboardPage({
redirect(`/${locale}/dashboard`);
}
- const [totalCourses, publishedCourses, totalStudents, totalEnrollments, totalCompletions] =
+ const [totalCourses, publishedCourses, totalStudents, totalEnrollments, totalCompletions, totalPaths] =
await Promise.all([
db.course.count(),
db.course.count({ where: { published: true } }),
db.user.count({ where: { role: "LEARNER" } }),
db.enrollment.count(),
db.enrollment.count({ where: { completedAt: { not: null } } }),
+ db.learningPath.count(),
]);
const stats = [
{ label: "Formations totales", value: totalCourses, sub: `${publishedCourses} publiées`, icon: "📚", href: `/${locale}/admin/courses`, color: "#1d4ed8" },
- { label: "Apprenants", value: totalStudents, sub: `${totalEnrollments} inscriptions`, icon: "👥", href: `/${locale}/admin/students`, color: "#8b5cf6" },
+ { label: "Parcours", value: totalPaths, sub: "programmes complets", icon: "🗺️", href: `/${locale}/admin/paths`, color: "#8b5cf6" },
+ { label: "Apprenants", value: totalStudents, sub: `${totalEnrollments} inscriptions`, icon: "👥", href: `/${locale}/admin/students`, color: "#06b6d4" },
{ label: "Complétions", value: totalCompletions, sub: "formations terminées", icon: "🎓", href: null, color: "#22c55e" },
];
@@ -50,7 +52,7 @@ export default async function AdminDashboardPage({
{t("courses")}
+
+ + Nouveau parcours
+
+
+ 🗺️ Parcours
+
{
+ setLoading(true);
+ await fetch(`/api/admin/paths/${pathId}/publish`, { method: "PUT" });
+ router.refresh();
+ setLoading(false);
+ };
+
+ const handleDelete = async () => {
+ if (!confirm("Supprimer ce parcours définitivement ?")) return;
+ setLoading(true);
+ await fetch(`/api/admin/paths/${pathId}`, { method: "DELETE" });
+ router.refresh();
+ setLoading(false);
+ };
+
+ return (
+
+
+ Modifier
+
+
+
+
+ );
+}
diff --git a/src/app/[locale]/admin/paths/PathForm.tsx b/src/app/[locale]/admin/paths/PathForm.tsx
new file mode 100644
index 00000000..c352fea7
--- /dev/null
+++ b/src/app/[locale]/admin/paths/PathForm.tsx
@@ -0,0 +1,154 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+
+interface PathFormProps {
+ locale: string;
+ path?: {
+ id: string;
+ titleFr: string;
+ titleEn: string;
+ titleEs: string;
+ descFr: string;
+ descEn: string;
+ descEs: string;
+ slug: string;
+ published: boolean;
+ };
+}
+
+const LANG_TABS = ["fr", "en", "es"] as const;
+
+function slugify(text: string) {
+ return text
+ .toLowerCase()
+ .replace(/[éèêë]/g, "e")
+ .replace(/[àâä]/g, "a")
+ .replace(/[îï]/g, "i")
+ .replace(/[ôö]/g, "o")
+ .replace(/[ùûü]/g, "u")
+ .replace(/[ç]/g, "c")
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "");
+}
+
+export function PathForm({ locale, path }: PathFormProps) {
+ const [langTab, setLangTab] = useState<"fr" | "en" | "es">("fr");
+ const [titleFr, setTitleFr] = useState(path?.titleFr ?? "");
+ const [titleEn, setTitleEn] = useState(path?.titleEn ?? "");
+ const [titleEs, setTitleEs] = useState(path?.titleEs ?? "");
+ const [descFr, setDescFr] = useState(path?.descFr ?? "");
+ const [descEn, setDescEn] = useState(path?.descEn ?? "");
+ const [descEs, setDescEs] = useState(path?.descEs ?? "");
+ const [slug, setSlug] = useState(path?.slug ?? "");
+ const [published, setPublished] = useState(path?.published ?? false);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState("");
+ const router = useRouter();
+
+ const handleTitleFrChange = (v: string) => {
+ setTitleFr(v);
+ if (!path) setSlug(slugify(v));
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setSaving(true);
+ setError("");
+
+ const body = { titleFr, titleEn: titleEn || titleFr, titleEs: titleEs || titleFr, descFr, descEn: descEn || descFr, descEs: descEs || descFr, slug, published };
+
+ const res = path
+ ? await fetch(`/api/admin/paths/${path.id}`, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
+ : await fetch("/api/admin/paths", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
+
+ setSaving(false);
+
+ if (!res.ok) {
+ const data = await res.json();
+ setError(data.error ?? "Erreur");
+ return;
+ }
+
+ const data = await res.json();
+ router.push(`/${locale}/admin/paths/${data.id}`);
+ };
+
+ return (
+
+ );
+}
diff --git a/src/app/[locale]/admin/paths/[id]/PathCourseManager.tsx b/src/app/[locale]/admin/paths/[id]/PathCourseManager.tsx
new file mode 100644
index 00000000..6f7b0539
--- /dev/null
+++ b/src/app/[locale]/admin/paths/[id]/PathCourseManager.tsx
@@ -0,0 +1,159 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+
+interface Course {
+ id: string;
+ titleFr: string;
+ published: boolean;
+}
+
+interface PathCourse {
+ id: string;
+ courseId: string;
+ order: number;
+ course: Course;
+}
+
+export function PathCourseManager({
+ pathId,
+ pathCourses: initial,
+ allCourses,
+}: {
+ pathId: string;
+ pathCourses: PathCourse[];
+ allCourses: Course[];
+}) {
+ const [pathCourses, setPathCourses] = useState(initial);
+ const [selectedCourseId, setSelectedCourseId] = useState("");
+ const [loading, setLoading] = useState(false);
+ const router = useRouter();
+
+ const addedIds = new Set(pathCourses.map((pc) => pc.courseId));
+ const availableCourses = allCourses.filter((c) => !addedIds.has(c.id));
+
+ const addCourse = async () => {
+ if (!selectedCourseId) return;
+ setLoading(true);
+ try {
+ const res = await fetch(`/api/admin/paths/${pathId}/courses`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ courseId: selectedCourseId }),
+ });
+ const lpc = await res.json();
+ setPathCourses((prev) => [...prev, lpc]);
+ setSelectedCourseId("");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ const removeCourse = async (courseId: string) => {
+ if (!confirm("Retirer ce cours du parcours ?")) return;
+ setLoading(true);
+ try {
+ await fetch(`/api/admin/paths/${pathId}/courses`, {
+ method: "DELETE",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ courseId }),
+ });
+ setPathCourses((prev) => prev.filter((pc) => pc.courseId !== courseId));
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+ {pathCourses.length === 0 ? (
+
+ Aucune formation dans ce parcours.
+
+ ) : (
+ pathCourses.map((pc, i) => (
+
+
+ {i + 1}
+
+
+
{pc.course.titleFr}
+ {!pc.course.published && (
+
Brouillon
+ )}
+
+
+
+ ))
+ )}
+
+ {availableCourses.length > 0 && (
+
+
+
+
+ )}
+
+ );
+}
diff --git a/src/app/[locale]/admin/paths/[id]/page.tsx b/src/app/[locale]/admin/paths/[id]/page.tsx
new file mode 100644
index 00000000..798c861c
--- /dev/null
+++ b/src/app/[locale]/admin/paths/[id]/page.tsx
@@ -0,0 +1,66 @@
+import { redirect, notFound } from "next/navigation";
+import { auth } from "@/auth";
+import { db } from "@/lib/db";
+import { setRequestLocale } from "next-intl/server";
+import Link from "next/link";
+import { PathForm } from "../PathForm";
+import { PathCourseManager } from "./PathCourseManager";
+
+export const dynamic = "force-dynamic";
+
+export default async function EditPathPage({
+ params,
+}: {
+ params: Promise<{ locale: string; id: string }>;
+}) {
+ const { locale, id } = await params;
+ setRequestLocale(locale);
+ const session = await auth();
+
+ if (!session?.user || (session.user as any).role !== "ADMIN") {
+ redirect(`/${locale}/dashboard`);
+ }
+
+ const [path, allCourses] = await Promise.all([
+ db.learningPath.findUnique({
+ where: { id },
+ include: {
+ courses: {
+ include: { course: true },
+ orderBy: { order: "asc" },
+ },
+ },
+ }),
+ db.course.findMany({ orderBy: { titleFr: "asc" }, select: { id: true, titleFr: true, published: true } }),
+ ]);
+
+ if (!path) notFound();
+
+ return (
+
+
+
+ ← Parcours
+
+
+ {path.titleFr}
+
+
+
+
+
+
+
+
+ Formations du parcours
+
+
({ id: lpc.id, courseId: lpc.courseId, order: lpc.order, course: lpc.course }))}
+ allCourses={allCourses}
+ />
+
+
+
+ );
+}
diff --git a/src/app/[locale]/admin/paths/new/page.tsx b/src/app/[locale]/admin/paths/new/page.tsx
new file mode 100644
index 00000000..a228a452
--- /dev/null
+++ b/src/app/[locale]/admin/paths/new/page.tsx
@@ -0,0 +1,33 @@
+import { redirect } from "next/navigation";
+import { auth } from "@/auth";
+import { setRequestLocale } from "next-intl/server";
+import Link from "next/link";
+import { PathForm } from "../PathForm";
+
+export default async function NewPathPage({
+ params,
+}: {
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+ setRequestLocale(locale);
+ const session = await auth();
+
+ if (!session?.user || (session.user as any).role !== "ADMIN") {
+ redirect(`/${locale}/dashboard`);
+ }
+
+ return (
+
+
+
+ ← Parcours
+
+
+ Nouveau parcours
+
+
+
+
+ );
+}
diff --git a/src/app/[locale]/admin/paths/page.tsx b/src/app/[locale]/admin/paths/page.tsx
new file mode 100644
index 00000000..4fd625f8
--- /dev/null
+++ b/src/app/[locale]/admin/paths/page.tsx
@@ -0,0 +1,108 @@
+import { redirect } from "next/navigation";
+import { auth } from "@/auth";
+import { db } from "@/lib/db";
+import { setRequestLocale } from "next-intl/server";
+import Link from "next/link";
+import { AdminPathActions } from "./AdminPathActions";
+
+export const dynamic = "force-dynamic";
+
+export default async function AdminPathsPage({
+ params,
+}: {
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+ setRequestLocale(locale);
+ const session = await auth();
+
+ if (!session?.user || (session.user as any).role !== "ADMIN") {
+ redirect(`/${locale}/dashboard`);
+ }
+
+ const paths = await db.learningPath.findMany({
+ orderBy: { order: "asc" },
+ include: {
+ courses: true,
+ _count: { select: { enrollments: true } },
+ },
+ });
+
+ return (
+
+
+
+
+ ← Administration
+
+
+ Parcours de formation
+
+
+
+ + Nouveau parcours
+
+
+
+
+
+
+
+ | Titre (FR) |
+ Formations |
+ Inscrits |
+ Statut |
+ Actions |
+
+
+
+ {paths.length === 0 ? (
+
+ |
+ Aucun parcours.{" "}
+
+ Créer le premier.
+
+ |
+
+ ) : (
+ paths.map((path) => (
+
+
+
+ {path.titleFr}
+ {path.slug}
+
+ |
+ {path.courses.length} cours |
+ {path._count.enrollments} |
+
+
+ {path.published ? "Publié" : "Brouillon"}
+
+ |
+
+
+ |
+
+ ))
+ )}
+
+
+
+
+ );
+}
diff --git a/src/app/[locale]/admin/students/[id]/page.tsx b/src/app/[locale]/admin/students/[id]/page.tsx
new file mode 100644
index 00000000..50fddf75
--- /dev/null
+++ b/src/app/[locale]/admin/students/[id]/page.tsx
@@ -0,0 +1,217 @@
+import { redirect, notFound } from "next/navigation";
+import { auth } from "@/auth";
+import { db } from "@/lib/db";
+import { setRequestLocale } from "next-intl/server";
+import Link from "next/link";
+import { ProgressBar } from "@/components/ProgressBar";
+
+export const dynamic = "force-dynamic";
+
+export default async function StudentDetailPage({
+ params,
+}: {
+ params: Promise<{ locale: string; id: string }>;
+}) {
+ const { locale, id } = await params;
+ setRequestLocale(locale);
+ const session = await auth();
+
+ if (!session?.user || (session.user as any).role !== "ADMIN") {
+ redirect(`/${locale}/dashboard`);
+ }
+
+ const student = await db.user.findUnique({
+ where: { id },
+ include: {
+ enrollments: {
+ include: {
+ course: {
+ include: {
+ modules: { include: { lessons: { select: { id: true } } } },
+ },
+ },
+ },
+ orderBy: { enrolledAt: "desc" },
+ },
+ certificates: { include: { course: true }, orderBy: { issuedAt: "desc" } },
+ quizAttempts: { include: { quiz: { include: { module: { include: { course: true } } } } }, orderBy: { completedAt: "desc" } },
+ learningPathEnrollments: { include: { learningPath: true } },
+ },
+ });
+
+ if (!student) notFound();
+
+ // Compute progress for each enrollment
+ const enriched = await Promise.all(
+ student.enrollments.map(async (e) => {
+ const allLessons = e.course.modules.flatMap((m) => m.lessons);
+ let progress = 0;
+ if (allLessons.length > 0) {
+ const done = await db.lessonProgress.count({
+ where: { userId: student.id, lessonId: { in: allLessons.map((l) => l.id) } },
+ });
+ progress = Math.round((done / allLessons.length) * 100);
+ }
+ return { ...e, progress, totalLessons: allLessons.length };
+ })
+ );
+
+ return (
+
+
+
+ ← Étudiants
+
+
+
+ {(student.name || student.email)[0].toUpperCase()}
+
+
+
+ {student.name || "—"}
+
+
{student.email}
+
+
+
+
+ {/* Stats */}
+
+ {[
+ { label: "Formations inscrites", value: student.enrollments.length, color: "#1d4ed8" },
+ { label: "Formations terminées", value: enriched.filter((e) => e.completedAt).length, color: "#22c55e" },
+ { label: "Certificats", value: student.certificates.length, color: "#f59e0b" },
+ { label: "Parcours", value: student.learningPathEnrollments.length, color: "#8b5cf6" },
+ ].map((s) => (
+
+
{s.value}
+
{s.label}
+
+ ))}
+
+
+
+ {/* Enrollments */}
+
+ Formations
+ {enriched.length === 0 ? (
+ Aucune inscription.
+ ) : (
+
+
+
+
+ | Formation |
+ Progression |
+ Leçons |
+ Statut |
+ Inscrit le |
+
+
+
+ {enriched.map((e) => (
+
+ |
+
+ {e.course.titleFr}
+
+ |
+
+
+ {e.progress}%
+ |
+ {e.totalLessons} |
+
+
+ {e.completedAt ? "Terminé" : "En cours"}
+
+ |
+
+ {e.enrolledAt.toLocaleDateString("fr-FR")}
+ |
+
+ ))}
+
+
+
+ )}
+
+
+ {/* Learning paths */}
+ {student.learningPathEnrollments.length > 0 && (
+
+ Parcours
+
+ {student.learningPathEnrollments.map((lpe) => (
+
+ {lpe.learningPath.titleFr}
+ Inscrit le {lpe.enrolledAt.toLocaleDateString("fr-FR")}
+
+ ))}
+
+
+ )}
+
+ {/* Recent quiz attempts */}
+ {student.quizAttempts.length > 0 && (
+
+ Tentatives de quiz
+
+
+
+
+ | Formation · Module |
+ Score |
+ Résultat |
+ Date |
+
+
+
+ {student.quizAttempts.slice(0, 20).map((qa) => (
+
+ |
+ {qa.quiz.module.course.titleFr} · {qa.quiz.module.titleFr}
+ |
+
+ {qa.score}%
+ |
+
+
+ {qa.passed ? "Réussi" : "Échoué"}
+
+ |
+ {qa.completedAt.toLocaleDateString("fr-FR")} |
+
+ ))}
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/app/[locale]/admin/students/page.tsx b/src/app/[locale]/admin/students/page.tsx
index 170cac87..f8b4b459 100644
--- a/src/app/[locale]/admin/students/page.tsx
+++ b/src/app/[locale]/admin/students/page.tsx
@@ -95,7 +95,7 @@ export default async function AdminStudentsPage({
students.map((student) => (
-
+
{student.image ? (

{student.name ?? "—"}
-
+
|
{student.email} |
{student._count.enrollments} |
diff --git a/src/app/[locale]/paths/EnrollPathButton.tsx b/src/app/[locale]/paths/EnrollPathButton.tsx
new file mode 100644
index 00000000..3bddfa94
--- /dev/null
+++ b/src/app/[locale]/paths/EnrollPathButton.tsx
@@ -0,0 +1,43 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+
+export function EnrollPathButton({
+ pathId,
+ locale,
+ isLoggedIn,
+}: {
+ pathId: string;
+ locale: string;
+ isLoggedIn: boolean;
+}) {
+ const [loading, setLoading] = useState(false);
+ const router = useRouter();
+
+ const handleEnroll = async () => {
+ if (!isLoggedIn) {
+ router.push(`/${locale}/auth/login`);
+ return;
+ }
+ setLoading(true);
+ await fetch("/api/paths/enroll", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ learningPathId: pathId }),
+ });
+ router.refresh();
+ setLoading(false);
+ };
+
+ return (
+
+ );
+}
diff --git a/src/app/[locale]/paths/[slug]/page.tsx b/src/app/[locale]/paths/[slug]/page.tsx
new file mode 100644
index 00000000..1d719897
--- /dev/null
+++ b/src/app/[locale]/paths/[slug]/page.tsx
@@ -0,0 +1,196 @@
+import { auth } from "@/auth";
+import { db } from "@/lib/db";
+import { setRequestLocale } from "next-intl/server";
+import { notFound } from "next/navigation";
+import Link from "next/link";
+import { EnrollPathButton } from "../EnrollPathButton";
+import { ProgressBar } from "@/components/ProgressBar";
+
+export const dynamic = "force-dynamic";
+
+function getLocaleText(obj: any, locale: string, field: string) {
+ if (locale === "en") return obj[`${field}En`] || obj[`${field}Fr`];
+ if (locale === "es") return obj[`${field}Es`] || obj[`${field}Fr`];
+ return obj[`${field}Fr`];
+}
+
+const levelLabel: Record = {
+ BEGINNER: "Débutant",
+ INTERMEDIATE: "Intermédiaire",
+ ADVANCED: "Avancé",
+};
+
+export default async function PathDetailPage({
+ params,
+}: {
+ params: Promise<{ locale: string; slug: string }>;
+}) {
+ const { locale, slug } = await params;
+ setRequestLocale(locale);
+ const session = await auth();
+ const userId = (session?.user as any)?.id as string | undefined;
+
+ const path = await db.learningPath.findUnique({
+ where: { slug, published: true },
+ include: {
+ courses: {
+ include: {
+ course: {
+ include: {
+ modules: { include: { lessons: { select: { id: true } } } },
+ },
+ },
+ },
+ orderBy: { order: "asc" },
+ },
+ _count: { select: { enrollments: true } },
+ },
+ });
+
+ if (!path) notFound();
+
+ const isEnrolled = userId
+ ? !!(await db.learningPathEnrollment.findUnique({ where: { userId_learningPathId: { userId, learningPathId: path.id } } }))
+ : false;
+
+ // Get enrollment + progress for each course
+ const courseProgress: Record = {};
+ if (userId) {
+ for (const lpc of path.courses) {
+ const enrollment = await db.enrollment.findUnique({ where: { userId_courseId: { userId, courseId: lpc.courseId } } });
+ const allLessons = lpc.course.modules.flatMap((m) => m.lessons);
+ let progress = 0;
+ if (allLessons.length > 0 && enrollment) {
+ const done = await db.lessonProgress.count({ where: { userId, lessonId: { in: allLessons.map((l) => l.id) } } });
+ progress = Math.round((done / allLessons.length) * 100);
+ }
+ courseProgress[lpc.courseId] = { enrolled: !!enrollment, progress, completedAt: enrollment?.completedAt ?? null };
+ }
+ }
+
+ const title = getLocaleText(path, locale, "title");
+ const desc = getLocaleText(path, locale, "desc");
+ const totalLessons = path.courses.reduce((sum, lpc) => sum + lpc.course.modules.reduce((s, m) => s + m.lessons.length, 0), 0);
+
+ return (
+
+
+ ← Parcours
+
+
+ {/* Header */}
+
+
+
{title}
+
{desc}
+
+ 📚 {path.courses.length} formation{path.courses.length !== 1 ? "s" : ""}
+ 🎬 {totalLessons} leçons
+ 👥 {path._count.enrollments} inscrits
+
+
+
+ {isEnrolled ? (
+
+ ✓ Inscrit à ce parcours
+
+ ) : (
+
+ )}
+
+
+
+ {/* Course list */}
+
Formations incluses
+
+ {path.courses.map((lpc, i) => {
+ const courseTitle = getLocaleText(lpc.course, locale, "title");
+ const courseDesc = getLocaleText(lpc.course, locale, "desc");
+ const lessonCount = lpc.course.modules.reduce((s, m) => s + m.lessons.length, 0);
+ const cp = courseProgress[lpc.courseId];
+
+ return (
+
+ {/* Step number */}
+
+ {cp?.completedAt ? "✓" : i + 1}
+
+
+ {/* Info */}
+
+
+
{courseTitle}
+
+ {levelLabel[lpc.course.level] ?? lpc.course.level}
+
+
+
+ {courseDesc?.slice(0, 120)}{courseDesc?.length > 120 ? "…" : ""}
+
+ {cp?.enrolled && (
+ <>
+
+
{cp.progress}% complété
+ >
+ )}
+
+ {lpc.course.modules.length} module{lpc.course.modules.length !== 1 ? "s" : ""} · {lessonCount} leçon{lessonCount !== 1 ? "s" : ""}
+
+
+
+ {/* Action */}
+
+ {cp?.enrolled ? (
+
+ {cp.completedAt ? "Revoir" : cp.progress > 0 ? "Continuer →" : "Commencer →"}
+
+ ) : (
+
+ Voir la formation
+
+ )}
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/app/[locale]/paths/page.tsx b/src/app/[locale]/paths/page.tsx
new file mode 100644
index 00000000..98006fc6
--- /dev/null
+++ b/src/app/[locale]/paths/page.tsx
@@ -0,0 +1,159 @@
+import { auth } from "@/auth";
+import { db } from "@/lib/db";
+import { setRequestLocale } from "next-intl/server";
+import Link from "next/link";
+import { EnrollPathButton } from "./EnrollPathButton";
+
+export const dynamic = "force-dynamic";
+
+function getLocaleText(obj: any, locale: string, field: string) {
+ if (locale === "en") return obj[`${field}En`] || obj[`${field}Fr`];
+ if (locale === "es") return obj[`${field}Es`] || obj[`${field}Fr`];
+ return obj[`${field}Fr`];
+}
+
+const levelColors: Record = {
+ BEGINNER: "#4ade80",
+ INTERMEDIATE: "#fbbf24",
+ ADVANCED: "#f87171",
+};
+
+export default async function PathsPage({
+ params,
+}: {
+ params: Promise<{ locale: string }>;
+}) {
+ const { locale } = await params;
+ setRequestLocale(locale);
+ const session = await auth();
+ const userId = (session?.user as any)?.id as string | undefined;
+
+ const paths = await db.learningPath.findMany({
+ where: { published: true },
+ orderBy: { order: "asc" },
+ include: {
+ courses: {
+ include: {
+ course: {
+ include: { modules: { include: { lessons: { select: { id: true } } } } },
+ },
+ },
+ orderBy: { order: "asc" },
+ },
+ _count: { select: { enrollments: true } },
+ },
+ });
+
+ // Check which paths the user is enrolled in
+ const enrolledPathIds = userId
+ ? new Set(
+ (await db.learningPathEnrollment.findMany({ where: { userId }, select: { learningPathId: true } })).map(
+ (e) => e.learningPathId
+ )
+ )
+ : new Set();
+
+ return (
+
+
+
+ Parcours de formation
+
+
+ Des programmes complets pour maîtriser la GRC et l'application OwlCub.
+
+
+
+ {paths.length === 0 ? (
+
+
Aucun parcours disponible pour l'instant.
+
+ ) : (
+
+ {paths.map((path) => {
+ const title = getLocaleText(path, locale, "title");
+ const desc = getLocaleText(path, locale, "desc");
+ const totalLessons = path.courses.reduce((sum, lpc) => sum + lpc.course.modules.reduce((s, m) => s + m.lessons.length, 0), 0);
+ const isEnrolled = enrolledPathIds.has(path.id);
+
+ return (
+
+
+
+
{title}
+ {isEnrolled && (
+
+ Inscrit
+
+ )}
+
+
{desc}
+
+ {/* Course list */}
+
+ {path.courses.map((lpc, i) => {
+ const courseTitle = getLocaleText(lpc.course, locale, "title");
+ const lessonCount = lpc.course.modules.reduce((s, m) => s + m.lessons.length, 0);
+ return (
+
+
+ {i + 1}
+
+ {courseTitle}
+ · {lessonCount} leçon{lessonCount !== 1 ? "s" : ""}
+
+ {lpc.course.level}
+
+
+ );
+ })}
+
+
+
+ 📚 {path.courses.length} formation{path.courses.length !== 1 ? "s" : ""}
+ 🎬 {totalLessons} leçons
+ 👥 {path._count.enrollments} inscrits
+
+
+
+
+ {isEnrolled ? (
+
+ Continuer →
+
+ ) : (
+
+ )}
+
+ Voir le détail
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+}
diff --git a/src/app/api/admin/modules/[id]/route.ts b/src/app/api/admin/modules/[id]/route.ts
index 28e69ea7..cf179e51 100644
--- a/src/app/api/admin/modules/[id]/route.ts
+++ b/src/app/api/admin/modules/[id]/route.ts
@@ -8,6 +8,29 @@ async function requireAdmin() {
return session;
}
+export async function PUT(
+ req: NextRequest,
+ { params }: { params: Promise<{ id: string }> }
+) {
+ if (!(await requireAdmin())) {
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ }
+
+ const { id } = await params;
+ const body = await req.json();
+
+ const updated = await db.module.update({
+ where: { id },
+ data: {
+ titleFr: body.titleFr,
+ titleEn: body.titleEn ?? body.titleFr,
+ titleEs: body.titleEs ?? body.titleFr,
+ },
+ });
+
+ return NextResponse.json(updated);
+}
+
export async function DELETE(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
diff --git a/src/app/api/admin/paths/[id]/courses/route.ts b/src/app/api/admin/paths/[id]/courses/route.ts
new file mode 100644
index 00000000..e07f7d5c
--- /dev/null
+++ b/src/app/api/admin/paths/[id]/courses/route.ts
@@ -0,0 +1,40 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "@/auth";
+import { db } from "@/lib/db";
+
+async function requireAdmin() {
+ const session = await auth();
+ if (!session?.user || (session.user as any).role !== "ADMIN") return null;
+ return session;
+}
+
+// Add a course to the learning path
+export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const session = await requireAdmin();
+ if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+
+ const { id: learningPathId } = await params;
+ const { courseId } = await req.json();
+
+ // Get current max order
+ const maxOrder = await db.learningPathCourse.count({ where: { learningPathId } });
+
+ const lpc = await db.learningPathCourse.create({
+ data: { learningPathId, courseId, order: maxOrder },
+ include: { course: true },
+ });
+
+ return NextResponse.json(lpc);
+}
+
+// Remove a course from the learning path
+export async function DELETE(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const session = await requireAdmin();
+ if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+
+ const { id: learningPathId } = await params;
+ const { courseId } = await req.json();
+
+ await db.learningPathCourse.deleteMany({ where: { learningPathId, courseId } });
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/app/api/admin/paths/[id]/publish/route.ts b/src/app/api/admin/paths/[id]/publish/route.ts
new file mode 100644
index 00000000..9fb7d47d
--- /dev/null
+++ b/src/app/api/admin/paths/[id]/publish/route.ts
@@ -0,0 +1,25 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "@/auth";
+import { db } from "@/lib/db";
+
+async function requireAdmin() {
+ const session = await auth();
+ if (!session?.user || (session.user as any).role !== "ADMIN") return null;
+ return session;
+}
+
+export async function PUT(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const session = await requireAdmin();
+ if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+
+ const { id } = await params;
+ const current = await db.learningPath.findUnique({ where: { id }, select: { published: true } });
+ if (!current) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+ const updated = await db.learningPath.update({
+ where: { id },
+ data: { published: !current.published },
+ });
+
+ return NextResponse.json(updated);
+}
diff --git a/src/app/api/admin/paths/[id]/route.ts b/src/app/api/admin/paths/[id]/route.ts
new file mode 100644
index 00000000..e84bd4b3
--- /dev/null
+++ b/src/app/api/admin/paths/[id]/route.ts
@@ -0,0 +1,60 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "@/auth";
+import { db } from "@/lib/db";
+
+async function requireAdmin() {
+ const session = await auth();
+ if (!session?.user || (session.user as any).role !== "ADMIN") return null;
+ return session;
+}
+
+export async function GET(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const session = await requireAdmin();
+ if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+
+ const { id } = await params;
+ const path = await db.learningPath.findUnique({
+ where: { id },
+ include: {
+ courses: {
+ include: { course: true },
+ orderBy: { order: "asc" },
+ },
+ },
+ });
+
+ if (!path) return NextResponse.json({ error: "Not found" }, { status: 404 });
+ return NextResponse.json(path);
+}
+
+export async function PUT(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const session = await requireAdmin();
+ if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+
+ const { id } = await params;
+ const body = await req.json();
+
+ const path = await db.learningPath.update({
+ where: { id },
+ data: {
+ titleFr: body.titleFr,
+ titleEn: body.titleEn,
+ titleEs: body.titleEs,
+ descFr: body.descFr,
+ descEn: body.descEn,
+ descEs: body.descEs,
+ slug: body.slug,
+ },
+ });
+
+ return NextResponse.json(path);
+}
+
+export async function DELETE(_: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+ const session = await requireAdmin();
+ if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+
+ const { id } = await params;
+ await db.learningPath.delete({ where: { id } });
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/app/api/admin/paths/route.ts b/src/app/api/admin/paths/route.ts
new file mode 100644
index 00000000..9583e501
--- /dev/null
+++ b/src/app/api/admin/paths/route.ts
@@ -0,0 +1,65 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "@/auth";
+import { db } from "@/lib/db";
+
+async function requireAdmin() {
+ const session = await auth();
+ if (!session?.user || (session.user as any).role !== "ADMIN") return null;
+ return session;
+}
+
+function slugify(text: string) {
+ return text
+ .toLowerCase()
+ .replace(/[éèêë]/g, "e")
+ .replace(/[àâä]/g, "a")
+ .replace(/[îï]/g, "i")
+ .replace(/[ôö]/g, "o")
+ .replace(/[ùûü]/g, "u")
+ .replace(/[ç]/g, "c")
+ .replace(/[^a-z0-9]+/g, "-")
+ .replace(/^-|-$/g, "");
+}
+
+export async function GET() {
+ const session = await requireAdmin();
+ if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+
+ const paths = await db.learningPath.findMany({
+ orderBy: { order: "asc" },
+ include: {
+ courses: { include: { course: { select: { id: true, titleFr: true } } }, orderBy: { order: "asc" } },
+ _count: { select: { enrollments: true } },
+ },
+ });
+
+ return NextResponse.json(paths);
+}
+
+export async function POST(req: NextRequest) {
+ const session = await requireAdmin();
+ if (!session) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+
+ const body = await req.json();
+ const { titleFr, titleEn, titleEs, descFr, descEn, descEs, slug } = body;
+
+ if (!titleFr || !descFr) {
+ return NextResponse.json({ error: "Titre et description FR requis" }, { status: 400 });
+ }
+
+ const finalSlug = slug || slugify(titleFr);
+
+ const path = await db.learningPath.create({
+ data: {
+ titleFr,
+ titleEn: titleEn || titleFr,
+ titleEs: titleEs || titleFr,
+ descFr,
+ descEn: descEn || descFr,
+ descEs: descEs || descFr,
+ slug: finalSlug,
+ },
+ });
+
+ return NextResponse.json(path);
+}
diff --git a/src/app/api/paths/[slug]/route.ts b/src/app/api/paths/[slug]/route.ts
new file mode 100644
index 00000000..a2f733c5
--- /dev/null
+++ b/src/app/api/paths/[slug]/route.ts
@@ -0,0 +1,26 @@
+import { NextRequest, NextResponse } from "next/server";
+import { db } from "@/lib/db";
+
+export async function GET(_: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = await params;
+
+ const path = await db.learningPath.findUnique({
+ where: { slug, published: true },
+ include: {
+ courses: {
+ include: {
+ course: {
+ include: {
+ modules: { include: { lessons: { select: { id: true } } } },
+ },
+ },
+ },
+ orderBy: { order: "asc" },
+ },
+ _count: { select: { enrollments: true } },
+ },
+ });
+
+ if (!path) return NextResponse.json({ error: "Not found" }, { status: 404 });
+ return NextResponse.json(path);
+}
diff --git a/src/app/api/paths/enroll/route.ts b/src/app/api/paths/enroll/route.ts
new file mode 100644
index 00000000..8031b844
--- /dev/null
+++ b/src/app/api/paths/enroll/route.ts
@@ -0,0 +1,42 @@
+import { NextRequest, NextResponse } from "next/server";
+import { auth } from "@/auth";
+import { db } from "@/lib/db";
+
+export async function POST(req: NextRequest) {
+ const session = await auth();
+ if (!session?.user) return NextResponse.json({ error: "Non autorisé" }, { status: 401 });
+
+ const userId = (session.user as any).id as string;
+ const { learningPathId } = await req.json();
+
+ // Check path exists and is published
+ const path = await db.learningPath.findUnique({
+ where: { id: learningPathId, published: true },
+ include: {
+ courses: {
+ include: { course: true },
+ orderBy: { order: "asc" },
+ },
+ },
+ });
+
+ if (!path) return NextResponse.json({ error: "Parcours introuvable" }, { status: 404 });
+
+ // Enroll in the path
+ await db.learningPathEnrollment.upsert({
+ where: { userId_learningPathId: { userId, learningPathId } },
+ create: { userId, learningPathId },
+ update: {},
+ });
+
+ // Also auto-enroll in each course of the path
+ for (const lpc of path.courses) {
+ await db.enrollment.upsert({
+ where: { userId_courseId: { userId, courseId: lpc.courseId } },
+ create: { userId, courseId: lpc.courseId },
+ update: {},
+ });
+ }
+
+ return NextResponse.json({ ok: true });
+}
diff --git a/src/app/api/paths/route.ts b/src/app/api/paths/route.ts
new file mode 100644
index 00000000..7949bd2f
--- /dev/null
+++ b/src/app/api/paths/route.ts
@@ -0,0 +1,22 @@
+import { NextResponse } from "next/server";
+import { db } from "@/lib/db";
+
+export async function GET() {
+ const paths = await db.learningPath.findMany({
+ where: { published: true },
+ orderBy: { order: "asc" },
+ include: {
+ courses: {
+ include: {
+ course: {
+ select: { id: true, titleFr: true, titleEn: true, titleEs: true, level: true, thumbnailUrl: true },
+ },
+ },
+ orderBy: { order: "asc" },
+ },
+ _count: { select: { enrollments: true } },
+ },
+ });
+
+ return NextResponse.json(paths);
+}
diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx
index b928293d..9b895bf1 100644
--- a/src/components/Nav.tsx
+++ b/src/components/Nav.tsx
@@ -78,6 +78,7 @@ export function Nav({ locale, userRole, isLoggedIn }: NavProps) {
{/* Nav links */}
+
{isLoggedIn && (
)}