Refactor code structure for improved readability and maintainability
This commit is contained in:
@@ -2,7 +2,13 @@ import { FC, ReactElement, useState, useEffect, useRef } from 'react';
|
|||||||
import { useParams, useNavigate } from 'react-router';
|
import { useParams, useNavigate } from 'react-router';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { decodeCertificateId } from '../../../utils/certificate';
|
import { decodeCertificateId } from '../../../utils/certificate';
|
||||||
import { useTeamById, useTeamSubmission } from '@imphnen-frontend-service/service';
|
import {
|
||||||
|
useTeamById,
|
||||||
|
useTeamSubmission,
|
||||||
|
useAuthStore,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
import html2canvas from 'html2canvas';
|
||||||
|
|
||||||
interface DecodedCert {
|
interface DecodedCert {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
@@ -12,12 +18,18 @@ interface DecodedCert {
|
|||||||
const CertificatePage: FC = (): ReactElement => {
|
const CertificatePage: FC = (): ReactElement => {
|
||||||
const { certId } = useParams<{ certId: string }>();
|
const { certId } = useParams<{ certId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { session } = useAuthStore();
|
||||||
const [decodedInfo, setDecodedInfo] = useState<DecodedCert | null>(null);
|
const [decodedInfo, setDecodedInfo] = useState<DecodedCert | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const teamNameRef = useRef<HTMLHeadingElement>(null);
|
const teamNameRef = useRef<HTMLHeadingElement>(null);
|
||||||
const projectNameRef = useRef<HTMLSpanElement>(null);
|
const userNameRef = useRef<HTMLHeadingElement>(null);
|
||||||
const [teamNameFontSize, setTeamNameFontSize] = useState('2.25rem');
|
const [teamNameFontSize, setTeamNameFontSize] = useState('2.25rem');
|
||||||
const [projectNameFontSize, setProjectNameFontSize] = useState('1.5rem');
|
const [userNameFontSize, setUserNameFontSize] = useState('2.25rem');
|
||||||
|
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
|
||||||
|
const certificateRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [certificateImage, setCertificateImage] = useState<string>('');
|
||||||
|
const [showTemplate, setShowTemplate] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (certId) {
|
if (certId) {
|
||||||
@@ -29,20 +41,44 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
}, [certId]);
|
}, [certId]);
|
||||||
|
|
||||||
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(decodedInfo?.teamId || '', !!decodedInfo?.teamId);
|
// Generate QR Code
|
||||||
const { data: submissionData, isLoading: isLoadingSubmission } = useTeamSubmission(
|
useEffect(() => {
|
||||||
|
if (certId) {
|
||||||
|
const certificateUrl = `${window.location.origin}/certificate/${certId}`;
|
||||||
|
QRCode.toDataURL(certificateUrl, {
|
||||||
|
width: 200,
|
||||||
|
margin: 1,
|
||||||
|
color: {
|
||||||
|
dark: '#000000',
|
||||||
|
light: '#ffffff',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(setQrCodeUrl)
|
||||||
|
.catch((err) => console.error('QR Code generation failed:', err));
|
||||||
|
}
|
||||||
|
}, [certId]);
|
||||||
|
|
||||||
|
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
|
||||||
decodedInfo?.teamId || '',
|
decodedInfo?.teamId || '',
|
||||||
!!decodedInfo?.teamId
|
!!decodedInfo?.teamId
|
||||||
);
|
);
|
||||||
|
const { data: submissionData, isLoading: isLoadingSubmission } =
|
||||||
|
useTeamSubmission(decodedInfo?.teamId || '', !!decodedInfo?.teamId);
|
||||||
|
|
||||||
const team = teamData?.data;
|
const team = teamData?.data;
|
||||||
const submission = submissionData?.data;
|
const submission = submissionData?.data;
|
||||||
|
|
||||||
const isLoading = (!decodedInfo && !error) || isLoadingTeam || isLoadingSubmission;
|
const isLoading =
|
||||||
|
(!decodedInfo && !error) || isLoadingTeam || isLoadingSubmission;
|
||||||
|
|
||||||
// Dynamic font sizing: shrink by 2px if height exceeds 80px
|
// Dynamic font sizing: shrink by 2px if height exceeds 80px
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const adjustFontSize = (element: HTMLElement | null, maxHeight: number, startSize: number, setter: (size: string) => void) => {
|
const adjustFontSize = (
|
||||||
|
element: HTMLElement | null,
|
||||||
|
maxHeight: number,
|
||||||
|
startSize: number,
|
||||||
|
setter: (size: string) => void
|
||||||
|
) => {
|
||||||
if (!element) return;
|
if (!element) return;
|
||||||
|
|
||||||
let currentSize = startSize;
|
let currentSize = startSize;
|
||||||
@@ -57,18 +93,94 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
adjustFontSize(teamNameRef.current, 80, 36, setTeamNameFontSize);
|
adjustFontSize(teamNameRef.current, 80, 20, setTeamNameFontSize);
|
||||||
adjustFontSize(projectNameRef.current, 80, 24, setProjectNameFontSize);
|
adjustFontSize(userNameRef.current, 80, 36, setUserNameFontSize);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [team?.name, submission?.project_name]);
|
}, [team?.name, session?.user?.fullname]);
|
||||||
|
|
||||||
|
// Generate certificate canvas screenshot
|
||||||
|
useEffect(() => {
|
||||||
|
const generateCertificate = async () => {
|
||||||
|
if (!certificateRef.current || !team || !submission || !qrCodeUrl) return;
|
||||||
|
|
||||||
|
setIsGenerating(true);
|
||||||
|
try {
|
||||||
|
// Wait a bit for fonts and images to load
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
|
|
||||||
|
const canvas = await html2canvas(certificateRef.current, {
|
||||||
|
scale: 2,
|
||||||
|
useCORS: true,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
logging: false,
|
||||||
|
width: 1000,
|
||||||
|
height: (1000 * 2480) / 3508,
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageUrl = canvas.toDataURL('image/png');
|
||||||
|
setCertificateImage(imageUrl);
|
||||||
|
setShowTemplate(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate certificate:', error);
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
generateCertificate();
|
||||||
|
}, [team, submission, qrCodeUrl, session?.user?.fullname]);
|
||||||
|
|
||||||
|
// Download certificate
|
||||||
|
const handleDownloadCertificate = () => {
|
||||||
|
if (!certificateImage) return;
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = certificateImage;
|
||||||
|
link.download = `certificate-${team?.name || 'hackathon'}.png`;
|
||||||
|
link.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Print certificate
|
||||||
|
const handlePrintCertificate = () => {
|
||||||
|
if (!certificateImage) return;
|
||||||
|
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
if (printWindow) {
|
||||||
|
printWindow.document.write(`
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Certificate - ${team?.name}</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
||||||
|
img { max-width: 100%; height: auto; }
|
||||||
|
@media print {
|
||||||
|
@page { size: A4 landscape; margin: 0; }
|
||||||
|
body { margin: 0; }
|
||||||
|
img { width: 100%; height: auto; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<img src="${certificateImage}" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
printWindow.document.close();
|
||||||
|
printWindow.onload = () => {
|
||||||
|
printWindow.print();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (error || !certId) {
|
if (error || !certId) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
<div className="text-6xl mb-4">❌</div>
|
<div className="text-6xl mb-4">❌</div>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Invalid Certificate</h2>
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Invalid Certificate
|
||||||
|
</h2>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
{error || 'The certificate ID is invalid or malformed.'}
|
{error || 'The certificate ID is invalid or malformed.'}
|
||||||
</p>
|
</p>
|
||||||
@@ -82,7 +194,9 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
<div className="text-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>
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
<div className="text-gray-600 dark:text-gray-400">Loading certificate...</div>
|
<div className="text-gray-600 dark:text-gray-400">
|
||||||
|
Loading certificate...
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -105,15 +219,71 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
{/* Print Styles */}
|
||||||
|
<style>{`
|
||||||
|
@media print {
|
||||||
|
@page {
|
||||||
|
size: A4 landscape;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
body * {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
#certificate-wrapper {
|
||||||
|
visibility: visible;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
#certificate, #certificate * {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
#certificate {
|
||||||
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
.no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive - zoom out to fit */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#certificate-container {
|
||||||
|
transform-origin: top center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 no-print">
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Certificate</h1>
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
<p className="text-gray-600 dark:text-gray-400 mt-1">{team?.name}</p>
|
Certificate
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{team?.name}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" onClick={() => navigate(`/teams/${decodedInfo?.teamId}/submission`)}>
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/teams/${decodedInfo?.teamId}/submission`)
|
||||||
|
}
|
||||||
|
>
|
||||||
Back to Submission
|
Back to Submission
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,85 +291,207 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Certificate Content */}
|
{/* Certificate Content */}
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
<div
|
||||||
{/* Certificate Container */}
|
className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12"
|
||||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 overflow-hidden">
|
id="certificate-wrapper"
|
||||||
{/* Certificate Design Area */}
|
>
|
||||||
|
{/* Hidden Template for Canvas Generation */}
|
||||||
|
<div
|
||||||
|
className={showTemplate ? 'block' : 'hidden'}
|
||||||
|
style={{ position: 'absolute', left: '-9999px' }}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
id="certificate"
|
ref={certificateRef}
|
||||||
className="p-12 sm:p-16 bg-linear-to-br from-slate-50 to-slate-100 dark:from-gray-800 dark:to-gray-900 border-4 border-amber-600 dark:border-amber-500 min-h-[500px] flex flex-col items-center justify-center space-y-6"
|
id="certificate-template"
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
backgroundImage: 'url(/images/blank_cert.png)',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
width: '1000px',
|
||||||
|
height: `${(1000 * 2480) / 3508}px`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Decorative Element */}
|
{/* User Name (from session) */}
|
||||||
<div className="text-5xl">🏆</div>
|
<div
|
||||||
|
style={{
|
||||||
{/* Certificate Title */}
|
position: 'absolute',
|
||||||
<h2 className="text-4xl sm:text-5xl font-bold text-center text-amber-900 dark:text-amber-100">
|
top: '40%',
|
||||||
Certificate of Participation
|
left: '50%',
|
||||||
</h2>
|
transform: 'translateX(-50%)',
|
||||||
|
width: '80%',
|
||||||
{/* Decorative Line */}
|
}}
|
||||||
<div className="w-32 h-1 bg-linear-to-r from-amber-600 to-amber-400"></div>
|
>
|
||||||
|
<h3
|
||||||
|
ref={userNameRef}
|
||||||
|
style={{
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#111827',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: userNameFontSize,
|
||||||
|
lineHeight: '1.2',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
textShadow: '0 1px 2px rgba(0,0,0,0.1)',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{session?.user?.fullname || 'N/A'}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Team Name */}
|
{/* Team Name */}
|
||||||
<div className="text-center space-y-2 w-full px-4">
|
<div
|
||||||
<p className="text-gray-600 dark:text-gray-400 text-sm uppercase tracking-widest">
|
style={{
|
||||||
This Certificate is Proudly Presented To
|
position: 'absolute',
|
||||||
</p>
|
top: '46%',
|
||||||
<h3 ref={teamNameRef} className="font-bold text-gray-900 dark:text-white wrap-break-word" style={{ fontSize: teamNameFontSize, lineHeight: '1.2', wordBreak: 'break-word' }}>
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
width: '70%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
ref={teamNameRef}
|
||||||
|
style={{
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#1f2937',
|
||||||
|
textAlign: 'center',
|
||||||
|
fontSize: teamNameFontSize,
|
||||||
|
lineHeight: '1.2',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
{team?.name}
|
{team?.name}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Achievement Text */}
|
{/* Participation Text */}
|
||||||
<div className="text-center max-w-2xl w-full px-4">
|
<div
|
||||||
<p className="text-gray-700 dark:text-gray-300 text-base sm:text-lg">
|
style={{
|
||||||
For successfully submitting their hackathon project
|
position: 'absolute',
|
||||||
|
top: '60%',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
width: '70%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
textAlign: 'center',
|
||||||
|
color: '#374151',
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: '500',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontWeight: 'bold' }}>
|
||||||
|
Peserta Hackathon IMPHNEN x KOLOSAL AI
|
||||||
|
</span>
|
||||||
</p>
|
</p>
|
||||||
<p ref={projectNameRef} className="font-bold text-amber-900 dark:text-amber-100 wrap-break-word block" style={{ fontSize: projectNameFontSize, lineHeight: '1.3', wordBreak: 'break-word' }}>
|
|
||||||
{submission.project_name}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Submission Details */}
|
{/* QR Code */}
|
||||||
<div className="grid grid-cols-2 gap-8 pt-6 text-center w-full max-w-md">
|
<div
|
||||||
<div>
|
style={{
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400 uppercase tracking-widest mb-1">
|
position: 'absolute',
|
||||||
Submission ID
|
bottom: '8%',
|
||||||
</p>
|
left: '8%',
|
||||||
<p className="text-xs font-mono text-gray-900 dark:text-white break-all">
|
}}
|
||||||
{submission.id}
|
>
|
||||||
</p>
|
{qrCodeUrl && (
|
||||||
</div>
|
<div
|
||||||
<div>
|
style={{
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400 uppercase tracking-widest mb-1">
|
backgroundColor: '#ffffff',
|
||||||
Submitted
|
padding: '8px',
|
||||||
</p>
|
borderRadius: '4px',
|
||||||
<p className="text-xs text-gray-900 dark:text-white">
|
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||||
{submission.submitted_at
|
}}
|
||||||
? new Date(submission.submitted_at).toLocaleDateString('id-ID')
|
>
|
||||||
: 'N/A'}
|
<img
|
||||||
</p>
|
src={qrCodeUrl}
|
||||||
</div>
|
alt="Certificate QR Code"
|
||||||
|
style={{ width: '96px', height: '96px' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Text */}
|
{/* Date */}
|
||||||
<p className="text-xs text-gray-600 dark:text-gray-400 pt-4 italic">
|
<div
|
||||||
Authenticated Certificate - {new Date().getFullYear()}
|
style={{
|
||||||
</p>
|
position: 'absolute',
|
||||||
|
bottom: '8%',
|
||||||
|
right: '8%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
fontSize: '14px',
|
||||||
|
color: '#374151',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{submission.submitted_at
|
||||||
|
? new Date(submission.submitted_at).toLocaleDateString(
|
||||||
|
'id-ID',
|
||||||
|
{
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
: 'N/A'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Display Certificate Image */}
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 overflow-hidden p-2">
|
||||||
|
{isGenerating && (
|
||||||
|
<div className="flex items-center justify-center p-12">
|
||||||
|
<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>
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">
|
||||||
|
Generating certificate...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{certificateImage && !isGenerating && (
|
||||||
|
<img
|
||||||
|
src={certificateImage}
|
||||||
|
alt="Certificate"
|
||||||
|
className="w-full h-auto"
|
||||||
|
style={{ maxWidth: '100%', height: 'auto' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="bg-gray-50 dark:bg-gray-800 p-6 flex gap-3 justify-center">
|
<div className="bg-gray-50 dark:bg-gray-800 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => window.print()}
|
onClick={handleDownloadCertificate}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
|
disabled={isGenerating}
|
||||||
>
|
>
|
||||||
🖨️ Print Certificate
|
{isGenerating ? '⏳ Generating...' : '📥 Download'}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate(`/teams/${decodedInfo?.teamId}/submission`)}
|
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
onClick={handlePrintCertificate}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
disabled={isGenerating}
|
||||||
|
>
|
||||||
|
{isGenerating ? '⏳ Generating...' : '🖨️ Print'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/teams/${decodedInfo?.teamId}/submission`)
|
||||||
|
}
|
||||||
|
variant="secondary"
|
||||||
|
className="col-span-2 flex items-center gap-2 xl:col-span-1"
|
||||||
>
|
>
|
||||||
View Submission
|
View Submission
|
||||||
</Button>
|
</Button>
|
||||||
@@ -207,11 +499,14 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Box */}
|
{/* Info Box */}
|
||||||
<div className="mt-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
|
<div className="mt-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6 no-print">
|
||||||
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">Certificate Information</h3>
|
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
|
||||||
|
Certificate Information
|
||||||
|
</h3>
|
||||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
This certificate is a digital record of your hackathon participation and project submission.
|
This certificate is a digital record of your hackathon participation
|
||||||
You can print or save this page as a PDF for your records.
|
and project submission. You can print or save this page as a PDF for
|
||||||
|
your records.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,11 +49,13 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"framer-motion": "^12.9.2",
|
"framer-motion": "^12.9.2",
|
||||||
"graphql": "^16.11.0",
|
"graphql": "^16.11.0",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"next": "~16.0.3",
|
"next": "~16.0.3",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"openapi-fetch": "^0.15.0",
|
"openapi-fetch": "^0.15.0",
|
||||||
"openapi-react-query": "^0.5.0",
|
"openapi-react-query": "^0.5.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.1.0",
|
"react": "^19.1.0",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.1.0",
|
||||||
"react-hook-form": "^7.56.4",
|
"react-hook-form": "^7.56.4",
|
||||||
@@ -96,6 +98,7 @@
|
|||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/node": "^22.12.0",
|
"@types/node": "^22.12.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.1.2",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.1.2",
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user