Refactor code structure for improved readability and maintainability

This commit is contained in:
xirf
2025-12-09 00:46:34 +07:00
parent 97a3ac90eb
commit 38ec9a28f7
3 changed files with 4981 additions and 76 deletions
@@ -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>
+4607
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -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",