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 && (
+ {/* Winner Banner */}
+ {winnerEntry && myTeams.length > 0 && (
+
+
+
+
🏆
+
+
+ Selamat! Tim Anda meraih JUARA {winnerEntry.rank}
+
+
+ Anda dapat generate sertifikat penghargaan dan
+ membagikannya.
+
+
+
+
{
+ const team = myTeams[0] as { id?: string } | null | undefined;
+ if (!team?.id) return;
+ const certId = await encodeWinnerCertificateId(team.id);
+ navigate(`/certificate/winner/${encodeURIComponent(certId)}`);
+ }}
+ className="shrink-0 px-6 py-2 bg-amber-600 hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-700 text-white font-medium rounded-lg transition-colors cursor-pointer"
+ >
+ Generate Sertifikat Juara
+
+
+
+ )}
+
{/* 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