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 { Button } from '@imphnen-frontend-service/ui/atoms';
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 {
teamId: string;
@@ -12,12 +18,18 @@ interface DecodedCert {
const CertificatePage: FC = (): ReactElement => {
const { certId } = useParams<{ certId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const [decodedInfo, setDecodedInfo] = useState<DecodedCert | null>(null);
const [error, setError] = useState<string | null>(null);
const teamNameRef = useRef<HTMLHeadingElement>(null);
const projectNameRef = useRef<HTMLSpanElement>(null);
const userNameRef = useRef<HTMLHeadingElement>(null);
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(() => {
if (certId) {
@@ -29,20 +41,44 @@ const CertificatePage: FC = (): ReactElement => {
}
}, [certId]);
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(decodedInfo?.teamId || '', !!decodedInfo?.teamId);
const { data: submissionData, isLoading: isLoadingSubmission } = useTeamSubmission(
// Generate QR Code
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
);
const { data: submissionData, isLoading: isLoadingSubmission } =
useTeamSubmission(decodedInfo?.teamId || '', !!decodedInfo?.teamId);
const team = teamData?.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
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;
let currentSize = startSize;
@@ -57,18 +93,94 @@ const CertificatePage: FC = (): ReactElement => {
};
const timer = setTimeout(() => {
adjustFontSize(teamNameRef.current, 80, 36, setTeamNameFontSize);
adjustFontSize(projectNameRef.current, 80, 24, setProjectNameFontSize);
adjustFontSize(teamNameRef.current, 80, 20, setTeamNameFontSize);
adjustFontSize(userNameRef.current, 80, 36, setUserNameFontSize);
}, 0);
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) {
return (
<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>
<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">
{error || 'The certificate ID is invalid or malformed.'}
</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="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">Loading certificate...</div>
<div className="text-gray-600 dark:text-gray-400">
Loading certificate...
</div>
</div>
</div>
);
@@ -105,15 +219,71 @@ const CertificatePage: FC = (): ReactElement => {
return (
<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 */}
<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="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Certificate</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">{team?.name}</p>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Certificate
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{team?.name}
</p>
</div>
<Button variant="secondary" onClick={() => navigate(`/teams/${decodedInfo?.teamId}/submission`)}>
<Button
variant="secondary"
onClick={() =>
navigate(`/teams/${decodedInfo?.teamId}/submission`)
}
>
Back to Submission
</Button>
</div>
@@ -121,85 +291,207 @@ const CertificatePage: FC = (): ReactElement => {
</div>
{/* Certificate Content */}
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
{/* Certificate Container */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 overflow-hidden">
{/* Certificate Design Area */}
<div
className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12"
id="certificate-wrapper"
>
{/* Hidden Template for Canvas Generation */}
<div
className={showTemplate ? 'block' : 'hidden'}
style={{ position: 'absolute', left: '-9999px' }}
>
<div
id="certificate"
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"
ref={certificateRef}
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 */}
<div className="text-5xl">🏆</div>
{/* Certificate Title */}
<h2 className="text-4xl sm:text-5xl font-bold text-center text-amber-900 dark:text-amber-100">
Certificate of Participation
</h2>
{/* Decorative Line */}
<div className="w-32 h-1 bg-linear-to-r from-amber-600 to-amber-400"></div>
{/* User Name (from session) */}
<div
style={{
position: 'absolute',
top: '40%',
left: '50%',
transform: 'translateX(-50%)',
width: '80%',
}}
>
<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 */}
<div className="text-center space-y-2 w-full px-4">
<p className="text-gray-600 dark:text-gray-400 text-sm uppercase tracking-widest">
This Certificate is Proudly Presented To
</p>
<h3 ref={teamNameRef} className="font-bold text-gray-900 dark:text-white wrap-break-word" style={{ fontSize: teamNameFontSize, lineHeight: '1.2', wordBreak: 'break-word' }}>
<div
style={{
position: 'absolute',
top: '46%',
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}
</h3>
</div>
{/* Achievement Text */}
<div className="text-center max-w-2xl w-full px-4">
<p className="text-gray-700 dark:text-gray-300 text-base sm:text-lg">
For successfully submitting their hackathon project
{/* Participation Text */}
<div
style={{
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 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>
{/* Submission Details */}
<div className="grid grid-cols-2 gap-8 pt-6 text-center w-full max-w-md">
<div>
<p className="text-xs text-gray-600 dark:text-gray-400 uppercase tracking-widest mb-1">
Submission ID
</p>
<p className="text-xs font-mono text-gray-900 dark:text-white break-all">
{submission.id}
</p>
</div>
<div>
<p className="text-xs text-gray-600 dark:text-gray-400 uppercase tracking-widest mb-1">
Submitted
</p>
<p className="text-xs text-gray-900 dark:text-white">
{submission.submitted_at
? new Date(submission.submitted_at).toLocaleDateString('id-ID')
: 'N/A'}
</p>
</div>
{/* QR Code */}
<div
style={{
position: 'absolute',
bottom: '8%',
left: '8%',
}}
>
{qrCodeUrl && (
<div
style={{
backgroundColor: '#ffffff',
padding: '8px',
borderRadius: '4px',
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
}}
>
<img
src={qrCodeUrl}
alt="Certificate QR Code"
style={{ width: '96px', height: '96px' }}
/>
</div>
)}
</div>
{/* Footer Text */}
<p className="text-xs text-gray-600 dark:text-gray-400 pt-4 italic">
Authenticated Certificate - {new Date().getFullYear()}
</p>
{/* Date */}
<div
style={{
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>
{/* 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 */}
<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
variant="secondary"
onClick={() => window.print()}
onClick={handleDownloadCertificate}
className="flex items-center gap-2"
disabled={isGenerating}
>
🖨 Print Certificate
{isGenerating ? '⏳ Generating...' : '📥 Download'}
</Button>
<Button
onClick={() => navigate(`/teams/${decodedInfo?.teamId}/submission`)}
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
</Button>
@@ -207,11 +499,14 @@ const CertificatePage: FC = (): ReactElement => {
</div>
{/* 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">
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">Certificate Information</h3>
<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>
<p className="text-sm text-blue-800 dark:text-blue-200">
This certificate is a digital record of your hackathon participation and project submission.
You can print or save this page as a PDF for your records.
This certificate is a digital record of your hackathon participation
and project submission. You can print or save this page as a PDF for
your records.
</p>
</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",
"framer-motion": "^12.9.2",
"graphql": "^16.11.0",
"html2canvas": "^1.4.1",
"js-cookie": "^3.0.5",
"next": "~16.0.3",
"next-themes": "^0.4.6",
"openapi-fetch": "^0.15.0",
"openapi-react-query": "^0.5.0",
"qrcode": "^1.5.4",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-hook-form": "^7.56.4",
@@ -96,6 +98,7 @@
"@testing-library/user-event": "^14.6.1",
"@types/js-cookie": "^3.0.6",
"@types/node": "^22.12.0",
"@types/qrcode": "^1.5.6",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.1.2",
"@vitejs/plugin-react": "^4.2.0",