From 38ee9a954980d87b9b989b046cd495eb6281f5b4 Mon Sep 17 00:00:00 2001 From: Hafid Nur <73023445+hafidnrzs@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:43:11 +0700 Subject: [PATCH 1/2] update hackathon landing page (#72) --- apps/hackathon/src/app/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/hackathon/src/app/page.tsx b/apps/hackathon/src/app/page.tsx index 6b35a26..8dc707c 100644 --- a/apps/hackathon/src/app/page.tsx +++ b/apps/hackathon/src/app/page.tsx @@ -554,7 +554,7 @@ export default function HomePage() { {/* Judge 2 */}

- Anka Tama + Muhammad Alif Ramadhan

Admin

From fe172cc1283188690ea0a4ae071f8b3ba4883f21 Mon Sep 17 00:00:00 2001 From: Hafid Nur <73023445+hafidnrzs@users.noreply.github.com> Date: Tue, 16 Dec 2025 12:33:23 +0700 Subject: [PATCH 2/2] feat(hackathon): winner certificate (#73) * update hackathon landing page * feat(hackathon): draft winner certificate * feat(hackathon): refine 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 | 569 ++++++++++++++++++ apps/hackathon/src/app/dashboard/page.tsx | 74 ++- apps/hackathon/src/utils/certificate.ts | 31 + 6 files changed, 1040 insertions(+), 18 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 (isLoadingWinners) { + return ( +
+
+
+
+ Loading winners... +
+
+
+ ); + } + + if (isWinnersError) { + return ( +
+

+ Unable to Load Winners +

+

+ Please try again later. +

+ +
+ ); + } + + 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 */} +
+

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

+
+ + {/* Rank badge */} + {!!rankLabel && ( +
+
+ {rankLabel} +
+
+ )} + + {/* 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..7dfa2bc 100644 --- a/apps/hackathon/src/app/dashboard/page.tsx +++ b/apps/hackathon/src/app/dashboard/page.tsx @@ -1,15 +1,17 @@ -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, useRespondToInvitation, useAuthStore, + useWinners, } from '@imphnen-frontend-service/service'; import { toast } from 'sonner'; import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Icon } from '@iconify/react'; import ProfilePage from '../profile/page'; +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; @@ -91,6 +94,7 @@ const DashboardPage: FC = (): ReactElement => { } }, [showProfileModal]); const { data: teamsData } = useMyTeams(); + const { data: winnersResponse } = useWinners(); const { data: invitationsData } = useMyInvitations(); const { mutateAsync: respondToInvitation } = useRespondToInvitation(); @@ -99,6 +103,13 @@ const DashboardPage: FC = (): ReactElement => { const invitations: Invitation[] = (invitationsData?.data || []) as Invitation[]; + const winnerEntry = useMemo(() => { + const team = (myTeams[0] as { id?: string } | null | undefined) || null; + const winners = winnersResponse?.data || []; + if (!team?.id) return null; + return winners.find((w) => w.team_id === team.id) || null; + }, [myTeams, winnersResponse?.data]); + const handleAcceptInvitation = async (invitationId: string) => { try { await respondToInvitation({ invitationId, action: 'accept' }); @@ -134,6 +145,37 @@ 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 && (
@@ -194,7 +236,8 @@ const DashboardPage: FC = (): ReactElement => {

- Team features are closed. You can no longer accept invitations. + Team features are closed. You can no longer accept + invitations.

)} @@ -263,7 +306,10 @@ const DashboardPage: FC = (): ReactElement => { /> ) : (
- +
)}
@@ -273,19 +319,33 @@ const DashboardPage: FC = (): ReactElement => {
{team.has_submission && ( - + Submitted )} {team.city && ( - + {team.city} )} - {team.member_count || team.members?.length || 0} member{(team.member_count || team.members?.length || 0) !== 1 ? 's' : ''} + {team.member_count || + team.members?.length || + 0}{' '} + member + {(team.member_count || + team.members?.length || + 0) !== 1 + ? 's' + : ''}
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