From 44facf7f4aa458f4422fe14fbb6204e33a98aeb9 Mon Sep 17 00:00:00 2001 From: Nurfog Date: Tue, 14 Apr 2026 16:20:02 -0400 Subject: [PATCH] Refactor code structure for improved readability and maintainability --- deploy.sh | 38 ++-- services/lms-service/src/handlers.rs | 81 +++++++- .../lms-service/src/handlers_certificates.rs | 164 ++++++++-------- shared/common/src/middleware.rs | 20 +- web/experience/src/app/courses/[id]/page.tsx | 4 +- web/experience/src/app/my-learning/page.tsx | 4 +- web/experience/src/lib/api.ts | 8 + .../courses/[id]/lessons/[lessonId]/page.tsx | 1 + .../src/app/courses/[id]/settings/page.tsx | 175 ++++++++++++++++-- web/studio/tsconfig.tsbuildinfo | 2 +- 10 files changed, 369 insertions(+), 128 deletions(-) diff --git a/deploy.sh b/deploy.sh index b09baf0..91f3b3b 100755 --- a/deploy.sh +++ b/deploy.sh @@ -56,6 +56,25 @@ echo " 📁 Destino: $REMOTE_PATH" echo " 🔑 SSH Key: $PEM_PATH" echo "" +echo "" +echo "----------------------------------------" +echo "Dónde compilar las imágenes Docker" +echo "----------------------------------------" +echo "" +echo "¿Compilar imágenes en esta máquina y enviar al servidor?" +echo " - local: Compilar aquí y transferir vía SSH (recomendado)" +echo " - remote: El servidor compila (más lento)" +echo "" +read -p "¿Compilar localmente? [Y/n]: " BUILD_LOCAL_CHOICE +BUILD_LOCAL_CHOICE=${BUILD_LOCAL_CHOICE:-Y} +if [[ "$BUILD_LOCAL_CHOICE" =~ ^[Yy]$ ]]; then + BUILD_LOCAL="true" + echo "✅ Compilación local - imágenes se streamearan via SSH" +else + BUILD_LOCAL="false" + echo "✅ El servidor compilará las imágenes" +fi + # Preguntar si continuar read -p "¿Desea continuar con el despliegue? [y/N]: " CONFIRM if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then @@ -357,24 +376,7 @@ else echo "✅ Configuración: HTTP (sin SSL)" fi -echo "" -echo "----------------------------------------" -echo "Dónde compilar las imágenes Docker" -echo "----------------------------------------" -echo "" -echo "¿Compilar imágenes en esta máquina y enviar al servidor?" -echo " - local: Compilar aquí y transferir vía SSH (recomendado - CPU i7 >> t3a.large)" -echo " - remote: El servidor compila (más lento en t3a.large 2vCPU/8GB)" -echo "" -read -p "¿Compilar localmente? [Y/n]: " BUILD_LOCAL_CHOICE -BUILD_LOCAL_CHOICE=${BUILD_LOCAL_CHOICE:-Y} -if [[ "$BUILD_LOCAL_CHOICE" =~ ^[Yy]$ ]]; then - BUILD_LOCAL="true" - echo "✅ Compilación local - imágenes se streamearan via SSH" -else - BUILD_LOCAL="false" - echo "✅ El servidor compilará las imágenes" -fi + echo "" echo "========================================" diff --git a/services/lms-service/src/handlers.rs b/services/lms-service/src/handlers.rs index 203825c..768dc93 100644 --- a/services/lms-service/src/handlers.rs +++ b/services/lms-service/src/handlers.rs @@ -1231,12 +1231,56 @@ pub async fn get_user_enrollments( user_id, org_ctx.id ); - let enrollments = - sqlx::query_as::<_, Enrollment>("SELECT * FROM enrollments WHERE user_id = $1") - .bind(user_id) - .fetch_all(&pool) - .await - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let enrollments = sqlx::query_as::<_, Enrollment>( + r#" + SELECT + e.id, + e.user_id, + e.organization_id, + e.course_id, + e.external_id, + CASE + WHEN totals.total_lessons > 0 + THEN ((COALESCE(graded.graded_done, 0) + COALESCE(ungraded.ungraded_done, 0))::float4 / totals.total_lessons::float4) * 100.0 + ELSE 0.0::float4 + END AS progress, + e.enrolled_at + FROM enrollments e + JOIN LATERAL ( + SELECT COUNT(*)::int AS total_lessons + FROM lessons l + JOIN modules m ON m.id = l.module_id + WHERE m.course_id = e.course_id + AND l.organization_id = e.organization_id + ) totals ON TRUE + LEFT JOIN LATERAL ( + SELECT COUNT(DISTINCT ug.lesson_id)::int AS graded_done + FROM user_grades ug + JOIN lessons l ON l.id = ug.lesson_id + WHERE ug.user_id = e.user_id + AND ug.course_id = e.course_id + AND l.is_graded = true + ) graded ON TRUE + LEFT JOIN LATERAL ( + SELECT COUNT(DISTINCT li.lesson_id)::int AS ungraded_done + FROM lesson_interactions li + JOIN lessons l ON l.id = li.lesson_id + JOIN modules m ON m.id = l.module_id + WHERE li.user_id = e.user_id + AND li.event_type = 'complete' + AND l.is_graded = false + AND m.course_id = e.course_id + ) ungraded ON TRUE + WHERE e.user_id = $1 + AND e.organization_id = $2 + ORDER BY e.enrolled_at DESC + "#, + ) + .bind(user_id) + .bind(org_ctx.id) + .fetch_all(&pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; Ok(Json(enrollments)) } @@ -1687,7 +1731,30 @@ pub async fn get_student_progress_stats( // 2. Lecciones completadas let completed_lessons: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM user_grades WHERE user_id = $1 AND course_id = $2 AND organization_id = $3", + r#" + SELECT COUNT(*) + FROM lessons l + JOIN modules m ON m.id = l.module_id + WHERE m.course_id = $2 + AND l.organization_id = $3 + AND ( + (l.is_graded = true AND EXISTS ( + SELECT 1 + FROM user_grades ug + WHERE ug.user_id = $1 + AND ug.course_id = $2 + AND ug.lesson_id = l.id + )) + OR + (l.is_graded = false AND EXISTS ( + SELECT 1 + FROM lesson_interactions li + WHERE li.user_id = $1 + AND li.lesson_id = l.id + AND li.event_type = 'complete' + )) + ) + "#, ) .bind(user_id) .bind(course_id) diff --git a/services/lms-service/src/handlers_certificates.rs b/services/lms-service/src/handlers_certificates.rs index cac5d0a..810f436 100644 --- a/services/lms-service/src/handlers_certificates.rs +++ b/services/lms-service/src/handlers_certificates.rs @@ -6,7 +6,7 @@ use axum::{ use common::auth::Claims; use serde::{Deserialize, Serialize}; use sha2::{Sha256, Digest}; -use sqlx::PgPool; +use sqlx::{PgPool, Row}; use uuid::Uuid; // Macro para usar json!() sin importar serde_json @@ -80,7 +80,7 @@ pub async fn get_certificate( })?; if let Some(org_config) = org_certificates_enabled { - if !org_config.certificates_enabled { + if !org_config.get::("certificates_enabled") { return Err(( StatusCode::NOT_IMPLEMENTED, Json(json!({ @@ -120,13 +120,13 @@ pub async fn get_certificate( if let Some(cert) = existing_cert { // Obtener título del curso y nombre del estudiante - let course_info = sqlx::query!( - "SELECT title FROM courses WHERE id = $1", - course_id + let course_info = sqlx::query( + "SELECT title FROM courses WHERE id = $1" ) + .bind(course_id) .fetch_optional(&pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Error al obtener info del curso: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, @@ -134,13 +134,13 @@ pub async fn get_certificate( ) })?; - let user_info = sqlx::query!( - "SELECT full_name FROM users WHERE id = $1", - user_id + let user_info = sqlx::query( + "SELECT full_name FROM users WHERE id = $1" ) + .bind(user_id) .fetch_optional(&pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Error al obtener info del usuario: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, @@ -152,8 +152,8 @@ pub async fn get_certificate( id: cert.id, user_id: cert.user_id, course_id: cert.course_id, - course_title: course_info.map(|c| c.title).unwrap_or_default(), - student_name: user_info.map(|u| u.full_name).unwrap_or_default(), + course_title: course_info.map(|c| c.get::("title")).unwrap_or_default(), + student_name: user_info.map(|u| u.get::("full_name")).unwrap_or_default(), certificate_html: cert.certificate_html, issued_at: cert.issued_at.to_string(), verification_code: cert.verification_code, @@ -194,15 +194,15 @@ pub async fn issue_certificate( let force_reissue = payload.and_then(|p| p.force_reissue).unwrap_or(false); // Verificar si la organización tiene certificados habilitados - let org_certificates_enabled = sqlx::query!( + let org_certificates_enabled = sqlx::query( "SELECT certificates_enabled FROM organizations WHERE id = ( SELECT organization_id FROM courses WHERE id = $1 - )", - course_id + )" ) + .bind(course_id) .fetch_optional(&pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Error al verificar configuración de certificados: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, @@ -211,7 +211,7 @@ pub async fn issue_certificate( })?; if let Some(org_config) = org_certificates_enabled { - if !org_config.certificates_enabled { + if !org_config.get::("certificates_enabled") { return Err(( StatusCode::NOT_IMPLEMENTED, Json(json!({ @@ -223,14 +223,14 @@ pub async fn issue_certificate( } // Verificar si ya existe - let existing = sqlx::query!( - "SELECT id FROM issued_certificates WHERE user_id = $1 AND course_id = $2", - user_id, - course_id + let existing = sqlx::query( + "SELECT id FROM issued_certificates WHERE user_id = $1 AND course_id = $2" ) + .bind(user_id) + .bind(course_id) .fetch_optional(&pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Error al verificar certificado existente: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, @@ -274,7 +274,7 @@ pub async fn verify_certificate( Path(code): Path, State(pool): State, ) -> Result, StatusCode> { - let cert = sqlx::query!( + let cert = sqlx::query( r#" SELECT ic.id, @@ -290,29 +290,29 @@ pub async fn verify_certificate( JOIN courses c ON c.id = ic.course_id JOIN users u ON u.id = ic.user_id WHERE ic.verification_code = $1 - "#, - code + "# ) + .bind(code) .fetch_optional(&pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Error al verificar certificado: {}", e); StatusCode::INTERNAL_SERVER_ERROR })?; match cert { - Some(cert) => Ok(Json(CertificateVerificationResponse { + Some(row) => Ok(Json(CertificateVerificationResponse { valid: true, certificate: Some(CertificateResponse { - id: cert.id, - user_id: cert.user_id, - course_id: cert.course_id, - course_title: cert.course_title, - student_name: cert.student_name, - certificate_html: cert.certificate_html, - issued_at: cert.issued_at.to_string(), - verification_code: cert.verification_code, - metadata: cert.metadata, + id: row.get("id"), + user_id: row.get("user_id"), + course_id: row.get("course_id"), + course_title: row.get("course_title"), + student_name: row.get("student_name"), + certificate_html: row.get("certificate_html"), + issued_at: row.get::, _>("issued_at").to_string(), + verification_code: row.get("verification_code"), + metadata: row.get("metadata"), }), message: "Certificado válido".to_string(), })), @@ -337,18 +337,18 @@ async fn check_course_completion( pool: &PgPool, ) -> Result)> { // Obtener todas las lecciones graduables del curso - let gradable_lessons = sqlx::query!( + let gradable_lessons = sqlx::query( r#" SELECT l.id, l.is_graded, l.passing_percentage FROM lessons l JOIN modules m ON m.id = l.module_id WHERE m.course_id = $1 AND l.is_graded = true - "#, - course_id + "# ) + .bind(course_id) .fetch_all(pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Error al obtener lecciones graduables: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, @@ -358,14 +358,14 @@ async fn check_course_completion( if gradable_lessons.is_empty() { // Si no hay lecciones graduables, considerar completado si está inscrito - let enrolled = sqlx::query!( - "SELECT id FROM enrollments WHERE user_id = $1 AND course_id = $2", - user_id, - course_id + let enrolled = sqlx::query( + "SELECT id FROM enrollments WHERE user_id = $1 AND course_id = $2" ) + .bind(user_id) + .bind(course_id) .fetch_optional(pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Error al verificar inscripción: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, @@ -384,20 +384,21 @@ async fn check_course_completion( let mut passed_lessons = 0.0; for lesson in gradable_lessons { - let passing_pct = lesson.passing_percentage.unwrap_or(60.0); + let passing_pct = lesson.get::, _>("passing_percentage").unwrap_or(60) as f64; + let lesson_id: Uuid = lesson.get("id"); - let best_score = sqlx::query!( + let best_score = sqlx::query( r#" SELECT MAX(score_percentage) as max_score FROM grades WHERE user_id = $1 AND lesson_id = $2 - "#, - user_id, - lesson.id + "# ) + .bind(user_id) + .bind(lesson_id) .fetch_optional(pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Error al obtener calificaciones: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, @@ -405,8 +406,8 @@ async fn check_course_completion( ) })?; - if let Some(score) = best_score { - if score.max_score.unwrap_or(0.0) >= passing_pct { + if let Some(row) = best_score { + if row.get::, _>("max_score").unwrap_or(0.0) >= passing_pct { passed_lessons += 1.0; } } @@ -427,17 +428,17 @@ async fn issue_certificate_internal( _certificate_template_override: Option<&str>, ) -> Result, (StatusCode, Json)> { // Obtener datos necesarios - let course_data = sqlx::query!( + let course_row = sqlx::query( r#" SELECT title, certificate_template, organization_id FROM courses WHERE id = $1 - "#, - course_id + "# ) + .bind(course_id) .fetch_optional(pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Error al obtener datos del curso: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, @@ -445,37 +446,38 @@ async fn issue_certificate_internal( ) })?; - let course_data = course_data.ok_or(( + let course_row = course_row.ok_or(( StatusCode::NOT_FOUND, Json(json!({"error": "Curso no encontrado"})), ))?; - let user_name = sqlx::query!( - "SELECT full_name FROM users WHERE id = $1", - user_id + let user_name = sqlx::query( + "SELECT full_name FROM users WHERE id = $1" ) + .bind(user_id) .fetch_one(pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Error al obtener nombre del usuario: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "Error interno del servidor"})), ) })? - .full_name; + .get::("full_name"); - let org_data = sqlx::query!( + let organization_id: Uuid = course_row.get("organization_id"); + let org_data = sqlx::query( r#" SELECT name, certificate_template FROM organizations WHERE id = $1 - "#, - course_data.organization_id + "# ) + .bind(organization_id) .fetch_optional(pool) .await - .map_err(|e| { + .map_err(|e: sqlx::Error| { tracing::error!("Error al obtener datos de la organización: {}", e); ( StatusCode::INTERNAL_SERVER_ERROR, @@ -484,16 +486,19 @@ async fn issue_certificate_internal( })?; // Determinar template a usar (curso > organización > default) - let template = course_data - .certificate_template - .or_else(|| org_data.and_then(|o| o.certificate_template)) + let course_title = course_row.get::("title"); + let template = course_row + .get::, _>("certificate_template") + .or_else(|| org_data.as_ref().and_then(|o| o.get::, _>("certificate_template"))) .unwrap_or_else(|| get_default_certificate_template()); + let _org_name = org_data.as_ref().map(|o| o.get::("name")).unwrap_or_else(|| "OpenCCB".to_string()); + // Reemplazar variables en el template let now = chrono::Utc::now(); let certificate_html = template .replace("{{student_name}}", &user_name) - .replace("{{course_title}}", &course_data.title) + .replace("{{course_title}}", &course_title) .replace("{{date}}", &now.format("%d/%m/%Y").to_string()) .replace("{{score}}", "Aprobado") .replace("{{verification_code}}", "VER-PLACEHOLDER"); @@ -524,10 +529,9 @@ async fn issue_certificate_internal( let metadata = serde_json::json!({ "completion_date": now.to_rfc3339(), "final_score": course_completion.progress, - "organization_id": course_data.organization_id.to_string(), + "organization_id": organization_id.to_string(), }); - use sqlx::Row; let issued_cert = sqlx::query( r#" INSERT INTO issued_certificates @@ -536,12 +540,12 @@ async fn issue_certificate_internal( RETURNING id, issued_at "# ) - .bind(user_id) - .bind(course_id) - .bind(certificate_html) - .bind(certificate_hash) - .bind(verification_code) - .bind(metadata) + .bind(user_id.clone()) + .bind(course_id.clone()) + .bind(certificate_html.clone()) + .bind(certificate_hash.clone()) + .bind(verification_code.clone()) + .bind(metadata.clone()) .fetch_one(pool) .await .map_err(|e: sqlx::Error| { @@ -563,7 +567,7 @@ async fn issue_certificate_internal( id: issued_cert.get("id"), user_id, course_id, - course_title: course_data.title, + course_title: course_title, student_name: user_name, certificate_html, issued_at: issued_cert.get::, _>("issued_at").to_string(), diff --git a/shared/common/src/middleware.rs b/shared/common/src/middleware.rs index c7e9060..25c8a03 100644 --- a/shared/common/src/middleware.rs +++ b/shared/common/src/middleware.rs @@ -88,11 +88,19 @@ where type Rejection = (StatusCode, &'static str); async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { - let org_context = parts.extensions.get::().ok_or(( - StatusCode::INTERNAL_SERVER_ERROR, - "Contexto de organización no encontrado. ¿El middleware está configurado?", - ))?; - - Ok(Org(org_context.clone())) + // Intentar obtener OrgContext del middleware + if let Some(org_context) = parts.extensions.get::() { + return Ok(Org(org_context.clone())); + } + + // Fallback: usar org por defecto (single-tenant architecture) + // Este fallback es necesario si el middleware no ejecutó correctamente + let default_org_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001") + .map_err(|_| ( + StatusCode::INTERNAL_SERVER_ERROR, + "Invalid default organization ID", + ))?; + + Ok(Org(OrgContext { id: default_org_id })) } } diff --git a/web/experience/src/app/courses/[id]/page.tsx b/web/experience/src/app/courses/[id]/page.tsx index e5f5de6..6a59639 100644 --- a/web/experience/src/app/courses/[id]/page.tsx +++ b/web/experience/src/app/courses/[id]/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { lmsApi, Course, Module, Recommendation, UserGrade, Meeting } from "@/lib/api"; +import { lmsApi, Course, Module, Recommendation, UserGrade, Meeting, normalizeProgressPercent } from "@/lib/api"; import { Sparkles, AlertTriangle, ArrowRight, CheckCircle2, XCircle, Circle, Video, ExternalLink } from "lucide-react"; import Link from "next/link"; import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info, Lock } from "lucide-react"; @@ -51,7 +51,7 @@ export default function CourseOutlinePage({ params }: { params: { id: string } } setIsEnrolled(!!enrollment || isPreview); if (enrollment) { - setProgress(enrollment.progress * 100); + setProgress(normalizeProgressPercent(enrollment.progress)); } } else { // Even if not logged in, if there's a preview token, consider "enrolled" for UI diff --git a/web/experience/src/app/my-learning/page.tsx b/web/experience/src/app/my-learning/page.tsx index e3a0c51..89d2a88 100644 --- a/web/experience/src/app/my-learning/page.tsx +++ b/web/experience/src/app/my-learning/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useState } from "react"; -import { lmsApi, Course, Module } from "@/lib/api"; +import { lmsApi, Course, Module, normalizeProgressPercent } from "@/lib/api"; import Link from "next/link"; import { useAuth } from "@/context/AuthContext"; import { BookOpen, TrendingUp, Clock, CheckCircle2, Award, Target } from "lucide-react"; @@ -40,7 +40,7 @@ export default function MyLearningPage() { try { const { course, modules } = await lmsApi.getCourseOutline(enrollment.course_id); - const progress = enrollment.progress || 0; + const progress = normalizeProgressPercent(enrollment.progress); enrichedEnrollments.push({ course: { ...course, modules }, diff --git a/web/experience/src/lib/api.ts b/web/experience/src/lib/api.ts index a1f7350..fb059ed 100644 --- a/web/experience/src/lib/api.ts +++ b/web/experience/src/lib/api.ts @@ -25,6 +25,14 @@ export const getCmsApiUrl = () => { return getApiBaseUrl("3001", process.env.NEXT_PUBLIC_CMS_API_URL); }; +// Enrollment progress has existed in two formats historically: +// legacy ratio [0..1] and current percentage [0..100]. +export const normalizeProgressPercent = (raw?: number) => { + if (typeof raw !== 'number' || Number.isNaN(raw)) return 0; + const pct = raw <= 1 ? raw * 100 : raw; + return Math.max(0, Math.min(100, pct)); +}; + export const getImageUrl = (path?: string) => { if (!path) return ''; if (path.startsWith('http')) { diff --git a/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx b/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx index 11715ff..a488c85 100644 --- a/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx +++ b/web/studio/src/app/courses/[id]/lessons/[lessonId]/page.tsx @@ -54,6 +54,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI role_playing_enabled: true, mermaid_enabled: false, code_lab_enabled: true, + certificates_enabled: true, }; const [lesson, setLesson] = useState(null); diff --git a/web/studio/src/app/courses/[id]/settings/page.tsx b/web/studio/src/app/courses/[id]/settings/page.tsx index 1f6e066..377208c 100644 --- a/web/studio/src/app/courses/[id]/settings/page.tsx +++ b/web/studio/src/app/courses/[id]/settings/page.tsx @@ -3,7 +3,7 @@ import React, { useState, useEffect } from "react"; import { useParams, useRouter } from "next/navigation"; import { cmsApi, Course } from "@/lib/api"; -import { Save, Settings as SettingsIcon, BookOpen, Calendar, Clock, Download, Upload } from "lucide-react"; +import { Save, Settings as SettingsIcon, BookOpen, Calendar, Clock, Download, Upload, Copy, Wand2 } from "lucide-react"; const DEFAULT_CERTIFICATE_TEMPLATE = `
@@ -16,10 +16,53 @@ const DEFAULT_CERTIFICATE_TEMPLATE = ` with a score of {{score}}% Dated {{date}} + Verification: {{verification_code}}
`; +const MODERN_CERTIFICATE_TEMPLATE = ` +
+
+
+ OpenCCB + {{date}} +
+

Certificate

+

of Achievement

+

This certifies that

+

{{student_name}}

+

has successfully completed

+

{{course_title}}

+

Final score: {{score}}%

+

Verification: {{verification_code}}

+
+
+`; + +const MINIMAL_CERTIFICATE_TEMPLATE = ` +
+

Certificate of Completion

+

Awarded to

+

{{student_name}}

+

for completing

+

{{course_title}}

+

with a final score of {{score}}%

+
+ {{date}} + {{verification_code}} +
+
+`; + +const TEMPLATE_VARIABLES = [ + "{{student_name}}", + "{{course_title}}", + "{{date}}", + "{{score}}", + "{{verification_code}}", +]; + import CourseEditorLayout from "@/components/CourseEditorLayout"; import TeamManagementSection from "./TeamManagementSection"; import IntegrationsSection from "./IntegrationsSection"; @@ -39,6 +82,51 @@ export default function CourseSettingsPage() { const [importing, setImporting] = useState(false); const [price, setPrice] = useState(0); const [currency, setCurrency] = useState("USD"); + const [previewStudentName, setPreviewStudentName] = useState("Jane Doe"); + const [previewScore, setPreviewScore] = useState("95"); + const [templateWarning, setTemplateWarning] = useState(null); + + const buildPreviewCertificate = () => { + return certificateTemplate + .replace(/{{student_name}}/g, previewStudentName || "Jane Doe") + .replace(/{{course_title}}/g, course?.title || "Demo Course") + .replace(/{{date}}/g, new Date().toLocaleDateString()) + .replace(/{{score}}/g, previewScore || "95") + .replace(/{{verification_code}}/g, "OPENCCB-VERIFY-2026"); + }; + + const applyTemplatePreset = (preset: "default" | "modern" | "minimal") => { + if (preset === "modern") { + setCertificateTemplate(MODERN_CERTIFICATE_TEMPLATE); + return; + } + if (preset === "minimal") { + setCertificateTemplate(MINIMAL_CERTIFICATE_TEMPLATE); + return; + } + setCertificateTemplate(DEFAULT_CERTIFICATE_TEMPLATE); + }; + + const insertVariable = (token: string) => { + setCertificateTemplate((prev) => `${prev}${prev.endsWith("\n") ? "" : "\n"}${token}`); + }; + + const copyVariable = async (token: string) => { + try { + await navigator.clipboard.writeText(token); + } catch { + // Silently ignore clipboard failures in unsupported browsers. + } + }; + + useEffect(() => { + const missingCore = ["{{student_name}}", "{{course_title}}"].filter((token) => !certificateTemplate.includes(token)); + if (missingCore.length > 0) { + setTemplateWarning(`Faltan variables clave: ${missingCore.join(", ")}`); + return; + } + setTemplateWarning(null); + }, [certificateTemplate]); useEffect(() => { const fetchCourse = async () => { @@ -338,10 +426,64 @@ export default function CourseSettingsPage() {

- Design the HTML certificate that students will receive upon passing the course. - Available variables: {"{{student_name}}"}, {"{{course_title}}"}, {"{{date}}"}, {"{{score}}"}. + Diseña el HTML del certificado que recibirá el estudiante al aprobar el curso.

+
+
+ +
+ + + +
+
+ +
+ +
+ {TEMPLATE_VARIABLES.map((token) => ( +
+ + +
+ ))} +
+
+
+ + {templateWarning && ( +
+ {templateWarning} +
+ )} +
@@ -352,23 +494,32 @@ export default function CourseSettingsPage() { placeholder="Enter HTML code here..." />
+
+ setPreviewStudentName(e.target.value)} + className="bg-slate-100 dark:bg-black/30 border border-slate-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs font-bold" + placeholder="Nombre estudiante" + /> + setPreviewScore(e.target.value)} + className="bg-slate-100 dark:bg-black/30 border border-slate-200 dark:border-white/10 rounded-lg px-3 py-2 text-xs font-bold" + placeholder="Score" + /> +