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,109 @@
import Link from "next/link";
import { BookOpenCheck, Clock3, UserRound } from "lucide-react";
import { cn } from "@/lib/utils";
import type { CatalogCourseCardView } from "@/lib/courses/publicCourses";
type CourseCardProps = {
course: CatalogCourseCardView;
};
const stageVisuals: Record<CatalogCourseCardView["stageId"], string> = {
base: "from-[#eadbc9] via-[#f4e9dc] to-[#fdf8f2]",
consolidacion: "from-[#e7ddd0] via-[#f3ece2] to-[#fdf9f3]",
especializacion: "from-[#e4d6dd] via-[#f2e8ee] to-[#fdf9fc]",
};
const availabilityClass: Record<CatalogCourseCardView["availabilityState"], string> = {
published: "border-emerald-300/70 bg-emerald-50 text-emerald-800 dark:border-emerald-700/40 dark:bg-emerald-900/30 dark:text-emerald-200",
upcoming: "border-amber-300/70 bg-amber-50 text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/30 dark:text-amber-200",
draft: "border-slate-300/70 bg-slate-100 text-slate-700 dark:border-slate-700/60 dark:bg-slate-800/70 dark:text-slate-200",
};
function getInitials(title: string): string {
return title
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((chunk) => chunk[0]?.toUpperCase() ?? "")
.join("");
}
function getCtaLabel(course: CatalogCourseCardView): string {
if (course.availabilityState === "upcoming") return "Ver programa";
if (course.isEnrolled && course.progressPercent > 0 && course.progressPercent < 100) return "Continuar";
return "Conocer programa";
}
export default function CourseCard({ course }: CourseCardProps) {
return (
<Link href={`/courses/${course.slug}`} className="group block h-full">
<article className="flex h-full flex-col overflow-hidden rounded-2xl border border-border/80 bg-card shadow-sm transition-all duration-200 hover:-translate-y-0.5 hover:border-primary/45 hover:shadow-md">
<div className="relative aspect-[16/10] overflow-hidden border-b border-border/70">
{course.thumbnailUrl ? (
<img
alt={`Portada del programa ${course.title}`}
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.02]"
loading="lazy"
src={course.thumbnailUrl}
/>
) : (
<div
className={cn(
"flex h-full w-full items-end bg-gradient-to-br p-4 text-3xl font-semibold text-primary/80",
stageVisuals[course.stageId],
)}
>
{getInitials(course.title)}
</div>
)}
<div className="absolute left-3 top-3 flex flex-wrap gap-2">
<span className="rounded-full border border-primary/35 bg-card/95 px-3 py-1 text-xs font-semibold text-primary">
{course.stageLabel}
</span>
<span className={cn("rounded-full border px-3 py-1 text-xs font-semibold", availabilityClass[course.availabilityState])}>
{course.availabilityLabel}
</span>
</div>
</div>
<div className="flex flex-1 flex-col p-4">
<h3 className="text-xl font-semibold leading-tight text-foreground">{course.title}</h3>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{course.shortDescription}</p>
<div className="mt-4 space-y-2 border-t border-border/70 pt-3 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<Clock3 className="h-3.5 w-3.5 text-primary/80" />
<span>{course.durationLabel}</span>
</div>
<div className="flex items-center gap-2">
<BookOpenCheck className="h-3.5 w-3.5 text-primary/80" />
<span>{course.lessonCount} lecciones</span>
</div>
<div className="flex items-center gap-2">
<UserRound className="h-3.5 w-3.5 text-primary/80" />
<span>{course.instructor}</span>
</div>
</div>
{course.isEnrolled ? (
<div className="mt-4 rounded-xl border border-primary/25 bg-primary/5 px-3 py-2">
<div className="flex items-center justify-between text-xs font-semibold text-primary">
<span>Progreso</span>
<span>{course.progressPercent}%</span>
</div>
<div className="mt-1 h-1.5 w-full rounded-full bg-primary/15">
<div className="h-1.5 rounded-full bg-primary transition-all" style={{ width: `${course.progressPercent}%` }} />
</div>
</div>
) : null}
<div className="mt-4 flex items-center justify-between border-t border-border/70 pt-3 text-sm">
<span className="text-muted-foreground">{course.studentsCount.toLocaleString()} inscritos</span>
<span className="font-semibold text-primary">{getCtaLabel(course)}</span>
</div>
</div>
</article>
</Link>
);
}

View File

@@ -0,0 +1,33 @@
type CourseCatalogIntroProps = {
totalCourses: number;
totalLessons: number;
instructorCount: number;
};
export default function CourseCatalogIntro({ totalCourses, totalLessons, instructorCount }: CourseCatalogIntroProps) {
return (
<section className="acve-panel acve-section-base">
<p className="acve-pill mb-4 w-fit text-sm">Catálogo</p>
<h1 className="acve-heading text-3xl leading-tight md:text-4xl">Formación Académica</h1>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-muted-foreground md:text-base">
Programas de inglés jurídico estructurados por etapa académica para acompañar un progreso sólido, práctico y
alineado con escenarios profesionales internacionales.
</p>
<div className="mt-5 grid gap-3 sm:grid-cols-3">
<article className="rounded-2xl border border-border/80 bg-card/70 px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Programas activos</p>
<p className="mt-1 text-2xl font-semibold text-foreground">{totalCourses}</p>
</article>
<article className="rounded-2xl border border-border/80 bg-card/70 px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Lecciones publicadas</p>
<p className="mt-1 text-2xl font-semibold text-foreground">{totalLessons}</p>
</article>
<article className="rounded-2xl border border-border/80 bg-card/70 px-4 py-3">
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Equipo docente</p>
<p className="mt-1 text-2xl font-semibold text-foreground">{instructorCount}</p>
</article>
</div>
</section>
);
}

View File

@@ -0,0 +1,109 @@
import Link from "next/link";
import { ArrowLeft, BookOpenCheck, Clock3, GraduationCap, UsersRound } from "lucide-react";
import { cn } from "@/lib/utils";
type CourseDetailHeaderProps = {
title: string;
stageLabel: string;
proficiencyLabel: string;
availabilityLabel: string;
availabilityState: "published" | "upcoming" | "draft";
description: string;
thumbnailUrl: string | null;
instructor: string;
durationLabel: string;
lessonCount: number;
studentsCount: number;
};
const availabilityClass: Record<CourseDetailHeaderProps["availabilityState"], string> = {
published: "border-emerald-300/70 bg-emerald-50 text-emerald-800 dark:border-emerald-700/40 dark:bg-emerald-900/30 dark:text-emerald-200",
upcoming: "border-amber-300/70 bg-amber-50 text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/30 dark:text-amber-200",
draft: "border-slate-300/70 bg-slate-100 text-slate-700 dark:border-slate-700/60 dark:bg-slate-800/70 dark:text-slate-200",
};
function initials(text: string): string {
return text
.split(" ")
.filter(Boolean)
.slice(0, 2)
.map((chunk) => chunk[0]?.toUpperCase() ?? "")
.join("");
}
export default function CourseDetailHeader({
title,
stageLabel,
proficiencyLabel,
availabilityLabel,
availabilityState,
description,
thumbnailUrl,
instructor,
durationLabel,
lessonCount,
studentsCount,
}: CourseDetailHeaderProps) {
return (
<section className="acve-panel acve-section-base">
<Link className="inline-flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground" href="/courses">
<ArrowLeft className="h-4 w-4" />
Volver a Formación Académica
</Link>
<div className="mt-4 grid gap-4 lg:grid-cols-[1.45fr_0.95fr]">
<div>
<div className="flex flex-wrap items-center gap-2 text-xs font-semibold">
<span className="rounded-full border border-primary/30 bg-primary/10 px-3 py-1 text-primary">{stageLabel}</span>
<span className="rounded-full border border-border/80 bg-card/80 px-3 py-1 text-muted-foreground">{proficiencyLabel}</span>
<span className={cn("rounded-full border px-3 py-1", availabilityClass[availabilityState])}>{availabilityLabel}</span>
</div>
<h1 className="acve-heading mt-4 text-3xl leading-tight md:text-4xl">{title}</h1>
<p className="mt-3 text-sm leading-relaxed text-muted-foreground md:text-base">{description}</p>
<div className="mt-5 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<article className="rounded-xl border border-border/80 bg-card/70 px-3 py-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Duración</p>
<p className="mt-1 inline-flex items-center gap-1.5 text-sm font-semibold text-foreground">
<Clock3 className="h-3.5 w-3.5 text-primary/80" />
{durationLabel}
</p>
</article>
<article className="rounded-xl border border-border/80 bg-card/70 px-3 py-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Lecciones</p>
<p className="mt-1 inline-flex items-center gap-1.5 text-sm font-semibold text-foreground">
<BookOpenCheck className="h-3.5 w-3.5 text-primary/80" />
{lessonCount}
</p>
</article>
<article className="rounded-xl border border-border/80 bg-card/70 px-3 py-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Instructor</p>
<p className="mt-1 inline-flex items-center gap-1.5 text-sm font-semibold text-foreground">
<GraduationCap className="h-3.5 w-3.5 text-primary/80" />
{instructor}
</p>
</article>
<article className="rounded-xl border border-border/80 bg-card/70 px-3 py-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">Comunidad</p>
<p className="mt-1 inline-flex items-center gap-1.5 text-sm font-semibold text-foreground">
<UsersRound className="h-3.5 w-3.5 text-primary/80" />
{studentsCount.toLocaleString()} inscritos
</p>
</article>
</div>
</div>
<div className="overflow-hidden rounded-2xl border border-border/70 bg-card/70">
{thumbnailUrl ? (
<img alt={`Portada del programa ${title}`} className="h-full min-h-56 w-full object-cover lg:min-h-full" src={thumbnailUrl} />
) : (
<div className="flex h-full min-h-56 items-end bg-gradient-to-br from-[#eadbc9] via-[#f4e9dc] to-[#fdf8f2] p-5 text-5xl font-semibold text-primary/75">
{initials(title)}
</div>
)}
</div>
</div>
</section>
);
}

View File

@@ -0,0 +1,82 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { cn } from "@/lib/utils";
type CourseLevelTabItem = {
id: string;
label: string;
anchorId: string;
count: number;
};
type CourseLevelTabsProps = {
items: CourseLevelTabItem[];
};
export default function CourseLevelTabs({ items }: CourseLevelTabsProps) {
const [activeId, setActiveId] = useState(items[0]?.id ?? "");
const sectionIds = useMemo(() => items.map((item) => item.anchorId), [items]);
useEffect(() => {
if (items.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((entry) => entry.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
if (visible.length === 0) return;
const matched = items.find((item) => item.anchorId === visible[0].target.id);
if (matched) setActiveId(matched.id);
},
{
rootMargin: "-30% 0px -55% 0px",
threshold: [0.2, 0.35, 0.5, 0.7],
},
);
sectionIds.forEach((sectionId) => {
const element = document.getElementById(sectionId);
if (element) observer.observe(element);
});
return () => observer.disconnect();
}, [items, sectionIds]);
const scrollToSection = (anchorId: string, id: string) => {
const section = document.getElementById(anchorId);
if (!section) return;
section.scrollIntoView({ behavior: "smooth", block: "start" });
setActiveId(id);
};
return (
<section className="acve-panel acve-section-tight sticky top-[8.4rem] z-30 border-border/80 bg-card/90 backdrop-blur">
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">Nivel académico</p>
<div className="grid gap-2 sm:grid-cols-3">
{items.map((item) => {
const isActive = item.id === activeId;
return (
<button
key={item.id}
className={cn(
"rounded-xl border px-3 py-2 text-left transition-colors",
isActive
? "border-primary/45 bg-primary/10 text-primary"
: "border-border/80 bg-card/70 text-foreground hover:border-primary/30 hover:bg-accent/60",
)}
type="button"
onClick={() => scrollToSection(item.anchorId, item.id)}
>
<p className="text-sm font-semibold">{item.label}</p>
<p className="text-xs text-muted-foreground">{item.count} programas</p>
</button>
);
})}
</div>
</section>
);
}

View File

@@ -0,0 +1,97 @@
import Link from "next/link";
import { cn } from "@/lib/utils";
type CourseAction = {
label: string;
href?: string;
disabled?: boolean;
};
type CourseProgressCardProps = {
progressPercent: number;
completedLessons: number;
totalLessons: number;
instructor: string;
durationLabel: string;
stageLabel: string;
availabilityLabel: string;
primaryAction: CourseAction;
secondaryAction?: CourseAction;
helperText?: string;
};
function ActionButton({ action, secondary = false }: { action: CourseAction; secondary?: boolean }) {
const classes = cn(
"inline-flex w-full items-center justify-center rounded-xl px-4 py-3 text-sm font-semibold transition-colors",
secondary
? "border border-border bg-card text-foreground hover:bg-accent"
: "acve-button-primary hover:brightness-105",
action.disabled && "cursor-not-allowed opacity-55 hover:brightness-100",
);
if (!action.href || action.disabled) {
return (
<button className={classes} disabled type="button">
{action.label}
</button>
);
}
return (
<Link className={classes} href={action.href}>
{action.label}
</Link>
);
}
export default function CourseProgressCard({
progressPercent,
completedLessons,
totalLessons,
instructor,
durationLabel,
stageLabel,
availabilityLabel,
primaryAction,
secondaryAction,
helperText,
}: CourseProgressCardProps) {
return (
<aside className="acve-panel p-5 md:p-6">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">Seguimiento</p>
<p className="mt-3 text-3xl font-semibold text-foreground">{progressPercent}%</p>
<p className="mt-1 text-sm text-muted-foreground">
{completedLessons}/{totalLessons} lecciones completadas
</p>
<div className="mt-3 h-2 w-full rounded-full bg-primary/15">
<div className="h-2 rounded-full bg-primary transition-all" style={{ width: `${Math.max(0, Math.min(100, progressPercent))}%` }} />
</div>
<dl className="mt-5 space-y-3 border-t border-border/70 pt-4 text-sm">
<div className="flex items-start justify-between gap-3">
<dt className="text-muted-foreground">Instructor</dt>
<dd className="text-right font-medium text-foreground">{instructor}</dd>
</div>
<div className="flex items-start justify-between gap-3">
<dt className="text-muted-foreground">Duración</dt>
<dd className="text-right font-medium text-foreground">{durationLabel}</dd>
</div>
<div className="flex items-start justify-between gap-3">
<dt className="text-muted-foreground">Etapa</dt>
<dd className="text-right font-medium text-foreground">{stageLabel}</dd>
</div>
<div className="flex items-start justify-between gap-3">
<dt className="text-muted-foreground">Disponibilidad</dt>
<dd className="text-right font-medium text-foreground">{availabilityLabel}</dd>
</div>
</dl>
<div className="mt-5 space-y-2">
<ActionButton action={primaryAction} />
{secondaryAction ? <ActionButton action={secondaryAction} secondary /> : null}
</div>
{helperText ? <p className="mt-3 text-xs leading-relaxed text-muted-foreground">{helperText}</p> : null}
</aside>
);
}

View File

@@ -0,0 +1,112 @@
import { LockKeyhole, PlayCircle } from "lucide-react";
import { cn } from "@/lib/utils";
import type { CourseProgramModuleView } from "@/lib/courses/publicCourses";
type ProgramContentListProps = {
modules: CourseProgramModuleView[];
};
const badgeClass: Record<string, string> = {
Video: "border-sky-300/70 bg-sky-50 text-sky-800 dark:border-sky-700/50 dark:bg-sky-900/30 dark:text-sky-200",
Lectura: "border-indigo-300/70 bg-indigo-50 text-indigo-800 dark:border-indigo-700/50 dark:bg-indigo-900/30 dark:text-indigo-200",
Actividad: "border-rose-300/70 bg-rose-50 text-rose-800 dark:border-rose-700/50 dark:bg-rose-900/30 dark:text-rose-200",
"Evaluación":
"border-amber-300/70 bg-amber-50 text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/30 dark:text-amber-200",
};
export default function ProgramContentList({ modules }: ProgramContentListProps) {
return (
<section className="acve-panel acve-section-base">
<div className="mb-5">
<p className="text-xs font-semibold uppercase tracking-[0.14em] text-muted-foreground">Plan de estudios</p>
<h2 className="acve-heading mt-2 text-2xl md:text-3xl">Contenido del Programa</h2>
</div>
{modules.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border bg-muted/35 px-6 py-9 text-center">
<p className="text-lg font-semibold text-foreground">Contenido en preparación</p>
<p className="mt-2 text-sm text-muted-foreground">
El equipo académico está publicando módulos y lecciones para este programa.
</p>
</div>
) : (
<div className="space-y-5">
{modules.map((module) => (
<article key={module.id} className="overflow-hidden rounded-2xl border border-border/80 bg-card/65">
<header className="flex flex-wrap items-center justify-between gap-3 border-b border-border/70 bg-muted/30 px-4 py-3">
<h3 className="text-lg font-semibold text-foreground">
Módulo {module.order}. {module.title}
</h3>
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{module.items.length} lecciones</p>
</header>
<ol className="divide-y divide-border/60">
{module.items.map((item) => (
<li key={item.id} className="px-4 py-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex min-w-8 items-center justify-center rounded-full border border-border bg-card px-2 py-0.5 text-xs font-semibold text-muted-foreground">
{String(item.order).padStart(2, "0")}
</span>
{item.badges.map((badge) => (
<span
key={`${item.id}-${badge}`}
className={cn("rounded-full border px-2 py-0.5 text-[11px] font-semibold", badgeClass[badge])}
>
{badge}
</span>
))}
{item.isPreview ? (
<span className="rounded-full border border-primary/35 bg-primary/10 px-2 py-0.5 text-[11px] font-semibold text-primary">
Vista previa
</span>
) : null}
{item.isFinalExam ? (
<span className="rounded-full border border-amber-300/70 bg-amber-50 px-2 py-0.5 text-[11px] font-semibold text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/30 dark:text-amber-200">
Evaluación final obligatoria
</span>
) : null}
{item.isCompleted ? (
<span className="rounded-full border border-emerald-300/70 bg-emerald-50 px-2 py-0.5 text-[11px] font-semibold text-emerald-800 dark:border-emerald-700/50 dark:bg-emerald-900/30 dark:text-emerald-200">
Completada
</span>
) : null}
</div>
<p className="mt-2 text-sm font-semibold text-foreground md:text-base">{item.title}</p>
{item.subtitle ? <p className="mt-1 text-sm text-muted-foreground">{item.subtitle}</p> : null}
</div>
<div className="flex shrink-0 items-center gap-2 text-xs font-medium text-muted-foreground">
{item.durationLabel ? <span>{item.durationLabel}</span> : null}
{item.isUpcoming ? (
<span className="rounded-full border border-amber-300/70 bg-amber-50 px-2 py-0.5 text-amber-800 dark:border-amber-700/50 dark:bg-amber-900/30 dark:text-amber-200">
Próximamente
</span>
) : item.isLocked ? (
<span className="inline-flex items-center gap-1 rounded-full border border-border bg-muted/35 px-2 py-0.5">
<LockKeyhole className="h-3 w-3" />
Bloqueada
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full border border-primary/30 bg-primary/10 px-2 py-0.5 text-primary">
<PlayCircle className="h-3 w-3" />
Disponible
</span>
)}
</div>
</div>
</li>
))}
</ol>
</article>
))}
</div>
)}
</section>
);
}

View File

@@ -0,0 +1,39 @@
import CourseCard from "@/components/courses/CourseCard";
import type { CatalogSectionView } from "@/lib/courses/publicCourses";
type ProgramSectionProps = {
section: CatalogSectionView;
};
export default function ProgramSection({ section }: ProgramSectionProps) {
return (
<section className="acve-panel acve-section-base scroll-mt-[13.5rem]" id={section.anchorId}>
<div className="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 className="acve-heading text-2xl md:text-3xl">{section.sectionTitle}</h2>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-muted-foreground md:text-base">
{section.sectionDescription}
</p>
</div>
<p className="rounded-full border border-border/80 bg-card/70 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{section.courses.length} programas
</p>
</div>
{section.courses.length === 0 ? (
<div className="mt-6 rounded-2xl border border-dashed border-border bg-muted/35 px-6 py-9 text-center">
<p className="text-lg font-semibold text-foreground">Sin programas visibles por ahora</p>
<p className="mt-2 text-sm text-muted-foreground">
Publicaremos nuevas rutas académicas para esta etapa en próximas actualizaciones.
</p>
</div>
) : (
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{section.courses.map((course) => (
<CourseCard key={course.id} course={course} />
))}
</div>
)}
</section>
);
}

View File

@@ -3,16 +3,21 @@
import { useEffect, useMemo, useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Check, Lock, PlayCircle } from "lucide-react";
import { toggleLessonComplete } from "@/app/(protected)/courses/[slug]/learn/actions";
import { Check, CircleDashed, ClipboardCheck, FileText, Lock, PlayCircle } from "lucide-react";
import { toggleLessonComplete } from "@/app/(public)/courses/[slug]/learn/actions";
import ProgressBar from "@/components/ProgressBar";
import { getLessonContentTypeLabel, isFinalExam, type LessonContentType } from "@/lib/courses/lessonContent";
type ClassroomLesson = {
id: string;
title: string;
description: string;
contentType: LessonContentType;
materialUrl: string | null;
videoUrl: string | null;
youtubeUrl: string | null;
estimatedDuration: number;
isFreePreview: boolean;
};
type ClassroomModule = {
@@ -27,19 +32,44 @@ type StudentClassroomClientProps = {
modules: ClassroomModule[];
initialSelectedLessonId: string;
initialCompletedLessonIds: string[];
isEnrolled: boolean;
};
type CompletionCertificate = {
certificateId: string;
certificateNumber: string | null;
};
function getYouTubeEmbedUrl(url: string | null | undefined): string | null {
if (!url?.trim()) return null;
const trimmed = url.trim();
const watchMatch = trimmed.match(/(?:youtube\.com\/watch\?v=)([a-zA-Z0-9_-]+)/);
if (watchMatch) return `https://www.youtube.com/embed/${watchMatch[1]}`;
const embedMatch = trimmed.match(/(?:youtube\.com\/embed\/)([a-zA-Z0-9_-]+)/);
if (embedMatch) return trimmed;
const shortMatch = trimmed.match(/(?:youtu\.be\/)([a-zA-Z0-9_-]+)/);
if (shortMatch) return `https://www.youtube.com/embed/${shortMatch[1]}`;
return null;
}
function getIsPdfUrl(url: string | null | undefined): boolean {
if (!url) return false;
return /\.pdf(?:$|\?)/i.test(url.trim());
}
export default function StudentClassroomClient({
courseSlug,
courseTitle,
modules,
initialSelectedLessonId,
initialCompletedLessonIds,
isEnrolled,
}: StudentClassroomClientProps) {
const router = useRouter();
const [isSaving, startTransition] = useTransition();
const [selectedLessonId, setSelectedLessonId] = useState(initialSelectedLessonId);
const [completedLessonIds, setCompletedLessonIds] = useState(initialCompletedLessonIds);
const [completionCertificate, setCompletionCertificate] = useState<CompletionCertificate | null>(null);
useEffect(() => {
setSelectedLessonId(initialSelectedLessonId);
@@ -59,7 +89,10 @@ export default function StudentClassroomClient({
const selectedLesson =
flatLessons.find((lesson) => lesson.id === selectedLessonId) ?? flatLessons[0] ?? null;
const selectedLessonTypeLabel = selectedLesson ? getLessonContentTypeLabel(selectedLesson.contentType) : "";
const isRestricted = (lessonId: string) => {
if (!isEnrolled) return false; // Non-enrolled can click any lesson (preview shows content, locked shows premium message)
const lessonIndex = flatLessons.findIndex((lesson) => lesson.id === lessonId);
if (lessonIndex <= 0) return false;
if (completedSet.has(lessonId)) return false;
@@ -67,6 +100,8 @@ export default function StudentClassroomClient({
return !completedSet.has(previousLesson.id);
};
const isLockedForUser = (lesson: ClassroomLesson) => !isEnrolled && !lesson.isFreePreview;
const navigateToLesson = (lessonId: string) => {
if (isRestricted(lessonId)) return;
setSelectedLessonId(lessonId);
@@ -100,6 +135,13 @@ export default function StudentClassroomClient({
return prev.filter((id) => id !== lessonId);
});
if (result.newlyIssuedCertificate && result.certificateId) {
setCompletionCertificate({
certificateId: result.certificateId,
certificateNumber: result.certificateNumber ?? null,
});
}
router.refresh();
});
};
@@ -114,30 +156,155 @@ export default function StudentClassroomClient({
}
return (
<div className="grid gap-6 lg:grid-cols-[1.7fr_1fr]">
<div className="relative grid gap-6 lg:grid-cols-[1.7fr_1fr]">
{completionCertificate ? (
<>
<div className="pointer-events-none fixed inset-0 z-40 overflow-hidden">
{Array.from({ length: 36 }).map((_, index) => (
<span
key={`confetti-${index}`}
className="absolute top-[-24px] h-3 w-2 rounded-sm opacity-90"
style={{
left: `${(index * 2.7) % 100}%`,
backgroundColor: ["#0ea5e9", "#22c55e", "#f59e0b", "#a855f7", "#ef4444"][index % 5],
animation: `acve-confetti-fall ${2.2 + (index % 5) * 0.35}s linear ${index * 0.04}s 1`,
}}
/>
))}
</div>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/55 p-4">
<div className="w-full max-w-lg rounded-2xl border border-slate-200 bg-white p-6 shadow-2xl">
<p className="text-xs font-semibold uppercase tracking-wide text-emerald-600">Course completed</p>
<h2 className="mt-2 text-3xl font-semibold text-slate-900">Congratulations</h2>
<p className="mt-2 text-sm text-slate-600">
You completed all lessons in this course and your ACVE certificate was issued.
</p>
<p className="mt-3 rounded-lg bg-slate-50 px-3 py-2 text-sm text-slate-700">
Certificate: {completionCertificate.certificateNumber ?? "Issued"}
</p>
<div className="mt-5 flex flex-wrap gap-2">
<a
className="acve-button-primary inline-flex px-4 py-2 text-sm font-semibold"
href={`/api/certificates/${completionCertificate.certificateId}/pdf`}
>
Download PDF
</a>
<Link
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50"
href="/profile"
>
Open Profile
</Link>
<button
type="button"
onClick={() => setCompletionCertificate(null)}
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50"
>
Close
</button>
</div>
</div>
</div>
<style jsx global>{`
@keyframes acve-confetti-fall {
0% {
transform: translateY(-24px) rotate(0deg);
}
100% {
transform: translateY(110vh) rotate(540deg);
}
}
`}</style>
</>
) : null}
<section className="space-y-4 rounded-xl border border-slate-200 bg-white p-5">
<Link href={`/courses/${courseSlug}`} className="text-sm font-medium text-slate-600 hover:text-slate-900">
{"<-"} Back to Course
</Link>
<div className="aspect-video overflow-hidden rounded-xl border border-slate-200 bg-black">
{selectedLesson.videoUrl ? (
<video
key={`${selectedLesson.id}-${selectedLesson.videoUrl}`}
className="h-full w-full"
controls
onEnded={handleToggleComplete}
src={selectedLesson.videoUrl}
/>
<div
className={`overflow-hidden rounded-xl border border-slate-200 ${
selectedLesson.contentType === "VIDEO" ? "aspect-video bg-black" : "bg-slate-50 p-5"
}`}
>
{isLockedForUser(selectedLesson) ? (
<div className="flex h-full min-h-[220px] flex-col items-center justify-center gap-4 bg-slate-900 p-6 text-center">
<p className="text-lg font-medium text-white">Contenido premium</p>
<p className="max-w-sm text-sm text-slate-300">
Inscríbete en el curso para desbloquear todas las secciones y registrar tu avance.
</p>
<Link
href={`/courses/${courseSlug}`}
className="rounded-md bg-white px-4 py-2 text-sm font-medium text-slate-900 hover:bg-slate-100"
>
Ver curso e inscripción
</Link>
</div>
) : selectedLesson.contentType === "VIDEO" ? (
getYouTubeEmbedUrl(selectedLesson.youtubeUrl) ? (
<iframe
key={`${selectedLesson.id}-${selectedLesson.youtubeUrl}`}
className="h-full w-full"
src={getYouTubeEmbedUrl(selectedLesson.youtubeUrl)!}
title={selectedLesson.title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
) : selectedLesson.videoUrl ? (
<video
key={`${selectedLesson.id}-${selectedLesson.videoUrl}`}
className="h-full w-full"
controls
onEnded={handleToggleComplete}
src={selectedLesson.videoUrl}
/>
) : (
<div className="flex h-full items-center justify-center text-sm text-slate-300">Video not available for this lesson</div>
)
) : (
<div className="flex h-full items-center justify-center text-sm text-slate-300">
Video not available for this lesson
<div className="space-y-4">
<p className="inline-flex rounded-full border border-slate-300 bg-white px-3 py-1 text-xs font-semibold uppercase tracking-wide text-slate-700">
{selectedLessonTypeLabel}
</p>
{selectedLesson.description ? (
<p className="text-sm leading-relaxed text-slate-700">{selectedLesson.description}</p>
) : (
<p className="text-sm text-slate-600">Este contenido no tiene descripción adicional.</p>
)}
{selectedLesson.materialUrl ? (
getIsPdfUrl(selectedLesson.materialUrl) ? (
<iframe
className="h-[430px] w-full rounded-lg border border-slate-200 bg-white"
src={selectedLesson.materialUrl}
title={`${selectedLesson.title} material`}
/>
) : (
<a
className="inline-flex rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-800 hover:bg-slate-100"
href={selectedLesson.materialUrl}
rel="noreferrer"
target="_blank"
>
Abrir material
</a>
)
) : null}
{isFinalExam(selectedLesson.contentType) ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
Debes completar esta evaluación final para graduarte y emitir el certificado del curso.
</div>
) : null}
</div>
)}
</div>
<div className="space-y-2 rounded-xl border border-slate-200 bg-white p-4">
<h1 className="text-2xl font-semibold text-slate-900">{selectedLesson.title}</h1>
<p className="inline-flex w-fit rounded-full border border-slate-300 bg-slate-50 px-2.5 py-0.5 text-xs font-semibold text-slate-700">
{selectedLessonTypeLabel}
</p>
{selectedLesson.description ? (
<p className="text-sm text-slate-600">{selectedLesson.description}</p>
) : null}
@@ -146,24 +313,27 @@ export default function StudentClassroomClient({
</p>
</div>
<button
type="button"
onClick={handleToggleComplete}
disabled={isSaving}
className="rounded-md border border-slate-300 bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800 disabled:opacity-60"
>
{completedSet.has(selectedLesson.id) ? "Mark as Incomplete" : "Mark as Complete"}
</button>
{isEnrolled && (
<button
type="button"
onClick={handleToggleComplete}
disabled={isSaving}
className="rounded-md border border-slate-300 bg-slate-900 px-4 py-2 text-sm font-medium text-white hover:bg-slate-800 disabled:opacity-60"
>
{completedSet.has(selectedLesson.id)
? "Marcar como pendiente"
: isFinalExam(selectedLesson.contentType)
? "Marcar evaluación final como completada"
: "Marcar como completada"}
</button>
)}
</section>
<aside className="rounded-xl border border-slate-200 bg-white p-4">
<h2 className="mb-3 text-lg font-semibold text-slate-900">Course Content</h2>
<h2 className="mb-3 text-lg font-semibold text-slate-900">Contenido del curso</h2>
<p className="mb-3 text-xs text-slate-500">{courseTitle}</p>
<div className="mb-4">
<ProgressBar
value={progressPercent}
label={`${completedCount}/${totalLessons} lessons (${progressPercent}%)`}
/>
<ProgressBar value={progressPercent} label={`${completedCount}/${totalLessons} lecciones (${progressPercent}%)`} />
</div>
<div className="max-h-[70vh] space-y-4 overflow-y-auto pr-1">
@@ -171,7 +341,7 @@ export default function StudentClassroomClient({
<div key={module.id} className="rounded-xl border border-slate-200 bg-slate-50/40">
<div className="border-b border-slate-200 px-3 py-2">
<p className="text-sm font-semibold text-slate-800">
Module {moduleIndex + 1}: {module.title}
Módulo {moduleIndex + 1}: {module.title}
</p>
</div>
@@ -179,6 +349,7 @@ export default function StudentClassroomClient({
{module.lessons.map((lesson, lessonIndex) => {
const completed = completedSet.has(lesson.id);
const restricted = isRestricted(lesson.id);
const locked = isLockedForUser(lesson);
const active = lesson.id === selectedLesson.id;
return (
@@ -193,17 +364,36 @@ export default function StudentClassroomClient({
: "border-transparent bg-white/70 text-slate-700 hover:border-slate-200"
} ${restricted ? "cursor-not-allowed opacity-60 hover:border-transparent" : ""}`}
>
<span className="inline-flex h-5 w-5 items-center justify-center">
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center">
{completed ? (
<Check className="h-4 w-4 text-emerald-600" />
) : restricted ? (
) : restricted || locked ? (
<Lock className="h-4 w-4 text-slate-400" />
) : lesson.contentType === "LECTURE" ? (
<FileText className="h-4 w-4 text-slate-500" />
) : lesson.contentType === "ACTIVITY" ? (
<CircleDashed className="h-4 w-4 text-slate-500" />
) : lesson.contentType === "QUIZ" || lesson.contentType === "FINAL_EXAM" ? (
<ClipboardCheck className="h-4 w-4 text-slate-500" />
) : (
<PlayCircle className="h-4 w-4 text-slate-500" />
)}
</span>
<span className="line-clamp-1">
<span className="min-w-0 flex-1 line-clamp-1">
{lessonIndex + 1}. {lesson.title}
<span className="ml-1.5 inline rounded bg-slate-100 px-1.5 py-0.5 text-[11px] font-medium text-slate-700">
{getLessonContentTypeLabel(lesson.contentType)}
</span>
{lesson.isFreePreview && (
<span className="ml-1.5 inline rounded bg-emerald-100 px-1.5 py-0.5 text-xs font-medium text-emerald-800">
Vista previa
</span>
)}
{isFinalExam(lesson.contentType) && (
<span className="ml-1.5 inline rounded bg-amber-100 px-1.5 py-0.5 text-xs font-medium text-amber-800">
Obligatoria
</span>
)}
</span>
</button>
);