From 48d97af04455912f29d3523c3fd1191bafb98c8a Mon Sep 17 00:00:00 2001 From: Hafid Nur <73023445+hafidnrzs@users.noreply.github.com> Date: Tue, 16 Dec 2025 11:26:00 +0700 Subject: [PATCH] feat(hackathon): draft winner certificate --- .../public/images/blank_winner_cert.svg | 132 ++++++ .../src/app/certificate/[certId]/page.tsx | 16 +- .../src/app/certificate/winner-data.json | 236 ++++++++++ .../app/certificate/winner/[certId]/page.tsx | 445 ++++++++++++++++++ apps/hackathon/src/app/dashboard/page.tsx | 43 +- apps/hackathon/src/utils/certificate.ts | 31 ++ 6 files changed, 890 insertions(+), 13 deletions(-) create mode 100644 apps/hackathon/public/images/blank_winner_cert.svg create mode 100644 apps/hackathon/src/app/certificate/winner-data.json create mode 100644 apps/hackathon/src/app/certificate/winner/[certId]/page.tsx diff --git a/apps/hackathon/public/images/blank_winner_cert.svg b/apps/hackathon/public/images/blank_winner_cert.svg new file mode 100644 index 0000000..6e04da2 --- /dev/null +++ b/apps/hackathon/public/images/blank_winner_cert.svg @@ -0,0 +1,132 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/hackathon/src/app/certificate/[certId]/page.tsx b/apps/hackathon/src/app/certificate/[certId]/page.tsx index 3506046..1a87e8e 100644 --- a/apps/hackathon/src/app/certificate/[certId]/page.tsx +++ b/apps/hackathon/src/app/certificate/[certId]/page.tsx @@ -42,10 +42,8 @@ const CertificatePage: FC = (): ReactElement => { }, [certId]); // Fetch certificate data using the new endpoint - const { data: certificateData, isLoading: isLoadingCertificate } = useCertificatePublicData( - decodedInfo?.userId || '', - !!decodedInfo?.userId - ); + const { data: certificateData, isLoading: isLoadingCertificate } = + useCertificatePublicData(decodedInfo?.userId || '', !!decodedInfo?.userId); // Generate QR Code useEffect(() => { @@ -292,9 +290,7 @@ const CertificatePage: FC = (): ReactElement => { {team && isTeamMember && ( @@ -425,7 +421,7 @@ const CertificatePage: FC = (): ReactElement => { {/* Actions */} {isTeamMember && ( -
+
{team && ( +
+ ); + } + + if (!decodedTeamId || isLoadingTeam) { + return ( +
+
+
+
+ Loading certificate... +
+
+
+ ); + } + + if (!isWinnerTeam) { + return ( +
+

+ Certificate Not Found +

+

+ This team is not listed as a hackathon winner. +

+ +
+ ); + } + + if (!team?.name) { + return ( +
+

+ Team Not Found +

+

+ The team associated with this certificate could not be loaded. +

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

+ Winner Certificate +

+

+ {team.name} +

+
+ {isTeamMember && ( + + )} +
+
+
+ +
+ {/* Hidden Template for Canvas Generation */} +
+
+ + + {/* Team Name (below "Diberikan Kepada") */} +
+

+ {team.name} +

+
+ + {/* Members list */} +
+
    + {(memberNames.length ? memberNames : ['(Members unavailable)']).map((name) => ( +
  • {name}
  • + ))} +
+
+ + {/* Award text */} +
+

+ Diberikan sebagai penghargaan atas pencapaian meraih +
+ JUARA {winnerEntry.rank} pada Hackathon IMPHNEN x Kolosal.ai +

+
+ + {/* QR Code (same placement as existing certificate page) */} +
+ {qrCodeUrl && ( + Certificate QR Code + )} +
+
+
+ + {/* Display Certificate Image */} +
+ {isGenerating && ( +
+
+
+
+ Generating certificate... +
+
+
+ )} + + {certificateImage && !isGenerating && ( + Winner Certificate + )} + + {/* Actions */} + {isTeamMember && ( +
+ + + +
+ )} +
+ + {/* Info Box */} +
+

+ Certificate Information +

+

+ This certificate is a digital record of your hackathon achievement. +

+
+
+
+ ); +}; + +export default CertificateWinnerPage; diff --git a/apps/hackathon/src/app/dashboard/page.tsx b/apps/hackathon/src/app/dashboard/page.tsx index 3c3a0bf..ff361cf 100644 --- a/apps/hackathon/src/app/dashboard/page.tsx +++ b/apps/hackathon/src/app/dashboard/page.tsx @@ -1,5 +1,5 @@ -import { FC, ReactElement, useEffect, useState } from 'react'; -import { Link } from 'react-router'; +import { FC, ReactElement, useEffect, useMemo, useState } from 'react'; +import { Link, useNavigate } from 'react-router'; import { useMyTeams, useMyInvitations, @@ -10,6 +10,8 @@ import { toast } from 'sonner'; import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Icon } from '@iconify/react'; import ProfilePage from '../profile/page'; +import winnerData from '../certificate/winner-data.json'; +import { encodeWinnerCertificateId } from '../../utils/certificate'; // Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7) const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z'); @@ -39,6 +41,7 @@ type Invitation = { const DashboardPage: FC = (): ReactElement => { const { session } = useAuthStore(); + const navigate = useNavigate(); const [showProfileModal, setShowProfileModal] = useState(false); const [timeLeft, setTimeLeft] = useState<{ days: number; @@ -99,6 +102,13 @@ const DashboardPage: FC = (): ReactElement => { const invitations: Invitation[] = (invitationsData?.data || []) as Invitation[]; + const winnerEntry = useMemo(() => { + const team = (myTeams[0] as any) || null; + const winners = (winnerData as any)?.data || []; + if (!team?.id || !Array.isArray(winners)) return null; + return winners.find((w: any) => w?.team_id === team.id) || null; + }, [myTeams]); + const handleAcceptInvitation = async (invitationId: string) => { try { await respondToInvitation({ invitationId, action: 'accept' }); @@ -134,6 +144,35 @@ const DashboardPage: FC = (): ReactElement => { )}
+ {/* Winner Banner */} + {winnerEntry && myTeams.length > 0 && ( +
+
+
+ 🏆 +
+

+ Selamat! Tim Anda meraih JUARA {winnerEntry.rank} +

+

+ Anda dapat generate sertifikat penghargaan dan membagikannya. +

+
+
+ +
+
+ )} + {/* Countdown Timer */} {timeLeft && !isSubmissionDeadlinePassed && (
diff --git a/apps/hackathon/src/utils/certificate.ts b/apps/hackathon/src/utils/certificate.ts index 31f4d34..e22dbd1 100644 --- a/apps/hackathon/src/utils/certificate.ts +++ b/apps/hackathon/src/utils/certificate.ts @@ -15,6 +15,16 @@ export const encodeCertificateId = async (teamId: string, submissionId: string, return encryptText(combined, SECRET_KEY); }; +/** + * Encode winner certificate ID (team-based) + * @param teamId - Winner team ID + * @returns Encoded winner certificate ID + */ +export const encodeWinnerCertificateId = async (teamId: string): Promise => { + const combined = `winner::${teamId}`; + return encryptText(combined, SECRET_KEY); +}; + /** * Decode certificate ID back to teamId, submissionId, and userId * @param certId - The encoded certificate ID @@ -40,6 +50,27 @@ export const decodeCertificateId = async (certId: string): Promise<{ teamId: str } }; +/** + * Decode winner certificate ID back to teamId + * @param certId - The encoded winner certificate ID + * @returns Object containing teamId + */ +export const decodeWinnerCertificateId = async (certId: string): Promise<{ teamId: string }> => { + try { + const decoded = await decryptText(certId, SECRET_KEY); + const parts = decoded.split('::'); + + // winner::teamId + if (parts.length === 2 && parts[0] === 'winner') { + return { teamId: parts[1] }; + } + + throw new Error('Invalid winner certificate format'); + } catch { + throw new Error('Invalid certificate ID'); + } +}; + /** * For development: Create a certId using created_at timestamp * @param teamId - The team ID