Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -56,6 +56,25 @@ echo " 📁 Destino: $REMOTE_PATH"
|
|||||||
echo " 🔑 SSH Key: $PEM_PATH"
|
echo " 🔑 SSH Key: $PEM_PATH"
|
||||||
echo ""
|
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
|
# Preguntar si continuar
|
||||||
read -p "¿Desea continuar con el despliegue? [y/N]: " CONFIRM
|
read -p "¿Desea continuar con el despliegue? [y/N]: " CONFIRM
|
||||||
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
|
if [[ ! "$CONFIRM" =~ ^[Yy]$ ]]; then
|
||||||
@@ -357,24 +376,7 @@ else
|
|||||||
echo "✅ Configuración: HTTP (sin SSL)"
|
echo "✅ Configuración: HTTP (sin SSL)"
|
||||||
fi
|
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 ""
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
|
|||||||
@@ -1231,12 +1231,56 @@ pub async fn get_user_enrollments(
|
|||||||
user_id,
|
user_id,
|
||||||
org_ctx.id
|
org_ctx.id
|
||||||
);
|
);
|
||||||
let enrollments =
|
let enrollments = sqlx::query_as::<_, Enrollment>(
|
||||||
sqlx::query_as::<_, Enrollment>("SELECT * FROM enrollments WHERE user_id = $1")
|
r#"
|
||||||
.bind(user_id)
|
SELECT
|
||||||
.fetch_all(&pool)
|
e.id,
|
||||||
.await
|
e.user_id,
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
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))
|
Ok(Json(enrollments))
|
||||||
}
|
}
|
||||||
@@ -1687,7 +1731,30 @@ pub async fn get_student_progress_stats(
|
|||||||
|
|
||||||
// 2. Lecciones completadas
|
// 2. Lecciones completadas
|
||||||
let completed_lessons: i64 = sqlx::query_scalar(
|
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(user_id)
|
||||||
.bind(course_id)
|
.bind(course_id)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use axum::{
|
|||||||
use common::auth::Claims;
|
use common::auth::Claims;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sha2::{Sha256, Digest};
|
use sha2::{Sha256, Digest};
|
||||||
use sqlx::PgPool;
|
use sqlx::{PgPool, Row};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
// Macro para usar json!() sin importar serde_json
|
// 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 let Some(org_config) = org_certificates_enabled {
|
||||||
if !org_config.certificates_enabled {
|
if !org_config.get::<bool, _>("certificates_enabled") {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::NOT_IMPLEMENTED,
|
StatusCode::NOT_IMPLEMENTED,
|
||||||
Json(json!({
|
Json(json!({
|
||||||
@@ -120,13 +120,13 @@ pub async fn get_certificate(
|
|||||||
|
|
||||||
if let Some(cert) = existing_cert {
|
if let Some(cert) = existing_cert {
|
||||||
// Obtener título del curso y nombre del estudiante
|
// Obtener título del curso y nombre del estudiante
|
||||||
let course_info = sqlx::query!(
|
let course_info = sqlx::query(
|
||||||
"SELECT title FROM courses WHERE id = $1",
|
"SELECT title FROM courses WHERE id = $1"
|
||||||
course_id
|
|
||||||
)
|
)
|
||||||
|
.bind(course_id)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Error al obtener info del curso: {}", e);
|
tracing::error!("Error al obtener info del curso: {}", e);
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
@@ -134,13 +134,13 @@ pub async fn get_certificate(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let user_info = sqlx::query!(
|
let user_info = sqlx::query(
|
||||||
"SELECT full_name FROM users WHERE id = $1",
|
"SELECT full_name FROM users WHERE id = $1"
|
||||||
user_id
|
|
||||||
)
|
)
|
||||||
|
.bind(user_id)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Error al obtener info del usuario: {}", e);
|
tracing::error!("Error al obtener info del usuario: {}", e);
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
@@ -152,8 +152,8 @@ pub async fn get_certificate(
|
|||||||
id: cert.id,
|
id: cert.id,
|
||||||
user_id: cert.user_id,
|
user_id: cert.user_id,
|
||||||
course_id: cert.course_id,
|
course_id: cert.course_id,
|
||||||
course_title: course_info.map(|c| c.title).unwrap_or_default(),
|
course_title: course_info.map(|c| c.get::<String, _>("title")).unwrap_or_default(),
|
||||||
student_name: user_info.map(|u| u.full_name).unwrap_or_default(),
|
student_name: user_info.map(|u| u.get::<String, _>("full_name")).unwrap_or_default(),
|
||||||
certificate_html: cert.certificate_html,
|
certificate_html: cert.certificate_html,
|
||||||
issued_at: cert.issued_at.to_string(),
|
issued_at: cert.issued_at.to_string(),
|
||||||
verification_code: cert.verification_code,
|
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);
|
let force_reissue = payload.and_then(|p| p.force_reissue).unwrap_or(false);
|
||||||
|
|
||||||
// Verificar si la organización tiene certificados habilitados
|
// 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 certificates_enabled FROM organizations WHERE id = (
|
||||||
SELECT organization_id FROM courses WHERE id = $1
|
SELECT organization_id FROM courses WHERE id = $1
|
||||||
)",
|
)"
|
||||||
course_id
|
|
||||||
)
|
)
|
||||||
|
.bind(course_id)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Error al verificar configuración de certificados: {}", e);
|
tracing::error!("Error al verificar configuración de certificados: {}", e);
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
@@ -211,7 +211,7 @@ pub async fn issue_certificate(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
if let Some(org_config) = org_certificates_enabled {
|
if let Some(org_config) = org_certificates_enabled {
|
||||||
if !org_config.certificates_enabled {
|
if !org_config.get::<bool, _>("certificates_enabled") {
|
||||||
return Err((
|
return Err((
|
||||||
StatusCode::NOT_IMPLEMENTED,
|
StatusCode::NOT_IMPLEMENTED,
|
||||||
Json(json!({
|
Json(json!({
|
||||||
@@ -223,14 +223,14 @@ pub async fn issue_certificate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verificar si ya existe
|
// Verificar si ya existe
|
||||||
let existing = sqlx::query!(
|
let existing = sqlx::query(
|
||||||
"SELECT id FROM issued_certificates WHERE user_id = $1 AND course_id = $2",
|
"SELECT id FROM issued_certificates WHERE user_id = $1 AND course_id = $2"
|
||||||
user_id,
|
|
||||||
course_id
|
|
||||||
)
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(course_id)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Error al verificar certificado existente: {}", e);
|
tracing::error!("Error al verificar certificado existente: {}", e);
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
@@ -274,7 +274,7 @@ pub async fn verify_certificate(
|
|||||||
Path(code): Path<String>,
|
Path(code): Path<String>,
|
||||||
State(pool): State<PgPool>,
|
State(pool): State<PgPool>,
|
||||||
) -> Result<Json<CertificateVerificationResponse>, StatusCode> {
|
) -> Result<Json<CertificateVerificationResponse>, StatusCode> {
|
||||||
let cert = sqlx::query!(
|
let cert = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
ic.id,
|
ic.id,
|
||||||
@@ -290,29 +290,29 @@ pub async fn verify_certificate(
|
|||||||
JOIN courses c ON c.id = ic.course_id
|
JOIN courses c ON c.id = ic.course_id
|
||||||
JOIN users u ON u.id = ic.user_id
|
JOIN users u ON u.id = ic.user_id
|
||||||
WHERE ic.verification_code = $1
|
WHERE ic.verification_code = $1
|
||||||
"#,
|
"#
|
||||||
code
|
|
||||||
)
|
)
|
||||||
|
.bind(code)
|
||||||
.fetch_optional(&pool)
|
.fetch_optional(&pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Error al verificar certificado: {}", e);
|
tracing::error!("Error al verificar certificado: {}", e);
|
||||||
StatusCode::INTERNAL_SERVER_ERROR
|
StatusCode::INTERNAL_SERVER_ERROR
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
match cert {
|
match cert {
|
||||||
Some(cert) => Ok(Json(CertificateVerificationResponse {
|
Some(row) => Ok(Json(CertificateVerificationResponse {
|
||||||
valid: true,
|
valid: true,
|
||||||
certificate: Some(CertificateResponse {
|
certificate: Some(CertificateResponse {
|
||||||
id: cert.id,
|
id: row.get("id"),
|
||||||
user_id: cert.user_id,
|
user_id: row.get("user_id"),
|
||||||
course_id: cert.course_id,
|
course_id: row.get("course_id"),
|
||||||
course_title: cert.course_title,
|
course_title: row.get("course_title"),
|
||||||
student_name: cert.student_name,
|
student_name: row.get("student_name"),
|
||||||
certificate_html: cert.certificate_html,
|
certificate_html: row.get("certificate_html"),
|
||||||
issued_at: cert.issued_at.to_string(),
|
issued_at: row.get::<chrono::DateTime<chrono::Utc>, _>("issued_at").to_string(),
|
||||||
verification_code: cert.verification_code,
|
verification_code: row.get("verification_code"),
|
||||||
metadata: cert.metadata,
|
metadata: row.get("metadata"),
|
||||||
}),
|
}),
|
||||||
message: "Certificado válido".to_string(),
|
message: "Certificado válido".to_string(),
|
||||||
})),
|
})),
|
||||||
@@ -337,18 +337,18 @@ async fn check_course_completion(
|
|||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
) -> Result<CourseCompletion, (StatusCode, Json<serde_json::Value>)> {
|
) -> Result<CourseCompletion, (StatusCode, Json<serde_json::Value>)> {
|
||||||
// Obtener todas las lecciones graduables del curso
|
// Obtener todas las lecciones graduables del curso
|
||||||
let gradable_lessons = sqlx::query!(
|
let gradable_lessons = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT l.id, l.is_graded, l.passing_percentage
|
SELECT l.id, l.is_graded, l.passing_percentage
|
||||||
FROM lessons l
|
FROM lessons l
|
||||||
JOIN modules m ON m.id = l.module_id
|
JOIN modules m ON m.id = l.module_id
|
||||||
WHERE m.course_id = $1 AND l.is_graded = true
|
WHERE m.course_id = $1 AND l.is_graded = true
|
||||||
"#,
|
"#
|
||||||
course_id
|
|
||||||
)
|
)
|
||||||
|
.bind(course_id)
|
||||||
.fetch_all(pool)
|
.fetch_all(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Error al obtener lecciones graduables: {}", e);
|
tracing::error!("Error al obtener lecciones graduables: {}", e);
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
@@ -358,14 +358,14 @@ async fn check_course_completion(
|
|||||||
|
|
||||||
if gradable_lessons.is_empty() {
|
if gradable_lessons.is_empty() {
|
||||||
// Si no hay lecciones graduables, considerar completado si está inscrito
|
// Si no hay lecciones graduables, considerar completado si está inscrito
|
||||||
let enrolled = sqlx::query!(
|
let enrolled = sqlx::query(
|
||||||
"SELECT id FROM enrollments WHERE user_id = $1 AND course_id = $2",
|
"SELECT id FROM enrollments WHERE user_id = $1 AND course_id = $2"
|
||||||
user_id,
|
|
||||||
course_id
|
|
||||||
)
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(course_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Error al verificar inscripción: {}", e);
|
tracing::error!("Error al verificar inscripción: {}", e);
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
@@ -384,20 +384,21 @@ async fn check_course_completion(
|
|||||||
let mut passed_lessons = 0.0;
|
let mut passed_lessons = 0.0;
|
||||||
|
|
||||||
for lesson in gradable_lessons {
|
for lesson in gradable_lessons {
|
||||||
let passing_pct = lesson.passing_percentage.unwrap_or(60.0);
|
let passing_pct = lesson.get::<Option<i32>, _>("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#"
|
r#"
|
||||||
SELECT MAX(score_percentage) as max_score
|
SELECT MAX(score_percentage) as max_score
|
||||||
FROM grades
|
FROM grades
|
||||||
WHERE user_id = $1 AND lesson_id = $2
|
WHERE user_id = $1 AND lesson_id = $2
|
||||||
"#,
|
"#
|
||||||
user_id,
|
|
||||||
lesson.id
|
|
||||||
)
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(lesson_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Error al obtener calificaciones: {}", e);
|
tracing::error!("Error al obtener calificaciones: {}", e);
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
@@ -405,8 +406,8 @@ async fn check_course_completion(
|
|||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if let Some(score) = best_score {
|
if let Some(row) = best_score {
|
||||||
if score.max_score.unwrap_or(0.0) >= passing_pct {
|
if row.get::<Option<f64>, _>("max_score").unwrap_or(0.0) >= passing_pct {
|
||||||
passed_lessons += 1.0;
|
passed_lessons += 1.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -427,17 +428,17 @@ async fn issue_certificate_internal(
|
|||||||
_certificate_template_override: Option<&str>,
|
_certificate_template_override: Option<&str>,
|
||||||
) -> Result<Json<CertificateResponse>, (StatusCode, Json<serde_json::Value>)> {
|
) -> Result<Json<CertificateResponse>, (StatusCode, Json<serde_json::Value>)> {
|
||||||
// Obtener datos necesarios
|
// Obtener datos necesarios
|
||||||
let course_data = sqlx::query!(
|
let course_row = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT title, certificate_template, organization_id
|
SELECT title, certificate_template, organization_id
|
||||||
FROM courses
|
FROM courses
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
"#,
|
"#
|
||||||
course_id
|
|
||||||
)
|
)
|
||||||
|
.bind(course_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Error al obtener datos del curso: {}", e);
|
tracing::error!("Error al obtener datos del curso: {}", e);
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
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,
|
StatusCode::NOT_FOUND,
|
||||||
Json(json!({"error": "Curso no encontrado"})),
|
Json(json!({"error": "Curso no encontrado"})),
|
||||||
))?;
|
))?;
|
||||||
|
|
||||||
let user_name = sqlx::query!(
|
let user_name = sqlx::query(
|
||||||
"SELECT full_name FROM users WHERE id = $1",
|
"SELECT full_name FROM users WHERE id = $1"
|
||||||
user_id
|
|
||||||
)
|
)
|
||||||
|
.bind(user_id)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Error al obtener nombre del usuario: {}", e);
|
tracing::error!("Error al obtener nombre del usuario: {}", e);
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
Json(json!({"error": "Error interno del servidor"})),
|
Json(json!({"error": "Error interno del servidor"})),
|
||||||
)
|
)
|
||||||
})?
|
})?
|
||||||
.full_name;
|
.get::<String, _>("full_name");
|
||||||
|
|
||||||
let org_data = sqlx::query!(
|
let organization_id: Uuid = course_row.get("organization_id");
|
||||||
|
let org_data = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
SELECT name, certificate_template
|
SELECT name, certificate_template
|
||||||
FROM organizations
|
FROM organizations
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
"#,
|
"#
|
||||||
course_data.organization_id
|
|
||||||
)
|
)
|
||||||
|
.bind(organization_id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e: sqlx::Error| {
|
||||||
tracing::error!("Error al obtener datos de la organización: {}", e);
|
tracing::error!("Error al obtener datos de la organización: {}", e);
|
||||||
(
|
(
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
@@ -484,16 +486,19 @@ async fn issue_certificate_internal(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Determinar template a usar (curso > organización > default)
|
// Determinar template a usar (curso > organización > default)
|
||||||
let template = course_data
|
let course_title = course_row.get::<String, _>("title");
|
||||||
.certificate_template
|
let template = course_row
|
||||||
.or_else(|| org_data.and_then(|o| o.certificate_template))
|
.get::<Option<String>, _>("certificate_template")
|
||||||
|
.or_else(|| org_data.as_ref().and_then(|o| o.get::<Option<String>, _>("certificate_template")))
|
||||||
.unwrap_or_else(|| get_default_certificate_template());
|
.unwrap_or_else(|| get_default_certificate_template());
|
||||||
|
|
||||||
|
let _org_name = org_data.as_ref().map(|o| o.get::<String, _>("name")).unwrap_or_else(|| "OpenCCB".to_string());
|
||||||
|
|
||||||
// Reemplazar variables en el template
|
// Reemplazar variables en el template
|
||||||
let now = chrono::Utc::now();
|
let now = chrono::Utc::now();
|
||||||
let certificate_html = template
|
let certificate_html = template
|
||||||
.replace("{{student_name}}", &user_name)
|
.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("{{date}}", &now.format("%d/%m/%Y").to_string())
|
||||||
.replace("{{score}}", "Aprobado")
|
.replace("{{score}}", "Aprobado")
|
||||||
.replace("{{verification_code}}", "VER-PLACEHOLDER");
|
.replace("{{verification_code}}", "VER-PLACEHOLDER");
|
||||||
@@ -524,10 +529,9 @@ async fn issue_certificate_internal(
|
|||||||
let metadata = serde_json::json!({
|
let metadata = serde_json::json!({
|
||||||
"completion_date": now.to_rfc3339(),
|
"completion_date": now.to_rfc3339(),
|
||||||
"final_score": course_completion.progress,
|
"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(
|
let issued_cert = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO issued_certificates
|
INSERT INTO issued_certificates
|
||||||
@@ -536,12 +540,12 @@ async fn issue_certificate_internal(
|
|||||||
RETURNING id, issued_at
|
RETURNING id, issued_at
|
||||||
"#
|
"#
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id.clone())
|
||||||
.bind(course_id)
|
.bind(course_id.clone())
|
||||||
.bind(certificate_html)
|
.bind(certificate_html.clone())
|
||||||
.bind(certificate_hash)
|
.bind(certificate_hash.clone())
|
||||||
.bind(verification_code)
|
.bind(verification_code.clone())
|
||||||
.bind(metadata)
|
.bind(metadata.clone())
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e: sqlx::Error| {
|
.map_err(|e: sqlx::Error| {
|
||||||
@@ -563,7 +567,7 @@ async fn issue_certificate_internal(
|
|||||||
id: issued_cert.get("id"),
|
id: issued_cert.get("id"),
|
||||||
user_id,
|
user_id,
|
||||||
course_id,
|
course_id,
|
||||||
course_title: course_data.title,
|
course_title: course_title,
|
||||||
student_name: user_name,
|
student_name: user_name,
|
||||||
certificate_html,
|
certificate_html,
|
||||||
issued_at: issued_cert.get::<chrono::DateTime<chrono::Utc>, _>("issued_at").to_string(),
|
issued_at: issued_cert.get::<chrono::DateTime<chrono::Utc>, _>("issued_at").to_string(),
|
||||||
|
|||||||
@@ -88,11 +88,19 @@ where
|
|||||||
type Rejection = (StatusCode, &'static str);
|
type Rejection = (StatusCode, &'static str);
|
||||||
|
|
||||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||||
let org_context = parts.extensions.get::<OrgContext>().ok_or((
|
// Intentar obtener OrgContext del middleware
|
||||||
StatusCode::INTERNAL_SERVER_ERROR,
|
if let Some(org_context) = parts.extensions.get::<OrgContext>() {
|
||||||
"Contexto de organización no encontrado. ¿El middleware está configurado?",
|
return Ok(Org(org_context.clone()));
|
||||||
))?;
|
}
|
||||||
|
|
||||||
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 }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 { Sparkles, AlertTriangle, ArrowRight, CheckCircle2, XCircle, Circle, Video, ExternalLink } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { BookOpen, ChevronRight, PlayCircle, Calendar, Clock, Info, Lock } from "lucide-react";
|
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);
|
setIsEnrolled(!!enrollment || isPreview);
|
||||||
|
|
||||||
if (enrollment) {
|
if (enrollment) {
|
||||||
setProgress(enrollment.progress * 100);
|
setProgress(normalizeProgressPercent(enrollment.progress));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Even if not logged in, if there's a preview token, consider "enrolled" for UI
|
// Even if not logged in, if there's a preview token, consider "enrolled" for UI
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 Link from "next/link";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { BookOpen, TrendingUp, Clock, CheckCircle2, Award, Target } from "lucide-react";
|
import { BookOpen, TrendingUp, Clock, CheckCircle2, Award, Target } from "lucide-react";
|
||||||
@@ -40,7 +40,7 @@ export default function MyLearningPage() {
|
|||||||
try {
|
try {
|
||||||
const { course, modules } = await lmsApi.getCourseOutline(enrollment.course_id);
|
const { course, modules } = await lmsApi.getCourseOutline(enrollment.course_id);
|
||||||
|
|
||||||
const progress = enrollment.progress || 0;
|
const progress = normalizeProgressPercent(enrollment.progress);
|
||||||
|
|
||||||
enrichedEnrollments.push({
|
enrichedEnrollments.push({
|
||||||
course: { ...course, modules },
|
course: { ...course, modules },
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ export const getCmsApiUrl = () => {
|
|||||||
return getApiBaseUrl("3001", process.env.NEXT_PUBLIC_CMS_API_URL);
|
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) => {
|
export const getImageUrl = (path?: string) => {
|
||||||
if (!path) return '';
|
if (!path) return '';
|
||||||
if (path.startsWith('http')) {
|
if (path.startsWith('http')) {
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export default function LessonEditor({ params }: { params: { id: string; lessonI
|
|||||||
role_playing_enabled: true,
|
role_playing_enabled: true,
|
||||||
mermaid_enabled: false,
|
mermaid_enabled: false,
|
||||||
code_lab_enabled: true,
|
code_lab_enabled: true,
|
||||||
|
certificates_enabled: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const [lesson, setLesson] = useState<Lesson | null>(null);
|
const [lesson, setLesson] = useState<Lesson | null>(null);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { cmsApi, Course } from "@/lib/api";
|
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 = `
|
const DEFAULT_CERTIFICATE_TEMPLATE = `
|
||||||
<div style="width: 800px; height: 600px; padding: 40px; text-align: center; border: 10px solid #787878; font-family: 'Times New Roman', serif; background-color: #fff; color: #333;">
|
<div style="width: 800px; height: 600px; padding: 40px; text-align: center; border: 10px solid #787878; font-family: 'Times New Roman', serif; background-color: #fff; color: #333;">
|
||||||
@@ -16,10 +16,53 @@ const DEFAULT_CERTIFICATE_TEMPLATE = `
|
|||||||
<span style="font-size: 20px; display: block; margin-bottom: 40px;">with a score of <b>{{score}}%</b></span>
|
<span style="font-size: 20px; display: block; margin-bottom: 40px;">with a score of <b>{{score}}%</b></span>
|
||||||
<span style="font-size: 25px; display: block; margin-bottom: 10px;"><i>Dated</i></span>
|
<span style="font-size: 25px; display: block; margin-bottom: 10px;"><i>Dated</i></span>
|
||||||
<span style="font-size: 20px; display: block;">{{date}}</span>
|
<span style="font-size: 20px; display: block;">{{date}}</span>
|
||||||
|
<span style="font-size: 14px; display: block; margin-top: 24px; color: #666;">Verification: {{verification_code}}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const MODERN_CERTIFICATE_TEMPLATE = `
|
||||||
|
<div style="width: 900px; height: 620px; padding: 0; font-family: 'Inter', sans-serif; background: linear-gradient(135deg, #0f172a, #1e293b); color: #fff; border-radius: 24px; overflow: hidden;">
|
||||||
|
<div style="padding: 48px; height: 100%; box-sizing: border-box; border: 1px solid rgba(255,255,255,0.15);">
|
||||||
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 40px;">
|
||||||
|
<span style="font-size: 14px; letter-spacing: 0.2em; text-transform: uppercase; color: #93c5fd;">OpenCCB</span>
|
||||||
|
<span style="font-size: 12px; color: #94a3b8;">{{date}}</span>
|
||||||
|
</div>
|
||||||
|
<h1 style="font-size: 54px; margin: 0 0 12px 0; line-height: 1;">Certificate</h1>
|
||||||
|
<p style="margin: 0 0 44px 0; color: #cbd5e1;">of Achievement</p>
|
||||||
|
<p style="font-size: 16px; color: #94a3b8; margin-bottom: 12px;">This certifies that</p>
|
||||||
|
<p style="font-size: 38px; margin: 0 0 24px 0; font-weight: 800;">{{student_name}}</p>
|
||||||
|
<p style="font-size: 16px; color: #94a3b8; margin-bottom: 8px;">has successfully completed</p>
|
||||||
|
<p style="font-size: 28px; margin: 0 0 34px 0; font-weight: 700; color: #bae6fd;">{{course_title}}</p>
|
||||||
|
<p style="margin: 0; color: #cbd5e1;">Final score: <strong>{{score}}%</strong></p>
|
||||||
|
<p style="margin-top: 40px; font-size: 12px; color: #64748b;">Verification: {{verification_code}}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const MINIMAL_CERTIFICATE_TEMPLATE = `
|
||||||
|
<div style="width: 850px; height: 600px; padding: 56px; box-sizing: border-box; font-family: 'Georgia', serif; background: #ffffff; color: #111827; border: 2px solid #e5e7eb;">
|
||||||
|
<h1 style="font-size: 44px; margin: 0 0 28px 0;">Certificate of Completion</h1>
|
||||||
|
<p style="font-size: 20px; margin: 0 0 12px 0;">Awarded to</p>
|
||||||
|
<p style="font-size: 36px; margin: 0 0 26px 0; text-decoration: underline;">{{student_name}}</p>
|
||||||
|
<p style="font-size: 18px; margin: 0 0 12px 0;">for completing</p>
|
||||||
|
<p style="font-size: 30px; margin: 0 0 26px 0; font-weight: bold;">{{course_title}}</p>
|
||||||
|
<p style="font-size: 18px; margin: 0 0 40px 0;">with a final score of {{score}}%</p>
|
||||||
|
<div style="display: flex; justify-content: space-between; font-size: 14px; color: #6b7280;">
|
||||||
|
<span>{{date}}</span>
|
||||||
|
<span>{{verification_code}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const TEMPLATE_VARIABLES = [
|
||||||
|
"{{student_name}}",
|
||||||
|
"{{course_title}}",
|
||||||
|
"{{date}}",
|
||||||
|
"{{score}}",
|
||||||
|
"{{verification_code}}",
|
||||||
|
];
|
||||||
|
|
||||||
import CourseEditorLayout from "@/components/CourseEditorLayout";
|
import CourseEditorLayout from "@/components/CourseEditorLayout";
|
||||||
import TeamManagementSection from "./TeamManagementSection";
|
import TeamManagementSection from "./TeamManagementSection";
|
||||||
import IntegrationsSection from "./IntegrationsSection";
|
import IntegrationsSection from "./IntegrationsSection";
|
||||||
@@ -39,6 +82,51 @@ export default function CourseSettingsPage() {
|
|||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
const [price, setPrice] = useState(0);
|
const [price, setPrice] = useState(0);
|
||||||
const [currency, setCurrency] = useState("USD");
|
const [currency, setCurrency] = useState("USD");
|
||||||
|
const [previewStudentName, setPreviewStudentName] = useState("Jane Doe");
|
||||||
|
const [previewScore, setPreviewScore] = useState("95");
|
||||||
|
const [templateWarning, setTemplateWarning] = useState<string | null>(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(() => {
|
useEffect(() => {
|
||||||
const fetchCourse = async () => {
|
const fetchCourse = async () => {
|
||||||
@@ -338,10 +426,64 @@ export default function CourseSettingsPage() {
|
|||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<p className="text-slate-500 dark:text-gray-400 font-medium">
|
<p className="text-slate-500 dark:text-gray-400 font-medium">
|
||||||
Design the HTML certificate that students will receive upon passing the course.
|
Diseña el HTML del certificado que recibirá el estudiante al aprobar el curso.
|
||||||
Available variables: <code className="text-blue-600 dark:text-blue-400 font-black">{"{{student_name}}"}</code>, <code className="text-blue-600 dark:text-blue-400 font-black">{"{{course_title}}"}</code>, <code className="text-blue-600 dark:text-blue-400 font-black">{"{{date}}"}</code>, <code className="text-blue-600 dark:text-blue-400 font-black">{"{{score}}"}</code>.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-xs font-black text-slate-500 dark:text-gray-400 uppercase tracking-[0.2em]">Presets</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => applyTemplatePreset("default")}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 text-[10px] font-black uppercase tracking-widest hover:border-blue-500/40 transition-colors"
|
||||||
|
>
|
||||||
|
Classic
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => applyTemplatePreset("modern")}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 text-[10px] font-black uppercase tracking-widest hover:border-blue-500/40 transition-colors"
|
||||||
|
>
|
||||||
|
Modern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => applyTemplatePreset("minimal")}
|
||||||
|
className="px-3 py-1.5 rounded-lg bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 text-[10px] font-black uppercase tracking-widest hover:border-blue-500/40 transition-colors"
|
||||||
|
>
|
||||||
|
Minimal
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="block text-xs font-black text-slate-500 dark:text-gray-400 uppercase tracking-[0.2em]">Variables</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{TEMPLATE_VARIABLES.map((token) => (
|
||||||
|
<div key={token} className="flex items-center gap-1 bg-white dark:bg-white/5 border border-slate-200 dark:border-white/10 rounded-lg px-2 py-1">
|
||||||
|
<button
|
||||||
|
onClick={() => insertVariable(token)}
|
||||||
|
className="text-[10px] font-black text-blue-600 dark:text-blue-400 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
{token}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => copyVariable(token)}
|
||||||
|
className="text-slate-400 hover:text-slate-700 dark:hover:text-white"
|
||||||
|
title="Copiar variable"
|
||||||
|
>
|
||||||
|
<Copy size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{templateWarning && (
|
||||||
|
<div className="rounded-xl border border-amber-300/50 bg-amber-50 dark:bg-amber-500/10 px-4 py-3 text-xs font-bold text-amber-700 dark:text-amber-300">
|
||||||
|
{templateWarning}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-black text-slate-700 dark:text-gray-300 uppercase tracking-wider">HTML Template</label>
|
<label className="block text-sm font-black text-slate-700 dark:text-gray-300 uppercase tracking-wider">HTML Template</label>
|
||||||
@@ -352,23 +494,32 @@ export default function CourseSettingsPage() {
|
|||||||
placeholder="Enter HTML code here..."
|
placeholder="Enter HTML code here..."
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCertificateTemplate(DEFAULT_CERTIFICATE_TEMPLATE)}
|
onClick={() => applyTemplatePreset("default")}
|
||||||
className="text-[10px] font-black uppercase tracking-widest text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
|
className="inline-flex items-center gap-2 text-[10px] font-black uppercase tracking-widest text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
|
||||||
>
|
>
|
||||||
Reset to Default Template
|
<Wand2 size={12} /> Reset to Default Template
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="block text-sm font-black text-slate-700 dark:text-gray-300 uppercase tracking-wider">Live Preview</label>
|
<label className="block text-sm font-black text-slate-700 dark:text-gray-300 uppercase tracking-wider">Live Preview</label>
|
||||||
|
<div className="grid grid-cols-2 gap-2 mb-2">
|
||||||
|
<input
|
||||||
|
value={previewStudentName}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={previewScore}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div className="w-full h-[400px] bg-white rounded-xl overflow-hidden relative group border border-slate-200 shadow-sm">
|
<div className="w-full h-[400px] bg-white rounded-xl overflow-hidden relative group border border-slate-200 shadow-sm">
|
||||||
<iframe
|
<iframe
|
||||||
srcDoc={certificateTemplate
|
srcDoc={buildPreviewCertificate()}
|
||||||
.replace(/{{student_name}}/g, "Jane Doe")
|
|
||||||
.replace(/{{course_title}}/g, course?.title || "Demo Course")
|
|
||||||
.replace(/{{date}}/g, new Date().toLocaleDateString())
|
|
||||||
.replace(/{{score}}/g, "95")
|
|
||||||
}
|
|
||||||
className="w-full h-full transform scale-75 origin-top-left w-[133%] h-[133%]"
|
className="w-full h-full transform scale-75 origin-top-left w-[133%] h-[133%]"
|
||||||
style={{ border: "none" }}
|
style={{ border: "none" }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user