Initial commit: FL-Akademie LMS mit Docker, Admin, Portal und Dokumentation.

Made-with: Cursor
This commit is contained in:
lo
2026-04-13 23:17:07 +02:00
commit d3367f0046
66 changed files with 3641 additions and 0 deletions

View File

@@ -0,0 +1,148 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import {
createLessonAction,
createModuleAction,
updateCourseAction,
} from "@/app/admin/courses/actions";
type Props = { params: Promise<{ id: string }> };
export default async function AdminEditCoursePage({ params }: Props) {
const { id } = await params;
const course = await prisma.course.findUnique({
where: { id },
include: {
modules: {
orderBy: { sortOrder: "asc" },
include: { lessons: { orderBy: { sortOrder: "asc" } } },
},
},
});
if (!course) notFound();
const priceEuros = (course.priceCents / 100).toFixed(2);
return (
<div>
<p className="muted">
<Link href="/admin/courses"> Alle Kurse</Link> ·{" "}
<Link href={`/kurse/${course.slug}`}>Öffentliche Kursseite</Link>
</p>
<h1 className="page-title">Kurs bearbeiten</h1>
<p className="muted subtitle">{course.title}</p>
<form action={updateCourseAction.bind(null, course.id)} className="panel form" style={{ maxWidth: 820 }}>
<h2 style={{ marginTop: 0 }}>Stammdaten</h2>
<label>
Titel
<input name="title" defaultValue={course.title} required />
</label>
<label>
Slug
<input name="slug" defaultValue={course.slug} required />
</label>
<label>
Beschreibung
<textarea name="description" defaultValue={course.description} />
</label>
<label>
Anzeige-Autor
<input name="authorName" defaultValue={course.authorName} required />
</label>
<label>
Preis (EUR)
<input name="priceEuros" type="number" step="0.01" min="0" defaultValue={priceEuros} />
</label>
<label>
Abrechnungsintervall
<select name="billingInterval" defaultValue={course.billingInterval}>
<option value="NONE">Einmal / kein Abo</option>
<option value="MONTH">Monatlich</option>
<option value="QUARTER">Vierteljährlich</option>
<option value="YEAR">Jährlich</option>
</select>
</label>
<label className="stack" style={{ alignItems: "center" }}>
<span style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<input type="checkbox" name="published" defaultChecked={course.published} /> Veröffentlicht
</span>
</label>
<button type="submit" className="btn btn-primary">
Speichern
</button>
</form>
<div className="panel" style={{ marginTop: "1.25rem" }}>
<h2 style={{ marginTop: 0 }}>Neues Modul</h2>
<form action={createModuleAction.bind(null, course.id)} className="form" style={{ maxWidth: 720 }}>
<label>
Modultitel
<input name="title" required />
</label>
<button type="submit" className="btn btn-ghost">
Modul hinzufügen
</button>
</form>
</div>
<div className="panel" style={{ marginTop: "1.25rem" }}>
<h2 style={{ marginTop: 0 }}>Neue Lektion</h2>
<form action={createLessonAction.bind(null, course.id)} className="form" style={{ maxWidth: 720 }}>
<label>
Modul
<select name="moduleId" required defaultValue={course.modules[0]?.id ?? ""}>
<option value="" disabled>
Modul wählen
</option>
{course.modules.map((m) => (
<option key={m.id} value={m.id}>
{m.title}
</option>
))}
</select>
</label>
<label>
Lektionstitel
<input name="lessonTitle" required />
</label>
<label>
Slug (optional)
<input name="lessonSlug" placeholder="wird sonst aus dem Titel erzeugt" />
</label>
<label>
Inhalt (HTML)
<textarea name="contentHtml" placeholder="<p>…</p>" />
</label>
<button type="submit" className="btn btn-ghost">
Lektion hinzufügen
</button>
</form>
</div>
<div className="panel" style={{ marginTop: "1.25rem" }}>
<h2 style={{ marginTop: 0 }}>Kurrikulum</h2>
{course.modules.length === 0 ? (
<p className="muted">Noch keine Module.</p>
) : (
<ul className="curriculum">
{course.modules.map((m) => (
<li key={m.id}>
<div className="module-title">{m.title}</div>
<ul className="curriculum">
{m.lessons.map((l) => (
<li key={l.id}>
<Link href={`/kurse/${course.slug}/lektionen/${l.slug}`}>{l.title}</Link>{" "}
<span className="muted">({l.slug})</span>
</li>
))}
</ul>
</li>
))}
</ul>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,153 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import { prisma } from "@/lib/prisma";
import { slugify } from "@/lib/slug";
import { BillingInterval } from "@prisma/client";
async function assertAdmin() {
const session = await getServerSession(authOptions);
if (!session?.user?.id || session.user.role !== "ADMIN") {
throw new Error("Keine Berechtigung.");
}
return session;
}
function parseBilling(v: FormDataEntryValue | null): BillingInterval {
const s = String(v ?? "NONE");
if (s === "MONTH" || s === "QUARTER" || s === "YEAR") return s;
return BillingInterval.NONE;
}
export async function createCourseAction(formData: FormData) {
const session = await assertAdmin();
const title = String(formData.get("title") ?? "").trim();
if (!title) redirect("/admin/courses/new?error=title");
let slug = String(formData.get("slug") ?? "").trim();
if (!slug) slug = slugify(title);
const exists = await prisma.course.findUnique({ where: { slug } });
if (exists) redirect(`/admin/courses/new?error=slug`);
const description = String(formData.get("description") ?? "").trim();
const authorName = String(formData.get("authorName") ?? "").trim() || session.user.name || "Admin";
const published = String(formData.get("published") ?? "") === "on";
const priceEuros = Number(String(formData.get("priceEuros") ?? "0").replace(",", "."));
const priceCents = Number.isFinite(priceEuros) ? Math.max(0, Math.round(priceEuros * 100)) : 0;
const course = await prisma.course.create({
data: {
slug,
title,
description,
published,
priceCents,
billingInterval: parseBilling(formData.get("billingInterval")),
authorId: session.user.id,
authorName,
},
});
revalidatePath("/kurse");
revalidatePath("/admin/courses");
redirect(`/admin/courses/${course.id}/edit`);
}
export async function updateCourseAction(courseId: string, formData: FormData) {
const session = await assertAdmin();
const title = String(formData.get("title") ?? "").trim();
if (!title) redirect(`/admin/courses/${courseId}/edit?error=title`);
let slug = String(formData.get("slug") ?? "").trim();
if (!slug) slug = slugify(title);
const clash = await prisma.course.findFirst({
where: { slug, NOT: { id: courseId } },
});
if (clash) redirect(`/admin/courses/${courseId}/edit?error=slug`);
const description = String(formData.get("description") ?? "").trim();
const authorName = String(formData.get("authorName") ?? "").trim() || session.user.name || "Admin";
const published = String(formData.get("published") ?? "") === "on";
const priceEuros = Number(String(formData.get("priceEuros") ?? "0").replace(",", "."));
const priceCents = Number.isFinite(priceEuros) ? Math.max(0, Math.round(priceEuros * 100)) : 0;
await prisma.course.update({
where: { id: courseId },
data: {
slug,
title,
description,
published,
priceCents,
billingInterval: parseBilling(formData.get("billingInterval")),
authorName,
},
});
revalidatePath("/kurse");
revalidatePath("/admin/courses");
revalidatePath(`/kurse/${slug}`);
redirect(`/admin/courses/${courseId}/edit?saved=1`);
}
export async function createModuleAction(courseId: string, formData: FormData) {
await assertAdmin();
const title = String(formData.get("title") ?? "").trim();
if (!title) redirect(`/admin/courses/${courseId}/edit?error=module`);
const max = await prisma.courseModule.aggregate({
where: { courseId },
_max: { sortOrder: true },
});
const sortOrder = (max._max.sortOrder ?? -1) + 1;
await prisma.courseModule.create({
data: { courseId, title, sortOrder },
});
revalidatePath(`/admin/courses/${courseId}/edit`);
redirect(`/admin/courses/${courseId}/edit`);
}
export async function createLessonAction(courseId: string, formData: FormData) {
await assertAdmin();
const moduleId = String(formData.get("moduleId") ?? "").trim();
const title = String(formData.get("lessonTitle") ?? "").trim();
if (!moduleId || !title) redirect(`/admin/courses/${courseId}/edit?error=lesson`);
const mod = await prisma.courseModule.findFirst({
where: { id: moduleId, courseId },
});
if (!mod) redirect(`/admin/courses/${courseId}/edit?error=lesson`);
let slug = String(formData.get("lessonSlug") ?? "").trim();
if (!slug) slug = slugify(title);
const contentHtml = String(formData.get("contentHtml") ?? "").trim();
const max = await prisma.lesson.aggregate({
where: { moduleId },
_max: { sortOrder: true },
});
const sortOrder = (max._max.sortOrder ?? -1) + 1;
const existing = await prisma.lesson.findUnique({
where: { moduleId_slug: { moduleId, slug } },
});
if (existing) redirect(`/admin/courses/${courseId}/edit?error=lessonSlug`);
await prisma.lesson.create({
data: { moduleId, slug, title, contentHtml, sortOrder, published: true },
});
const course = await prisma.course.findUnique({ where: { id: courseId }, select: { slug: true } });
if (course?.slug) revalidatePath(`/kurse/${course.slug}`);
revalidatePath(`/admin/courses/${courseId}/edit`);
redirect(`/admin/courses/${courseId}/edit`);
}

View File

@@ -0,0 +1,54 @@
import Link from "next/link";
import { createCourseAction } from "@/app/admin/courses/actions";
export default function AdminNewCoursePage() {
return (
<div>
<p className="muted">
<Link href="/admin/courses"> Zurück</Link>
</p>
<h1 className="page-title">Neuer Kurs</h1>
<p className="muted subtitle">Nach dem Anlegen kannst du Module und Lektionen hinzufügen.</p>
<form action={createCourseAction} className="panel form">
<label>
Titel
<input name="title" required />
</label>
<label>
Slug (optional, sonst aus Titel)
<input name="slug" placeholder="z.b. modul-3-sicherheit" />
</label>
<label>
Kurzbeschreibung
<textarea name="description" />
</label>
<label>
Anzeige-Autor
<input name="authorName" placeholder="z.B. MatzeFix" />
</label>
<label>
Preis (EUR, 0 = kostenlos)
<input name="priceEuros" type="number" step="0.01" min="0" defaultValue="0" />
</label>
<label>
Abrechnungsintervall
<select name="billingInterval" defaultValue="NONE">
<option value="NONE">Einmal / kein Abo</option>
<option value="MONTH">Monatlich</option>
<option value="QUARTER">Vierteljährlich</option>
<option value="YEAR">Jährlich</option>
</select>
</label>
<label className="stack" style={{ alignItems: "center" }}>
<span style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
<input type="checkbox" name="published" /> Veröffentlicht
</span>
</label>
<button type="submit" className="btn btn-primary">
Kurs anlegen
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,64 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { formatMoney, billingLabel } from "@/lib/format";
export default async function AdminCoursesPage() {
const courses = await prisma.course.findMany({
orderBy: { updatedAt: "desc" },
include: { _count: { select: { enrollments: true, modules: true } } },
});
return (
<div>
<div className="stack" style={{ justifyContent: "space-between", marginBottom: "1rem" }}>
<div>
<h1 className="page-title">Kurse</h1>
<p className="muted subtitle">Alle Kurse inkl. Entwürfe</p>
</div>
<Link href="/admin/courses/new" className="btn btn-primary">
Neuer Kurs
</Link>
</div>
<div className="table-wrap">
<table className="simple">
<thead>
<tr>
<th>Titel</th>
<th>Slug</th>
<th>Status</th>
<th>Preis</th>
<th>Module</th>
<th>Einschreibungen</th>
<th />
</tr>
</thead>
<tbody>
{courses.map((c) => (
<tr key={c.id}>
<td>{c.title}</td>
<td className="muted">{c.slug}</td>
<td>{c.published ? "Live" : "Entwurf"}</td>
<td>
{c.priceCents === 0 ? (
"kostenlos"
) : (
<>
{formatMoney(c.priceCents, c.currency)}
{billingLabel(c.billingInterval) ? ` ${billingLabel(c.billingInterval)}` : ""}
</>
)}
</td>
<td>{c._count.modules}</td>
<td>{c._count.enrollments}</td>
<td>
<Link href={`/admin/courses/${c.id}/edit`}>Bearbeiten</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,51 @@
"use server";
import { revalidatePath } from "next/cache";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import type { LandingContentV1 } from "@/lib/landing";
import { saveLandingContent } from "@/lib/landing";
async function assertAdmin() {
const session = await getServerSession(authOptions);
if (!session?.user?.id || session.user.role !== "ADMIN") {
throw new Error("Keine Berechtigung.");
}
}
export async function updateLandingAction(formData: FormData) {
await assertAdmin();
const heroTitle = String(formData.get("heroTitle") ?? "").trim();
const heroLead = String(formData.get("heroLead") ?? "").trim();
const primaryLabel = String(formData.get("primaryLabel") ?? "").trim();
const primaryHref = String(formData.get("primaryHref") ?? "").trim();
const secondaryLabel = String(formData.get("secondaryLabel") ?? "").trim();
const secondaryHref = String(formData.get("secondaryHref") ?? "").trim();
const benefitSectionTitle = String(formData.get("benefitSectionTitle") ?? "").trim();
const benefits: { title: string; body: string }[] = [];
for (let i = 1; i <= 6; i++) {
const title = String(formData.get(`benefit${i}Title`) ?? "").trim();
const body = String(formData.get(`benefit${i}Body`) ?? "").trim();
if (title || body) benefits.push({ title, body });
}
const content: LandingContentV1 = {
version: 1,
heroTitle,
heroLead,
primaryCta: {
label: primaryLabel || "Zu den Kursen",
href: primaryHref || "/kurse",
},
secondaryCta:
secondaryLabel && secondaryHref ? { label: secondaryLabel, href: secondaryHref } : undefined,
benefitSectionTitle: benefitSectionTitle || "Deine Vorteile",
benefits,
};
await saveLandingContent(content);
revalidatePath("/");
revalidatePath("/admin/landing");
}

View File

@@ -0,0 +1,75 @@
import { getLandingContent } from "@/lib/landing";
import { updateLandingAction } from "@/app/admin/landing/actions";
export default async function AdminLandingPage() {
const c = await getLandingContent();
const benefits = [...c.benefits];
while (benefits.length < 6) benefits.push({ title: "", body: "" });
return (
<div>
<h1 className="page-title">Startseite bearbeiten</h1>
<p className="muted subtitle">
Änderungen sind nach dem Speichern sofort auf der öffentlichen Startseite sichtbar.
</p>
<form action={updateLandingAction} className="panel form" style={{ maxWidth: 820 }}>
<label>
Hero-Titel
<input name="heroTitle" defaultValue={c.heroTitle} required />
</label>
<label>
Hero-Text
<textarea name="heroLead" defaultValue={c.heroLead} required />
</label>
<div className="stack" style={{ gap: "1rem" }}>
<label style={{ flex: "1 1 220px" }}>
Primär-Button Text
<input name="primaryLabel" defaultValue={c.primaryCta.label} required />
</label>
<label style={{ flex: "1 1 220px" }}>
Primär-Button Link
<input name="primaryHref" defaultValue={c.primaryCta.href} required />
</label>
</div>
<div className="stack" style={{ gap: "1rem" }}>
<label style={{ flex: "1 1 220px" }}>
Sekundär-Button Text (optional)
<input name="secondaryLabel" defaultValue={c.secondaryCta?.label ?? ""} />
</label>
<label style={{ flex: "1 1 220px" }}>
Sekundär-Button Link (optional)
<input name="secondaryHref" defaultValue={c.secondaryCta?.href ?? ""} />
</label>
</div>
<label>
Abschnittstitel Vorteile
<input name="benefitSectionTitle" defaultValue={c.benefitSectionTitle} required />
</label>
{benefits.map((b, idx) => (
<div key={idx} className="panel" style={{ padding: "1rem" }}>
<div className="muted" style={{ marginBottom: "0.5rem", fontWeight: 700 }}>
Vorteil {idx + 1}
</div>
<label>
Titel
<input name={`benefit${idx + 1}Title`} defaultValue={b.title} />
</label>
<label>
Text
<textarea name={`benefit${idx + 1}Body`} defaultValue={b.body} />
</label>
</div>
))}
<button type="submit" className="btn btn-primary">
Speichern
</button>
</form>
</div>
);
}

25
app/admin/layout.tsx Normal file
View File

@@ -0,0 +1,25 @@
import Link from "next/link";
import { requireAdmin } from "@/lib/session-helpers";
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
await requireAdmin();
return (
<section className="section">
<div className="container layout-split">
<aside className="side-nav" aria-label="Administration">
<strong style={{ display: "block", padding: "0.35rem 0.65rem", marginBottom: "0.35rem" }}>
Admin
</strong>
<Link href="/admin">Übersicht</Link>
<Link href="/admin/users">Nutzer</Link>
<Link href="/admin/landing">Startseite</Link>
<Link href="/admin/courses">Kurse</Link>
<Link href="/portal">Mitgliederbereich</Link>
<Link href="/">Öffentliche Website</Link>
</aside>
<div>{children}</div>
</div>
</section>
);
}

24
app/admin/page.tsx Normal file
View File

@@ -0,0 +1,24 @@
import Link from "next/link";
export default function AdminHomePage() {
return (
<div>
<h1 className="page-title">Administration</h1>
<p className="muted subtitle">Verwaltung der Akademie-Inhalte und Nutzer.</p>
<div className="course-grid">
<Link href="/admin/courses" className="panel" style={{ display: "block" }}>
<h2 style={{ marginTop: 0 }}>Kurse</h2>
<p className="muted">Neue Kurse anlegen und bestehende bearbeiten.</p>
</Link>
<Link href="/admin/landing" className="panel" style={{ display: "block" }}>
<h2 style={{ marginTop: 0 }}>Startseite</h2>
<p className="muted">Hero, Texte und Vorteils-Kacheln der Landing Page.</p>
</Link>
<Link href="/admin/users" className="panel" style={{ display: "block" }}>
<h2 style={{ marginTop: 0 }}>Nutzer</h2>
<p className="muted">Übersicht über registrierte Accounts.</p>
</Link>
</div>
</div>
);
}

50
app/admin/users/page.tsx Normal file
View File

@@ -0,0 +1,50 @@
import { prisma } from "@/lib/prisma";
export default async function AdminUsersPage() {
const users = await prisma.user.findMany({
orderBy: { createdAt: "desc" },
select: {
id: true,
email: true,
name: true,
role: true,
createdAt: true,
_count: { select: { enrollments: true, certificates: true } },
},
});
return (
<div>
<h1 className="page-title">Nutzer</h1>
<p className="muted subtitle">{users.length} Accounts</p>
<div className="table-wrap">
<table className="simple">
<thead>
<tr>
<th>Name</th>
<th>E-Mail</th>
<th>Rolle</th>
<th>Kurse</th>
<th>Zertifikate</th>
<th>Seit</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id}>
<td>{u.name}</td>
<td>{u.email}</td>
<td>
<span className="badge">{u.role}</span>
</td>
<td>{u._count.enrollments}</td>
<td>{u._count.certificates}</td>
<td className="muted">{u.createdAt.toLocaleDateString("de-DE")}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,6 @@
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth-options";
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

38
app/api/enroll/route.ts Normal file
View File

@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import { prisma } from "@/lib/prisma";
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
const body = (await req.json().catch(() => null)) as { courseSlug?: string } | null;
const courseSlug = body?.courseSlug?.trim();
if (!courseSlug) {
return NextResponse.json({ error: "courseSlug fehlt." }, { status: 400 });
}
const course = await prisma.course.findFirst({
where: { slug: courseSlug, published: true },
});
if (!course) {
return NextResponse.json({ error: "Kurs nicht gefunden." }, { status: 404 });
}
if (course.priceCents > 0) {
return NextResponse.json({ error: "payment_required" }, { status: 402 });
}
await prisma.enrollment.upsert({
where: {
userId_courseId: { userId: session.user.id, courseId: course.id },
},
update: {},
create: { userId: session.user.id, courseId: course.id },
});
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,34 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import bcrypt from "bcryptjs";
import { authOptions } from "@/lib/auth-options";
import { prisma } from "@/lib/prisma";
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
const body = (await req.json().catch(() => null)) as {
currentPassword?: string;
newPassword?: string;
} | null;
const currentPassword = body?.currentPassword ?? "";
const newPassword = body?.newPassword ?? "";
if (!currentPassword || newPassword.length < 8) {
return NextResponse.json({ error: "Ungültige Eingaben." }, { status: 400 });
}
const user = await prisma.user.findUnique({ where: { id: session.user.id } });
if (!user) return NextResponse.json({ error: "Nutzer nicht gefunden." }, { status: 404 });
const ok = await bcrypt.compare(currentPassword, user.passwordHash);
if (!ok) return NextResponse.json({ error: "Aktuelles Passwort ist falsch." }, { status: 403 });
const passwordHash = await bcrypt.hash(newPassword, 10);
await prisma.user.update({ where: { id: user.id }, data: { passwordHash } });
return NextResponse.json({ ok: true });
}

View File

@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import { prisma } from "@/lib/prisma";
import { clearCourseProgress } from "@/lib/certificates";
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
const body = (await req.json().catch(() => null)) as { courseId?: string } | null;
const courseId = body?.courseId?.trim();
if (!courseId) return NextResponse.json({ error: "courseId fehlt." }, { status: 400 });
const enrollment = await prisma.enrollment.findUnique({
where: { userId_courseId: { userId: session.user.id, courseId } },
});
if (!enrollment) return NextResponse.json({ error: "Nicht eingeschrieben." }, { status: 403 });
await clearCourseProgress(session.user.id, courseId);
return NextResponse.json({ ok: true });
}

57
app/api/progress/route.ts Normal file
View File

@@ -0,0 +1,57 @@
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import { prisma } from "@/lib/prisma";
import { syncCertificateForCourse } from "@/lib/certificates";
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
return NextResponse.json({ error: "Nicht angemeldet." }, { status: 401 });
}
const body = (await req.json().catch(() => null)) as {
lessonId?: string;
completed?: boolean;
} | null;
const lessonId = body?.lessonId?.trim();
if (!lessonId) {
return NextResponse.json({ error: "lessonId fehlt." }, { status: 400 });
}
const lesson = await prisma.lesson.findUnique({
where: { id: lessonId },
include: { module: { include: { course: true } } },
});
if (!lesson) {
return NextResponse.json({ error: "Lektion nicht gefunden." }, { status: 404 });
}
const courseId = lesson.module.course.id;
const enrollment = await prisma.enrollment.findUnique({
where: { userId_courseId: { userId: session.user.id, courseId } },
});
if (!enrollment) {
return NextResponse.json({ error: "Nicht eingeschrieben." }, { status: 403 });
}
const completed =
typeof body?.completed === "boolean" ? body.completed : true;
if (completed) {
await prisma.lessonProgress.upsert({
where: { userId_lessonId: { userId: session.user.id, lessonId } },
update: { completedAt: new Date() },
create: { userId: session.user.id, lessonId, completedAt: new Date() },
});
await syncCertificateForCourse(session.user.id, courseId);
} else {
await prisma.lessonProgress.deleteMany({
where: { userId: session.user.id, lessonId },
});
await syncCertificateForCourse(session.user.id, courseId);
}
return NextResponse.json({ ok: true });
}

5
app/dashboard/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function LegacyDashboardRedirect() {
redirect("/portal");
}

505
app/globals.css Normal file
View File

@@ -0,0 +1,505 @@
:root {
--bg: #ffffff;
--bg-muted: #f4f6f8;
--bg-elevated: #ffffff;
--border: #e2e8f0;
--text: #0f172a;
--muted: #475569;
--accent: #1d4ed8;
--accent-soft: #eff6ff;
--accent-hover: #1e40af;
--radius: 14px;
--shadow-sm: 0 1px 2px rgba(15, 23, 42, 0.06);
--shadow-md: 0 10px 30px rgba(15, 23, 42, 0.08);
--font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Ubuntu, Cantarell, "Noto Sans",
sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
min-height: 100%;
background: var(--bg-muted);
color: var(--text);
font-family: var(--font);
line-height: 1.65;
}
a {
color: var(--accent);
text-decoration: none;
}
a:hover {
color: var(--accent-hover);
}
.container {
width: min(1120px, 100% - 2rem);
margin-inline: auto;
}
.site-header {
border-bottom: 1px solid var(--border);
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(10px);
position: sticky;
top: 0;
z-index: 20;
box-shadow: var(--shadow-sm);
}
.header-inner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.9rem 0;
}
.logo {
font-weight: 800;
letter-spacing: -0.02em;
color: var(--text);
}
.logo span {
font-weight: 600;
color: var(--muted);
}
.nav {
display: flex;
align-items: center;
gap: 1.1rem;
font-size: 0.95rem;
}
.nav a:not(.btn) {
color: var(--text);
}
.nav a:not(.btn):hover {
color: var(--accent);
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.55rem 1.05rem;
border-radius: 999px;
border: 1px solid transparent;
font-weight: 600;
font-size: 0.9rem;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s;
}
.btn-primary {
background: var(--text);
color: #ffffff;
box-shadow: var(--shadow-sm);
}
.btn-primary:hover {
background: #111c33;
color: #ffffff;
}
.btn-ghost {
border-color: var(--border);
background: #ffffff;
color: var(--text);
}
.btn-ghost:hover {
border-color: #cbd5e1;
background: var(--bg-muted);
}
.btn-danger {
border-color: #fecaca;
background: #fff1f2;
color: #9f1239;
}
.btn-danger:hover {
border-color: #fca5a5;
background: #ffe4e6;
}
.hero {
padding: 3.25rem 0 2rem;
text-align: center;
background: linear-gradient(180deg, #ffffff 0%, #f4f6f8 100%);
border-bottom: 1px solid var(--border);
}
.hero h1 {
font-size: clamp(2rem, 4vw, 2.65rem);
margin: 0 0 1rem;
letter-spacing: -0.03em;
color: var(--text);
}
.hero .lead {
color: var(--muted);
font-size: 1.12rem;
max-width: 44rem;
margin: 0 auto 1.5rem;
}
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
justify-content: center;
}
.section {
padding: 2.5rem 0 3rem;
}
.section h2 {
margin: 0 0 1.25rem;
font-size: 1.45rem;
letter-spacing: -0.02em;
}
.course-grid {
display: grid;
gap: 1.25rem;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
.course-card {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-elevated);
display: flex;
flex-direction: column;
min-height: 100%;
box-shadow: var(--shadow-sm);
}
.course-card-body {
padding: 1.25rem;
flex: 1;
}
.course-card-body h3 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.course-meta {
margin: 0 0 0.75rem;
color: var(--muted);
font-size: 0.9rem;
}
.course-rating {
font-size: 0.9rem;
}
.stars {
color: #ca8a04;
}
.course-price {
margin: 0.75rem 0 0;
font-weight: 700;
}
.course-card-actions {
padding: 0 1.25rem 1.25rem;
}
.course-card-actions .btn-primary {
width: 100%;
}
.muted {
color: var(--muted);
}
.site-footer {
border-top: 1px solid var(--border);
margin-top: 2rem;
padding: 2rem 0;
font-size: 0.9rem;
background: #ffffff;
}
.footer-inner {
display: flex;
flex-wrap: wrap;
gap: 1rem;
justify-content: space-between;
align-items: center;
}
.footer-nav .sep {
margin: 0 0.5rem;
color: var(--muted);
}
.panel {
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg-elevated);
padding: 1.5rem;
box-shadow: var(--shadow-sm);
}
.two-col {
display: grid;
gap: 1.5rem;
}
@media (min-width: 900px) {
.two-col {
grid-template-columns: 280px 1fr;
align-items: start;
}
}
.curriculum {
list-style: none;
margin: 0;
padding: 0;
}
.curriculum li {
margin-bottom: 0.35rem;
}
.curriculum a {
display: block;
padding: 0.35rem 0.5rem;
border-radius: 8px;
color: var(--text);
}
.curriculum a:hover {
background: var(--accent-soft);
}
.curriculum a.active {
background: var(--accent-soft);
color: var(--accent-hover);
font-weight: 600;
}
.module-title {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--muted);
margin: 1rem 0 0.35rem;
}
.module-title:first-child {
margin-top: 0;
}
.prose {
max-width: 65ch;
}
.prose ul {
padding-left: 1.25rem;
}
.form {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 520px;
}
.form label {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.9rem;
color: var(--text);
}
.form input,
.form textarea,
.form select {
padding: 0.6rem 0.75rem;
border-radius: 10px;
border: 1px solid var(--border);
background: #ffffff;
color: var(--text);
font: inherit;
}
.form textarea {
min-height: 120px;
resize: vertical;
}
.form .error,
.error {
color: #b91c1c;
font-size: 0.85rem;
}
.table-wrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: var(--radius);
background: #ffffff;
}
table.simple {
width: 100%;
border-collapse: collapse;
font-size: 0.92rem;
}
table.simple th,
table.simple td {
padding: 0.65rem 0.85rem;
border-bottom: 1px solid var(--border);
text-align: left;
vertical-align: top;
}
table.simple th {
background: var(--bg-muted);
font-weight: 700;
}
.badge {
display: inline-flex;
align-items: center;
padding: 0.15rem 0.55rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
border: 1px solid var(--border);
background: #ffffff;
}
.badge-admin {
border-color: #bfdbfe;
background: #eff6ff;
color: #1d4ed8;
}
.layout-split {
display: grid;
gap: 1.25rem;
}
@media (min-width: 960px) {
.layout-split {
grid-template-columns: 240px 1fr;
align-items: start;
}
}
.side-nav {
border: 1px solid var(--border);
border-radius: var(--radius);
background: #ffffff;
padding: 0.75rem;
box-shadow: var(--shadow-sm);
}
.side-nav a {
display: block;
padding: 0.55rem 0.65rem;
border-radius: 10px;
color: var(--text);
}
.side-nav a:hover {
background: var(--bg-muted);
}
.side-nav a.active {
background: var(--accent-soft);
color: var(--accent-hover);
font-weight: 700;
}
.progress {
height: 10px;
border-radius: 999px;
background: var(--bg-muted);
border: 1px solid var(--border);
overflow: hidden;
}
.progress > span {
display: block;
height: 100%;
background: linear-gradient(90deg, #1d4ed8, #2563eb);
}
.cert-print {
max-width: 900px;
margin: 0 auto;
background: #ffffff;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2.25rem;
box-shadow: var(--shadow-md);
}
.cert-print h1 {
margin: 0 0 0.5rem;
letter-spacing: 0.04em;
text-transform: uppercase;
font-size: 1.1rem;
color: var(--muted);
}
.cert-print .name {
font-size: 2rem;
margin: 0.75rem 0;
letter-spacing: -0.02em;
}
.stack {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.page-title {
margin: 0 0 0.35rem;
letter-spacing: -0.02em;
}
.subtitle {
margin: 0 0 1.25rem;
}
@media print {
.site-header,
.site-footer,
.no-print {
display: none !important;
}
body {
background: #ffffff !important;
}
.section {
padding: 0 !important;
}
}

View File

@@ -0,0 +1,81 @@
import Link from "next/link";
import { notFound, redirect } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import { getLessonContext } from "@/lib/course-queries";
import { prisma } from "@/lib/prisma";
import { CompleteLessonButton } from "@/components/complete-lesson-button";
type Props = { params: Promise<{ slug: string; lessonSlug: string }> };
export default async function LessonPage({ params }: Props) {
const { slug: courseSlug, lessonSlug } = await params;
const session = await getServerSession(authOptions);
if (!session?.user?.id) {
redirect(`/login?callbackUrl=/kurse/${courseSlug}/lektionen/${lessonSlug}`);
}
const ctx = await getLessonContext(courseSlug, lessonSlug);
if (!ctx) notFound();
const enrollment = await prisma.enrollment.findUnique({
where: {
userId_courseId: { userId: session.user.id, courseId: ctx.course.id },
},
});
if (!enrollment) {
redirect(`/kurse/${ctx.course.slug}`);
}
const lessonIds = ctx.course.modules.flatMap((m) => m.lessons.map((l) => l.id));
const progressRows = await prisma.lessonProgress.findMany({
where: { userId: session.user.id, lessonId: { in: lessonIds } },
});
const completed = new Set(
progressRows.filter((p) => p.completedAt).map((p) => p.lessonId),
);
const currentDone = !!completed.has(ctx.lesson.id);
return (
<section className="section">
<div className="container">
<p className="muted">
<Link href={`/kurse/${ctx.course.slug}`}> {ctx.course.title}</Link>
</p>
<div className="two-col">
<nav className="panel" aria-label="Kurrikulum">
{ctx.course.modules.map((m) => (
<div key={m.id}>
<div className="module-title">{m.title}</div>
<ul className="curriculum">
{m.lessons.map((l) => {
const href = `/kurse/${ctx.course.slug}/lektionen/${l.slug}`;
const active = l.id === ctx.lesson.id;
return (
<li key={l.id}>
<Link href={href} className={active ? "active" : undefined}>
{completed.has(l.id) ? "✓ " : ""}
{l.title}
</Link>
</li>
);
})}
</ul>
</div>
))}
</nav>
<article className="panel prose">
<h1 style={{ marginTop: 0 }}>{ctx.lesson.title}</h1>
{ctx.lesson.videoUrl ? (
<p className="muted">
Video: <a href={ctx.lesson.videoUrl}>{ctx.lesson.videoUrl}</a>
</p>
) : null}
<div dangerouslySetInnerHTML={{ __html: ctx.lesson.contentHtml }} />
<CompleteLessonButton lessonId={ctx.lesson.id} initialCompleted={currentDone} />
</article>
</div>
</div>
</section>
);
}

90
app/kurse/[slug]/page.tsx Normal file
View File

@@ -0,0 +1,90 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import { getCourseBySlug, firstLessonPath } from "@/lib/course-queries";
import { formatMoney, billingLabel } from "@/lib/format";
import { prisma } from "@/lib/prisma";
import { EnrollButton } from "@/components/enroll-button";
type Props = { params: Promise<{ slug: string }> };
export default async function CoursePage({ params }: Props) {
const { slug } = await params;
const course = await getCourseBySlug(slug);
if (!course) notFound();
const session = await getServerSession(authOptions);
const enrollment =
session?.user?.id &&
(await prisma.enrollment.findUnique({
where: { userId_courseId: { userId: session.user.id, courseId: course.id } },
}));
const startPath = firstLessonPath(course);
const cats = course.categories.map((c) => c.category.name).join(", ");
const priceSuffix = billingLabel(course.billingInterval);
const isFree = course.priceCents === 0;
return (
<section className="section">
<div className="container">
<p className="muted">
<Link href="/kurse"> Alle Kurse</Link>
</p>
<h1>{course.title}</h1>
<p className="course-meta">
Von <strong>{course.authorName}</strong>
{cats ? <> · {cats}</> : null}
</p>
{!isFree && (
<p className="course-price">
{formatMoney(course.priceCents, course.currency)}
{priceSuffix ? ` ${priceSuffix}` : ""}{" "}
<span className="muted">(Zahlungsanbindung in Arbeit aktuell nicht kaufbar)</span>
</p>
)}
<div style={{ margin: "1.5rem 0" }}>
{!session ? (
<Link href={`/login?callbackUrl=/kurse/${course.slug}`} className="btn btn-primary">
Anmelden, um fortzufahren
</Link>
) : enrollment && startPath ? (
<Link href={startPath} className="btn btn-primary">
Mit dem Lernen beginnen
</Link>
) : enrollment ? (
<span className="muted">Noch keine Lektionen.</span>
) : isFree ? (
<EnrollButton courseSlug={course.slug} />
) : (
<p className="muted">Kostenpflichtige Kurse werden über Stripe angebunden.</p>
)}
</div>
<div className="panel">
<h2>Kurrikulum</h2>
{course.description ? <p>{course.description}</p> : null}
<ul className="curriculum">
{course.modules.map((m) => (
<li key={m.id}>
<div className="module-title">{m.title}</div>
<ul className="curriculum">
{m.lessons.map((lesson) => {
const href = `/kurse/${course.slug}/lektionen/${lesson.slug}`;
return (
<li key={lesson.id}>
<Link href={href}>{lesson.title}</Link>
</li>
);
})}
</ul>
</li>
))}
</ul>
</div>
</div>
</section>
);
}

35
app/kurse/page.tsx Normal file
View File

@@ -0,0 +1,35 @@
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import { listPublishedCourses } from "@/lib/course-queries";
import { CourseCard } from "@/components/course-card";
import { prisma } from "@/lib/prisma";
export default async function CoursesPage() {
const session = await getServerSession(authOptions);
const courses = await listPublishedCourses();
let enrolledIds = new Set<string>();
if (session?.user?.id) {
const rows = await prisma.enrollment.findMany({
where: { userId: session.user.id },
select: { courseId: true },
});
enrolledIds = new Set(rows.map((r) => r.courseId));
}
return (
<section className="section">
<div className="container">
<h1>Kursinhalte</h1>
<p className="muted" style={{ marginBottom: "1.5rem" }}>
Alle veröffentlichten Kurse Einschreibung und Lektionen wie auf der Live-Akademie vorgesehen.
</p>
<div className="course-grid">
{courses.map((c) => (
<CourseCard key={c.id} course={c} enrolled={enrolledIds.has(c.id)} />
))}
</div>
</div>
</section>
);
}

24
app/layout.tsx Normal file
View File

@@ -0,0 +1,24 @@
import type { Metadata } from "next";
import "./globals.css";
import { Providers } from "@/components/providers";
import { SiteHeader } from "@/components/site-header";
import { SiteFooter } from "@/components/site-footer";
export const metadata: Metadata = {
title: "Motorrad Akademie LMS (Dev)",
description: "Private Lernplattform Entwicklungsumgebung",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="de">
<body>
<Providers>
<SiteHeader />
<main>{children}</main>
<SiteFooter />
</Providers>
</body>
</html>
);
}

16
app/login/page.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { Suspense } from "react";
import { LoginForm } from "@/components/login-form";
export default function LoginPage() {
return (
<section className="section">
<div className="container">
<h1>Anmelden</h1>
<p className="muted">Entwicklung: Demo-Zugänge siehe docs/PLAN.md</p>
<Suspense fallback={<p className="muted">Laden</p>}>
<LoginForm />
</Suspense>
</div>
</section>
);
}

68
app/page.tsx Normal file
View File

@@ -0,0 +1,68 @@
import Link from "next/link";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth-options";
import { listPublishedCourses } from "@/lib/course-queries";
import { CourseCard } from "@/components/course-card";
import { prisma } from "@/lib/prisma";
import { getLandingContent } from "@/lib/landing";
export default async function HomePage() {
const session = await getServerSession(authOptions);
const courses = await listPublishedCourses();
const landing = await getLandingContent();
let enrolledIds = new Set<string>();
if (session?.user?.id) {
const rows = await prisma.enrollment.findMany({
where: { userId: session.user.id },
select: { courseId: true },
});
enrolledIds = new Set(rows.map((r) => r.courseId));
}
return (
<>
<section className="hero">
<div className="container">
<h1>{landing.heroTitle}</h1>
<p className="lead">{landing.heroLead}</p>
<div className="hero-actions">
<Link href={landing.primaryCta.href} className="btn btn-primary">
{landing.primaryCta.label}
</Link>
{landing.secondaryCta ? (
<Link href={landing.secondaryCta.href} className="btn btn-ghost">
{landing.secondaryCta.label}
</Link>
) : null}
</div>
</div>
</section>
<section className="section">
<div className="container">
<h2>Unsere Kurse &amp; Module</h2>
<div className="course-grid">
{courses.map((c) => (
<CourseCard key={c.id} course={c} enrolled={enrolledIds.has(c.id)} />
))}
</div>
</div>
</section>
<section className="section">
<div className="container">
<h2>{landing.benefitSectionTitle}</h2>
<div className="course-grid">
{landing.benefits.map((b, idx) => (
<div key={idx} className="panel">
<h3>{b.title}</h3>
<p className="muted">{b.body}</p>
</div>
))}
</div>
</div>
</section>
</>
);
}

View File

@@ -0,0 +1,18 @@
import { PasswordChangeForm } from "@/components/password-change-form";
import { requireSession } from "@/lib/session-helpers";
export default async function PortalAccountPage() {
const session = await requireSession();
return (
<div>
<h1 className="page-title">Konto</h1>
<p className="muted subtitle">
Angemeldet als <strong>{session.user.email}</strong>
</p>
<h2 style={{ marginTop: "2rem" }}>Passwort ändern</h2>
<PasswordChangeForm />
</div>
);
}

View File

@@ -0,0 +1,54 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/session-helpers";
export default async function PortalCertificatesPage() {
const session = await requireSession();
const rows = await prisma.certificate.findMany({
where: { userId: session.user.id },
orderBy: { issuedAt: "desc" },
include: { course: { select: { title: true, slug: true } } },
});
return (
<div>
<h1 className="page-title">Zertifikate</h1>
<p className="muted subtitle">Teilnahmebestätigungen nach vollständigem Kursabschluss.</p>
{rows.length === 0 ? (
<div className="panel">
<p className="muted">Noch keine Zertifikate schließe zuerst alle Lektionen eines Kurses ab.</p>
<Link href="/portal" className="btn btn-primary">
Zu deinen Kursen
</Link>
</div>
) : (
<div className="table-wrap">
<table className="simple">
<thead>
<tr>
<th>Kurs</th>
<th>Ausgestellt</th>
<th>Code</th>
<th />
</tr>
</thead>
<tbody>
{rows.map((r) => (
<tr key={r.id}>
<td>{r.course.title}</td>
<td className="muted">{r.issuedAt.toLocaleDateString("de-DE")}</td>
<td className="muted">{r.code}</td>
<td>
<Link href={`/zertifikat/${r.code}`}>Ansehen / Drucken</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

25
app/portal/layout.tsx Normal file
View File

@@ -0,0 +1,25 @@
import Link from "next/link";
import { requireSession } from "@/lib/session-helpers";
export default async function PortalLayout({ children }: { children: React.ReactNode }) {
const session = await requireSession();
return (
<section className="section">
<div className="container layout-split">
<aside className="side-nav" aria-label="Mitgliederbereich">
<strong style={{ display: "block", padding: "0.35rem 0.65rem", marginBottom: "0.35rem" }}>
Mitgliederbereich
</strong>
<Link href="/portal">Übersicht</Link>
<Link href="/portal/account">Konto &amp; Passwort</Link>
<Link href="/portal/certificates">Zertifikate</Link>
<Link href="/kurse">Kurskatalog</Link>
{session.user.role === "ADMIN" ? <Link href="/admin">Administration</Link> : null}
<Link href="/">Startseite</Link>
</aside>
<div>{children}</div>
</div>
</section>
);
}

93
app/portal/page.tsx Normal file
View File

@@ -0,0 +1,93 @@
import Link from "next/link";
import { prisma } from "@/lib/prisma";
import { requireSession } from "@/lib/session-helpers";
import { firstLessonPath } from "@/lib/course-queries";
import { getUserCourseProgress } from "@/lib/course-progress";
import { RestartCourseButton } from "@/components/restart-course-button";
export default async function PortalHomePage() {
const session = await requireSession();
const enrollments = await prisma.enrollment.findMany({
where: { userId: session.user.id },
include: {
course: {
include: {
modules: {
orderBy: { sortOrder: "asc" },
include: {
lessons: {
where: { published: true },
orderBy: { sortOrder: "asc" },
select: { slug: true },
},
},
},
},
},
},
orderBy: { createdAt: "desc" },
});
const certs = await prisma.certificate.findMany({
where: { userId: session.user.id },
select: { courseId: true, code: true },
});
const certByCourse = new Map(certs.map((c) => [c.courseId, c.code]));
return (
<div>
<h1 className="page-title">Hallo{session.user.name ? `, ${session.user.name}` : ""}!</h1>
<p className="muted subtitle">Deine Kurse, Fortschritt und nächste Schritte.</p>
{enrollments.length === 0 ? (
<div className="panel">
<p>Du bist noch in keinem Kurs eingeschrieben.</p>
<Link href="/kurse" className="btn btn-primary">
Kurse entdecken
</Link>
</div>
) : (
<div className="course-grid" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(320px, 1fr))" }}>
{await Promise.all(
enrollments.map(async (e) => {
const p = await getUserCourseProgress(session.user.id, e.course.id);
const start = firstLessonPath(e.course);
const cert = certByCourse.get(e.course.id);
return (
<div key={e.id} className="panel">
<h2 style={{ marginTop: 0, fontSize: "1.15rem" }}>{e.course.title}</h2>
<p className="muted" style={{ marginTop: 0 }}>
Fortschritt: {p.completed}/{p.total} Lektionen ({p.percent}%)
</p>
<div className="progress" aria-hidden="true">
<span style={{ width: `${p.percent}%` }} />
</div>
<div className="stack" style={{ marginTop: "1rem" }}>
{start ? (
<Link href={start} className="btn btn-primary">
{p.percent >= 100 ? "Kurs wiederholen" : "Weiterlernen"}
</Link>
) : (
<span className="muted">Keine Lektionen</span>
)}
<Link href={`/kurse/${e.course.slug}`} className="btn btn-ghost">
Kurrikulum
</Link>
{cert ? (
<Link href={`/zertifikat/${cert}`} className="btn btn-ghost">
Zertifikat
</Link>
) : null}
<RestartCourseButton courseId={e.course.id} />
</div>
</div>
);
}),
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,46 @@
import { notFound } from "next/navigation";
import { prisma } from "@/lib/prisma";
import { PrintButton } from "@/components/print-button";
type Props = { params: Promise<{ code: string }> };
export default async function CertificatePublicPage({ params }: Props) {
const { code } = await params;
const cert = await prisma.certificate.findUnique({
where: { code },
include: {
course: { select: { title: true } },
user: { select: { name: true } },
},
});
if (!cert) notFound();
return (
<section className="section">
<div className="container">
<div className="cert-print">
<p className="muted" style={{ margin: 0 }}>
Fahrlässig Motorrad Akademie
</p>
<h1>Teilnahmebestätigung</h1>
<p className="muted" style={{ marginTop: "0.75rem" }}>
Hiermit bestätigen wir, dass
</p>
<div className="name">{cert.user.name}</div>
<p style={{ margin: "0.75rem 0 0", fontSize: "1.1rem" }}>
den Online-Kurs <strong>{cert.course.title}</strong> vollständig absolviert hat.
</p>
<p className="muted" style={{ marginTop: "1.25rem" }}>
Ausstellungsdatum: {cert.issuedAt.toLocaleDateString("de-DE")}
</p>
<p className="muted" style={{ marginTop: "0.5rem", fontSize: "0.9rem" }}>
Verifikationscode: <span style={{ fontFamily: "ui-monospace, monospace" }}>{cert.code}</span>
</p>
<div style={{ marginTop: "1.5rem" }}>
<PrintButton />
</div>
</div>
</div>
</section>
);
}