Pending course, rest ready for launch
This commit is contained in:
375
app/(protected)/profile/page.tsx
Normal file
375
app/(protected)/profile/page.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user