feat(hackathon): refine winner certificate

This commit is contained in:
Hafid Nur
2025-12-16 12:06:29 +07:00
parent 48d97af044
commit 3eb86c6ef4
2 changed files with 177 additions and 32 deletions
@@ -1,15 +1,16 @@
import { FC, ReactElement, useEffect, useMemo, useRef, useState } from 'react'; import { FC, ReactElement, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router'; import { useNavigate, useParams } from 'react-router';
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Icon } from '@iconify/react';
import { decodeWinnerCertificateId } from '../../../../utils/certificate'; import { decodeWinnerCertificateId } from '../../../../utils/certificate';
import { import {
useAuthStore, useAuthStore,
useMyTeams, useMyTeams,
useTeamById, useTeamById,
useWinners,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
import html2canvas from 'html2canvas'; import html2canvas from 'html2canvas';
import winnerData from '../../winner-data.json';
type WinnerEntry = { type WinnerEntry = {
team_id: string; team_id: string;
@@ -20,9 +21,20 @@ type WinnerEntry = {
}; };
}; };
type WinnerDataFile = { const formatOrdinalRank = (rank: number): string => {
message: string; const mod100 = rank % 100;
data: WinnerEntry[]; if (mod100 >= 11 && mod100 <= 13) return `${rank}th`;
switch (rank % 10) {
case 1:
return `${rank}st`;
case 2:
return `${rank}nd`;
case 3:
return `${rank}rd`;
default:
return `${rank}th`;
}
}; };
const CERT_WIDTH = 1000; const CERT_WIDTH = 1000;
@@ -33,6 +45,11 @@ const CertificateWinnerPage: FC = (): ReactElement => {
const navigate = useNavigate(); const navigate = useNavigate();
const { session } = useAuthStore(); const { session } = useAuthStore();
const { data: myTeamsData } = useMyTeams(); const { data: myTeamsData } = useMyTeams();
const {
data: winnersResponse,
isLoading: isLoadingWinners,
isError: isWinnersError,
} = useWinners();
const [decodedTeamId, setDecodedTeamId] = useState<string>(''); const [decodedTeamId, setDecodedTeamId] = useState<string>('');
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -51,12 +68,20 @@ const CertificateWinnerPage: FC = (): ReactElement => {
.catch(() => setError('Invalid certificate ID')); .catch(() => setError('Invalid certificate ID'));
}, [certId]); }, [certId]);
const winners = (winnerData as unknown as WinnerDataFile).data; const winners = useMemo(
() => (winnersResponse?.data || []) as WinnerEntry[],
[winnersResponse?.data]
);
const winnerEntry = useMemo(() => { const winnerEntry = useMemo(() => {
if (!decodedTeamId) return undefined; if (!decodedTeamId) return undefined;
return winners.find((w) => w.team_id === decodedTeamId); return winners.find((w) => w.team_id === decodedTeamId);
}, [decodedTeamId, winners]); }, [decodedTeamId, winners]);
const rankLabel = useMemo(() => {
if (!winnerEntry?.rank) return '';
return formatOrdinalRank(winnerEntry.rank);
}, [winnerEntry?.rank]);
const { data: teamData, isLoading: isLoadingTeam } = useTeamById( const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
decodedTeamId, decodedTeamId,
!!decodedTeamId !!decodedTeamId
@@ -202,6 +227,33 @@ const CertificateWinnerPage: FC = (): ReactElement => {
); );
} }
if (isLoadingWinners) {
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 winners...
</div>
</div>
</div>
);
}
if (isWinnersError) {
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">
Unable to Load Winners
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Please try again later.
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
</div>
);
}
if (!isWinnerTeam) { if (!isWinnerTeam) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
@@ -245,7 +297,10 @@ const CertificateWinnerPage: FC = (): ReactElement => {
</p> </p>
</div> </div>
{isTeamMember && ( {isTeamMember && (
<Button variant="secondary" onClick={() => navigate('/dashboard')}> <Button
variant="secondary"
onClick={() => navigate('/dashboard')}
>
Back to Dashboard Back to Dashboard
</Button> </Button>
)} )}
@@ -253,7 +308,10 @@ const CertificateWinnerPage: FC = (): ReactElement => {
</div> </div>
</div> </div>
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12" id="certificate-wrapper"> <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 */} {/* Hidden Template for Canvas Generation */}
<div <div
className={showTemplate ? 'block' : 'hidden'} className={showTemplate ? 'block' : 'hidden'}
@@ -277,11 +335,11 @@ const CertificateWinnerPage: FC = (): ReactElement => {
} }
`}</style> `}</style>
{/* Team Name (below "Diberikan Kepada") */} {/* Team Name */}
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
top: '40%', top: '37%',
left: '3.5%', left: '3.5%',
width: '55%', width: '55%',
}} }}
@@ -306,7 +364,7 @@ const CertificateWinnerPage: FC = (): ReactElement => {
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
top: '45%', top: '42.5%',
left: '3.5%', left: '3.5%',
width: '55%', width: '55%',
}} }}
@@ -315,15 +373,17 @@ const CertificateWinnerPage: FC = (): ReactElement => {
id="winner-members" id="winner-members"
style={{ style={{
margin: 0, margin: 0,
paddingLeft: '18px',
fontFamily: 'Poppins, sans-serif', fontFamily: 'Poppins, sans-serif',
fontSize: '18px', fontSize: '18px',
lineHeight: '1.35', lineHeight: '1.35',
color: '#323232', color: '#59bef5',
}} }}
> >
{(memberNames.length ? memberNames : ['(Members unavailable)']).map((name) => ( {(memberNames.length
<li key={name}>{name}</li> ? memberNames
: ['(Members unavailable)']
).map((name) => (
<li key={name}> {name}</li>
))} ))}
</ul> </ul>
</div> </div>
@@ -348,10 +408,42 @@ const CertificateWinnerPage: FC = (): ReactElement => {
> >
Diberikan sebagai penghargaan atas pencapaian meraih Diberikan sebagai penghargaan atas pencapaian meraih
<br /> <br />
JUARA {winnerEntry.rank} pada Hackathon IMPHNEN x Kolosal.ai <b>JUARA {winnerEntry.rank}</b> pada Hackathon IMPHNEN x
Kolosal.ai
</p> </p>
</div> </div>
{/* Rank badge */}
{!!rankLabel && (
<div
style={{
position: 'absolute',
top: '8%',
right: '8.5%',
width: '190px',
display: 'flex',
justifyContent: 'center',
}}
>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '6px 16px',
textAlign: 'center',
fontFamily: 'Poppins, sans-serif',
fontWeight: 700,
color: '#78350F',
fontSize: '24px',
lineHeight: '1',
}}
>
{rankLabel}
</div>
</div>
)}
{/* QR Code (same placement as existing certificate page) */} {/* QR Code (same placement as existing certificate page) */}
<div <div
style={{ style={{
@@ -400,14 +492,32 @@ const CertificateWinnerPage: FC = (): ReactElement => {
{/* Actions */} {/* Actions */}
{isTeamMember && ( {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 <Button
variant="secondary" variant="secondary"
onClick={handleDownloadCertificate} onClick={handleDownloadCertificate}
className="flex items-center gap-2" className="flex items-center gap-2"
disabled={isGenerating} disabled={isGenerating}
> >
{isGenerating ? '⏳ Generating...' : '📥 Download'} {isGenerating ? (
<>
<Icon
icon="svg-spinners:ring-resize"
width="18"
height="18"
/>
Generating...
</>
) : (
<>
<Icon
icon="heroicons:arrow-down-tray"
width="18"
height="18"
/>
Download
</>
)}
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
@@ -415,7 +525,21 @@ const CertificateWinnerPage: FC = (): ReactElement => {
className="flex items-center gap-2" className="flex items-center gap-2"
disabled={isGenerating} disabled={isGenerating}
> >
{isGenerating ? '⏳ Generating...' : '🖨️ Print'} {isGenerating ? (
<>
<Icon
icon="svg-spinners:ring-resize"
width="18"
height="18"
/>
Generating...
</>
) : (
<>
<Icon icon="mdi:printer" width="18" height="18" />
Print
</>
)}
</Button> </Button>
<Button <Button
onClick={() => navigate('/dashboard')} onClick={() => navigate('/dashboard')}
+35 -14
View File
@@ -5,12 +5,12 @@ import {
useMyInvitations, useMyInvitations,
useRespondToInvitation, useRespondToInvitation,
useAuthStore, useAuthStore,
useWinners,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import ProfilePage from '../profile/page'; import ProfilePage from '../profile/page';
import winnerData from '../certificate/winner-data.json';
import { encodeWinnerCertificateId } from '../../utils/certificate'; import { encodeWinnerCertificateId } from '../../utils/certificate';
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7) // Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
@@ -94,6 +94,7 @@ const DashboardPage: FC = (): ReactElement => {
} }
}, [showProfileModal]); }, [showProfileModal]);
const { data: teamsData } = useMyTeams(); const { data: teamsData } = useMyTeams();
const { data: winnersResponse } = useWinners();
const { data: invitationsData } = useMyInvitations(); const { data: invitationsData } = useMyInvitations();
const { mutateAsync: respondToInvitation } = useRespondToInvitation(); const { mutateAsync: respondToInvitation } = useRespondToInvitation();
@@ -103,11 +104,11 @@ const DashboardPage: FC = (): ReactElement => {
[]) as Invitation[]; []) as Invitation[];
const winnerEntry = useMemo(() => { const winnerEntry = useMemo(() => {
const team = (myTeams[0] as any) || null; const team = (myTeams[0] as { id?: string } | null | undefined) || null;
const winners = (winnerData as any)?.data || []; const winners = winnersResponse?.data || [];
if (!team?.id || !Array.isArray(winners)) return null; if (!team?.id) return null;
return winners.find((w: any) => w?.team_id === team.id) || null; return winners.find((w) => w.team_id === team.id) || null;
}, [myTeams]); }, [myTeams, winnersResponse?.data]);
const handleAcceptInvitation = async (invitationId: string) => { const handleAcceptInvitation = async (invitationId: string) => {
try { try {
@@ -155,17 +156,19 @@ const DashboardPage: FC = (): ReactElement => {
Selamat! Tim Anda meraih JUARA {winnerEntry.rank} Selamat! Tim Anda meraih JUARA {winnerEntry.rank}
</h3> </h3>
<p className="text-amber-700 dark:text-amber-300 text-sm"> <p className="text-amber-700 dark:text-amber-300 text-sm">
Anda dapat generate sertifikat penghargaan dan membagikannya. Anda dapat generate sertifikat penghargaan dan
membagikannya.
</p> </p>
</div> </div>
</div> </div>
<button <button
onClick={async () => { onClick={async () => {
const team = myTeams[0] as any; const team = myTeams[0] as { id?: string } | null | undefined;
if (!team?.id) return;
const certId = await encodeWinnerCertificateId(team.id); const certId = await encodeWinnerCertificateId(team.id);
navigate(`/certificate/winner/${encodeURIComponent(certId)}`); 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" 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 Generate Sertifikat Juara
</button> </button>
@@ -233,7 +236,8 @@ const DashboardPage: FC = (): ReactElement => {
<div className="mb-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3"> <div className="mb-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
<p className="text-sm text-amber-700 dark:text-amber-300 flex items-center gap-2"> <p className="text-sm text-amber-700 dark:text-amber-300 flex items-center gap-2">
<Icon icon="mdi:clock-alert" className="text-lg shrink-0" /> <Icon icon="mdi:clock-alert" className="text-lg shrink-0" />
Team features are closed. You can no longer accept invitations. Team features are closed. You can no longer accept
invitations.
</p> </p>
</div> </div>
)} )}
@@ -302,7 +306,10 @@ const DashboardPage: FC = (): ReactElement => {
/> />
) : ( ) : (
<div className="w-16 h-16 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0"> <div className="w-16 h-16 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0">
<Icon icon="mdi:account-group" className="text-gray-500 dark:text-gray-400 text-2xl" /> <Icon
icon="mdi:account-group"
className="text-gray-500 dark:text-gray-400 text-2xl"
/>
</div> </div>
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -312,19 +319,33 @@ const DashboardPage: FC = (): ReactElement => {
<div className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-3 font-sans mt-2"> <div className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-3 font-sans mt-2">
{team.has_submission && ( {team.has_submission && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 shrink-0"> <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 shrink-0">
<Icon icon="mdi:check-circle" className="text-sm" /> <Icon
icon="mdi:check-circle"
className="text-sm"
/>
Submitted Submitted
</span> </span>
)} )}
{team.city && ( {team.city && (
<span className="flex items-center gap-1 truncate"> <span className="flex items-center gap-1 truncate">
<Icon icon="mdi:map-marker" className="shrink-0" /> <Icon
icon="mdi:map-marker"
className="shrink-0"
/>
<span className="truncate">{team.city}</span> <span className="truncate">{team.city}</span>
</span> </span>
)} )}
<span className="flex items-center gap-1 shrink-0"> <span className="flex items-center gap-1 shrink-0">
<Icon icon="mdi:account-group" /> <Icon icon="mdi:account-group" />
{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'
: ''}
</span> </span>
</div> </div>
</div> </div>