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

@@ -1,9 +1,98 @@
import Image from "next/image";
import Link from "next/link";
const academicLinks = [
{ href: "/courses", label: "Formación Académica" },
{ href: "/case-studies", label: "Casos prácticos" },
{ href: "/practice", label: "Retos" },
{ href: "/eventos", label: "Eventos" },
{ href: "/noticias", label: "Noticias", id: "footer-news" },
{ href: "/comunidad", label: "Comunidad", id: "footer-community" },
];
const institutionalLinks = [
{ href: "/auth/login", label: "Ingresa" },
{ href: "/#footer-contact", label: "Contáctanos" },
{ href: "/sobre-acve", label: "Sobre ACVE", id: "footer-about" },
{ href: "mailto:academico@acve.mx", label: "Retroalimentación", external: true },
];
const legalLinks = [
{ href: "/#privacy", label: "Aviso de Privacidad" },
{ href: "/#terms", label: "Términos y Condiciones" },
];
export default function Footer() {
return (
<footer className="border-t bg-muted/30">
<div className="mx-auto flex w-full max-w-[1300px] items-center justify-between px-4 py-6 text-sm text-muted-foreground">
<span className="font-medium">ACVE Centro de Estudios</span>
<span>Professional legal English learning</span>
<footer className="border-t border-border/80 bg-card/95">
<div className="mx-auto w-full max-w-[1300px] px-4 py-12 md:px-6">
<div className="grid gap-10 md:grid-cols-2 xl:grid-cols-[1.4fr_1fr_1fr_1fr]">
<section className="space-y-6">
<Link className="inline-flex items-center gap-3" href="/">
<div className="rounded-xl bg-primary/10 p-2 ring-1 ring-primary/20">
<Image alt="ACVE logo" className="h-10 w-10 rounded-lg object-cover" height={40} src="/images/logo.png" width={40} />
</div>
<div>
<p className="text-xl font-bold tracking-tight text-primary">ACVE Centro de Estudios</p>
<p className="text-sm text-muted-foreground">Empowering Lawyer one word at a time</p>
</div>
</Link>
<div className="space-y-2 text-sm text-muted-foreground" id="footer-contact">
<p className="font-semibold text-foreground">Contacto</p>
<p>Av. José Vasconcelos 345, Torre Tanarah Piso 23, San Pedro, N.L., México.</p>
<p>
Teléfono:{" "}
<a className="text-primary hover:underline" href="tel:+528117789777">
+52-81-17-78-97-77
</a>
</p>
<p>
Correo:{" "}
<a className="text-primary hover:underline" href="mailto:academico@acve.mx">
academico@acve.mx
</a>
</p>
</div>
</section>
<nav className="space-y-3 text-sm">
<p className="font-semibold text-foreground">Navegación</p>
{academicLinks.map((link) => (
<Link key={link.label} className="block text-muted-foreground transition hover:text-primary" href={link.href} id={link.id}>
{link.label}
</Link>
))}
</nav>
<nav className="space-y-3 text-sm">
<p className="font-semibold text-foreground">Institucional</p>
{institutionalLinks.map((link) =>
link.external ? (
<a key={link.label} className="block text-muted-foreground transition hover:text-primary" href={link.href} id={link.id}>
{link.label}
</a>
) : (
<Link key={link.label} className="block text-muted-foreground transition hover:text-primary" href={link.href} id={link.id}>
{link.label}
</Link>
),
)}
</nav>
<nav className="space-y-3 text-sm">
<p className="font-semibold text-foreground">Legal</p>
{legalLinks.map((link) => (
<Link key={link.label} className="block text-muted-foreground transition hover:text-primary" href={link.href} id={link.href.slice(2)}>
{link.label}
</Link>
))}
</nav>
</div>
<div className="mt-10 border-t border-border/70 pt-5 text-xs text-muted-foreground">
<p>© {new Date().getFullYear()} ACVE Centro de Estudios. Todos los derechos reservados.</p>
</div>
</div>
</footer>
);

View File

@@ -4,10 +4,10 @@ import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { usePathname } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { ASSISTANT_TOGGLE_EVENT } from "@/components/AssistantDrawer";
import { useEffect, useState } from "react";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { DEMO_AUTH_EMAIL_COOKIE, DEMO_AUTH_ROLE_COOKIE } from "@/lib/auth/demoAuth";
import { isTeacherEmailAllowed, readTeacherEmailsBrowser } from "@/lib/auth/teacherAllowlist";
import { supabaseBrowser } from "@/lib/supabase/browser";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -18,10 +18,13 @@ type NavLink = {
};
const navLinks: NavLink[] = [
{ href: "/", label: "Home" },
{ href: "/courses", label: "Courses" },
{ href: "/case-studies", label: "Case Studies" },
{ href: "/practice", label: "Practice" },
{ href: "/courses", label: "Formación Académica" },
{ href: "/case-studies", label: "Casos prácticos" },
{ href: "/practice", label: "Retos" },
{ href: "/eventos", label: "Eventos" },
{ href: "/noticias", label: "Noticias" },
{ href: "/comunidad", label: "Comunidad" },
{ href: "/sobre-acve", label: "Sobre ACVE" },
];
export default function Navbar() {
@@ -29,8 +32,8 @@ export default function Navbar() {
const router = useRouter();
const [userEmail, setUserEmail] = useState<string | null>(null);
const [isTeacher, setIsTeacher] = useState(false);
const teacherEmails = useMemo(() => readTeacherEmailsBrowser(), []);
const [mounted, setMounted] = useState(false);
const { resolvedTheme, setTheme } = useTheme();
useEffect(() => {
const client = supabaseBrowser();
@@ -49,140 +52,185 @@ export default function Navbar() {
const email = cookieMap.get(DEMO_AUTH_EMAIL_COOKIE) ?? null;
const role = cookieMap.get(DEMO_AUTH_ROLE_COOKIE) ?? "";
setUserEmail(email);
setIsTeacher(role === "teacher" || isTeacherEmailAllowed(email, teacherEmails));
setIsTeacher(role === "teacher");
return;
}
let mounted = true;
client.auth.getUser().then(({ data }) => {
const fetchSession = async () => {
const {
data: { user },
} = await client.auth.getUser();
if (!mounted) return;
const email = data.user?.email ?? null;
const email = user?.email ?? null;
setUserEmail(email);
setIsTeacher(isTeacherEmailAllowed(email, teacherEmails));
});
if (!user) {
setIsTeacher(false);
return;
}
try {
const res = await fetch("/api/auth/session");
if (!mounted) return;
const data = await res.json();
setIsTeacher(data.isTeacher === true);
} catch {
if (!mounted) return;
setIsTeacher(false);
}
};
const { data } = client.auth.onAuthStateChange((_event, session) => {
const email = session?.user?.email ?? null;
setUserEmail(email);
setIsTeacher(isTeacherEmailAllowed(email, teacherEmails));
fetchSession();
const {
data: { subscription },
} = client.auth.onAuthStateChange(() => {
fetchSession();
});
return () => {
mounted = false;
data.subscription.unsubscribe();
subscription.unsubscribe();
};
}, [teacherEmails]);
}, []);
const links = useMemo(() => {
if (!isTeacher) return navLinks;
return [...navLinks, { href: "/teacher", label: "Teacher Dashboard" }];
}, [isTeacher]);
useEffect(() => {
setMounted(true);
}, []);
const authNode = useMemo(() => {
if (!userEmail) {
return (
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild>
<Link href="/auth/login">Login</Link>
</Button>
<Button size="sm" asChild>
<Link href="/auth/signup">Sign up</Link>
</Button>
</div>
);
const handleLogout = async () => {
const loginSwitchUrl = "/auth/login?switchUser=1&redirectTo=/courses";
document.cookie = `${DEMO_AUTH_EMAIL_COOKIE}=; path=/; max-age=0`;
document.cookie = `${DEMO_AUTH_ROLE_COOKIE}=; path=/; max-age=0`;
const client = supabaseBrowser();
if (!client) {
setUserEmail(null);
setIsTeacher(false);
router.replace(loginSwitchUrl);
router.refresh();
return;
}
return (
<div className="flex items-center gap-2 text-sm">
<span className="hidden max-w-36 truncate text-muted-foreground sm:inline-block">{userEmail}</span>
{!isTeacher ? (
<Link
className="inline-flex items-center rounded-md border border-amber-300 bg-amber-50 px-2 py-1 text-xs font-semibold text-amber-900"
href="/auth/login?role=teacher"
>
Teacher area (Teacher only)
</Link>
) : null}
<Button
variant="outline"
size="sm"
onClick={async () => {
const client = supabaseBrowser();
if (!client) {
document.cookie = `${DEMO_AUTH_EMAIL_COOKIE}=; path=/; max-age=0`;
document.cookie = `${DEMO_AUTH_ROLE_COOKIE}=; path=/; max-age=0`;
setUserEmail(null);
setIsTeacher(false);
router.refresh();
return;
}
await client.auth.signOut();
}}
>
Logout
</Button>
</div>
);
}, [isTeacher, router, userEmail]);
await client.auth.signOut();
setUserEmail(null);
setIsTeacher(false);
router.replace(loginSwitchUrl);
router.refresh();
};
const isDark = resolvedTheme === "dark";
const loginHref = userEmail ? "/profile" : "/auth/login";
const loginLabel = userEmail ? "Mi cuenta" : "Ingresa";
const isNavActive = (href: string) => {
return pathname === href || pathname?.startsWith(`${href}/`);
};
return (
<header className="sticky top-0 z-40 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex w-full max-w-[1300px] items-center justify-between gap-4 px-4 py-3">
<div className="flex items-center gap-8">
<Link className="flex items-center gap-3" href="/">
<div className="rounded-xl bg-accent p-1.5 shadow-sm">
<Image alt="ACVE logo" className="h-10 w-10 rounded-lg object-cover" height={40} src="/images/logo.png" width={40} />
</div>
<div>
<div className="text-2xl font-bold leading-none tracking-tight text-primary md:text-4xl">ACVE</div>
<div className="-mt-1 text-xs text-muted-foreground md:text-sm">Centro de Estudios</div>
</div>
</Link>
<nav className="hidden items-center gap-1 text-sm lg:flex">
{links.map((link) => {
const isActive = pathname === link.href || pathname?.startsWith(`${link.href}/`);
return (
<Button
key={link.href}
variant={isActive ? "default" : "ghost"}
asChild
className={cn("rounded-xl text-sm font-semibold", !isActive && "text-muted-foreground hover:text-primary")}
>
<Link href={link.href}>{link.label}</Link>
</Button>
);
})}
</nav>
</div>
<header className="sticky top-0 z-50 border-b border-border/70 bg-background/90 backdrop-blur supports-[backdrop-filter]:bg-background/70">
<div className="border-b border-border/60 bg-card/80">
<div className="mx-auto flex w-full max-w-[1300px] items-center gap-3 px-4 py-2 text-xs text-muted-foreground md:px-6">
<div className="hidden items-center gap-4 sm:flex">
<Link className="hover:text-foreground" href="/auth/signup">
Únete
</Link>
<Link className="hover:text-foreground" href={loginHref}>
{loginLabel}
</Link>
<Link className="hover:text-foreground" href="/#footer-contact">
Contáctanos
</Link>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="border-primary/20 text-primary hover:bg-primary/5 hover:text-primary"
onClick={() => window.dispatchEvent(new Event(ASSISTANT_TOGGLE_EVENT))}
>
AI Assistant
</Button>
{authNode}
<div className="ml-auto flex items-center gap-2">
<Button
aria-label={isDark ? "Cambiar a modo claro" : "Cambiar a modo oscuro"}
className="rounded-full"
size="icon"
type="button"
variant="ghost"
onClick={() => setTheme(isDark ? "light" : "dark")}
>
{mounted && isDark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</Button>
{userEmail ? (
<>
<span className="hidden max-w-44 truncate px-2 text-muted-foreground md:inline-block">{userEmail}</span>
<Button className="rounded-full" size="sm" variant="outline" asChild>
<Link href="/profile">Mi cuenta</Link>
</Button>
<Button className="rounded-full" size="sm" type="button" variant="ghost" onClick={handleLogout}>
Salir
</Button>
</>
) : (
<Button className="rounded-full" size="sm" variant="outline" asChild>
<Link href="/auth/login">Ingresa</Link>
</Button>
)}
</div>
</div>
</div>
<nav className="mx-auto flex w-full max-w-[1300px] gap-2 overflow-x-auto px-4 pb-3 text-sm lg:hidden">
{links.map((link) => {
const isActive = pathname === link.href;
return (
<Button
key={link.href}
variant={isActive ? "default" : "ghost"}
size="sm"
asChild
className="whitespace-nowrap rounded-xl"
>
<Link href={link.href}>{link.label}</Link>
<div className="mx-auto w-full max-w-[1300px] px-4 md:px-6">
<div className="flex items-center justify-between gap-4 py-4">
<Link className="flex items-center gap-3" href="/">
<div className="rounded-xl bg-primary/10 p-1.5 shadow-sm ring-1 ring-primary/20">
<Image alt="ACVE logo" className="h-10 w-10 rounded-lg object-cover" height={40} src="/images/logo.png" width={40} />
</div>
<div className="leading-tight">
<div className="text-2xl font-bold tracking-tight text-primary md:text-3xl">ACVE Centro de Estudios</div>
<div className="text-xs text-muted-foreground md:text-sm">Empowering Lawyer one word at a time</div>
</div>
</Link>
<div className="flex items-center gap-2">
{isTeacher ? (
<Button className="hidden rounded-full md:inline-flex" size="sm" variant="outline" asChild>
<Link href="/teacher">Panel docente</Link>
</Button>
) : null}
<Button className="rounded-full px-5" asChild>
<Link href="/auth/signup">¡Inscríbete!</Link>
</Button>
);
})}
</nav>
</div>
</div>
<nav className="hidden items-center gap-1 border-t border-border/70 py-3 text-sm lg:flex">
{navLinks.map((link) => {
const isActive = isNavActive(link.href);
return (
<Button
key={link.href}
className={cn("rounded-full px-4 font-semibold", !isActive && "text-muted-foreground hover:text-foreground")}
variant={isActive ? "default" : "ghost"}
asChild
>
<Link href={link.href}>{link.label}</Link>
</Button>
);
})}
</nav>
<nav className="flex gap-2 overflow-x-auto border-t border-border/70 pb-3 pt-2 text-sm lg:hidden">
{navLinks.map((link) => {
const isActive = isNavActive(link.href);
return (
<Button
key={link.href}
className="whitespace-nowrap rounded-full px-4"
size="sm"
variant={isActive ? "default" : "outline"}
asChild
>
<Link href={link.href}>{link.label}</Link>
</Button>
);
})}
</nav>
</div>
</header>
);
}

View File

@@ -1,7 +1,7 @@
"use client";
import { createBrowserClient } from "@supabase/ssr";
import { FormEvent, useState } from "react";
import { FormEvent, useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -9,17 +9,24 @@ type LoginFormProps = {
redirectTo: string;
role?: string;
showForgot?: boolean;
skipAuthedRedirect?: boolean;
};
// Helper to prevent open redirect vulnerabilities
const normalizeRedirect = (redirectTo: string) => {
if (redirectTo.startsWith("/") && !redirectTo.startsWith("//")) {
return redirectTo;
if (!redirectTo.startsWith("/") || redirectTo.startsWith("//")) {
return "/courses";
}
return "/home"; // Default fallback
// Never redirect back into auth routes after successful login.
if (redirectTo.startsWith("/auth/")) {
return "/courses";
}
return redirectTo;
};
export default function LoginForm({ redirectTo, role, showForgot }: LoginFormProps) {
export default function LoginForm({ redirectTo, role, showForgot, skipAuthedRedirect }: LoginFormProps) {
const router = useRouter();
const safeRedirect = normalizeRedirect(redirectTo);
const isTeacher = role === "teacher";
@@ -30,6 +37,23 @@ export default function LoginForm({ redirectTo, role, showForgot }: LoginFormPro
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (skipAuthedRedirect) {
return;
}
const supabase = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
);
supabase.auth.getUser().then(({ data }) => {
if (data.user) {
router.replace(safeRedirect);
}
});
}, [router, safeRedirect, skipAuthedRedirect]);
// Construct the "Forgot Password" link to preserve context
const forgotHref = `/auth/login?redirectTo=${encodeURIComponent(safeRedirect)}${isTeacher ? "&role=teacher" : ""
}&forgot=1`;
@@ -62,8 +86,9 @@ export default function LoginForm({ redirectTo, role, showForgot }: LoginFormPro
// so they see the new cookie immediately.
router.refresh();
// 4. Navigate to the protected page
router.push(safeRedirect);
// 4. Navigate to the protected page and release button state.
setLoading(false);
router.replace(safeRedirect);
};
return (
@@ -149,4 +174,4 @@ export default function LoginForm({ redirectTo, role, showForgot }: LoginFormPro
</div>
</div>
);
}
}

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>
);

View File

@@ -7,14 +7,22 @@ import {
createLesson,
reorderModules,
reorderLessons,
updateModuleTitle,
} from "@/app/(protected)/teacher/actions";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { toast } from "sonner";
import { getClientLocale } from "@/lib/i18n/clientLocale";
import { Prisma } from "@prisma/client";
function parseLearningOutcomes(val: Prisma.JsonValue | null | undefined): string[] {
if (val == null) return [];
if (Array.isArray(val)) return val.filter((x): x is string => typeof x === "string");
return [];
}
type CourseData = {
id: string;
slug: string;
@@ -23,6 +31,7 @@ type CourseData = {
level: string;
status: string;
price: number;
learningOutcomes?: Prisma.JsonValue | null;
modules: {
id: string;
title: Prisma.JsonValue;
@@ -34,11 +43,38 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
const router = useRouter();
const [loading, setLoading] = useState(false);
const [optimisticModules, setOptimisticModules] = useState(course.modules);
const [editingModuleId, setEditingModuleId] = useState<string | null>(null);
const [editingTitle, setEditingTitle] = useState("");
const [learningOutcomes, setLearningOutcomes] = useState<string[]>(() =>
parseLearningOutcomes(course.learningOutcomes)
);
useEffect(() => {
setOptimisticModules(course.modules);
}, [course.modules]);
useEffect(() => {
setLearningOutcomes(parseLearningOutcomes(course.learningOutcomes));
}, [course.learningOutcomes]);
const showSavedToast = () => {
const isSpanish = getClientLocale() === "es";
toast.success(isSpanish ? "Cambios guardados" : "Changes saved", {
description: isSpanish
? "Puedes seguir editando o volver al panel de cursos."
: "You can keep editing or go back to the courses dashboard.",
action: {
label: isSpanish ? "Volver a cursos" : "Back to courses",
onClick: () => router.push("/teacher"),
},
cancel: {
label: isSpanish ? "Seguir editando" : "Keep editing",
onClick: () => {},
},
duration: 7000,
});
};
// Helper for JSON/String fields
const getStr = (val: Prisma.JsonValue) => {
if (typeof val === "string") return val;
@@ -52,12 +88,16 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
};
// 1. SAVE COURSE SETTINGS
async function handleSubmit(formData: FormData) {
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setLoading(true);
const form = event.currentTarget;
const formData = new FormData(form);
formData.set("learningOutcomes", JSON.stringify(learningOutcomes));
const res = await updateCourse(course.id, course.slug, formData);
if (res.success) {
toast.success("Curso actualizado");
showSavedToast();
router.refresh();
} else {
toast.error("Error al guardar");
@@ -70,7 +110,21 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
setLoading(true); // Block UI while working
const res = await createModule(course.id);
if (res.success) {
toast.success("Módulo agregado");
const isSpanish = getClientLocale() === "es";
toast.success(isSpanish ? "Módulo creado" : "Module created", {
description: isSpanish
? "Puedes seguir editando o volver al panel de cursos."
: "You can keep editing or go back to the courses dashboard.",
action: {
label: isSpanish ? "Volver a cursos" : "Back to courses",
onClick: () => router.push("/teacher"),
},
cancel: {
label: isSpanish ? "Seguir editando" : "Keep editing",
onClick: () => {},
},
duration: 7000,
});
router.refresh();
} else {
toast.error("Error al crear módulo");
@@ -92,6 +146,33 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
}
};
const startEditingModuleTitle = (moduleId: string, currentTitle: string) => {
setEditingModuleId(moduleId);
setEditingTitle(currentTitle || "");
};
const cancelEditingModuleTitle = () => {
setEditingModuleId(null);
setEditingTitle("");
};
const saveModuleTitle = async () => {
if (!editingModuleId || !editingTitle.trim()) {
cancelEditingModuleTitle();
return;
}
setLoading(true);
const res = await updateModuleTitle(editingModuleId, editingTitle.trim());
if (res.success) {
toast.success("Título actualizado");
cancelEditingModuleTitle();
router.push(`/teacher/courses/${course.slug}/edit`);
} else {
toast.error(res.error ?? "Error al guardar");
}
setLoading(false);
};
// 4. REORDER MODULES (optimistic)
const handleReorderModule = async (moduleIndex: number, direction: "up" | "down") => {
const swapWith = direction === "up" ? moduleIndex - 1 : moduleIndex + 1;
@@ -167,7 +248,7 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
{/* LEFT COLUMN: Main Info */}
<div className="lg:col-span-2 space-y-6">
<form id="edit-form" action={handleSubmit} className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm space-y-4">
<form id="edit-form" onSubmit={handleSubmit} className="bg-white p-6 rounded-xl border border-slate-200 shadow-sm space-y-4">
{/* Title */}
<div>
@@ -190,6 +271,48 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
/>
</div>
{/* What you will learn */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Qué aprenderán (What you will learn)
</label>
<p className="text-xs text-slate-500 mb-2">
Una línea por resultado de aprendizaje. Se muestra en la página pública del curso.
</p>
<ul className="space-y-2">
{learningOutcomes.map((text, index) => (
<li key={index} className="flex gap-2">
<input
type="text"
value={text}
onChange={(e) => {
const next = [...learningOutcomes];
next[index] = e.target.value;
setLearningOutcomes(next);
}}
placeholder="Ej. Comprender vocabulario jurídico en contexto"
className="flex-1 rounded-md border border-slate-300 px-3 py-2 text-sm outline-none focus:border-black transition-all"
/>
<button
type="button"
onClick={() => setLearningOutcomes(learningOutcomes.filter((_, i) => i !== index))}
className="rounded-md border border-slate-300 px-2 py-1 text-sm text-slate-600 hover:bg-slate-100"
aria-label="Quitar"
>
</button>
</li>
))}
</ul>
<button
type="button"
onClick={() => setLearningOutcomes([...learningOutcomes, ""])}
className="mt-2 text-sm text-blue-600 font-medium hover:underline"
>
+ Agregar resultado de aprendizaje
</button>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Nivel</label>
@@ -247,33 +370,76 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
{/* Module Header */}
<div className="bg-slate-50 px-4 py-2 border-b border-slate-200 flex justify-between items-center">
<div className="flex items-center gap-2">
<span className="font-medium text-sm text-slate-800">{getStr(module.title)}</span>
<div className="flex items-center">
<button
type="button"
onClick={() => handleReorderModule(moduleIndex, "up")}
disabled={moduleIndex === 0}
className="p-1.5 rounded-lg text-slate-500 hover:bg-slate-200 hover:text-slate-800 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors"
aria-label="Mover módulo arriba"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
</button>
<button
type="button"
onClick={() => handleReorderModule(moduleIndex, "down")}
disabled={moduleIndex === optimisticModules.length - 1}
className="p-1.5 rounded-lg text-slate-500 hover:bg-slate-200 hover:text-slate-800 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors"
aria-label="Mover módulo abajo"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
{editingModuleId === module.id ? (
<div className="flex items-center gap-2 flex-1 min-w-0">
<input
type="text"
value={editingTitle}
onChange={(e) => setEditingTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") saveModuleTitle();
if (e.key === "Escape") cancelEditingModuleTitle();
}}
className="flex-1 min-w-0 rounded-md border border-slate-300 px-2 py-1 text-sm font-medium text-slate-800 outline-none focus:border-black"
autoFocus
disabled={loading}
/>
<button
type="button"
onClick={saveModuleTitle}
disabled={loading || !editingTitle.trim()}
className="rounded-md bg-black px-2 py-1 text-xs font-medium text-white hover:bg-slate-800 disabled:opacity-50"
>
Guardar
</button>
<button
type="button"
onClick={cancelEditingModuleTitle}
disabled={loading}
className="rounded-md border border-slate-300 px-2 py-1 text-xs font-medium text-slate-700 hover:bg-slate-100"
>
Cancelar
</button>
</div>
) : (
<>
<span className="font-medium text-sm text-slate-800">{getStr(module.title)}</span>
<div className="flex items-center">
<button
type="button"
onClick={() => handleReorderModule(moduleIndex, "up")}
disabled={moduleIndex === 0}
className="p-1.5 rounded-lg text-slate-500 hover:bg-slate-200 hover:text-slate-800 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors"
aria-label="Mover módulo arriba"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 15l7-7 7 7" />
</svg>
</button>
<button
type="button"
onClick={() => handleReorderModule(moduleIndex, "down")}
disabled={moduleIndex === optimisticModules.length - 1}
className="p-1.5 rounded-lg text-slate-500 hover:bg-slate-200 hover:text-slate-800 disabled:opacity-30 disabled:cursor-not-allowed disabled:hover:bg-transparent transition-colors"
aria-label="Mover módulo abajo"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
</div>
</>
)}
</div>
<button className="text-xs text-slate-500 hover:text-black">Editar Título</button>
{editingModuleId !== module.id && (
<button
type="button"
onClick={() => startEditingModuleTitle(module.id, getStr(module.title))}
className="text-xs text-slate-500 hover:text-black"
>
Editar Título
</button>
)}
</div>
{/* Lessons List */}
@@ -370,4 +536,4 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
</div>
</div>
);
}
}

View File

@@ -0,0 +1,10 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
type ThemeProviderProps = React.ComponentProps<typeof NextThemesProvider>;
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}