feat(hackathon): draft winner certificate

This commit is contained in:
Hafid Nur
2025-12-16 11:26:00 +07:00
parent 36f7d10e7a
commit 48d97af044
6 changed files with 890 additions and 13 deletions
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 818 KiB

@@ -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 && (
<Button
variant="secondary"
onClick={() =>
navigate(`/teams/${team.id}/submission`)
}
onClick={() => navigate(`/teams/${team.id}/submission`)}
>
Back to Submission
</Button>
@@ -425,7 +421,7 @@ const CertificatePage: FC = (): ReactElement => {
{/* Actions */}
{isTeamMember && (
<div className="bg-gray-50 dark:bg-gray-800 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
<div className="bg-gray-50 dark:bg-gray-900 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
<Button
variant="secondary"
onClick={handleDownloadCertificate}
@@ -444,9 +440,7 @@ const CertificatePage: FC = (): ReactElement => {
</Button>
{team && (
<Button
onClick={() =>
navigate(`/teams/${team.id}/submission`)
}
onClick={() => navigate(`/teams/${team.id}/submission`)}
variant="secondary"
className="col-span-2 flex items-center gap-2 xl:col-span-1"
>
@@ -0,0 +1,236 @@
{
"message": "Success",
"data": [
{
"id": "afbead4b-2ffa-435e-ae7b-91f26e111fc6",
"team_id": "ab436f6e-027f-4831-873b-474345273491",
"team": {
"id": "ab436f6e-027f-4831-873b-474345273491",
"name": "nama timnya apa ya @All",
"description": "pikri carry",
"city": "Kota Depok",
"visibility": "public",
"logo": null,
"banner": "https://voymsjeqmvwrryetfldy.supabase.co/storage/v1/object/public/hackathon-uploads/teams/9a6dc902-c91b-4276-89cf-31e821531158-1764517227821.png",
"leader_id": "9a6dc902-c91b-4276-89cf-31e821531158",
"created_at": "2025-11-30T15:40:29.865564Z",
"updated_at": "2025-11-30T15:48:52.520850Z"
},
"rank": 1,
"prize": "Rp6.000.000",
"announced_at": "2025-12-15T16:41:01.931757Z",
"created_at": "2025-12-15T16:41:01.931757Z",
"updated_at": "2025-12-15T16:41:01.931757Z"
},
{
"id": "8e591dc6-3cff-4019-bdfc-5e42ea081208",
"team_id": "06cbf0f4-cdad-40d0-b0d0-1fa8a3d50968",
"team": {
"id": "06cbf0f4-cdad-40d0-b0d0-1fa8a3d50968",
"name": "TheLastOfUs",
"description": "We truly believe in Meaning First — designing innovation, powered by technology.",
"city": "Bandung",
"visibility": "private",
"logo": "https://voymsjeqmvwrryetfldy.supabase.co/storage/v1/object/public/hackathon-uploads/teams/a32c0e81-1fbd-4bec-8e91-4cd6d10f553b-1764074851150.png",
"banner": "https://voymsjeqmvwrryetfldy.supabase.co/storage/v1/object/public/hackathon-uploads/teams/a32c0e81-1fbd-4bec-8e91-4cd6d10f553b-1764074852181.png",
"leader_id": "a32c0e81-1fbd-4bec-8e91-4cd6d10f553b",
"created_at": "2025-11-25T12:47:33.040330Z",
"updated_at": "2025-11-25T12:47:33.040330Z"
},
"rank": 2,
"prize": "Rp4.000.000",
"announced_at": "2025-12-15T16:41:32.534295Z",
"created_at": "2025-12-15T16:41:32.534295Z",
"updated_at": "2025-12-15T16:41:32.534295Z"
},
{
"id": "5ca29971-5726-40dd-872c-853a6bfa03d9",
"team_id": "9d9ed9db-3523-47ae-a387-66c36dbec983",
"team": {
"id": "9d9ed9db-3523-47ae-a387-66c36dbec983",
"name": "Pecinta Capybara",
"description": "Ingin ikut meramaikan suasana.",
"city": "Tangerang",
"visibility": "public",
"logo": "https://voymsjeqmvwrryetfldy.supabase.co/storage/v1/object/public/hackathon-uploads/teams/648f6d3d-a92e-49cc-805e-2f600de87074-1764205746897.jpg",
"banner": "https://voymsjeqmvwrryetfldy.supabase.co/storage/v1/object/public/hackathon-uploads/teams/648f6d3d-a92e-49cc-805e-2f600de87074-1764205747479.png",
"leader_id": "648f6d3d-a92e-49cc-805e-2f600de87074",
"created_at": "2025-11-27T01:09:32.787150Z",
"updated_at": "2025-11-29T15:30:43.334609Z"
},
"rank": 3,
"prize": "Rp2.500.000",
"announced_at": "2025-12-15T16:41:59.091294Z",
"created_at": "2025-12-15T16:41:59.091294Z",
"updated_at": "2025-12-15T16:41:59.091294Z"
},
{
"id": "e9f82de6-f5d0-43c1-a0bd-4687f2e4ed5e",
"team_id": "95f782f4-433f-489d-a06d-eed89ca83beb",
"team": {
"id": "95f782f4-433f-489d-a06d-eed89ca83beb",
"name": "Savora",
"description": "a complete food ordering application for Indonesian SMEs (UMKM)",
"city": "Bandung",
"visibility": "private",
"logo": "https://voymsjeqmvwrryetfldy.supabase.co/storage/v1/object/public/hackathon-uploads/teams/748de15d-374a-4756-89e2-75754e90542f-1764071952995.png",
"banner": "https://voymsjeqmvwrryetfldy.supabase.co/storage/v1/object/public/hackathon-uploads/teams/748de15d-374a-4756-89e2-75754e90542f-1764071953753.png",
"leader_id": "748de15d-374a-4756-89e2-75754e90542f",
"created_at": "2025-11-25T11:57:52.098043Z",
"updated_at": "2025-11-25T11:59:14.508877Z"
},
"rank": 4,
"prize": "Rp500.000",
"announced_at": "2025-12-15T16:42:39.361469Z",
"created_at": "2025-12-15T16:42:39.361469Z",
"updated_at": "2025-12-15T16:42:39.361469Z"
},
{
"id": "ed93e080-0913-4c70-b69d-fce9bc0cdda7",
"team_id": "f9747574-8c01-4c3e-b20a-7ac1a70b947a",
"team": {
"id": "f9747574-8c01-4c3e-b20a-7ac1a70b947a",
"name": "AMBANET Corporation 🙀😸🤓",
"description": "Bersiaplah ambanet bakal mengguncang seluruh team hackathon ygy",
"city": "Kota Padang",
"visibility": "public",
"logo": "https://voymsjeqmvwrryetfldy.supabase.co/storage/v1/object/public/hackathon-uploads/teams/a5ea628a-24d9-4754-b461-3eda53da53bf-1764351234491.png",
"banner": "https://voymsjeqmvwrryetfldy.supabase.co/storage/v1/object/public/hackathon-uploads/teams/a5ea628a-24d9-4754-b461-3eda53da53bf-1764219109981.gif",
"leader_id": "a5ea628a-24d9-4754-b461-3eda53da53bf",
"created_at": "2025-11-27T04:48:53.080955Z",
"updated_at": "2025-11-29T13:31:51.276246Z"
},
"rank": 5,
"prize": "Rp500.000",
"announced_at": "2025-12-15T16:42:57.065225Z",
"created_at": "2025-12-15T16:42:57.065225Z",
"updated_at": "2025-12-15T16:42:57.065225Z"
},
{
"id": "7ed55b12-d585-419d-bae2-1a362a57798e",
"team_id": "4f3e2e85-3f3f-4880-9104-b3f66bb1c9d9",
"team": {
"id": "4f3e2e85-3f3f-4880-9104-b3f66bb1c9d9",
"name": "Pemuja Deadline Anti Refund",
"description": "Diusahakan banyak deadline tapi kg refund ~ \"Angga Kentod\"",
"city": "Kota Bandung",
"visibility": "public",
"logo": "https://voymsjeqmvwrryetfldy.supabase.co/storage/v1/object/public/hackathon-uploads/teams/6cf045e2-d853-4480-91b8-244fb0b76b7f-1764332887921.jpg",
"banner": null,
"leader_id": "6cf045e2-d853-4480-91b8-244fb0b76b7f",
"created_at": "2025-11-27T05:02:35.128625Z",
"updated_at": "2025-11-28T12:28:09.697222Z"
},
"rank": 6,
"prize": "Rp200.000",
"announced_at": "2025-12-15T16:43:09.471633Z",
"created_at": "2025-12-15T16:43:09.471633Z",
"updated_at": "2025-12-15T16:43:09.471633Z"
},
{
"id": "0b3a0b5c-2038-4d09-85a7-ae4709702951",
"team_id": "9d8cd404-8e69-4efd-a125-fc0863d7afc7",
"team": {
"id": "9d8cd404-8e69-4efd-a125-fc0863d7afc7",
"name": "Aku nak meletup",
"description": "masih pemula bg",
"city": "Boyolali",
"visibility": "public",
"logo": "https://voymsjeqmvwrryetfldy.supabase.co/storage/v1/object/public/hackathon-uploads/teams/c79d4b2b-e63f-4187-a8cd-520fde333ab3-1764211224866.jpg",
"banner": null,
"leader_id": "c79d4b2b-e63f-4187-a8cd-520fde333ab3",
"created_at": "2025-11-27T02:40:25.376372Z",
"updated_at": "2025-11-27T02:40:25.376372Z"
},
"rank": 7,
"prize": "Rp200.000",
"announced_at": "2025-12-15T16:43:22.467439Z",
"created_at": "2025-12-15T16:43:22.467439Z",
"updated_at": "2025-12-15T16:43:22.467439Z"
},
{
"id": "bb0706b9-b723-4a49-b0b0-3be9af70225b",
"team_id": "dd6c62dd-2632-4156-9da5-8ff9a3560f06",
"team": {
"id": "dd6c62dd-2632-4156-9da5-8ff9a3560f06",
"name": "Iseng Hackathon",
"description": "HME ITB Oke Champ",
"city": "Kota Bandung",
"visibility": "public",
"logo": null,
"banner": null,
"leader_id": "d1e4e3bf-098f-453e-bf00-612685d311d9",
"created_at": "2025-11-30T03:58:52.429673Z",
"updated_at": "2025-11-30T03:58:52.429673Z"
},
"rank": 8,
"prize": "Rp200.000",
"announced_at": "2025-12-15T16:43:34.191207Z",
"created_at": "2025-12-15T16:43:34.191207Z",
"updated_at": "2025-12-15T16:43:34.191207Z"
},
{
"id": "4a3e807c-2cf2-4557-a6af-99fcc726cebb",
"team_id": "52316c25-5697-4436-bfc2-02baef8ca956",
"team": {
"id": "52316c25-5697-4436-bfc2-02baef8ca956",
"name": "Gorengan 3 Ronde",
"description": "Jam 10 izin turun",
"city": "Kota Medan",
"visibility": "public",
"logo": "https://voymsjeqmvwrryetfldy.supabase.co/storage/v1/object/public/hackathon-uploads/teams/e7bec056-7f63-4642-a763-afa39c3c340e-1764509780084.png",
"banner": "https://voymsjeqmvwrryetfldy.supabase.co/storage/v1/object/public/hackathon-uploads/teams/e7bec056-7f63-4642-a763-afa39c3c340e-1764509810717.png",
"leader_id": "e7bec056-7f63-4642-a763-afa39c3c340e",
"created_at": "2025-11-30T13:36:52.344019Z",
"updated_at": "2025-11-30T13:36:52.344019Z"
},
"rank": 9,
"prize": "Rp200.000",
"announced_at": "2025-12-15T16:43:51.988908Z",
"created_at": "2025-12-15T16:43:51.988908Z",
"updated_at": "2025-12-15T16:43:51.988908Z"
},
{
"id": "e18827dd-839d-4e54-9126-c1faebce2c2c",
"team_id": "d2b2fb99-e934-405c-836e-711352e0f8a0",
"team": {
"id": "d2b2fb99-e934-405c-836e-711352e0f8a0",
"name": "Spark Developer",
"description": "Yang penting yakin untuk menang.",
"city": "Kota Batam",
"visibility": "public",
"logo": "https://voymsjeqmvwrryetfldy.supabase.co/storage/v1/object/public/hackathon-uploads/teams/087921fb-2d17-423b-9628-73de2176360f-1764486044138.png",
"banner": "https://voymsjeqmvwrryetfldy.supabase.co/storage/v1/object/public/hackathon-uploads/teams/087921fb-2d17-423b-9628-73de2176360f-1764486059581.jpg",
"leader_id": "087921fb-2d17-423b-9628-73de2176360f",
"created_at": "2025-11-29T04:49:54.266394Z",
"updated_at": "2025-11-30T07:01:02.316185Z"
},
"rank": 10,
"prize": "Rp200.000",
"announced_at": "2025-12-15T16:44:02.680036Z",
"created_at": "2025-12-15T16:44:02.680036Z",
"updated_at": "2025-12-15T16:44:02.680036Z"
},
{
"id": "a0ed8e58-f8e3-491c-8c03-20e82090d6f8",
"team_id": "d30d7d03-dce7-4220-9e7f-a537a45c54e0",
"team": {
"id": "d30d7d03-dce7-4220-9e7f-a537a45c54e0",
"name": "Eye of Horus",
"description": "Eye of horus",
"city": "Garut",
"visibility": "public",
"logo": "https://voymsjeqmvwrryetfldy.supabase.co/storage/v1/object/public/hackathon-uploads/teams/96dc1c2b-a1ef-441a-93d5-da2ccad37d81-1764145807852.png",
"banner": "https://voymsjeqmvwrryetfldy.supabase.co/storage/v1/object/public/hackathon-uploads/teams/96dc1c2b-a1ef-441a-93d5-da2ccad37d81-1764145810329.jpeg",
"leader_id": "96dc1c2b-a1ef-441a-93d5-da2ccad37d81",
"created_at": "2025-11-26T08:30:10.385714Z",
"updated_at": "2025-11-26T09:35:12.386986Z"
},
"rank": 11,
"prize": "hadiah",
"announced_at": "2025-12-16T04:19:09.502015Z",
"created_at": "2025-12-16T04:19:09.502015Z",
"updated_at": "2025-12-16T04:19:09.502015Z"
}
]
}
@@ -0,0 +1,445 @@
import { FC, ReactElement, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { decodeWinnerCertificateId } from '../../../../utils/certificate';
import {
useAuthStore,
useMyTeams,
useTeamById,
} from '@imphnen-frontend-service/service';
import QRCode from 'qrcode';
import html2canvas from 'html2canvas';
import winnerData from '../../winner-data.json';
type WinnerEntry = {
team_id: string;
rank: number;
team?: {
id: string;
name: string;
};
};
type WinnerDataFile = {
message: string;
data: WinnerEntry[];
};
const CERT_WIDTH = 1000;
const CERT_HEIGHT = (CERT_WIDTH * 595) / 842; // matches blank_winner_cert.svg aspect ratio
const CertificateWinnerPage: FC = (): ReactElement => {
const { certId } = useParams<{ certId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const { data: myTeamsData } = useMyTeams();
const [decodedTeamId, setDecodedTeamId] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
const certificateRef = useRef<HTMLDivElement>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [certificateImage, setCertificateImage] = useState<string>('');
const [showTemplate, setShowTemplate] = useState(true);
useEffect(() => {
if (!certId) return;
decodeWinnerCertificateId(certId)
.then((decoded) => setDecodedTeamId(decoded.teamId))
.catch(() => setError('Invalid certificate ID'));
}, [certId]);
const winners = (winnerData as unknown as WinnerDataFile).data;
const winnerEntry = useMemo(() => {
if (!decodedTeamId) return undefined;
return winners.find((w) => w.team_id === decodedTeamId);
}, [decodedTeamId, winners]);
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
decodedTeamId,
!!decodedTeamId
);
const team = teamData?.data;
const memberNames = useMemo(() => {
const members = team?.members || [];
return members
.map((m) => m.user?.fullname)
.filter((name): name is string => !!name);
}, [team?.members]);
const isWinnerTeam = !!winnerEntry;
const isTeamMember =
!!session?.user?.id &&
!!decodedTeamId &&
(myTeamsData?.data || []).some(
(t) => (t as { id?: string } | null | undefined)?.id === decodedTeamId
);
// Generate QR Code (public link)
useEffect(() => {
if (!certId) return;
const encodedCertId = encodeURIComponent(certId);
const certificateUrl = `${window.location.origin}/certificate/winner/${encodedCertId}`;
QRCode.toDataURL(certificateUrl, {
width: 200,
margin: 1,
color: {
dark: '#000000',
light: '#ffffff',
},
})
.then(setQrCodeUrl)
.catch((err) => console.error('QR Code generation failed:', err));
}, [certId]);
// Generate certificate canvas screenshot
useEffect(() => {
const generateCertificate = async () => {
if (!certificateRef.current) return;
if (!team?.name) return;
if (!qrCodeUrl) return;
if (!winnerEntry?.rank) return;
setIsGenerating(true);
try {
await new Promise((resolve) => setTimeout(resolve, 1500));
const canvas = await html2canvas(certificateRef.current, {
scale: 4,
useCORS: true,
backgroundColor: '#ffffff',
logging: false,
width: CERT_WIDTH,
height: CERT_HEIGHT,
allowTaint: true,
imageTimeout: 0,
removeContainer: true,
});
const imageUrl = canvas.toDataURL('image/png', 1.0);
setCertificateImage(imageUrl);
setShowTemplate(false);
} catch (e) {
console.error('Failed to generate certificate:', e);
} finally {
setIsGenerating(false);
}
};
generateCertificate();
}, [team?.name, memberNames, qrCodeUrl, winnerEntry?.rank]);
const handleDownloadCertificate = () => {
if (!certificateImage) return;
const link = document.createElement('a');
link.href = certificateImage;
link.download = `winner-certificate-${team?.name || 'hackathon'}.png`;
link.click();
};
const handlePrintCertificate = () => {
if (!certificateImage) return;
const printWindow = window.open('', '_blank');
if (!printWindow) return;
printWindow.document.write(`
<html>
<head>
<title>Certificate - ${team?.name}</title>
<style>
body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
img { max-width: 100%; height: auto; }
@media print {
@page { size: A4 landscape; margin: 0; }
body { margin: 0; }
img { width: 100%; height: auto; }
}
</style>
</head>
<body>
<img src="${certificateImage}" />
</body>
</html>
`);
printWindow.document.close();
printWindow.onload = () => {
printWindow.print();
};
};
if (error || !certId) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Invalid Certificate
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
{error || 'The certificate ID is invalid or malformed.'}
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
</div>
);
}
if (!decodedTeamId || isLoadingTeam) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<div className="text-gray-600 dark:text-gray-400">
Loading certificate...
</div>
</div>
</div>
);
}
if (!isWinnerTeam) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Certificate Not Found
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
This team is not listed as a hackathon winner.
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
</div>
);
}
if (!team?.name) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Team Not Found
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
The team associated with this certificate could not be loaded.
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
{/* Header */}
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 no-print">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Winner Certificate
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{team.name}
</p>
</div>
{isTeamMember && (
<Button variant="secondary" onClick={() => navigate('/dashboard')}>
Back to Dashboard
</Button>
)}
</div>
</div>
</div>
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12" id="certificate-wrapper">
{/* Hidden Template for Canvas Generation */}
<div
className={showTemplate ? 'block' : 'hidden'}
style={{ position: 'absolute', left: '-9999px' }}
>
<div
ref={certificateRef}
id="certificate-template"
style={{
position: 'relative',
backgroundImage: 'url(/images/blank_winner_cert.svg)',
backgroundSize: 'cover',
backgroundPosition: 'center',
width: `${CERT_WIDTH}px`,
height: `${CERT_HEIGHT}px`,
}}
>
<style>{`
#winner-members li::marker {
color: #59bef5;
}
`}</style>
{/* Team Name (below "Diberikan Kepada") */}
<div
style={{
position: 'absolute',
top: '40%',
left: '3.5%',
width: '55%',
}}
>
<h3
style={{
fontFamily: 'Poppins, sans-serif',
fontWeight: 'bold',
color: '#59bef5',
textAlign: 'left',
fontSize: '28px',
lineHeight: '1.2',
wordBreak: 'break-word',
margin: 0,
}}
>
{team.name}
</h3>
</div>
{/* Members list */}
<div
style={{
position: 'absolute',
top: '45%',
left: '3.5%',
width: '55%',
}}
>
<ul
id="winner-members"
style={{
margin: 0,
paddingLeft: '18px',
fontFamily: 'Poppins, sans-serif',
fontSize: '18px',
lineHeight: '1.35',
color: '#323232',
}}
>
{(memberNames.length ? memberNames : ['(Members unavailable)']).map((name) => (
<li key={name}>{name}</li>
))}
</ul>
</div>
{/* Award text */}
<div
style={{
position: 'absolute',
top: '62%',
left: '3.5%',
width: '60%',
}}
>
<p
style={{
margin: 0,
fontFamily: 'Poppins, sans-serif',
fontSize: '18px',
lineHeight: '1.35',
color: '#6B6B6B',
}}
>
Diberikan sebagai penghargaan atas pencapaian meraih
<br />
JUARA {winnerEntry.rank} pada Hackathon IMPHNEN x Kolosal.ai
</p>
</div>
{/* QR Code (same placement as existing certificate page) */}
<div
style={{
position: 'absolute',
top: '33%',
right: '9.3%',
width: '190px',
height: '190px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{qrCodeUrl && (
<img
src={qrCodeUrl}
alt="Certificate QR Code"
style={{ width: '190px', height: '190px', display: 'block' }}
/>
)}
</div>
</div>
</div>
{/* Display Certificate Image */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 overflow-hidden p-2">
{isGenerating && (
<div className="flex items-center justify-center p-12">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<div className="text-gray-600 dark:text-gray-400">
Generating certificate...
</div>
</div>
</div>
)}
{certificateImage && !isGenerating && (
<img
src={certificateImage}
alt="Winner Certificate"
className="w-full h-auto"
style={{ maxWidth: '100%', height: 'auto' }}
/>
)}
{/* Actions */}
{isTeamMember && (
<div className="bg-gray-50 dark:bg-gray-800 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
<Button
variant="secondary"
onClick={handleDownloadCertificate}
className="flex items-center gap-2"
disabled={isGenerating}
>
{isGenerating ? '⏳ Generating...' : '📥 Download'}
</Button>
<Button
variant="secondary"
onClick={handlePrintCertificate}
className="flex items-center gap-2"
disabled={isGenerating}
>
{isGenerating ? '⏳ Generating...' : '🖨️ Print'}
</Button>
<Button
onClick={() => navigate('/dashboard')}
variant="secondary"
className="col-span-2 flex items-center gap-2 xl:col-span-1"
>
Back to Dashboard
</Button>
</div>
)}
</div>
{/* Info Box */}
<div className="mt-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6 no-print">
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
Certificate Information
</h3>
<p className="text-sm text-blue-800 dark:text-blue-200">
This certificate is a digital record of your hackathon achievement.
</p>
</div>
</div>
</div>
);
};
export default CertificateWinnerPage;
+41 -2
View File
@@ -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 => {
)}
</div>
{/* Winner Banner */}
{winnerEntry && myTeams.length > 0 && (
<div className="mb-8 bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-lg p-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center space-x-3 flex-1 min-w-0">
<span className="text-4xl shrink-0">🏆</span>
<div className="min-w-0">
<h3 className="font-bold text-amber-900 dark:text-amber-100 text-lg">
Selamat! Tim Anda meraih JUARA {winnerEntry.rank}
</h3>
<p className="text-amber-700 dark:text-amber-300 text-sm">
Anda dapat generate sertifikat penghargaan dan membagikannya.
</p>
</div>
</div>
<button
onClick={async () => {
const team = myTeams[0] as any;
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"
>
Generate Sertifikat Juara
</button>
</div>
</div>
)}
{/* Countdown Timer */}
{timeLeft && !isSubmissionDeadlinePassed && (
<div className="mb-8 bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 rounded-lg p-6">
+31
View File
@@ -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<string> => {
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