feat: fixing certificate and block

This commit is contained in:
2026-04-14 13:07:45 -04:00
parent e0e6655b91
commit 169a0a18a2
21 changed files with 837 additions and 1226 deletions
+69 -3
View File
@@ -9,6 +9,8 @@ import { useAuth } from "@/context/AuthContext";
import DiscussionBoard from "@/components/DiscussionBoard";
import { AnnouncementsList } from "@/components/AnnouncementsList";
import AboutCourse from "@/components/AboutCourse";
import CertificateModal from "@/components/CertificateModal";
import { CertificateResponse } from "@/lib/api";
export default function CourseOutlinePage({ params }: { params: { id: string } }) {
const { user } = useAuth();
@@ -22,6 +24,11 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
const [instructors, setInstructors] = useState<any[]>([]);
const [meetings, setMeetings] = useState<Meeting[]>([]);
const [activeTab, setActiveTab] = useState<'outline' | 'about'>('outline');
const [progress, setProgress] = useState(0);
const [certificate, setCertificate] = useState<CertificateResponse | null>(null);
const [showCertificateModal, setShowCertificateModal] = useState(false);
const [loadingCertificate, setLoadingCertificate] = useState(false);
const [orgSettings, setOrgSettings] = useState<any>(null);
useEffect(() => {
const fetchData = async () => {
@@ -37,11 +44,15 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
setUserGrades(grades);
const enrollmentData = await lmsApi.getEnrollments(user.id);
const enrolled = enrollmentData.some(e => e.course_id === params.id);
const enrollment = enrollmentData.find(e => e.course_id === params.id);
// Allow preview token to override enrollment status
const isPreview = typeof window !== 'undefined' && !!sessionStorage.getItem('preview_token');
setIsEnrolled(enrolled || isPreview);
setIsEnrolled(!!enrollment || isPreview);
if (enrollment) {
setProgress(enrollment.progress * 100);
}
} else {
// Even if not logged in, if there's a preview token, consider "enrolled" for UI
const isPreview = typeof window !== 'undefined' && !!sessionStorage.getItem('preview_token');
@@ -65,6 +76,11 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
lmsApi.getMeetings(params.id)
.then(setMeetings)
.catch(console.error);
// Load organization settings (branding includes the certificates_enabled flag)
lmsApi.getBranding()
.then(res => setOrgSettings(res.organization))
.catch(console.error);
}, [params.id, user]);
const handleEnrollOrBuy = async () => {
@@ -120,6 +136,38 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
});
};
const handleViewCertificate = async () => {
if (certificate) {
setShowCertificateModal(true);
return;
}
setLoadingCertificate(true);
try {
const data = await lmsApi.getCertificate(params.id);
setCertificate(data);
setShowCertificateModal(true);
} catch (error: any) {
console.error("No se pudo obtener el certificado", error);
if (error.status === 404 || error.status === 403) {
// Try to issue it if completed but not issued
if (progress >= 100) {
try {
const issued = await lmsApi.issueCertificate(params.id);
setCertificate(issued);
setShowCertificateModal(true);
} catch (issueError) {
alert("No se pudo generar el certificado. Asegúrate de haber aprobado todas las lecciones.");
}
} else {
alert("Aún no has completado este curso.");
}
}
} finally {
setLoadingCertificate(false);
}
};
const getStatusIcon = (lessonId: string, isGraded: boolean, allowRetry: boolean) => {
if (isLessonLocked(lessonId)) {
return <Lock size={18} className="text-gray-600" />;
@@ -274,6 +322,17 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
📊 Progreso
</button>
</Link>
{isEnrolled && orgSettings?.certificates_enabled !== false && progress >= 100 && (
<button
onClick={handleViewCertificate}
disabled={loadingCertificate}
className="btn-premium px-8 py-3 !bg-emerald-600 !text-white shadow-lg shadow-emerald-500/20 active:scale-95 flex items-center gap-2"
>
<Award size={16} />
{loadingCertificate ? "Preparando..." : "Mi Certificado"}
</button>
)}
</div>
</div>
@@ -485,6 +544,13 @@ export default function CourseOutlinePage({ params }: { params: { id: string } }
</div>
)}
</div>
{showCertificateModal && certificate && (
<CertificateModal
certificate={certificate}
onClose={() => setShowCertificateModal(false)}
/>
)}
</div>
);
}
+1 -3
View File
@@ -40,9 +40,7 @@ export default function MyLearningPage() {
try {
const { course, modules } = await lmsApi.getCourseOutline(enrollment.course_id);
// TODO: Implement actual progress tracking
// For now, show 0% progress for all courses
const progress = 0;
const progress = enrollment.progress || 0;
enrichedEnrollments.push({
course: { ...course, modules },
@@ -0,0 +1,98 @@
"use client";
import { X, Printer, Download, Award, ShieldCheck } from "lucide-react";
import { CertificateResponse } from "@/lib/api";
interface CertificateModalProps {
certificate: CertificateResponse;
onClose: () => void;
}
export default function CertificateModal({ certificate, onClose }: CertificateModalProps) {
const handlePrint = () => {
window.print();
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/80 backdrop-blur-md animate-in fade-in duration-300">
<div className="relative w-full max-w-5xl md:h-[90vh] bg-white dark:bg-slate-900 rounded-[2rem] overflow-hidden flex flex-col shadow-2xl border border-white/10">
{/* Header / Toolbar */}
<div className="flex items-center justify-between p-6 border-b border-slate-100 dark:border-white/5 bg-slate-50 dark:bg-black/20 no-print">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-blue-600 flex items-center justify-center text-white shadow-lg shadow-blue-500/20">
<Award size={20} />
</div>
<div>
<h2 className="text-sm font-bold text-slate-900 dark:text-white">Certificado Oficial</h2>
<p className="text-[10px] font-black uppercase tracking-widest text-blue-600 dark:text-blue-500">Emitido el {new Date(certificate.issued_at).toLocaleDateString()}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={handlePrint}
className="p-3 rounded-xl hover:bg-slate-200 dark:hover:bg-white/10 text-slate-600 dark:text-gray-400 transition-all flex items-center gap-2 text-xs font-bold"
>
<Printer size={18} />
<span className="hidden sm:inline">Imprimir / PDF</span>
</button>
<button
onClick={onClose}
className="p-3 rounded-xl hover:bg-red-500/10 text-slate-400 hover:text-red-500 transition-all"
>
<X size={20} />
</button>
</div>
</div>
{/* Certificate Content Wrapper */}
<div className="flex-1 overflow-y-auto p-4 md:p-12 flex items-center justify-center bg-slate-100 dark:bg-black/40">
<div className="certificate-container shadow-2xl ring-1 ring-black/5">
{/*
We inject the HTML directly.
Note: The backend must sanitize this OR we trust our own generated template.
*/}
<div
className="bg-white"
dangerouslySetInnerHTML={{ __html: certificate.certificate_html }}
/>
</div>
</div>
{/* Footer Info */}
<div className="p-6 bg-slate-50 dark:bg-black/20 border-t border-slate-100 dark:border-white/5 flex flex-col md:flex-row items-center justify-between gap-4 no-print">
<div className="flex items-center gap-3 text-emerald-600 dark:text-emerald-500">
<ShieldCheck size={18} />
<span className="text-[10px] font-black uppercase tracking-widest">Este certificado es auténtico y verificable</span>
</div>
<div className="text-[10px] font-bold text-slate-400 dark:text-gray-500 flex items-center gap-2">
Código: <code className="bg-slate-200 dark:bg-white/5 px-2 py-1 rounded text-blue-600 dark:text-blue-400 font-mono">{certificate.verification_code}</code>
</div>
</div>
<style jsx global>{`
.certificate-container {
width: 100%;
max-width: 800px;
background: white;
transform-origin: center;
}
@media print {
.no-print { display: none !important; }
body { background: white !important; margin: 0 !important; padding: 0 !important; }
.certificate-container {
box-shadow: none !important;
ring: none !important;
width: 100% !important;
max-width: none !important;
position: absolute;
top: 0;
left: 0;
}
}
`}</style>
</div>
</div>
);
}
+39
View File
@@ -142,6 +142,30 @@ export interface PaymentPreferenceResponse {
init_point: string;
}
export interface CertificateResponse {
id: string;
user_id: string;
course_id: string;
course_title: string;
student_name: string;
certificate_html: string;
issued_at: string;
verification_code: string;
metadata: any;
}
export interface CertificateResponse {
id: string;
user_id: string;
course_id: string;
course_title: string;
student_name: string;
certificate_html: string;
issued_at: string;
verification_code: string;
metadata: any;
}
export interface QuizQuestion {
id: string;
question: string;
@@ -371,6 +395,7 @@ export interface Enrollment {
id: string;
user_id: string;
course_id: string;
progress: number;
enrolled_at: string;
}
@@ -862,5 +887,19 @@ export const lmsApi = {
async getMyBadges(): Promise<Badge[]> {
return apiFetch(`/my/badges`, {}, false);
},
// Certificates
async getCertificate(courseId: string): Promise<CertificateResponse> {
return apiFetch(`/courses/${courseId}/certificate`);
},
async issueCertificate(courseId: string, forceReissue = false): Promise<CertificateResponse> {
return apiFetch(`/courses/${courseId}/certificate/issue`, {
method: 'POST',
body: JSON.stringify({ force_reissue: forceReissue })
});
},
async verifyCertificate(code: string): Promise<any> {
return apiFetch(`/certificates/verify/${code}`);
}
};