Pending course, rest ready for launch
This commit is contained in:
@@ -1,5 +1,4 @@
|
|||||||
NEXT_PUBLIC_SUPABASE_URL=YOUR_URL
|
NEXT_PUBLIC_SUPABASE_URL=YOUR_URL
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_ANON_KEY
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=YOUR_ANON_KEY
|
||||||
TEACHER_EMAILS=teacher@example.com
|
|
||||||
DATABASE_URL=
|
DATABASE_URL=
|
||||||
DIRECT_URL=
|
DIRECT_URL=
|
||||||
|
|||||||
BIN
Centro ACVE - Pagina Cursos.pdf
Normal file
BIN
Centro ACVE - Pagina Cursos.pdf
Normal file
Binary file not shown.
BIN
Course1/ACVE - Evaluación Final.pdf
Normal file
BIN
Course1/ACVE - Evaluación Final.pdf
Normal file
Binary file not shown.
BIN
Course1/ACVE - Sección 1 - Lectura.pdf
Normal file
BIN
Course1/ACVE - Sección 1 - Lectura.pdf
Normal file
Binary file not shown.
BIN
Course1/ACVE - Sección 2 - Actividad.pdf
Normal file
BIN
Course1/ACVE - Sección 2 - Actividad.pdf
Normal file
Binary file not shown.
BIN
Course1/ACVE - Sección 3 - Lectura y Quiz.pdf
Normal file
BIN
Course1/ACVE - Sección 3 - Lectura y Quiz.pdf
Normal file
Binary file not shown.
BIN
Course1/ACVE - Sección 4 - Actividad.pdf
Normal file
BIN
Course1/ACVE - Sección 4 - Actividad.pdf
Normal file
Binary file not shown.
BIN
Course1/ACVE - Sección 5 - Actividad.pdf
Normal file
BIN
Course1/ACVE - Sección 5 - Actividad.pdf
Normal file
Binary file not shown.
BIN
Course1/ACVE - Sección 6 - Actividad.pdf
Normal file
BIN
Course1/ACVE - Sección 6 - Actividad.pdf
Normal file
Binary file not shown.
BIN
Course1/ACVE - Sección 7 - Actividad.pdf
Normal file
BIN
Course1/ACVE - Sección 7 - Actividad.pdf
Normal file
Binary file not shown.
BIN
Página de Inicio Centro de Estudios.pdf
Normal file
BIN
Página de Inicio Centro de Estudios.pdf
Normal file
Binary file not shown.
@@ -5,6 +5,7 @@ type LoginPageProps = {
|
|||||||
redirectTo?: string | string[];
|
redirectTo?: string | string[];
|
||||||
role?: string | string[];
|
role?: string | string[];
|
||||||
forgot?: 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 role = Array.isArray(roleValue) ? roleValue[0] : roleValue;
|
||||||
const forgotValue = params.forgot;
|
const forgotValue = params.forgot;
|
||||||
const forgot = Array.isArray(forgotValue) ? forgotValue[0] : forgotValue;
|
const forgot = Array.isArray(forgotValue) ? forgotValue[0] : forgotValue;
|
||||||
|
const switchUserValue = params.switchUser;
|
||||||
|
const switchUser = Array.isArray(switchUserValue) ? switchUserValue[0] : switchUserValue;
|
||||||
|
|
||||||
return <LoginForm redirectTo={redirectTo ?? "/courses"} role={role} showForgot={forgot === "1"} />;
|
return (
|
||||||
|
<LoginForm
|
||||||
|
redirectTo={redirectTo ?? "/courses"}
|
||||||
|
role={role}
|
||||||
|
showForgot={forgot === "1"}
|
||||||
|
skipAuthedRedirect={switchUser === "1"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
|
||||||
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 (
|
|
||||||
<StudentClassroomClient
|
|
||||||
courseSlug={course.slug}
|
|
||||||
courseTitle={getText(course.title) || "Untitled course"}
|
|
||||||
modules={modules}
|
|
||||||
initialSelectedLessonId={initialSelectedLessonId}
|
|
||||||
initialCompletedLessonIds={completedProgress.map((progress) => progress.lessonId)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
229
app/(protected)/my-courses/page.tsx
Normal file
229
app/(protected)/my-courses/page.tsx
Normal file
@@ -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<string, unknown>;
|
||||||
|
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 (
|
||||||
|
<div className="acve-page">
|
||||||
|
<section className="acve-panel acve-section-base">
|
||||||
|
<p className="acve-pill mb-3 w-fit">My Courses</p>
|
||||||
|
<h1 className="text-3xl font-semibold leading-tight text-[#222a38] md:text-4xl">Your created courses</h1>
|
||||||
|
<p className="mt-2 max-w-3xl text-base leading-relaxed text-slate-600">
|
||||||
|
Review, edit, and publish the courses you are building for your students.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{courses.length === 0 ? (
|
||||||
|
<section className="acve-panel p-6">
|
||||||
|
<h2 className="text-2xl font-semibold text-slate-900">No courses created yet</h2>
|
||||||
|
<p className="mt-2 text-slate-600">Create your first teacher course to get started.</p>
|
||||||
|
<Link className="acve-button-primary mt-4 inline-flex px-4 py-2 text-sm font-semibold" href="/teacher/courses/new">
|
||||||
|
Create course
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="grid gap-4 md:grid-cols-2">
|
||||||
|
{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 (
|
||||||
|
<article key={course.id} className="acve-panel p-5">
|
||||||
|
<h2 className="text-2xl font-semibold text-[#222a38]">{title}</h2>
|
||||||
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
|
{course._count.enrollments} students | {course.modules.length} modules | {totalLessons} lessons
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm text-slate-600">
|
||||||
|
Upload coverage: {lessonsWithVideo}/{totalLessons || 0} lessons with video
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<Link
|
||||||
|
className="acve-button-primary inline-flex px-4 py-2 text-sm font-semibold"
|
||||||
|
href={`/teacher/courses/${course.slug}/edit`}
|
||||||
|
>
|
||||||
|
Edit course
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50"
|
||||||
|
href={`/courses/${course.slug}`}
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string, number>();
|
||||||
|
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 (
|
||||||
|
<div className="acve-page">
|
||||||
|
<section className="acve-panel acve-section-base">
|
||||||
|
<p className="acve-pill mb-3 w-fit">My Courses</p>
|
||||||
|
<h1 className="text-3xl font-semibold leading-tight text-[#222a38] md:text-4xl">Your enrolled courses</h1>
|
||||||
|
<p className="mt-2 max-w-3xl text-base leading-relaxed text-slate-600">
|
||||||
|
Continue where you left off, check completion, and download certificates for finished courses.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{enrollments.length === 0 ? (
|
||||||
|
<section className="acve-panel p-6">
|
||||||
|
<h2 className="text-2xl font-semibold text-slate-900">No courses enrolled yet</h2>
|
||||||
|
<p className="mt-2 text-slate-600">Browse the catalog and start your first learning path.</p>
|
||||||
|
<Link className="acve-button-primary mt-4 inline-flex px-4 py-2 text-sm font-semibold" href="/courses">
|
||||||
|
Browse courses
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
) : (
|
||||||
|
<section className="grid gap-4 md:grid-cols-2">
|
||||||
|
{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 (
|
||||||
|
<article key={enrollment.id} className="acve-panel p-5">
|
||||||
|
<h2 className="text-2xl font-semibold text-[#222a38]">{courseTitle}</h2>
|
||||||
|
<p className="mt-2 text-sm text-slate-600">
|
||||||
|
Progress: {completedLessons}/{totalLessons} lessons ({progressPercent}%)
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
<Link
|
||||||
|
className="acve-button-primary inline-flex px-4 py-2 text-sm font-semibold"
|
||||||
|
href={`/courses/${enrollment.course.slug}/learn`}
|
||||||
|
>
|
||||||
|
{progressPercent >= 100 ? "Review" : "Continue"}
|
||||||
|
</Link>
|
||||||
|
{certificateId ? (
|
||||||
|
<Link
|
||||||
|
className="rounded-md border border-slate-300 px-4 py-2 text-sm font-semibold text-slate-700 hover:bg-slate-50"
|
||||||
|
href={`/api/certificates/${certificateId}/pdf`}
|
||||||
|
>
|
||||||
|
Download Certificate
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
app/(protected)/practice/[slug]/actions.ts
Normal file
113
app/(protected)/practice/[slug]/actions.ts
Normal file
@@ -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<unknown>;
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
90
app/(protected)/practice/[slug]/page.tsx
Executable file → Normal file
90
app/(protected)/practice/[slug]/page.tsx
Executable file → Normal file
@@ -1,17 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useTransition } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import ProgressBar from "@/components/ProgressBar";
|
import ProgressBar from "@/components/ProgressBar";
|
||||||
import { getPracticeBySlug, mockPracticeModules } from "@/lib/data/mockPractice";
|
import { getPracticeBySlug, mockPracticeModules } from "@/lib/data/mockPractice";
|
||||||
|
import { getPracticeAttempts, submitPracticeAttempt } from "@/app/(protected)/practice/[slug]/actions";
|
||||||
const attemptsKey = (slug: string) => `acve.practice-attempts.${slug}`;
|
|
||||||
|
|
||||||
type AttemptRecord = {
|
type AttemptRecord = {
|
||||||
completedAt: string;
|
id: string;
|
||||||
score: number;
|
scorePercent: number;
|
||||||
total: number;
|
correctCount: number;
|
||||||
|
totalQuestions: number;
|
||||||
|
completedAt: Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function PracticeExercisePage() {
|
export default function PracticeExercisePage() {
|
||||||
@@ -23,21 +24,16 @@ export default function PracticeExercisePage() {
|
|||||||
const [score, setScore] = useState(0);
|
const [score, setScore] = useState(0);
|
||||||
const [finished, setFinished] = useState(false);
|
const [finished, setFinished] = useState(false);
|
||||||
const [selected, setSelected] = useState<number | null>(null);
|
const [selected, setSelected] = useState<number | null>(null);
|
||||||
|
const [selectedAnswers, setSelectedAnswers] = useState<number[]>([]);
|
||||||
const [attempts, setAttempts] = useState<AttemptRecord[]>([]);
|
const [attempts, setAttempts] = useState<AttemptRecord[]>([]);
|
||||||
|
const [isSaving, startTransition] = useTransition();
|
||||||
|
|
||||||
const loadAttempts = () => {
|
const loadAttempts = () => {
|
||||||
if (!practiceModule) return;
|
if (!practiceModule) return;
|
||||||
if (typeof window === "undefined") return;
|
startTransition(async () => {
|
||||||
const raw = window.localStorage.getItem(attemptsKey(practiceModule.slug));
|
const result = await getPracticeAttempts(practiceModule.slug);
|
||||||
if (!raw) {
|
setAttempts(result as AttemptRecord[]);
|
||||||
setAttempts([]);
|
});
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
setAttempts(JSON.parse(raw) as AttemptRecord[]);
|
|
||||||
} catch {
|
|
||||||
setAttempts([]);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -79,21 +75,23 @@ export default function PracticeExercisePage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const next = () => {
|
const next = () => {
|
||||||
|
if (selected === null) return;
|
||||||
|
setSelectedAnswers((prev) => {
|
||||||
|
const nextAnswers = [...prev];
|
||||||
|
nextAnswers[index] = selected;
|
||||||
|
return nextAnswers;
|
||||||
|
});
|
||||||
|
|
||||||
if (index + 1 >= total) {
|
if (index + 1 >= total) {
|
||||||
if (typeof window !== "undefined") {
|
const finalAnswers = [...selectedAnswers];
|
||||||
const raw = window.localStorage.getItem(attemptsKey(practiceModule.slug));
|
finalAnswers[index] = selected;
|
||||||
const parsed = raw ? ((JSON.parse(raw) as AttemptRecord[]) ?? []) : [];
|
startTransition(async () => {
|
||||||
const nextAttempts = [
|
await submitPracticeAttempt({
|
||||||
{
|
slug: practiceModule.slug,
|
||||||
completedAt: new Date().toISOString(),
|
selectedAnswers: finalAnswers,
|
||||||
score,
|
});
|
||||||
total,
|
loadAttempts();
|
||||||
},
|
});
|
||||||
...parsed,
|
|
||||||
].slice(0, 5);
|
|
||||||
window.localStorage.setItem(attemptsKey(practiceModule.slug), JSON.stringify(nextAttempts));
|
|
||||||
setAttempts(nextAttempts);
|
|
||||||
}
|
|
||||||
setFinished(true);
|
setFinished(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -106,6 +104,7 @@ export default function PracticeExercisePage() {
|
|||||||
setIndex(0);
|
setIndex(0);
|
||||||
setScore(0);
|
setScore(0);
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
|
setSelectedAnswers([]);
|
||||||
setFinished(false);
|
setFinished(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -115,6 +114,7 @@ export default function PracticeExercisePage() {
|
|||||||
setIndex(0);
|
setIndex(0);
|
||||||
setScore(0);
|
setScore(0);
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
|
setSelectedAnswers([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (finished) {
|
if (finished) {
|
||||||
@@ -130,9 +130,6 @@ export default function PracticeExercisePage() {
|
|||||||
<button className="acve-button-primary px-5 py-2 text-sm font-semibold hover:brightness-105" onClick={restart} type="button">
|
<button className="acve-button-primary px-5 py-2 text-sm font-semibold hover:brightness-105" onClick={restart} type="button">
|
||||||
Retake quiz
|
Retake quiz
|
||||||
</button>
|
</button>
|
||||||
<button className="rounded-md border border-slate-300 px-5 py-2 text-sm font-semibold text-slate-700" type="button">
|
|
||||||
Review answers (placeholder)
|
|
||||||
</button>
|
|
||||||
<Link className="rounded-md border border-slate-300 px-5 py-2 text-sm font-semibold text-slate-700" href="/practice">
|
<Link className="rounded-md border border-slate-300 px-5 py-2 text-sm font-semibold text-slate-700" href="/practice">
|
||||||
Back to modules
|
Back to modules
|
||||||
</Link>
|
</Link>
|
||||||
@@ -140,14 +137,15 @@ export default function PracticeExercisePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="acve-panel p-4">
|
<section className="acve-panel p-4">
|
||||||
<h2 className="text-base font-semibold text-slate-800">Attempt History (Mock)</h2>
|
<h2 className="text-base font-semibold text-slate-800">Attempt History</h2>
|
||||||
{attempts.length === 0 ? (
|
{attempts.length === 0 ? (
|
||||||
<p className="mt-2 text-sm text-slate-600">No attempts recorded yet.</p>
|
<p className="mt-2 text-sm text-slate-600">No attempts recorded yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-2 space-y-2">
|
<ul className="mt-2 space-y-2">
|
||||||
{attempts.map((attempt) => (
|
{attempts.map((attempt) => (
|
||||||
<li key={attempt.completedAt} className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700">
|
<li key={attempt.id} className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700">
|
||||||
Score {attempt.score}/{attempt.total} on {new Date(attempt.completedAt).toLocaleString()}
|
Score {attempt.correctCount}/{attempt.totalQuestions} ({attempt.scorePercent}%) on{" "}
|
||||||
|
{new Date(attempt.completedAt).toLocaleString()}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -175,7 +173,7 @@ export default function PracticeExercisePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="rounded-xl border border-slate-200 bg-white p-3">
|
<div className="rounded-xl border border-slate-200 bg-white p-3">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Difficulty</p>
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Difficulty</p>
|
||||||
<p className="mt-1 text-2xl font-semibold text-slate-900">Intermediate</p>
|
<p className="mt-1 text-2xl font-semibold text-slate-900">{practiceModule.difficulty ?? "Intermediate"}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex flex-wrap items-center gap-2">
|
<div className="mt-5 flex flex-wrap items-center gap-2">
|
||||||
@@ -189,14 +187,15 @@ export default function PracticeExercisePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="acve-panel p-4">
|
<section className="acve-panel p-4">
|
||||||
<h2 className="text-base font-semibold text-slate-800">Attempt History (Mock)</h2>
|
<h2 className="text-base font-semibold text-slate-800">Attempt History</h2>
|
||||||
{attempts.length === 0 ? (
|
{attempts.length === 0 ? (
|
||||||
<p className="mt-2 text-sm text-slate-600">No attempts recorded yet for this module.</p>
|
<p className="mt-2 text-sm text-slate-600">No attempts recorded yet for this module.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-2 space-y-2">
|
<ul className="mt-2 space-y-2">
|
||||||
{attempts.map((attempt) => (
|
{attempts.map((attempt) => (
|
||||||
<li key={attempt.completedAt} className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700">
|
<li key={attempt.id} className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700">
|
||||||
Score {attempt.score}/{attempt.total} on {new Date(attempt.completedAt).toLocaleString()}
|
Score {attempt.correctCount}/{attempt.totalQuestions} ({attempt.scorePercent}%) on{" "}
|
||||||
|
{new Date(attempt.completedAt).toLocaleString()}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -237,14 +236,15 @@ export default function PracticeExercisePage() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="acve-panel p-4">
|
<section className="acve-panel p-4">
|
||||||
<h2 className="text-lg font-semibold text-slate-800">Attempt History (Mock)</h2>
|
<h2 className="text-lg font-semibold text-slate-800">Attempt History</h2>
|
||||||
{attempts.length === 0 ? (
|
{attempts.length === 0 ? (
|
||||||
<p className="mt-2 text-sm text-slate-600">No attempts recorded yet.</p>
|
<p className="mt-2 text-sm text-slate-600">No attempts recorded yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="mt-2 space-y-2">
|
<ul className="mt-2 space-y-2">
|
||||||
{attempts.map((attempt) => (
|
{attempts.map((attempt) => (
|
||||||
<li key={attempt.completedAt} className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700">
|
<li key={attempt.id} className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700">
|
||||||
Score {attempt.score}/{attempt.total} on {new Date(attempt.completedAt).toLocaleString()}
|
Score {attempt.correctCount}/{attempt.totalQuestions} ({attempt.scorePercent}%) on{" "}
|
||||||
|
{new Date(attempt.completedAt).toLocaleString()}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -285,7 +285,7 @@ export default function PracticeExercisePage() {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
className="acve-button-primary mt-6 px-6 py-2 text-sm font-semibold hover:brightness-105 disabled:opacity-50"
|
className="acve-button-primary mt-6 px-6 py-2 text-sm font-semibold hover:brightness-105 disabled:opacity-50"
|
||||||
disabled={selected === null}
|
disabled={selected === null || isSaving}
|
||||||
onClick={next}
|
onClick={next}
|
||||||
type="button"
|
type="button"
|
||||||
>
|
>
|
||||||
|
|||||||
375
app/(protected)/profile/page.tsx
Normal file
375
app/(protected)/profile/page.tsx
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
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";
|
||||||
|
import { getActiveRecommendations, getMiniGameGrade } from "@/lib/recommendations";
|
||||||
|
|
||||||
|
type ProfilePrismaClient = {
|
||||||
|
miniGameAttempt: {
|
||||||
|
findMany: (args: object) => Promise<
|
||||||
|
{
|
||||||
|
miniGameId: string;
|
||||||
|
scorePercent: number;
|
||||||
|
miniGame: { id: string; title: string; slug: string };
|
||||||
|
}[]
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function getText(value: unknown): string {
|
||||||
|
if (!value) return "";
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "object") {
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (typeof record.en === "string") return record.en;
|
||||||
|
if (typeof record.es === "string") return record.es;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelLabel: Record<string, string> = {
|
||||||
|
BEGINNER: "Beginner",
|
||||||
|
INTERMEDIATE: "Intermediate",
|
||||||
|
ADVANCED: "Advanced",
|
||||||
|
EXPERT: "Expert",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default async function ProfilePage() {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user?.id) {
|
||||||
|
redirect("/auth/login?redirectTo=/profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTeacher = user.role === UserRole.TEACHER || user.role === UserRole.SUPER_ADMIN;
|
||||||
|
|
||||||
|
if (isTeacher) {
|
||||||
|
const authoredCourses = await db.course
|
||||||
|
.findMany({
|
||||||
|
where: { authorId: user.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
slug: true,
|
||||||
|
tags: true,
|
||||||
|
level: true,
|
||||||
|
status: true,
|
||||||
|
learningOutcomes: true,
|
||||||
|
updatedAt: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
modules: true,
|
||||||
|
enrollments: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
modules: {
|
||||||
|
select: {
|
||||||
|
lessons: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
videoUrl: true,
|
||||||
|
youtubeUrl: true,
|
||||||
|
isFreePreview: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to load authored courses for profile.", error);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalCourses = authoredCourses.length;
|
||||||
|
const publishedCourses = authoredCourses.filter((course) => course.status === "PUBLISHED").length;
|
||||||
|
const totalModules = authoredCourses.reduce((acc, course) => acc + course._count.modules, 0);
|
||||||
|
const totalStudents = authoredCourses.reduce((acc, course) => acc + course._count.enrollments, 0);
|
||||||
|
|
||||||
|
const lessons = authoredCourses.flatMap((course) => course.modules.flatMap((module) => module.lessons));
|
||||||
|
const totalLessons = lessons.length;
|
||||||
|
const uploadedLessons = lessons.filter((lesson) => lesson.videoUrl || lesson.youtubeUrl).length;
|
||||||
|
const previewLessons = lessons.filter((lesson) => lesson.isFreePreview).length;
|
||||||
|
|
||||||
|
const topicSet = new Set<string>();
|
||||||
|
const outcomeSet = new Set<string>();
|
||||||
|
for (const course of authoredCourses) {
|
||||||
|
for (const tag of course.tags) {
|
||||||
|
if (tag.trim()) topicSet.add(tag.trim());
|
||||||
|
}
|
||||||
|
if (Array.isArray(course.learningOutcomes)) {
|
||||||
|
for (const outcome of course.learningOutcomes) {
|
||||||
|
if (typeof outcome === "string" && outcome.trim()) {
|
||||||
|
outcomeSet.add(outcome.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const masteredTopics = topicSet.size > 0 ? [...topicSet] : [...new Set(authoredCourses.map((course) => levelLabel[course.level] ?? course.level))];
|
||||||
|
const strengths = [...outcomeSet].slice(0, 8);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="acve-page">
|
||||||
|
<section className="acve-panel acve-section-base">
|
||||||
|
<p className="acve-pill mb-3 w-fit">Profile</p>
|
||||||
|
<h1 className="text-3xl font-semibold leading-tight text-[#222a38] md:text-4xl">{user.fullName || user.email}</h1>
|
||||||
|
<p className="mt-2 text-base text-slate-600">Teacher account | {user.email}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 md:grid-cols-4">
|
||||||
|
<article className="acve-panel p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Courses created</p>
|
||||||
|
<p className="mt-2 text-4xl font-semibold text-slate-900">{totalCourses}</p>
|
||||||
|
</article>
|
||||||
|
<article className="acve-panel p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Published</p>
|
||||||
|
<p className="mt-2 text-4xl font-semibold text-slate-900">{publishedCourses}</p>
|
||||||
|
</article>
|
||||||
|
<article className="acve-panel p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Students</p>
|
||||||
|
<p className="mt-2 text-4xl font-semibold text-slate-900">{totalStudents}</p>
|
||||||
|
</article>
|
||||||
|
<article className="acve-panel p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Content units</p>
|
||||||
|
<p className="mt-2 text-4xl font-semibold text-slate-900">{totalModules + totalLessons}</p>
|
||||||
|
<p className="mt-2 text-sm text-slate-600">{totalModules} modules + {totalLessons} lessons</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<article className="acve-panel p-5">
|
||||||
|
<h2 className="text-2xl font-semibold text-slate-900">Topics mastered</h2>
|
||||||
|
{masteredTopics.length === 0 ? (
|
||||||
|
<p className="mt-3 text-sm text-slate-600">No topic metadata yet. Add tags to your courses.</p>
|
||||||
|
) : (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{masteredTopics.map((topic) => (
|
||||||
|
<span key={topic} className="rounded-full border border-slate-300 px-3 py-1 text-xs font-semibold text-slate-700">
|
||||||
|
{topic}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{strengths.length > 0 ? (
|
||||||
|
<ul className="mt-4 space-y-2 text-sm text-slate-700">
|
||||||
|
{strengths.map((item) => (
|
||||||
|
<li key={item} className="rounded-lg border border-slate-200 px-3 py-2">
|
||||||
|
{item}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="acve-panel p-5">
|
||||||
|
<h2 className="text-2xl font-semibold text-slate-900">Upload information</h2>
|
||||||
|
<ul className="mt-3 space-y-2 text-sm text-slate-700">
|
||||||
|
<li className="rounded-lg border border-slate-200 px-3 py-2">
|
||||||
|
Lessons with media: {uploadedLessons}/{totalLessons || 0}
|
||||||
|
</li>
|
||||||
|
<li className="rounded-lg border border-slate-200 px-3 py-2">Preview lessons enabled: {previewLessons}</li>
|
||||||
|
<li className="rounded-lg border border-slate-200 px-3 py-2">Lessons pending upload: {Math.max(totalLessons - uploadedLessons, 0)}</li>
|
||||||
|
</ul>
|
||||||
|
<Link className="mt-4 inline-flex text-sm font-semibold text-brand hover:underline" href="/teacher/uploads">
|
||||||
|
Open upload library
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="acve-panel p-5">
|
||||||
|
<h2 className="text-2xl font-semibold text-slate-900">Recent course activity</h2>
|
||||||
|
{authoredCourses.length === 0 ? (
|
||||||
|
<p className="mt-3 text-sm text-slate-600">No courses yet. Start by creating a new course.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-3 space-y-2">
|
||||||
|
{authoredCourses.slice(0, 6).map((course) => (
|
||||||
|
<li key={course.id} className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700">
|
||||||
|
<p className="font-semibold text-slate-900">{getText(course.title) || "Untitled course"}</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
{course.status} | {course._count.modules} modules | {course._count.enrollments} students | updated{" "}
|
||||||
|
{new Date(course.updatedAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
<Link className="mt-1 inline-flex text-xs font-semibold text-brand hover:underline" href={`/teacher/courses/${course.slug}/edit`}>
|
||||||
|
Open editor
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [grade, recommendations, certificates] = await Promise.all([
|
||||||
|
getMiniGameGrade(user.id).catch((error) => {
|
||||||
|
console.error("Failed to load mini-game grade.", error);
|
||||||
|
return 0;
|
||||||
|
}),
|
||||||
|
getActiveRecommendations(user.id).catch((error) => {
|
||||||
|
console.error("Failed to load recommendations.", error);
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
db.certificate
|
||||||
|
.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
issuedAt: true,
|
||||||
|
metadataSnapshot: true,
|
||||||
|
course: {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { issuedAt: "desc" },
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to load certificates for profile.", error);
|
||||||
|
return [];
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let attempts: {
|
||||||
|
miniGameId: string;
|
||||||
|
scorePercent: number;
|
||||||
|
miniGame: { id: string; title: string; slug: string };
|
||||||
|
}[] = [];
|
||||||
|
try {
|
||||||
|
attempts = await (db as unknown as ProfilePrismaClient).miniGameAttempt.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
include: {
|
||||||
|
miniGame: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
slug: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { completedAt: "desc" },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
attempts = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestByGame = new Map<string, number>();
|
||||||
|
const bestByGame = new Map<string, number>();
|
||||||
|
const titleByGame = new Map<string, string>();
|
||||||
|
for (const attempt of attempts) {
|
||||||
|
if (!latestByGame.has(attempt.miniGameId)) {
|
||||||
|
latestByGame.set(attempt.miniGameId, attempt.scorePercent);
|
||||||
|
}
|
||||||
|
const best = bestByGame.get(attempt.miniGameId) ?? 0;
|
||||||
|
if (attempt.scorePercent > best) {
|
||||||
|
bestByGame.set(attempt.miniGameId, attempt.scorePercent);
|
||||||
|
}
|
||||||
|
titleByGame.set(attempt.miniGameId, attempt.miniGame.title);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gameRows = [...titleByGame.keys()].map((gameId) => ({
|
||||||
|
gameId,
|
||||||
|
title: titleByGame.get(gameId) ?? "Mini-game",
|
||||||
|
latest: latestByGame.get(gameId) ?? 0,
|
||||||
|
best: bestByGame.get(gameId) ?? 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="acve-page">
|
||||||
|
<section className="acve-panel acve-section-base">
|
||||||
|
<p className="acve-pill mb-3 w-fit">Profile</p>
|
||||||
|
<h1 className="text-3xl font-semibold leading-tight text-[#222a38] md:text-4xl">{user.fullName || user.email}</h1>
|
||||||
|
<p className="mt-2 text-base text-slate-600">{user.email}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 md:grid-cols-3">
|
||||||
|
<article className="acve-panel p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Mini-game grade</p>
|
||||||
|
<p className="mt-2 text-4xl font-semibold text-slate-900">{grade}%</p>
|
||||||
|
<p className="mt-2 text-sm text-slate-600">Average of latest attempts across mini-games.</p>
|
||||||
|
</article>
|
||||||
|
<article className="acve-panel p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Attempts</p>
|
||||||
|
<p className="mt-2 text-4xl font-semibold text-slate-900">{attempts.length}</p>
|
||||||
|
<Link className="mt-2 inline-flex text-sm font-semibold text-brand hover:underline" href="/practice">
|
||||||
|
Play mini-games
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
<article className="acve-panel p-5">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Certificates</p>
|
||||||
|
<p className="mt-2 text-4xl font-semibold text-slate-900">{certificates.length}</p>
|
||||||
|
<Link className="mt-2 inline-flex text-sm font-semibold text-brand hover:underline" href="/my-courses">
|
||||||
|
View my courses
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<article className="acve-panel p-5">
|
||||||
|
<h2 className="text-2xl font-semibold text-slate-900">Mini-game breakdown</h2>
|
||||||
|
{gameRows.length === 0 ? (
|
||||||
|
<p className="mt-3 text-sm text-slate-600">No attempts yet. Complete a mini-game to generate your grade.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-3 space-y-2">
|
||||||
|
{gameRows.map((row) => (
|
||||||
|
<li key={row.gameId} className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700">
|
||||||
|
<span className="font-semibold text-slate-900">{row.title}</span> | latest: {row.latest}% | best: {row.best}%
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article className="acve-panel p-5">
|
||||||
|
<h2 className="text-2xl font-semibold text-slate-900">Recommended next</h2>
|
||||||
|
{recommendations.length === 0 ? (
|
||||||
|
<p className="mt-3 text-sm text-slate-600">No recommendations yet. Complete a mini-game or enroll in a course first.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-3 space-y-2">
|
||||||
|
{recommendations.map((recommendation) => (
|
||||||
|
<li key={recommendation.courseId} className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700">
|
||||||
|
<p className="font-semibold text-slate-900">{recommendation.title}</p>
|
||||||
|
<p className="text-xs text-slate-500">{recommendation.reason}</p>
|
||||||
|
<Link className="mt-1 inline-flex text-xs font-semibold text-brand hover:underline" href={`/courses/${recommendation.slug}`}>
|
||||||
|
Open course
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="acve-panel p-5">
|
||||||
|
<h2 className="text-2xl font-semibold text-slate-900">Certificates</h2>
|
||||||
|
{certificates.length === 0 ? (
|
||||||
|
<p className="mt-3 text-sm text-slate-600">No certificates issued yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-3 space-y-2">
|
||||||
|
{certificates.map((certificate) => {
|
||||||
|
const certificateNumber =
|
||||||
|
(certificate as Record<string, unknown>).certificateNumber ??
|
||||||
|
(certificate.metadataSnapshot as Record<string, unknown> | null)?.certificateNumber ??
|
||||||
|
"N/A";
|
||||||
|
return (
|
||||||
|
<li key={certificate.id} className="rounded-lg border border-slate-200 px-3 py-2 text-sm text-slate-700">
|
||||||
|
<p className="font-semibold text-slate-900">{getText(certificate.course.title) || "Course"}</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Certificate #{String(certificateNumber)} | issued {new Date(certificate.issuedAt).toLocaleDateString()}
|
||||||
|
</p>
|
||||||
|
<Link className="mt-1 inline-flex text-xs font-semibold text-brand hover:underline" href={`/api/certificates/${certificate.id}/pdf`}>
|
||||||
|
Download PDF
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,11 @@ import { requireTeacher } from "@/lib/auth/requireTeacher";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { ContentStatus, Prisma, ProficiencyLevel } from "@prisma/client";
|
import { ContentStatus, Prisma, ProficiencyLevel } from "@prisma/client";
|
||||||
|
import {
|
||||||
|
buildLessonDescriptionMeta,
|
||||||
|
parseLessonDescriptionMeta,
|
||||||
|
type LessonContentType,
|
||||||
|
} from "@/lib/courses/lessonContent";
|
||||||
|
|
||||||
// --- VALIDATION SCHEMAS (Zod) ---
|
// --- VALIDATION SCHEMAS (Zod) ---
|
||||||
|
|
||||||
@@ -129,6 +134,17 @@ export async function deleteCourse(courseId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseLearningOutcomes(raw: FormDataEntryValue | null): string[] {
|
||||||
|
if (raw == null || typeof raw !== "string") return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown;
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
return parsed.filter((x): x is string => typeof x === "string");
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateCourse(courseId: string, courseSlug: string, formData: FormData) {
|
export async function updateCourse(courseId: string, courseSlug: string, formData: FormData) {
|
||||||
const user = await requireTeacher();
|
const user = await requireTeacher();
|
||||||
if (!user) return { success: false, error: "Unauthorized" };
|
if (!user) return { success: false, error: "Unauthorized" };
|
||||||
@@ -140,6 +156,7 @@ export async function updateCourse(courseId: string, courseSlug: string, formDat
|
|||||||
const level = formData.get("level") as ProficiencyLevel;
|
const level = formData.get("level") as ProficiencyLevel;
|
||||||
const status = formData.get("status") as ContentStatus;
|
const status = formData.get("status") as ContentStatus;
|
||||||
const price = parseFloat(formData.get("price") as string) || 0;
|
const price = parseFloat(formData.get("price") as string) || 0;
|
||||||
|
const learningOutcomes = parseLearningOutcomes(formData.get("learningOutcomes"));
|
||||||
|
|
||||||
await db.course.update({
|
await db.course.update({
|
||||||
where: { id: courseId, authorId: user.id },
|
where: { id: courseId, authorId: user.id },
|
||||||
@@ -149,12 +166,16 @@ export async function updateCourse(courseId: string, courseSlug: string, formDat
|
|||||||
level,
|
level,
|
||||||
status,
|
status,
|
||||||
price,
|
price,
|
||||||
},
|
learningOutcomes:
|
||||||
|
learningOutcomes.length > 0 ? (learningOutcomes as Prisma.InputJsonValue) : Prisma.JsonNull,
|
||||||
|
} as Prisma.CourseUpdateInput,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Revalidate both the list and the editor (edit route uses slug, not id)
|
// Revalidate teacher list, editor page + layout (so router.refresh() gets fresh data), and public catalog
|
||||||
revalidatePath("/teacher/courses");
|
revalidatePath("/teacher/courses");
|
||||||
revalidatePath(`/teacher/courses/${courseSlug}/edit`, "page");
|
revalidatePath(`/teacher/courses/${courseSlug}/edit`, "page");
|
||||||
|
revalidatePath(`/teacher/courses/${courseSlug}/edit`, "layout");
|
||||||
|
revalidatePath("/courses");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch {
|
} catch {
|
||||||
return { success: false, error: "Failed to update" };
|
return { success: false, error: "Failed to update" };
|
||||||
@@ -163,8 +184,13 @@ export async function updateCourse(courseId: string, courseSlug: string, formDat
|
|||||||
|
|
||||||
export async function updateLesson(lessonId: string, data: {
|
export async function updateLesson(lessonId: string, data: {
|
||||||
title?: string;
|
title?: string;
|
||||||
description?: Prisma.InputJsonValue | null;
|
description?: string;
|
||||||
videoUrl?: string;
|
videoUrl?: string;
|
||||||
|
youtubeUrl?: string;
|
||||||
|
materialUrl?: string;
|
||||||
|
contentType?: LessonContentType;
|
||||||
|
estimatedDurationMinutes?: number;
|
||||||
|
isPreview?: boolean; // maps to DB field isFreePreview
|
||||||
isPublished?: boolean; // optional: for later
|
isPublished?: boolean; // optional: for later
|
||||||
}) {
|
}) {
|
||||||
const user = await requireTeacher();
|
const user = await requireTeacher();
|
||||||
@@ -173,8 +199,30 @@ export async function updateLesson(lessonId: string, data: {
|
|||||||
try {
|
try {
|
||||||
const updateData: Prisma.LessonUpdateInput = { updatedAt: new Date() };
|
const updateData: Prisma.LessonUpdateInput = { updatedAt: new Date() };
|
||||||
if (data.title !== undefined) updateData.title = data.title;
|
if (data.title !== undefined) updateData.title = data.title;
|
||||||
if (data.description !== undefined) updateData.description = data.description === null ? Prisma.JsonNull : data.description;
|
|
||||||
if (data.videoUrl !== undefined) updateData.videoUrl = data.videoUrl;
|
if (data.videoUrl !== undefined) updateData.videoUrl = data.videoUrl;
|
||||||
|
if (data.youtubeUrl !== undefined) updateData.youtubeUrl = data.youtubeUrl;
|
||||||
|
if (data.estimatedDurationMinutes !== undefined) {
|
||||||
|
const minutes = Math.max(0, Math.round(data.estimatedDurationMinutes));
|
||||||
|
updateData.estimatedDuration = minutes * 60;
|
||||||
|
}
|
||||||
|
if (data.isPreview !== undefined) updateData.isFreePreview = data.isPreview;
|
||||||
|
|
||||||
|
const shouldUpdateMeta =
|
||||||
|
data.description !== undefined || data.contentType !== undefined || data.materialUrl !== undefined;
|
||||||
|
|
||||||
|
if (shouldUpdateMeta) {
|
||||||
|
const lesson = await db.lesson.findUnique({
|
||||||
|
where: { id: lessonId },
|
||||||
|
select: { description: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const existingMeta = parseLessonDescriptionMeta(lesson?.description);
|
||||||
|
updateData.description = buildLessonDescriptionMeta({
|
||||||
|
text: data.description ?? existingMeta.text,
|
||||||
|
contentType: data.contentType ?? existingMeta.contentType,
|
||||||
|
materialUrl: data.materialUrl ?? existingMeta.materialUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await db.lesson.update({
|
await db.lesson.update({
|
||||||
where: { id: lessonId },
|
where: { id: lessonId },
|
||||||
@@ -211,7 +259,7 @@ export async function createModule(courseId: string) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath(`/teacher/courses/${courseId}/edit`, "page");
|
revalidatePath("/teacher/courses");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Create Module Error:", error);
|
console.error("Create Module Error:", error);
|
||||||
@@ -219,6 +267,38 @@ export async function createModule(courseId: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UPDATE MODULE TITLE
|
||||||
|
*/
|
||||||
|
export async function updateModuleTitle(moduleId: string, title: string) {
|
||||||
|
const user = await requireTeacher();
|
||||||
|
if (!user) return { success: false, error: "Unauthorized" };
|
||||||
|
|
||||||
|
const trimmed = title?.trim() || "";
|
||||||
|
if (!trimmed) return { success: false, error: "El título no puede estar vacío" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const moduleRow = await db.module.findFirst({
|
||||||
|
where: { id: moduleId, course: { authorId: user.id } },
|
||||||
|
select: { id: true, course: { select: { slug: true } } },
|
||||||
|
});
|
||||||
|
if (!moduleRow) return { success: false, error: "Módulo no encontrado" };
|
||||||
|
|
||||||
|
await db.module.update({
|
||||||
|
where: { id: moduleId },
|
||||||
|
data: { title: trimmed },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath("/teacher/courses");
|
||||||
|
revalidatePath(`/teacher/courses/${moduleRow.course.slug}/edit`, "page");
|
||||||
|
revalidatePath(`/teacher/courses/${moduleRow.course.slug}/edit`, "layout");
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Update Module Title Error:", error);
|
||||||
|
return { success: false, error: "No se pudo actualizar el título" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 2. CREATE LESSON
|
// 2. CREATE LESSON
|
||||||
export async function createLesson(moduleId: string) {
|
export async function createLesson(moduleId: string) {
|
||||||
const user = await requireTeacher();
|
const user = await requireTeacher();
|
||||||
@@ -238,6 +318,9 @@ export async function createLesson(moduleId: string) {
|
|||||||
data: {
|
data: {
|
||||||
moduleId,
|
moduleId,
|
||||||
title: "Nueva Lección",
|
title: "Nueva Lección",
|
||||||
|
description: {
|
||||||
|
contentType: "VIDEO",
|
||||||
|
},
|
||||||
orderIndex: newOrder,
|
orderIndex: newOrder,
|
||||||
estimatedDuration: 0,
|
estimatedDuration: 0,
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -383,4 +466,4 @@ export async function reorderLessons(lessonId: string, direction: "up" | "down")
|
|||||||
|
|
||||||
revalidatePath(`/teacher/courses/${currentLesson.module.course.slug}/edit`, "page");
|
revalidatePath(`/teacher/courses/${currentLesson.module.course.slug}/edit`, "page");
|
||||||
return { success: true };
|
return { success: true };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { requireTeacher } from "@/lib/auth/requireTeacher";
|
|||||||
import { notFound, redirect } from "next/navigation";
|
import { notFound, redirect } from "next/navigation";
|
||||||
import TeacherEditCourseForm from "@/components/teacher/TeacherEditCourseForm";
|
import TeacherEditCourseForm from "@/components/teacher/TeacherEditCourseForm";
|
||||||
|
|
||||||
|
// Always fetch fresh course data (no Full Route Cache) so save + router.refresh() shows updated level/status
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function CourseEditPage({ params }: { params: Promise<{ slug: string }> }) {
|
export default async function CourseEditPage({ params }: { params: Promise<{ slug: string }> }) {
|
||||||
const user = await requireTeacher();
|
const user = await requireTeacher();
|
||||||
if (!user) redirect("/auth/login");
|
if (!user) redirect("/auth/login");
|
||||||
@@ -37,5 +40,6 @@ export default async function CourseEditPage({ params }: { params: Promise<{ slu
|
|||||||
price: course.price.toNumber(),
|
price: course.price.toNumber(),
|
||||||
};
|
};
|
||||||
|
|
||||||
return <TeacherEditCourseForm course={courseData} />;
|
// Key forces remount when course updates so uncontrolled inputs (level, status) show new defaultValues after save + router.refresh()
|
||||||
|
return <TeacherEditCourseForm key={`${course.id}-${course.updatedAt.toISOString()}`} course={courseData} />;
|
||||||
}
|
}
|
||||||
@@ -5,6 +5,8 @@ import { useRouter } from "next/navigation";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { updateLesson } from "@/app/(protected)/teacher/actions";
|
import { updateLesson } from "@/app/(protected)/teacher/actions";
|
||||||
import VideoUpload from "@/components/teacher/VideoUpload"; // The component you created earlier
|
import VideoUpload from "@/components/teacher/VideoUpload"; // The component you created earlier
|
||||||
|
import { getClientLocale } from "@/lib/i18n/clientLocale";
|
||||||
|
import { getLessonContentTypeLabel, lessonContentTypes, type LessonContentType } from "@/lib/courses/lessonContent";
|
||||||
|
|
||||||
interface LessonEditorFormProps {
|
interface LessonEditorFormProps {
|
||||||
lesson: {
|
lesson: {
|
||||||
@@ -12,6 +14,11 @@ interface LessonEditorFormProps {
|
|||||||
title: string;
|
title: string;
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
videoUrl?: string | null;
|
videoUrl?: string | null;
|
||||||
|
youtubeUrl?: string | null;
|
||||||
|
isFreePreview?: boolean;
|
||||||
|
contentType: LessonContentType;
|
||||||
|
materialUrl?: string | null;
|
||||||
|
estimatedDurationMinutes: number;
|
||||||
};
|
};
|
||||||
courseSlug: string;
|
courseSlug: string;
|
||||||
}
|
}
|
||||||
@@ -21,6 +28,29 @@ export function LessonEditorForm({ lesson, courseSlug }: LessonEditorFormProps)
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [title, setTitle] = useState(lesson.title);
|
const [title, setTitle] = useState(lesson.title);
|
||||||
const [description, setDescription] = useState(lesson.description ?? "");
|
const [description, setDescription] = useState(lesson.description ?? "");
|
||||||
|
const [youtubeUrl, setYoutubeUrl] = useState(lesson.youtubeUrl ?? "");
|
||||||
|
const [isFreePreview, setIsFreePreview] = useState(lesson.isFreePreview ?? false);
|
||||||
|
const [contentType, setContentType] = useState<LessonContentType>(lesson.contentType);
|
||||||
|
const [materialUrl, setMaterialUrl] = useState(lesson.materialUrl ?? "");
|
||||||
|
const [durationMinutes, setDurationMinutes] = useState(lesson.estimatedDurationMinutes);
|
||||||
|
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// 1. Auto-save Video URL when upload finishes
|
// 1. Auto-save Video URL when upload finishes
|
||||||
const handleVideoUploaded = async (url: string) => {
|
const handleVideoUploaded = async (url: string) => {
|
||||||
@@ -36,12 +66,20 @@ export function LessonEditorForm({ lesson, courseSlug }: LessonEditorFormProps)
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 2. Save Text Changes (Title/Desc)
|
// 2. Save Text Changes (Title/Desc/YouTube/Preview)
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const res = await updateLesson(lesson.id, { title, description });
|
const res = await updateLesson(lesson.id, {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
youtubeUrl: youtubeUrl.trim() || undefined,
|
||||||
|
materialUrl: materialUrl.trim() || undefined,
|
||||||
|
contentType,
|
||||||
|
estimatedDurationMinutes: durationMinutes,
|
||||||
|
isPreview: isFreePreview,
|
||||||
|
});
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success("Cambios guardados");
|
showSavedToast();
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
toast.error("Error al guardar cambios");
|
toast.error("Error al guardar cambios");
|
||||||
@@ -68,6 +106,17 @@ export function LessonEditorForm({ lesson, courseSlug }: LessonEditorFormProps)
|
|||||||
onUploadComplete={handleVideoUploaded}
|
onUploadComplete={handleVideoUploaded}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="border-t border-slate-100 p-6">
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">YouTube URL</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={youtubeUrl}
|
||||||
|
onChange={(e) => setYoutubeUrl(e.target.value)}
|
||||||
|
placeholder="https://www.youtube.com/watch?v=..."
|
||||||
|
className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">Si se proporciona, se usará en lugar del video subido en la lección.</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Text Content */}
|
{/* Text Content */}
|
||||||
@@ -91,11 +140,64 @@ export function LessonEditorForm({ lesson, courseSlug }: LessonEditorFormProps)
|
|||||||
placeholder="Escribe aquí el contenido de la lección..."
|
placeholder="Escribe aquí el contenido de la lección..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">URL de material (PDF / actividad / lectura)</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={materialUrl}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">Se usará en la vista del alumno como lectura o actividad descargable.</p>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* RIGHT: Settings / Actions */}
|
{/* RIGHT: Settings / Actions */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-6">
|
||||||
|
<h3 className="font-semibold text-slate-900 mb-4">Tipo de contenido</h3>
|
||||||
|
<label className="block text-sm text-slate-700 mb-2">Formato de la lección</label>
|
||||||
|
<select
|
||||||
|
value={contentType}
|
||||||
|
onChange={(e) => setContentType(e.target.value as LessonContentType)}
|
||||||
|
className="w-full rounded-md border border-slate-300 px-3 py-2 outline-none focus:border-black"
|
||||||
|
>
|
||||||
|
{lessonContentTypes.map((type) => (
|
||||||
|
<option key={type} value={type}>
|
||||||
|
{getLessonContentTypeLabel(type)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<p className="mt-2 text-xs text-slate-500">
|
||||||
|
Usa “Evaluación final” para marcar el examen obligatorio del curso.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label className="block text-sm text-slate-700 mb-1 mt-4">Duración estimada (minutos)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={durationMinutes}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-6">
|
||||||
|
<h3 className="font-semibold text-slate-900 mb-4">Vista previa gratuita</h3>
|
||||||
|
<label className="flex cursor-pointer items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isFreePreview}
|
||||||
|
onChange={(e) => setIsFreePreview(e.target.checked)}
|
||||||
|
className="h-4 w-4 rounded border-slate-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-slate-700">Accesible sin inscripción (teaser)</span>
|
||||||
|
</label>
|
||||||
|
<p className="mt-2 text-xs text-slate-500">Los no inscritos podrán ver esta lección sin comprar el curso.</p>
|
||||||
|
</div>
|
||||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-6">
|
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-6">
|
||||||
<h3 className="font-semibold text-slate-900 mb-4">Acciones</h3>
|
<h3 className="font-semibold text-slate-900 mb-4">Acciones</h3>
|
||||||
<button
|
<button
|
||||||
@@ -117,4 +219,4 @@ export function LessonEditorForm({ lesson, courseSlug }: LessonEditorFormProps)
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { requireTeacher } from "@/lib/auth/requireTeacher";
|
|||||||
import { redirect, notFound } from "next/navigation";
|
import { redirect, notFound } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { LessonEditorForm } from "./LessonEditorForm";
|
import { LessonEditorForm } from "./LessonEditorForm";
|
||||||
|
import { parseLessonDescriptionMeta } from "@/lib/courses/lessonContent";
|
||||||
|
|
||||||
function getText(value: unknown): string {
|
function getText(value: unknown): string {
|
||||||
if (!value) return "";
|
if (!value) return "";
|
||||||
@@ -42,6 +43,8 @@ export default async function LessonPage({ params }: PageProps) {
|
|||||||
if (!lesson) notFound();
|
if (!lesson) notFound();
|
||||||
if (lesson.module.course.authorId !== user.id) redirect("/teacher");
|
if (lesson.module.course.authorId !== user.id) redirect("/teacher");
|
||||||
|
|
||||||
|
const lessonMeta = parseLessonDescriptionMeta(lesson.description);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
<div className="max-w-4xl mx-auto p-6">
|
||||||
{/* Breadcrumbs */}
|
{/* Breadcrumbs */}
|
||||||
@@ -64,10 +67,13 @@ export default async function LessonPage({ params }: PageProps) {
|
|||||||
lesson={{
|
lesson={{
|
||||||
...lesson,
|
...lesson,
|
||||||
title: getText(lesson.title),
|
title: getText(lesson.title),
|
||||||
description: getText(lesson.description),
|
description: lessonMeta.text,
|
||||||
|
contentType: lessonMeta.contentType,
|
||||||
|
materialUrl: lessonMeta.materialUrl,
|
||||||
|
estimatedDurationMinutes: Math.max(0, Math.ceil((lesson.estimatedDuration ?? 0) / 60)),
|
||||||
}}
|
}}
|
||||||
courseSlug={slug}
|
courseSlug={slug}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export default async function TeacherDashboardPage() {
|
|||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
logger.info("User not authorized as teacher, redirecting");
|
logger.info("User not authorized as teacher, redirecting");
|
||||||
redirect("/login");
|
redirect("/auth/login?role=teacher");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -104,4 +104,4 @@ export default async function TeacherDashboardPage() {
|
|||||||
logger.error("Critical error in TeacherDashboardPage", error);
|
logger.error("Critical error in TeacherDashboardPage", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,34 +33,40 @@ export default function CaseStudyDetailPage() {
|
|||||||
<p className="text-sm font-semibold uppercase tracking-wide text-slate-500">{caseStudy.citation} ({caseStudy.year})</p>
|
<p className="text-sm font-semibold uppercase tracking-wide text-slate-500">{caseStudy.citation} ({caseStudy.year})</p>
|
||||||
<h1 className="mt-2 text-4xl font-semibold leading-tight text-[#1f2b3a] md:text-5xl">{caseStudy.title}</h1>
|
<h1 className="mt-2 text-4xl font-semibold leading-tight text-[#1f2b3a] md:text-5xl">{caseStudy.title}</h1>
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
<span className="rounded-full border border-slate-300 bg-white px-3 py-1 text-xs font-semibold text-slate-700">Topic: {caseStudy.topic}</span>
|
<span className="rounded-full border border-slate-300 bg-white px-3 py-1 text-xs font-semibold text-slate-700">
|
||||||
|
Categoria: {caseStudy.category}
|
||||||
|
</span>
|
||||||
|
<span className="rounded-full border border-slate-300 bg-white px-3 py-1 text-xs font-semibold text-slate-700">
|
||||||
|
Dificultad: {caseStudy.difficulty}
|
||||||
|
</span>
|
||||||
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${levelBadgeClass(caseStudy.level)}`}>Level: {caseStudy.level}</span>
|
<span className={`rounded-full px-3 py-1 text-xs font-semibold ${levelBadgeClass(caseStudy.level)}`}>Level: {caseStudy.level}</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section className="grid gap-4 lg:grid-cols-[1.35fr_0.8fr]">
|
<section className="grid gap-4 lg:grid-cols-[1.35fr_0.8fr]">
|
||||||
<div className="acve-panel p-5">
|
<div className="acve-panel p-5">
|
||||||
<h2 className="text-2xl font-semibold text-[#232b39]">Case Summary</h2>
|
<h2 className="text-2xl font-semibold text-[#232b39]">Resumen del caso</h2>
|
||||||
<p className="mt-2 text-base leading-relaxed text-slate-700 md:text-lg">{caseStudy.summary}</p>
|
<p className="mt-2 text-base leading-relaxed text-slate-700 md:text-lg">{caseStudy.summaryEs}</p>
|
||||||
|
|
||||||
<h3 className="mt-5 text-lg font-semibold text-[#232b39]">Reading Guide (Placeholder)</h3>
|
<h3 className="mt-5 text-lg font-semibold text-[#232b39]">Resultado Juridico</h3>
|
||||||
<ul className="mt-2 space-y-2 text-sm text-slate-700">
|
<p className="mt-2 rounded-lg border border-slate-200 bg-white px-3 py-3 text-sm leading-relaxed text-slate-700">
|
||||||
<li className="rounded-lg border border-slate-200 bg-white px-3 py-2">1. Identify the legal issue and the jurisdiction context.</li>
|
{caseStudy.legalOutcomeEs}
|
||||||
<li className="rounded-lg border border-slate-200 bg-white px-3 py-2">2. Highlight the key reasoning applied by the court.</li>
|
</p>
|
||||||
<li className="rounded-lg border border-slate-200 bg-white px-3 py-2">3. Extract practical implications for drafting or litigation.</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside className="acve-panel p-5">
|
<aside className="acve-panel p-5">
|
||||||
<h2 className="text-lg font-semibold text-[#232b39]">Key Legal Terms</h2>
|
<h2 className="text-lg font-semibold text-[#232b39]">Terminos Juridicos Fundamentales</h2>
|
||||||
<div className="mt-3 space-y-2">
|
<div className="mt-3 space-y-2">
|
||||||
{caseStudy.keyTerms.map((term, index) => (
|
{caseStudy.keyTerms.map((term) => (
|
||||||
<div key={term} className="rounded-xl border border-slate-200 bg-[#faf8f8] p-3">
|
<div key={term.term} className="rounded-xl border border-slate-200 bg-[#faf8f8] p-3">
|
||||||
<p className="text-base font-semibold text-brand">{term}</p>
|
<p className="text-base font-semibold text-brand">{term.term}</p>
|
||||||
<p className="mt-1 text-sm text-slate-600">Legal explanation block {index + 1}</p>
|
<p className="mt-1 text-sm text-slate-600">{term.definitionEs}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
<p className="mt-4 inline-flex rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-slate-700">
|
||||||
|
{caseStudy.quizPrompt}
|
||||||
|
</p>
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -53,7 +53,8 @@ export default function CaseStudiesPage() {
|
|||||||
[{caseStudy.year}] {caseStudy.citation}
|
[{caseStudy.year}] {caseStudy.citation}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex items-center gap-2 text-xs">
|
<div className="mt-2 flex items-center gap-2 text-xs">
|
||||||
<span className="rounded-full bg-slate-100 px-2 py-1 text-slate-700">{caseStudy.topic}</span>
|
<span className="rounded-full bg-slate-100 px-2 py-1 text-slate-700">{caseStudy.category}</span>
|
||||||
|
<span className="rounded-full bg-slate-100 px-2 py-1 text-slate-700">{caseStudy.difficulty}</span>
|
||||||
<span className={`rounded-full px-2 py-1 font-semibold ${levelBadgeClass(caseStudy.level)}`}>{caseStudy.level}</span>
|
<span className={`rounded-full px-2 py-1 font-semibold ${levelBadgeClass(caseStudy.level)}`}>{caseStudy.level}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@@ -72,27 +73,35 @@ export default function CaseStudiesPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2 text-right text-xs">
|
<div className="space-y-2 text-right text-xs">
|
||||||
<p className={`rounded-full px-3 py-1 font-semibold ${levelBadgeClass(activeCase.level)}`}>{activeCase.level}</p>
|
<p className={`rounded-full px-3 py-1 font-semibold ${levelBadgeClass(activeCase.level)}`}>{activeCase.level}</p>
|
||||||
<p className="rounded-full bg-slate-100 px-3 py-1 text-slate-700">{activeCase.topic}</p>
|
<p className="rounded-full bg-slate-100 px-3 py-1 text-slate-700">{activeCase.category}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="border-t border-slate-200 pt-4">
|
<section className="border-t border-slate-200 pt-4">
|
||||||
<h3 className="text-2xl font-semibold text-[#232b39]">Case Summary</h3>
|
<h3 className="text-2xl font-semibold text-[#232b39]">Resumen del caso</h3>
|
||||||
<p className="mt-2 text-base leading-relaxed text-slate-700 md:text-lg">{activeCase.summary}</p>
|
<p className="mt-2 text-base leading-relaxed text-slate-700 md:text-lg">{activeCase.summaryEs}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="mt-5">
|
<section className="mt-5">
|
||||||
<h3 className="mb-3 text-2xl font-semibold text-[#232b39]">Key Legal Terms Explained</h3>
|
<h3 className="mb-3 text-2xl font-semibold text-[#232b39]">Terminos Juridicos Fundamentales</h3>
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
{activeCase.keyTerms.map((term) => (
|
{activeCase.keyTerms.map((term) => (
|
||||||
<div key={term} className="rounded-xl border border-slate-200 bg-[#faf8f8] p-4">
|
<div key={term.term} className="rounded-xl border border-slate-200 bg-[#faf8f8] p-4">
|
||||||
<p className="text-lg font-semibold text-brand">{term}</p>
|
<p className="text-lg font-semibold text-brand">{term.term}</p>
|
||||||
<p className="mt-1 text-sm text-slate-600">Detailed explanation will expand in phase 2 content.</p>
|
<p className="mt-1 text-sm text-slate-600">{term.definitionEs}</p>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="mt-5 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<h3 className="text-xl font-semibold text-[#232b39]">Resultado Juridico</h3>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-slate-700">{activeCase.legalOutcomeEs}</p>
|
||||||
|
<p className="mt-3 inline-flex rounded-full bg-white px-3 py-1 text-xs font-semibold uppercase tracking-wide text-slate-600">
|
||||||
|
{activeCase.quizPrompt}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<Link className="acve-button-secondary mt-6 inline-flex px-4 py-2 text-sm font-semibold hover:bg-brand-soft" href={`/case-studies/${activeCase.slug}`}>
|
<Link className="acve-button-secondary mt-6 inline-flex px-4 py-2 text-sm font-semibold hover:bg-brand-soft" href={`/case-studies/${activeCase.slug}`}>
|
||||||
Open detail page
|
Open detail page
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
87
app/(public)/comunidad/page.tsx
Normal file
87
app/(public)/comunidad/page.tsx
Normal file
@@ -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 (
|
||||||
|
<div className="acve-page">
|
||||||
|
<section className="acve-panel acve-section-base">
|
||||||
|
<p className="acve-pill mb-4 w-fit">Comunidad ACVE</p>
|
||||||
|
<h1 className="acve-heading text-4xl md:text-5xl">Red de práctica y colaboración</h1>
|
||||||
|
<p className="mt-3 max-w-3xl text-base text-muted-foreground md:text-lg">
|
||||||
|
Collage visual temporal con fotos de referencia para representar talleres, networking y actividades colaborativas.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-8 grid auto-rows-[150px] grid-cols-2 gap-3 md:auto-rows-[170px] md:grid-cols-4">
|
||||||
|
{communityPhotos.map((photo) => (
|
||||||
|
<figure
|
||||||
|
key={photo.id}
|
||||||
|
className={`group relative overflow-hidden rounded-2xl border border-border/80 bg-card shadow-sm ${photo.span}`}
|
||||||
|
>
|
||||||
|
<img alt={photo.alt} className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]" loading="lazy" src={photo.src} />
|
||||||
|
<figcaption className="absolute inset-x-2 bottom-2 rounded-lg bg-black/55 px-2 py-1 text-xs font-semibold text-white">
|
||||||
|
{photo.caption}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-5 text-sm font-semibold text-primary">Estas imágenes son temporales y se podrán reemplazar por contenido oficial.</p>
|
||||||
|
<Link className="acve-button-secondary mt-6 inline-flex px-5 py-2.5 text-sm font-semibold hover:bg-accent" href="/">
|
||||||
|
Regresar al inicio
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import { requireUser } from "@/lib/auth/requireUser";
|
import { requireUser } from "@/lib/auth/requireUser";
|
||||||
import { db } from "@/lib/prisma";
|
import { db } from "@/lib/prisma";
|
||||||
|
import { issueCertificateIfEligible } from "@/lib/certificates";
|
||||||
|
import { refreshStudyRecommendations } from "@/lib/recommendations";
|
||||||
|
|
||||||
type ToggleLessonCompleteInput = {
|
type ToggleLessonCompleteInput = {
|
||||||
courseSlug: string;
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
192
app/(public)/courses/[slug]/learn/page.tsx
Normal file
192
app/(public)/courses/[slug]/learn/page.tsx
Normal file
@@ -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<string, unknown>;
|
||||||
|
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 (
|
||||||
|
<StudentClassroomClient
|
||||||
|
courseSlug={course.slug}
|
||||||
|
courseTitle={getText(course.title) || "Untitled course"}
|
||||||
|
modules={modules}
|
||||||
|
initialSelectedLessonId={initialSelectedLessonId}
|
||||||
|
initialCompletedLessonIds={completedProgress.map((p) => p.lessonId)}
|
||||||
|
isEnrolled={isEnrolled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,184 +1,169 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { db } from "@/lib/prisma";
|
|
||||||
import { requireUser } from "@/lib/auth/requireUser";
|
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 {
|
export const dynamic = "force-dynamic";
|
||||||
if (!value) return "";
|
|
||||||
if (typeof value === "string") return value;
|
|
||||||
if (typeof value === "object") {
|
|
||||||
const record = value as Record<string, unknown>;
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
type PageProps = {
|
type PageProps = {
|
||||||
params: Promise<{ slug: string }>;
|
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) {
|
export default async function CourseDetailPage({ params }: PageProps) {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
const user = await requireUser().catch(() => null);
|
||||||
|
|
||||||
const course = await db.course.findFirst({
|
const course = await getCourseDetailViewModel(slug, user?.id ?? null);
|
||||||
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 } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!course) notFound();
|
if (!course) notFound();
|
||||||
|
|
||||||
const user = await requireUser();
|
const actions = buildActionState({
|
||||||
const isAuthed = Boolean(user?.id);
|
slug: course.slug,
|
||||||
|
isAuthenticated: Boolean(user?.id),
|
||||||
const title = getText(course.title) || "Untitled course";
|
isEnrolled: course.isEnrolled,
|
||||||
const summary = getText(course.description) || "";
|
availabilityState: course.availabilityState,
|
||||||
|
progressPercent: course.progressPercent,
|
||||||
const lessons = course.modules.flatMap((m) =>
|
firstPreviewLessonId: course.firstPreviewLessonId,
|
||||||
m.lessons.map((l) => ({
|
price: course.price,
|
||||||
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",
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="acve-page">
|
<div className="acve-page">
|
||||||
<section className="acve-panel overflow-hidden p-0">
|
<section className="grid items-start gap-5 xl:grid-cols-[1.55fr_0.95fr]">
|
||||||
<div className="grid gap-0 lg:grid-cols-[1.6fr_0.9fr]">
|
<CourseDetailHeader
|
||||||
<div className="acve-section-base">
|
availabilityLabel={course.availabilityLabel}
|
||||||
<Link className="inline-flex items-center gap-2 text-base text-slate-600 hover:text-brand" href="/courses">
|
availabilityState={course.availabilityState}
|
||||||
<span>{"<-"}</span>
|
description={course.longDescription}
|
||||||
Back to Courses
|
durationLabel={course.durationLabel}
|
||||||
</Link>
|
instructor={course.instructor}
|
||||||
|
lessonCount={course.lessonCount}
|
||||||
|
proficiencyLabel={course.proficiencyLabel}
|
||||||
|
stageLabel={course.stage.levelLabel}
|
||||||
|
studentsCount={course.studentsCount}
|
||||||
|
thumbnailUrl={course.thumbnailUrl}
|
||||||
|
title={course.title}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-2 text-sm text-slate-600">
|
<CourseProgressCard
|
||||||
<span className="rounded-full bg-accent px-3 py-1 font-semibold text-white">
|
availabilityLabel={course.availabilityLabel}
|
||||||
{levelLabel(course.level)}
|
completedLessons={course.completedLessons}
|
||||||
</span>
|
durationLabel={course.durationLabel}
|
||||||
<span className="rounded-full border border-slate-300 bg-white px-3 py-1 font-semibold">
|
helperText={actions.helperText}
|
||||||
{course.status.toLowerCase()}
|
instructor={course.instructor}
|
||||||
</span>
|
primaryAction={actions.primaryAction}
|
||||||
<span className="rounded-full border border-slate-300 bg-white px-3 py-1">
|
progressPercent={course.progressPercent}
|
||||||
{lessonsCount} lessons
|
secondaryAction={actions.secondaryAction}
|
||||||
</span>
|
stageLabel={course.stage.levelLabel}
|
||||||
</div>
|
totalLessons={course.totalLessons}
|
||||||
|
/>
|
||||||
<h1 className="mt-4 text-4xl font-semibold leading-tight text-[#1f2a3a] md:text-5xl">{title}</h1>
|
|
||||||
<p className="mt-3 max-w-3xl text-base leading-relaxed text-slate-600 md:text-lg">{summary}</p>
|
|
||||||
|
|
||||||
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
|
||||||
<div className="rounded-xl border border-slate-200 bg-white p-3">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Students</p>
|
|
||||||
<p className="mt-1 text-2xl font-semibold text-slate-900">
|
|
||||||
{course._count.enrollments.toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl border border-slate-200 bg-white p-3">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Lessons</p>
|
|
||||||
<p className="mt-1 text-2xl font-semibold text-slate-900">{lessonsCount}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl border border-slate-200 bg-white p-3">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Instructor</p>
|
|
||||||
<p className="mt-1 text-lg font-semibold text-slate-900">
|
|
||||||
{course.author.fullName || "ACVE Team"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside className="border-t border-slate-200 bg-slate-50/80 p-6 lg:border-l lg:border-t-0">
|
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-wide text-slate-500">What you will learn</h2>
|
|
||||||
<ul className="mt-3 space-y-2">
|
|
||||||
{learningOutcomes.map((item) => (
|
|
||||||
<li key={item} className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700">
|
|
||||||
{item}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="grid gap-5 lg:grid-cols-[1.6fr_0.85fr]">
|
<ProgramContentList modules={course.modules} />
|
||||||
<article className="acve-panel acve-section-base">
|
|
||||||
<h2 className="text-2xl font-semibold text-slate-900">Course structure preview</h2>
|
|
||||||
<div className="mt-4 grid gap-2">
|
|
||||||
{lessons.slice(0, 5).map((lesson, index) => (
|
|
||||||
<div
|
|
||||||
key={lesson.id}
|
|
||||||
className="flex items-center justify-between rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm"
|
|
||||||
>
|
|
||||||
<span className="font-medium text-slate-800">
|
|
||||||
Lesson {index + 1}: {lesson.title}
|
|
||||||
</span>
|
|
||||||
<span className="text-slate-500">{lesson.minutes} min</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{lessons.length === 0 && (
|
|
||||||
<p className="rounded-lg border border-dashed border-slate-200 bg-slate-50 px-3 py-4 text-sm text-slate-500">
|
|
||||||
No lessons yet. Check back soon.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
|
|
||||||
<aside className="acve-panel space-y-5 p-6">
|
|
||||||
<h2 className="text-2xl text-slate-700 md:text-4xl">Course details</h2>
|
|
||||||
<div className="border-t border-slate-200 pt-5 text-lg text-slate-700 md:text-3xl">
|
|
||||||
<p className="mb-1 text-base text-slate-500 md:text-2xl">Instructor</p>
|
|
||||||
<p className="mb-4 font-semibold text-slate-800">{course.author.fullName || "ACVE Team"}</p>
|
|
||||||
<p className="mb-1 text-base text-slate-500 md:text-2xl">Level</p>
|
|
||||||
<p className="mb-4 font-semibold text-slate-800">{levelLabel(course.level)}</p>
|
|
||||||
<p className="mb-1 text-base text-slate-500 md:text-2xl">Lessons</p>
|
|
||||||
<p className="font-semibold text-slate-800">{lessonsCount}</p>
|
|
||||||
</div>
|
|
||||||
{isAuthed ? (
|
|
||||||
<Link
|
|
||||||
className="acve-button-primary mt-2 inline-flex w-full justify-center px-4 py-3 text-xl font-semibold md:text-2xl hover:brightness-105"
|
|
||||||
href={redirect}
|
|
||||||
>
|
|
||||||
Start Course
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<Link
|
|
||||||
className="acve-button-primary mt-2 inline-flex w-full justify-center px-4 py-3 text-xl font-semibold md:text-2xl hover:brightness-105"
|
|
||||||
href={loginUrl}
|
|
||||||
>
|
|
||||||
Login to start
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,93 +1,35 @@
|
|||||||
import CourseCard from "@/components/CourseCard";
|
import { requireUser } from "@/lib/auth/requireUser";
|
||||||
import { db } from "@/lib/prisma";
|
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() {
|
export default async function CoursesPage() {
|
||||||
const courses = await db.course.findMany({
|
const user = await requireUser().catch(() => null);
|
||||||
where: {
|
const catalog = await getCourseCatalogViewModel(user?.id ?? null);
|
||||||
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,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="acve-page">
|
<div className="acve-page">
|
||||||
<section className="acve-panel overflow-hidden p-0">
|
<CourseCatalogIntro
|
||||||
<div className="grid gap-0 lg:grid-cols-[1.45fr_0.95fr]">
|
instructorCount={catalog.totals.instructorCount}
|
||||||
<div className="acve-section-base">
|
totalCourses={catalog.totals.totalCourses}
|
||||||
<p className="acve-pill mb-4 w-fit">Course Catalog</p>
|
totalLessons={catalog.totals.totalLessons}
|
||||||
<h1 className="text-3xl font-semibold leading-tight text-[#202a39] md:text-4xl">Build your legal English learning path</h1>
|
/>
|
||||||
<p className="mt-3 max-w-2xl text-base leading-relaxed text-slate-600 md:text-lg">
|
|
||||||
Discover our published legal English courses and start with the path that matches your level.
|
|
||||||
</p>
|
|
||||||
<div className="mt-5 grid gap-3 sm:grid-cols-3">
|
|
||||||
<div className="rounded-xl border border-slate-200 bg-white p-3">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Total Courses</p>
|
|
||||||
<p className="mt-1 text-2xl font-semibold text-slate-900">{courses.length}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl border border-slate-200 bg-white p-3">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Published Lessons</p>
|
|
||||||
<p className="mt-1 text-2xl font-semibold text-slate-900">{totalLessons}</p>
|
|
||||||
</div>
|
|
||||||
<div className="rounded-xl border border-slate-200 bg-white p-3">
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Instructors</p>
|
|
||||||
<p className="mt-1 text-2xl font-semibold text-slate-900">
|
|
||||||
{new Set(courses.map((course) => course.author.fullName || "ACVE Team")).size}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<aside className="border-t border-slate-200 bg-slate-50/80 p-5 lg:border-l lg:border-t-0">
|
<CourseLevelTabs
|
||||||
<h2 className="text-base font-semibold text-slate-800">Open Access</h2>
|
items={catalog.sections.map((section) => ({
|
||||||
<p className="mt-2 text-sm text-slate-600">
|
id: section.id,
|
||||||
This catalog is public. Open any course card to visit the landing page and enroll.
|
label: section.tabLabel,
|
||||||
</p>
|
anchorId: section.anchorId,
|
||||||
</aside>
|
count: section.courses.length,
|
||||||
</div>
|
}))}
|
||||||
</section>
|
/>
|
||||||
|
|
||||||
{courses.length === 0 ? (
|
{catalog.sections.map((section) => (
|
||||||
<section className="acve-panel acve-section-base">
|
<ProgramSection key={section.id} section={section} />
|
||||||
<div className="rounded-xl border border-dashed border-slate-300 bg-slate-50 px-6 py-12 text-center">
|
))}
|
||||||
<h2 className="text-2xl font-semibold text-slate-900">Coming Soon</h2>
|
|
||||||
<p className="mt-2 text-slate-600">We are preparing new courses. Please check back shortly.</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
) : (
|
|
||||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
{courses.map((course) => (
|
|
||||||
<CourseCard key={course.slug} course={course} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
462
app/(public)/eventos/page.tsx
Normal file
462
app/(public)/eventos/page.tsx
Normal file
@@ -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<string, unknown>;
|
||||||
|
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<EventItem | null>(null);
|
||||||
|
const [addedEvents, setAddedEvents] = useState<EventItem[]>([]);
|
||||||
|
const [draft, setDraft] = useState<EventDraft>(blankDraft);
|
||||||
|
const [formError, setFormError] = useState<string | null>(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<HTMLFormElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="acve-page">
|
||||||
|
<section className="acve-panel acve-section-base">
|
||||||
|
<p className="acve-pill mb-4 w-fit">Eventos ACVE</p>
|
||||||
|
<h1 className="acve-heading text-4xl md:text-5xl">Calendario académico y networking</h1>
|
||||||
|
<p className="mt-3 max-w-3xl text-base text-muted-foreground md:text-lg">
|
||||||
|
Eventos ordenados cronológicamente. Haz clic en cualquier tarjeta para ver el detalle completo del evento.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="acve-scrollbar-none mt-8 flex gap-4 overflow-x-auto pb-2">
|
||||||
|
{orderedEvents.map((eventCard) => {
|
||||||
|
const cardDate = formatCardDate(eventCard.date);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={eventCard.id}
|
||||||
|
className="group relative flex min-h-[272px] min-w-[292px] max-w-[320px] flex-col overflow-hidden rounded-2xl border border-border/80 bg-card text-left shadow-sm transition hover:-translate-y-0.5 hover:border-primary/45 hover:shadow-md"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedEvent(eventCard)}
|
||||||
|
>
|
||||||
|
<div className="h-32 w-full overflow-hidden border-b border-border/70">
|
||||||
|
<img
|
||||||
|
alt={`Miniatura de ${eventCard.title}`}
|
||||||
|
className="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
|
||||||
|
loading="lazy"
|
||||||
|
src={eventCard.thumbnail}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 flex-col p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-xl border border-primary/25 bg-primary/10 px-2 py-1 text-center text-primary">
|
||||||
|
<p className="text-lg font-bold leading-none">{cardDate.day}</p>
|
||||||
|
<p className="text-[11px] font-semibold tracking-wide">{cardDate.month}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">{eventCard.mode}</p>
|
||||||
|
<h2 className="text-base font-semibold text-foreground">{eventCard.title}</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground">{eventCard.summary}</p>
|
||||||
|
<p className="mt-auto pt-4 text-xs font-semibold uppercase tracking-wide text-primary">{eventCard.location}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<Link className="acve-button-secondary inline-flex px-5 py-2.5 text-sm font-semibold hover:bg-accent" href="/#eventos">
|
||||||
|
Ver sección de eventos en Inicio
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="acve-panel acve-section-base border-dashed">
|
||||||
|
<h2 className="acve-heading text-2xl md:text-3xl">Gestión de eventos</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground md:text-base">Solo el equipo docente puede publicar nuevos eventos en este tablero.</p>
|
||||||
|
|
||||||
|
{isCheckingRole ? (
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground">Verificando permisos...</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isCheckingRole && !isTeacher ? (
|
||||||
|
<p className="mt-4 rounded-xl border border-border/80 bg-card/70 px-4 py-3 text-sm text-muted-foreground">
|
||||||
|
Tu perfil actual no tiene permisos de publicación. Si eres docente, inicia sesión con tu cuenta de profesor.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!isCheckingRole && isTeacher ? (
|
||||||
|
<form className="mt-6 grid gap-3 md:grid-cols-2" onSubmit={handleAddEvent}>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
className="rounded-xl border border-border bg-background px-3 py-2 text-sm"
|
||||||
|
placeholder="Título del evento"
|
||||||
|
value={draft.title}
|
||||||
|
onChange={(event) => setDraft((previous) => ({ ...previous, title: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
className="rounded-xl border border-border bg-background px-3 py-2 text-sm"
|
||||||
|
type="date"
|
||||||
|
value={draft.date}
|
||||||
|
onChange={(event) => setDraft((previous) => ({ ...previous, date: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
className="rounded-xl border border-border bg-background px-3 py-2 text-sm"
|
||||||
|
type="time"
|
||||||
|
value={draft.startTime}
|
||||||
|
onChange={(event) => setDraft((previous) => ({ ...previous, startTime: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
className="rounded-xl border border-border bg-background px-3 py-2 text-sm"
|
||||||
|
type="time"
|
||||||
|
value={draft.endTime}
|
||||||
|
onChange={(event) => setDraft((previous) => ({ ...previous, endTime: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
className="rounded-xl border border-border bg-background px-3 py-2 text-sm"
|
||||||
|
placeholder="Modalidad (Online, Presencial, Híbrido)"
|
||||||
|
value={draft.mode}
|
||||||
|
onChange={(event) => setDraft((previous) => ({ ...previous, mode: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
className="rounded-xl border border-border bg-background px-3 py-2 text-sm"
|
||||||
|
placeholder="Ubicación"
|
||||||
|
value={draft.location}
|
||||||
|
onChange={(event) => setDraft((previous) => ({ ...previous, location: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
className="min-h-[92px] rounded-xl border border-border bg-background px-3 py-2 text-sm md:col-span-2"
|
||||||
|
placeholder="Resumen breve para la tarjeta"
|
||||||
|
value={draft.summary}
|
||||||
|
onChange={(event) => setDraft((previous) => ({ ...previous, summary: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
required
|
||||||
|
className="min-h-[120px] rounded-xl border border-border bg-background px-3 py-2 text-sm md:col-span-2"
|
||||||
|
placeholder="Detalle completo para el popup"
|
||||||
|
value={draft.details}
|
||||||
|
onChange={(event) => setDraft((previous) => ({ ...previous, details: event.target.value }))}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="rounded-xl border border-border bg-background px-3 py-2 text-sm md:col-span-2"
|
||||||
|
placeholder="URL de miniatura (opcional)"
|
||||||
|
value={draft.thumbnail}
|
||||||
|
onChange={(event) => setDraft((previous) => ({ ...previous, thumbnail: event.target.value }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{formError ? <p className="text-sm font-medium text-primary md:col-span-2">{formError}</p> : null}
|
||||||
|
|
||||||
|
<button className="acve-button-primary inline-flex w-fit items-center justify-center px-5 py-2.5 text-sm font-semibold md:col-span-2" type="submit">
|
||||||
|
Publicar evento
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{selectedEvent ? (
|
||||||
|
<div
|
||||||
|
aria-modal="true"
|
||||||
|
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/55 p-4"
|
||||||
|
role="dialog"
|
||||||
|
onClick={() => setSelectedEvent(null)}
|
||||||
|
>
|
||||||
|
<article
|
||||||
|
className="acve-panel relative w-full max-w-3xl overflow-hidden shadow-2xl"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
aria-label="Cerrar detalle del evento"
|
||||||
|
className="absolute right-3 top-3 z-10 rounded-full border border-white/70 bg-white/90 px-3 py-1 text-xs font-semibold text-foreground hover:bg-white"
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedEvent(null)}
|
||||||
|
>
|
||||||
|
Cerrar
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="h-52 w-full overflow-hidden border-b border-border/70 md:h-72">
|
||||||
|
<img alt={`Imagen del evento ${selectedEvent.title}`} className="h-full w-full object-cover" src={selectedEvent.thumbnail} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-5 md:p-7">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-primary">
|
||||||
|
{formatLongDate(selectedEvent.date)} · {selectedEvent.startTime} - {selectedEvent.endTime}
|
||||||
|
</p>
|
||||||
|
<h3 className="mt-2 text-2xl font-semibold text-foreground md:text-3xl">{selectedEvent.title}</h3>
|
||||||
|
<p className="mt-3 text-sm font-semibold uppercase tracking-wide text-muted-foreground">
|
||||||
|
{selectedEvent.mode} · {selectedEvent.location}
|
||||||
|
</p>
|
||||||
|
<p className="mt-4 text-sm leading-relaxed text-muted-foreground md:text-base">{selectedEvent.details}</p>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
108
app/(public)/noticias/page.tsx
Normal file
108
app/(public)/noticias/page.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type NewsPost = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
date: string;
|
||||||
|
category: string;
|
||||||
|
excerpt: string;
|
||||||
|
bodyPreview: string;
|
||||||
|
thumbnail: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const newsPosts: NewsPost[] = [
|
||||||
|
{
|
||||||
|
id: "news-1",
|
||||||
|
title: "ACVE abre convocatoria para la cohorte de primavera",
|
||||||
|
date: "15 de marzo de 2026",
|
||||||
|
category: "Comunicado",
|
||||||
|
excerpt: "Se habilitó la convocatoria para estudiantes y profesionales que buscan fortalecer inglés jurídico aplicado.",
|
||||||
|
bodyPreview:
|
||||||
|
"El proceso incluye evaluación de nivel, entrevista breve y acompañamiento para elegir la ruta formativa más adecuada según perfil profesional.",
|
||||||
|
thumbnail:
|
||||||
|
"https://images.unsplash.com/photo-1454165804606-c3d57bc86b40?auto=format&fit=crop&w=1200&q=80",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "news-2",
|
||||||
|
title: "Nuevo ciclo de webinars con enfoque en redacción contractual",
|
||||||
|
date: "08 de marzo de 2026",
|
||||||
|
category: "Académico",
|
||||||
|
excerpt: "Iniciamos una serie de sesiones en vivo para reforzar estructuras clave de contratos en inglés.",
|
||||||
|
bodyPreview:
|
||||||
|
"Cada webinar incluye ejemplos prácticos, análisis de cláusulas frecuentes y espacio de preguntas para conectar teoría con práctica legal real.",
|
||||||
|
thumbnail:
|
||||||
|
"https://images.unsplash.com/photo-1523240795612-9a054b0db644?auto=format&fit=crop&w=1200&q=80",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "news-3",
|
||||||
|
title: "ACVE fortalece su red de mentores en litigio internacional",
|
||||||
|
date: "02 de marzo de 2026",
|
||||||
|
category: "Institucional",
|
||||||
|
excerpt: "Se integran nuevos especialistas para acompañar el desarrollo profesional de la comunidad ACVE.",
|
||||||
|
bodyPreview:
|
||||||
|
"La red de mentores cubrirá preparación de audiencias, argumentación oral y terminología crítica para contextos transnacionales.",
|
||||||
|
thumbnail:
|
||||||
|
"https://images.unsplash.com/photo-1560439514-4e9645039924?auto=format&fit=crop&w=1200&q=80",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "news-4",
|
||||||
|
title: "Publicamos guía práctica para entrevistas legales en inglés",
|
||||||
|
date: "22 de febrero de 2026",
|
||||||
|
category: "Recursos",
|
||||||
|
excerpt: "Disponible un nuevo material descargable para preparar entrevistas y reuniones con clientes internacionales.",
|
||||||
|
bodyPreview:
|
||||||
|
"La guía presenta frases útiles, recomendaciones de tono profesional y checklist de preparación para mejorar seguridad comunicativa.",
|
||||||
|
thumbnail:
|
||||||
|
"https://images.unsplash.com/photo-1521791136064-7986c2920216?auto=format&fit=crop&w=1200&q=80",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "news-5",
|
||||||
|
title: "Comunidad ACVE inicia calendario de foros colaborativos",
|
||||||
|
date: "12 de febrero de 2026",
|
||||||
|
category: "Comunidad",
|
||||||
|
excerpt: "Los foros temáticos permitirán compartir casos, dudas y mejores prácticas entre cohortes.",
|
||||||
|
bodyPreview:
|
||||||
|
"Las dinámicas estarán moderadas por el equipo docente y buscarán acelerar el aprendizaje aplicado con interacción entre perfiles diversos.",
|
||||||
|
thumbnail:
|
||||||
|
"https://images.unsplash.com/photo-1521737604893-d14cc237f11d?auto=format&fit=crop&w=1200&q=80",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function NoticiasPage() {
|
||||||
|
return (
|
||||||
|
<div className="acve-page">
|
||||||
|
<section className="acve-panel acve-section-base">
|
||||||
|
<p className="acve-pill mb-4 w-fit">Noticias ACVE</p>
|
||||||
|
<h1 className="acve-heading text-4xl md:text-5xl">Actualizaciones institucionales</h1>
|
||||||
|
<p className="mt-3 max-w-3xl text-base text-muted-foreground md:text-lg">
|
||||||
|
Formato tipo blog con tarjetas horizontales amplias, priorizando contenido textual y con miniaturas de apoyo.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="acve-scrollbar-none mt-8 max-h-[72vh] space-y-4 overflow-y-auto pr-1">
|
||||||
|
{newsPosts.map((post) => (
|
||||||
|
<article key={post.id} className="rounded-2xl border border-border/80 bg-card p-4 shadow-sm md:p-6">
|
||||||
|
<div className="flex flex-col gap-4 md:flex-row">
|
||||||
|
<div className="h-32 w-full shrink-0 overflow-hidden rounded-xl border border-border/70 md:h-24 md:w-56">
|
||||||
|
<img alt={`Miniatura de ${post.title}`} className="h-full w-full object-cover" loading="lazy" src={post.thumbnail} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wide text-primary">
|
||||||
|
{post.category} · {post.date}
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-1 text-xl font-semibold text-foreground md:text-2xl">{post.title}</h2>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground md:text-base">{post.excerpt}</p>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{post.bodyPreview}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link className="acve-button-secondary mt-6 inline-flex px-5 py-2.5 text-sm font-semibold hover:bg-accent" href="/">
|
||||||
|
Regresar al inicio
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,79 +1,297 @@
|
|||||||
import Image from "next/image";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { db } from "@/lib/prisma";
|
||||||
|
|
||||||
const highlights = [
|
type ProgramCard = {
|
||||||
"Courses designed for Latin American professionals",
|
title: string;
|
||||||
"Real English law case studies and analysis",
|
description: string;
|
||||||
"AI-powered legal assistant available 24/7",
|
status: string;
|
||||||
"Interactive practice exercises and assessments",
|
href: string;
|
||||||
|
visualClass: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fallbackPrograms: ProgramCard[] = [
|
||||||
|
{
|
||||||
|
title: "Introducción al Inglés Legal",
|
||||||
|
description:
|
||||||
|
"Una introducción estructurada al lenguaje jurídico en inglés para comprender y utilizar terminología clave con seguridad profesional.",
|
||||||
|
status: "Cohorte abierta",
|
||||||
|
href: "/courses",
|
||||||
|
visualClass: "from-[#ead9cb] via-[#f7ece3] to-[#fef8f2]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Common vs Civil Law",
|
||||||
|
description:
|
||||||
|
"Explora los dos sistemas jurídicos predominantes del mundo y aprende su lógica, diferencias y vocabulario aplicado a contextos internacionales.",
|
||||||
|
status: "Próximamente",
|
||||||
|
href: "/courses",
|
||||||
|
visualClass: "from-[#e4d4dd] via-[#f3e7ed] to-[#fff7fb]",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Fundamentos de la Traducción Jurídica",
|
||||||
|
description:
|
||||||
|
"Aprende los fundamentos de la equivalencia jurídica entre distintos sistemas legales para traducir con claridad, rigor y contexto.",
|
||||||
|
status: "Próximamente",
|
||||||
|
href: "/courses",
|
||||||
|
visualClass: "from-[#e9ddd0] via-[#f7eee5] to-[#fefaf4]",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function HomePage() {
|
const events = [
|
||||||
|
{ title: "Networking virtual", date: "24", month: "MAR", mode: "Online", note: "Próximamente..." },
|
||||||
|
{ title: "Webinar", date: "03", month: "ABR", mode: "Streaming", note: "Próximamente..." },
|
||||||
|
{ title: "Sesión Q&A", date: "17", month: "ABR", mode: "En vivo", note: "Próximamente..." },
|
||||||
|
{ title: "Taller presencial", date: "09", month: "MAY", mode: "Monterrey", note: "Próximamente..." },
|
||||||
|
];
|
||||||
|
|
||||||
|
const caseStudies = [
|
||||||
|
{
|
||||||
|
title: "Miranda v. Arizona",
|
||||||
|
year: "1966",
|
||||||
|
summary:
|
||||||
|
"Caso central en derecho penal del que surgen los Miranda rights. Ideal para trabajar vocabulario procesal, derechos constitucionales y lenguaje judicial.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Marbury v. Madison",
|
||||||
|
year: "1803",
|
||||||
|
summary:
|
||||||
|
"Caso fundacional que establece el judicial review en Estados Unidos y permite comprender cómo opera el control constitucional del poder público.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Brown v. Board of Education",
|
||||||
|
year: "1954",
|
||||||
|
summary:
|
||||||
|
"Decisión emblemática en materia de derechos civiles que declaró inconstitucional la segregación racial en escuelas públicas.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const challenges = [
|
||||||
|
{
|
||||||
|
title: "Desafío de traducción",
|
||||||
|
description: "Resuelve una traducción jurídica breve con foco en precisión terminológica y estilo profesional.",
|
||||||
|
href: "/practice",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Reto de términos legales",
|
||||||
|
description: "Pon a prueba tu dominio de conceptos clave con ejercicios de uso contextual y equivalencia funcional.",
|
||||||
|
href: "/practice",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Ejercicio de cláusulas",
|
||||||
|
description: "Analiza cláusulas reales y mejora tu capacidad de redacción y negociación en inglés jurídico.",
|
||||||
|
href: "/practice",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const visualPresets = [
|
||||||
|
"from-[#ead9cb] via-[#f7ece3] to-[#fef8f2]",
|
||||||
|
"from-[#e4d4dd] via-[#f3e7ed] to-[#fff7fb]",
|
||||||
|
"from-[#e9ddd0] via-[#f7eee5] to-[#fefaf4]",
|
||||||
|
"from-[#dfe4f1] via-[#ecf0f7] to-[#f8f9fd]",
|
||||||
|
"from-[#e7e1d6] via-[#f2ece4] to-[#fcfaf6]",
|
||||||
|
];
|
||||||
|
|
||||||
|
function getText(value: unknown): string {
|
||||||
|
if (!value) return "";
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "object") {
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (typeof record.es === "string") return record.es;
|
||||||
|
if (typeof record.en === "string") return record.en;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAcademicCards(): Promise<ProgramCard[]> {
|
||||||
|
const publishedCourses = await db.course
|
||||||
|
.findMany({
|
||||||
|
where: { status: "PUBLISHED" },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: 6,
|
||||||
|
select: {
|
||||||
|
slug: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to load featured courses for homepage.", error);
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (publishedCourses.length === 0) {
|
||||||
|
return fallbackPrograms;
|
||||||
|
}
|
||||||
|
|
||||||
|
return publishedCourses.map((course, index) => ({
|
||||||
|
title: getText(course.title) || "Programa académico ACVE",
|
||||||
|
description:
|
||||||
|
getText(course.description) ||
|
||||||
|
"Programa académico orientado al dominio del inglés jurídico con enfoque aplicado en contextos profesionales.",
|
||||||
|
status: "Disponible",
|
||||||
|
href: `/courses/${course.slug}`,
|
||||||
|
visualClass: visualPresets[index % visualPresets.length],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function HomePage() {
|
||||||
|
const programs = await getAcademicCards();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="acve-page">
|
<div className="acve-page">
|
||||||
<section className="acve-panel relative overflow-hidden p-5 md:p-8">
|
<section className="acve-panel relative overflow-hidden px-6 py-10 md:px-10 md:py-14">
|
||||||
<div className="grid items-start gap-6 lg:grid-cols-[1.15fr_0.85fr]">
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,_rgba(152,20,63,0.18),_transparent_55%)]" />
|
||||||
<div>
|
<div className="relative">
|
||||||
<p className="acve-pill mb-4 w-fit text-sm">
|
<p className="acve-pill mb-5 w-fit text-sm">ACVE Centro de Estudios</p>
|
||||||
<span className="mr-2 text-accent">*</span>
|
<h1 className="acve-heading max-w-4xl text-4xl leading-tight md:text-6xl">
|
||||||
Professional Legal Education
|
Domina el inglés jurídico y ejerce a nivel internacional.
|
||||||
</p>
|
</h1>
|
||||||
<h1 className="acve-heading text-4xl leading-tight md:text-5xl">Learn English Law with Confidence</h1>
|
<p className="mt-5 max-w-3xl text-base leading-relaxed text-muted-foreground md:text-lg">
|
||||||
<p className="mt-3 max-w-2xl text-base leading-relaxed text-slate-600 md:text-lg">
|
Formación práctica para comprender, redactar y negociar documentos legales en inglés. Diseñada para abogados y estudiantes que desean
|
||||||
Courses, case studies, and guided practice designed for Latin American professionals and students.
|
desenvolverse con solvencia en entornos académicos y profesionales globales.
|
||||||
</p>
|
</p>
|
||||||
<ul className="mt-5 grid gap-2 sm:grid-cols-2">
|
|
||||||
{highlights.map((item) => (
|
|
||||||
<li key={item} className="flex items-start gap-2 rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-700">
|
|
||||||
<span className="mt-0.5 flex h-5 w-5 items-center justify-center rounded-full border border-accent text-xs text-accent">
|
|
||||||
v
|
|
||||||
</span>
|
|
||||||
{item}
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div className="mt-6 flex flex-wrap gap-2">
|
<div className="mt-7 flex flex-wrap gap-3">
|
||||||
<Link className="acve-button-primary px-6 py-2 text-sm font-semibold transition hover:brightness-105" href="/courses">
|
<Link className="acve-button-primary inline-flex items-center px-6 py-3 text-sm font-semibold hover:brightness-105" href="/courses">
|
||||||
Start Learning
|
Explorar programas
|
||||||
</Link>
|
</Link>
|
||||||
<Link className="acve-button-secondary px-6 py-2 text-sm font-semibold transition hover:bg-brand-soft" href="/courses">
|
<Link className="acve-button-secondary inline-flex items-center px-6 py-3 text-sm font-semibold hover:bg-accent" href="/case-studies">
|
||||||
Explore Courses
|
Ver casos prácticos
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative">
|
<div className="mt-8 grid gap-3 md:grid-cols-3">
|
||||||
<div className="overflow-hidden rounded-3xl border border-slate-300 bg-white shadow-sm">
|
{["Metodología aplicada", "Casos emblemáticos", "Comunidad jurídica"].map((item) => (
|
||||||
<Image
|
<div key={item} className="rounded-2xl border border-border/80 bg-card/90 px-4 py-3 text-sm font-semibold text-foreground shadow-sm">
|
||||||
alt="ACVE legal library"
|
{item}
|
||||||
className="h-[360px] w-full object-cover object-right md:h-[460px]"
|
</div>
|
||||||
height={900}
|
))}
|
||||||
priority
|
|
||||||
src="/images/hero-reference.png"
|
|
||||||
width={700}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="absolute bottom-4 left-4 right-4 rounded-2xl border border-slate-300 bg-white px-4 py-3 shadow-sm">
|
|
||||||
<p className="text-sm font-semibold uppercase tracking-wide text-accent">AI</p>
|
|
||||||
<p className="text-lg font-semibold text-slate-800">Legal Assistant Ready</p>
|
|
||||||
<p className="text-sm text-slate-500">Ask me anything about English Law</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="mt-6 grid gap-3 border-t border-slate-200 pt-5 text-center text-xs font-semibold uppercase tracking-wide text-slate-500 md:grid-cols-3">
|
<section className="acve-panel px-6 py-8 md:px-10 md:py-10" id="formacion-academica">
|
||||||
<Link className="rounded-xl border border-slate-300 bg-white px-3 py-3 hover:border-brand hover:text-brand" href="/courses">
|
<div className="max-w-3xl">
|
||||||
Browse courses
|
<h2 className="acve-heading text-3xl md:text-4xl">Formación Académica</h2>
|
||||||
</Link>
|
<p className="mt-3 text-muted-foreground md:text-lg">
|
||||||
<Link className="rounded-xl border border-slate-300 bg-white px-3 py-3 hover:border-brand hover:text-brand" href="/case-studies">
|
Programas impartidos por instructores de élite, con lecciones focalizadas y enfoque académico para una aplicación profesional real. Los
|
||||||
Read case studies
|
cursos publicados aparecen automáticamente en esta sección.
|
||||||
</Link>
|
</p>
|
||||||
<Link className="rounded-xl border border-slate-300 bg-white px-3 py-3 hover:border-brand hover:text-brand" href="/practice">
|
</div>
|
||||||
Practice and exercises
|
|
||||||
|
<div className="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{programs.map((program) => (
|
||||||
|
<article key={program.title} className="rounded-2xl border border-border/80 bg-card p-4 shadow-sm">
|
||||||
|
<div className={cn("h-36 rounded-xl border border-border/70 bg-gradient-to-br p-4", program.visualClass)}>
|
||||||
|
<span className="inline-flex rounded-full border border-primary/20 bg-card/85 px-3 py-1 text-xs font-semibold text-primary">
|
||||||
|
{program.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<h3 className="mt-4 text-xl font-semibold text-foreground">{program.title}</h3>
|
||||||
|
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">{program.description}</p>
|
||||||
|
<Link className="mt-4 inline-flex text-sm font-semibold text-primary hover:underline" href={program.href}>
|
||||||
|
Conocer más
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="acve-panel px-6 py-8 md:px-10 md:py-10" id="eventos">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<h2 className="acve-heading text-3xl md:text-4xl">Próximos eventos</h2>
|
||||||
|
<p className="mt-3 text-muted-foreground md:text-lg">
|
||||||
|
Espacios para practicar inglés legal, fortalecer comunidad y mantenerte actualizado en tendencias jurídicas internacionales.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-7 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
{events.map((event) => (
|
||||||
|
<article key={event.title} className="rounded-2xl border border-border/80 bg-card p-4 shadow-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="rounded-xl border border-primary/20 bg-primary/10 px-2 py-2 text-center text-primary">
|
||||||
|
<p className="text-lg font-bold leading-none">{event.date}</p>
|
||||||
|
<p className="text-[11px] font-semibold tracking-wide">{event.month}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-foreground">{event.title}</h3>
|
||||||
|
<p className="text-xs uppercase tracking-wide text-muted-foreground">{event.mode}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground">{event.note}</p>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<Link className="acve-button-secondary inline-flex items-center px-5 py-2.5 text-sm font-semibold hover:bg-accent" href="/eventos">
|
||||||
|
Ver el calendario completo
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="acve-panel px-6 py-8 md:px-10 md:py-10">
|
||||||
|
<div className="max-w-4xl">
|
||||||
|
<h2 className="acve-heading text-3xl md:text-4xl">Casos prácticos</h2>
|
||||||
|
<p className="mt-3 text-muted-foreground md:text-lg">
|
||||||
|
Pon a prueba tu nivel de inglés legal con los casos judiciales más emblemáticos de Estados Unidos y del mundo.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{caseStudies.map((entry) => (
|
||||||
|
<article key={entry.title} className="rounded-2xl border border-border/80 bg-card p-5 shadow-sm">
|
||||||
|
<p className="text-sm font-semibold uppercase tracking-wide text-primary">{entry.year}</p>
|
||||||
|
<h3 className="mt-1 text-xl font-semibold text-foreground">{entry.title}</h3>
|
||||||
|
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">{entry.summary}</p>
|
||||||
|
<Link className="mt-4 inline-flex text-sm font-semibold text-primary hover:underline" href="/case-studies">
|
||||||
|
Read more
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="relative overflow-hidden rounded-3xl border border-primary/30 bg-gradient-to-r from-[#98143f] via-[#6f1737] to-[#3f1327] px-7 py-10 text-white shadow-xl md:px-10 md:py-12">
|
||||||
|
<div className="absolute inset-0 bg-[radial-gradient(circle_at_70%_30%,_rgba(255,255,255,0.24),_transparent_40%)]" />
|
||||||
|
<div className="relative max-w-3xl">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-white/80">Evento destacado</p>
|
||||||
|
<h2 className="mt-3 font-[var(--acve-heading-font)] text-3xl leading-tight md:text-4xl">Congreso Internacional de Inglés Jurídico ACVE 2026</h2>
|
||||||
|
<p className="mt-4 text-sm leading-relaxed text-white/90 md:text-base">
|
||||||
|
Una jornada intensiva con expertos en litigio, arbitraje y traducción jurídica para fortalecer tus competencias profesionales en contextos
|
||||||
|
multinacionales.
|
||||||
|
</p>
|
||||||
|
<div className="mt-6 flex flex-wrap gap-3">
|
||||||
|
<Link className="inline-flex rounded-full bg-white px-5 py-2.5 text-sm font-semibold text-[#6f1737] hover:bg-white/90" href="/auth/signup">
|
||||||
|
Reservar lugar
|
||||||
|
</Link>
|
||||||
|
<Link className="inline-flex rounded-full border border-white/50 px-5 py-2.5 text-sm font-semibold text-white hover:bg-white/10" href="/eventos">
|
||||||
|
Ver agenda preliminar
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="acve-panel px-6 py-8 md:px-10 md:py-10">
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<h2 className="acve-heading text-3xl md:text-4xl">Retos</h2>
|
||||||
|
<p className="mt-3 text-muted-foreground md:text-lg">
|
||||||
|
Entrena tu precisión lingüística con ejercicios breves diseñados para el trabajo real de abogados y equipos legales bilingües.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-7 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{challenges.map((challenge) => (
|
||||||
|
<article key={challenge.title} className="rounded-2xl border border-border/80 bg-card p-5 shadow-sm">
|
||||||
|
<h3 className="text-xl font-semibold text-foreground">{challenge.title}</h3>
|
||||||
|
<p className="mt-3 text-sm leading-relaxed text-muted-foreground">{challenge.description}</p>
|
||||||
|
<Link className="acve-button-primary mt-5 inline-flex items-center px-5 py-2.5 text-sm font-semibold hover:brightness-105" href={challenge.href}>
|
||||||
|
Iniciar
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
28
app/(public)/sobre-acve/page.tsx
Normal file
28
app/(public)/sobre-acve/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function SobreAcvePage() {
|
||||||
|
return (
|
||||||
|
<div className="acve-page">
|
||||||
|
<section className="acve-panel acve-section-base">
|
||||||
|
<p className="acve-pill mb-4 w-fit">Sobre ACVE</p>
|
||||||
|
<h1 className="acve-heading text-4xl md:text-5xl">Nuestra historia</h1>
|
||||||
|
<p className="mt-3 max-w-3xl text-base text-muted-foreground md:text-lg">
|
||||||
|
ACVE nació en Monterrey como una iniciativa de cinco fundadores con una misma convicción: que dominar el inglés jurídico abre puertas
|
||||||
|
académicas y profesionales a nivel internacional.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 max-w-3xl text-base text-muted-foreground md:text-lg">
|
||||||
|
Empezamos con grupos pequeños, sesiones prácticas y acompañamiento cercano para abogados y estudiantes. Con el tiempo, ese formato se
|
||||||
|
convirtió en una comunidad enfocada en claridad técnica, confianza comunicativa y aplicación real en contextos legales.
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 max-w-3xl text-base text-muted-foreground md:text-lg">
|
||||||
|
Hoy seguimos creciendo con el mismo enfoque de origen: formación seria, metodología aplicada y una red que impulse a cada alumno a ejercer
|
||||||
|
con mayor seguridad en escenarios globales.
|
||||||
|
</p>
|
||||||
|
<p className="mt-5 text-sm font-semibold text-primary">Origen: Monterrey · Equipo fundador: 5 personas</p>
|
||||||
|
<Link className="acve-button-secondary mt-6 inline-flex px-5 py-2.5 text-sm font-semibold hover:bg-accent" href="/">
|
||||||
|
Regresar al inicio
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
38
app/api/auth/session/route.ts
Normal file
38
app/api/auth/session/route.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { db } from "@/lib/prisma";
|
||||||
|
import { UserRole } from "@prisma/client";
|
||||||
|
import { supabaseServer } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const supabase = await supabaseServer();
|
||||||
|
if (!supabase) {
|
||||||
|
return NextResponse.json({ user: null, isTeacher: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ user: null, isTeacher: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
const profile = await db.profile.findUnique({
|
||||||
|
where: { id: user.id },
|
||||||
|
select: { role: true },
|
||||||
|
}).catch(() => null);
|
||||||
|
|
||||||
|
const isTeacher =
|
||||||
|
!!profile &&
|
||||||
|
(profile.role === UserRole.TEACHER || profile.role === UserRole.SUPER_ADMIN);
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
user: { id: user.id, email: user.email ?? null },
|
||||||
|
isTeacher,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to resolve auth session route. Returning anonymous fallback.", error);
|
||||||
|
return NextResponse.json({ user: null, isTeacher: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
69
app/api/certificates/[id]/pdf/route.ts
Normal file
69
app/api/certificates/[id]/pdf/route.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { requireUser } from "@/lib/auth/requireUser";
|
||||||
|
import { db } from "@/lib/prisma";
|
||||||
|
import { buildCertificatePdf } from "@/lib/certificates";
|
||||||
|
|
||||||
|
function getText(value: unknown): string {
|
||||||
|
if (!value) return "";
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "object") {
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (typeof record.en === "string") return record.en;
|
||||||
|
if (typeof record.es === "string") return record.es;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(_: Request, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user?.id) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const certificate = await db.certificate.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: {
|
||||||
|
course: {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!certificate || certificate.userId !== user.id) {
|
||||||
|
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
const certificateNumber =
|
||||||
|
(certificate as Record<string, unknown>).certificateNumber ??
|
||||||
|
(certificate.metadataSnapshot as Record<string, unknown> | null)?.certificateNumber ??
|
||||||
|
`ACVE-${certificate.id.slice(0, 8)}`;
|
||||||
|
|
||||||
|
const pdf = buildCertificatePdf({
|
||||||
|
certificateNumber: String(certificateNumber),
|
||||||
|
learnerName: user.fullName || user.email || "Learner",
|
||||||
|
learnerEmail: user.email,
|
||||||
|
courseTitle: getText(certificate.course.title) || "Untitled course",
|
||||||
|
issuedAt: certificate.issuedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(Buffer.from(pdf), {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/pdf",
|
||||||
|
"Content-Disposition": `attachment; filename="certificate-${String(certificateNumber)}.pdf"`,
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Certificate service is not available until latest migrations are applied." },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
175
app/globals.css
175
app/globals.css
@@ -5,9 +5,15 @@
|
|||||||
:root {
|
:root {
|
||||||
/* ACVE core variables */
|
/* ACVE core variables */
|
||||||
--acve-brand: #98143f;
|
--acve-brand: #98143f;
|
||||||
--acve-ink: #253247;
|
--acve-brand-strong: #7c1033;
|
||||||
--acve-line: #d8dce3;
|
--acve-brand-soft: #f7e8eb;
|
||||||
--acve-panel: #ffffff;
|
--acve-ink: #2f2931;
|
||||||
|
--acve-muted: #655e66;
|
||||||
|
--acve-line: #d8cec2;
|
||||||
|
--acve-panel: #fffaf3;
|
||||||
|
--acve-panel-strong: #fffdf9;
|
||||||
|
--acve-shell-bg: linear-gradient(180deg, #faf4ea 0%, #f4ebde 100%);
|
||||||
|
--acve-body-bg: radial-gradient(circle at top right, #fff8ef 0%, #f8efe3 52%, #f2e7d9 100%);
|
||||||
--acve-heading-font: "Palatino Linotype", "Book Antiqua", "Times New Roman", serif;
|
--acve-heading-font: "Palatino Linotype", "Book Antiqua", "Times New Roman", serif;
|
||||||
--acve-body-font: "Segoe UI", "Trebuchet MS", "Verdana", sans-serif;
|
--acve-body-font: "Segoe UI", "Trebuchet MS", "Verdana", sans-serif;
|
||||||
--acve-space-1: 4px;
|
--acve-space-1: 4px;
|
||||||
@@ -18,53 +24,81 @@
|
|||||||
--acve-space-6: 32px;
|
--acve-space-6: 32px;
|
||||||
--acve-space-7: 48px;
|
--acve-space-7: 48px;
|
||||||
|
|
||||||
/* SHADCN MAPPING */
|
/* SHADCN mapping */
|
||||||
--background: 0 0% 98%;
|
--background: 36 45% 95%;
|
||||||
/* Matches your #f5f5f7 approx */
|
--foreground: 330 10% 18%;
|
||||||
--foreground: 220 24% 20%;
|
|
||||||
/* Matches your --acve-ink */
|
|
||||||
|
|
||||||
--primary: 341 77% 34%;
|
--primary: 341 77% 34%;
|
||||||
/* Your Burgundy #98143f */
|
|
||||||
--primary-foreground: 0 0% 100%;
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--card: 34 55% 98%;
|
||||||
--card: 0 0% 100%;
|
--card-foreground: 330 10% 18%;
|
||||||
--card-foreground: 220 24% 20%;
|
--border: 32 26% 80%;
|
||||||
|
--input: 32 26% 80%;
|
||||||
--border: 223 18% 87%;
|
|
||||||
/* Matches your --acve-line */
|
|
||||||
--input: 223 18% 87%;
|
|
||||||
--ring: 341 77% 34%;
|
--ring: 341 77% 34%;
|
||||||
|
--secondary: 28 37% 90%;
|
||||||
--secondary: 210 40% 96.1%;
|
--secondary-foreground: 330 10% 20%;
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
--muted: 30 28% 90%;
|
||||||
|
--muted-foreground: 329 8% 41%;
|
||||||
--muted: 210 40% 96.1%;
|
--accent: 345 53% 94%;
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
--accent-foreground: 339 51% 24%;
|
||||||
|
|
||||||
--accent: 210 40% 96.1%;
|
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
|
||||||
|
|
||||||
--destructive: 0 84.2% 60.2%;
|
--destructive: 0 84.2% 60.2%;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--destructive-foreground: 210 40% 98%;
|
||||||
|
--popover: 34 55% 98%;
|
||||||
|
--popover-foreground: 330 10% 18%;
|
||||||
|
--radius: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
--popover: 0 0% 100%;
|
.dark {
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
--acve-brand: #bf4069;
|
||||||
|
--acve-brand-strong: #c95a7f;
|
||||||
|
--acve-brand-soft: #3c1f2d;
|
||||||
|
--acve-ink: #ede6e0;
|
||||||
|
--acve-muted: #b5aeb6;
|
||||||
|
--acve-line: #353847;
|
||||||
|
--acve-panel: #171c27;
|
||||||
|
--acve-panel-strong: #1b2130;
|
||||||
|
--acve-shell-bg: linear-gradient(180deg, #121621 0%, #0f1420 100%);
|
||||||
|
--acve-body-bg: radial-gradient(circle at top right, #1f2433 0%, #151a26 54%, #0f131e 100%);
|
||||||
|
|
||||||
--radius: 0.75rem;
|
--background: 222 30% 11%;
|
||||||
/* 12px to match your primary button */
|
--foreground: 30 25% 92%;
|
||||||
|
--primary: 338 53% 51%;
|
||||||
|
--primary-foreground: 0 0% 100%;
|
||||||
|
--card: 224 26% 15%;
|
||||||
|
--card-foreground: 30 25% 92%;
|
||||||
|
--border: 225 17% 27%;
|
||||||
|
--input: 225 17% 27%;
|
||||||
|
--ring: 338 53% 51%;
|
||||||
|
--secondary: 224 20% 20%;
|
||||||
|
--secondary-foreground: 30 25% 92%;
|
||||||
|
--muted: 224 20% 19%;
|
||||||
|
--muted-foreground: 225 14% 71%;
|
||||||
|
--accent: 336 27% 24%;
|
||||||
|
--accent-foreground: 30 25% 92%;
|
||||||
|
--destructive: 0 63% 38%;
|
||||||
|
--destructive-foreground: 0 0% 100%;
|
||||||
|
--popover: 224 26% 15%;
|
||||||
|
--popover-foreground: 30 25% 92%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
@apply border-border;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
background: radial-gradient(circle at top right, #fcfcfd 0%, #f5f5f7 55%, #f0f0f2 100%);
|
background: var(--acve-body-bg);
|
||||||
color: var(--acve-ink);
|
color: hsl(var(--foreground));
|
||||||
font-family: var(--acve-body-font);
|
font-family: var(--acve-body-font);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
text-rendering: geometricPrecision;
|
text-rendering: geometricPrecision;
|
||||||
|
transition:
|
||||||
|
background-color 0.2s ease,
|
||||||
|
color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
@@ -99,12 +133,17 @@ h4 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: #f2d6df;
|
background: #f1d9e1;
|
||||||
color: #421020;
|
color: #3f1020;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark ::selection {
|
||||||
|
background: #5d2e41;
|
||||||
|
color: #fff6fa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.acve-shell {
|
.acve-shell {
|
||||||
background: linear-gradient(180deg, #f7f7f8 0%, #f2f3f5 100%);
|
background: var(--acve-shell-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.acve-panel {
|
.acve-panel {
|
||||||
@@ -125,8 +164,8 @@ h4 {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
border: 1px solid var(--acve-line);
|
border: 1px solid var(--acve-line);
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
background: #fafafa;
|
background: var(--acve-panel-strong);
|
||||||
color: #384253;
|
color: var(--acve-muted);
|
||||||
padding: 8px 14px;
|
padding: 8px 14px;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
}
|
}
|
||||||
@@ -142,7 +181,7 @@ h4 {
|
|||||||
.acve-button-secondary {
|
.acve-button-secondary {
|
||||||
border: 1px solid var(--acve-brand);
|
border: 1px solid var(--acve-brand);
|
||||||
color: var(--acve-brand);
|
color: var(--acve-brand);
|
||||||
background: #ffffff;
|
background: var(--acve-panel-strong);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
min-height: 44px;
|
min-height: 44px;
|
||||||
}
|
}
|
||||||
@@ -161,8 +200,66 @@ h4 {
|
|||||||
padding: var(--acve-space-5);
|
padding: var(--acve-space-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.acve-scrollbar-none {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.acve-scrollbar-none::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.acve-page {
|
.acve-page {
|
||||||
gap: var(--acve-space-6);
|
gap: var(--acve-space-6);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Dark-mode compatibility layer for legacy light-only utility classes */
|
||||||
|
.dark .bg-white,
|
||||||
|
.dark .bg-white\/70,
|
||||||
|
.dark .bg-white\/80,
|
||||||
|
.dark .bg-white\/90,
|
||||||
|
.dark .bg-\[\#faf8f8\],
|
||||||
|
.dark .bg-\[\#f6f6f8\] {
|
||||||
|
background-color: hsl(var(--card)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-slate-50,
|
||||||
|
.dark .bg-slate-50\/40,
|
||||||
|
.dark .bg-slate-50\/50,
|
||||||
|
.dark .bg-slate-50\/80,
|
||||||
|
.dark .bg-slate-100,
|
||||||
|
.dark .bg-slate-200 {
|
||||||
|
background-color: hsl(var(--muted)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .border-slate-100,
|
||||||
|
.dark .border-slate-200,
|
||||||
|
.dark .border-slate-300,
|
||||||
|
.dark .border-slate-400,
|
||||||
|
.dark .border-gray-200 {
|
||||||
|
border-color: hsl(var(--border)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .text-black,
|
||||||
|
.dark .text-slate-900,
|
||||||
|
.dark .text-slate-800,
|
||||||
|
.dark .text-slate-700 {
|
||||||
|
color: hsl(var(--foreground)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .text-slate-600,
|
||||||
|
.dark .text-slate-500,
|
||||||
|
.dark .text-slate-400 {
|
||||||
|
color: hsl(var(--muted-foreground)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark [class*="text-[#1"],
|
||||||
|
.dark [class*="text-[#2"],
|
||||||
|
.dark [class*="text-[#3"],
|
||||||
|
.dark [class*="text-[#0"] {
|
||||||
|
color: hsl(var(--foreground)) !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import "./globals.css";
|
|||||||
import Navbar from "@/components/Navbar";
|
import Navbar from "@/components/Navbar";
|
||||||
import Footer from "@/components/Footer";
|
import Footer from "@/components/Footer";
|
||||||
import AssistantDrawer from "@/components/AssistantDrawer";
|
import AssistantDrawer from "@/components/AssistantDrawer";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
import { ThemeProvider } from "@/components/theme-provider";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "ACVE",
|
title: "ACVE",
|
||||||
@@ -11,14 +13,17 @@ export const metadata: Metadata = {
|
|||||||
|
|
||||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="es" suppressHydrationWarning>
|
||||||
<body>
|
<body>
|
||||||
<div className="acve-shell flex min-h-screen flex-col">
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem disableTransitionOnChange>
|
||||||
<Navbar />
|
<div className="acve-shell flex min-h-screen flex-col">
|
||||||
<main className="mx-auto w-full max-w-[1200px] flex-1 px-4 py-5 md:px-5 md:py-7">{children}</main>
|
<Navbar />
|
||||||
<Footer />
|
<main className="mx-auto w-full max-w-[1240px] flex-1 px-4 py-6 md:px-6 md:py-8">{children}</main>
|
||||||
</div>
|
<Footer />
|
||||||
<AssistantDrawer />
|
</div>
|
||||||
|
<Toaster />
|
||||||
|
<AssistantDrawer />
|
||||||
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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() {
|
export default function Footer() {
|
||||||
return (
|
return (
|
||||||
<footer className="border-t bg-muted/30">
|
<footer className="border-t border-border/80 bg-card/95">
|
||||||
<div className="mx-auto flex w-full max-w-[1300px] items-center justify-between px-4 py-6 text-sm text-muted-foreground">
|
<div className="mx-auto w-full max-w-[1300px] px-4 py-12 md:px-6">
|
||||||
<span className="font-medium">ACVE Centro de Estudios</span>
|
<div className="grid gap-10 md:grid-cols-2 xl:grid-cols-[1.4fr_1fr_1fr_1fr]">
|
||||||
<span>Professional legal English learning</span>
|
<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>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ASSISTANT_TOGGLE_EVENT } from "@/components/AssistantDrawer";
|
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 { 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 { supabaseBrowser } from "@/lib/supabase/browser";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -18,10 +18,13 @@ type NavLink = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const navLinks: NavLink[] = [
|
const navLinks: NavLink[] = [
|
||||||
{ href: "/", label: "Home" },
|
{ href: "/courses", label: "Formación Académica" },
|
||||||
{ href: "/courses", label: "Courses" },
|
{ href: "/case-studies", label: "Casos prácticos" },
|
||||||
{ href: "/case-studies", label: "Case Studies" },
|
{ href: "/practice", label: "Retos" },
|
||||||
{ href: "/practice", label: "Practice" },
|
{ href: "/eventos", label: "Eventos" },
|
||||||
|
{ href: "/noticias", label: "Noticias" },
|
||||||
|
{ href: "/comunidad", label: "Comunidad" },
|
||||||
|
{ href: "/sobre-acve", label: "Sobre ACVE" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
@@ -29,8 +32,8 @@ export default function Navbar() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [userEmail, setUserEmail] = useState<string | null>(null);
|
const [userEmail, setUserEmail] = useState<string | null>(null);
|
||||||
const [isTeacher, setIsTeacher] = useState(false);
|
const [isTeacher, setIsTeacher] = useState(false);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
const teacherEmails = useMemo(() => readTeacherEmailsBrowser(), []);
|
const { resolvedTheme, setTheme } = useTheme();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const client = supabaseBrowser();
|
const client = supabaseBrowser();
|
||||||
@@ -49,140 +52,185 @@ export default function Navbar() {
|
|||||||
const email = cookieMap.get(DEMO_AUTH_EMAIL_COOKIE) ?? null;
|
const email = cookieMap.get(DEMO_AUTH_EMAIL_COOKIE) ?? null;
|
||||||
const role = cookieMap.get(DEMO_AUTH_ROLE_COOKIE) ?? "";
|
const role = cookieMap.get(DEMO_AUTH_ROLE_COOKIE) ?? "";
|
||||||
setUserEmail(email);
|
setUserEmail(email);
|
||||||
setIsTeacher(role === "teacher" || isTeacherEmailAllowed(email, teacherEmails));
|
setIsTeacher(role === "teacher");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
|
||||||
client.auth.getUser().then(({ data }) => {
|
const fetchSession = async () => {
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await client.auth.getUser();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
const email = data.user?.email ?? null;
|
const email = user?.email ?? null;
|
||||||
setUserEmail(email);
|
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) => {
|
fetchSession();
|
||||||
const email = session?.user?.email ?? null;
|
|
||||||
setUserEmail(email);
|
const {
|
||||||
setIsTeacher(isTeacherEmailAllowed(email, teacherEmails));
|
data: { subscription },
|
||||||
|
} = client.auth.onAuthStateChange(() => {
|
||||||
|
fetchSession();
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
mounted = false;
|
mounted = false;
|
||||||
data.subscription.unsubscribe();
|
subscription.unsubscribe();
|
||||||
};
|
};
|
||||||
}, [teacherEmails]);
|
}, []);
|
||||||
|
|
||||||
const links = useMemo(() => {
|
useEffect(() => {
|
||||||
if (!isTeacher) return navLinks;
|
setMounted(true);
|
||||||
return [...navLinks, { href: "/teacher", label: "Teacher Dashboard" }];
|
}, []);
|
||||||
}, [isTeacher]);
|
|
||||||
|
|
||||||
const authNode = useMemo(() => {
|
const handleLogout = async () => {
|
||||||
if (!userEmail) {
|
const loginSwitchUrl = "/auth/login?switchUser=1&redirectTo=/courses";
|
||||||
return (
|
document.cookie = `${DEMO_AUTH_EMAIL_COOKIE}=; path=/; max-age=0`;
|
||||||
<div className="flex items-center gap-2">
|
document.cookie = `${DEMO_AUTH_ROLE_COOKIE}=; path=/; max-age=0`;
|
||||||
<Button variant="outline" size="sm" asChild>
|
|
||||||
<Link href="/auth/login">Login</Link>
|
const client = supabaseBrowser();
|
||||||
</Button>
|
if (!client) {
|
||||||
<Button size="sm" asChild>
|
setUserEmail(null);
|
||||||
<Link href="/auth/signup">Sign up</Link>
|
setIsTeacher(false);
|
||||||
</Button>
|
router.replace(loginSwitchUrl);
|
||||||
</div>
|
router.refresh();
|
||||||
);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
await client.auth.signOut();
|
||||||
<div className="flex items-center gap-2 text-sm">
|
setUserEmail(null);
|
||||||
<span className="hidden max-w-36 truncate text-muted-foreground sm:inline-block">{userEmail}</span>
|
setIsTeacher(false);
|
||||||
{!isTeacher ? (
|
router.replace(loginSwitchUrl);
|
||||||
<Link
|
router.refresh();
|
||||||
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"
|
|
||||||
>
|
const isDark = resolvedTheme === "dark";
|
||||||
Teacher area (Teacher only)
|
const loginHref = userEmail ? "/profile" : "/auth/login";
|
||||||
</Link>
|
const loginLabel = userEmail ? "Mi cuenta" : "Ingresa";
|
||||||
) : null}
|
|
||||||
<Button
|
const isNavActive = (href: string) => {
|
||||||
variant="outline"
|
return pathname === href || pathname?.startsWith(`${href}/`);
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-40 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<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="mx-auto flex w-full max-w-[1300px] items-center justify-between gap-4 px-4 py-3">
|
<div className="border-b border-border/60 bg-card/80">
|
||||||
<div className="flex items-center gap-8">
|
<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">
|
||||||
<Link className="flex items-center gap-3" href="/">
|
<div className="hidden items-center gap-4 sm:flex">
|
||||||
<div className="rounded-xl bg-accent p-1.5 shadow-sm">
|
<Link className="hover:text-foreground" href="/auth/signup">
|
||||||
<Image alt="ACVE logo" className="h-10 w-10 rounded-lg object-cover" height={40} src="/images/logo.png" width={40} />
|
Únete
|
||||||
</div>
|
</Link>
|
||||||
<div>
|
<Link className="hover:text-foreground" href={loginHref}>
|
||||||
<div className="text-2xl font-bold leading-none tracking-tight text-primary md:text-4xl">ACVE</div>
|
{loginLabel}
|
||||||
<div className="-mt-1 text-xs text-muted-foreground md:text-sm">Centro de Estudios</div>
|
</Link>
|
||||||
</div>
|
<Link className="hover:text-foreground" href="/#footer-contact">
|
||||||
</Link>
|
Contáctanos
|
||||||
<nav className="hidden items-center gap-1 text-sm lg:flex">
|
</Link>
|
||||||
{links.map((link) => {
|
</div>
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
aria-label={isDark ? "Cambiar a modo claro" : "Cambiar a modo oscuro"}
|
||||||
className="border-primary/20 text-primary hover:bg-primary/5 hover:text-primary"
|
className="rounded-full"
|
||||||
onClick={() => window.dispatchEvent(new Event(ASSISTANT_TOGGLE_EVENT))}
|
size="icon"
|
||||||
>
|
type="button"
|
||||||
AI Assistant
|
variant="ghost"
|
||||||
</Button>
|
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||||
{authNode}
|
>
|
||||||
|
{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>
|
||||||
</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) => {
|
<div className="mx-auto w-full max-w-[1300px] px-4 md:px-6">
|
||||||
const isActive = pathname === link.href;
|
<div className="flex items-center justify-between gap-4 py-4">
|
||||||
return (
|
<Link className="flex items-center gap-3" href="/">
|
||||||
<Button
|
<div className="rounded-xl bg-primary/10 p-1.5 shadow-sm ring-1 ring-primary/20">
|
||||||
key={link.href}
|
<Image alt="ACVE logo" className="h-10 w-10 rounded-lg object-cover" height={40} src="/images/logo.png" width={40} />
|
||||||
variant={isActive ? "default" : "ghost"}
|
</div>
|
||||||
size="sm"
|
<div className="leading-tight">
|
||||||
asChild
|
<div className="text-2xl font-bold tracking-tight text-primary md:text-3xl">ACVE Centro de Estudios</div>
|
||||||
className="whitespace-nowrap rounded-xl"
|
<div className="text-xs text-muted-foreground md:text-sm">Empowering Lawyer one word at a time</div>
|
||||||
>
|
</div>
|
||||||
<Link href={link.href}>{link.label}</Link>
|
</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>
|
</Button>
|
||||||
);
|
</div>
|
||||||
})}
|
</div>
|
||||||
</nav>
|
|
||||||
|
<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>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createBrowserClient } from "@supabase/ssr";
|
import { createBrowserClient } from "@supabase/ssr";
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
@@ -9,17 +9,24 @@ type LoginFormProps = {
|
|||||||
redirectTo: string;
|
redirectTo: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
showForgot?: boolean;
|
showForgot?: boolean;
|
||||||
|
skipAuthedRedirect?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper to prevent open redirect vulnerabilities
|
// Helper to prevent open redirect vulnerabilities
|
||||||
const normalizeRedirect = (redirectTo: string) => {
|
const normalizeRedirect = (redirectTo: string) => {
|
||||||
if (redirectTo.startsWith("/") && !redirectTo.startsWith("//")) {
|
if (!redirectTo.startsWith("/") || redirectTo.startsWith("//")) {
|
||||||
return redirectTo;
|
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 router = useRouter();
|
||||||
const safeRedirect = normalizeRedirect(redirectTo);
|
const safeRedirect = normalizeRedirect(redirectTo);
|
||||||
const isTeacher = role === "teacher";
|
const isTeacher = role === "teacher";
|
||||||
@@ -30,6 +37,23 @@ export default function LoginForm({ redirectTo, role, showForgot }: LoginFormPro
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
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
|
// Construct the "Forgot Password" link to preserve context
|
||||||
const forgotHref = `/auth/login?redirectTo=${encodeURIComponent(safeRedirect)}${isTeacher ? "&role=teacher" : ""
|
const forgotHref = `/auth/login?redirectTo=${encodeURIComponent(safeRedirect)}${isTeacher ? "&role=teacher" : ""
|
||||||
}&forgot=1`;
|
}&forgot=1`;
|
||||||
@@ -62,8 +86,9 @@ export default function LoginForm({ redirectTo, role, showForgot }: LoginFormPro
|
|||||||
// so they see the new cookie immediately.
|
// so they see the new cookie immediately.
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
|
||||||
// 4. Navigate to the protected page
|
// 4. Navigate to the protected page and release button state.
|
||||||
router.push(safeRedirect);
|
setLoading(false);
|
||||||
|
router.replace(safeRedirect);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -149,4 +174,4 @@ export default function LoginForm({ redirectTo, role, showForgot }: LoginFormPro
|
|||||||
</div>
|
</div>
|
||||||
</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 { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Check, Lock, PlayCircle } from "lucide-react";
|
import { Check, CircleDashed, ClipboardCheck, FileText, Lock, PlayCircle } from "lucide-react";
|
||||||
import { toggleLessonComplete } from "@/app/(protected)/courses/[slug]/learn/actions";
|
import { toggleLessonComplete } from "@/app/(public)/courses/[slug]/learn/actions";
|
||||||
import ProgressBar from "@/components/ProgressBar";
|
import ProgressBar from "@/components/ProgressBar";
|
||||||
|
import { getLessonContentTypeLabel, isFinalExam, type LessonContentType } from "@/lib/courses/lessonContent";
|
||||||
|
|
||||||
type ClassroomLesson = {
|
type ClassroomLesson = {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
contentType: LessonContentType;
|
||||||
|
materialUrl: string | null;
|
||||||
videoUrl: string | null;
|
videoUrl: string | null;
|
||||||
|
youtubeUrl: string | null;
|
||||||
estimatedDuration: number;
|
estimatedDuration: number;
|
||||||
|
isFreePreview: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ClassroomModule = {
|
type ClassroomModule = {
|
||||||
@@ -27,19 +32,44 @@ type StudentClassroomClientProps = {
|
|||||||
modules: ClassroomModule[];
|
modules: ClassroomModule[];
|
||||||
initialSelectedLessonId: string;
|
initialSelectedLessonId: string;
|
||||||
initialCompletedLessonIds: 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({
|
export default function StudentClassroomClient({
|
||||||
courseSlug,
|
courseSlug,
|
||||||
courseTitle,
|
courseTitle,
|
||||||
modules,
|
modules,
|
||||||
initialSelectedLessonId,
|
initialSelectedLessonId,
|
||||||
initialCompletedLessonIds,
|
initialCompletedLessonIds,
|
||||||
|
isEnrolled,
|
||||||
}: StudentClassroomClientProps) {
|
}: StudentClassroomClientProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isSaving, startTransition] = useTransition();
|
const [isSaving, startTransition] = useTransition();
|
||||||
const [selectedLessonId, setSelectedLessonId] = useState(initialSelectedLessonId);
|
const [selectedLessonId, setSelectedLessonId] = useState(initialSelectedLessonId);
|
||||||
const [completedLessonIds, setCompletedLessonIds] = useState(initialCompletedLessonIds);
|
const [completedLessonIds, setCompletedLessonIds] = useState(initialCompletedLessonIds);
|
||||||
|
const [completionCertificate, setCompletionCertificate] = useState<CompletionCertificate | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedLessonId(initialSelectedLessonId);
|
setSelectedLessonId(initialSelectedLessonId);
|
||||||
@@ -59,7 +89,10 @@ export default function StudentClassroomClient({
|
|||||||
const selectedLesson =
|
const selectedLesson =
|
||||||
flatLessons.find((lesson) => lesson.id === selectedLessonId) ?? flatLessons[0] ?? null;
|
flatLessons.find((lesson) => lesson.id === selectedLessonId) ?? flatLessons[0] ?? null;
|
||||||
|
|
||||||
|
const selectedLessonTypeLabel = selectedLesson ? getLessonContentTypeLabel(selectedLesson.contentType) : "";
|
||||||
|
|
||||||
const isRestricted = (lessonId: string) => {
|
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);
|
const lessonIndex = flatLessons.findIndex((lesson) => lesson.id === lessonId);
|
||||||
if (lessonIndex <= 0) return false;
|
if (lessonIndex <= 0) return false;
|
||||||
if (completedSet.has(lessonId)) return false;
|
if (completedSet.has(lessonId)) return false;
|
||||||
@@ -67,6 +100,8 @@ export default function StudentClassroomClient({
|
|||||||
return !completedSet.has(previousLesson.id);
|
return !completedSet.has(previousLesson.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isLockedForUser = (lesson: ClassroomLesson) => !isEnrolled && !lesson.isFreePreview;
|
||||||
|
|
||||||
const navigateToLesson = (lessonId: string) => {
|
const navigateToLesson = (lessonId: string) => {
|
||||||
if (isRestricted(lessonId)) return;
|
if (isRestricted(lessonId)) return;
|
||||||
setSelectedLessonId(lessonId);
|
setSelectedLessonId(lessonId);
|
||||||
@@ -100,6 +135,13 @@ export default function StudentClassroomClient({
|
|||||||
return prev.filter((id) => id !== lessonId);
|
return prev.filter((id) => id !== lessonId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (result.newlyIssuedCertificate && result.certificateId) {
|
||||||
|
setCompletionCertificate({
|
||||||
|
certificateId: result.certificateId,
|
||||||
|
certificateNumber: result.certificateNumber ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
router.refresh();
|
router.refresh();
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -114,30 +156,155 @@ export default function StudentClassroomClient({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<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">
|
<Link href={`/courses/${courseSlug}`} className="text-sm font-medium text-slate-600 hover:text-slate-900">
|
||||||
{"<-"} Back to Course
|
{"<-"} Back to Course
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="aspect-video overflow-hidden rounded-xl border border-slate-200 bg-black">
|
<div
|
||||||
{selectedLesson.videoUrl ? (
|
className={`overflow-hidden rounded-xl border border-slate-200 ${
|
||||||
<video
|
selectedLesson.contentType === "VIDEO" ? "aspect-video bg-black" : "bg-slate-50 p-5"
|
||||||
key={`${selectedLesson.id}-${selectedLesson.videoUrl}`}
|
}`}
|
||||||
className="h-full w-full"
|
>
|
||||||
controls
|
{isLockedForUser(selectedLesson) ? (
|
||||||
onEnded={handleToggleComplete}
|
<div className="flex h-full min-h-[220px] flex-col items-center justify-center gap-4 bg-slate-900 p-6 text-center">
|
||||||
src={selectedLesson.videoUrl}
|
<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">
|
<div className="space-y-4">
|
||||||
Video not available for this lesson
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2 rounded-xl border border-slate-200 bg-white p-4">
|
<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>
|
<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 ? (
|
{selectedLesson.description ? (
|
||||||
<p className="text-sm text-slate-600">{selectedLesson.description}</p>
|
<p className="text-sm text-slate-600">{selectedLesson.description}</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -146,24 +313,27 @@ export default function StudentClassroomClient({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{isEnrolled && (
|
||||||
type="button"
|
<button
|
||||||
onClick={handleToggleComplete}
|
type="button"
|
||||||
disabled={isSaving}
|
onClick={handleToggleComplete}
|
||||||
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"
|
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>
|
{completedSet.has(selectedLesson.id)
|
||||||
|
? "Marcar como pendiente"
|
||||||
|
: isFinalExam(selectedLesson.contentType)
|
||||||
|
? "Marcar evaluación final como completada"
|
||||||
|
: "Marcar como completada"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside className="rounded-xl border border-slate-200 bg-white p-4">
|
<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>
|
<p className="mb-3 text-xs text-slate-500">{courseTitle}</p>
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<ProgressBar
|
<ProgressBar value={progressPercent} label={`${completedCount}/${totalLessons} lecciones (${progressPercent}%)`} />
|
||||||
value={progressPercent}
|
|
||||||
label={`${completedCount}/${totalLessons} lessons (${progressPercent}%)`}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[70vh] space-y-4 overflow-y-auto pr-1">
|
<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 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">
|
<div className="border-b border-slate-200 px-3 py-2">
|
||||||
<p className="text-sm font-semibold text-slate-800">
|
<p className="text-sm font-semibold text-slate-800">
|
||||||
Module {moduleIndex + 1}: {module.title}
|
Módulo {moduleIndex + 1}: {module.title}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -179,6 +349,7 @@ export default function StudentClassroomClient({
|
|||||||
{module.lessons.map((lesson, lessonIndex) => {
|
{module.lessons.map((lesson, lessonIndex) => {
|
||||||
const completed = completedSet.has(lesson.id);
|
const completed = completedSet.has(lesson.id);
|
||||||
const restricted = isRestricted(lesson.id);
|
const restricted = isRestricted(lesson.id);
|
||||||
|
const locked = isLockedForUser(lesson);
|
||||||
const active = lesson.id === selectedLesson.id;
|
const active = lesson.id === selectedLesson.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -193,17 +364,36 @@ export default function StudentClassroomClient({
|
|||||||
: "border-transparent bg-white/70 text-slate-700 hover:border-slate-200"
|
: "border-transparent bg-white/70 text-slate-700 hover:border-slate-200"
|
||||||
} ${restricted ? "cursor-not-allowed opacity-60 hover:border-transparent" : ""}`}
|
} ${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 ? (
|
{completed ? (
|
||||||
<Check className="h-4 w-4 text-emerald-600" />
|
<Check className="h-4 w-4 text-emerald-600" />
|
||||||
) : restricted ? (
|
) : restricted || locked ? (
|
||||||
<Lock className="h-4 w-4 text-slate-400" />
|
<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" />
|
<PlayCircle className="h-4 w-4 text-slate-500" />
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="line-clamp-1">
|
<span className="min-w-0 flex-1 line-clamp-1">
|
||||||
{lessonIndex + 1}. {lesson.title}
|
{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>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,14 +7,22 @@ import {
|
|||||||
createLesson,
|
createLesson,
|
||||||
reorderModules,
|
reorderModules,
|
||||||
reorderLessons,
|
reorderLessons,
|
||||||
|
updateModuleTitle,
|
||||||
} from "@/app/(protected)/teacher/actions";
|
} from "@/app/(protected)/teacher/actions";
|
||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { getClientLocale } from "@/lib/i18n/clientLocale";
|
||||||
|
|
||||||
import { Prisma } from "@prisma/client";
|
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 = {
|
type CourseData = {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -23,6 +31,7 @@ type CourseData = {
|
|||||||
level: string;
|
level: string;
|
||||||
status: string;
|
status: string;
|
||||||
price: number;
|
price: number;
|
||||||
|
learningOutcomes?: Prisma.JsonValue | null;
|
||||||
modules: {
|
modules: {
|
||||||
id: string;
|
id: string;
|
||||||
title: Prisma.JsonValue;
|
title: Prisma.JsonValue;
|
||||||
@@ -34,11 +43,38 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [optimisticModules, setOptimisticModules] = useState(course.modules);
|
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(() => {
|
useEffect(() => {
|
||||||
setOptimisticModules(course.modules);
|
setOptimisticModules(course.modules);
|
||||||
}, [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
|
// Helper for JSON/String fields
|
||||||
const getStr = (val: Prisma.JsonValue) => {
|
const getStr = (val: Prisma.JsonValue) => {
|
||||||
if (typeof val === "string") return val;
|
if (typeof val === "string") return val;
|
||||||
@@ -52,12 +88,16 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 1. SAVE COURSE SETTINGS
|
// 1. SAVE COURSE SETTINGS
|
||||||
async function handleSubmit(formData: FormData) {
|
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
setLoading(true);
|
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);
|
const res = await updateCourse(course.id, course.slug, formData);
|
||||||
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
toast.success("Curso actualizado");
|
showSavedToast();
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
toast.error("Error al guardar");
|
toast.error("Error al guardar");
|
||||||
@@ -70,7 +110,21 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
|
|||||||
setLoading(true); // Block UI while working
|
setLoading(true); // Block UI while working
|
||||||
const res = await createModule(course.id);
|
const res = await createModule(course.id);
|
||||||
if (res.success) {
|
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();
|
router.refresh();
|
||||||
} else {
|
} else {
|
||||||
toast.error("Error al crear módulo");
|
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)
|
// 4. REORDER MODULES (optimistic)
|
||||||
const handleReorderModule = async (moduleIndex: number, direction: "up" | "down") => {
|
const handleReorderModule = async (moduleIndex: number, direction: "up" | "down") => {
|
||||||
const swapWith = direction === "up" ? moduleIndex - 1 : moduleIndex + 1;
|
const swapWith = direction === "up" ? moduleIndex - 1 : moduleIndex + 1;
|
||||||
@@ -167,7 +248,7 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
|
|||||||
|
|
||||||
{/* LEFT COLUMN: Main Info */}
|
{/* LEFT COLUMN: Main Info */}
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<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 */}
|
{/* Title */}
|
||||||
<div>
|
<div>
|
||||||
@@ -190,6 +271,48 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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 className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">Nivel</label>
|
<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 */}
|
{/* Module Header */}
|
||||||
<div className="bg-slate-50 px-4 py-2 border-b border-slate-200 flex justify-between items-center">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-sm text-slate-800">{getStr(module.title)}</span>
|
{editingModuleId === module.id ? (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||||
<button
|
<input
|
||||||
type="button"
|
type="text"
|
||||||
onClick={() => handleReorderModule(moduleIndex, "up")}
|
value={editingTitle}
|
||||||
disabled={moduleIndex === 0}
|
onChange={(e) => setEditingTitle(e.target.value)}
|
||||||
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"
|
onKeyDown={(e) => {
|
||||||
aria-label="Mover módulo arriba"
|
if (e.key === "Enter") saveModuleTitle();
|
||||||
>
|
if (e.key === "Escape") cancelEditingModuleTitle();
|
||||||
<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" />
|
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"
|
||||||
</svg>
|
autoFocus
|
||||||
</button>
|
disabled={loading}
|
||||||
<button
|
/>
|
||||||
type="button"
|
<button
|
||||||
onClick={() => handleReorderModule(moduleIndex, "down")}
|
type="button"
|
||||||
disabled={moduleIndex === optimisticModules.length - 1}
|
onClick={saveModuleTitle}
|
||||||
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"
|
disabled={loading || !editingTitle.trim()}
|
||||||
aria-label="Mover módulo abajo"
|
className="rounded-md bg-black px-2 py-1 text-xs font-medium text-white hover:bg-slate-800 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
Guardar
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
</button>
|
||||||
</svg>
|
<button
|
||||||
</button>
|
type="button"
|
||||||
</div>
|
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>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Lessons List */}
|
{/* Lessons List */}
|
||||||
@@ -370,4 +536,4 @@ export default function TeacherEditCourseForm({ course }: { course: CourseData }
|
|||||||
</div>
|
</div>
|
||||||
</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>;
|
||||||
|
}
|
||||||
@@ -1,53 +1,44 @@
|
|||||||
import { createServerClient, type CookieOptions } from "@supabase/ssr";
|
|
||||||
import { cookies } from "next/headers";
|
|
||||||
import { db } from "@/lib/prisma";
|
import { db } from "@/lib/prisma";
|
||||||
import { UserRole } from "@prisma/client";
|
import { UserRole } from "@prisma/client";
|
||||||
import { logger } from "@/lib/logger";
|
import { logger } from "@/lib/logger";
|
||||||
|
import { supabaseServer } from "@/lib/supabase/server";
|
||||||
|
|
||||||
export async function requireTeacher() {
|
export async function requireTeacher() {
|
||||||
|
const supabase = await supabaseServer();
|
||||||
|
if (!supabase) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const cookieStore = await cookies();
|
const {
|
||||||
|
data: { user },
|
||||||
// 1. Get Supabase Session
|
} = await supabase.auth.getUser();
|
||||||
const supabase = createServerClient(
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
|
||||||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
|
||||||
{
|
|
||||||
cookies: {
|
|
||||||
getAll() { return cookieStore.getAll() },
|
|
||||||
setAll(cookiesToSet: { name: string; value: string; options?: CookieOptions }[]) {
|
|
||||||
try {
|
|
||||||
cookiesToSet.forEach(({ name, value, options }) =>
|
|
||||||
cookieStore.set(name, value, options)
|
|
||||||
)
|
|
||||||
} catch (error) {
|
|
||||||
// This is expected in Server Components, but let's log it just in case
|
|
||||||
logger.warn("Failed to set cookies in Server Component context (expected behavior)", error);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return null; // Let the caller handle the redirect
|
return null; // Let the caller handle the redirect
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check Role in Database
|
|
||||||
const profile = await db.profile.findUnique({
|
const profile = await db.profile.findUnique({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
}
|
}).catch((error) => {
|
||||||
);
|
logger.error("Failed to load profile in requireTeacher", {
|
||||||
console.log("AUTH_USER_ID:", user.id);
|
userId: user.id,
|
||||||
console.log("DB_PROFILE:", profile);
|
email: user.email,
|
||||||
|
error: error instanceof Error ? error.message : "unknown",
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (
|
||||||
if (!profile || (profile.role !== UserRole.TEACHER && profile.role !== UserRole.SUPER_ADMIN)) {
|
!profile ||
|
||||||
// You can decide to return null or throw an error here
|
(profile.role !== UserRole.TEACHER && profile.role !== UserRole.SUPER_ADMIN)
|
||||||
|
) {
|
||||||
|
logger.info("User authenticated but not authorized as teacher", {
|
||||||
|
userId: user.id,
|
||||||
|
email: user.email,
|
||||||
|
role: profile?.role ?? "none",
|
||||||
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return profile;
|
return profile;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createServerClient } from "@supabase/ssr";
|
import { createServerClient } from "@supabase/ssr";
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
import { db } from "@/lib/prisma";
|
import { db } from "@/lib/prisma";
|
||||||
|
import { UserRole } from "@prisma/client";
|
||||||
|
|
||||||
export async function requireUser() {
|
export async function requireUser() {
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
@@ -13,6 +14,20 @@ export async function requireUser() {
|
|||||||
const { data: { user } } = await supabase.auth.getUser();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
|
|
||||||
const profile = await db.profile.findUnique({ where: { id: user.id } });
|
const profile = await db.profile.findUnique({ where: { id: user.id } }).catch((error) => {
|
||||||
return profile;
|
console.error("Failed to load profile for authenticated user.", error);
|
||||||
}
|
return null;
|
||||||
|
});
|
||||||
|
if (profile) return profile;
|
||||||
|
|
||||||
|
// Keep authenticated flows working even if profile lookup fails.
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
email: user.email ?? "unknown@acve.local",
|
||||||
|
fullName: (user.user_metadata?.full_name as string | undefined) ?? null,
|
||||||
|
avatarUrl: null,
|
||||||
|
role: UserRole.LEARNER,
|
||||||
|
createdAt: new Date(0),
|
||||||
|
updatedAt: new Date(0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
const parseTeacherEmails = (source: string | undefined): string[] =>
|
|
||||||
(source ?? "")
|
|
||||||
.split(",")
|
|
||||||
.map((email) => email.trim().toLowerCase())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
export const readTeacherEmailsServer = (): string[] => parseTeacherEmails(process.env.TEACHER_EMAILS);
|
|
||||||
|
|
||||||
export const readTeacherEmailsBrowser = (): string[] =>
|
|
||||||
parseTeacherEmails(process.env.NEXT_PUBLIC_TEACHER_EMAILS);
|
|
||||||
|
|
||||||
export const isTeacherEmailAllowed = (email: string | null, allowed: string[]): boolean => {
|
|
||||||
if (!email) return false;
|
|
||||||
if (allowed.length === 0) return false;
|
|
||||||
return allowed.includes(email.toLowerCase());
|
|
||||||
};
|
|
||||||
197
lib/certificates.ts
Normal file
197
lib/certificates.ts
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { db } from "@/lib/prisma";
|
||||||
|
import { isFinalExam, parseLessonDescriptionMeta } from "@/lib/courses/lessonContent";
|
||||||
|
|
||||||
|
type CertificatePrismaClient = {
|
||||||
|
certificate: {
|
||||||
|
create: (args: object) => Promise<{ id: string; certificateNumber?: string }>;
|
||||||
|
findFirst: (args: object) => Promise<{ id: string; certificateNumber?: string } | null>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CertificateIssueResult = {
|
||||||
|
certificateId: string | null;
|
||||||
|
certificateNumber: string | null;
|
||||||
|
newlyIssued: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getText(value: unknown): string {
|
||||||
|
if (!value) return "";
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "object") {
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (typeof record.en === "string") return record.en;
|
||||||
|
if (typeof record.es === "string") return record.es;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapePdfText(value: string): string {
|
||||||
|
return value.replace(/\\/g, "\\\\").replace(/\(/g, "\\(").replace(/\)/g, "\\)");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMinimalPdf(lines: string[]): Uint8Array {
|
||||||
|
const contentLines = lines
|
||||||
|
.map((line, index) => `BT /F1 14 Tf 72 ${730 - index * 24} Td (${escapePdfText(line)}) Tj ET`)
|
||||||
|
.join("\n");
|
||||||
|
const stream = `${contentLines}\n`;
|
||||||
|
|
||||||
|
const objects = [
|
||||||
|
"1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj",
|
||||||
|
"2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj",
|
||||||
|
"3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >> endobj",
|
||||||
|
"4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj",
|
||||||
|
`5 0 obj << /Length ${stream.length} >> stream\n${stream}endstream endobj`,
|
||||||
|
];
|
||||||
|
|
||||||
|
let pdf = "%PDF-1.4\n";
|
||||||
|
const offsets: number[] = [0];
|
||||||
|
|
||||||
|
for (const object of objects) {
|
||||||
|
offsets.push(pdf.length);
|
||||||
|
pdf += `${object}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const xrefStart = pdf.length;
|
||||||
|
pdf += `xref\n0 ${objects.length + 1}\n`;
|
||||||
|
pdf += "0000000000 65535 f \n";
|
||||||
|
for (let i = 1; i <= objects.length; i += 1) {
|
||||||
|
pdf += `${offsets[i].toString().padStart(10, "0")} 00000 n \n`;
|
||||||
|
}
|
||||||
|
pdf += `trailer << /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xrefStart}\n%%EOF`;
|
||||||
|
|
||||||
|
return new TextEncoder().encode(pdf);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function issueCertificateIfEligible(userId: string, courseId: string): Promise<CertificateIssueResult> {
|
||||||
|
const prismaAny = db as unknown as CertificatePrismaClient;
|
||||||
|
try {
|
||||||
|
const existing = await prismaAny.certificate.findFirst({
|
||||||
|
where: { userId, courseId },
|
||||||
|
select: { id: true, certificateNumber: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
return {
|
||||||
|
certificateId: existing.id,
|
||||||
|
certificateNumber: existing.certificateNumber ?? null,
|
||||||
|
newlyIssued: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [course, completedCount, lessons] = await Promise.all([
|
||||||
|
db.course.findUnique({
|
||||||
|
where: { id: courseId },
|
||||||
|
select: { id: true, title: true, slug: true },
|
||||||
|
}),
|
||||||
|
db.userProgress.count({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
isCompleted: true,
|
||||||
|
lesson: {
|
||||||
|
module: {
|
||||||
|
courseId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.lesson.findMany({
|
||||||
|
where: {
|
||||||
|
module: {
|
||||||
|
courseId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
description: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const totalCount = lessons.length;
|
||||||
|
if (!course || totalCount === 0 || completedCount < totalCount) {
|
||||||
|
return { certificateId: null, certificateNumber: null, newlyIssued: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalExamLessonIds = lessons
|
||||||
|
.filter((lesson) => isFinalExam(parseLessonDescriptionMeta(lesson.description).contentType))
|
||||||
|
.map((lesson) => lesson.id);
|
||||||
|
|
||||||
|
if (finalExamLessonIds.length > 0) {
|
||||||
|
const completedFinalExams = await db.userProgress.count({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
isCompleted: true,
|
||||||
|
lessonId: {
|
||||||
|
in: finalExamLessonIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (completedFinalExams < finalExamLessonIds.length) {
|
||||||
|
return { certificateId: null, certificateNumber: null, newlyIssued: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const membership = await db.membership.findFirst({
|
||||||
|
where: { userId, isActive: true },
|
||||||
|
select: { companyId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const certificateNumber = `ACVE-${new Date().getFullYear()}-${Math.floor(
|
||||||
|
100000 + Math.random() * 900000,
|
||||||
|
)}`;
|
||||||
|
|
||||||
|
const certificate = await prismaAny.certificate.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
courseId,
|
||||||
|
companyId: membership?.companyId ?? null,
|
||||||
|
certificateNumber,
|
||||||
|
pdfVersion: 1,
|
||||||
|
metadataSnapshot: {
|
||||||
|
courseId: course.id,
|
||||||
|
courseSlug: course.slug,
|
||||||
|
courseTitle: getText(course.title) || "Untitled course",
|
||||||
|
certificateNumber,
|
||||||
|
completionPercent: 100,
|
||||||
|
issuedAt: new Date().toISOString(),
|
||||||
|
brandingVersion: "ACVE-2026-01",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
certificateId: certificate.id,
|
||||||
|
certificateNumber: certificate.certificateNumber ?? certificateNumber,
|
||||||
|
newlyIssued: true,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { certificateId: null, certificateNumber: null, newlyIssued: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCertificatePdf(input: {
|
||||||
|
certificateNumber: string;
|
||||||
|
learnerName: string;
|
||||||
|
learnerEmail: string;
|
||||||
|
courseTitle: string;
|
||||||
|
issuedAt: Date;
|
||||||
|
}): Uint8Array {
|
||||||
|
const date = new Intl.DateTimeFormat("en-US", { dateStyle: "long" }).format(input.issuedAt);
|
||||||
|
|
||||||
|
return buildMinimalPdf([
|
||||||
|
"ACVE - Certificate of Completion",
|
||||||
|
"",
|
||||||
|
`Certificate No: ${input.certificateNumber}`,
|
||||||
|
"",
|
||||||
|
"This certifies that",
|
||||||
|
input.learnerName,
|
||||||
|
`(${input.learnerEmail})`,
|
||||||
|
"",
|
||||||
|
"has successfully completed the course",
|
||||||
|
input.courseTitle,
|
||||||
|
"",
|
||||||
|
`Issued on ${date}`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
108
lib/courses/lessonContent.ts
Normal file
108
lib/courses/lessonContent.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
export const lessonContentTypes = ["VIDEO", "LECTURE", "ACTIVITY", "QUIZ", "FINAL_EXAM"] as const;
|
||||||
|
|
||||||
|
export type LessonContentType = (typeof lessonContentTypes)[number];
|
||||||
|
|
||||||
|
type LessonDescriptionMeta = {
|
||||||
|
text: string;
|
||||||
|
contentType: LessonContentType;
|
||||||
|
materialUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const lessonTypeAliases: Record<string, LessonContentType> = {
|
||||||
|
VIDEO: "VIDEO",
|
||||||
|
LECTURE: "LECTURE",
|
||||||
|
LECTURA: "LECTURE",
|
||||||
|
READING: "LECTURE",
|
||||||
|
ACTIVITY: "ACTIVITY",
|
||||||
|
ACTIVIDAD: "ACTIVITY",
|
||||||
|
QUIZ: "QUIZ",
|
||||||
|
EVALUACION: "QUIZ",
|
||||||
|
EVALUACIÓN: "QUIZ",
|
||||||
|
FINAL_EXAM: "FINAL_EXAM",
|
||||||
|
EXAMEN_FINAL: "FINAL_EXAM",
|
||||||
|
EXAMENFINAL: "FINAL_EXAM",
|
||||||
|
EVALUACION_FINAL: "FINAL_EXAM",
|
||||||
|
EVALUACIÓN_FINAL: "FINAL_EXAM",
|
||||||
|
};
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown): string {
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeType(value: string): LessonContentType {
|
||||||
|
const normalized = value.trim().toUpperCase();
|
||||||
|
return lessonTypeAliases[normalized] ?? "VIDEO";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDescriptionText(input: unknown): string {
|
||||||
|
if (typeof input === "string") return input.trim();
|
||||||
|
if (isRecord(input)) {
|
||||||
|
const direct = asString(input.text);
|
||||||
|
if (direct) return direct;
|
||||||
|
const es = asString(input.es);
|
||||||
|
if (es) return es;
|
||||||
|
const en = asString(input.en);
|
||||||
|
if (en) return en;
|
||||||
|
const summary = asString(input.summary);
|
||||||
|
if (summary) return summary;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLessonDescriptionMeta(description: unknown): LessonDescriptionMeta {
|
||||||
|
if (!isRecord(description)) {
|
||||||
|
return {
|
||||||
|
text: getDescriptionText(description),
|
||||||
|
contentType: "VIDEO",
|
||||||
|
materialUrl: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentTypeRaw = asString(description.contentType) || asString(description.kind) || asString(description.type);
|
||||||
|
const materialUrl =
|
||||||
|
asString(description.materialUrl) ||
|
||||||
|
asString(description.resourceUrl) ||
|
||||||
|
asString(description.pdfUrl) ||
|
||||||
|
asString(description.attachmentUrl) ||
|
||||||
|
"";
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: getDescriptionText(description),
|
||||||
|
contentType: normalizeType(contentTypeRaw || "VIDEO"),
|
||||||
|
materialUrl: materialUrl || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLessonDescriptionMeta(input: {
|
||||||
|
text: string;
|
||||||
|
contentType: LessonContentType;
|
||||||
|
materialUrl?: string | null;
|
||||||
|
}): Record<string, string> {
|
||||||
|
const payload: Record<string, string> = {
|
||||||
|
contentType: input.contentType,
|
||||||
|
};
|
||||||
|
|
||||||
|
const text = input.text.trim();
|
||||||
|
if (text) payload.es = text;
|
||||||
|
|
||||||
|
const materialUrl = (input.materialUrl ?? "").trim();
|
||||||
|
if (materialUrl) payload.materialUrl = materialUrl;
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLessonContentTypeLabel(contentType: LessonContentType): string {
|
||||||
|
if (contentType === "LECTURE") return "Lectura";
|
||||||
|
if (contentType === "ACTIVITY") return "Actividad";
|
||||||
|
if (contentType === "QUIZ") return "Quiz";
|
||||||
|
if (contentType === "FINAL_EXAM") return "Evaluación final";
|
||||||
|
return "Video";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFinalExam(contentType: LessonContentType): boolean {
|
||||||
|
return contentType === "FINAL_EXAM";
|
||||||
|
}
|
||||||
254
lib/courses/presentation.ts
Normal file
254
lib/courses/presentation.ts
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { ContentStatus, ProficiencyLevel } from "@prisma/client";
|
||||||
|
|
||||||
|
export type AcademicStageId = "base" | "consolidacion" | "especializacion";
|
||||||
|
export type AvailabilityState = "published" | "upcoming" | "draft";
|
||||||
|
|
||||||
|
export type AcademicStageConfig = {
|
||||||
|
id: AcademicStageId;
|
||||||
|
tabLabel: string;
|
||||||
|
sectionTitle: string;
|
||||||
|
sectionDescription: string;
|
||||||
|
levelLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ACADEMIC_STAGE_ORDER: AcademicStageId[] = ["base", "consolidacion", "especializacion"];
|
||||||
|
|
||||||
|
export const ACADEMIC_STAGE_CONFIG: Record<AcademicStageId, AcademicStageConfig> = {
|
||||||
|
base: {
|
||||||
|
id: "base",
|
||||||
|
tabLabel: "Base",
|
||||||
|
sectionTitle: "Programas Base",
|
||||||
|
sectionDescription:
|
||||||
|
"Trayectos introductorios para fortalecer fundamentos del inglés jurídico y lenguaje técnico aplicado.",
|
||||||
|
levelLabel: "Base",
|
||||||
|
},
|
||||||
|
consolidacion: {
|
||||||
|
id: "consolidacion",
|
||||||
|
tabLabel: "Consolidación",
|
||||||
|
sectionTitle: "Programas de Consolidación",
|
||||||
|
sectionDescription:
|
||||||
|
"Programas para consolidar precisión terminológica, comprensión de textos jurídicos y comunicación profesional.",
|
||||||
|
levelLabel: "Consolidación",
|
||||||
|
},
|
||||||
|
especializacion: {
|
||||||
|
id: "especializacion",
|
||||||
|
tabLabel: "Especialización",
|
||||||
|
sectionTitle: "Programas de Especialización",
|
||||||
|
sectionDescription:
|
||||||
|
"Itinerarios avanzados orientados a práctica profesional internacional, redacción especializada y análisis complejo.",
|
||||||
|
levelLabel: "Especialización",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const STAGE_KEYWORDS: Record<AcademicStageId, string[]> = {
|
||||||
|
base: ["base", "fundamentos", "fundamentals", "intro", "introduccion", "beginner", "inicial"],
|
||||||
|
consolidacion: ["consolidacion", "consolidación", "consolidation", "intermedio", "intermediate"],
|
||||||
|
especializacion: ["especializacion", "especialización", "specialization", "avanzado", "advanced", "expert", "experto"],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PUBLIC_UPCOMING_TAGS = ["upcoming", "coming-soon", "proximamente", "próximamente", "public-upcoming"];
|
||||||
|
|
||||||
|
const IMAGE_KEYS = ["coverImageUrl", "coverImage", "thumbnailUrl", "thumbnail", "imageUrl", "image", "cover"];
|
||||||
|
const SHORT_KEYS = ["shortEs", "shortEn", "short", "summary", "excerpt", "resumen"];
|
||||||
|
const LONG_KEYS = ["longEs", "longEn", "long", "description", "descripcion"];
|
||||||
|
|
||||||
|
type UnknownRecord = Record<string, unknown>;
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is UnknownRecord {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown): string {
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fromRecord(record: UnknownRecord, keys: string[]): string {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = asString(record[key]);
|
||||||
|
if (value) return value;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLocalizedText(value: unknown): string {
|
||||||
|
if (!value) return "";
|
||||||
|
if (typeof value === "string") return value.trim();
|
||||||
|
if (isRecord(value)) {
|
||||||
|
const preferred = fromRecord(value, ["es", "en"]);
|
||||||
|
if (preferred) return preferred;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCourseDescriptions(description: unknown): { short: string; long: string } {
|
||||||
|
if (!description) {
|
||||||
|
return {
|
||||||
|
short: "Programa en actualización académica.",
|
||||||
|
long: "Programa en actualización académica.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const localized = getLocalizedText(description);
|
||||||
|
|
||||||
|
if (!isRecord(description)) {
|
||||||
|
const normalized = localized || "Programa en actualización académica.";
|
||||||
|
return { short: truncateText(normalized, 170), long: normalized };
|
||||||
|
}
|
||||||
|
|
||||||
|
const shortCandidate = fromRecord(description, SHORT_KEYS);
|
||||||
|
const longCandidate = fromRecord(description, LONG_KEYS) || localized;
|
||||||
|
const longText = longCandidate || "Programa en actualización académica.";
|
||||||
|
const shortText = shortCandidate || truncateText(longText, 170);
|
||||||
|
|
||||||
|
return {
|
||||||
|
short: shortText,
|
||||||
|
long: longText,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toTags(input: string[] | null | undefined): string[] {
|
||||||
|
if (!Array.isArray(input)) return [];
|
||||||
|
return input.filter((value): value is string => typeof value === "string");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageFromTags(tags: string[] | null | undefined): string {
|
||||||
|
for (const rawTag of toTags(tags)) {
|
||||||
|
const tag = rawTag.trim();
|
||||||
|
if (!tag) continue;
|
||||||
|
const lower = tag.toLowerCase();
|
||||||
|
if (lower.startsWith("cover:") || lower.startsWith("cover-url:") || lower.startsWith("thumbnail:")) {
|
||||||
|
const value = tag.slice(tag.indexOf(":") + 1).trim();
|
||||||
|
if (value.startsWith("http://") || value.startsWith("https://")) return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getYouTubeThumbnail(url: string): string {
|
||||||
|
if (!url) return "";
|
||||||
|
const normalized = url.trim();
|
||||||
|
if (!normalized) return "";
|
||||||
|
|
||||||
|
const patterns = [/v=([a-zA-Z0-9_-]{6,})/, /youtu\.be\/([a-zA-Z0-9_-]{6,})/, /embed\/([a-zA-Z0-9_-]{6,})/];
|
||||||
|
for (const pattern of patterns) {
|
||||||
|
const match = normalized.match(pattern);
|
||||||
|
const videoId = match?.[1];
|
||||||
|
if (videoId) return `https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageFromDescription(description: unknown): string {
|
||||||
|
if (!isRecord(description)) return "";
|
||||||
|
const image = fromRecord(description, IMAGE_KEYS);
|
||||||
|
if (!image) return "";
|
||||||
|
return image.startsWith("http://") || image.startsWith("https://") || image.startsWith("/") ? image : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getImageFromVideoUrl(url: string): string {
|
||||||
|
if (!url) return "";
|
||||||
|
const normalized = url.trim();
|
||||||
|
if (!normalized) return "";
|
||||||
|
if (/\.(png|jpe?g|webp|gif)$/i.test(normalized)) return normalized;
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveThumbnailUrl(args: {
|
||||||
|
description: unknown;
|
||||||
|
tags: string[] | null | undefined;
|
||||||
|
youtubeUrl?: string | null;
|
||||||
|
videoUrl?: string | null;
|
||||||
|
}): string {
|
||||||
|
const fromDescription = getImageFromDescription(args.description);
|
||||||
|
if (fromDescription) return fromDescription;
|
||||||
|
|
||||||
|
const fromTags = getImageFromTags(args.tags);
|
||||||
|
if (fromTags) return fromTags;
|
||||||
|
|
||||||
|
const fromYouTube = getYouTubeThumbnail(args.youtubeUrl ?? "");
|
||||||
|
if (fromYouTube) return fromYouTube;
|
||||||
|
|
||||||
|
const fromVideo = getImageFromVideoUrl(args.videoUrl ?? "");
|
||||||
|
if (fromVideo) return fromVideo;
|
||||||
|
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTag(tag: string): string {
|
||||||
|
return tag
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTags(tags: string[] | null | undefined): string[] {
|
||||||
|
return toTags(tags).map(normalizeTag).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function includesKeyword(tags: string[], stage: AcademicStageId): boolean {
|
||||||
|
return tags.some((tag) => STAGE_KEYWORDS[stage].some((keyword) => tag.includes(keyword)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAcademicStage(level: ProficiencyLevel, tags: string[] | null | undefined): AcademicStageConfig {
|
||||||
|
const normalizedTags = normalizeTags(tags);
|
||||||
|
|
||||||
|
if (includesKeyword(normalizedTags, "base")) return ACADEMIC_STAGE_CONFIG.base;
|
||||||
|
if (includesKeyword(normalizedTags, "consolidacion")) return ACADEMIC_STAGE_CONFIG.consolidacion;
|
||||||
|
if (includesKeyword(normalizedTags, "especializacion")) return ACADEMIC_STAGE_CONFIG.especializacion;
|
||||||
|
|
||||||
|
if (level === "BEGINNER") return ACADEMIC_STAGE_CONFIG.base;
|
||||||
|
if (level === "INTERMEDIATE") return ACADEMIC_STAGE_CONFIG.consolidacion;
|
||||||
|
return ACADEMIC_STAGE_CONFIG.especializacion;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveAvailability(
|
||||||
|
status: ContentStatus,
|
||||||
|
tags: string[] | null | undefined,
|
||||||
|
): { state: AvailabilityState; label: string } {
|
||||||
|
if (status === "PUBLISHED") return { state: "published", label: "Disponible" };
|
||||||
|
|
||||||
|
const normalizedTags = normalizeTags(tags);
|
||||||
|
const hasUpcomingTag = normalizedTags.some((tag) => PUBLIC_UPCOMING_TAGS.includes(tag));
|
||||||
|
if (hasUpcomingTag) return { state: "upcoming", label: "Próximamente" };
|
||||||
|
|
||||||
|
return { state: "draft", label: "Borrador" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUpcomingTagPresent(tags: string[] | null | undefined): boolean {
|
||||||
|
const normalizedTags = normalizeTags(tags);
|
||||||
|
return normalizedTags.some((tag) => PUBLIC_UPCOMING_TAGS.includes(tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProficiencyLabel(level: ProficiencyLevel): string {
|
||||||
|
if (level === "BEGINNER") return "Inicial";
|
||||||
|
if (level === "INTERMEDIATE") return "Intermedio";
|
||||||
|
if (level === "ADVANCED") return "Avanzado";
|
||||||
|
return "Experto";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDuration(totalSeconds: number): string {
|
||||||
|
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) return "Duración por definir";
|
||||||
|
|
||||||
|
const totalMinutes = Math.max(1, Math.ceil(totalSeconds / 60));
|
||||||
|
const hours = Math.floor(totalMinutes / 60);
|
||||||
|
const minutes = totalMinutes % 60;
|
||||||
|
|
||||||
|
if (hours === 0) return `${totalMinutes} min`;
|
||||||
|
if (minutes === 0) return `${hours} h`;
|
||||||
|
return `${hours} h ${minutes} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMinutes(minutes: number): string {
|
||||||
|
if (!Number.isFinite(minutes) || minutes <= 0) return "Sin duración";
|
||||||
|
if (minutes < 60) return `${minutes} min`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
const remainingMinutes = minutes % 60;
|
||||||
|
if (remainingMinutes === 0) return `${hours} h`;
|
||||||
|
return `${hours} h ${remainingMinutes} min`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function truncateText(value: string, maxChars: number): string {
|
||||||
|
if (value.length <= maxChars) return value;
|
||||||
|
return `${value.slice(0, Math.max(0, maxChars - 1)).trimEnd()}…`;
|
||||||
|
}
|
||||||
581
lib/courses/publicCourses.ts
Normal file
581
lib/courses/publicCourses.ts
Normal file
@@ -0,0 +1,581 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { db } from "@/lib/prisma";
|
||||||
|
import {
|
||||||
|
ACADEMIC_STAGE_CONFIG,
|
||||||
|
ACADEMIC_STAGE_ORDER,
|
||||||
|
PUBLIC_UPCOMING_TAGS,
|
||||||
|
type AcademicStageConfig,
|
||||||
|
type AcademicStageId,
|
||||||
|
formatDuration,
|
||||||
|
formatMinutes,
|
||||||
|
getCourseDescriptions,
|
||||||
|
getLocalizedText,
|
||||||
|
getProficiencyLabel,
|
||||||
|
resolveAcademicStage,
|
||||||
|
resolveAvailability,
|
||||||
|
resolveThumbnailUrl,
|
||||||
|
truncateText,
|
||||||
|
} from "@/lib/courses/presentation";
|
||||||
|
import { isFinalExam, parseLessonDescriptionMeta } from "@/lib/courses/lessonContent";
|
||||||
|
|
||||||
|
type CatalogCourseRow = Prisma.CourseGetPayload<{
|
||||||
|
include: {
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
fullName: true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
modules: {
|
||||||
|
orderBy: {
|
||||||
|
orderIndex: "asc";
|
||||||
|
};
|
||||||
|
select: {
|
||||||
|
lessons: {
|
||||||
|
orderBy: {
|
||||||
|
orderIndex: "asc";
|
||||||
|
};
|
||||||
|
select: {
|
||||||
|
id: true;
|
||||||
|
estimatedDuration: true;
|
||||||
|
isFreePreview: true;
|
||||||
|
youtubeUrl: true;
|
||||||
|
videoUrl: true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
enrollments: true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type DetailCourseRow = Prisma.CourseGetPayload<{
|
||||||
|
include: {
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
fullName: true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
modules: {
|
||||||
|
orderBy: {
|
||||||
|
orderIndex: "asc";
|
||||||
|
};
|
||||||
|
select: {
|
||||||
|
id: true;
|
||||||
|
title: true;
|
||||||
|
orderIndex: true;
|
||||||
|
lessons: {
|
||||||
|
orderBy: {
|
||||||
|
orderIndex: "asc";
|
||||||
|
};
|
||||||
|
select: {
|
||||||
|
id: true;
|
||||||
|
title: true;
|
||||||
|
description: true;
|
||||||
|
orderIndex: true;
|
||||||
|
estimatedDuration: true;
|
||||||
|
isFreePreview: true;
|
||||||
|
youtubeUrl: true;
|
||||||
|
videoUrl: true;
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
resources: true;
|
||||||
|
exercises: true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
enrollments: true;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type CatalogCourseCardView = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
shortDescription: string;
|
||||||
|
longDescription: string;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
stageId: AcademicStageId;
|
||||||
|
stageLabel: string;
|
||||||
|
proficiencyLabel: string;
|
||||||
|
durationLabel: string;
|
||||||
|
durationMinutes: number;
|
||||||
|
lessonCount: number;
|
||||||
|
instructor: string;
|
||||||
|
availabilityLabel: string;
|
||||||
|
availabilityState: "published" | "upcoming" | "draft";
|
||||||
|
progressPercent: number;
|
||||||
|
completedLessons: number;
|
||||||
|
totalLessons: number;
|
||||||
|
studentsCount: number;
|
||||||
|
isEnrolled: boolean;
|
||||||
|
hasPreview: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CatalogSectionView = {
|
||||||
|
id: AcademicStageId;
|
||||||
|
anchorId: string;
|
||||||
|
tabLabel: string;
|
||||||
|
sectionTitle: string;
|
||||||
|
sectionDescription: string;
|
||||||
|
courses: CatalogCourseCardView[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CourseCatalogViewModel = {
|
||||||
|
sections: CatalogSectionView[];
|
||||||
|
totals: {
|
||||||
|
totalCourses: number;
|
||||||
|
totalLessons: number;
|
||||||
|
instructorCount: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProgramBadge = "Video" | "Lectura" | "Actividad" | "Evaluación";
|
||||||
|
|
||||||
|
export type CourseProgramItemView = {
|
||||||
|
id: string;
|
||||||
|
order: number;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
durationLabel: string | null;
|
||||||
|
badges: ProgramBadge[];
|
||||||
|
isPreview: boolean;
|
||||||
|
isFinalExam: boolean;
|
||||||
|
isLocked: boolean;
|
||||||
|
isCompleted: boolean;
|
||||||
|
isUpcoming: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CourseProgramModuleView = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
order: number;
|
||||||
|
items: CourseProgramItemView[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CourseDetailViewModel = {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
shortDescription: string;
|
||||||
|
longDescription: string;
|
||||||
|
thumbnailUrl: string | null;
|
||||||
|
stage: AcademicStageConfig;
|
||||||
|
proficiencyLabel: string;
|
||||||
|
instructor: string;
|
||||||
|
availabilityLabel: string;
|
||||||
|
availabilityState: "published" | "upcoming" | "draft";
|
||||||
|
studentsCount: number;
|
||||||
|
lessonCount: number;
|
||||||
|
durationLabel: string;
|
||||||
|
durationMinutes: number;
|
||||||
|
moduleCount: number;
|
||||||
|
modules: CourseProgramModuleView[];
|
||||||
|
progressPercent: number;
|
||||||
|
completedLessons: number;
|
||||||
|
totalLessons: number;
|
||||||
|
isEnrolled: boolean;
|
||||||
|
firstPreviewLessonId: string | null;
|
||||||
|
price: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toMinutes(totalSeconds: number): number {
|
||||||
|
if (!Number.isFinite(totalSeconds) || totalSeconds <= 0) return 0;
|
||||||
|
return Math.max(1, Math.ceil(totalSeconds / 60));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrimaryMedia(lessons: Array<{ youtubeUrl: string | null; videoUrl: string | null }>) {
|
||||||
|
const withYoutube = lessons.find((lesson) => Boolean(lesson.youtubeUrl?.trim()));
|
||||||
|
if (withYoutube) return { youtubeUrl: withYoutube.youtubeUrl, videoUrl: withYoutube.videoUrl };
|
||||||
|
|
||||||
|
const withVideo = lessons.find((lesson) => Boolean(lesson.videoUrl?.trim()));
|
||||||
|
if (withVideo) return { youtubeUrl: withVideo.youtubeUrl, videoUrl: withVideo.videoUrl };
|
||||||
|
|
||||||
|
return { youtubeUrl: null, videoUrl: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProgramBadges(lesson: DetailCourseRow["modules"][number]["lessons"][number]): ProgramBadge[] {
|
||||||
|
const lessonMeta = parseLessonDescriptionMeta(lesson.description);
|
||||||
|
const badges: ProgramBadge[] = [];
|
||||||
|
|
||||||
|
if (lessonMeta.contentType === "VIDEO") badges.push("Video");
|
||||||
|
if (lessonMeta.contentType === "LECTURE") badges.push("Lectura");
|
||||||
|
if (lessonMeta.contentType === "ACTIVITY") badges.push("Actividad");
|
||||||
|
if (lessonMeta.contentType === "QUIZ" || lessonMeta.contentType === "FINAL_EXAM") badges.push("Evaluación");
|
||||||
|
if (lesson.youtubeUrl || lesson.videoUrl) {
|
||||||
|
if (!badges.includes("Video")) badges.push("Video");
|
||||||
|
}
|
||||||
|
if (lesson._count.resources > 0 && !badges.includes("Lectura")) badges.push("Lectura");
|
||||||
|
if (lesson._count.exercises > 0 && !badges.includes("Evaluación")) badges.push("Evaluación");
|
||||||
|
if (badges.length === 0) badges.push("Actividad");
|
||||||
|
|
||||||
|
return badges;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPublicCatalogWhere() {
|
||||||
|
return {
|
||||||
|
OR: [
|
||||||
|
{ status: "PUBLISHED" as const },
|
||||||
|
{
|
||||||
|
status: "DRAFT" as const,
|
||||||
|
tags: {
|
||||||
|
hasSome: PUBLIC_UPCOMING_TAGS,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCourseCatalogViewModel(userId: string | null): Promise<CourseCatalogViewModel> {
|
||||||
|
const courses = await db.course
|
||||||
|
.findMany({
|
||||||
|
where: getPublicCatalogWhere(),
|
||||||
|
include: {
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
fullName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
modules: {
|
||||||
|
orderBy: {
|
||||||
|
orderIndex: "asc",
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
lessons: {
|
||||||
|
orderBy: {
|
||||||
|
orderIndex: "asc",
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
estimatedDuration: true,
|
||||||
|
isFreePreview: true,
|
||||||
|
youtubeUrl: true,
|
||||||
|
videoUrl: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
enrollments: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: [
|
||||||
|
{
|
||||||
|
updatedAt: "desc",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to load public course catalog.", error);
|
||||||
|
return [] as CatalogCourseRow[];
|
||||||
|
});
|
||||||
|
|
||||||
|
const courseIds = courses.map((course) => course.id);
|
||||||
|
const [enrollments, completedProgress] =
|
||||||
|
userId && courseIds.length > 0
|
||||||
|
? await Promise.all([
|
||||||
|
db.enrollment
|
||||||
|
.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
courseId: {
|
||||||
|
in: courseIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
courseId: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => []),
|
||||||
|
db.userProgress
|
||||||
|
.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
isCompleted: true,
|
||||||
|
lesson: {
|
||||||
|
module: {
|
||||||
|
courseId: {
|
||||||
|
in: courseIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
lesson: {
|
||||||
|
select: {
|
||||||
|
module: {
|
||||||
|
select: {
|
||||||
|
courseId: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => []),
|
||||||
|
])
|
||||||
|
: [[], []];
|
||||||
|
|
||||||
|
const enrollmentSet = new Set(enrollments.map((entry) => entry.courseId));
|
||||||
|
const completedByCourse = new Map<string, number>();
|
||||||
|
for (const entry of completedProgress) {
|
||||||
|
const courseId = entry.lesson.module.courseId;
|
||||||
|
completedByCourse.set(courseId, (completedByCourse.get(courseId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped: Record<AcademicStageId, CatalogCourseCardView[]> = {
|
||||||
|
base: [],
|
||||||
|
consolidacion: [],
|
||||||
|
especializacion: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const course of courses) {
|
||||||
|
const allLessons = course.modules.flatMap((module) => module.lessons);
|
||||||
|
const totalSeconds = allLessons.reduce((sum, lesson) => sum + lesson.estimatedDuration, 0);
|
||||||
|
const totalLessons = allLessons.length;
|
||||||
|
const completedLessons = completedByCourse.get(course.id) ?? 0;
|
||||||
|
const isEnrolled = enrollmentSet.has(course.id);
|
||||||
|
const progressPercent = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0;
|
||||||
|
const stage = resolveAcademicStage(course.level, course.tags);
|
||||||
|
const availability = resolveAvailability(course.status, course.tags);
|
||||||
|
const descriptions = getCourseDescriptions(course.description);
|
||||||
|
const media = getPrimaryMedia(allLessons);
|
||||||
|
const thumbnailUrl =
|
||||||
|
resolveThumbnailUrl({
|
||||||
|
description: course.description,
|
||||||
|
tags: course.tags,
|
||||||
|
youtubeUrl: media.youtubeUrl,
|
||||||
|
videoUrl: media.videoUrl,
|
||||||
|
}) || null;
|
||||||
|
|
||||||
|
grouped[stage.id].push({
|
||||||
|
id: course.id,
|
||||||
|
slug: course.slug,
|
||||||
|
title: getLocalizedText(course.title) || "Programa académico ACVE",
|
||||||
|
shortDescription: descriptions.short,
|
||||||
|
longDescription: descriptions.long,
|
||||||
|
thumbnailUrl,
|
||||||
|
stageId: stage.id,
|
||||||
|
stageLabel: stage.levelLabel,
|
||||||
|
proficiencyLabel: getProficiencyLabel(course.level),
|
||||||
|
durationLabel: formatDuration(totalSeconds),
|
||||||
|
durationMinutes: toMinutes(totalSeconds),
|
||||||
|
lessonCount: totalLessons,
|
||||||
|
instructor: course.author.fullName || "Equipo académico ACVE",
|
||||||
|
availabilityLabel: availability.label,
|
||||||
|
availabilityState: availability.state,
|
||||||
|
progressPercent,
|
||||||
|
completedLessons,
|
||||||
|
totalLessons,
|
||||||
|
studentsCount: course._count.enrollments,
|
||||||
|
isEnrolled,
|
||||||
|
hasPreview: allLessons.some((lesson) => lesson.isFreePreview),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections: CatalogSectionView[] = ACADEMIC_STAGE_ORDER.map((stageId) => {
|
||||||
|
const config = ACADEMIC_STAGE_CONFIG[stageId];
|
||||||
|
return {
|
||||||
|
id: stageId,
|
||||||
|
anchorId: `programas-${stageId}`,
|
||||||
|
tabLabel: config.tabLabel,
|
||||||
|
sectionTitle: config.sectionTitle,
|
||||||
|
sectionDescription: config.sectionDescription,
|
||||||
|
courses: grouped[stageId],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalLessons = sections.reduce(
|
||||||
|
(sum, section) => sum + section.courses.reduce((sectionSum, course) => sectionSum + course.lessonCount, 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const instructorCount = new Set(sections.flatMap((section) => section.courses.map((course) => course.instructor))).size;
|
||||||
|
const totalCourses = sections.reduce((sum, section) => sum + section.courses.length, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
sections,
|
||||||
|
totals: {
|
||||||
|
totalCourses,
|
||||||
|
totalLessons,
|
||||||
|
instructorCount,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(value: Prisma.Decimal): number {
|
||||||
|
const asNumber = Number(value);
|
||||||
|
return Number.isFinite(asNumber) ? asNumber : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCourseDetailViewModel(
|
||||||
|
slug: string,
|
||||||
|
userId: string | null,
|
||||||
|
): Promise<CourseDetailViewModel | null> {
|
||||||
|
const course = await db.course.findFirst({
|
||||||
|
where: {
|
||||||
|
slug,
|
||||||
|
...getPublicCatalogWhere(),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
author: {
|
||||||
|
select: {
|
||||||
|
fullName: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
modules: {
|
||||||
|
orderBy: {
|
||||||
|
orderIndex: "asc",
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
orderIndex: true,
|
||||||
|
lessons: {
|
||||||
|
orderBy: {
|
||||||
|
orderIndex: "asc",
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
description: true,
|
||||||
|
orderIndex: true,
|
||||||
|
estimatedDuration: true,
|
||||||
|
isFreePreview: true,
|
||||||
|
youtubeUrl: true,
|
||||||
|
videoUrl: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
resources: true,
|
||||||
|
exercises: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
enrollments: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!course) return null;
|
||||||
|
|
||||||
|
const allLessons = course.modules.flatMap((module) => module.lessons);
|
||||||
|
const lessonIds = allLessons.map((lesson) => lesson.id);
|
||||||
|
const [enrollment, completedProgress] = userId
|
||||||
|
? await Promise.all([
|
||||||
|
db.enrollment.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_courseId: {
|
||||||
|
userId,
|
||||||
|
courseId: course.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
lessonIds.length > 0
|
||||||
|
? db.userProgress.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
isCompleted: true,
|
||||||
|
lessonId: {
|
||||||
|
in: lessonIds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
lessonId: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
: Promise.resolve([] as { lessonId: string }[]),
|
||||||
|
])
|
||||||
|
: [null, [] as { lessonId: string }[]];
|
||||||
|
|
||||||
|
const completedSet = new Set(completedProgress.map((entry) => entry.lessonId));
|
||||||
|
const isEnrolled = Boolean(enrollment);
|
||||||
|
const totalLessons = allLessons.length;
|
||||||
|
const completedLessons = completedSet.size;
|
||||||
|
const progressPercent = totalLessons > 0 ? Math.round((completedLessons / totalLessons) * 100) : 0;
|
||||||
|
const totalSeconds = allLessons.reduce((sum, lesson) => sum + lesson.estimatedDuration, 0);
|
||||||
|
const stage = resolveAcademicStage(course.level, course.tags);
|
||||||
|
const availability = resolveAvailability(course.status, course.tags);
|
||||||
|
const descriptions = getCourseDescriptions(course.description);
|
||||||
|
const media = getPrimaryMedia(allLessons);
|
||||||
|
const thumbnailUrl =
|
||||||
|
resolveThumbnailUrl({
|
||||||
|
description: course.description,
|
||||||
|
tags: course.tags,
|
||||||
|
youtubeUrl: media.youtubeUrl,
|
||||||
|
videoUrl: media.videoUrl,
|
||||||
|
}) || null;
|
||||||
|
|
||||||
|
let runningOrder = 0;
|
||||||
|
const modules: CourseProgramModuleView[] = course.modules.map((module) => ({
|
||||||
|
id: module.id,
|
||||||
|
title: getLocalizedText(module.title) || `Módulo ${module.orderIndex + 1}`,
|
||||||
|
order: module.orderIndex + 1,
|
||||||
|
items: module.lessons.map((lesson) => {
|
||||||
|
runningOrder += 1;
|
||||||
|
const lessonMeta = parseLessonDescriptionMeta(lesson.description);
|
||||||
|
const subtitleRaw = lessonMeta.text || getLocalizedText(lesson.description);
|
||||||
|
const subtitle = subtitleRaw ? truncateText(subtitleRaw, 130) : "";
|
||||||
|
const isUpcoming = availability.state !== "published";
|
||||||
|
const isLocked = !isUpcoming && !isEnrolled && !lesson.isFreePreview;
|
||||||
|
return {
|
||||||
|
id: lesson.id,
|
||||||
|
order: runningOrder,
|
||||||
|
title: getLocalizedText(lesson.title) || `Lección ${runningOrder}`,
|
||||||
|
subtitle,
|
||||||
|
durationLabel: lesson.estimatedDuration > 0 ? formatMinutes(toMinutes(lesson.estimatedDuration)) : null,
|
||||||
|
badges: getProgramBadges(lesson),
|
||||||
|
isPreview: lesson.isFreePreview,
|
||||||
|
isFinalExam: isFinalExam(lessonMeta.contentType),
|
||||||
|
isLocked,
|
||||||
|
isCompleted: completedSet.has(lesson.id),
|
||||||
|
isUpcoming,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: course.id,
|
||||||
|
slug: course.slug,
|
||||||
|
title: getLocalizedText(course.title) || "Programa académico ACVE",
|
||||||
|
shortDescription: descriptions.short,
|
||||||
|
longDescription: descriptions.long,
|
||||||
|
thumbnailUrl,
|
||||||
|
stage,
|
||||||
|
proficiencyLabel: getProficiencyLabel(course.level),
|
||||||
|
instructor: course.author.fullName || "Equipo académico ACVE",
|
||||||
|
availabilityLabel: availability.label,
|
||||||
|
availabilityState: availability.state,
|
||||||
|
studentsCount: course._count.enrollments,
|
||||||
|
lessonCount: totalLessons,
|
||||||
|
durationLabel: formatDuration(totalSeconds),
|
||||||
|
durationMinutes: toMinutes(totalSeconds),
|
||||||
|
moduleCount: modules.length,
|
||||||
|
modules,
|
||||||
|
progressPercent,
|
||||||
|
completedLessons,
|
||||||
|
totalLessons,
|
||||||
|
isEnrolled,
|
||||||
|
firstPreviewLessonId: allLessons.find((lesson) => lesson.isFreePreview)?.id ?? null,
|
||||||
|
price: toNumber(course.price),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -2,34 +2,96 @@ import type { CaseStudy } from "@/types/caseStudy";
|
|||||||
|
|
||||||
export const mockCaseStudies: CaseStudy[] = [
|
export const mockCaseStudies: CaseStudy[] = [
|
||||||
{
|
{
|
||||||
slug: "acme-v-zenith",
|
slug: "marbury-v-madison",
|
||||||
title: "Acme v. Zenith: Non-Compete Enforcement",
|
title: "Marbury v. Madison",
|
||||||
citation: "2021 App. Ct. 402",
|
citation: "5 U.S. (1 Cranch) 137",
|
||||||
year: 2021,
|
year: 1803,
|
||||||
summary: "Dispute over enforceability of a cross-state non-compete with broad scope language.",
|
summaryEs:
|
||||||
level: "Intermediate",
|
"Un caso emblematico que establece el principio de judicial review. William Marbury solicito a la Corte Suprema que ordenara al Secretario de Estado entregarle su nombramiento como juez federal. Aunque la Corte reconocio que tenia derecho al cargo, declaro inconstitucional la norma que le otorgaba competencia para resolver el asunto.",
|
||||||
topic: "Employment",
|
legalOutcomeEs:
|
||||||
keyTerms: ["Reasonableness", "Geographic scope", "Public policy"],
|
"La Corte Suprema sostuvo que Marbury tenia derecho a su nombramiento y existia un remedio legal, pero la disposicion que otorgaba competencia originaria para emitir un writ of mandamus era inconstitucional.",
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "state-v-garcia",
|
|
||||||
title: "State v. Garcia: Evidence Admissibility",
|
|
||||||
citation: "2019 Sup. Ct. 88",
|
|
||||||
year: 2019,
|
|
||||||
summary: "Examines when digital communications meet admissibility and chain-of-custody standards.",
|
|
||||||
level: "Advanced",
|
|
||||||
topic: "Criminal Procedure",
|
|
||||||
keyTerms: ["Authentication", "Hearsay exception", "Prejudice test"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
slug: "harbor-bank-v-orchid",
|
|
||||||
title: "Harbor Bank v. Orchid Labs",
|
|
||||||
citation: "2023 Com. Ct. 110",
|
|
||||||
year: 2023,
|
|
||||||
summary: "Breach of financing covenants and acceleration remedies in a distressed credit event.",
|
|
||||||
level: "Beginner",
|
level: "Beginner",
|
||||||
topic: "Commercial",
|
category: "Constitucional",
|
||||||
keyTerms: ["Default", "Covenant breach", "Acceleration"],
|
difficulty: "Base",
|
||||||
|
keyTerms: [
|
||||||
|
{
|
||||||
|
term: "Judicial Review",
|
||||||
|
definitionEs:
|
||||||
|
"Facultad del poder judicial para declarar inconstitucional una ley del Congreso cuando contradice la Constitucion.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
term: "Writ of Mandamus",
|
||||||
|
definitionEs:
|
||||||
|
"Orden judicial que instruye a un funcionario publico a cumplir con un deber legal especifico.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
term: "Original Jurisdiction",
|
||||||
|
definitionEs:
|
||||||
|
"Competencia de un tribunal para conocer un asunto en primera instancia.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
quizPrompt: "Ponte a prueba",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "miranda-v-arizona",
|
||||||
|
title: "Miranda v. Arizona",
|
||||||
|
citation: "384 U.S. 436",
|
||||||
|
year: 1966,
|
||||||
|
summaryEs:
|
||||||
|
"Un caso emblematico que transformo el procedimiento penal en Estados Unidos. Ernesto Miranda fue detenido e interrogado sin ser informado de su derecho a guardar silencio ni a contar con un abogado. La Corte Suprema determino que esas declaraciones no podian utilizarse como prueba.",
|
||||||
|
legalOutcomeEs:
|
||||||
|
"La Corte sostuvo que toda persona detenida debe ser informada de sus derechos antes de un interrogatorio custodial; sin esas advertencias, las declaraciones son inadmisibles por la proteccion contra la autoincriminacion.",
|
||||||
|
level: "Intermediate",
|
||||||
|
category: "Constitucional",
|
||||||
|
difficulty: "Base",
|
||||||
|
keyTerms: [
|
||||||
|
{
|
||||||
|
term: "Custodial Interrogation",
|
||||||
|
definitionEs:
|
||||||
|
"Interrogatorio realizado por la policia cuando una persona se encuentra bajo custodia y privada de su libertad.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
term: "Self-Incrimination",
|
||||||
|
definitionEs:
|
||||||
|
"Derecho constitucional a no declarar contra uno mismo, protegido por la Quinta Enmienda.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
term: "Miranda Warnings",
|
||||||
|
definitionEs:
|
||||||
|
"Advertencias que la policia debe comunicar antes de interrogar a un detenido, incluyendo derecho a guardar silencio y abogado.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
quizPrompt: "Ponte a prueba",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "brown-v-board-of-education",
|
||||||
|
title: "Brown v. Board of Education",
|
||||||
|
citation: "347 U.S. 483",
|
||||||
|
year: 1953,
|
||||||
|
summaryEs:
|
||||||
|
"Un caso emblematico que transformo el derecho constitucional estadounidense en materia de igualdad. Varias familias afroamericanas impugnaron la segregacion racial en escuelas publicas. La Corte concluyo que la doctrina de separados pero iguales era incompatible con la igualdad ante la ley.",
|
||||||
|
legalOutcomeEs:
|
||||||
|
"La Corte Suprema sostuvo que la segregacion racial en escuelas publicas genera desigualdad inherente y viola la Equal Protection Clause de la Decimocuarta Enmienda.",
|
||||||
|
level: "Advanced",
|
||||||
|
category: "Constitucional",
|
||||||
|
difficulty: "Base",
|
||||||
|
keyTerms: [
|
||||||
|
{
|
||||||
|
term: "Equal Protection Clause",
|
||||||
|
definitionEs:
|
||||||
|
"Clausula de la Decimocuarta Enmienda que garantiza la igualdad de proteccion de las leyes para todas las personas.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
term: "Segregation",
|
||||||
|
definitionEs: "Separacion legal de personas con base en criterios raciales.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
term: "Precedent",
|
||||||
|
definitionEs:
|
||||||
|
"Decision judicial previa que sirve como guia o autoridad obligatoria para casos posteriores.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
quizPrompt: "Ponte a prueba",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const mockPracticeModules: PracticeModule[] = [
|
|||||||
title: "Legal Translation Challenge",
|
title: "Legal Translation Challenge",
|
||||||
description: "Translate legal terms accurately in context with timed multiple-choice questions.",
|
description: "Translate legal terms accurately in context with timed multiple-choice questions.",
|
||||||
isInteractive: true,
|
isInteractive: true,
|
||||||
|
difficulty: "Beginner",
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
id: "q1",
|
id: "q1",
|
||||||
@@ -30,14 +31,86 @@ export const mockPracticeModules: PracticeModule[] = [
|
|||||||
{
|
{
|
||||||
slug: "term-matching",
|
slug: "term-matching",
|
||||||
title: "Term Matching Game",
|
title: "Term Matching Game",
|
||||||
description: "Pair legal terms with practical definitions.",
|
description: "Match core legal concepts with the most accurate definition in English.",
|
||||||
isInteractive: false,
|
isInteractive: true,
|
||||||
|
difficulty: "Intermediate",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "q1",
|
||||||
|
prompt: "Match: consideration",
|
||||||
|
choices: [
|
||||||
|
"A legally binding command from the court",
|
||||||
|
"A bargained-for exchange of value between parties",
|
||||||
|
"A prior case that has no legal effect",
|
||||||
|
"A statement made outside of court",
|
||||||
|
],
|
||||||
|
answerIndex: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "q2",
|
||||||
|
prompt: "Match: injunction",
|
||||||
|
choices: [
|
||||||
|
"A court order requiring a party to do or stop doing something",
|
||||||
|
"A clause that sets venue for disputes",
|
||||||
|
"A witness statement under oath",
|
||||||
|
"A mandatory arbitration waiver",
|
||||||
|
],
|
||||||
|
answerIndex: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "q3",
|
||||||
|
prompt: "Match: precedent",
|
||||||
|
choices: [
|
||||||
|
"A contractual deadline extension",
|
||||||
|
"A final administrative regulation",
|
||||||
|
"A prior judicial decision used as authority",
|
||||||
|
"A private settlement term",
|
||||||
|
],
|
||||||
|
answerIndex: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
slug: "contract-clauses",
|
slug: "contract-clauses",
|
||||||
title: "Contract Clause Practice",
|
title: "Contract Clause Practice",
|
||||||
description: "Identify weak and risky clause drafting choices.",
|
description: "Identify the strongest contract clause option for each scenario.",
|
||||||
isInteractive: false,
|
isInteractive: true,
|
||||||
|
difficulty: "Advanced",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
id: "q1",
|
||||||
|
prompt: "Choose the strongest force majeure clause element:",
|
||||||
|
choices: [
|
||||||
|
"No definition of triggering events",
|
||||||
|
"Broad reference without notice obligations",
|
||||||
|
"Defined events, notice timeline, and mitigation duty",
|
||||||
|
"Automatic termination without limits",
|
||||||
|
],
|
||||||
|
answerIndex: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "q2",
|
||||||
|
prompt: "Best limitation of liability drafting choice:",
|
||||||
|
choices: [
|
||||||
|
"Exclude all damages including willful misconduct",
|
||||||
|
"Cap liability with carve-outs for fraud and gross negligence",
|
||||||
|
"No cap and no exclusions",
|
||||||
|
"Cap liability only for one party",
|
||||||
|
],
|
||||||
|
answerIndex: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "q3",
|
||||||
|
prompt: "Best dispute resolution clause for cross-border deals:",
|
||||||
|
choices: [
|
||||||
|
"No governing law or venue specified",
|
||||||
|
"Unilateral right to sue in any court worldwide",
|
||||||
|
"Clear governing law, venue, and arbitration seat",
|
||||||
|
"Only internal escalation with no external remedy",
|
||||||
|
],
|
||||||
|
answerIndex: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
7
lib/i18n/clientLocale.ts
Normal file
7
lib/i18n/clientLocale.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export type ClientLocale = "en" | "es";
|
||||||
|
|
||||||
|
export function getClientLocale(): ClientLocale {
|
||||||
|
if (typeof navigator === "undefined") return "en";
|
||||||
|
const language = navigator.language?.toLowerCase() ?? "en";
|
||||||
|
return language.startsWith("es") ? "es" : "en";
|
||||||
|
}
|
||||||
224
lib/recommendations.ts
Normal file
224
lib/recommendations.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
import { db } from "@/lib/prisma";
|
||||||
|
|
||||||
|
type RecommendedCourse = {
|
||||||
|
courseId: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
level: string;
|
||||||
|
reason: string;
|
||||||
|
priority: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RecommendationPrismaClient = {
|
||||||
|
miniGameAttempt: {
|
||||||
|
findMany: (args: object) => Promise<{ miniGameId: string; scorePercent: number }[]>;
|
||||||
|
};
|
||||||
|
studyRecommendation: {
|
||||||
|
updateMany: (args: object) => Promise<unknown>;
|
||||||
|
createMany: (args: object) => Promise<unknown>;
|
||||||
|
findMany: (args: object) => Promise<
|
||||||
|
{
|
||||||
|
courseId: string;
|
||||||
|
reason: string;
|
||||||
|
priority: number;
|
||||||
|
course: { title: unknown; slug: string; level: string };
|
||||||
|
}[]
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function getText(value: unknown): string {
|
||||||
|
if (!value) return "";
|
||||||
|
if (typeof value === "string") return value;
|
||||||
|
if (typeof value === "object") {
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
if (typeof record.en === "string") return record.en;
|
||||||
|
if (typeof record.es === "string") return record.es;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function targetLevelByGrade(grade: number): "BEGINNER" | "INTERMEDIATE" | "ADVANCED" {
|
||||||
|
if (grade < 60) return "BEGINNER";
|
||||||
|
if (grade < 80) return "INTERMEDIATE";
|
||||||
|
return "ADVANCED";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getMiniGameGrade(userId: string): Promise<number> {
|
||||||
|
let attempts: { miniGameId: string; scorePercent: number }[] = [];
|
||||||
|
const prismaAny = db as unknown as RecommendationPrismaClient;
|
||||||
|
try {
|
||||||
|
attempts = await prismaAny.miniGameAttempt.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: { completedAt: "desc" },
|
||||||
|
select: { miniGameId: true, scorePercent: true },
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestByGame = new Map<string, number>();
|
||||||
|
for (const attempt of attempts) {
|
||||||
|
if (!latestByGame.has(attempt.miniGameId)) {
|
||||||
|
latestByGame.set(attempt.miniGameId, attempt.scorePercent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = [...latestByGame.values()];
|
||||||
|
if (latest.length === 0) return 0;
|
||||||
|
return Math.round(latest.reduce((acc, value) => acc + value, 0) / latest.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshStudyRecommendations(userId: string) {
|
||||||
|
const grade = await getMiniGameGrade(userId);
|
||||||
|
const targetLevel = targetLevelByGrade(grade);
|
||||||
|
|
||||||
|
const [courses, enrollments] = await Promise.all([
|
||||||
|
db.course.findMany({
|
||||||
|
where: {
|
||||||
|
status: "PUBLISHED",
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
modules: {
|
||||||
|
include: {
|
||||||
|
lessons: {
|
||||||
|
select: { id: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
db.enrollment.findMany({
|
||||||
|
where: { userId },
|
||||||
|
select: { courseId: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const enrolledSet = new Set(enrollments.map((enrollment) => enrollment.courseId));
|
||||||
|
const completedProgress = await db.userProgress.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
isCompleted: true,
|
||||||
|
lesson: {
|
||||||
|
module: {
|
||||||
|
courseId: {
|
||||||
|
in: courses.map((course) => course.id),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
lesson: {
|
||||||
|
select: {
|
||||||
|
module: {
|
||||||
|
select: { courseId: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const completedByCourse = new Map<string, number>();
|
||||||
|
for (const item of completedProgress) {
|
||||||
|
const courseId = item.lesson.module.courseId;
|
||||||
|
completedByCourse.set(courseId, (completedByCourse.get(courseId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recommendations: RecommendedCourse[] = [];
|
||||||
|
for (const course of courses) {
|
||||||
|
const totalLessons = course.modules.reduce((acc, module) => acc + module.lessons.length, 0);
|
||||||
|
const completedLessons = completedByCourse.get(course.id) ?? 0;
|
||||||
|
const isCompleted = totalLessons > 0 && completedLessons >= totalLessons;
|
||||||
|
if (isCompleted) continue;
|
||||||
|
|
||||||
|
const isEnrolled = enrolledSet.has(course.id);
|
||||||
|
const levelMatch = course.level === targetLevel;
|
||||||
|
|
||||||
|
let priority = 20;
|
||||||
|
let reason = `Aligned with your current level focus (${targetLevel.toLowerCase()}).`;
|
||||||
|
|
||||||
|
if (isEnrolled) {
|
||||||
|
priority = 5;
|
||||||
|
reason = "You already started this course and can keep progressing.";
|
||||||
|
} else if (!levelMatch) {
|
||||||
|
priority = 40;
|
||||||
|
reason = "Useful as a secondary recommendation outside your current level target.";
|
||||||
|
}
|
||||||
|
|
||||||
|
recommendations.push({
|
||||||
|
courseId: course.id,
|
||||||
|
slug: course.slug,
|
||||||
|
title: getText(course.title) || "Untitled course",
|
||||||
|
level: course.level,
|
||||||
|
reason,
|
||||||
|
priority,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = recommendations.sort((a, b) => a.priority - b.priority).slice(0, 5);
|
||||||
|
|
||||||
|
const prismaAny = db as unknown as RecommendationPrismaClient;
|
||||||
|
try {
|
||||||
|
await prismaAny.studyRecommendation.updateMany({
|
||||||
|
where: { userId, isActive: true },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sorted.length > 0) {
|
||||||
|
await prismaAny.studyRecommendation.createMany({
|
||||||
|
data: sorted.map((item) => ({
|
||||||
|
userId,
|
||||||
|
courseId: item.courseId,
|
||||||
|
reason: item.reason,
|
||||||
|
priority: item.priority,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getActiveRecommendations(userId: string) {
|
||||||
|
let existing:
|
||||||
|
| {
|
||||||
|
courseId: string;
|
||||||
|
reason: string;
|
||||||
|
priority: number;
|
||||||
|
course: { title: unknown; slug: string; level: string };
|
||||||
|
}[]
|
||||||
|
| null = null;
|
||||||
|
try {
|
||||||
|
existing = await (db as unknown as RecommendationPrismaClient).studyRecommendation.findMany({
|
||||||
|
where: { userId, isActive: true },
|
||||||
|
include: {
|
||||||
|
course: {
|
||||||
|
select: {
|
||||||
|
title: true,
|
||||||
|
slug: true,
|
||||||
|
level: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { priority: "asc" },
|
||||||
|
take: 5,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return refreshStudyRecommendations(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existing || existing.length === 0) {
|
||||||
|
return refreshStudyRecommendations(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return existing.map((item) => ({
|
||||||
|
courseId: item.courseId,
|
||||||
|
slug: item.course.slug,
|
||||||
|
title: getText(item.course.title) || "Untitled course",
|
||||||
|
level: item.course.level,
|
||||||
|
reason: item.reason,
|
||||||
|
priority: item.priority,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -1,8 +1,13 @@
|
|||||||
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
|
import { createBrowserClient } from "@supabase/ssr";
|
||||||
|
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||||
import { readSupabasePublicConfig } from "@/lib/supabase/config";
|
import { readSupabasePublicConfig } from "@/lib/supabase/config";
|
||||||
|
|
||||||
let browserClient: SupabaseClient | null = null;
|
let browserClient: SupabaseClient | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Browser Supabase client that uses cookies (via @supabase/ssr) so the session
|
||||||
|
* is shared with the server/middleware and stays in sync after login.
|
||||||
|
*/
|
||||||
export const supabaseBrowser = (): SupabaseClient | null => {
|
export const supabaseBrowser = (): SupabaseClient | null => {
|
||||||
const config = readSupabasePublicConfig();
|
const config = readSupabasePublicConfig();
|
||||||
if (!config) {
|
if (!config) {
|
||||||
@@ -10,7 +15,7 @@ export const supabaseBrowser = (): SupabaseClient | null => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!browserClient) {
|
if (!browserClient) {
|
||||||
browserClient = createClient(config.url, config.anonKey);
|
browserClient = createBrowserClient(config.url, config.anonKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
return browserClient;
|
return browserClient;
|
||||||
|
|||||||
@@ -29,19 +29,13 @@ export async function middleware(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// IMPORTANT: This refreshes the session.
|
// IMPORTANT: This refreshes the session.
|
||||||
// If the user is not logged in, 'user' will be null.
|
// If the user is not logged in, 'user' will be null.
|
||||||
const { data: { user } } = await supabase.auth.getUser();
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
|
||||||
const isTeacherRoute = request.nextUrl.pathname.startsWith("/teacher");
|
const isTeacherRoute = request.nextUrl.pathname.startsWith("/teacher");
|
||||||
const isProtectedRoute =
|
// Practice mini-games are public; auth is handled in server actions for persistence only.
|
||||||
request.nextUrl.pathname.startsWith("/courses") ||
|
const isProtectedRoute = isTeacherRoute;
|
||||||
request.nextUrl.pathname.startsWith("/practice") ||
|
|
||||||
isTeacherRoute;
|
|
||||||
|
|
||||||
const isLocalDev = process.env.NODE_ENV === 'development';
|
|
||||||
const activeUser = isLocalDev ? { id: 'f3bbd600-4c58-45b0-855b-cc8f045117c6' } : user;
|
|
||||||
console.log("ACTIVE_USER:", activeUser);
|
|
||||||
|
|
||||||
// If they are trying to access a protected route and aren't logged in, redirect to login
|
// If they are trying to access a protected route and aren't logged in, redirect to login
|
||||||
if (isProtectedRoute && !user) {
|
if (isProtectedRoute && !user) {
|
||||||
@@ -58,4 +52,4 @@ export const config = {
|
|||||||
matcher: [
|
matcher: [
|
||||||
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,12 @@
|
|||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"prisma:generate": "prisma generate",
|
||||||
|
"prisma:status": "prisma migrate status",
|
||||||
|
"prisma:migrate:dev": "prisma migrate dev",
|
||||||
|
"prisma:migrate:deploy": "prisma migrate deploy",
|
||||||
|
"prisma:seed": "prisma db seed"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/adapter-pg": "^7.3.0",
|
"@prisma/adapter-pg": "^7.3.0",
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default defineConfig({
|
|||||||
schema: "prisma/schema.prisma",
|
schema: "prisma/schema.prisma",
|
||||||
migrations: {
|
migrations: {
|
||||||
path: "prisma/migrations",
|
path: "prisma/migrations",
|
||||||
seed: 'npx tsx prisma/seed.ts',
|
seed: "npx ts-node prisma/seed.ts",
|
||||||
},
|
},
|
||||||
datasource: {
|
datasource: {
|
||||||
url: process.env["DIRECT_URL"],
|
url: process.env["DIRECT_URL"],
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- A unique constraint covering the columns `[certificateNumber]` on the table `Certificate` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- A unique constraint covering the columns `[userId,courseId]` on the table `Certificate` will be added. If there are existing duplicate values, this will fail.
|
||||||
|
- Added the required column `certificateNumber` to the `Certificate` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "MiniGameDifficulty" AS ENUM ('BEGINNER', 'INTERMEDIATE', 'ADVANCED');
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Certificate" ADD COLUMN "certificateNumber" TEXT NOT NULL,
|
||||||
|
ADD COLUMN "pdfVersion" INTEGER NOT NULL DEFAULT 1;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MiniGame" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"title" TEXT NOT NULL,
|
||||||
|
"description" TEXT NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"difficulty" "MiniGameDifficulty" NOT NULL DEFAULT 'INTERMEDIATE',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "MiniGame_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MiniGameQuestion" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"miniGameId" TEXT NOT NULL,
|
||||||
|
"prompt" TEXT NOT NULL,
|
||||||
|
"choices" TEXT[],
|
||||||
|
"answerIndex" INTEGER NOT NULL,
|
||||||
|
"orderIndex" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "MiniGameQuestion_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "MiniGameAttempt" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"miniGameId" TEXT NOT NULL,
|
||||||
|
"scorePercent" INTEGER NOT NULL,
|
||||||
|
"correctCount" INTEGER NOT NULL,
|
||||||
|
"totalQuestions" INTEGER NOT NULL,
|
||||||
|
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"completedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "MiniGameAttempt_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "StudyRecommendation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"courseId" TEXT NOT NULL,
|
||||||
|
"reason" TEXT NOT NULL,
|
||||||
|
"priority" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "StudyRecommendation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "MiniGame_slug_key" ON "MiniGame"("slug");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MiniGameQuestion_miniGameId_orderIndex_idx" ON "MiniGameQuestion"("miniGameId", "orderIndex");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "MiniGameAttempt_userId_miniGameId_completedAt_idx" ON "MiniGameAttempt"("userId", "miniGameId", "completedAt");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "StudyRecommendation_userId_isActive_priority_idx" ON "StudyRecommendation"("userId", "isActive", "priority");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Certificate_certificateNumber_key" ON "Certificate"("certificateNumber");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Certificate_userId_courseId_key" ON "Certificate"("userId", "courseId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MiniGameQuestion" ADD CONSTRAINT "MiniGameQuestion_miniGameId_fkey" FOREIGN KEY ("miniGameId") REFERENCES "MiniGame"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MiniGameAttempt" ADD CONSTRAINT "MiniGameAttempt_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "MiniGameAttempt" ADD CONSTRAINT "MiniGameAttempt_miniGameId_fkey" FOREIGN KEY ("miniGameId") REFERENCES "MiniGame"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "StudyRecommendation" ADD CONSTRAINT "StudyRecommendation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "StudyRecommendation" ADD CONSTRAINT "StudyRecommendation_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
0
prisma/migrations/20260225_baseline/migration.sql
Normal file
0
prisma/migrations/20260225_baseline/migration.sql
Normal file
225
prisma/migrations/20260225_baseline_v2/migration.sql
Normal file
225
prisma/migrations/20260225_baseline_v2/migration.sql
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
-- CreateSchema
|
||||||
|
CREATE SCHEMA IF NOT EXISTS "public";
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "public"."ContentStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "public"."ExerciseType" AS ENUM ('MULTIPLE_CHOICE', 'FILL_IN_BLANK', 'TRANSLATION', 'MATCHING');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "public"."ProficiencyLevel" AS ENUM ('BEGINNER', 'INTERMEDIATE', 'ADVANCED', 'EXPERT');
|
||||||
|
|
||||||
|
-- CreateEnum
|
||||||
|
CREATE TYPE "public"."UserRole" AS ENUM ('SUPER_ADMIN', 'ORG_ADMIN', 'TEACHER', 'LEARNER');
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "public"."Certificate" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"courseId" TEXT NOT NULL,
|
||||||
|
"companyId" TEXT,
|
||||||
|
"issuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"metadataSnapshot" JSONB NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Certificate_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "public"."Company" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"name" TEXT NOT NULL,
|
||||||
|
"logoUrl" TEXT,
|
||||||
|
"billingEmail" TEXT,
|
||||||
|
"maxSeats" INTEGER NOT NULL DEFAULT 5,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Company_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "public"."Course" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"title" JSONB NOT NULL,
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"description" JSONB NOT NULL,
|
||||||
|
"level" "public"."ProficiencyLevel" NOT NULL DEFAULT 'INTERMEDIATE',
|
||||||
|
"tags" TEXT[],
|
||||||
|
"status" "public"."ContentStatus" NOT NULL DEFAULT 'DRAFT',
|
||||||
|
"price" DECIMAL(65,30) NOT NULL DEFAULT 0.00,
|
||||||
|
"authorId" TEXT NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"prerequisiteId" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Course_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "public"."Enrollment" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"courseId" TEXT NOT NULL,
|
||||||
|
"amountPaid" DECIMAL(65,30) NOT NULL,
|
||||||
|
"currency" TEXT NOT NULL DEFAULT 'MXN',
|
||||||
|
"paymentMethod" TEXT NOT NULL DEFAULT 'MERCADO_PAGO',
|
||||||
|
"externalId" TEXT,
|
||||||
|
"purchasedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "Enrollment_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "public"."Exercise" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"lessonId" TEXT NOT NULL,
|
||||||
|
"type" "public"."ExerciseType" NOT NULL,
|
||||||
|
"content" JSONB NOT NULL,
|
||||||
|
"orderIndex" INTEGER NOT NULL DEFAULT 0,
|
||||||
|
|
||||||
|
CONSTRAINT "Exercise_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "public"."Lesson" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"moduleId" TEXT NOT NULL,
|
||||||
|
"title" JSONB NOT NULL,
|
||||||
|
"slug" TEXT,
|
||||||
|
"orderIndex" INTEGER NOT NULL,
|
||||||
|
"estimatedDuration" INTEGER NOT NULL,
|
||||||
|
"version" INTEGER NOT NULL DEFAULT 1,
|
||||||
|
"isFreePreview" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
"videoUrl" TEXT,
|
||||||
|
"description" JSONB,
|
||||||
|
"youtubeUrl" TEXT,
|
||||||
|
|
||||||
|
CONSTRAINT "Lesson_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "public"."Membership" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"companyId" TEXT NOT NULL,
|
||||||
|
"isActive" BOOLEAN NOT NULL DEFAULT true,
|
||||||
|
"role" TEXT NOT NULL DEFAULT 'MEMBER',
|
||||||
|
"joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Membership_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "public"."Module" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"courseId" TEXT NOT NULL,
|
||||||
|
"title" JSONB NOT NULL,
|
||||||
|
"orderIndex" INTEGER NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Module_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "public"."Profile" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"email" TEXT NOT NULL,
|
||||||
|
"fullName" TEXT,
|
||||||
|
"avatarUrl" TEXT,
|
||||||
|
"role" "public"."UserRole" NOT NULL DEFAULT 'LEARNER',
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Profile_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "public"."Resource" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"lessonId" TEXT NOT NULL,
|
||||||
|
"fileUrl" TEXT NOT NULL,
|
||||||
|
"displayName" JSONB NOT NULL,
|
||||||
|
"fileType" TEXT NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "Resource_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "public"."UserProgress" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"lessonId" TEXT NOT NULL,
|
||||||
|
"isCompleted" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"score" INTEGER,
|
||||||
|
"startedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"finishedAt" TIMESTAMP(3),
|
||||||
|
"lastPlayedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
CONSTRAINT "UserProgress_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Course_slug_key" ON "public"."Course"("slug" ASC);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Enrollment_userId_courseId_key" ON "public"."Enrollment"("userId" ASC, "courseId" ASC);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Membership_userId_companyId_key" ON "public"."Membership"("userId" ASC, "companyId" ASC);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "Profile_email_key" ON "public"."Profile"("email" ASC);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "UserProgress_userId_lessonId_key" ON "public"."UserProgress"("userId" ASC, "lessonId" ASC);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."Certificate" ADD CONSTRAINT "Certificate_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "public"."Company"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."Certificate" ADD CONSTRAINT "Certificate_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "public"."Course"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."Certificate" ADD CONSTRAINT "Certificate_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."Profile"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."Course" ADD CONSTRAINT "Course_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "public"."Profile"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."Course" ADD CONSTRAINT "Course_prerequisiteId_fkey" FOREIGN KEY ("prerequisiteId") REFERENCES "public"."Course"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."Enrollment" ADD CONSTRAINT "Enrollment_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "public"."Course"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."Enrollment" ADD CONSTRAINT "Enrollment_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."Exercise" ADD CONSTRAINT "Exercise_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "public"."Lesson"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."Lesson" ADD CONSTRAINT "Lesson_moduleId_fkey" FOREIGN KEY ("moduleId") REFERENCES "public"."Module"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."Membership" ADD CONSTRAINT "Membership_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "public"."Company"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."Membership" ADD CONSTRAINT "Membership_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."Module" ADD CONSTRAINT "Module_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "public"."Course"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."Resource" ADD CONSTRAINT "Resource_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "public"."Lesson"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."UserProgress" ADD CONSTRAINT "UserProgress_lessonId_fkey" FOREIGN KEY ("lessonId") REFERENCES "public"."Lesson"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "public"."UserProgress" ADD CONSTRAINT "UserProgress_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."Profile"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Please do not edit this file manually
|
||||||
|
# It should be added in your version-control system (e.g., Git)
|
||||||
|
provider = "postgresql"
|
||||||
@@ -37,6 +37,12 @@ enum ExerciseType {
|
|||||||
MATCHING
|
MATCHING
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum MiniGameDifficulty {
|
||||||
|
BEGINNER
|
||||||
|
INTERMEDIATE
|
||||||
|
ADVANCED
|
||||||
|
}
|
||||||
|
|
||||||
// --- MODELS ---
|
// --- MODELS ---
|
||||||
|
|
||||||
model Profile {
|
model Profile {
|
||||||
@@ -55,6 +61,8 @@ model Profile {
|
|||||||
progress UserProgress[]
|
progress UserProgress[]
|
||||||
certificates Certificate[]
|
certificates Certificate[]
|
||||||
authoredCourses Course[] @relation("CourseAuthor") // Teachers own courses
|
authoredCourses Course[] @relation("CourseAuthor") // Teachers own courses
|
||||||
|
miniGameAttempts MiniGameAttempt[]
|
||||||
|
recommendations StudyRecommendation[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Company {
|
model Company {
|
||||||
@@ -94,7 +102,8 @@ model Course {
|
|||||||
description Json // { "en": "...", "es": "..." }
|
description Json // { "en": "...", "es": "..." }
|
||||||
level ProficiencyLevel @default(INTERMEDIATE)
|
level ProficiencyLevel @default(INTERMEDIATE)
|
||||||
tags String[]
|
tags String[]
|
||||||
|
learningOutcomes Json? // "What you will learn"; array of strings now, i18n object later
|
||||||
|
|
||||||
status ContentStatus @default(DRAFT)
|
status ContentStatus @default(DRAFT)
|
||||||
price Decimal @default(0.00) // Price in MXN
|
price Decimal @default(0.00) // Price in MXN
|
||||||
|
|
||||||
@@ -111,6 +120,7 @@ model Course {
|
|||||||
modules Module[]
|
modules Module[]
|
||||||
certificates Certificate[]
|
certificates Certificate[]
|
||||||
enrollments Enrollment[]
|
enrollments Enrollment[]
|
||||||
|
recommendations StudyRecommendation[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Enrollment {
|
model Enrollment {
|
||||||
@@ -152,6 +162,7 @@ model Lesson {
|
|||||||
slug String? // Optional for direct linking
|
slug String? // Optional for direct linking
|
||||||
orderIndex Int
|
orderIndex Int
|
||||||
videoUrl String?
|
videoUrl String?
|
||||||
|
youtubeUrl String?
|
||||||
estimatedDuration Int // Seconds
|
estimatedDuration Int // Seconds
|
||||||
version Int @default(1)
|
version Int @default(1)
|
||||||
isFreePreview Boolean @default(false) // Marketing hook
|
isFreePreview Boolean @default(false) // Marketing hook
|
||||||
@@ -206,10 +217,72 @@ model Certificate {
|
|||||||
userId String
|
userId String
|
||||||
courseId String
|
courseId String
|
||||||
companyId String? // Captured for co-branding (Nullable for B2C)
|
companyId String? // Captured for co-branding (Nullable for B2C)
|
||||||
|
certificateNumber String @unique
|
||||||
|
pdfVersion Int @default(1)
|
||||||
issuedAt DateTime @default(now())
|
issuedAt DateTime @default(now())
|
||||||
metadataSnapshot Json // Burn the course name/version here
|
metadataSnapshot Json // Burn the course name/version here
|
||||||
|
|
||||||
user Profile @relation(fields: [userId], references: [id])
|
user Profile @relation(fields: [userId], references: [id])
|
||||||
course Course @relation(fields: [courseId], references: [id])
|
course Course @relation(fields: [courseId], references: [id])
|
||||||
company Company? @relation(fields: [companyId], references: [id])
|
company Company? @relation(fields: [companyId], references: [id])
|
||||||
}
|
|
||||||
|
@@unique([userId, courseId])
|
||||||
|
}
|
||||||
|
|
||||||
|
model MiniGame {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
slug String @unique
|
||||||
|
title String
|
||||||
|
description String
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
difficulty MiniGameDifficulty @default(INTERMEDIATE)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
questions MiniGameQuestion[]
|
||||||
|
attempts MiniGameAttempt[]
|
||||||
|
}
|
||||||
|
|
||||||
|
model MiniGameQuestion {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
miniGameId String
|
||||||
|
prompt String
|
||||||
|
choices String[]
|
||||||
|
answerIndex Int
|
||||||
|
orderIndex Int @default(0)
|
||||||
|
|
||||||
|
miniGame MiniGame @relation(fields: [miniGameId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([miniGameId, orderIndex])
|
||||||
|
}
|
||||||
|
|
||||||
|
model MiniGameAttempt {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
miniGameId String
|
||||||
|
scorePercent Int
|
||||||
|
correctCount Int
|
||||||
|
totalQuestions Int
|
||||||
|
startedAt DateTime @default(now())
|
||||||
|
completedAt DateTime @default(now())
|
||||||
|
|
||||||
|
user Profile @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
miniGame MiniGame @relation(fields: [miniGameId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId, miniGameId, completedAt])
|
||||||
|
}
|
||||||
|
|
||||||
|
model StudyRecommendation {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
userId String
|
||||||
|
courseId String
|
||||||
|
reason String
|
||||||
|
priority Int @default(0)
|
||||||
|
isActive Boolean @default(true)
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
user Profile @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
@@index([userId, isActive, priority])
|
||||||
|
}
|
||||||
|
|||||||
122
prisma/seed.ts
122
prisma/seed.ts
@@ -93,6 +93,126 @@ async function main() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
console.log(`📚 Created Course: ${course.slug}`)
|
console.log(`📚 Created Course: ${course.slug}`)
|
||||||
|
|
||||||
|
const miniGames = [
|
||||||
|
{
|
||||||
|
slug: "translation",
|
||||||
|
title: "Legal Translation Challenge",
|
||||||
|
description: "Translate legal terms accurately in context.",
|
||||||
|
difficulty: "BEGINNER",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
prompt: "Spanish term: incumplimiento contractual",
|
||||||
|
choices: ["Contractual compliance", "Breach of contract", "Contract interpretation", "Mutual assent"],
|
||||||
|
answerIndex: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prompt: "Spanish term: medida cautelar",
|
||||||
|
choices: ["Class action", "Summary judgment", "Injunctive relief", "Arbitration clause"],
|
||||||
|
answerIndex: 2,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "term-matching",
|
||||||
|
title: "Term Matching Game",
|
||||||
|
description: "Match legal terms with accurate definitions.",
|
||||||
|
difficulty: "INTERMEDIATE",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
prompt: "Match: consideration",
|
||||||
|
choices: [
|
||||||
|
"A legally binding command from the court",
|
||||||
|
"A bargained-for exchange of value between parties",
|
||||||
|
"A prior case that has no legal effect",
|
||||||
|
"A statement made outside of court",
|
||||||
|
],
|
||||||
|
answerIndex: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prompt: "Match: injunction",
|
||||||
|
choices: [
|
||||||
|
"A court order requiring a party to do or stop doing something",
|
||||||
|
"A clause that sets venue for disputes",
|
||||||
|
"A witness statement under oath",
|
||||||
|
"A mandatory arbitration waiver",
|
||||||
|
],
|
||||||
|
answerIndex: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: "contract-clauses",
|
||||||
|
title: "Contract Clause Practice",
|
||||||
|
description: "Pick the best clause drafting option for each scenario.",
|
||||||
|
difficulty: "ADVANCED",
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
prompt: "Choose the strongest force majeure clause element:",
|
||||||
|
choices: [
|
||||||
|
"No definition of triggering events",
|
||||||
|
"Broad reference without notice obligations",
|
||||||
|
"Defined events, notice timeline, and mitigation duty",
|
||||||
|
"Automatic termination without limits",
|
||||||
|
],
|
||||||
|
answerIndex: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
prompt: "Best limitation of liability drafting choice:",
|
||||||
|
choices: [
|
||||||
|
"Exclude all damages including willful misconduct",
|
||||||
|
"Cap liability with carve-outs for fraud and gross negligence",
|
||||||
|
"No cap and no exclusions",
|
||||||
|
"Cap liability only for one party",
|
||||||
|
],
|
||||||
|
answerIndex: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const prismaAny = prisma as unknown as {
|
||||||
|
miniGame: {
|
||||||
|
upsert: (args: object) => Promise<{ id: string }>
|
||||||
|
}
|
||||||
|
miniGameQuestion: {
|
||||||
|
deleteMany: (args: object) => Promise<unknown>
|
||||||
|
createMany: (args: object) => Promise<unknown>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const game of miniGames) {
|
||||||
|
const saved = await prismaAny.miniGame.upsert({
|
||||||
|
where: { slug: game.slug },
|
||||||
|
update: {
|
||||||
|
title: game.title,
|
||||||
|
description: game.description,
|
||||||
|
difficulty: game.difficulty,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
slug: game.slug,
|
||||||
|
title: game.title,
|
||||||
|
description: game.description,
|
||||||
|
difficulty: game.difficulty,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await prismaAny.miniGameQuestion.deleteMany({
|
||||||
|
where: { miniGameId: saved.id },
|
||||||
|
})
|
||||||
|
|
||||||
|
await prismaAny.miniGameQuestion.createMany({
|
||||||
|
data: game.questions.map((q, index) => ({
|
||||||
|
miniGameId: saved.id,
|
||||||
|
prompt: q.prompt,
|
||||||
|
choices: q.choices,
|
||||||
|
answerIndex: q.answerIndex,
|
||||||
|
orderIndex: index,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
console.log(`🧩 Seeded ${miniGames.length} mini-games`)
|
||||||
|
|
||||||
console.log('✅ Seed complete!')
|
console.log('✅ Seed complete!')
|
||||||
|
|
||||||
@@ -104,4 +224,4 @@ async function main() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main()
|
main()
|
||||||
|
|||||||
297
resumen_cambios.md
Normal file
297
resumen_cambios.md
Normal file
@@ -0,0 +1,297 @@
|
|||||||
|
# Resumen de Cambios (25/02/26)
|
||||||
|
# Marcelo
|
||||||
|
|
||||||
|
Fecha de trabajo: 2026-02-25
|
||||||
|
Proyecto: `/opt/ACVE`
|
||||||
|
|
||||||
|
## 1) Objetivo de la sesion
|
||||||
|
Implementar el backlog solicitado:
|
||||||
|
- Mas mini-games con calificacion ligada al perfil.
|
||||||
|
- Recomendaciones de estudio por perfil/progreso.
|
||||||
|
- Certificados al completar curso.
|
||||||
|
- Ver cursos inscritos (`enrolled`).
|
||||||
|
- Cambiar `Start Course` por `Continue` cuando ya esta inscrito.
|
||||||
|
- Actualizar Case Studies con contenido de Andres.
|
||||||
|
|
||||||
|
Tambien se resolvio operativa de entorno para Prisma/Node y baseline de migraciones sobre Supabase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2) Features implementadas
|
||||||
|
|
||||||
|
## 2.1 Mini-games (3 interactivos) + persistencia + perfil
|
||||||
|
- Se expandieron los mini-games a 3 modulos interactivos:
|
||||||
|
- `translation`
|
||||||
|
- `term-matching`
|
||||||
|
- `contract-clauses`
|
||||||
|
- Se paso de historial localStorage a acciones server-side con persistencia DB (si tablas ya migradas).
|
||||||
|
- Se agrego calculo de calificacion global de mini-games para perfil (promedio de ultimo intento por juego).
|
||||||
|
|
||||||
|
Archivos:
|
||||||
|
- `lib/data/mockPractice.ts`
|
||||||
|
- `types/practice.ts`
|
||||||
|
- `app/(protected)/practice/[slug]/actions.ts`
|
||||||
|
- `app/(protected)/practice/[slug]/page.tsx`
|
||||||
|
- `lib/recommendations.ts`
|
||||||
|
- `app/(protected)/profile/page.tsx`
|
||||||
|
|
||||||
|
## 2.2 Recomendaciones de estudio (deterministicas)
|
||||||
|
- Se implemento motor de recomendaciones por reglas:
|
||||||
|
- basado en score de mini-games y progreso/cursos.
|
||||||
|
- prioriza cursos ya inscritos e incompletos.
|
||||||
|
- fallback por nivel objetivo (beginner/intermediate/advanced).
|
||||||
|
- Se muestra bloque “Recommended next” en perfil.
|
||||||
|
|
||||||
|
Archivos:
|
||||||
|
- `lib/recommendations.ts`
|
||||||
|
- `app/(protected)/profile/page.tsx`
|
||||||
|
- `app/(protected)/courses/[slug]/learn/actions.ts` (refresh de recomendaciones al completar leccion)
|
||||||
|
|
||||||
|
## 2.3 Certificados automáticos + descarga PDF
|
||||||
|
- Emision automatica al llegar a 100% de lecciones completadas (idempotente por curso/usuario).
|
||||||
|
- Endpoint de descarga PDF:
|
||||||
|
- `/api/certificates/[id]/pdf`
|
||||||
|
- Integracion en:
|
||||||
|
- `Profile` (listado de certificados)
|
||||||
|
- `My Courses` (download cuando existe certificado)
|
||||||
|
|
||||||
|
Archivos:
|
||||||
|
- `lib/certificates.ts`
|
||||||
|
- `app/api/certificates/[id]/pdf/route.ts`
|
||||||
|
- `app/(protected)/courses/[slug]/learn/actions.ts`
|
||||||
|
- `app/(protected)/profile/page.tsx`
|
||||||
|
- `app/(protected)/my-courses/page.tsx`
|
||||||
|
|
||||||
|
## 2.4 UX: completion modal + confetti + CTA de certificado
|
||||||
|
- Al completar curso por primera vez (cuando se emite certificado nuevo):
|
||||||
|
- aparece modal de “Course completed”
|
||||||
|
- animacion de confetti
|
||||||
|
- boton `Download PDF`
|
||||||
|
- boton `Open Profile`
|
||||||
|
|
||||||
|
Archivo:
|
||||||
|
- `components/courses/StudentClassroomClient.tsx`
|
||||||
|
|
||||||
|
## 2.5 Vista de cursos inscritos
|
||||||
|
- Nueva ruta protegida:
|
||||||
|
- `/my-courses`
|
||||||
|
- Muestra progreso por curso, CTA `Continue/Review`, y descarga de certificado si aplica.
|
||||||
|
|
||||||
|
Archivo:
|
||||||
|
- `app/(protected)/my-courses/page.tsx`
|
||||||
|
|
||||||
|
## 2.6 Cambio Start -> Continue
|
||||||
|
- En detalle de curso (`/courses/[slug]`):
|
||||||
|
- si usuario inscrito: `Continue`
|
||||||
|
- si no inscrito: `Start Course`
|
||||||
|
- no autenticado: `Login to start`
|
||||||
|
|
||||||
|
Archivo:
|
||||||
|
- `app/(public)/courses/[slug]/page.tsx`
|
||||||
|
|
||||||
|
## 2.7 Profile del alumno
|
||||||
|
- Nueva ruta protegida:
|
||||||
|
- `/profile`
|
||||||
|
- Incluye:
|
||||||
|
- score mini-games
|
||||||
|
- desglose latest/best por mini-game
|
||||||
|
- recomendaciones
|
||||||
|
- certificados + descarga
|
||||||
|
|
||||||
|
Archivo:
|
||||||
|
- `app/(protected)/profile/page.tsx`
|
||||||
|
|
||||||
|
## 2.8 Navbar
|
||||||
|
- Se agregaron enlaces para usuario autenticado:
|
||||||
|
- `My Courses`
|
||||||
|
- `Profile`
|
||||||
|
|
||||||
|
Archivo:
|
||||||
|
- `components/Navbar.tsx`
|
||||||
|
|
||||||
|
## 2.9 Case Studies actualizados con Andres
|
||||||
|
- Se reemplazo contenido mock por:
|
||||||
|
- Marbury v. Madison
|
||||||
|
- Miranda v. Arizona
|
||||||
|
- Brown v. Board of Education
|
||||||
|
- Se modelaron campos nuevos:
|
||||||
|
- `summaryEs`
|
||||||
|
- `legalOutcomeEs`
|
||||||
|
- `category`
|
||||||
|
- `difficulty`
|
||||||
|
- `keyTerms` estructurado (termino + definicion)
|
||||||
|
- `quizPrompt`
|
||||||
|
- Se actualizaron lista y detalle para mostrar contenido legal en espanol.
|
||||||
|
|
||||||
|
Archivos:
|
||||||
|
- `types/caseStudy.ts`
|
||||||
|
- `lib/data/mockCaseStudies.ts`
|
||||||
|
- `app/(public)/case-studies/page.tsx`
|
||||||
|
- `app/(public)/case-studies/[slug]/page.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3) Cambios de datos (Prisma schema)
|
||||||
|
|
||||||
|
Se actualizaron modelos en `prisma/schema.prisma`:
|
||||||
|
|
||||||
|
- Nuevos:
|
||||||
|
- `MiniGame`
|
||||||
|
- `MiniGameQuestion`
|
||||||
|
- `MiniGameAttempt`
|
||||||
|
- `StudyRecommendation`
|
||||||
|
- enum `MiniGameDifficulty`
|
||||||
|
|
||||||
|
- Ajustes:
|
||||||
|
- `Certificate`:
|
||||||
|
- `certificateNumber` (unique)
|
||||||
|
- `pdfVersion`
|
||||||
|
- `@@unique([userId, courseId])`
|
||||||
|
- `Profile`:
|
||||||
|
- relaciones a mini-games/recomendaciones
|
||||||
|
- `Course`:
|
||||||
|
- relacion inversa `recommendations StudyRecommendation[]` (se corrigio tras error P1012)
|
||||||
|
|
||||||
|
Seed actualizado:
|
||||||
|
- `prisma/seed.ts` para sembrar 3 mini-games y preguntas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4) Node 24+ / Prisma / Supabase (incidentes y fix)
|
||||||
|
|
||||||
|
## 4.1 Problema detectado
|
||||||
|
- En Node `18.19.1`, Prisma CLI (`v7.3.0`) fallaba con:
|
||||||
|
- `ERR_REQUIRE_ESM` (zeptomatch / @prisma/dev)
|
||||||
|
- Resultado:
|
||||||
|
- no funcionaba correctamente `prisma generate/migrate`.
|
||||||
|
|
||||||
|
## 4.2 Fix aplicado
|
||||||
|
- Se trabajo con Node `24.14.0`.
|
||||||
|
- Se agrego `.nvmrc` con `24`.
|
||||||
|
- Se reinstalaron dependencias (`npm ci`).
|
||||||
|
- Se agregaron scripts Prisma en `package.json`:
|
||||||
|
- `prisma:generate`
|
||||||
|
- `prisma:status`
|
||||||
|
- `prisma:migrate:dev`
|
||||||
|
- `prisma:migrate:deploy`
|
||||||
|
- `prisma:seed`
|
||||||
|
- Se corrigio `prisma.config.ts` seed command:
|
||||||
|
- `npx ts-node prisma/seed.ts`
|
||||||
|
|
||||||
|
## 4.3 Baseline y drift en Supabase
|
||||||
|
- No existia `prisma/migrations` historico en repo.
|
||||||
|
- Se intento baseline inicial con comando viejo (`--to-url`) y genero SQL vacio.
|
||||||
|
- Se corrigio usando Prisma 7:
|
||||||
|
- `--to-config-datasource`
|
||||||
|
- Se genero baseline real (`~8148 bytes`) y se marco aplicado.
|
||||||
|
- Estado final reportado:
|
||||||
|
- `Database schema is up to date!`
|
||||||
|
|
||||||
|
Nota:
|
||||||
|
- Hubo dos carpetas baseline:
|
||||||
|
- `20260225_baseline` (invalida / vacia)
|
||||||
|
- `20260225_baseline_v2` (valida)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5) Archivos nuevos creados
|
||||||
|
|
||||||
|
- `task_implementation.md`
|
||||||
|
- `resumen_cambios.md`
|
||||||
|
- `app/(protected)/my-courses/page.tsx`
|
||||||
|
- `app/(protected)/profile/page.tsx`
|
||||||
|
- `app/(protected)/practice/[slug]/actions.ts`
|
||||||
|
- `app/api/certificates/[id]/pdf/route.ts`
|
||||||
|
- `lib/certificates.ts`
|
||||||
|
- `lib/recommendations.ts`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6) Validaciones ejecutadas
|
||||||
|
|
||||||
|
Se ejecutaron en distintos momentos:
|
||||||
|
- `npm run lint` -> OK
|
||||||
|
- `npx tsc --noEmit` -> OK
|
||||||
|
- `npm run build` -> compilacion/lint/types OK en varios intentos; hubo un fallo intermitente de worker de Next durante static generation sin stack especifico en esta sesion.
|
||||||
|
- `npm run prisma:generate` -> OK (Node 24)
|
||||||
|
- `npm run prisma:status` -> OK tras baseline correcto
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7) Estado funcional actual
|
||||||
|
|
||||||
|
- Certificado automatico: funcionando.
|
||||||
|
- Descarga PDF: funcionando.
|
||||||
|
- Modal de finalizacion + confetti: funcionando.
|
||||||
|
- Profile muestra certificados/recomendaciones: implementado.
|
||||||
|
- My Courses: implementado.
|
||||||
|
- Case Studies actualizados: implementado.
|
||||||
|
|
||||||
|
Observacion de UX:
|
||||||
|
- El diseno del certificado actual es basico/tecnico (no final de branding visual).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8) Implicaciones y pendientes recomendados
|
||||||
|
|
||||||
|
1. Limpiar migracion baseline invalida:
|
||||||
|
- revisar si se debe eliminar `prisma/migrations/20260225_baseline` del historial.
|
||||||
|
|
||||||
|
2. Confirmar estrategia de migraciones por entorno:
|
||||||
|
- `migrate dev` solo en DB de desarrollo.
|
||||||
|
- `migrate deploy` en staging/prod.
|
||||||
|
|
||||||
|
3. Mejorar UI de certificado/modal:
|
||||||
|
- plantilla de certificado con branding final.
|
||||||
|
- polish de animacion/confetti.
|
||||||
|
|
||||||
|
4. QA end-to-end:
|
||||||
|
- flujo completo desde completar curso hasta ver certificado en `Profile` y `My Courses`.
|
||||||
|
|
||||||
|
5. Verificar consistencia en Supabase:
|
||||||
|
- mantener `DATABASE_URL` (pooler) para runtime app.
|
||||||
|
- mantener `DIRECT_URL` (5432) para Prisma migrate.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9) Resumen ejecutivo
|
||||||
|
- Se implementaron todos los ejes funcionales del backlog.
|
||||||
|
- Se desbloqueo el problema estructural de Prisma (Node 18 -> Node 24).
|
||||||
|
- Se normalizo flujo de migraciones con baseline real en Supabase.
|
||||||
|
- Ya existe flujo real de completion + certificado + descarga + visibilidad en perfil/cursos.
|
||||||
|
|
||||||
|
# Daniel
|
||||||
|
|
||||||
|
## Resumen de cambios (sesión 25/02/26)
|
||||||
|
|
||||||
|
### 1) Catálogo y publicación de cursos
|
||||||
|
- **Problema:** Los cursos publicados no aparecían de inmediato en el catálogo y hacía falta refrescar la página a mano.
|
||||||
|
- **Causa:** No se revalidaba la ruta pública `/courses` al guardar un curso.
|
||||||
|
- **Solución:** En la acción `updateCourse` se agregó `revalidatePath("/courses")` y revalidación con `"page"` y `"layout"` para la página de edición del curso, para que el catálogo y el formulario de edición muestren datos actualizados sin refrescar.
|
||||||
|
|
||||||
|
### 2) Estado y nivel del curso que volvían a valores anteriores al guardar
|
||||||
|
- **Problema:** Al cambiar Estado (Borrador → Publicado) o Nivel (p. ej. Intermedio → Principiante) y dar "Guardar Cambios", el formulario seguía mostrando el valor anterior hasta hacer un refresco manual.
|
||||||
|
- **Causas:** (1) Los inputs de nivel y estado son no controlados (`defaultValue`); React no actualiza el valor en re-renders. (2) `router.refresh()` a veces seguía sirviendo una versión en caché del RSC.
|
||||||
|
- **Soluciones:**
|
||||||
|
- En la página de edición del curso se puso `export const dynamic = "force-dynamic"` para no cachear la ruta.
|
||||||
|
- Al componente `TeacherEditCourseForm` se le pasó un `key` que incluye `course.updatedAt` para forzar un nuevo montaje cuando cambian los datos del curso y así aplicar los nuevos `defaultValue`.
|
||||||
|
- Tras guardar con éxito se reemplazó `router.refresh()` por `router.push(\`/teacher/courses/${course.slug}/edit\`)` para forzar una navegación y cargar datos frescos.
|
||||||
|
- **Archivos:** `app/(protected)/teacher/actions.ts`, `app/(protected)/teacher/courses/[slug]/edit/page.tsx`, `components/teacher/TeacherEditCourseForm.tsx`.
|
||||||
|
|
||||||
|
### 3) Vista previa gratuita para usuarios sin sesión
|
||||||
|
- **Objetivo:** Que los usuarios no autenticados puedan ver lecciones marcadas como "free preview" y que en la ficha del curso se indique cuáles son y se ofrezca un CTA para verlas.
|
||||||
|
- **Cambios:**
|
||||||
|
- Se movió la ruta de aprendizaje de **`app/(protected)/courses/[slug]/learn`** a **`app/(public)/courses/[slug]/learn`** (page + actions) para que no quede detrás de un layout que exija autenticación.
|
||||||
|
- Se actualizó el import en `StudentClassroomClient` para usar las acciones en la nueva ruta pública.
|
||||||
|
- En la página de detalle del curso (landing):
|
||||||
|
- Se incluyó `isFreePreview` en la consulta de lecciones.
|
||||||
|
- En "Course structure preview" se muestra un indicador "Free preview" (pill amarillo) en las lecciones que lo son.
|
||||||
|
- Para invitados: si hay al menos una lección en free preview, se muestra el botón principal "Watch free preview" con enlace a `/courses/[slug]/learn?lesson=[id]`, y como secundario "Login to unlock all lessons" con `redirectTo=/courses/[slug]` para que tras iniciar sesión vuelvan a la ficha y puedan inscribirse/comprar.
|
||||||
|
- **Archivos:** `app/(public)/courses/[slug]/learn/page.tsx`, `app/(public)/courses/[slug]/learn/actions.ts`, `app/(public)/courses/[slug]/page.tsx`, `components/courses/StudentClassroomClient.tsx`. Eliminados los archivos bajo `app/(protected)/courses/[slug]/learn/`.
|
||||||
|
|
||||||
|
### 4) Botón "Editar Título" en módulos
|
||||||
|
- **Problema:** El botón "Editar Título" en cada módulo del plan de estudios no hacía nada.
|
||||||
|
- **Solución:**
|
||||||
|
- Nueva acción de servidor `updateModuleTitle(moduleId, title)` en teacher actions: valida que el módulo sea del profesor, actualiza el título en BD y revalida las rutas correspondientes.
|
||||||
|
- En el formulario de edición del curso: al hacer clic en "Editar Título" se muestra un campo de texto inline con el título actual y botones "Guardar" y "Cancelar"; Enter guarda, Escape cancela. Tras guardar se navega a la misma página de edición para ver el título actualizado.
|
||||||
|
- **Archivos:** `app/(protected)/teacher/actions.ts`, `components/teacher/TeacherEditCourseForm.tsx`.
|
||||||
93
task_implementation.md
Normal file
93
task_implementation.md
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
# ACVE Task Implementation
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- Add 3 mini-games with persisted scoring and profile grading.
|
||||||
|
- Add deterministic study recommendations from user profile/progress.
|
||||||
|
- Auto-issue completion certificates and support PDF download.
|
||||||
|
- Add enrolled-courses view (`/my-courses`).
|
||||||
|
- Show `Continue` instead of `Start Course` when user is enrolled.
|
||||||
|
- Update case studies with Andres source content (`case_studies.txt`).
|
||||||
|
|
||||||
|
## Implemented Items
|
||||||
|
|
||||||
|
## 1. Mini-games + profile grade
|
||||||
|
- Expanded interactive modules to 3 in `lib/data/mockPractice.ts`.
|
||||||
|
- Added DB persistence for attempts via:
|
||||||
|
- `app/(protected)/practice/[slug]/actions.ts`
|
||||||
|
- `submitPracticeAttempt`
|
||||||
|
- `getPracticeAttempts`
|
||||||
|
- Replaced localStorage attempt history in `app/(protected)/practice/[slug]/page.tsx`.
|
||||||
|
- Added profile grade computation in `lib/recommendations.ts#getMiniGameGrade`.
|
||||||
|
|
||||||
|
## 2. Recommendations
|
||||||
|
- Added deterministic recommendation engine in `lib/recommendations.ts`:
|
||||||
|
- `refreshStudyRecommendations`
|
||||||
|
- `getActiveRecommendations`
|
||||||
|
- Recommendations refresh after:
|
||||||
|
- mini-game submission
|
||||||
|
- lesson completion toggle
|
||||||
|
- Recommendations are displayed on `/profile`.
|
||||||
|
|
||||||
|
## 3. Certificates + PDF
|
||||||
|
- Added certificate issuance utility in `lib/certificates.ts`:
|
||||||
|
- `issueCertificateIfEligible`
|
||||||
|
- `buildCertificatePdf`
|
||||||
|
- Hooked issuance after lesson completion in:
|
||||||
|
- `app/(protected)/courses/[slug]/learn/actions.ts`
|
||||||
|
- Added PDF endpoint:
|
||||||
|
- `app/api/certificates/[id]/pdf/route.ts`
|
||||||
|
|
||||||
|
## 4. Enrolled courses view
|
||||||
|
- Added protected page:
|
||||||
|
- `app/(protected)/my-courses/page.tsx`
|
||||||
|
- Displays enrolled courses, progress, continue/review CTA, and certificate download CTA.
|
||||||
|
|
||||||
|
## 5. Start vs Continue
|
||||||
|
- Updated course detail CTA in:
|
||||||
|
- `app/(public)/courses/[slug]/page.tsx`
|
||||||
|
- Logic:
|
||||||
|
- enrolled user => `Continue`
|
||||||
|
- not enrolled => `Start Course`
|
||||||
|
- unauthenticated => `Login to start`
|
||||||
|
|
||||||
|
## 6. Case studies update
|
||||||
|
- Expanded case-study type model:
|
||||||
|
- `types/caseStudy.ts`
|
||||||
|
- Replaced mock cases with:
|
||||||
|
- Marbury v. Madison
|
||||||
|
- Miranda v. Arizona
|
||||||
|
- Brown v. Board of Education
|
||||||
|
- Updated pages to render:
|
||||||
|
- resumen
|
||||||
|
- terminos juridicos con definiciones
|
||||||
|
- resultado juridico
|
||||||
|
- "Ponte a prueba"
|
||||||
|
- Files:
|
||||||
|
- `lib/data/mockCaseStudies.ts`
|
||||||
|
- `app/(public)/case-studies/page.tsx`
|
||||||
|
- `app/(public)/case-studies/[slug]/page.tsx`
|
||||||
|
|
||||||
|
## Navigation updates
|
||||||
|
- Added authenticated links in navbar:
|
||||||
|
- `/my-courses`
|
||||||
|
- `/profile`
|
||||||
|
- File:
|
||||||
|
- `components/Navbar.tsx`
|
||||||
|
|
||||||
|
## Prisma changes (schema)
|
||||||
|
- Updated `prisma/schema.prisma` with:
|
||||||
|
- `MiniGame`, `MiniGameQuestion`, `MiniGameAttempt`, `StudyRecommendation`
|
||||||
|
- `Certificate.certificateNumber`, `Certificate.pdfVersion`
|
||||||
|
- `@@unique([userId, courseId])` on `Certificate`
|
||||||
|
- Updated `prisma/seed.ts` to seed 3 mini-games + questions.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
- Ran: `npm run lint` (passes; one pre-existing warning remains in `lib/auth/requireTeacher.ts`).
|
||||||
|
|
||||||
|
## Required follow-up (environment)
|
||||||
|
- Prisma client generation failed in this environment due Node runtime mismatch (`Node v18`, project expects `>=24`).
|
||||||
|
- Before production use of new DB-backed features, run:
|
||||||
|
1. Node 24+ runtime
|
||||||
|
2. `npx prisma generate`
|
||||||
|
3. apply migration for schema updates
|
||||||
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,12 +1,20 @@
|
|||||||
import type { CourseLevel } from "./course";
|
import type { CourseLevel } from "./course";
|
||||||
|
|
||||||
|
export type CaseTerm = {
|
||||||
|
term: string;
|
||||||
|
definitionEs: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type CaseStudy = {
|
export type CaseStudy = {
|
||||||
slug: string;
|
slug: string;
|
||||||
title: string;
|
title: string;
|
||||||
citation: string;
|
citation: string;
|
||||||
year: number;
|
year: number;
|
||||||
summary: string;
|
summaryEs: string;
|
||||||
|
legalOutcomeEs: string;
|
||||||
level: CourseLevel;
|
level: CourseLevel;
|
||||||
topic: string;
|
category: string;
|
||||||
keyTerms: string[];
|
difficulty: string;
|
||||||
|
keyTerms: CaseTerm[];
|
||||||
|
quizPrompt: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ export type PracticeModule = {
|
|||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
isInteractive: boolean;
|
isInteractive: boolean;
|
||||||
|
difficulty?: "Beginner" | "Intermediate" | "Advanced";
|
||||||
questions?: PracticeQuestion[];
|
questions?: PracticeQuestion[];
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user