diff --git a/.env.local.example b/.env.local.example index f3304ba..8ca7c51 100755 --- a/.env.local.example +++ b/.env.local.example @@ -1,5 +1,4 @@ NEXT_PUBLIC_SUPABASE_URL=YOUR_URL NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_ANON_KEY -TEACHER_EMAILS=teacher@example.com DATABASE_URL= DIRECT_URL= diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/Centro ACVE - Pagina Cursos.pdf b/Centro ACVE - Pagina Cursos.pdf new file mode 100644 index 0000000..d932b02 Binary files /dev/null and b/Centro ACVE - Pagina Cursos.pdf differ diff --git a/Course1/ACVE - Evaluación Final.pdf b/Course1/ACVE - Evaluación Final.pdf new file mode 100644 index 0000000..8e23577 Binary files /dev/null and b/Course1/ACVE - Evaluación Final.pdf differ diff --git a/Course1/ACVE - Sección 1 - Lectura.pdf b/Course1/ACVE - Sección 1 - Lectura.pdf new file mode 100644 index 0000000..c140a5b Binary files /dev/null and b/Course1/ACVE - Sección 1 - Lectura.pdf differ diff --git a/Course1/ACVE - Sección 2 - Actividad.pdf b/Course1/ACVE - Sección 2 - Actividad.pdf new file mode 100644 index 0000000..7d389ee Binary files /dev/null and b/Course1/ACVE - Sección 2 - Actividad.pdf differ diff --git a/Course1/ACVE - Sección 3 - Lectura y Quiz.pdf b/Course1/ACVE - Sección 3 - Lectura y Quiz.pdf new file mode 100644 index 0000000..ae16e14 Binary files /dev/null and b/Course1/ACVE - Sección 3 - Lectura y Quiz.pdf differ diff --git a/Course1/ACVE - Sección 4 - Actividad.pdf b/Course1/ACVE - Sección 4 - Actividad.pdf new file mode 100644 index 0000000..9bf3d72 Binary files /dev/null and b/Course1/ACVE - Sección 4 - Actividad.pdf differ diff --git a/Course1/ACVE - Sección 5 - Actividad.pdf b/Course1/ACVE - Sección 5 - Actividad.pdf new file mode 100644 index 0000000..ce50f20 Binary files /dev/null and b/Course1/ACVE - Sección 5 - Actividad.pdf differ diff --git a/Course1/ACVE - Sección 6 - Actividad.pdf b/Course1/ACVE - Sección 6 - Actividad.pdf new file mode 100644 index 0000000..d206c41 Binary files /dev/null and b/Course1/ACVE - Sección 6 - Actividad.pdf differ diff --git a/Course1/ACVE - Sección 7 - Actividad.pdf b/Course1/ACVE - Sección 7 - Actividad.pdf new file mode 100644 index 0000000..2518781 Binary files /dev/null and b/Course1/ACVE - Sección 7 - Actividad.pdf differ diff --git a/Página de Inicio Centro de Estudios.pdf b/Página de Inicio Centro de Estudios.pdf new file mode 100644 index 0000000..71b61f7 Binary files /dev/null and b/Página de Inicio Centro de Estudios.pdf differ diff --git a/app/(auth)/auth/login/page.tsx b/app/(auth)/auth/login/page.tsx index bd4b43b..784e429 100755 --- a/app/(auth)/auth/login/page.tsx +++ b/app/(auth)/auth/login/page.tsx @@ -5,6 +5,7 @@ type LoginPageProps = { redirectTo?: string | string[]; role?: string | string[]; forgot?: string | string[]; + switchUser?: string | string[]; }>; }; @@ -16,6 +17,15 @@ export default async function LoginPage({ searchParams }: LoginPageProps) { const role = Array.isArray(roleValue) ? roleValue[0] : roleValue; const forgotValue = params.forgot; const forgot = Array.isArray(forgotValue) ? forgotValue[0] : forgotValue; + const switchUserValue = params.switchUser; + const switchUser = Array.isArray(switchUserValue) ? switchUserValue[0] : switchUserValue; - return ; + return ( + + ); } diff --git a/app/(protected)/courses/[slug]/learn/page.tsx b/app/(protected)/courses/[slug]/learn/page.tsx deleted file mode 100755 index 4cd0923..0000000 --- a/app/(protected)/courses/[slug]/learn/page.tsx +++ /dev/null @@ -1,130 +0,0 @@ -import { notFound, redirect } from "next/navigation"; -import { db } from "@/lib/prisma"; -import { requireUser } from "@/lib/auth/requireUser"; -import StudentClassroomClient from "@/components/courses/StudentClassroomClient"; - -function getText(value: unknown): string { - if (!value) return ""; - if (typeof value === "string") return value; - if (typeof value === "object") { - const record = value as Record; - if (typeof record.es === "string") return record.es; - if (typeof record.en === "string") return record.en; - } - return ""; -} - -type PageProps = { - params: Promise<{ slug: string }>; - searchParams: Promise<{ lesson?: string }>; -}; - -export default async function CourseLearnPage({ params, searchParams }: PageProps) { - const { slug } = await params; - const { lesson: requestedLessonId } = await searchParams; - - const user = await requireUser(); - if (!user?.id) { - redirect(`/courses/${slug}`); - } - - const course = await db.course.findUnique({ - where: { slug }, - select: { - id: true, - slug: true, - title: true, - price: true, - modules: { - orderBy: { orderIndex: "asc" }, - select: { - id: true, - title: true, - lessons: { - orderBy: { orderIndex: "asc" }, - select: { - id: true, - title: true, - description: true, - videoUrl: true, - estimatedDuration: true, - }, - }, - }, - }, - }, - }); - - if (!course) { - notFound(); - } - - let enrollment = await db.enrollment.findUnique({ - where: { - userId_courseId: { - userId: user.id, - courseId: course.id, - }, - }, - select: { id: true }, - }); - - if (!enrollment) { - const isFree = Number(course.price) === 0; - if (isFree) { - enrollment = await db.enrollment.create({ - data: { - userId: user.id, - courseId: course.id, - amountPaid: 0, - }, - select: { id: true }, - }); - } else { - redirect(`/courses/${slug}`); - } - } - - const completedProgress = await db.userProgress.findMany({ - where: { - userId: user.id, - isCompleted: true, - lesson: { - module: { - courseId: course.id, - }, - }, - }, - select: { - lessonId: true, - }, - }); - - const modules = course.modules.map((module) => ({ - id: module.id, - title: getText(module.title) || "Untitled module", - lessons: module.lessons.map((lesson) => ({ - id: lesson.id, - title: getText(lesson.title) || "Untitled lesson", - description: getText(lesson.description), - videoUrl: lesson.videoUrl, - estimatedDuration: lesson.estimatedDuration, - })), - })); - - const flattenedLessonIds = modules.flatMap((module) => module.lessons.map((lesson) => lesson.id)); - const initialSelectedLessonId = - requestedLessonId && flattenedLessonIds.includes(requestedLessonId) - ? requestedLessonId - : flattenedLessonIds[0] ?? ""; - - return ( - progress.lessonId)} - /> - ); -} diff --git a/app/(protected)/my-courses/page.tsx b/app/(protected)/my-courses/page.tsx new file mode 100644 index 0000000..34a7147 --- /dev/null +++ b/app/(protected)/my-courses/page.tsx @@ -0,0 +1,229 @@ +import Link from "next/link"; +import { redirect } from "next/navigation"; +import { UserRole } from "@prisma/client"; +import { requireUser } from "@/lib/auth/requireUser"; +import { db } from "@/lib/prisma"; + +function getText(value: unknown): string { + if (!value) return ""; + if (typeof value === "string") return value; + if (typeof value === "object") { + const record = value as Record; + if (typeof record.en === "string") return record.en; + if (typeof record.es === "string") return record.es; + } + return ""; +} + +export default async function MyCoursesPage() { + const user = await requireUser(); + if (!user?.id) { + redirect("/auth/login?redirectTo=/my-courses"); + } + + const isTeacher = user.role === UserRole.TEACHER || user.role === UserRole.SUPER_ADMIN; + + if (isTeacher) { + const courses = await db.course.findMany({ + where: { authorId: user.id }, + include: { + modules: { + include: { + lessons: { + select: { + id: true, + videoUrl: true, + youtubeUrl: true, + }, + }, + }, + }, + _count: { + select: { + enrollments: true, + }, + }, + }, + orderBy: { updatedAt: "desc" }, + }); + + return ( +
+
+

My Courses

+

Your created courses

+

+ Review, edit, and publish the courses you are building for your students. +

+
+ + {courses.length === 0 ? ( +
+

No courses created yet

+

Create your first teacher course to get started.

+ + Create course + +
+ ) : ( +
+ {courses.map((course) => { + const totalLessons = course.modules.reduce((acc, module) => acc + module.lessons.length, 0); + const lessonsWithVideo = course.modules.reduce( + (acc, module) => acc + module.lessons.filter((lesson) => lesson.videoUrl || lesson.youtubeUrl).length, + 0, + ); + const title = getText(course.title) || "Untitled course"; + + return ( +
+

{title}

+

+ {course._count.enrollments} students | {course.modules.length} modules | {totalLessons} lessons +

+

+ Upload coverage: {lessonsWithVideo}/{totalLessons || 0} lessons with video +

+
+ + Edit course + + + Preview + +
+
+ ); + })} +
+ )} +
+ ); + } + + const enrollments = await db.enrollment.findMany({ + where: { userId: user.id }, + include: { + course: { + include: { + modules: { + include: { + lessons: { + select: { id: true }, + }, + }, + }, + }, + }, + }, + orderBy: { purchasedAt: "desc" }, + }); + + const courseIds = enrollments.map((enrollment) => enrollment.courseId); + const completed = await db.userProgress.findMany({ + where: { + userId: user.id, + isCompleted: true, + lesson: { + module: { + courseId: { + in: courseIds, + }, + }, + }, + }, + select: { + lesson: { + select: { + module: { + select: { courseId: true }, + }, + }, + }, + }, + }); + + const certificates = await db.certificate.findMany({ + where: { + userId: user.id, + courseId: { + in: courseIds, + }, + }, + select: { + id: true, + courseId: true, + }, + }); + + const completedByCourse = new Map(); + for (const item of completed) { + const courseId = item.lesson.module.courseId; + completedByCourse.set(courseId, (completedByCourse.get(courseId) ?? 0) + 1); + } + const certificateByCourse = new Map(certificates.map((certificate) => [certificate.courseId, certificate.id])); + + return ( +
+
+

My Courses

+

Your enrolled courses

+

+ Continue where you left off, check completion, and download certificates for finished courses. +

+
+ + {enrollments.length === 0 ? ( +
+

No courses enrolled yet

+

Browse the catalog and start your first learning path.

+ + Browse courses + +
+ ) : ( +
+ {enrollments.map((enrollment) => { + const totalLessons = enrollment.course.modules.reduce((acc, module) => acc + module.lessons.length, 0); + const completedLessons = completedByCourse.get(enrollment.course.id) ?? 0; + const progressPercent = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0; + const courseTitle = getText(enrollment.course.title) || "Untitled course"; + const certificateId = certificateByCourse.get(enrollment.course.id); + + return ( +
+

{courseTitle}

+

+ Progress: {completedLessons}/{totalLessons} lessons ({progressPercent}%) +

+
+ + {progressPercent >= 100 ? "Review" : "Continue"} + + {certificateId ? ( + + Download Certificate + + ) : null} +
+
+ ); + })} +
+ )} +
+ ); +} diff --git a/app/(protected)/practice/[slug]/actions.ts b/app/(protected)/practice/[slug]/actions.ts new file mode 100644 index 0000000..cb6549a --- /dev/null +++ b/app/(protected)/practice/[slug]/actions.ts @@ -0,0 +1,113 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireUser } from "@/lib/auth/requireUser"; +import { mockPracticeModules } from "@/lib/data/mockPractice"; +import { db } from "@/lib/prisma"; +import { refreshStudyRecommendations } from "@/lib/recommendations"; + +type SubmitAttemptInput = { + slug: string; + selectedAnswers: number[]; +}; + +type PracticePrismaClient = { + miniGame: { + upsert: (args: object) => Promise<{ id: string }>; + }; + miniGameAttempt: { + create: (args: object) => Promise; + findMany: (args: object) => Promise< + { id: string; scorePercent: number; correctCount: number; totalQuestions: number; completedAt: Date }[] + >; + }; +}; + +function toDifficulty(level?: string): "BEGINNER" | "INTERMEDIATE" | "ADVANCED" { + if (level === "Beginner") return "BEGINNER"; + if (level === "Advanced") return "ADVANCED"; + return "INTERMEDIATE"; +} + +export async function submitPracticeAttempt({ slug, selectedAnswers }: SubmitAttemptInput) { + const user = await requireUser(); + if (!user?.id) return { success: false as const, error: "Unauthorized" }; + + const practiceModule = mockPracticeModules.find((item) => item.slug === slug && item.isInteractive && item.questions?.length); + if (!practiceModule || !practiceModule.questions) return { success: false as const, error: "Practice module not found" }; + + const correctCount = practiceModule.questions.reduce((acc, question, index) => { + return acc + (selectedAnswers[index] === question.answerIndex ? 1 : 0); + }, 0); + const total = practiceModule.questions.length; + const scorePercent = Math.round((correctCount / total) * 100); + const prismaMini = db as unknown as PracticePrismaClient; + + try { + const miniGame = await prismaMini.miniGame.upsert({ + where: { slug: practiceModule.slug }, + update: { + title: practiceModule.title, + description: practiceModule.description, + isActive: true, + difficulty: toDifficulty(practiceModule.difficulty), + }, + create: { + slug: practiceModule.slug, + title: practiceModule.title, + description: practiceModule.description, + isActive: true, + difficulty: toDifficulty(practiceModule.difficulty), + }, + select: { id: true }, + }); + + await prismaMini.miniGameAttempt.create({ + data: { + userId: user.id, + miniGameId: miniGame.id, + scorePercent, + correctCount, + totalQuestions: total, + }, + }); + + await refreshStudyRecommendations(user.id); + } catch { + return { success: false as const, error: "Mini-game tables are not migrated yet" }; + } + revalidatePath("/profile"); + + return { success: true as const, scorePercent, correctCount, total }; +} + +export async function getPracticeAttempts(slug: string) { + const user = await requireUser(); + if (!user?.id) return []; + + try { + const attempts = await (db as unknown as PracticePrismaClient).miniGameAttempt.findMany({ + where: { + userId: user.id, + miniGame: { + slug, + }, + }, + orderBy: { + completedAt: "desc", + }, + take: 5, + select: { + id: true, + scorePercent: true, + correctCount: true, + totalQuestions: true, + completedAt: true, + }, + }); + + return attempts; + } catch { + return []; + } +} diff --git a/app/(protected)/practice/[slug]/page.tsx b/app/(protected)/practice/[slug]/page.tsx old mode 100755 new mode 100644 index 1c2c8e6..5184d9b --- a/app/(protected)/practice/[slug]/page.tsx +++ b/app/(protected)/practice/[slug]/page.tsx @@ -1,17 +1,18 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useTransition } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; import ProgressBar from "@/components/ProgressBar"; import { getPracticeBySlug, mockPracticeModules } from "@/lib/data/mockPractice"; - -const attemptsKey = (slug: string) => `acve.practice-attempts.${slug}`; +import { getPracticeAttempts, submitPracticeAttempt } from "@/app/(protected)/practice/[slug]/actions"; type AttemptRecord = { - completedAt: string; - score: number; - total: number; + id: string; + scorePercent: number; + correctCount: number; + totalQuestions: number; + completedAt: Date; }; export default function PracticeExercisePage() { @@ -23,21 +24,16 @@ export default function PracticeExercisePage() { const [score, setScore] = useState(0); const [finished, setFinished] = useState(false); const [selected, setSelected] = useState(null); + const [selectedAnswers, setSelectedAnswers] = useState([]); const [attempts, setAttempts] = useState([]); + const [isSaving, startTransition] = useTransition(); const loadAttempts = () => { if (!practiceModule) return; - if (typeof window === "undefined") return; - const raw = window.localStorage.getItem(attemptsKey(practiceModule.slug)); - if (!raw) { - setAttempts([]); - return; - } - try { - setAttempts(JSON.parse(raw) as AttemptRecord[]); - } catch { - setAttempts([]); - } + startTransition(async () => { + const result = await getPracticeAttempts(practiceModule.slug); + setAttempts(result as AttemptRecord[]); + }); }; useEffect(() => { @@ -79,21 +75,23 @@ export default function PracticeExercisePage() { }; const next = () => { + if (selected === null) return; + setSelectedAnswers((prev) => { + const nextAnswers = [...prev]; + nextAnswers[index] = selected; + return nextAnswers; + }); + if (index + 1 >= total) { - if (typeof window !== "undefined") { - const raw = window.localStorage.getItem(attemptsKey(practiceModule.slug)); - const parsed = raw ? ((JSON.parse(raw) as AttemptRecord[]) ?? []) : []; - const nextAttempts = [ - { - completedAt: new Date().toISOString(), - score, - total, - }, - ...parsed, - ].slice(0, 5); - window.localStorage.setItem(attemptsKey(practiceModule.slug), JSON.stringify(nextAttempts)); - setAttempts(nextAttempts); - } + const finalAnswers = [...selectedAnswers]; + finalAnswers[index] = selected; + startTransition(async () => { + await submitPracticeAttempt({ + slug: practiceModule.slug, + selectedAnswers: finalAnswers, + }); + loadAttempts(); + }); setFinished(true); return; } @@ -106,6 +104,7 @@ export default function PracticeExercisePage() { setIndex(0); setScore(0); setSelected(null); + setSelectedAnswers([]); setFinished(false); }; @@ -115,6 +114,7 @@ export default function PracticeExercisePage() { setIndex(0); setScore(0); setSelected(null); + setSelectedAnswers([]); }; if (finished) { @@ -130,9 +130,6 @@ export default function PracticeExercisePage() { - Back to modules @@ -140,14 +137,15 @@ export default function PracticeExercisePage() {
-

Attempt History (Mock)

+

Attempt History

{attempts.length === 0 ? (

No attempts recorded yet.

) : (
    {attempts.map((attempt) => ( -
  • - Score {attempt.score}/{attempt.total} on {new Date(attempt.completedAt).toLocaleString()} +
  • + Score {attempt.correctCount}/{attempt.totalQuestions} ({attempt.scorePercent}%) on{" "} + {new Date(attempt.completedAt).toLocaleString()}
  • ))}
@@ -175,7 +173,7 @@ export default function PracticeExercisePage() {

Difficulty

-

Intermediate

+

{practiceModule.difficulty ?? "Intermediate"}

@@ -189,14 +187,15 @@ export default function PracticeExercisePage() {
-

Attempt History (Mock)

+

Attempt History

{attempts.length === 0 ? (

No attempts recorded yet for this module.

) : (
    {attempts.map((attempt) => ( -
  • - Score {attempt.score}/{attempt.total} on {new Date(attempt.completedAt).toLocaleString()} +
  • + Score {attempt.correctCount}/{attempt.totalQuestions} ({attempt.scorePercent}%) on{" "} + {new Date(attempt.completedAt).toLocaleString()}
  • ))}
@@ -237,14 +236,15 @@ export default function PracticeExercisePage() {
-

Attempt History (Mock)

+

Attempt History

{attempts.length === 0 ? (

No attempts recorded yet.

) : (
    {attempts.map((attempt) => ( -
  • - Score {attempt.score}/{attempt.total} on {new Date(attempt.completedAt).toLocaleString()} +
  • + Score {attempt.correctCount}/{attempt.totalQuestions} ({attempt.scorePercent}%) on{" "} + {new Date(attempt.completedAt).toLocaleString()}
  • ))}
@@ -285,7 +285,7 @@ export default function PracticeExercisePage() {
{/* Text Content */} @@ -91,11 +140,64 @@ export function LessonEditorForm({ lesson, courseSlug }: LessonEditorFormProps) placeholder="Escribe aquí el contenido de la lección..." /> + +
+ + setMaterialUrl(e.target.value)} + placeholder="https://.../material.pdf" + className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black" + /> +

Se usará en la vista del alumno como lectura o actividad descargable.

+
{/* RIGHT: Settings / Actions */}
+
+

Tipo de contenido

+ + +

+ Usa “Evaluación final” para marcar el examen obligatorio del curso. +

+ + + setDurationMinutes(Math.max(0, Number(e.target.value)))} + className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black" + /> +
+ +
+

Vista previa gratuita

+ +

Los no inscritos podrán ver esta lección sin comprar el curso.

+

Acciones

@@ -72,27 +73,35 @@ export default function CaseStudiesPage() {

{activeCase.level}

-

{activeCase.topic}

+

{activeCase.category}

-

Case Summary

-

{activeCase.summary}

+

Resumen del caso

+

{activeCase.summaryEs}

-

Key Legal Terms Explained

+

Terminos Juridicos Fundamentales

{activeCase.keyTerms.map((term) => ( -
-

{term}

-

Detailed explanation will expand in phase 2 content.

+
+

{term.term}

+

{term.definitionEs}

))}
+
+

Resultado Juridico

+

{activeCase.legalOutcomeEs}

+

+ {activeCase.quizPrompt} +

+
+ Open detail page diff --git a/app/(public)/comunidad/page.tsx b/app/(public)/comunidad/page.tsx new file mode 100644 index 0000000..4462a05 --- /dev/null +++ b/app/(public)/comunidad/page.tsx @@ -0,0 +1,87 @@ +import Link from "next/link"; + +type CommunityPhoto = { + id: string; + src: string; + alt: string; + caption: string; + span: string; +}; + +const communityPhotos: CommunityPhoto[] = [ + { + id: "community-1", + src: "https://images.unsplash.com/photo-1522071820081-009f0129c71c?auto=format&fit=crop&w=1200&q=80", + alt: "Equipo colaborando en una mesa de trabajo", + caption: "Taller colaborativo", + span: "md:col-span-2 md:row-span-2", + }, + { + id: "community-2", + src: "https://images.unsplash.com/photo-1528901166007-3784c7dd3653?auto=format&fit=crop&w=1200&q=80", + alt: "Grupo de estudiantes revisando material", + caption: "Cohorte activa", + span: "md:col-span-2", + }, + { + id: "community-3", + src: "https://images.unsplash.com/photo-1517048676732-d65bc937f952?auto=format&fit=crop&w=1200&q=80", + alt: "Conversación profesional durante evento", + caption: "Networking legal", + span: "", + }, + { + id: "community-4", + src: "https://images.unsplash.com/photo-1552664730-d307ca884978?auto=format&fit=crop&w=1200&q=80", + alt: "Sesión en auditorio con ponentes", + caption: "Masterclass", + span: "", + }, + { + id: "community-5", + src: "https://images.unsplash.com/photo-1573164713988-8665fc963095?auto=format&fit=crop&w=1200&q=80", + alt: "Personas participando en una dinámica", + caption: "Práctica guiada", + span: "md:col-span-2", + }, + { + id: "community-6", + src: "https://images.unsplash.com/photo-1543269865-0a740d43b90c?auto=format&fit=crop&w=1200&q=80", + alt: "Estudiantes en jornada formativa", + caption: "Comunidad ACVE", + span: "", + }, +]; + +export default function ComunidadPage() { + return ( +
+
+

Comunidad ACVE

+

Red de práctica y colaboración

+

+ Collage visual temporal con fotos de referencia para representar talleres, networking y actividades colaborativas. +

+ +
+ {communityPhotos.map((photo) => ( +
+ {photo.alt} +
+ {photo.caption} +
+
+ ))} +
+ +

Estas imágenes son temporales y se podrán reemplazar por contenido oficial.

+ + Regresar al inicio + +
+
+ ); +} diff --git a/app/(protected)/courses/[slug]/learn/actions.ts b/app/(public)/courses/[slug]/learn/actions.ts similarity index 74% rename from app/(protected)/courses/[slug]/learn/actions.ts rename to app/(public)/courses/[slug]/learn/actions.ts index 70ff816..02bb814 100644 --- a/app/(protected)/courses/[slug]/learn/actions.ts +++ b/app/(public)/courses/[slug]/learn/actions.ts @@ -3,6 +3,8 @@ import { revalidatePath } from "next/cache"; import { requireUser } from "@/lib/auth/requireUser"; import { db } from "@/lib/prisma"; +import { issueCertificateIfEligible } from "@/lib/certificates"; +import { refreshStudyRecommendations } from "@/lib/recommendations"; type ToggleLessonCompleteInput = { courseSlug: string; @@ -86,7 +88,20 @@ export async function toggleLessonComplete({ courseSlug, lessonId }: ToggleLesso }); } - revalidatePath(`/courses/${courseSlug}/learn`); + const certificateResult = nextCompleted + ? await issueCertificateIfEligible(user.id, lesson.module.courseId) + : { certificateId: null, certificateNumber: null, newlyIssued: false }; - return { success: true, isCompleted: nextCompleted }; + revalidatePath(`/courses/${courseSlug}/learn`); + revalidatePath("/my-courses"); + revalidatePath("/profile"); + await refreshStudyRecommendations(user.id); + + return { + success: true, + isCompleted: nextCompleted, + certificateId: certificateResult.certificateId, + certificateNumber: certificateResult.certificateNumber, + newlyIssuedCertificate: certificateResult.newlyIssued, + }; } diff --git a/app/(public)/courses/[slug]/learn/page.tsx b/app/(public)/courses/[slug]/learn/page.tsx new file mode 100644 index 0000000..75d00a2 --- /dev/null +++ b/app/(public)/courses/[slug]/learn/page.tsx @@ -0,0 +1,192 @@ +import { notFound, redirect } from "next/navigation"; +import { db } from "@/lib/prisma"; +import { requireUser } from "@/lib/auth/requireUser"; +import StudentClassroomClient from "@/components/courses/StudentClassroomClient"; +import { parseLessonDescriptionMeta } from "@/lib/courses/lessonContent"; + +type LessonSelect = { + id: string; + title: unknown; + description: unknown; + videoUrl: string | null; + youtubeUrl: string | null; + estimatedDuration: number; + isFreePreview: boolean; +}; +type ModuleSelect = { id: string; title: unknown; lessons: LessonSelect[] }; +type CourseWithModules = { id: string; slug: string; title: unknown; price: unknown; modules: ModuleSelect[] }; + +function getText(value: unknown): string { + if (!value) return ""; + if (typeof value === "string") return value; + if (typeof value === "object") { + const record = value as Record; + if (typeof record.es === "string") return record.es; + if (typeof record.en === "string") return record.en; + } + return ""; +} + +type PageProps = { + params: Promise<{ slug: string }>; + searchParams: Promise<{ lesson?: string }>; +}; + +export default async function CourseLearnPage({ params, searchParams }: PageProps) { + const { slug } = await params; + const { lesson: requestedLessonId } = await searchParams; + + const user = await requireUser(); + + const courseSelect = { + id: true, + slug: true, + title: true, + price: true, + modules: { + orderBy: { orderIndex: "asc" as const }, + select: { + id: true, + title: true, + lessons: { + orderBy: { orderIndex: "asc" as const }, + select: { + id: true, + title: true, + description: true, + videoUrl: true, + youtubeUrl: true, + estimatedDuration: true, + isFreePreview: true, + }, + }, + }, + }, + }; + const course = (await db.course.findUnique({ + where: { slug }, + select: courseSelect, + })) as CourseWithModules | null; + + if (!course) { + notFound(); + } + + let enrollment: { id: string } | null = null; + let isEnrolled: boolean; + + if (!user?.id) { + // Anonymous: no enrollment, preview-only access + const allLessons = course.modules.flatMap((m) => + m.lessons.map((l) => ({ id: l.id, isFreePreview: l.isFreePreview })) + ); + const firstPreviewLesson = allLessons.find((l) => l.isFreePreview); + if (!firstPreviewLesson) { + redirect(`/courses/${slug}`); + } + isEnrolled = false; + } else { + enrollment = await db.enrollment.findUnique({ + where: { + userId_courseId: { + userId: user.id, + courseId: course.id, + }, + }, + select: { id: true }, + }); + + const isFree = Number(course.price) === 0; + + if (!enrollment) { + if (isFree) { + enrollment = await db.enrollment.create({ + data: { + userId: user.id, + courseId: course.id, + amountPaid: 0, + }, + select: { id: true }, + }); + isEnrolled = true; + } else { + // Paid course, no enrollment: allow only if there are preview lessons + const allLessons = course.modules.flatMap((m) => + m.lessons.map((l) => ({ id: l.id, isFreePreview: l.isFreePreview })) + ); + const firstPreviewLesson = allLessons.find((l) => l.isFreePreview); + if (!firstPreviewLesson) { + redirect(`/courses/${slug}`); + } + isEnrolled = false; + } + } else { + isEnrolled = true; + } + } + + const completedProgress = + isEnrolled && user + ? await db.userProgress.findMany({ + where: { + userId: user.id, + isCompleted: true, + lesson: { + module: { courseId: course.id }, + }, + }, + select: { lessonId: true }, + }) + : []; + + const modules = course.modules.map((module) => ({ + id: module.id, + title: getText(module.title) || "Untitled module", + lessons: module.lessons.map((lesson) => { + const lessonMeta = parseLessonDescriptionMeta(lesson.description); + return { + id: lesson.id, + title: getText(lesson.title) || "Untitled lesson", + description: lessonMeta.text, + contentType: lessonMeta.contentType, + materialUrl: lessonMeta.materialUrl, + videoUrl: lesson.videoUrl, + youtubeUrl: lesson.youtubeUrl, + estimatedDuration: lesson.estimatedDuration, + isFreePreview: lesson.isFreePreview, + }; + }), + })); + + const flattenedLessons = modules.flatMap((module) => module.lessons); + const flattenedLessonIds = flattenedLessons.map((l) => l.id); + + let initialSelectedLessonId: string; + if (isEnrolled) { + initialSelectedLessonId = + requestedLessonId && flattenedLessonIds.includes(requestedLessonId) + ? requestedLessonId + : flattenedLessonIds[0] ?? ""; + } else { + const firstPreview = flattenedLessons.find((l) => l.isFreePreview); + const requestedLesson = requestedLessonId + ? flattenedLessons.find((l) => l.id === requestedLessonId) + : null; + if (requestedLesson?.isFreePreview) { + initialSelectedLessonId = requestedLessonId!; + } else { + initialSelectedLessonId = firstPreview?.id ?? ""; + } + } + + return ( + p.lessonId)} + isEnrolled={isEnrolled} + /> + ); +} diff --git a/app/(public)/courses/[slug]/page.tsx b/app/(public)/courses/[slug]/page.tsx index d84b5dd..5f8809c 100755 --- a/app/(public)/courses/[slug]/page.tsx +++ b/app/(public)/courses/[slug]/page.tsx @@ -1,184 +1,169 @@ -import Link from "next/link"; import { notFound } from "next/navigation"; -import { db } from "@/lib/prisma"; import { requireUser } from "@/lib/auth/requireUser"; +import CourseDetailHeader from "@/components/courses/CourseDetailHeader"; +import CourseProgressCard from "@/components/courses/CourseProgressCard"; +import ProgramContentList from "@/components/courses/ProgramContentList"; +import { getCourseDetailViewModel } from "@/lib/courses/publicCourses"; -function getText(value: unknown): string { - if (!value) return ""; - if (typeof value === "string") return value; - if (typeof value === "object") { - const record = value as Record; - if (typeof record.en === "string") return record.en; - if (typeof record.es === "string") return record.es; - } - return ""; -} - -const levelLabel = (level: string) => { - if (level === "BEGINNER") return "Beginner"; - if (level === "INTERMEDIATE") return "Intermediate"; - if (level === "ADVANCED") return "Advanced"; - return level; -}; +export const dynamic = "force-dynamic"; type PageProps = { params: Promise<{ slug: string }>; }; +type DetailActionState = { + primaryAction: { + label: string; + href?: string; + disabled?: boolean; + }; + secondaryAction?: { + label: string; + href?: string; + disabled?: boolean; + }; + helperText?: string; +}; + +function buildActionState(args: { + slug: string; + isAuthenticated: boolean; + isEnrolled: boolean; + availabilityState: "published" | "upcoming" | "draft"; + progressPercent: number; + firstPreviewLessonId: string | null; + price: number; +}): DetailActionState { + const learnUrl = `/courses/${args.slug}/learn`; + const loginUrl = `/auth/login?redirectTo=${encodeURIComponent(`/courses/${args.slug}`)}`; + const previewUrl = args.firstPreviewLessonId ? `${learnUrl}?lesson=${args.firstPreviewLessonId}` : undefined; + + if (args.availabilityState !== "published") { + return { + primaryAction: { + label: "Próximamente", + disabled: true, + }, + helperText: "Este programa se encuentra en preparación editorial y estará habilitado en próximas publicaciones.", + }; + } + + if (args.isAuthenticated && args.isEnrolled) { + const label = args.progressPercent >= 100 ? "Revisar programa" : args.progressPercent > 0 ? "Continuar" : "Comenzar"; + return { + primaryAction: { + label, + href: learnUrl, + }, + helperText: + args.progressPercent > 0 + ? "Tu avance se conserva automáticamente. Puedes continuar desde la lección más reciente." + : "Inicia el recorrido académico desde el primer módulo.", + }; + } + + if (args.isAuthenticated) { + if (args.price <= 0) { + return { + primaryAction: { + label: "Comenzar", + href: learnUrl, + }, + helperText: "Programa con acceso abierto para iniciar de inmediato.", + }; + } + + if (previewUrl) { + return { + primaryAction: { + label: "Ver clase de muestra", + href: previewUrl, + }, + helperText: "El contenido completo está disponible para estudiantes inscritos en el programa.", + }; + } + + return { + primaryAction: { + label: "Inscripción próximamente", + disabled: true, + }, + helperText: "La inscripción completa para este programa estará disponible en breve.", + }; + } + + if (previewUrl) { + return { + primaryAction: { + label: "Ver clase de muestra", + href: previewUrl, + }, + secondaryAction: { + label: "Iniciar sesión", + href: loginUrl, + }, + helperText: "Puedes revisar una vista previa o iniciar sesión para gestionar tu itinerario académico.", + }; + } + + return { + primaryAction: { + label: "Iniciar sesión para comenzar", + href: loginUrl, + }, + helperText: "Accede con tu cuenta para comenzar este programa y registrar tu progreso.", + }; +} + export default async function CourseDetailPage({ params }: PageProps) { const { slug } = await params; + const user = await requireUser().catch(() => null); - const course = await db.course.findFirst({ - where: { slug, status: "PUBLISHED" }, - include: { - author: { select: { fullName: true } }, - modules: { - orderBy: { orderIndex: "asc" }, - include: { - lessons: { - orderBy: { orderIndex: "asc" }, - select: { id: true, title: true, estimatedDuration: true }, - }, - }, - }, - _count: { select: { enrollments: true } }, - }, - }); - + const course = await getCourseDetailViewModel(slug, user?.id ?? null); if (!course) notFound(); - const user = await requireUser(); - const isAuthed = Boolean(user?.id); - - const title = getText(course.title) || "Untitled course"; - const summary = getText(course.description) || ""; - - const lessons = course.modules.flatMap((m) => - m.lessons.map((l) => ({ - id: l.id, - title: getText(l.title) || "Untitled lesson", - minutes: Math.ceil((l.estimatedDuration ?? 0) / 60), - })), - ); - const lessonsCount = lessons.length; - - const redirect = `/courses/${course.slug}/learn`; - const loginUrl = `/auth/login?redirectTo=${encodeURIComponent(redirect)}`; - - const learningOutcomes = [ - "Understand key legal vocabulary in context", - "Apply contract and case analysis patterns", - "Improve professional written legal communication", - ]; + const actions = buildActionState({ + slug: course.slug, + isAuthenticated: Boolean(user?.id), + isEnrolled: course.isEnrolled, + availabilityState: course.availabilityState, + progressPercent: course.progressPercent, + firstPreviewLessonId: course.firstPreviewLessonId, + price: course.price, + }); return (
-
-
-
- - {"<-"} - Back to Courses - +
+ -
- - {levelLabel(course.level)} - - - {course.status.toLowerCase()} - - - {lessonsCount} lessons - -
- -

{title}

-

{summary}

- -
-
-

Students

-

- {course._count.enrollments.toLocaleString()} -

-
-
-

Lessons

-

{lessonsCount}

-
-
-

Instructor

-

- {course.author.fullName || "ACVE Team"} -

-
-
-
- - -
+
-
-
-

Course structure preview

-
- {lessons.slice(0, 5).map((lesson, index) => ( -
- - Lesson {index + 1}: {lesson.title} - - {lesson.minutes} min -
- ))} - {lessons.length === 0 && ( -

- No lessons yet. Check back soon. -

- )} -
-
- - -
+
); } diff --git a/app/(public)/courses/page.tsx b/app/(public)/courses/page.tsx index 772e799..8339652 100755 --- a/app/(public)/courses/page.tsx +++ b/app/(public)/courses/page.tsx @@ -1,93 +1,35 @@ -import CourseCard from "@/components/CourseCard"; -import { db } from "@/lib/prisma"; +import { requireUser } from "@/lib/auth/requireUser"; +import CourseCatalogIntro from "@/components/courses/CourseCatalogIntro"; +import CourseLevelTabs from "@/components/courses/CourseLevelTabs"; +import ProgramSection from "@/components/courses/ProgramSection"; +import { getCourseCatalogViewModel } from "@/lib/courses/publicCourses"; + +export const dynamic = "force-dynamic"; export default async function CoursesPage() { - const courses = await db.course.findMany({ - where: { - status: "PUBLISHED", - }, - include: { - author: { - select: { - fullName: true, - }, - }, - modules: { - select: { - _count: { - select: { - lessons: true, - }, - }, - }, - }, - _count: { - select: { - enrollments: true, - }, - }, - }, - orderBy: { - updatedAt: "desc", - }, - }); - - const totalLessons = courses.reduce( - (total, course) => total + course.modules.reduce((courseTotal, module) => courseTotal + module._count.lessons, 0), - 0, - ); + const user = await requireUser().catch(() => null); + const catalog = await getCourseCatalogViewModel(user?.id ?? null); return (
-
-
-
-

Course Catalog

-

Build your legal English learning path

-

- Discover our published legal English courses and start with the path that matches your level. -

-
-
-

Total Courses

-

{courses.length}

-
-
-

Published Lessons

-

{totalLessons}

-
-
-

Instructors

-

- {new Set(courses.map((course) => course.author.fullName || "ACVE Team")).size} -

-
-
-
+ - -
-
+ ({ + id: section.id, + label: section.tabLabel, + anchorId: section.anchorId, + count: section.courses.length, + }))} + /> - {courses.length === 0 ? ( -
-
-

Coming Soon

-

We are preparing new courses. Please check back shortly.

-
-
- ) : ( -
- {courses.map((course) => ( - - ))} -
- )} + {catalog.sections.map((section) => ( + + ))}
); } diff --git a/app/(public)/eventos/page.tsx b/app/(public)/eventos/page.tsx new file mode 100644 index 0000000..6805aab --- /dev/null +++ b/app/(public)/eventos/page.tsx @@ -0,0 +1,462 @@ +"use client"; + +import Link from "next/link"; +import { type FormEvent, useEffect, useMemo, useState } from "react"; +import { readDemoClientAuth } from "@/lib/auth/clientAuth"; +import { supabaseBrowser } from "@/lib/supabase/browser"; + +type EventItem = { + id: string; + title: string; + date: string; + startTime: string; + endTime: string; + mode: string; + location: string; + summary: string; + details: string; + thumbnail: string; +}; + +type EventDraft = { + title: string; + date: string; + startTime: string; + endTime: string; + mode: string; + location: string; + summary: string; + details: string; + thumbnail: string; +}; + +const EVENTS_STORAGE_KEY = "acve.custom-events.v1"; +const defaultThumbnail = + "https://images.unsplash.com/photo-1511578314322-379afb476865?auto=format&fit=crop&w=1200&q=80"; + +const defaultEvents: EventItem[] = [ + { + id: "launch-day-2026-03-20", + title: "Launch Day ACVE", + date: "2026-03-20", + startTime: "18:00", + endTime: "20:30", + mode: "Híbrido", + location: "Monterrey", + summary: "Presentación oficial de ACVE, agenda académica y networking inicial.", + details: + "Sesión inaugural con bienvenida institucional, visión del programa 2026 y espacio de networking para alumnos, docentes e invitados del sector legal.", + thumbnail: + "https://images.unsplash.com/photo-1528605248644-14dd04022da1?auto=format&fit=crop&w=1200&q=80", + }, + { + id: "webinar-drafting-2026-03-27", + title: "Webinar: Legal Drafting in English", + date: "2026-03-27", + startTime: "19:00", + endTime: "20:15", + mode: "Online", + location: "Zoom ACVE", + summary: "Buenas prácticas para redactar cláusulas con claridad y precisión.", + details: + "Revisión de estructura, vocabulario funcional y errores frecuentes en contratos internacionales. Incluye sesión breve de preguntas al final.", + thumbnail: + "https://images.unsplash.com/photo-1543269865-cbf427effbad?auto=format&fit=crop&w=1200&q=80", + }, + { + id: "qa-session-2026-04-05", + title: "Q&A de Cohorte", + date: "2026-04-05", + startTime: "17:30", + endTime: "18:30", + mode: "Streaming", + location: "Campus Virtual ACVE", + summary: "Resolución de dudas académicas y guía de estudio para la siguiente unidad.", + details: + "Encuentro en vivo para alinear progreso de la cohorte, resolver dudas de contenido y compartir recomendaciones de práctica.", + thumbnail: + "https://images.unsplash.com/photo-1515169067868-5387ec356754?auto=format&fit=crop&w=1200&q=80", + }, + { + id: "workshop-monterrey-2026-04-18", + title: "Workshop Presencial", + date: "2026-04-18", + startTime: "10:00", + endTime: "13:00", + mode: "Presencial", + location: "Monterrey", + summary: "Simulación de negociación y revisión colaborativa de cláusulas.", + details: + "Taller práctico con actividades por equipos para aplicar vocabulario jurídico en contexto de negociación y redacción de términos clave.", + thumbnail: + "https://images.unsplash.com/photo-1475721027785-f74eccf877e2?auto=format&fit=crop&w=1200&q=80", + }, + { + id: "networking-night-2026-05-02", + title: "Networking Night", + date: "2026-05-02", + startTime: "19:30", + endTime: "21:00", + mode: "Híbrido", + location: "Monterrey + Online", + summary: "Conexión entre estudiantes, alumni y profesores ACVE.", + details: + "Espacio informal para compartir experiencias, oportunidades de colaboración y avances en el uso profesional del inglés legal.", + thumbnail: + "https://images.unsplash.com/photo-1528909514045-2fa4ac7a08ba?auto=format&fit=crop&w=1200&q=80", + }, +]; + +const blankDraft: EventDraft = { + title: "", + date: "", + startTime: "18:00", + endTime: "19:00", + mode: "Online", + location: "Monterrey", + summary: "", + details: "", + thumbnail: "", +}; + +function parseDateInUtc(value: string): Date { + const [year, month, day] = value.split("-").map(Number); + return new Date(Date.UTC(year, month - 1, day)); +} + +function sortEvents(items: EventItem[]): EventItem[] { + return [...items].sort((a, b) => { + if (a.date !== b.date) return a.date.localeCompare(b.date); + return a.startTime.localeCompare(b.startTime); + }); +} + +function formatCardDate(value: string) { + const date = parseDateInUtc(value); + return { + day: new Intl.DateTimeFormat("es-MX", { day: "2-digit", timeZone: "UTC" }).format(date), + month: new Intl.DateTimeFormat("es-MX", { month: "short", timeZone: "UTC" }).format(date).toUpperCase(), + }; +} + +function formatLongDate(value: string): string { + return new Intl.DateTimeFormat("es-MX", { + weekday: "long", + day: "numeric", + month: "long", + year: "numeric", + timeZone: "UTC", + }).format(parseDateInUtc(value)); +} + +function isEventItem(value: unknown): value is EventItem { + if (!value || typeof value !== "object") return false; + const record = value as Record; + return ( + typeof record.id === "string" && + typeof record.title === "string" && + typeof record.date === "string" && + typeof record.startTime === "string" && + typeof record.endTime === "string" && + typeof record.mode === "string" && + typeof record.location === "string" && + typeof record.summary === "string" && + typeof record.details === "string" && + typeof record.thumbnail === "string" + ); +} + +export default function EventosPage() { + const [selectedEvent, setSelectedEvent] = useState(null); + const [addedEvents, setAddedEvents] = useState([]); + const [draft, setDraft] = useState(blankDraft); + const [formError, setFormError] = useState(null); + const [isTeacher, setIsTeacher] = useState(false); + const [isCheckingRole, setIsCheckingRole] = useState(true); + + const orderedEvents = useMemo(() => sortEvents([...defaultEvents, ...addedEvents]), [addedEvents]); + + useEffect(() => { + if (typeof window === "undefined") return; + try { + const stored = window.localStorage.getItem(EVENTS_STORAGE_KEY); + if (!stored) return; + const parsed = JSON.parse(stored); + if (Array.isArray(parsed)) { + setAddedEvents(parsed.filter(isEventItem)); + } + } catch { + setAddedEvents([]); + } + }, []); + + useEffect(() => { + if (typeof window === "undefined") return; + window.localStorage.setItem(EVENTS_STORAGE_KEY, JSON.stringify(sortEvents(addedEvents))); + }, [addedEvents]); + + useEffect(() => { + let mounted = true; + const demoSnapshot = readDemoClientAuth(); + const client = supabaseBrowser(); + + if (!client) { + setIsTeacher(demoSnapshot.isTeacher); + setIsCheckingRole(false); + return; + } + + const resolveTeacherRole = async () => { + try { + const { + data: { user }, + } = await client.auth.getUser(); + + if (!mounted) return; + if (!user) { + setIsTeacher(demoSnapshot.isTeacher); + return; + } + + const response = await fetch("/api/auth/session", { cache: "no-store" }); + if (!mounted) return; + + const payload = (await response.json()) as { isTeacher?: boolean }; + setIsTeacher(payload.isTeacher === true || demoSnapshot.isTeacher); + } catch { + if (mounted) setIsTeacher(demoSnapshot.isTeacher); + } finally { + if (mounted) setIsCheckingRole(false); + } + }; + + void resolveTeacherRole(); + + const { + data: { subscription }, + } = client.auth.onAuthStateChange(() => { + void resolveTeacherRole(); + }); + + return () => { + mounted = false; + subscription.unsubscribe(); + }; + }, []); + + const handleAddEvent = (event: FormEvent) => { + event.preventDefault(); + if (!isTeacher) { + setFormError("Solo docentes pueden agregar eventos."); + return; + } + + const required = [draft.title, draft.date, draft.startTime, draft.endTime, draft.mode, draft.location, draft.summary, draft.details]; + if (required.some((field) => field.trim().length === 0)) { + setFormError("Completa todos los campos obligatorios para publicar el evento."); + return; + } + + setAddedEvents((previous) => [ + ...previous, + { + id: typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now()}`, + title: draft.title.trim(), + date: draft.date, + startTime: draft.startTime, + endTime: draft.endTime, + mode: draft.mode.trim(), + location: draft.location.trim(), + summary: draft.summary.trim(), + details: draft.details.trim(), + thumbnail: draft.thumbnail.trim() || defaultThumbnail, + }, + ]); + + setDraft(blankDraft); + setFormError(null); + }; + + return ( +
+
+

Eventos ACVE

+

Calendario académico y networking

+

+ Eventos ordenados cronológicamente. Haz clic en cualquier tarjeta para ver el detalle completo del evento. +

+ +
+ {orderedEvents.map((eventCard) => { + const cardDate = formatCardDate(eventCard.date); + return ( + + ); + })} +
+ +
+ + Ver sección de eventos en Inicio + +
+
+ +
+

Gestión de eventos

+

Solo el equipo docente puede publicar nuevos eventos en este tablero.

+ + {isCheckingRole ? ( +

Verificando permisos...

+ ) : null} + + {!isCheckingRole && !isTeacher ? ( +

+ Tu perfil actual no tiene permisos de publicación. Si eres docente, inicia sesión con tu cuenta de profesor. +

+ ) : null} + + {!isCheckingRole && isTeacher ? ( +
+ setDraft((previous) => ({ ...previous, title: event.target.value }))} + /> + setDraft((previous) => ({ ...previous, date: event.target.value }))} + /> + setDraft((previous) => ({ ...previous, startTime: event.target.value }))} + /> + setDraft((previous) => ({ ...previous, endTime: event.target.value }))} + /> + setDraft((previous) => ({ ...previous, mode: event.target.value }))} + /> + setDraft((previous) => ({ ...previous, location: event.target.value }))} + /> +