Pending course, rest ready for launch
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
109
components/courses/CourseCard.tsx
Normal file
109
components/courses/CourseCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
components/courses/CourseCatalogIntro.tsx
Normal file
33
components/courses/CourseCatalogIntro.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
components/courses/CourseDetailHeader.tsx
Normal file
109
components/courses/CourseDetailHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
82
components/courses/CourseLevelTabs.tsx
Normal file
82
components/courses/CourseLevelTabs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
components/courses/CourseProgressCard.tsx
Normal file
97
components/courses/CourseProgressCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
components/courses/ProgramContentList.tsx
Normal file
112
components/courses/ProgramContentList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
components/courses/ProgramSection.tsx
Normal file
39
components/courses/ProgramSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
10
components/theme-provider.tsx
Normal file
10
components/theme-provider.tsx
Normal 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>;
|
||||
}
|
||||
Reference in New Issue
Block a user