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..cfbfb07 --- /dev/null +++ b/apps/hackathon/src/app/certificate/[certId]/page.tsx @@ -0,0 +1,222 @@ +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'; + +interface DecodedCert { + teamId: string; + submissionId: string; +} + +const CertificatePage: FC = (): ReactElement => { + const { certId } = useParams<{ certId: string }>(); + const navigate = useNavigate(); + const [decodedInfo, setDecodedInfo] = useState(null); + const [error, setError] = useState(null); + const teamNameRef = useRef(null); + const projectNameRef = useRef(null); + const [teamNameFontSize, setTeamNameFontSize] = useState('2.25rem'); + const [projectNameFontSize, setProjectNameFontSize] = useState('1.5rem'); + + useEffect(() => { + if (certId) { + decodeCertificateId(certId) + .then(setDecodedInfo) + .catch(() => { + setError('Invalid certificate ID'); + }); + } + }, [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, 36, setTeamNameFontSize); + adjustFontSize(projectNameRef.current, 80, 24, setProjectNameFontSize); + }, 0); + + return () => clearTimeout(timer); + }, [team?.name, submission?.project_name]); + + 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 ( +
+ {/* Header */} +
+
+
+
+

Certificate

+

{team?.name}

+
+ +
+
+
+ + {/* Certificate Content */} +
+ {/* Certificate Container */} +
+ {/* Certificate Design Area */} +
+ {/* Decorative Element */} +
🏆
+ + {/* Certificate Title */} +

+ Certificate of Participation +

+ + {/* Decorative Line */} +
+ + {/* Team Name */} +
+

+ This Certificate is Proudly Presented To +

+

+ {team?.name} +

+
+ + {/* Achievement Text */} +
+

+ For successfully submitting their hackathon project +

+

+ {submission.project_name} +

+
+ + {/* Submission Details */} +
+
+

+ Submission ID +

+

+ {submission.id} +

+
+
+

+ Submitted +

+

+ {submission.submitted_at + ? new Date(submission.submitted_at).toLocaleDateString('id-ID') + : 'N/A'} +

+
+
+ + {/* Footer Text */} +

+ Authenticated Certificate - {new Date().getFullYear()} +

+
+ + {/* 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/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/winners/page.tsx b/apps/hackathon/src/app/winners/page.tsx new file mode 100644 index 0000000..39a7004 --- /dev/null +++ b/apps/hackathon/src/app/winners/page.tsx @@ -0,0 +1,318 @@ +import { FC, ReactElement } from 'react'; +import { useNavigate } from 'react-router'; +import { useWinners } from '@imphnen-frontend-service/service'; + +const WinnerPage: FC = (): ReactElement => { + const navigate = useNavigate(); + const { data, isLoading, error } = useWinners(); + + const winners = data?.data ?? []; + + if (isLoading) { + return ( +
+
+
+
+ Loading winners... +
+
+
+ ); + } + + if (error) { + return ( +
+
⚠️
+

+ Error Loading Winners +

+

+ Unable to load winners at this time. Please try again later. +

+
+ ); + } + + // Sort winners by rank + const sortedWinners = [...winners].sort((a, b) => a.rank - b.rank); + + // Medal emojis for top 3 + const getMedalEmoji = (rank: number) => { + switch (rank) { + case 1: + return '🥇'; + case 2: + return '🥈'; + case 3: + return '🥉'; + default: + return '🏆'; + } + }; + + // Get rank color + const getRankColor = (rank: number) => { + switch (rank) { + case 1: + return 'from-yellow-400 to-yellow-600'; + case 2: + return 'from-gray-300 to-gray-500'; + case 3: + return 'from-amber-600 to-amber-800'; + default: + return 'from-blue-500 to-blue-700'; + } + }; + + return ( +
+ {/* Header */} +
+
+
+
🏆
+

+ Hackathon Winners +

+

+ Congratulations to all the winning teams! +

+
+
+
+ + {/* Winners List */} +
+ {winners.length === 0 ? ( +
+
🎯
+

+ No Winners Announced Yet +

+

+ Winners will be announced here once the hackathon concludes. +

+
+ ) : ( +
+ {/* Top 3 Winners - Mobile View */} +
+ {[1, 2, 3].map((position) => { + const winner = sortedWinners[position - 1]; + + if(!winner) return null; + + return ( +
+
+
+
+ {getMedalEmoji(winner.rank)} +
+
+ + {winner.team.logo && ( + {`${winner.team.name} + )} + +
+

+ {winner.team.name} +

+

+ {winner.team.city} +

+
+
+
+ ); + })} +
+ + {/* Top 3 Winners - Tablet/Desktop Podium View */} +
+ {[2, 1, 3].map((position) => { + const winner = sortedWinners[position - 1]; + if(!winner) return null; + return ( +
+
{getMedalEmoji(winner.rank)}
+ + {/* Team Logo */} + {winner.team.logo && ( + {`${winner.team.name} + )} + +
+

+ {winner.team.name} +

+

+ {winner.team.city} +

+
+ + {/* Podium */} +
+
+ #{winner.rank} +
+
+
+ ); + })} +
+ + {/* Ranks 4-23: Prize Winners */} + {sortedWinners.filter((w) => w.rank >= 4 && w.rank <= 23).length > + 0 && ( +
+

+ Favorite +

+
+ {sortedWinners + .filter((w) => w.rank >= 4 && w.rank <= 23) + .map((winner) => ( +
+
+
+
+ #{winner.rank} +
+
+ + {winner.team.logo && ( + {`${winner.team.name} + )} + +
+

+ {winner.team.name} +

+

+ {winner.team.city} +

+
+ + 🎁 Prize Winner + +
+
+
+
+ ))} +
+
+ )} + + {/* Rank 24+: Remaining Participants */} + {sortedWinners.filter((w) => w.rank >= 24).length > 0 && ( +
+

+ All Participants +

+
+
+ {sortedWinners + .filter((w) => w.rank >= 24) + .map((participant) => ( +
+
+
+ + #{participant.rank} + +
+ + {participant.team.logo && ( + {`${participant.team.name} + )} + +
+

+ {participant.team.name} +

+

+ {participant.team.city} +

+
+
+
+ ))} +
+
+
+ )} +
+ )} +
+ + {/* Footer Info */} + {winners.length > 0 && ( +
+
+

+ Congratulations! 🎉 +

+

+ Thank you to all participants for making this hackathon a success. + Every project and idea contributed to an incredible showcase of + innovation and creativity. +

+
+
+ )} +
+ ); +}; + +export default WinnerPage; diff --git a/apps/hackathon/src/utils/aesclient.ts b/apps/hackathon/src/utils/aesclient.ts new file mode 100644 index 0000000..0485330 --- /dev/null +++ b/apps/hackathon/src/utils/aesclient.ts @@ -0,0 +1,86 @@ +const enc = new TextEncoder(); +const dec = new TextDecoder(); + +function randBytes(len: number): Uint8Array { + const b = new Uint8Array(len); + crypto.getRandomValues(b); + return b; +} + +function bufToBase64(buf: ArrayBuffer): string { + const bytes = new Uint8Array(buf); + let s = ''; + for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]); + return btoa(s); +} + +function base64ToBuf(b64: string): ArrayBuffer { + const s = atob(b64); + const arr = new Uint8Array(s.length); + for (let i = 0; i < s.length; i++) arr[i] = s.charCodeAt(i); + return arr.buffer; +} + +async function deriveKeyFromPassphrase(passphrase: string, salt: Uint8Array, iterations = 100_000) { + const passKey = await crypto.subtle.importKey( + 'raw', + enc.encode(passphrase), + { name: 'PBKDF2' }, + false, + ['deriveKey'] + ); + + return crypto.subtle.deriveKey( + { + name: 'PBKDF2', + salt, + iterations, + hash: 'SHA-256' + }, + passKey, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ); +} + +/** + * Encrypts plaintext with passphrase -> returns base64(salt||iv||ciphertext) + */ +export async function encryptText(plaintext: string, passphrase: string) { + const salt = randBytes(16); // 128-bit salt + const iv = randBytes(12); // 96-bit IV recommended for GCM + const key = await deriveKeyFromPassphrase(passphrase, salt); + + const cipher = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + key, + enc.encode(plaintext) + ); + + // concat salt + iv + ciphertext + const out = new Uint8Array(salt.length + iv.length + cipher.byteLength); + out.set(salt, 0); + out.set(iv, salt.length); + out.set(new Uint8Array(cipher), salt.length + iv.length); + + return bufToBase64(out.buffer); +} + +/** + * Decrypts base64(salt||iv||ciphertext) with passphrase -> plaintext + */ +export async function decryptText(b64combined: string, passphrase: string) { + const combined = new Uint8Array(base64ToBuf(b64combined)); + const salt = combined.slice(0, 16); + const iv = combined.slice(16, 28); + const cipher = combined.slice(28); + + const key = await deriveKeyFromPassphrase(passphrase, salt); + const plainBuf = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv }, + key, + cipher + ); + return dec.decode(plainBuf); +} diff --git a/apps/hackathon/src/utils/certificate.ts b/apps/hackathon/src/utils/certificate.ts new file mode 100644 index 0000000..8f58931 --- /dev/null +++ b/apps/hackathon/src/utils/certificate.ts @@ -0,0 +1,41 @@ +import { decryptText, encryptText } from "./aesclient"; + +const SECRET_KEY = 'imphnen-hackathon-2025'; + +/** + * Encode teamId and submissionId into a certificate ID + * Uses base64 encoding for simple obfuscation + * @param teamId - The team ID + * @param submissionId - The submission ID + * @returns Encoded certificate ID + */ +export const encodeCertificateId = async (teamId: string, submissionId: string): Promise => { + const combined = `${teamId}::${submissionId}`; + return encryptText(combined, SECRET_KEY); +}; + +/** + * Decode certificate ID back to teamId and submissionId + * @param certId - The encoded certificate ID + * @returns Object containing teamId and submissionId + */ +export const decodeCertificateId = async (certId: string): Promise<{ teamId: string; submissionId: string }> => { + try { + const decoded = await decryptText(certId, SECRET_KEY); + const [teamId, submissionId] = decoded.split('::'); + return { teamId, submissionId }; + } catch { + throw new Error('Invalid certificate ID'); + } +}; + +/** + * For development: Create a certId using created_at timestamp + * @param teamId - The team ID + * @param createdAt - The creation timestamp + * @returns Encoded certificate ID + */ +export const encodeCertificateIdWithTimestamp = (teamId: string, createdAt: string): string => { + const combined = `${teamId}::${createdAt}`; + return Buffer.from(combined).toString('base64'); +}; diff --git a/libs/service/src/hooks/index.ts b/libs/service/src/hooks/index.ts index 02899cd..71337db 100644 --- a/libs/service/src/hooks/index.ts +++ b/libs/service/src/hooks/index.ts @@ -5,3 +5,4 @@ export * from './mentors'; export * from './upload'; export * from './teams'; export * from './messages'; +export * from './winners'; diff --git a/libs/service/src/hooks/winners/index.ts b/libs/service/src/hooks/winners/index.ts new file mode 100644 index 0000000..a242f1d --- /dev/null +++ b/libs/service/src/hooks/winners/index.ts @@ -0,0 +1,43 @@ +import { useQuery } from '@tanstack/react-query'; +import { hackathonApi, HackathonApiResponse } from '../../api/hackathon'; + +// Query keys +export const winnerKeys = { + all: ['winners'] as const, + lists: () => [...winnerKeys.all, 'list'] as const, +}; + +// API response types +interface Team { + id: string; + name: string; + description: string; + city: string; + visibility: string; + logo: string; + banner: string; + leader_id: string; + created_at: string; + updated_at: string; +} + +interface Winner { + id: string; + team_id: string; + team: Team; + rank: number; + prize: string; + announced_at: string; + created_at: string; + updated_at: string; +} + +export const useWinners = () => { + return useQuery>({ + queryKey: winnerKeys.lists(), + queryFn: async () => { + const response = await hackathonApi.get('/winners'); + return response.data; + }, + }); +};