Pending course, rest ready for launch
This commit is contained in:
107
app/(public)/courses/[slug]/learn/actions.ts
Normal file
107
app/(public)/courses/[slug]/learn/actions.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { requireUser } from "@/lib/auth/requireUser";
|
||||
import { db } from "@/lib/prisma";
|
||||
import { issueCertificateIfEligible } from "@/lib/certificates";
|
||||
import { refreshStudyRecommendations } from "@/lib/recommendations";
|
||||
|
||||
type ToggleLessonCompleteInput = {
|
||||
courseSlug: string;
|
||||
lessonId: string;
|
||||
};
|
||||
|
||||
export async function toggleLessonComplete({ courseSlug, lessonId }: ToggleLessonCompleteInput) {
|
||||
const user = await requireUser();
|
||||
|
||||
if (!user?.id) {
|
||||
return { success: false, error: "Unauthorized" };
|
||||
}
|
||||
|
||||
const lesson = await db.lesson.findUnique({
|
||||
where: { id: lessonId },
|
||||
select: {
|
||||
id: true,
|
||||
module: {
|
||||
select: {
|
||||
courseId: true,
|
||||
course: {
|
||||
select: {
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!lesson || lesson.module.course.slug !== courseSlug) {
|
||||
return { success: false, error: "Lesson not found" };
|
||||
}
|
||||
|
||||
const enrollment = await db.enrollment.findUnique({
|
||||
where: {
|
||||
userId_courseId: {
|
||||
userId: user.id,
|
||||
courseId: lesson.module.courseId,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!enrollment) {
|
||||
return { success: false, error: "Not enrolled in this course" };
|
||||
}
|
||||
|
||||
const existingProgress = await db.userProgress.findUnique({
|
||||
where: {
|
||||
userId_lessonId: {
|
||||
userId: user.id,
|
||||
lessonId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
isCompleted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const nextCompleted = !existingProgress?.isCompleted;
|
||||
|
||||
if (existingProgress) {
|
||||
await db.userProgress.update({
|
||||
where: { id: existingProgress.id },
|
||||
data: {
|
||||
isCompleted: nextCompleted,
|
||||
finishedAt: nextCompleted ? new Date() : null,
|
||||
lastPlayedAt: new Date(),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await db.userProgress.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
lessonId,
|
||||
isCompleted: true,
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const certificateResult = nextCompleted
|
||||
? await issueCertificateIfEligible(user.id, lesson.module.courseId)
|
||||
: { certificateId: null, certificateNumber: null, newlyIssued: false };
|
||||
|
||||
revalidatePath(`/courses/${courseSlug}/learn`);
|
||||
revalidatePath("/my-courses");
|
||||
revalidatePath("/profile");
|
||||
await refreshStudyRecommendations(user.id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
isCompleted: nextCompleted,
|
||||
certificateId: certificateResult.certificateId,
|
||||
certificateNumber: certificateResult.certificateNumber,
|
||||
newlyIssuedCertificate: certificateResult.newlyIssued,
|
||||
};
|
||||
}
|
||||
192
app/(public)/courses/[slug]/learn/page.tsx
Normal file
192
app/(public)/courses/[slug]/learn/page.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { db } from "@/lib/prisma";
|
||||
import { requireUser } from "@/lib/auth/requireUser";
|
||||
import StudentClassroomClient from "@/components/courses/StudentClassroomClient";
|
||||
import { parseLessonDescriptionMeta } from "@/lib/courses/lessonContent";
|
||||
|
||||
type LessonSelect = {
|
||||
id: string;
|
||||
title: unknown;
|
||||
description: unknown;
|
||||
videoUrl: string | null;
|
||||
youtubeUrl: string | null;
|
||||
estimatedDuration: number;
|
||||
isFreePreview: boolean;
|
||||
};
|
||||
type ModuleSelect = { id: string; title: unknown; lessons: LessonSelect[] };
|
||||
type CourseWithModules = { id: string; slug: string; title: unknown; price: unknown; modules: ModuleSelect[] };
|
||||
|
||||
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.es === "string") return record.es;
|
||||
if (typeof record.en === "string") return record.en;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
type PageProps = {
|
||||
params: Promise<{ slug: string }>;
|
||||
searchParams: Promise<{ lesson?: string }>;
|
||||
};
|
||||
|
||||
export default async function CourseLearnPage({ params, searchParams }: PageProps) {
|
||||
const { slug } = await params;
|
||||
const { lesson: requestedLessonId } = await searchParams;
|
||||
|
||||
const user = await requireUser();
|
||||
|
||||
const courseSelect = {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
price: true,
|
||||
modules: {
|
||||
orderBy: { orderIndex: "asc" as const },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
lessons: {
|
||||
orderBy: { orderIndex: "asc" as const },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
videoUrl: true,
|
||||
youtubeUrl: true,
|
||||
estimatedDuration: true,
|
||||
isFreePreview: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const course = (await db.course.findUnique({
|
||||
where: { slug },
|
||||
select: courseSelect,
|
||||
})) as CourseWithModules | null;
|
||||
|
||||
if (!course) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
let enrollment: { id: string } | null = null;
|
||||
let isEnrolled: boolean;
|
||||
|
||||
if (!user?.id) {
|
||||
// Anonymous: no enrollment, preview-only access
|
||||
const allLessons = course.modules.flatMap((m) =>
|
||||
m.lessons.map((l) => ({ id: l.id, isFreePreview: l.isFreePreview }))
|
||||
);
|
||||
const firstPreviewLesson = allLessons.find((l) => l.isFreePreview);
|
||||
if (!firstPreviewLesson) {
|
||||
redirect(`/courses/${slug}`);
|
||||
}
|
||||
isEnrolled = false;
|
||||
} else {
|
||||
enrollment = await db.enrollment.findUnique({
|
||||
where: {
|
||||
userId_courseId: {
|
||||
userId: user.id,
|
||||
courseId: course.id,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const isFree = Number(course.price) === 0;
|
||||
|
||||
if (!enrollment) {
|
||||
if (isFree) {
|
||||
enrollment = await db.enrollment.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
courseId: course.id,
|
||||
amountPaid: 0,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
isEnrolled = true;
|
||||
} else {
|
||||
// Paid course, no enrollment: allow only if there are preview lessons
|
||||
const allLessons = course.modules.flatMap((m) =>
|
||||
m.lessons.map((l) => ({ id: l.id, isFreePreview: l.isFreePreview }))
|
||||
);
|
||||
const firstPreviewLesson = allLessons.find((l) => l.isFreePreview);
|
||||
if (!firstPreviewLesson) {
|
||||
redirect(`/courses/${slug}`);
|
||||
}
|
||||
isEnrolled = false;
|
||||
}
|
||||
} else {
|
||||
isEnrolled = true;
|
||||
}
|
||||
}
|
||||
|
||||
const completedProgress =
|
||||
isEnrolled && user
|
||||
? await db.userProgress.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
isCompleted: true,
|
||||
lesson: {
|
||||
module: { courseId: course.id },
|
||||
},
|
||||
},
|
||||
select: { lessonId: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
const modules = course.modules.map((module) => ({
|
||||
id: module.id,
|
||||
title: getText(module.title) || "Untitled module",
|
||||
lessons: module.lessons.map((lesson) => {
|
||||
const lessonMeta = parseLessonDescriptionMeta(lesson.description);
|
||||
return {
|
||||
id: lesson.id,
|
||||
title: getText(lesson.title) || "Untitled lesson",
|
||||
description: lessonMeta.text,
|
||||
contentType: lessonMeta.contentType,
|
||||
materialUrl: lessonMeta.materialUrl,
|
||||
videoUrl: lesson.videoUrl,
|
||||
youtubeUrl: lesson.youtubeUrl,
|
||||
estimatedDuration: lesson.estimatedDuration,
|
||||
isFreePreview: lesson.isFreePreview,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
const flattenedLessons = modules.flatMap((module) => module.lessons);
|
||||
const flattenedLessonIds = flattenedLessons.map((l) => l.id);
|
||||
|
||||
let initialSelectedLessonId: string;
|
||||
if (isEnrolled) {
|
||||
initialSelectedLessonId =
|
||||
requestedLessonId && flattenedLessonIds.includes(requestedLessonId)
|
||||
? requestedLessonId
|
||||
: flattenedLessonIds[0] ?? "";
|
||||
} else {
|
||||
const firstPreview = flattenedLessons.find((l) => l.isFreePreview);
|
||||
const requestedLesson = requestedLessonId
|
||||
? flattenedLessons.find((l) => l.id === requestedLessonId)
|
||||
: null;
|
||||
if (requestedLesson?.isFreePreview) {
|
||||
initialSelectedLessonId = requestedLessonId!;
|
||||
} else {
|
||||
initialSelectedLessonId = firstPreview?.id ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<StudentClassroomClient
|
||||
courseSlug={course.slug}
|
||||
courseTitle={getText(course.title) || "Untitled course"}
|
||||
modules={modules}
|
||||
initialSelectedLessonId={initialSelectedLessonId}
|
||||
initialCompletedLessonIds={completedProgress.map((p) => p.lessonId)}
|
||||
isEnrolled={isEnrolled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user