feat(wip): implement certificate generation and download functionality in CourseEndView component

This commit is contained in:
swve 2025-07-16 21:23:38 +02:00
parent e39c9c37ba
commit a913c0a366
6 changed files with 994 additions and 10 deletions

View file

@ -10,6 +10,9 @@ interface CertificatePreviewProps {
certificationType: string; certificationType: string;
certificatePattern: string; certificatePattern: string;
certificateInstructor?: string; certificateInstructor?: string;
certificateId?: string;
awardedDate?: string;
qrCodeLink?: string;
} }
const CertificatePreview: React.FC<CertificatePreviewProps> = ({ const CertificatePreview: React.FC<CertificatePreviewProps> = ({
@ -17,7 +20,10 @@ const CertificatePreview: React.FC<CertificatePreviewProps> = ({
certificationDescription, certificationDescription,
certificationType, certificationType,
certificatePattern, certificatePattern,
certificateInstructor certificateInstructor,
certificateId,
awardedDate,
qrCodeLink
}) => { }) => {
const [qrCodeUrl, setQrCodeUrl] = useState<string>(''); const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
const org = useOrg() as any; const org = useOrg() as any;
@ -26,7 +32,7 @@ const CertificatePreview: React.FC<CertificatePreviewProps> = ({
useEffect(() => { useEffect(() => {
const generateQRCode = async () => { const generateQRCode = async () => {
try { try {
const certificateData = `https://learnhouse.app/verify/LH-2024-001`; const certificateData = qrCodeLink || `${certificateId}`;
const qrUrl = await QRCode.toDataURL(certificateData, { const qrUrl = await QRCode.toDataURL(certificateData, {
width: 185, width: 185,
margin: 1, margin: 1,
@ -44,7 +50,7 @@ const CertificatePreview: React.FC<CertificatePreviewProps> = ({
}; };
generateQRCode(); generateQRCode();
}, []); }, [certificateId, qrCodeLink]);
// Function to get theme colors for each pattern // Function to get theme colors for each pattern
const getPatternTheme = (pattern: string) => { const getPatternTheme = (pattern: string) => {
switch (pattern) { switch (pattern) {
@ -433,7 +439,7 @@ const CertificatePreview: React.FC<CertificatePreviewProps> = ({
<div className="absolute top-4 left-4 sm:top-6 sm:left-6 z-20"> <div className="absolute top-4 left-4 sm:top-6 sm:left-6 z-20">
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
<Hash className={`w-3 h-3 sm:w-4 sm:h-4 ${theme.icon}`} /> <Hash className={`w-3 h-3 sm:w-4 sm:h-4 ${theme.icon}`} />
<span className={`text-xs sm:text-sm ${theme.secondary} font-medium`}>ID: LH-2024-001</span> <span className={`text-xs sm:text-sm ${theme.secondary} font-medium`}>ID: {certificateId || 'LH-2024-001'}</span>
</div> </div>
</div> </div>
@ -553,7 +559,7 @@ const CertificatePreview: React.FC<CertificatePreviewProps> = ({
<span className={`text-xs ${theme.secondary} font-medium`}>Awarded</span> <span className={`text-xs ${theme.secondary} font-medium`}>Awarded</span>
</div> </div>
<div className={`text-xs ${theme.primary} font-semibold`}> <div className={`text-xs ${theme.primary} font-semibold`}>
Dec 15, 2024 {awardedDate || 'Dec 15, 2024'}
</div> </div>
</div> </div>
</div> </div>

View file

@ -1,11 +1,17 @@
import React, { useMemo } from 'react'; import React, { useMemo, useEffect, useState } from 'react';
import ReactConfetti from 'react-confetti'; import ReactConfetti from 'react-confetti';
import { Trophy, ArrowLeft, BookOpen, Target } from 'lucide-react'; import { Trophy, ArrowLeft, BookOpen, Target, Download } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { getUriWithOrg } from '@services/config/config'; import { getUriWithOrg } from '@services/config/config';
import { getCourseThumbnailMediaDirectory } from '@services/media/media'; import { getCourseThumbnailMediaDirectory } from '@services/media/media';
import { useWindowSize } from 'usehooks-ts'; import { useWindowSize } from 'usehooks-ts';
import { useOrg } from '@components/Contexts/OrgContext'; import { useOrg } from '@components/Contexts/OrgContext';
import { useLHSession } from '@components/Contexts/LHSessionContext';
import { getUserCertificates } from '@services/courses/certifications';
import CertificatePreview from '@components/Dashboard/Pages/Course/EditCourseCertification/CertificatePreview';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
import QRCode from 'qrcode';
interface CourseEndViewProps { interface CourseEndViewProps {
courseName: string; courseName: string;
@ -26,6 +32,13 @@ const CourseEndView: React.FC<CourseEndViewProps> = ({
}) => { }) => {
const { width, height } = useWindowSize(); const { width, height } = useWindowSize();
const org = useOrg() as any; const org = useOrg() as any;
const session = useLHSession() as any;
const [userCertificate, setUserCertificate] = useState<any>(null);
const [isLoadingCertificate, setIsLoadingCertificate] = useState(false);
const [certificateError, setCertificateError] = useState<string | null>(null);
const qrCodeLink = getUriWithOrg(orgslug, `/certificate/${userCertificate.user_certification_uuid}/verify`);
// Check if course is actually completed // Check if course is actually completed
const isCourseCompleted = useMemo(() => { const isCourseCompleted = useMemo(() => {
@ -62,6 +75,296 @@ const CourseEndView: React.FC<CourseEndViewProps> = ({
return totalActivities > 0 && completedActivities === totalActivities; return totalActivities > 0 && completedActivities === totalActivities;
}, [trailData, course]); }, [trailData, course]);
// Fetch user certificate when course is completed
useEffect(() => {
const fetchUserCertificate = async () => {
if (!isCourseCompleted) return;
if (!session?.data?.tokens?.access_token) {
setCertificateError('Authentication required to view certificate');
return;
}
setIsLoadingCertificate(true);
setCertificateError(null);
try {
const cleanCourseUuid = courseUuid.replace('course_', '');
const result = await getUserCertificates(
`course_${cleanCourseUuid}`,
session.data.tokens.access_token
);
if (result.success && result.data && result.data.length > 0) {
setUserCertificate(result.data[0]);
} else {
setCertificateError('No certificate found for this course');
}
} catch (error) {
console.error('Error fetching user certificate:', error);
setCertificateError('Failed to load certificate. Please try again later.');
} finally {
setIsLoadingCertificate(false);
}
};
fetchUserCertificate();
}, [isCourseCompleted, courseUuid, session?.data?.tokens?.access_token]);
// Generate PDF using canvas
const downloadCertificate = async () => {
if (!userCertificate) return;
try {
// Create a temporary div for the certificate
const certificateDiv = document.createElement('div');
certificateDiv.style.position = 'absolute';
certificateDiv.style.left = '-9999px';
certificateDiv.style.top = '0';
certificateDiv.style.width = '800px';
certificateDiv.style.height = '600px';
certificateDiv.style.background = 'white';
certificateDiv.style.padding = '40px';
certificateDiv.style.fontFamily = 'Arial, sans-serif';
certificateDiv.style.textAlign = 'center';
certificateDiv.style.display = 'flex';
certificateDiv.style.flexDirection = 'column';
certificateDiv.style.justifyContent = 'center';
certificateDiv.style.alignItems = 'center';
certificateDiv.style.position = 'relative';
certificateDiv.style.overflow = 'hidden';
// Get theme colors based on pattern
const getPatternTheme = (pattern: string) => {
switch (pattern) {
case 'royal':
return { primary: '#b45309', secondary: '#d97706', icon: '#d97706' };
case 'tech':
return { primary: '#0e7490', secondary: '#0891b2', icon: '#0891b2' };
case 'nature':
return { primary: '#15803d', secondary: '#16a34a', icon: '#16a34a' };
case 'geometric':
return { primary: '#7c3aed', secondary: '#9333ea', icon: '#9333ea' };
case 'vintage':
return { primary: '#c2410c', secondary: '#ea580c', icon: '#ea580c' };
case 'waves':
return { primary: '#1d4ed8', secondary: '#2563eb', icon: '#2563eb' };
case 'minimal':
return { primary: '#374151', secondary: '#4b5563', icon: '#4b5563' };
case 'professional':
return { primary: '#334155', secondary: '#475569', icon: '#475569' };
case 'academic':
return { primary: '#3730a3', secondary: '#4338ca', icon: '#4338ca' };
case 'modern':
return { primary: '#1d4ed8', secondary: '#2563eb', icon: '#2563eb' };
default:
return { primary: '#374151', secondary: '#4b5563', icon: '#4b5563' };
}
};
const theme = getPatternTheme(userCertificate.certification.config.certificate_pattern);
const certificateId = userCertificate.certificate_user.user_certification_uuid;
const qrCodeData = qrCodeLink;
// Generate QR code
const qrCodeDataUrl = await QRCode.toDataURL(qrCodeData, {
width: 120,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
},
errorCorrectionLevel: 'M',
type: 'image/png'
});
// Create certificate content
certificateDiv.innerHTML = `
<div style="
position: absolute;
top: 20px;
left: 20px;
font-size: 12px;
color: ${theme.secondary};
font-weight: 500;
">ID: ${certificateId}</div>
<div style="
position: absolute;
top: 20px;
right: 20px;
width: 80px;
height: 80px;
border: 2px solid ${theme.secondary};
border-radius: 8px;
background: white;
display: flex;
align-items: center;
justify-content: center;
">
<img src="${qrCodeDataUrl}" alt="QR Code" style="width: 100%; height: 100%; object-fit: contain;" />
</div>
<div style="
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 30px;
font-size: 14px;
color: ${theme.secondary};
font-weight: 500;
text-transform: uppercase;
letter-spacing: 1px;
">
<div style="width: 24px; height: 1px; background: linear-gradient(90deg, transparent, ${theme.secondary}, transparent);"></div>
Certificate
<div style="width: 24px; height: 1px; background: linear-gradient(90deg, transparent, ${theme.secondary}, transparent);"></div>
</div>
<div style="
width: 80px;
height: 80px;
background: linear-gradient(135deg, ${theme.icon}20 0%, ${theme.icon}40 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 30px;
font-size: 40px;
line-height: 1;
">🏆</div>
<div style="
font-size: 32px;
font-weight: bold;
color: ${theme.primary};
margin-bottom: 20px;
line-height: 1.2;
max-width: 600px;
">${userCertificate.certification.config.certification_name}</div>
<div style="
font-size: 18px;
color: #6b7280;
margin-bottom: 30px;
line-height: 1.5;
max-width: 500px;
">${userCertificate.certification.config.certification_description || 'This is to certify that the course has been successfully completed.'}</div>
<div style="
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
margin: 20px 0;
">
<div style="width: 8px; height: 1px; background: ${theme.secondary}; opacity: 0.5;"></div>
<div style="width: 4px; height: 4px; background: ${theme.primary}; border-radius: 50%; opacity: 0.6;"></div>
<div style="width: 8px; height: 1px; background: ${theme.secondary}; opacity: 0.5;"></div>
</div>
<div style="
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 16px;
color: ${theme.primary};
background: ${theme.icon}10;
padding: 12px 24px;
border-radius: 20px;
border: 1px solid ${theme.icon}20;
font-weight: 500;
margin-bottom: 30px;
white-space: nowrap;
">
<span style="font-weight: bold; font-size: 18px;"></span>
<span>${userCertificate.certification.config.certification_type === 'completion' ? 'Course Completion' :
userCertificate.certification.config.certification_type === 'achievement' ? 'Achievement Based' :
userCertificate.certification.config.certification_type === 'assessment' ? 'Assessment Based' :
userCertificate.certification.config.certification_type === 'participation' ? 'Participation' :
userCertificate.certification.config.certification_type === 'mastery' ? 'Skill Mastery' :
userCertificate.certification.config.certification_type === 'professional' ? 'Professional Development' :
userCertificate.certification.config.certification_type === 'continuing' ? 'Continuing Education' :
userCertificate.certification.config.certification_type === 'workshop' ? 'Workshop Attendance' :
userCertificate.certification.config.certification_type === 'specialization' ? 'Specialization' : 'Course Completion'}</span>
</div>
<div style="
margin-top: 30px;
padding: 24px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
max-width: 400px;
">
<div style="margin: 8px 0; font-size: 14px; color: #374151;">
<strong style="color: ${theme.primary};">Certificate ID:</strong> ${certificateId}
</div>
<div style="margin: 8px 0; font-size: 14px; color: #374151;">
<strong style="color: ${theme.primary};">Awarded:</strong> ${new Date(userCertificate.certificate_user.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</div>
${userCertificate.certification.config.certificate_instructor ?
`<div style="margin: 8px 0; font-size: 14px; color: #374151;">
<strong style="color: ${theme.primary};">Instructor:</strong> ${userCertificate.certification.config.certificate_instructor}
</div>` : ''
}
</div>
<div style="
margin-top: 20px;
font-size: 12px;
color: #6b7280;
">
This certificate can be verified at ${qrCodeLink}
</div>
`;
// Add to document temporarily
document.body.appendChild(certificateDiv);
// Convert to canvas
const canvas = await html2canvas(certificateDiv, {
width: 800,
height: 600,
scale: 2, // Higher resolution
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff'
});
// Remove temporary div
document.body.removeChild(certificateDiv);
// Create PDF
const imgData = canvas.toDataURL('image/png');
const pdf = new jsPDF('landscape', 'mm', 'a4');
// Calculate dimensions to center the certificate
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
const imgWidth = 280; // mm
const imgHeight = 210; // mm
// Center the image
const x = (pdfWidth - imgWidth) / 2;
const y = (pdfHeight - imgHeight) / 2;
pdf.addImage(imgData, 'PNG', x, y, imgWidth, imgHeight);
// Save the PDF
const fileName = `${userCertificate.certification.config.certification_name.replace(/[^a-zA-Z0-9]/g, '_')}_Certificate.pdf`;
pdf.save(fileName);
} catch (error) {
console.error('Error generating PDF:', error);
alert('Failed to generate PDF. Please try again.');
}
};
// Calculate progress for incomplete courses // Calculate progress for incomplete courses
const progressInfo = useMemo(() => { const progressInfo = useMemo(() => {
if (!trailData || !course || isCourseCompleted) return null; if (!trailData || !course || isCourseCompleted) return null;
@ -115,7 +418,7 @@ const CourseEndView: React.FC<CourseEndViewProps> = ({
/> />
</div> </div>
<div className="bg-white rounded-2xl p-8 nice-shadow max-w-2xl w-full space-y-6 relative z-10"> <div className="bg-white rounded-2xl p-8 nice-shadow max-w-4xl w-full space-y-6 relative z-10">
<div className="flex flex-col items-center space-y-6"> <div className="flex flex-col items-center space-y-6">
{thumbnailImage && ( {thumbnailImage && (
<img <img
@ -147,9 +450,60 @@ const CourseEndView: React.FC<CourseEndViewProps> = ({
Your dedication and hard work have paid off. You've mastered all the content in this course. Your dedication and hard work have paid off. You've mastered all the content in this course.
</p> </p>
{/* Certificate Display */}
{isLoadingCertificate ? (
<div className="flex items-center justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
<span className="ml-3 text-gray-600">Loading your certificate...</span>
</div>
) : certificateError ? (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
<p className="text-yellow-800">
{certificateError}
</p>
</div>
) : userCertificate ? (
<div className="space-y-4">
<h2 className="text-2xl font-semibold text-gray-900">Your Certificate</h2>
<div className="max-w-2xl mx-auto" id="certificate-preview">
<div id="certificate-content">
<CertificatePreview
certificationName={userCertificate.certification.config.certification_name}
certificationDescription={userCertificate.certification.config.certification_description}
certificationType={userCertificate.certification.config.certification_type}
certificatePattern={userCertificate.certification.config.certificate_pattern}
certificateInstructor={userCertificate.certification.config.certificate_instructor}
certificateId={userCertificate.certificate_user.user_certification_uuid}
awardedDate={new Date(userCertificate.certificate_user.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
qrCodeLink={qrCodeLink}
/>
</div>
</div>
<div className="flex justify-center space-x-4">
<button
onClick={downloadCertificate}
className="inline-flex items-center space-x-2 bg-green-600 text-white px-6 py-3 rounded-full hover:bg-green-700 transition duration-200"
>
<Download className="w-5 h-5" />
<span>Download Certificate PDF</span>
</button>
</div>
</div>
) : (
<div className="bg-gray-50 rounded-lg p-6">
<p className="text-gray-600">
No certificate is available for this course. Contact your instructor for more information.
</p>
</div>
)}
<div className="pt-6"> <div className="pt-6">
<Link <Link
href={getUriWithOrg(orgslug, '') + `/course/${courseUuid.replace('course_', '')}`} href={getUriWithOrg(orgslug, `/course/${courseUuid.replace('course_', '')}`)}
className="inline-flex items-center space-x-2 bg-gray-800 text-white px-6 py-3 rounded-full hover:bg-gray-700 transition duration-200" className="inline-flex items-center space-x-2 bg-gray-800 text-white px-6 py-3 rounded-full hover:bg-gray-700 transition duration-200"
> >
<ArrowLeft className="w-5 h-5" /> <ArrowLeft className="w-5 h-5" />
@ -224,7 +578,7 @@ const CourseEndView: React.FC<CourseEndViewProps> = ({
<div className="pt-6"> <div className="pt-6">
<Link <Link
href={getUriWithOrg(orgslug, '') + `/course/${courseUuid.replace('course_', '')}`} href={getUriWithOrg(orgslug, `/course/${courseUuid.replace('course_', '')}`)}
className="inline-flex items-center space-x-2 bg-blue-600 text-white px-6 py-3 rounded-full hover:bg-blue-700 transition duration-200" className="inline-flex items-center space-x-2 bg-blue-600 text-white px-6 py-3 rounded-full hover:bg-blue-700 transition duration-200"
> >
<ArrowLeft className="w-5 h-5" /> <ArrowLeft className="w-5 h-5" />

View file

@ -0,0 +1,426 @@
'use client';
import React, { useEffect, useState, useRef } from 'react';
import { useLHSession } from '@components/Contexts/LHSessionContext';
import { getUserCertificates } from '@services/courses/certifications';
import CertificatePreview from '@components/Dashboard/Pages/Course/EditCourseCertification/CertificatePreview';
import { ArrowLeft, Download } from 'lucide-react';
import Link from 'next/link';
import { getUriWithOrg } from '@services/config/config';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
import QRCode from 'qrcode';
interface CertificatePageProps {
orgslug: string;
courseid: string;
qrCodeLink: string;
}
const CertificatePage: React.FC<CertificatePageProps> = ({ orgslug, courseid, qrCodeLink }) => {
const session = useLHSession() as any;
const [userCertificate, setUserCertificate] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch user certificate
useEffect(() => {
const fetchCertificate = async () => {
if (!session?.data?.tokens?.access_token) {
setError('Authentication required to view certificate');
setIsLoading(false);
return;
}
try {
const cleanCourseId = courseid.replace('course_', '');
const result = await getUserCertificates(
`course_${cleanCourseId}`,
session.data.tokens.access_token
);
if (result.success && result.data && result.data.length > 0) {
setUserCertificate(result.data[0]);
} else {
setError('No certificate found for this course');
}
} catch (error) {
console.error('Error fetching certificate:', error);
setError('Failed to load certificate. Please try again later.');
} finally {
setIsLoading(false);
}
};
fetchCertificate();
}, [courseid, session?.data?.tokens?.access_token]);
// Generate PDF using canvas
const downloadCertificate = async () => {
if (!userCertificate) return;
try {
// Create a temporary div for the certificate
const certificateDiv = document.createElement('div');
certificateDiv.style.position = 'absolute';
certificateDiv.style.left = '-9999px';
certificateDiv.style.top = '0';
certificateDiv.style.width = '800px';
certificateDiv.style.height = '600px';
certificateDiv.style.background = 'white';
certificateDiv.style.padding = '40px';
certificateDiv.style.fontFamily = 'Arial, sans-serif';
certificateDiv.style.textAlign = 'center';
certificateDiv.style.display = 'flex';
certificateDiv.style.flexDirection = 'column';
certificateDiv.style.justifyContent = 'center';
certificateDiv.style.alignItems = 'center';
certificateDiv.style.position = 'relative';
certificateDiv.style.overflow = 'hidden';
// Get theme colors based on pattern
const getPatternTheme = (pattern: string) => {
switch (pattern) {
case 'royal':
return { primary: '#b45309', secondary: '#d97706', icon: '#d97706' };
case 'tech':
return { primary: '#0e7490', secondary: '#0891b2', icon: '#0891b2' };
case 'nature':
return { primary: '#15803d', secondary: '#16a34a', icon: '#16a34a' };
case 'geometric':
return { primary: '#7c3aed', secondary: '#9333ea', icon: '#9333ea' };
case 'vintage':
return { primary: '#c2410c', secondary: '#ea580c', icon: '#ea580c' };
case 'waves':
return { primary: '#1d4ed8', secondary: '#2563eb', icon: '#2563eb' };
case 'minimal':
return { primary: '#374151', secondary: '#4b5563', icon: '#4b5563' };
case 'professional':
return { primary: '#334155', secondary: '#475569', icon: '#475569' };
case 'academic':
return { primary: '#3730a3', secondary: '#4338ca', icon: '#4338ca' };
case 'modern':
return { primary: '#1d4ed8', secondary: '#2563eb', icon: '#2563eb' };
default:
return { primary: '#374151', secondary: '#4b5563', icon: '#4b5563' };
}
};
const theme = getPatternTheme(userCertificate.certification.config.certificate_pattern);
const certificateId = userCertificate.certificate_user.user_certification_uuid;
const qrCodeData = qrCodeLink ;
// Generate QR code
const qrCodeDataUrl = await QRCode.toDataURL(qrCodeData, {
width: 120,
margin: 2,
color: {
dark: '#000000',
light: '#FFFFFF'
},
errorCorrectionLevel: 'M',
type: 'image/png'
});
// Create certificate content
certificateDiv.innerHTML = `
<div style="
position: absolute;
top: 20px;
left: 20px;
font-size: 12px;
color: ${theme.secondary};
font-weight: 500;
">ID: ${certificateId}</div>
<div style="
position: absolute;
top: 20px;
right: 20px;
width: 80px;
height: 80px;
border: 2px solid ${theme.secondary};
border-radius: 8px;
background: white;
display: flex;
align-items: center;
justify-content: center;
">
<img src="${qrCodeDataUrl}" alt="QR Code" style="width: 100%; height: 100%; object-fit: contain;" />
</div>
<div style="
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin-bottom: 30px;
font-size: 14px;
color: ${theme.secondary};
font-weight: 500;
text-transform: uppercase;
letter-spacing: 1px;
">
<div style="width: 24px; height: 1px; background: linear-gradient(90deg, transparent, ${theme.secondary}, transparent);"></div>
Certificate
<div style="width: 24px; height: 1px; background: linear-gradient(90deg, transparent, ${theme.secondary}, transparent);"></div>
</div>
<div style="
width: 80px;
height: 80px;
background: linear-gradient(135deg, ${theme.icon}20 0%, ${theme.icon}40 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 30px;
font-size: 40px;
line-height: 1;
">🏆</div>
<div style="
font-size: 32px;
font-weight: bold;
color: ${theme.primary};
margin-bottom: 20px;
line-height: 1.2;
max-width: 600px;
">${userCertificate.certification.config.certification_name}</div>
<div style="
font-size: 18px;
color: #6b7280;
margin-bottom: 30px;
line-height: 1.5;
max-width: 500px;
">${userCertificate.certification.config.certification_description || 'This is to certify that the course has been successfully completed.'}</div>
<div style="
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
margin: 20px 0;
">
<div style="width: 8px; height: 1px; background: ${theme.secondary}; opacity: 0.5;"></div>
<div style="width: 4px; height: 4px; background: ${theme.primary}; border-radius: 50%; opacity: 0.6;"></div>
<div style="width: 8px; height: 1px; background: ${theme.secondary}; opacity: 0.5;"></div>
</div>
<div style="
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 16px;
color: ${theme.primary};
background: ${theme.icon}10;
padding: 12px 24px;
border-radius: 20px;
border: 1px solid ${theme.icon}20;
font-weight: 500;
margin-bottom: 30px;
white-space: nowrap;
">
<span style="font-weight: bold; font-size: 18px;"></span>
<span>${userCertificate.certification.config.certification_type === 'completion' ? 'Course Completion' :
userCertificate.certification.config.certification_type === 'achievement' ? 'Achievement Based' :
userCertificate.certification.config.certification_type === 'assessment' ? 'Assessment Based' :
userCertificate.certification.config.certification_type === 'participation' ? 'Participation' :
userCertificate.certification.config.certification_type === 'mastery' ? 'Skill Mastery' :
userCertificate.certification.config.certification_type === 'professional' ? 'Professional Development' :
userCertificate.certification.config.certification_type === 'continuing' ? 'Continuing Education' :
userCertificate.certification.config.certification_type === 'workshop' ? 'Workshop Attendance' :
userCertificate.certification.config.certification_type === 'specialization' ? 'Specialization' : 'Course Completion'}</span>
</div>
<div style="
margin-top: 30px;
padding: 24px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
max-width: 400px;
">
<div style="margin: 8px 0; font-size: 14px; color: #374151;">
<strong style="color: ${theme.primary};">Certificate ID:</strong> ${certificateId}
</div>
<div style="margin: 8px 0; font-size: 14px; color: #374151;">
<strong style="color: ${theme.primary};">Awarded:</strong> ${new Date(userCertificate.certificate_user.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</div>
${userCertificate.certification.config.certificate_instructor ?
`<div style="margin: 8px 0; font-size: 14px; color: #374151;">
<strong style="color: ${theme.primary};">Instructor:</strong> ${userCertificate.certification.config.certificate_instructor}
</div>` : ''
}
</div>
<div style="
margin-top: 20px;
font-size: 12px;
color: #6b7280;
">
This certificate can be verified at ${qrCodeData.replace('https://', '').replace('http://', '')}
</div>
`;
// Add to document temporarily
document.body.appendChild(certificateDiv);
// Convert to canvas
const canvas = await html2canvas(certificateDiv, {
width: 800,
height: 600,
scale: 2, // Higher resolution
useCORS: true,
allowTaint: true,
backgroundColor: '#ffffff'
});
// Remove temporary div
document.body.removeChild(certificateDiv);
// Create PDF
const imgData = canvas.toDataURL('image/png');
const pdf = new jsPDF('landscape', 'mm', 'a4');
// Calculate dimensions to center the certificate
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
const imgWidth = 280; // mm
const imgHeight = 210; // mm
// Center the image
const x = (pdfWidth - imgWidth) / 2;
const y = (pdfHeight - imgHeight) / 2;
pdf.addImage(imgData, 'PNG', x, y, imgWidth, imgHeight);
// Save the PDF
const fileName = `${userCertificate.certification.config.certification_name.replace(/[^a-zA-Z0-9]/g, '_')}_Certificate.pdf`;
pdf.save(fileName);
} catch (error) {
console.error('Error generating PDF:', error);
alert('Failed to generate PDF. Please try again.');
}
};
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Loading certificate...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center max-w-md mx-auto p-6">
<div className="bg-red-50 border border-red-200 rounded-lg p-6">
<h2 className="text-xl font-semibold text-red-800 mb-2">Certificate Not Available</h2>
<p className="text-red-600 mb-4">{error}</p>
<Link
href={getUriWithOrg(orgslug, '') + `/course/${courseid}`}
className="inline-flex items-center space-x-2 bg-blue-600 text-white px-6 py-3 rounded-full hover:bg-blue-700 transition duration-200"
>
<ArrowLeft className="w-5 h-5" />
<span>Back to Course</span>
</Link>
</div>
</div>
</div>
);
}
if (!userCertificate) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="text-center max-w-md mx-auto p-6">
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-6">
<h2 className="text-xl font-semibold text-yellow-800 mb-2">No Certificate Found</h2>
<p className="text-yellow-600 mb-4">
No certificate is available for this course. Please contact your instructor for more information.
</p>
<Link
href={getUriWithOrg(orgslug, '') + `/course/${courseid}`}
className="inline-flex items-center space-x-2 bg-blue-600 text-white px-6 py-3 rounded-full hover:bg-blue-700 transition duration-200"
>
<ArrowLeft className="w-5 h-5" />
<span>Back to Course</span>
</Link>
</div>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<Link
href={getUriWithOrg(orgslug, '') + `/course/${courseid}`}
className="inline-flex items-center space-x-2 text-gray-600 hover:text-gray-900 transition duration-200"
>
<ArrowLeft className="w-5 h-5" />
<span>Back to Course</span>
</Link>
<div className="flex items-center space-x-4">
<button
onClick={downloadCertificate}
className="inline-flex items-center space-x-2 bg-green-600 text-white px-6 py-3 rounded-full hover:bg-green-700 transition duration-200"
>
<Download className="w-5 h-5" />
<span>Download PDF</span>
</button>
</div>
</div>
{/* Certificate Display */}
<div className="bg-white rounded-2xl shadow-lg p-8">
<div className="max-w-2xl mx-auto">
<CertificatePreview
certificationName={userCertificate.certification.config.certification_name}
certificationDescription={userCertificate.certification.config.certification_description}
certificationType={userCertificate.certification.config.certification_type}
certificatePattern={userCertificate.certification.config.certificate_pattern}
certificateInstructor={userCertificate.certification.config.certificate_instructor}
certificateId={userCertificate.certificate_user.user_certification_uuid}
awardedDate={new Date(userCertificate.certificate_user.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
qrCodeLink={qrCodeLink}
/>
</div>
</div>
{/* Instructions */}
<div className="mt-8 text-center text-gray-600">
<p className="mb-2">
Click "Download PDF" to generate and download a high-quality certificate PDF.
</p>
<p className="text-sm">
The PDF includes a scannable QR code for certificate verification.
</p>
</div>
</div>
</div>
);
};
export default CertificatePage;

View file

@ -50,6 +50,7 @@
"@tiptap/react": "^2.11.7", "@tiptap/react": "^2.11.7",
"@tiptap/starter-kit": "^2.11.7", "@tiptap/starter-kit": "^2.11.7",
"@types/dompurify": "^3.2.0", "@types/dompurify": "^3.2.0",
"@types/html2canvas": "^1.0.0",
"@types/randomcolor": "^0.5.9", "@types/randomcolor": "^0.5.9",
"avvvatars-react": "^0.4.2", "avvvatars-react": "^0.4.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
@ -62,6 +63,9 @@
"framer-motion": "^12.6.3", "framer-motion": "^12.6.3",
"get-youtube-id": "^1.0.1", "get-youtube-id": "^1.0.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"html2canvas": "^1.4.1",
"jspdf": "^3.0.1",
"jspdf-html2canvas": "^1.5.2",
"katex": "^0.16.21", "katex": "^0.16.21",
"lowlight": "^3.3.0", "lowlight": "^3.3.0",
"lucide-react": "^0.453.0", "lucide-react": "^0.453.0",

182
apps/web/pnpm-lock.yaml generated
View file

@ -129,6 +129,9 @@ importers:
'@types/dompurify': '@types/dompurify':
specifier: ^3.2.0 specifier: ^3.2.0
version: 3.2.0 version: 3.2.0
'@types/html2canvas':
specifier: ^1.0.0
version: 1.0.0
'@types/randomcolor': '@types/randomcolor':
specifier: ^0.5.9 specifier: ^0.5.9
version: 0.5.9 version: 0.5.9
@ -165,6 +168,15 @@ importers:
highlight.js: highlight.js:
specifier: ^11.11.1 specifier: ^11.11.1
version: 11.11.1 version: 11.11.1
html2canvas:
specifier: ^1.4.1
version: 1.4.1
jspdf:
specifier: ^3.0.1
version: 3.0.1
jspdf-html2canvas:
specifier: ^1.5.2
version: 1.5.2
katex: katex:
specifier: ^0.16.21 specifier: ^0.16.21
version: 0.16.21 version: 0.16.21
@ -1842,6 +1854,10 @@ packages:
'@types/hoist-non-react-statics@3.3.6': '@types/hoist-non-react-statics@3.3.6':
resolution: {integrity: sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==} resolution: {integrity: sha512-lPByRJUer/iN/xa4qpyL0qmL11DqNW81iU/IG1S3uvRUq4oKagz8VCxZjiWkumgt66YT3vOdDgZ0o32sGKtCEw==}
'@types/html2canvas@1.0.0':
resolution: {integrity: sha512-BJpVf+FIN9UERmzhbtUgpXj6XBZpG67FMgBLLoj9HZKd9XifcCpSV+UnFcwTZfEyun4U/KmCrrVOG7829L589w==}
deprecated: This is a stub types definition. html2canvas provides its own type definitions, so you do not need this installed.
'@types/json-schema@7.0.15': '@types/json-schema@7.0.15':
resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==}
@ -1863,6 +1879,9 @@ packages:
'@types/qrcode@1.5.5': '@types/qrcode@1.5.5':
resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==} resolution: {integrity: sha512-CdfBi/e3Qk+3Z/fXYShipBT13OJ2fDO2Q2w5CIP5anLTLIndQG9z6P1cnm+8zCWSpm5dnxMFd/uREtb0EXuQzg==}
'@types/raf@3.4.3':
resolution: {integrity: sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==}
'@types/randomcolor@0.5.9': '@types/randomcolor@0.5.9':
resolution: {integrity: sha512-k58cfpkK15AKn1m+oRd9nh5BnuiowhbyvBBdAzcddtARMr3xRzP0VlFaAKovSG6N6Knx08EicjPlOMzDejerrQ==} resolution: {integrity: sha512-k58cfpkK15AKn1m+oRd9nh5BnuiowhbyvBBdAzcddtARMr3xRzP0VlFaAKovSG6N6Knx08EicjPlOMzDejerrQ==}
@ -2097,6 +2116,11 @@ packages:
resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
atob@2.1.2:
resolution: {integrity: sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==}
engines: {node: '>= 4.5.0'}
hasBin: true
available-typed-arrays@1.0.7: available-typed-arrays@1.0.7:
resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
@ -2118,6 +2142,10 @@ packages:
balanced-match@1.0.2: balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
base64-arraybuffer@1.0.2:
resolution: {integrity: sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==}
engines: {node: '>= 0.6.0'}
brace-expansion@1.1.11: brace-expansion@1.1.11:
resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==}
@ -2128,6 +2156,11 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'} engines: {node: '>=8'}
btoa@1.2.1:
resolution: {integrity: sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==}
engines: {node: '>= 0.4.0'}
hasBin: true
busboy@1.6.0: busboy@1.6.0:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'} engines: {node: '>=10.16.0'}
@ -2158,6 +2191,10 @@ packages:
caniuse-lite@1.0.30001712: caniuse-lite@1.0.30001712:
resolution: {integrity: sha512-MBqPpGYYdQ7/hfKiet9SCI+nmN5/hp4ZzveOJubl5DTAMa5oggjAuoi0Z4onBpKPFI2ePGnQuQIzF3VxDjDJig==} resolution: {integrity: sha512-MBqPpGYYdQ7/hfKiet9SCI+nmN5/hp4ZzveOJubl5DTAMa5oggjAuoi0Z4onBpKPFI2ePGnQuQIzF3VxDjDJig==}
canvg@3.0.11:
resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==}
engines: {node: '>=10.0.0'}
chalk@4.1.2: chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -2223,6 +2260,9 @@ packages:
resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==} resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==}
engines: {node: '>=4'} engines: {node: '>=4'}
css-line-break@2.1.0:
resolution: {integrity: sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==}
css-to-react-native@3.2.0: css-to-react-native@3.2.0:
resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==} resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==}
@ -2322,6 +2362,9 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
dompurify@2.5.8:
resolution: {integrity: sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==}
dompurify@3.2.5: dompurify@3.2.5:
resolution: {integrity: sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==} resolution: {integrity: sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==}
@ -2550,6 +2593,9 @@ packages:
picomatch: picomatch:
optional: true optional: true
fflate@0.8.2:
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
file-entry-cache@8.0.0: file-entry-cache@8.0.0:
resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==}
engines: {node: '>=16.0.0'} engines: {node: '>=16.0.0'}
@ -2700,6 +2746,10 @@ packages:
hoist-non-react-statics@3.3.2: hoist-non-react-statics@3.3.2:
resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==}
html2canvas@1.4.1:
resolution: {integrity: sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==}
engines: {node: '>=8.0.0'}
ignore@5.3.2: ignore@5.3.2:
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
engines: {node: '>= 4'} engines: {node: '>= 4'}
@ -2859,6 +2909,15 @@ packages:
resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==}
hasBin: true hasBin: true
jspdf-html2canvas@1.5.2:
resolution: {integrity: sha512-jA/QvZWPiBJFR7Ut4XCwKsOcml1L75lvO7+mmw5qgTfmi8qbfHNr2DPXeQ8J6mNKJfB4Fn8QBBiYcr2NR3ssdQ==}
jspdf@2.5.2:
resolution: {integrity: sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==}
jspdf@3.0.1:
resolution: {integrity: sha512-qaGIxqxetdoNnFQQXxTKUD9/Z7AloLaw94fFsOiJMxbfYdBbrBuhWmbzI8TVjrw7s3jBY1PFHofBKMV/wZPapg==}
jsx-ast-utils@3.3.5: jsx-ast-utils@3.3.5:
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
engines: {node: '>=4.0'} engines: {node: '>=4.0'}
@ -3185,6 +3244,9 @@ packages:
path-parse@1.0.7: path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
performance-now@2.1.0:
resolution: {integrity: sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==}
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@ -3320,6 +3382,9 @@ packages:
raf-schd@4.0.3: raf-schd@4.0.3:
resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==} resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==}
raf@3.4.1:
resolution: {integrity: sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==}
randomcolor@0.6.2: randomcolor@0.6.2:
resolution: {integrity: sha512-Mn6TbyYpFgwFuQ8KJKqf3bqqY9O1y37/0jgSK/61PUxV4QfIMv0+K2ioq8DfOjkBslcjwSzRfIDEXfzA9aCx7A==} resolution: {integrity: sha512-Mn6TbyYpFgwFuQ8KJKqf3bqqY9O1y37/0jgSK/61PUxV4QfIMv0+K2ioq8DfOjkBslcjwSzRfIDEXfzA9aCx7A==}
@ -3459,6 +3524,9 @@ packages:
resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==} resolution: {integrity: sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
regenerator-runtime@0.13.11:
resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==}
regenerator-runtime@0.14.1: regenerator-runtime@0.14.1:
resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
@ -3497,6 +3565,10 @@ packages:
resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==}
engines: {iojs: '>=1.0.0', node: '>=0.10.0'} engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
rgbcolor@1.0.1:
resolution: {integrity: sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==}
engines: {node: '>= 0.8.15'}
rope-sequence@1.3.4: rope-sequence@1.3.4:
resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==} resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
@ -3590,6 +3662,10 @@ packages:
stable-hash@0.0.5: stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
stackblur-canvas@2.7.0:
resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==}
engines: {node: '>=0.1.14'}
streamsearch@1.1.0: streamsearch@1.1.0:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==} resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'} engines: {node: '>=10.0.0'}
@ -3664,6 +3740,10 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
svg-pathdata@6.0.3:
resolution: {integrity: sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==}
engines: {node: '>=12.0.0'}
swr@2.3.3: swr@2.3.3:
resolution: {integrity: sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==} resolution: {integrity: sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==}
peerDependencies: peerDependencies:
@ -3687,6 +3767,9 @@ packages:
resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==}
engines: {node: '>=6'} engines: {node: '>=6'}
text-segmentation@1.0.3:
resolution: {integrity: sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==}
tiny-case@1.0.3: tiny-case@1.0.3:
resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==}
@ -3814,6 +3897,9 @@ packages:
peerDependencies: peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc react: ^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc
utrie@1.0.2:
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
uuid@8.3.2: uuid@8.3.2:
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
hasBin: true hasBin: true
@ -5354,6 +5440,10 @@ snapshots:
'@types/react': 19.0.10 '@types/react': 19.0.10
hoist-non-react-statics: 3.3.2 hoist-non-react-statics: 3.3.2
'@types/html2canvas@1.0.0':
dependencies:
html2canvas: 1.4.1
'@types/json-schema@7.0.15': {} '@types/json-schema@7.0.15': {}
'@types/json5@0.0.29': {} '@types/json5@0.0.29': {}
@ -5375,6 +5465,9 @@ snapshots:
dependencies: dependencies:
'@types/node': 20.12.2 '@types/node': 20.12.2
'@types/raf@3.4.3':
optional: true
'@types/randomcolor@0.5.9': {} '@types/randomcolor@0.5.9': {}
'@types/react-dom@19.0.4(@types/react@19.0.10)': '@types/react-dom@19.0.4(@types/react@19.0.10)':
@ -5632,6 +5725,8 @@ snapshots:
async-function@1.0.0: {} async-function@1.0.0: {}
atob@2.1.2: {}
available-typed-arrays@1.0.7: available-typed-arrays@1.0.7:
dependencies: dependencies:
possible-typed-array-names: 1.1.0 possible-typed-array-names: 1.1.0
@ -5650,6 +5745,8 @@ snapshots:
balanced-match@1.0.2: {} balanced-match@1.0.2: {}
base64-arraybuffer@1.0.2: {}
brace-expansion@1.1.11: brace-expansion@1.1.11:
dependencies: dependencies:
balanced-match: 1.0.2 balanced-match: 1.0.2
@ -5663,6 +5760,8 @@ snapshots:
dependencies: dependencies:
fill-range: 7.1.1 fill-range: 7.1.1
btoa@1.2.1: {}
busboy@1.6.0: busboy@1.6.0:
dependencies: dependencies:
streamsearch: 1.1.0 streamsearch: 1.1.0
@ -5692,6 +5791,18 @@ snapshots:
caniuse-lite@1.0.30001712: {} caniuse-lite@1.0.30001712: {}
canvg@3.0.11:
dependencies:
'@babel/runtime': 7.27.0
'@types/raf': 3.4.3
core-js: 3.42.0
raf: 3.4.1
regenerator-runtime: 0.13.11
rgbcolor: 1.0.1
stackblur-canvas: 2.7.0
svg-pathdata: 6.0.3
optional: true
chalk@4.1.2: chalk@4.1.2:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@ -5757,6 +5868,10 @@ snapshots:
css-color-keywords@1.0.0: {} css-color-keywords@1.0.0: {}
css-line-break@2.1.0:
dependencies:
utrie: 1.0.2
css-to-react-native@3.2.0: css-to-react-native@3.2.0:
dependencies: dependencies:
camelize: 1.0.1 camelize: 1.0.1
@ -5842,6 +5957,9 @@ snapshots:
dependencies: dependencies:
esutils: 2.0.3 esutils: 2.0.3
dompurify@2.5.8:
optional: true
dompurify@3.2.5: dompurify@3.2.5:
optionalDependencies: optionalDependencies:
'@types/trusted-types': 2.0.7 '@types/trusted-types': 2.0.7
@ -6217,6 +6335,8 @@ snapshots:
optionalDependencies: optionalDependencies:
picomatch: 4.0.2 picomatch: 4.0.2
fflate@0.8.2: {}
file-entry-cache@8.0.0: file-entry-cache@8.0.0:
dependencies: dependencies:
flat-cache: 4.0.1 flat-cache: 4.0.1
@ -6370,6 +6490,11 @@ snapshots:
dependencies: dependencies:
react-is: 16.13.1 react-is: 16.13.1
html2canvas@1.4.1:
dependencies:
css-line-break: 2.1.0
text-segmentation: 1.0.3
ignore@5.3.2: {} ignore@5.3.2: {}
import-fresh@3.3.1: import-fresh@3.3.1:
@ -6531,6 +6656,35 @@ snapshots:
dependencies: dependencies:
minimist: 1.2.8 minimist: 1.2.8
jspdf-html2canvas@1.5.2:
dependencies:
html2canvas: 1.4.1
jspdf: 2.5.2
jspdf@2.5.2:
dependencies:
'@babel/runtime': 7.27.0
atob: 2.1.2
btoa: 1.2.1
fflate: 0.8.2
optionalDependencies:
canvg: 3.0.11
core-js: 3.42.0
dompurify: 2.5.8
html2canvas: 1.4.1
jspdf@3.0.1:
dependencies:
'@babel/runtime': 7.27.0
atob: 2.1.2
btoa: 1.2.1
fflate: 0.8.2
optionalDependencies:
canvg: 3.0.11
core-js: 3.42.0
dompurify: 3.2.5
html2canvas: 1.4.1
jsx-ast-utils@3.3.5: jsx-ast-utils@3.3.5:
dependencies: dependencies:
array-includes: 3.1.8 array-includes: 3.1.8
@ -6845,6 +6999,9 @@ snapshots:
path-parse@1.0.7: {} path-parse@1.0.7: {}
performance-now@2.1.0:
optional: true
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@2.3.1: {} picomatch@2.3.1: {}
@ -7019,6 +7176,11 @@ snapshots:
raf-schd@4.0.3: {} raf-schd@4.0.3: {}
raf@3.4.1:
dependencies:
performance-now: 2.1.0
optional: true
randomcolor@0.6.2: {} randomcolor@0.6.2: {}
rangetouch@2.0.1: {} rangetouch@2.0.1: {}
@ -7155,6 +7317,9 @@ snapshots:
get-proto: 1.0.1 get-proto: 1.0.1
which-builtin-type: 1.2.1 which-builtin-type: 1.2.1
regenerator-runtime@0.13.11:
optional: true
regenerator-runtime@0.14.1: {} regenerator-runtime@0.14.1: {}
regexp.prototype.flags@1.5.4: regexp.prototype.flags@1.5.4:
@ -7196,6 +7361,9 @@ snapshots:
reusify@1.1.0: {} reusify@1.1.0: {}
rgbcolor@1.0.1:
optional: true
rope-sequence@1.3.4: {} rope-sequence@1.3.4: {}
run-parallel@1.2.0: run-parallel@1.2.0:
@ -7351,6 +7519,9 @@ snapshots:
stable-hash@0.0.5: {} stable-hash@0.0.5: {}
stackblur-canvas@2.7.0:
optional: true
streamsearch@1.1.0: {} streamsearch@1.1.0: {}
string-width@4.2.3: string-width@4.2.3:
@ -7444,6 +7615,9 @@ snapshots:
supports-preserve-symlinks-flag@1.0.0: {} supports-preserve-symlinks-flag@1.0.0: {}
svg-pathdata@6.0.3:
optional: true
swr@2.3.3(react@19.0.0): swr@2.3.3(react@19.0.0):
dependencies: dependencies:
dequal: 2.0.3 dequal: 2.0.3
@ -7462,6 +7636,10 @@ snapshots:
tapable@2.2.1: {} tapable@2.2.1: {}
text-segmentation@1.0.3:
dependencies:
utrie: 1.0.2
tiny-case@1.0.3: {} tiny-case@1.0.3: {}
tiny-invariant@1.3.3: {} tiny-invariant@1.3.3: {}
@ -7604,6 +7782,10 @@ snapshots:
lodash.debounce: 4.0.8 lodash.debounce: 4.0.8
react: 19.0.0 react: 19.0.0
utrie@1.0.2:
dependencies:
base64-arraybuffer: 1.0.2
uuid@8.3.2: {} uuid@8.3.2: {}
uuid@9.0.1: {} uuid@9.0.1: {}

View file

@ -59,4 +59,16 @@ export async function deleteCertification(
) )
const res = await errorHandling(result) const res = await errorHandling(result)
return res return res
}
export async function getUserCertificates(
course_uuid: string,
access_token: string
) {
const result = await fetch(
`${getAPIUrl()}certifications/user/course/${course_uuid}`,
RequestBodyWithAuthHeader('GET', null, null, access_token)
)
const res = await getResponseMetadata(result)
return res
} }