Files
ACVE/app/(protected)/profile/page.tsx
2026-03-15 13:52:11 +00:00

376 lines
15 KiB
TypeScript

import Link from "next/link";
import { redirect } from "next/navigation";
import { UserRole } from "@prisma/client";
import { requireUser } from "@/lib/auth/requireUser";
import { db } from "@/lib/prisma";
import { getActiveRecommendations, getMiniGameGrade } from "@/lib/recommendations";
type ProfilePrismaClient = {
miniGameAttempt: {
findMany: (args: object) => Promise<
{
miniGameId: string;
scorePercent: number;
miniGame: { id: string; title: string; slug: string };
}[]
>;
};
};
function getText(value: unknown): string {
if (!value) return "";
if (typeof value === "string") return value;
if (typeof value === "object") {
const record = value as Record<string, unknown>;
if (typeof record.en === "string") return record.en;
if (typeof record.es === "string") return record.es;
}
return "";
}
const levelLabel: Record<string, string> = {
BEGINNER: "Beginner",
INTERMEDIATE: "Intermediate",
ADVANCED: "Advanced",
EXPERT: "Expert",
};
export default async function ProfilePage() {
const user = await requireUser();
if (!user?.id) {
redirect("/auth/login?redirectTo=/profile");
}
const isTeacher = user.role === UserRole.TEACHER || user.role === UserRole.SUPER_ADMIN;
if (isTeacher) {
const authoredCourses = await db.course
.findMany({
where: { authorId: user.id },
select: {
id: true,
title: true,
slug: true,
tags: true,
level: true,
status: true,
learningOutcomes: true,
updatedAt: true,
_count: {
select: {
modules: true,
enrollments: true,
},
},
modules: {
select: {
lessons: {
select: {
id: true,
videoUrl: true,
youtubeUrl: true,
isFreePreview: true,
},
},
},
},
},
orderBy: { updatedAt: "desc" },
})
.catch((error) => {
console.error("Failed to load authored courses for profile.", error);
return [];
});
const totalCourses = authoredCourses.length;
const publishedCourses = authoredCourses.filter((course) => course.status === "PUBLISHED").length;
const totalModules = authoredCourses.reduce((acc, course) => acc + course._count.modules, 0);
const totalStudents = authoredCourses.reduce((acc, course) => acc + course._count.enrollments, 0);
const lessons = authoredCourses.flatMap((course) => course.modules.flatMap((module) => module.lessons));
const totalLessons = lessons.length;
const uploadedLessons = lessons.filter((lesson) => lesson.videoUrl || lesson.youtubeUrl).length;
const previewLessons = lessons.filter((lesson) => lesson.isFreePreview).length;
const topicSet = new Set<string>();
const outcomeSet = new Set<string>();
for (const course of authoredCourses) {
for (const tag of course.tags) {
if (tag.trim()) topicSet.add(tag.trim());
}
if (Array.isArray(course.learningOutcomes)) {
for (const outcome of course.learningOutcomes) {
if (typeof outcome === "string" && outcome.trim()) {
outcomeSet.add(outcome.trim());
}
}
}
}
const masteredTopics = topicSet.size > 0 ? [...topicSet] : [...new Set(authoredCourses.map((course) => levelLabel[course.level] ?? course.level))];
const strengths = [...outcomeSet].slice(0, 8);
return (
<div className="acve-page">
<section className="acve-panel acve-section-base">
<p className="acve-pill mb-3 w-fit">Profile</p>
<h1 className="text-3xl font-semibold leading-tight text-[#222a38] md:text-4xl">{user.fullName || user.email}</h1>
<p className="mt-2 text-base text-slate-600">Teacher account | {user.email}</p>
</section>
<section className="grid gap-4 md:grid-cols-4">
<article className="acve-panel p-5">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Courses created</p>
<p className="mt-2 text-4xl font-semibold text-slate-900">{totalCourses}</p>
</article>
<article className="acve-panel p-5">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Published</p>
<p className="mt-2 text-4xl font-semibold text-slate-900">{publishedCourses}</p>
</article>
<article className="acve-panel p-5">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Students</p>
<p className="mt-2 text-4xl font-semibold text-slate-900">{totalStudents}</p>
</article>
<article className="acve-panel p-5">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Content units</p>
<p className="mt-2 text-4xl font-semibold text-slate-900">{totalModules + totalLessons}</p>
<p className="mt-2 text-sm text-slate-600">{totalModules} modules + {totalLessons} lessons</p>
</article>
</section>
<section className="grid gap-4 lg:grid-cols-2">
<article className="acve-panel p-5">
<h2 className="text-2xl font-semibold text-slate-900">Topics mastered</h2>
{masteredTopics.length === 0 ? (
<p className="mt-3 text-sm text-slate-600">No topic metadata yet. Add tags to your courses.</p>
) : (
<div className="mt-3 flex flex-wrap gap-2">
{masteredTopics.map((topic) => (
<span key={topic} className="rounded-full border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-700">
{topic}
</span>
))}
</div>
)}
{strengths.length > 0 ? (
<ul className="mt-4 space-y-2 text-sm text-slate-700">
{strengths.map((item) => (
<li key={item} className="rounded-lg border border-slate-200 px-3 py-2">
{item}
</li>
))}
</ul>
) : null}
</article>
<article className="acve-panel p-5">
<h2 className="text-2xl font-semibold text-slate-900">Upload information</h2>
<ul className="mt-3 space-y-2 text-sm text-slate-700">
<li className="rounded-lg border border-slate-200 px-3 py-2">
Lessons with media: {uploadedLessons}/{totalLessons || 0}
</li>
<li className="rounded-lg border border-slate-200 px-3 py-2">Preview lessons enabled: {previewLessons}</li>
<li className="rounded-lg border border-slate-200 px-3 py-2">Lessons pending upload: {Math.max(totalLessons - uploadedLessons, 0)}</li>
</ul>
<Link className="mt-4 inline-flex text-sm font-semibold text-brand hover:underline" href="/teacher/uploads">
Open upload library
</Link>
</article>
</section>
<section className="acve-panel p-5">
<h2 className="text-2xl font-semibold text-slate-900">Recent course activity</h2>
{authoredCourses.length === 0 ? (
<p className="mt-3 text-sm text-slate-600">No courses yet. Start by creating a new course.</p>
) : (
<ul className="mt-3 space-y-2">
{authoredCourses.slice(0, 6).map((course) => (
<li key={course.id} className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700">
<p className="font-semibold text-slate-900">{getText(course.title) || "Untitled course"}</p>
<p className="text-xs text-slate-500">
{course.status} | {course._count.modules} modules | {course._count.enrollments} students | updated{" "}
{new Date(course.updatedAt).toLocaleDateString()}
</p>
<Link className="mt-1 inline-flex text-xs font-semibold text-brand hover:underline" href={`/teacher/courses/${course.slug}/edit`}>
Open editor
</Link>
</li>
))}
</ul>
)}
</section>
</div>
);
}
const [grade, recommendations, certificates] = await Promise.all([
getMiniGameGrade(user.id).catch((error) => {
console.error("Failed to load mini-game grade.", error);
return 0;
}),
getActiveRecommendations(user.id).catch((error) => {
console.error("Failed to load recommendations.", error);
return [];
}),
db.certificate
.findMany({
where: { userId: user.id },
select: {
id: true,
issuedAt: true,
metadataSnapshot: true,
course: {
select: {
title: true,
slug: true,
},
},
},
orderBy: { issuedAt: "desc" },
})
.catch((error) => {
console.error("Failed to load certificates for profile.", error);
return [];
}),
]);
let attempts: {
miniGameId: string;
scorePercent: number;
miniGame: { id: string; title: string; slug: string };
}[] = [];
try {
attempts = await (db as unknown as ProfilePrismaClient).miniGameAttempt.findMany({
where: { userId: user.id },
include: {
miniGame: {
select: {
id: true,
title: true,
slug: true,
},
},
},
orderBy: { completedAt: "desc" },
});
} catch {
attempts = [];
}
const latestByGame = new Map<string, number>();
const bestByGame = new Map<string, number>();
const titleByGame = new Map<string, string>();
for (const attempt of attempts) {
if (!latestByGame.has(attempt.miniGameId)) {
latestByGame.set(attempt.miniGameId, attempt.scorePercent);
}
const best = bestByGame.get(attempt.miniGameId) ?? 0;
if (attempt.scorePercent > best) {
bestByGame.set(attempt.miniGameId, attempt.scorePercent);
}
titleByGame.set(attempt.miniGameId, attempt.miniGame.title);
}
const gameRows = [...titleByGame.keys()].map((gameId) => ({
gameId,
title: titleByGame.get(gameId) ?? "Mini-game",
latest: latestByGame.get(gameId) ?? 0,
best: bestByGame.get(gameId) ?? 0,
}));
return (
<div className="acve-page">
<section className="acve-panel acve-section-base">
<p className="acve-pill mb-3 w-fit">Profile</p>
<h1 className="text-3xl font-semibold leading-tight text-[#222a38] md:text-4xl">{user.fullName || user.email}</h1>
<p className="mt-2 text-base text-slate-600">{user.email}</p>
</section>
<section className="grid gap-4 md:grid-cols-3">
<article className="acve-panel p-5">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Mini-game grade</p>
<p className="mt-2 text-4xl font-semibold text-slate-900">{grade}%</p>
<p className="mt-2 text-sm text-slate-600">Average of latest attempts across mini-games.</p>
</article>
<article className="acve-panel p-5">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Attempts</p>
<p className="mt-2 text-4xl font-semibold text-slate-900">{attempts.length}</p>
<Link className="mt-2 inline-flex text-sm font-semibold text-brand hover:underline" href="/practice">
Play mini-games
</Link>
</article>
<article className="acve-panel p-5">
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Certificates</p>
<p className="mt-2 text-4xl font-semibold text-slate-900">{certificates.length}</p>
<Link className="mt-2 inline-flex text-sm font-semibold text-brand hover:underline" href="/my-courses">
View my courses
</Link>
</article>
</section>
<section className="grid gap-4 lg:grid-cols-2">
<article className="acve-panel p-5">
<h2 className="text-2xl font-semibold text-slate-900">Mini-game breakdown</h2>
{gameRows.length === 0 ? (
<p className="mt-3 text-sm text-slate-600">No attempts yet. Complete a mini-game to generate your grade.</p>
) : (
<ul className="mt-3 space-y-2">
{gameRows.map((row) => (
<li key={row.gameId} className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700">
<span className="font-semibold text-slate-900">{row.title}</span> | latest: {row.latest}% | best: {row.best}%
</li>
))}
</ul>
)}
</article>
<article className="acve-panel p-5">
<h2 className="text-2xl font-semibold text-slate-900">Recommended next</h2>
{recommendations.length === 0 ? (
<p className="mt-3 text-sm text-slate-600">No recommendations yet. Complete a mini-game or enroll in a course first.</p>
) : (
<ul className="mt-3 space-y-2">
{recommendations.map((recommendation) => (
<li key={recommendation.courseId} className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700">
<p className="font-semibold text-slate-900">{recommendation.title}</p>
<p className="text-xs text-slate-500">{recommendation.reason}</p>
<Link className="mt-1 inline-flex text-xs font-semibold text-brand hover:underline" href={`/courses/${recommendation.slug}`}>
Open course
</Link>
</li>
))}
</ul>
)}
</article>
</section>
<section className="acve-panel p-5">
<h2 className="text-2xl font-semibold text-slate-900">Certificates</h2>
{certificates.length === 0 ? (
<p className="mt-3 text-sm text-slate-600">No certificates issued yet.</p>
) : (
<ul className="mt-3 space-y-2">
{certificates.map((certificate) => {
const certificateNumber =
(certificate as Record<string, unknown>).certificateNumber ??
(certificate.metadataSnapshot as Record<string, unknown> | null)?.certificateNumber ??
"N/A";
return (
<li key={certificate.id} className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700">
<p className="font-semibold text-slate-900">{getText(certificate.course.title) || "Course"}</p>
<p className="text-xs text-slate-500">
Certificate #{String(certificateNumber)} | issued {new Date(certificate.issuedAt).toLocaleDateString()}
</p>
<Link className="mt-1 inline-flex text-xs font-semibold text-brand hover:underline" href={`/api/certificates/${certificate.id}/pdf`}>
Download PDF
</Link>
</li>
);
})}
</ul>
)}
</section>
</div>
);
}