Pending course, rest ready for launch

This commit is contained in:
Marcelo
2026-03-15 13:52:11 +00:00
parent be4ca2ed78
commit 62b3cfe467
77 changed files with 6450 additions and 868 deletions

View File

@@ -0,0 +1,108 @@
export const lessonContentTypes = ["VIDEO", "LECTURE", "ACTIVITY", "QUIZ", "FINAL_EXAM"] as const;
export type LessonContentType = (typeof lessonContentTypes)[number];
type LessonDescriptionMeta = {
text: string;
contentType: LessonContentType;
materialUrl: string | null;
};
const lessonTypeAliases: Record<string, LessonContentType> = {
VIDEO: "VIDEO",
LECTURE: "LECTURE",
LECTURA: "LECTURE",
READING: "LECTURE",
ACTIVITY: "ACTIVITY",
ACTIVIDAD: "ACTIVITY",
QUIZ: "QUIZ",
EVALUACION: "QUIZ",
EVALUACIÓN: "QUIZ",
FINAL_EXAM: "FINAL_EXAM",
EXAMEN_FINAL: "FINAL_EXAM",
EXAMENFINAL: "FINAL_EXAM",
EVALUACION_FINAL: "FINAL_EXAM",
EVALUACIÓN_FINAL: "FINAL_EXAM",
};
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function asString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function normalizeType(value: string): LessonContentType {
const normalized = value.trim().toUpperCase();
return lessonTypeAliases[normalized] ?? "VIDEO";
}
function getDescriptionText(input: unknown): string {
if (typeof input === "string") return input.trim();
if (isRecord(input)) {
const direct = asString(input.text);
if (direct) return direct;
const es = asString(input.es);
if (es) return es;
const en = asString(input.en);
if (en) return en;
const summary = asString(input.summary);
if (summary) return summary;
}
return "";
}
export function parseLessonDescriptionMeta(description: unknown): LessonDescriptionMeta {
if (!isRecord(description)) {
return {
text: getDescriptionText(description),
contentType: "VIDEO",
materialUrl: null,
};
}
const contentTypeRaw = asString(description.contentType) || asString(description.kind) || asString(description.type);
const materialUrl =
asString(description.materialUrl) ||
asString(description.resourceUrl) ||
asString(description.pdfUrl) ||
asString(description.attachmentUrl) ||
"";
return {
text: getDescriptionText(description),
contentType: normalizeType(contentTypeRaw || "VIDEO"),
materialUrl: materialUrl || null,
};
}
export function buildLessonDescriptionMeta(input: {
text: string;
contentType: LessonContentType;
materialUrl?: string | null;
}): Record<string, string> {
const payload: Record<string, string> = {
contentType: input.contentType,
};
const text = input.text.trim();
if (text) payload.es = text;
const materialUrl = (input.materialUrl ?? "").trim();
if (materialUrl) payload.materialUrl = materialUrl;
return payload;
}
export function getLessonContentTypeLabel(contentType: LessonContentType): string {
if (contentType === "LECTURE") return "Lectura";
if (contentType === "ACTIVITY") return "Actividad";
if (contentType === "QUIZ") return "Quiz";
if (contentType === "FINAL_EXAM") return "Evaluación final";
return "Video";
}
export function isFinalExam(contentType: LessonContentType): boolean {
return contentType === "FINAL_EXAM";
}

254
lib/courses/presentation.ts Normal file
View File

@@ -0,0 +1,254 @@
import { ContentStatus, ProficiencyLevel } from "@prisma/client";
export type AcademicStageId = "base" | "consolidacion" | "especializacion";
export type AvailabilityState = "published" | "upcoming" | "draft";
export type AcademicStageConfig = {
id: AcademicStageId;
tabLabel: string;
sectionTitle: string;
sectionDescription: string;
levelLabel: string;
};
export const ACADEMIC_STAGE_ORDER: AcademicStageId[] = ["base", "consolidacion", "especializacion"];
export const ACADEMIC_STAGE_CONFIG: Record<AcademicStageId, AcademicStageConfig> = {
base: {
id: "base",
tabLabel: "Base",
sectionTitle: "Programas Base",
sectionDescription:
"Trayectos introductorios para fortalecer fundamentos del inglés jurídico y lenguaje técnico aplicado.",
levelLabel: "Base",
},
consolidacion: {
id: "consolidacion",
tabLabel: "Consolidación",
sectionTitle: "Programas de Consolidación",
sectionDescription:
"Programas para consolidar precisión terminológica, comprensión de textos jurídicos y comunicación profesional.",
levelLabel: "Consolidación",
},
especializacion: {
id: "especializacion",
tabLabel: "Especialización",
sectionTitle: "Programas de Especialización",
sectionDescription:
"Itinerarios avanzados orientados a práctica profesional internacional, redacción especializada y análisis complejo.",
levelLabel: "Especialización",
},
};
const STAGE_KEYWORDS: Record<AcademicStageId, string[]> = {
base: ["base", "fundamentos", "fundamentals", "intro", "introduccion", "beginner", "inicial"],
consolidacion: ["consolidacion", "consolidación", "consolidation", "intermedio", "intermediate"],
especializacion: ["especializacion", "especialización", "specialization", "avanzado", "advanced", "expert", "experto"],
};
export const PUBLIC_UPCOMING_TAGS = ["upcoming", "coming-soon", "proximamente", "próximamente", "public-upcoming"];
const IMAGE_KEYS = ["coverImageUrl", "coverImage", "thumbnailUrl", "thumbnail", "imageUrl", "image", "cover"];
const SHORT_KEYS = ["shortEs", "shortEn", "short", "summary", "excerpt", "resumen"];
const LONG_KEYS = ["longEs", "longEn", "long", "description", "descripcion"];
type UnknownRecord = Record<string, unknown>;
function isRecord(value: unknown): value is UnknownRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function asString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function fromRecord(record: UnknownRecord, keys: string[]): string {
for (const key of keys) {
const value = asString(record[key]);
if (value) return value;
}
return "";
}
export function getLocalizedText(value: unknown): string {
if (!value) return "";
if (typeof value === "string") return value.trim();
if (isRecord(value)) {
const preferred = fromRecord(value, ["es", "en"]);
if (preferred) return preferred;
}
return "";
}
export function getCourseDescriptions(description: unknown): { short: string; long: string } {
if (!description) {
return {
short: "Programa en actualización académica.",
long: "Programa en actualización académica.",
};
}
const localized = getLocalizedText(description);
if (!isRecord(description)) {
const normalized = localized || "Programa en actualización académica.";
return { short: truncateText(normalized, 170), long: normalized };
}
const shortCandidate = fromRecord(description, SHORT_KEYS);
const longCandidate = fromRecord(description, LONG_KEYS) || localized;
const longText = longCandidate || "Programa en actualización académica.";
const shortText = shortCandidate || truncateText(longText, 170);
return {
short: shortText,
long: longText,
};
}
function toTags(input: string[] | null | undefined): string[] {
if (!Array.isArray(input)) return [];
return input.filter((value): value is string => typeof value === "string");
}
function getImageFromTags(tags: string[] | null | undefined): string {
for (const rawTag of toTags(tags)) {
const tag = rawTag.trim();
if (!tag) continue;
const lower = tag.toLowerCase();
if (lower.startsWith("cover:") || lower.startsWith("cover-url:") || lower.startsWith("thumbnail:")) {
const value = tag.slice(tag.indexOf(":") + 1).trim();
if (value.startsWith("http://") || value.startsWith("https://")) return value;
}
}
return "";
}
function getYouTubeThumbnail(url: string): string {
if (!url) return "";
const normalized = url.trim();
if (!normalized) return "";
const patterns = [/v=([a-zA-Z0-9_-]{6,})/, /youtu\.be\/([a-zA-Z0-9_-]{6,})/, /embed\/([a-zA-Z0-9_-]{6,})/];
for (const pattern of patterns) {
const match = normalized.match(pattern);
const videoId = match?.[1];
if (videoId) return `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
}
return "";
}
function getImageFromDescription(description: unknown): string {
if (!isRecord(description)) return "";
const image = fromRecord(description, IMAGE_KEYS);
if (!image) return "";
return image.startsWith("http://") || image.startsWith("https://") || image.startsWith("/") ? image : "";
}
function getImageFromVideoUrl(url: string): string {
if (!url) return "";
const normalized = url.trim();
if (!normalized) return "";
if (/\.(png|jpe?g|webp|gif)$/i.test(normalized)) return normalized;
return "";
}
export function resolveThumbnailUrl(args: {
description: unknown;
tags: string[] | null | undefined;
youtubeUrl?: string | null;
videoUrl?: string | null;
}): string {
const fromDescription = getImageFromDescription(args.description);
if (fromDescription) return fromDescription;
const fromTags = getImageFromTags(args.tags);
if (fromTags) return fromTags;
const fromYouTube = getYouTubeThumbnail(args.youtubeUrl ?? "");
if (fromYouTube) return fromYouTube;
const fromVideo = getImageFromVideoUrl(args.videoUrl ?? "");
if (fromVideo) return fromVideo;
return "";
}
function normalizeTag(tag: string): string {
return tag
.trim()
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
}
export function normalizeTags(tags: string[] | null | undefined): string[] {
return toTags(tags).map(normalizeTag).filter(Boolean);
}
function includesKeyword(tags: string[], stage: AcademicStageId): boolean {
return tags.some((tag) => STAGE_KEYWORDS[stage].some((keyword) => tag.includes(keyword)));
}
export function resolveAcademicStage(level: ProficiencyLevel, tags: string[] | null | undefined): AcademicStageConfig {
const normalizedTags = normalizeTags(tags);
if (includesKeyword(normalizedTags, "base")) return ACADEMIC_STAGE_CONFIG.base;
if (includesKeyword(normalizedTags, "consolidacion")) return ACADEMIC_STAGE_CONFIG.consolidacion;
if (includesKeyword(normalizedTags, "especializacion")) return ACADEMIC_STAGE_CONFIG.especializacion;
if (level === "BEGINNER") return ACADEMIC_STAGE_CONFIG.base;
if (level === "INTERMEDIATE") return ACADEMIC_STAGE_CONFIG.consolidacion;
return ACADEMIC_STAGE_CONFIG.especializacion;
}
export function resolveAvailability(
status: ContentStatus,
tags: string[] | null | undefined,
): { state: AvailabilityState; label: string } {
if (status === "PUBLISHED") return { state: "published", label: "Disponible" };
const normalizedTags = normalizeTags(tags);
const hasUpcomingTag = normalizedTags.some((tag) => PUBLIC_UPCOMING_TAGS.includes(tag));
if (hasUpcomingTag) return { state: "upcoming", label: "Próximamente" };
return { state: "draft", label: "Borrador" };
}
export function isUpcomingTagPresent(tags: string[] | null | undefined): boolean {
const normalizedTags = normalizeTags(tags);
return normalizedTags.some((tag) => PUBLIC_UPCOMING_TAGS.includes(tag));
}
export function getProficiencyLabel(level: ProficiencyLevel): string {
if (level === "BEGINNER") return "Inicial";
if (level === "INTERMEDIATE") return "Intermedio";
if (level === "ADVANCED") return "Avanzado";
return "Experto";
}
export function formatDuration(totalSeconds: number): string {
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) return "Duración por definir";
const totalMinutes = Math.max(1, Math.ceil(totalSeconds / 60));
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours === 0) return `${totalMinutes} min`;
if (minutes === 0) return `${hours} h`;
return `${hours} h ${minutes} min`;
}
export function formatMinutes(minutes: number): string {
if (!Number.isFinite(minutes) || minutes <= 0) return "Sin duración";
if (minutes < 60) return `${minutes} min`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
if (remainingMinutes === 0) return `${hours} h`;
return `${hours} h ${remainingMinutes} min`;
}
export function truncateText(value: string, maxChars: number): string {
if (value.length <= maxChars) return value;
return `${value.slice(0, Math.max(0, maxChars - 1)).trimEnd()}`;
}

View File

@@ -0,0 +1,581 @@
import type { Prisma } from "@prisma/client";
import { db } from "@/lib/prisma";
import {
ACADEMIC_STAGE_CONFIG,
ACADEMIC_STAGE_ORDER,
PUBLIC_UPCOMING_TAGS,
type AcademicStageConfig,
type AcademicStageId,
formatDuration,
formatMinutes,
getCourseDescriptions,
getLocalizedText,
getProficiencyLabel,
resolveAcademicStage,
resolveAvailability,
resolveThumbnailUrl,
truncateText,
} from "@/lib/courses/presentation";
import { isFinalExam, parseLessonDescriptionMeta } from "@/lib/courses/lessonContent";
type CatalogCourseRow = Prisma.CourseGetPayload<{
include: {
author: {
select: {
fullName: true;
};
};
modules: {
orderBy: {
orderIndex: "asc";
};
select: {
lessons: {
orderBy: {
orderIndex: "asc";
};
select: {
id: true;
estimatedDuration: true;
isFreePreview: true;
youtubeUrl: true;
videoUrl: true;
};
};
};
};
_count: {
select: {
enrollments: true;
};
};
};
}>;
type DetailCourseRow = Prisma.CourseGetPayload<{
include: {
author: {
select: {
fullName: true;
};
};
modules: {
orderBy: {
orderIndex: "asc";
};
select: {
id: true;
title: true;
orderIndex: true;
lessons: {
orderBy: {
orderIndex: "asc";
};
select: {
id: true;
title: true;
description: true;
orderIndex: true;
estimatedDuration: true;
isFreePreview: true;
youtubeUrl: true;
videoUrl: true;
_count: {
select: {
resources: true;
exercises: true;
};
};
};
};
};
};
_count: {
select: {
enrollments: true;
};
};
};
}>;
export type CatalogCourseCardView = {
id: string;
slug: string;
title: string;
shortDescription: string;
longDescription: string;
thumbnailUrl: string | null;
stageId: AcademicStageId;
stageLabel: string;
proficiencyLabel: string;
durationLabel: string;
durationMinutes: number;
lessonCount: number;
instructor: string;
availabilityLabel: string;
availabilityState: "published" | "upcoming" | "draft";
progressPercent: number;
completedLessons: number;
totalLessons: number;
studentsCount: number;
isEnrolled: boolean;
hasPreview: boolean;
};
export type CatalogSectionView = {
id: AcademicStageId;
anchorId: string;
tabLabel: string;
sectionTitle: string;
sectionDescription: string;
courses: CatalogCourseCardView[];
};
export type CourseCatalogViewModel = {
sections: CatalogSectionView[];
totals: {
totalCourses: number;
totalLessons: number;
instructorCount: number;
};
};
type ProgramBadge = "Video" | "Lectura" | "Actividad" | "Evaluación";
export type CourseProgramItemView = {
id: string;
order: number;
title: string;
subtitle: string;
durationLabel: string | null;
badges: ProgramBadge[];
isPreview: boolean;
isFinalExam: boolean;
isLocked: boolean;
isCompleted: boolean;
isUpcoming: boolean;
};
export type CourseProgramModuleView = {
id: string;
title: string;
order: number;
items: CourseProgramItemView[];
};
export type CourseDetailViewModel = {
id: string;
slug: string;
title: string;
shortDescription: string;
longDescription: string;
thumbnailUrl: string | null;
stage: AcademicStageConfig;
proficiencyLabel: string;
instructor: string;
availabilityLabel: string;
availabilityState: "published" | "upcoming" | "draft";
studentsCount: number;
lessonCount: number;
durationLabel: string;
durationMinutes: number;
moduleCount: number;
modules: CourseProgramModuleView[];
progressPercent: number;
completedLessons: number;
totalLessons: number;
isEnrolled: boolean;
firstPreviewLessonId: string | null;
price: number;
};
function toMinutes(totalSeconds: number): number {
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) return 0;
return Math.max(1, Math.ceil(totalSeconds / 60));
}
function getPrimaryMedia(lessons: Array<{ youtubeUrl: string | null; videoUrl: string | null }>) {
const withYoutube = lessons.find((lesson) => Boolean(lesson.youtubeUrl?.trim()));
if (withYoutube) return { youtubeUrl: withYoutube.youtubeUrl, videoUrl: withYoutube.videoUrl };
const withVideo = lessons.find((lesson) => Boolean(lesson.videoUrl?.trim()));
if (withVideo) return { youtubeUrl: withVideo.youtubeUrl, videoUrl: withVideo.videoUrl };
return { youtubeUrl: null, videoUrl: null };
}
function getProgramBadges(lesson: DetailCourseRow["modules"][number]["lessons"][number]): ProgramBadge[] {
const lessonMeta = parseLessonDescriptionMeta(lesson.description);
const badges: ProgramBadge[] = [];
if (lessonMeta.contentType === "VIDEO") badges.push("Video");
if (lessonMeta.contentType === "LECTURE") badges.push("Lectura");
if (lessonMeta.contentType === "ACTIVITY") badges.push("Actividad");
if (lessonMeta.contentType === "QUIZ" || lessonMeta.contentType === "FINAL_EXAM") badges.push("Evaluación");
if (lesson.youtubeUrl || lesson.videoUrl) {
if (!badges.includes("Video")) badges.push("Video");
}
if (lesson._count.resources > 0 && !badges.includes("Lectura")) badges.push("Lectura");
if (lesson._count.exercises > 0 && !badges.includes("Evaluación")) badges.push("Evaluación");
if (badges.length === 0) badges.push("Actividad");
return badges;
}
function getPublicCatalogWhere() {
return {
OR: [
{ status: "PUBLISHED" as const },
{
status: "DRAFT" as const,
tags: {
hasSome: PUBLIC_UPCOMING_TAGS,
},
},
],
};
}
export async function getCourseCatalogViewModel(userId: string | null): Promise<CourseCatalogViewModel> {
const courses = await db.course
.findMany({
where: getPublicCatalogWhere(),
include: {
author: {
select: {
fullName: true,
},
},
modules: {
orderBy: {
orderIndex: "asc",
},
select: {
lessons: {
orderBy: {
orderIndex: "asc",
},
select: {
id: true,
estimatedDuration: true,
isFreePreview: true,
youtubeUrl: true,
videoUrl: true,
},
},
},
},
_count: {
select: {
enrollments: true,
},
},
},
orderBy: [
{
updatedAt: "desc",
},
],
})
.catch((error) => {
console.error("Failed to load public course catalog.", error);
return [] as CatalogCourseRow[];
});
const courseIds = courses.map((course) => course.id);
const [enrollments, completedProgress] =
userId && courseIds.length > 0
? await Promise.all([
db.enrollment
.findMany({
where: {
userId,
courseId: {
in: courseIds,
},
},
select: {
courseId: true,
},
})
.catch(() => []),
db.userProgress
.findMany({
where: {
userId,
isCompleted: true,
lesson: {
module: {
courseId: {
in: courseIds,
},
},
},
},
select: {
lesson: {
select: {
module: {
select: {
courseId: true,
},
},
},
},
},
})
.catch(() => []),
])
: [[], []];
const enrollmentSet = new Set(enrollments.map((entry) => entry.courseId));
const completedByCourse = new Map<string, number>();
for (const entry of completedProgress) {
const courseId = entry.lesson.module.courseId;
completedByCourse.set(courseId, (completedByCourse.get(courseId) ?? 0) + 1);
}
const grouped: Record<AcademicStageId, CatalogCourseCardView[]> = {
base: [],
consolidacion: [],
especializacion: [],
};
for (const course of courses) {
const allLessons = course.modules.flatMap((module) => module.lessons);
const totalSeconds = allLessons.reduce((sum, lesson) => sum + lesson.estimatedDuration, 0);
const totalLessons = allLessons.length;
const completedLessons = completedByCourse.get(course.id) ?? 0;
const isEnrolled = enrollmentSet.has(course.id);
const progressPercent = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0;
const stage = resolveAcademicStage(course.level, course.tags);
const availability = resolveAvailability(course.status, course.tags);
const descriptions = getCourseDescriptions(course.description);
const media = getPrimaryMedia(allLessons);
const thumbnailUrl =
resolveThumbnailUrl({
description: course.description,
tags: course.tags,
youtubeUrl: media.youtubeUrl,
videoUrl: media.videoUrl,
}) || null;
grouped[stage.id].push({
id: course.id,
slug: course.slug,
title: getLocalizedText(course.title) || "Programa académico ACVE",
shortDescription: descriptions.short,
longDescription: descriptions.long,
thumbnailUrl,
stageId: stage.id,
stageLabel: stage.levelLabel,
proficiencyLabel: getProficiencyLabel(course.level),
durationLabel: formatDuration(totalSeconds),
durationMinutes: toMinutes(totalSeconds),
lessonCount: totalLessons,
instructor: course.author.fullName || "Equipo académico ACVE",
availabilityLabel: availability.label,
availabilityState: availability.state,
progressPercent,
completedLessons,
totalLessons,
studentsCount: course._count.enrollments,
isEnrolled,
hasPreview: allLessons.some((lesson) => lesson.isFreePreview),
});
}
const sections: CatalogSectionView[] = ACADEMIC_STAGE_ORDER.map((stageId) => {
const config = ACADEMIC_STAGE_CONFIG[stageId];
return {
id: stageId,
anchorId: `programas-${stageId}`,
tabLabel: config.tabLabel,
sectionTitle: config.sectionTitle,
sectionDescription: config.sectionDescription,
courses: grouped[stageId],
};
});
const totalLessons = sections.reduce(
(sum, section) => sum + section.courses.reduce((sectionSum, course) => sectionSum + course.lessonCount, 0),
0,
);
const instructorCount = new Set(sections.flatMap((section) => section.courses.map((course) => course.instructor))).size;
const totalCourses = sections.reduce((sum, section) => sum + section.courses.length, 0);
return {
sections,
totals: {
totalCourses,
totalLessons,
instructorCount,
},
};
}
function toNumber(value: Prisma.Decimal): number {
const asNumber = Number(value);
return Number.isFinite(asNumber) ? asNumber : 0;
}
export async function getCourseDetailViewModel(
slug: string,
userId: string | null,
): Promise<CourseDetailViewModel | null> {
const course = await db.course.findFirst({
where: {
slug,
...getPublicCatalogWhere(),
},
include: {
author: {
select: {
fullName: true,
},
},
modules: {
orderBy: {
orderIndex: "asc",
},
select: {
id: true,
title: true,
orderIndex: true,
lessons: {
orderBy: {
orderIndex: "asc",
},
select: {
id: true,
title: true,
description: true,
orderIndex: true,
estimatedDuration: true,
isFreePreview: true,
youtubeUrl: true,
videoUrl: true,
_count: {
select: {
resources: true,
exercises: true,
},
},
},
},
},
},
_count: {
select: {
enrollments: true,
},
},
},
});
if (!course) return null;
const allLessons = course.modules.flatMap((module) => module.lessons);
const lessonIds = allLessons.map((lesson) => lesson.id);
const [enrollment, completedProgress] = userId
? await Promise.all([
db.enrollment.findUnique({
where: {
userId_courseId: {
userId,
courseId: course.id,
},
},
select: {
id: true,
},
}),
lessonIds.length > 0
? db.userProgress.findMany({
where: {
userId,
isCompleted: true,
lessonId: {
in: lessonIds,
},
},
select: {
lessonId: true,
},
})
: Promise.resolve([] as { lessonId: string }[]),
])
: [null, [] as { lessonId: string }[]];
const completedSet = new Set(completedProgress.map((entry) => entry.lessonId));
const isEnrolled = Boolean(enrollment);
const totalLessons = allLessons.length;
const completedLessons = completedSet.size;
const progressPercent = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0;
const totalSeconds = allLessons.reduce((sum, lesson) => sum + lesson.estimatedDuration, 0);
const stage = resolveAcademicStage(course.level, course.tags);
const availability = resolveAvailability(course.status, course.tags);
const descriptions = getCourseDescriptions(course.description);
const media = getPrimaryMedia(allLessons);
const thumbnailUrl =
resolveThumbnailUrl({
description: course.description,
tags: course.tags,
youtubeUrl: media.youtubeUrl,
videoUrl: media.videoUrl,
}) || null;
let runningOrder = 0;
const modules: CourseProgramModuleView[] = course.modules.map((module) => ({
id: module.id,
title: getLocalizedText(module.title) || `Módulo ${module.orderIndex + 1}`,
order: module.orderIndex + 1,
items: module.lessons.map((lesson) => {
runningOrder += 1;
const lessonMeta = parseLessonDescriptionMeta(lesson.description);
const subtitleRaw = lessonMeta.text || getLocalizedText(lesson.description);
const subtitle = subtitleRaw ? truncateText(subtitleRaw, 130) : "";
const isUpcoming = availability.state !== "published";
const isLocked = !isUpcoming && !isEnrolled && !lesson.isFreePreview;
return {
id: lesson.id,
order: runningOrder,
title: getLocalizedText(lesson.title) || `Lección ${runningOrder}`,
subtitle,
durationLabel: lesson.estimatedDuration > 0 ? formatMinutes(toMinutes(lesson.estimatedDuration)) : null,
badges: getProgramBadges(lesson),
isPreview: lesson.isFreePreview,
isFinalExam: isFinalExam(lessonMeta.contentType),
isLocked,
isCompleted: completedSet.has(lesson.id),
isUpcoming,
};
}),
}));
return {
id: course.id,
slug: course.slug,
title: getLocalizedText(course.title) || "Programa académico ACVE",
shortDescription: descriptions.short,
longDescription: descriptions.long,
thumbnailUrl,
stage,
proficiencyLabel: getProficiencyLabel(course.level),
instructor: course.author.fullName || "Equipo académico ACVE",
availabilityLabel: availability.label,
availabilityState: availability.state,
studentsCount: course._count.enrollments,
lessonCount: totalLessons,
durationLabel: formatDuration(totalSeconds),
durationMinutes: toMinutes(totalSeconds),
moduleCount: modules.length,
modules,
progressPercent,
completedLessons,
totalLessons,
isEnrolled,
firstPreviewLessonId: allLessons.find((lesson) => lesson.isFreePreview)?.id ?? null,
price: toNumber(course.price),
};
}