Pending course, rest ready for launch

This commit is contained in:
Marcelo
2026-03-15 13:52:11 +00:00
parent be4ca2ed78
commit 62b3cfe467
77 changed files with 6450 additions and 868 deletions

View File

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