diff --git a/apps/hackathon/public/images/blank_cert.png b/apps/hackathon/public/images/blank_cert.png new file mode 100644 index 0000000..56c2c8e Binary files /dev/null and b/apps/hackathon/public/images/blank_cert.png differ diff --git a/apps/hackathon/src/app/certificate/[certId]/page.tsx b/apps/hackathon/src/app/certificate/[certId]/page.tsx new file mode 100644 index 0000000..39ceab8 --- /dev/null +++ b/apps/hackathon/src/app/certificate/[certId]/page.tsx @@ -0,0 +1,517 @@ +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, + useAuthStore, +} from '@imphnen-frontend-service/service'; +import QRCode from 'qrcode'; +import html2canvas from 'html2canvas'; + +interface DecodedCert { + teamId: string; + submissionId: string; +} + +const CertificatePage: FC = (): ReactElement => { + const { certId } = useParams<{ certId: string }>(); + const navigate = useNavigate(); + const { session } = useAuthStore(); + const [decodedInfo, setDecodedInfo] = useState(null); + const [error, setError] = useState(null); + const teamNameRef = useRef(null); + const userNameRef = useRef(null); + const [teamNameFontSize, setTeamNameFontSize] = useState('2.25rem'); + const [userNameFontSize, setUserNameFontSize] = useState('2.25rem'); + const [qrCodeUrl, setQrCodeUrl] = useState(''); + const certificateRef = useRef(null); + const [isGenerating, setIsGenerating] = useState(false); + const [certificateImage, setCertificateImage] = useState(''); + const [showTemplate, setShowTemplate] = useState(true); + + useEffect(() => { + if (certId) { + decodeCertificateId(certId) + .then(setDecodedInfo) + .catch(() => { + setError('Invalid certificate ID'); + }); + } + }, [certId]); + + // 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; + + // Dynamic font sizing: shrink by 2px if height exceeds 80px + useEffect(() => { + const adjustFontSize = ( + element: HTMLElement | null, + maxHeight: number, + startSize: number, + setter: (size: string) => void + ) => { + if (!element) return; + + let currentSize = startSize; + element.style.fontSize = `${currentSize}px`; + + while (element.offsetHeight > maxHeight && currentSize > 1) { + currentSize -= 2; + element.style.fontSize = `${currentSize}px`; + } + + setter(`${currentSize}px`); + }; + + const timer = setTimeout(() => { + adjustFontSize(teamNameRef.current, 80, 20, setTeamNameFontSize); + adjustFontSize(userNameRef.current, 80, 36, setUserNameFontSize); + }, 0); + + return () => clearTimeout(timer); + }, [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(` + + + Certificate - ${team?.name} + + + + + + + `); + printWindow.document.close(); + printWindow.onload = () => { + printWindow.print(); + }; + } + }; + + if (error || !certId) { + return ( +
+
+

+ Invalid Certificate +

+

+ {error || 'The certificate ID is invalid or malformed.'} +

+ +
+ ); + } + + if (isLoading) { + return ( +
+
+
+
+ Loading certificate... +
+
+
+ ); + } + + if (!submission || submission.id !== decodedInfo?.submissionId) { + return ( +
+
📄
+

+ Certificate Not Found +

+

+ The submission associated with this certificate could not be found. +

+ +
+ ); + } + + return ( +
+ {/* Print Styles */} + + + {/* Header */} +
+
+
+
+

+ Certificate +

+

+ {team?.name} +

+
+ +
+
+
+ + {/* Certificate Content */} +
+ {/* Hidden Template for Canvas Generation */} +
+
+ {/* User Name (from session) */} +
+

+ {session?.user?.fullname || 'N/A'} +

+
+ + {/* Team Name */} +
+

+ {team?.name} +

+
+ + {/* Participation Text */} +
+

+ + Peserta Hackathon IMPHNEN x KOLOSAL AI + +

+
+ + {/* QR Code */} +
+ {qrCodeUrl && ( +
+ Certificate QR Code +
+ )} +
+ + {/* Date */} +
+

+ {submission.submitted_at + ? new Date(submission.submitted_at).toLocaleDateString( + 'id-ID', + { + day: 'numeric', + month: 'long', + year: 'numeric', + } + ) + : 'N/A'} +

+
+
+
+ + {/* Display Certificate Image */} +
+ {isGenerating && ( +
+
+
+
+ Generating certificate... +
+
+
+ )} + + {certificateImage && !isGenerating && ( + Certificate + )} + + {/* Actions */} +
+ + + +
+
+ + {/* Info Box */} +
+

+ Certificate Information +

+

+ 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. +

+
+
+
+ ); +}; + +export default CertificatePage; diff --git a/apps/hackathon/src/app/dashboard/page.tsx b/apps/hackathon/src/app/dashboard/page.tsx index ffe5f2d..3c3a0bf 100644 --- a/apps/hackathon/src/app/dashboard/page.tsx +++ b/apps/hackathon/src/app/dashboard/page.tsx @@ -14,6 +14,9 @@ import ProfilePage from '../profile/page'; // Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7) const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z'); +// Submission deadline: 2025-12-07 23:59:00 WIB (UTC+7) +const SUBMISSION_DEADLINE = new Date('2025-12-07T16:59:00Z'); + type Invitation = { id: string; team: { @@ -37,9 +40,46 @@ type Invitation = { const DashboardPage: FC = (): ReactElement => { const { session } = useAuthStore(); const [showProfileModal, setShowProfileModal] = useState(false); + const [timeLeft, setTimeLeft] = useState<{ + days: number; + hours: number; + minutes: number; + seconds: number; + } | null>(null); // Check if team features are closed const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE; + + // Check if submission deadline passed + const isSubmissionDeadlinePassed = new Date() >= SUBMISSION_DEADLINE; + + // Countdown timer + useEffect(() => { + if (isSubmissionDeadlinePassed) return; + + const calculateTimeLeft = () => { + const now = new Date(); + const difference = SUBMISSION_DEADLINE.getTime() - now.getTime(); + + if (difference <= 0) { + setTimeLeft(null); + return; + } + + const days = Math.floor(difference / (1000 * 60 * 60 * 24)); + const hours = Math.floor((difference / (1000 * 60 * 60)) % 24); + const minutes = Math.floor((difference / 1000 / 60) % 60); + const seconds = Math.floor((difference / 1000) % 60); + + setTimeLeft({ days, hours, minutes, seconds }); + }; + + calculateTimeLeft(); + const timer = setInterval(calculateTimeLeft, 1000); + + return () => clearInterval(timer); + }, [isSubmissionDeadlinePassed]); + // Lock background scroll when profile modal is open useEffect(() => { if (showProfileModal) { @@ -94,6 +134,57 @@ const DashboardPage: FC = (): ReactElement => { )} + {/* Countdown Timer */} + {timeLeft && !isSubmissionDeadlinePassed && ( +
+
+ +
+

+ Submission Deadline +

+

+ Project submissions close on December 7, 2025 at 23:59 WIB +

+
+
+
+ {timeLeft.days} +
+
+ Days +
+
+
+
+ {timeLeft.hours.toString().padStart(2, '0')} +
+
+ Hours +
+
+
+
+ {timeLeft.minutes.toString().padStart(2, '0')} +
+
+ Minutes +
+
+
+
+ {timeLeft.seconds.toString().padStart(2, '0')} +
+
+ Seconds +
+
+
+
+
+
+ )} + {invitations.length > 0 && (

diff --git a/apps/hackathon/src/app/teams/[teamId]/submission/page.tsx b/apps/hackathon/src/app/teams/[teamId]/submission/page.tsx index 66325dc..1009580 100644 --- a/apps/hackathon/src/app/teams/[teamId]/submission/page.tsx +++ b/apps/hackathon/src/app/teams/[teamId]/submission/page.tsx @@ -2,6 +2,7 @@ import { FC, ReactElement } from 'react'; import { Button } from '@imphnen-frontend-service/ui/atoms'; import { useNavigate, useParams } from 'react-router'; import { useTeamById, useTeamSubmission } from '@imphnen-frontend-service/service'; +import { encodeCertificateId } from '../../../../utils/certificate'; const SubmissionViewPage: FC = (): ReactElement => { const { teamId } = useParams<{ teamId: string }>(); @@ -15,18 +16,18 @@ const SubmissionViewPage: FC = (): ReactElement => { if (isLoading) { return ( -
-
Loading submission...
+
+
Loading submission...
); } if (!submission) { return ( -
+
📄

No Submission Yet

-

Your team hasn't submitted a project

+

Your team hasn't submitted a project

); @@ -43,13 +44,13 @@ const SubmissionViewPage: FC = (): ReactElement => { : 'Not submitted'; return ( -
-
+
+

Project Submission

-

{team?.name}

+

{team?.name}

)} -
+ {/* Certificate Banner - Only show when submission is submitted */} + {submission.status === 'submitted' && ( +
+
+
+ 🏆 +
+

+ View Your Certificate +

+

+ Congratulations! Your certificate is ready to download and share. +

+
+
+ +
+
+ )} + +
{/* Project Header */} -
+

{submission.project_name}

Team: {team?.name}

@@ -113,8 +142,8 @@ const SubmissionViewPage: FC = (): ReactElement => { {/* Description */}

Project Description

-
-

{submission.description}

+
+

{submission.description}

@@ -170,7 +199,7 @@ const SubmissionViewPage: FC = (): ReactElement => { {`Screenshot ))} @@ -179,11 +208,11 @@ const SubmissionViewPage: FC = (): ReactElement => { )} {/* Submission Info */} -
+

Submission Information

- Status: + Status: {
- Submitted: + Submitted: {submittedDate}
- Submission ID: + Submission ID: {submission.id} diff --git a/apps/hackathon/src/app/teams/[teamId]/submit/page.tsx b/apps/hackathon/src/app/teams/[teamId]/submit/page.tsx index c92c93e..2460e89 100644 --- a/apps/hackathon/src/app/teams/[teamId]/submit/page.tsx +++ b/apps/hackathon/src/app/teams/[teamId]/submit/page.tsx @@ -1,4 +1,4 @@ -import { FC, ReactElement, useState } from 'react'; +import { FC, ReactElement, useState, useEffect } from 'react'; import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms'; import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms'; import { useNavigate, useParams } from 'react-router'; @@ -14,10 +14,14 @@ import { } from '@imphnen-frontend-service/service'; import { zodResolver } from '@hookform/resolvers/zod'; import { toast } from 'sonner'; +import { Icon } from '@iconify/react'; const MIN_TEAM_MEMBERS = 2; // Minimum members required to submit (including leader) const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB +// Submission deadline: 2025-12-07 23:59:00 WIB (UTC+7) +const SUBMISSION_DEADLINE = new Date('2025-12-07T16:59:00Z'); + const SubmitProjectPage: FC = (): ReactElement => { const { teamId } = useParams<{ teamId: string }>(); const navigate = useNavigate(); @@ -25,6 +29,42 @@ const SubmitProjectPage: FC = (): ReactElement => { const [showConfirmModal, setShowConfirmModal] = useState(false); const [confirmText, setConfirmText] = useState(''); const [screenshots, setScreenshots] = useState([]); + const [timeLeft, setTimeLeft] = useState<{ + days: number; + hours: number; + minutes: number; + seconds: number; + } | null>(null); + + // Check if deadline passed + const isDeadlinePassed = new Date() >= SUBMISSION_DEADLINE; + + // Countdown timer + useEffect(() => { + if (isDeadlinePassed) return; + + const calculateTimeLeft = () => { + const now = new Date(); + const difference = SUBMISSION_DEADLINE.getTime() - now.getTime(); + + if (difference <= 0) { + setTimeLeft(null); + return; + } + + const days = Math.floor(difference / (1000 * 60 * 60 * 24)); + const hours = Math.floor((difference / (1000 * 60 * 60)) % 24); + const minutes = Math.floor((difference / 1000 / 60) % 60); + const seconds = Math.floor((difference / 1000) % 60); + + setTimeLeft({ days, hours, minutes, seconds }); + }; + + calculateTimeLeft(); + const timer = setInterval(calculateTimeLeft, 1000); + + return () => clearInterval(timer); + }, [isDeadlinePassed]); const { data: teamData } = useTeamById(teamId || ''); const { data: submissionData } = useTeamSubmission(teamId || '', !!teamId); @@ -85,6 +125,50 @@ const SubmitProjectPage: FC = (): ReactElement => { ); } + // Show deadline passed screen + if (isDeadlinePassed) { + return ( +
+
+
+
+ +
+

+ Submission Closed +

+

+ Project submissions are no longer accepted. +

+
+ +
+

+ The submission deadline was December 7, 2025 at 23:59 WIB. +

+ + + + +
+
+
+ ); + } + const handleScreenshotUpload = async ( e: React.ChangeEvent ) => { @@ -138,6 +222,57 @@ const SubmitProjectPage: FC = (): ReactElement => {
+ {/* Countdown Timer */} + {timeLeft && ( +
+
+ +
+

+ Submission Deadline +

+

+ Submissions close on December 7, 2025 at 23:59 WIB +

+
+
+
+ {timeLeft.days} +
+
+ Days +
+
+
+
+ {timeLeft.hours.toString().padStart(2, '0')} +
+
+ Hours +
+
+
+
+ {timeLeft.minutes.toString().padStart(2, '0')} +
+
+ Minutes +
+
+
+
+ {timeLeft.seconds.toString().padStart(2, '0')} +
+
+ Seconds +
+
+
+
+
+
+ )} + {/* Minimum Members Warning */} {!hasEnoughMembers && (
@@ -200,6 +335,9 @@ const SubmitProjectPage: FC = (): ReactElement => { +

+ Describe your project, its features, and what problem it solves. You can also paste your demo video link here. +

{