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 { useNavigate, useParams } from 'react-router';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Icon } from '@iconify/react';
import { decodeWinnerCertificateId } from '../../../../utils/certificate';
import {
useAuthStore,
useMyTeams,
useTeamById,
useWinners,
} from '@imphnen-frontend-service/service';
import QRCode from 'qrcode';
import html2canvas from 'html2canvas';
import winnerData from '../../winner-data.json';
type WinnerEntry = {
team_id: string;
@@ -20,9 +21,20 @@ type WinnerEntry = {
};
};
type WinnerDataFile = {
message: string;
data: WinnerEntry[];
const formatOrdinalRank = (rank: number): string => {
const mod100 = rank % 100;
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;
@@ -33,6 +45,11 @@ const CertificateWinnerPage: FC = (): ReactElement => {
const navigate = useNavigate();
const { session } = useAuthStore();
const { data: myTeamsData } = useMyTeams();
const {
data: winnersResponse,
isLoading: isLoadingWinners,
isError: isWinnersError,
} = useWinners();
const [decodedTeamId, setDecodedTeamId] = useState<string>('');
const [error, setError] = useState<string | null>(null);
@@ -51,12 +68,20 @@ const CertificateWinnerPage: FC = (): ReactElement => {
.catch(() => setError('Invalid certificate ID'));
}, [certId]);
const winners = (winnerData as unknown as WinnerDataFile).data;
const winners = useMemo(
() => (winnersResponse?.data || []) as WinnerEntry[],
[winnersResponse?.data]
);
const winnerEntry = useMemo(() => {
if (!decodedTeamId) return undefined;
return winners.find((w) => w.team_id === decodedTeamId);
}, [decodedTeamId, winners]);
const rankLabel = useMemo(() => {
if (!winnerEntry?.rank) return '';
return formatOrdinalRank(winnerEntry.rank);
}, [winnerEntry?.rank]);
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
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) {
return (
<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>
</div>
{isTeamMember && (
<Button variant="secondary" onClick={() => navigate('/dashboard')}>
<Button
variant="secondary"
onClick={() => navigate('/dashboard')}
>
Back to Dashboard
</Button>
)}
@@ -253,7 +308,10 @@ const CertificateWinnerPage: FC = (): ReactElement => {
</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 */}
<div
className={showTemplate ? 'block' : 'hidden'}
@@ -277,11 +335,11 @@ const CertificateWinnerPage: FC = (): ReactElement => {
}
`}</style>
{/* Team Name (below "Diberikan Kepada") */}
{/* Team Name */}
<div
style={{
position: 'absolute',
top: '40%',
top: '37%',
left: '3.5%',
width: '55%',
}}
@@ -306,7 +364,7 @@ const CertificateWinnerPage: FC = (): ReactElement => {
<div
style={{
position: 'absolute',
top: '45%',
top: '42.5%',
left: '3.5%',
width: '55%',
}}
@@ -315,15 +373,17 @@ const CertificateWinnerPage: FC = (): ReactElement => {
id="winner-members"
style={{
margin: 0,
paddingLeft: '18px',
fontFamily: 'Poppins, sans-serif',
fontSize: '18px',
lineHeight: '1.35',
color: '#323232',
color: '#59bef5',
}}
>
{(memberNames.length ? memberNames : ['(Members unavailable)']).map((name) => (
<li key={name}>{name}</li>
{(memberNames.length
? memberNames
: ['(Members unavailable)']
).map((name) => (
<li key={name}> {name}</li>
))}
</ul>
</div>
@@ -348,10 +408,42 @@ const CertificateWinnerPage: FC = (): ReactElement => {
>
Diberikan sebagai penghargaan atas pencapaian meraih
<br />
JUARA {winnerEntry.rank} pada Hackathon IMPHNEN x Kolosal.ai
<b>JUARA {winnerEntry.rank}</b> pada Hackathon IMPHNEN x
Kolosal.ai
</p>
</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) */}
<div
style={{
@@ -400,14 +492,32 @@ const CertificateWinnerPage: 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}
className="flex items-center gap-2"
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
variant="secondary"
@@ -415,7 +525,21 @@ const CertificateWinnerPage: FC = (): ReactElement => {
className="flex items-center gap-2"
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
onClick={() => navigate('/dashboard')}
+35 -14
View File
@@ -5,12 +5,12 @@ import {
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 winnerData from '../certificate/winner-data.json';
import { encodeWinnerCertificateId } from '../../utils/certificate';
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
@@ -94,6 +94,7 @@ const DashboardPage: FC = (): ReactElement => {
}
}, [showProfileModal]);
const { data: teamsData } = useMyTeams();
const { data: winnersResponse } = useWinners();
const { data: invitationsData } = useMyInvitations();
const { mutateAsync: respondToInvitation } = useRespondToInvitation();
@@ -103,11 +104,11 @@ const DashboardPage: FC = (): ReactElement => {
[]) 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 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 {
@@ -155,17 +156,19 @@ const DashboardPage: FC = (): ReactElement => {
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.
Anda dapat generate sertifikat penghargaan dan
membagikannya.
</p>
</div>
</div>
<button
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);
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
</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">
<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" />
Team features are closed. You can no longer accept invitations.
Team features are closed. You can no longer accept
invitations.
</p>
</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">
<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 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">
{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">
<Icon icon="mdi:check-circle" className="text-sm" />
<Icon
icon="mdi:check-circle"
className="text-sm"
/>
Submitted
</span>
)}
{team.city && (
<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>
)}
<span className="flex items-center gap-1 shrink-0">
<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>
</div>
</div>