Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38ec9a28f7 | ||
|
|
97a3ac90eb | ||
|
|
8f8c720632 | ||
|
|
ac22bd6cab | ||
|
|
27ed5605ce | ||
|
|
2c6389a67a |
+2
-5
@@ -1,6 +1,3 @@
|
||||
VITE_API_URL=https://forsomereason.dev/api/v1
|
||||
VITE_GITHUB_CLIENT_ID=SuperSecretClientID12345
|
||||
|
||||
VITE_SUPABASE_URL=https://mamahakumaurtx5090ti.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=anonkeykekgimanasihbang
|
||||
SUPABASE_SERVICE_ROLE_KEY=service.role.keyisahsecret
|
||||
VITE_SUPABASE_URL=https://your-project-id.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=your-anon-key-here
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
<!-- nx configuration start-->
|
||||
<!-- Leave the start & end comments to automatically receive updates. -->
|
||||
|
||||
# General Guidelines for working with Nx
|
||||
|
||||
- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly
|
||||
- You have access to the Nx MCP server and its tools, use them to help the user
|
||||
- When answering questions about the repository, use the `nx_workspace` tool first to gain an understanding of the workspace architecture where applicable.
|
||||
- When working in individual projects, use the `nx_project_details` mcp tool to analyze and understand the specific project structure and dependencies
|
||||
- For questions around nx configuration, best practices or if you're unsure, use the `nx_docs` tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration
|
||||
- If the user needs help with an Nx configuration or project graph error, use the `nx_workspace` tool to get any errors
|
||||
|
||||
<!-- nx configuration end-->
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.0 MiB |
@@ -1,294 +1,514 @@
|
||||
'use client';
|
||||
|
||||
import { FC, ReactElement, useEffect, useState } from 'react';
|
||||
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 {
|
||||
useUserMe,
|
||||
useMyTeams,
|
||||
useTeamById,
|
||||
useTeamSubmission,
|
||||
useAuthStore,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { generateCertificateHash } from '@imphnen-frontend-service/utils';
|
||||
import { Icon } from '@iconify/react';
|
||||
import QRCode from 'qrcode';
|
||||
import html2canvas from 'html2canvas';
|
||||
|
||||
interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
city: string;
|
||||
created_at: string;
|
||||
interface DecodedCert {
|
||||
teamId: string;
|
||||
submissionId: string;
|
||||
}
|
||||
|
||||
const CertificatePage: FC = (): ReactElement => {
|
||||
const { certId } = useParams<{ certId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { data: userData } = useUserMe();
|
||||
const { data: teamsData } = useMyTeams();
|
||||
const { session } = useAuthStore();
|
||||
const [decodedInfo, setDecodedInfo] = useState<DecodedCert | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const teamNameRef = useRef<HTMLHeadingElement>(null);
|
||||
const userNameRef = useRef<HTMLHeadingElement>(null);
|
||||
const [teamNameFontSize, setTeamNameFontSize] = useState('2.25rem');
|
||||
const [userNameFontSize, setUserNameFontSize] = useState('2.25rem');
|
||||
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);
|
||||
|
||||
const [isValid, setIsValid] = useState<boolean>(false);
|
||||
const [validTeam, setValidTeam] = useState<Team | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const user = userData?.data;
|
||||
|
||||
// Add print styles
|
||||
useEffect(() => {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
@media print {
|
||||
@page {
|
||||
size: landscape;
|
||||
margin: 0;
|
||||
}
|
||||
body {
|
||||
print-color-adjust: exact;
|
||||
-webkit-print-color-adjust: exact;
|
||||
}
|
||||
if (certId) {
|
||||
decodeCertificateId(certId)
|
||||
.then(setDecodedInfo)
|
||||
.catch(() => {
|
||||
setError('Invalid certificate ID');
|
||||
});
|
||||
}
|
||||
}, [certId]);
|
||||
|
||||
// Generate QR Code
|
||||
useEffect(() => {
|
||||
if (certId) {
|
||||
const certificateUrl = `${window.location.origin}/certificate/${certId}`;
|
||||
QRCode.toDataURL(certificateUrl, {
|
||||
width: 200,
|
||||
margin: 1,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
.then(setQrCodeUrl)
|
||||
.catch((err) => console.error('QR Code generation failed:', err));
|
||||
}
|
||||
}, [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`;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
return () => {
|
||||
document.head.removeChild(style);
|
||||
|
||||
setter(`${currentSize}px`);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Verify certificate
|
||||
const timer = setTimeout(() => {
|
||||
adjustFontSize(teamNameRef.current, 80, 20, setTeamNameFontSize);
|
||||
adjustFontSize(userNameRef.current, 80, 36, setUserNameFontSize);
|
||||
}, 0);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [team?.name, session?.user?.fullname]);
|
||||
|
||||
// Generate certificate canvas screenshot
|
||||
useEffect(() => {
|
||||
const teams = teamsData?.data || [];
|
||||
const generateCertificate = async () => {
|
||||
if (!certificateRef.current || !team || !submission || !qrCodeUrl) return;
|
||||
|
||||
if (!user || !teams.length || !certId) {
|
||||
setLoading(true);
|
||||
return;
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
// Wait a bit for fonts and images to load
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const canvas = await html2canvas(certificateRef.current, {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
backgroundColor: '#ffffff',
|
||||
logging: false,
|
||||
width: 1000,
|
||||
height: (1000 * 2480) / 3508,
|
||||
});
|
||||
|
||||
const imageUrl = canvas.toDataURL('image/png');
|
||||
setCertificateImage(imageUrl);
|
||||
setShowTemplate(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate certificate:', error);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
generateCertificate();
|
||||
}, [team, submission, qrCodeUrl, session?.user?.fullname]);
|
||||
|
||||
// Download certificate
|
||||
const handleDownloadCertificate = () => {
|
||||
if (!certificateImage) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = certificateImage;
|
||||
link.download = `certificate-${team?.name || 'hackathon'}.png`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
// Print certificate
|
||||
const handlePrintCertificate = () => {
|
||||
if (!certificateImage) return;
|
||||
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (printWindow) {
|
||||
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();
|
||||
};
|
||||
}
|
||||
|
||||
const matchingTeam = teams.find((team: Team) => {
|
||||
const generatedHash = generateCertificateHash(user.id, team.id);
|
||||
return generatedHash === certId;
|
||||
});
|
||||
|
||||
if (matchingTeam) {
|
||||
setIsValid(true);
|
||||
setValidTeam(matchingTeam as Team);
|
||||
} else {
|
||||
setIsValid(false);
|
||||
setValidTeam(null);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
}, [user, teamsData, certId]);
|
||||
const formatDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-GB', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
window.print(); // Yoloooo
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
if (error || !certId) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-linear-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800">
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-6xl mb-4">❌</div>
|
||||
<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 (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-center">
|
||||
<Icon icon="eos-icons:loading" className="text-6xl text-primary-600 mb-4 mx-auto" />
|
||||
<p className="text-gray-600 dark:text-gray-400">Validating certificate...</p>
|
||||
<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 (!isValid || !validTeam || !user) {
|
||||
if (!submission || submission.id !== decodedInfo?.submissionId) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-linear-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 p-4">
|
||||
<div className="text-center max-w-md">
|
||||
<Icon icon="heroicons:x-circle-solid" className="text-8xl text-red-500 mb-6 mx-auto" />
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Invalid Certificate
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-8">
|
||||
The certificate ID you provided is not valid or you don't have access to view this certificate.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="px-6 py-3 bg-primary-600 hover:bg-primary-700 text-white rounded-lg transition-colors"
|
||||
>
|
||||
Go to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-6xl mb-4">📄</div>
|
||||
<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">
|
||||
The submission associated with this certificate could not be found.
|
||||
</p>
|
||||
<Button onClick={() => navigate('/')}>Back to Home</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-linear-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 p-4 md:p-8">
|
||||
{/* Action buttons - hidden on print */}
|
||||
<div className="max-w-7xl mx-auto mb-6 print:hidden">
|
||||
<div className="flex justify-between items-center">
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="flex items-center gap-2 px-4 py-2 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
{/* Print Styles */}
|
||||
<style>{`
|
||||
@media print {
|
||||
@page {
|
||||
size: A4 landscape;
|
||||
margin: 0;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body * {
|
||||
visibility: hidden;
|
||||
}
|
||||
#certificate-wrapper {
|
||||
visibility: visible;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
}
|
||||
#certificate, #certificate * {
|
||||
visibility: visible;
|
||||
}
|
||||
#certificate {
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile responsive - zoom out to fit */
|
||||
@media (max-width: 768px) {
|
||||
#certificate-container {
|
||||
transform-origin: top center;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* 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">
|
||||
Certificate
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{team?.name}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() =>
|
||||
navigate(`/teams/${decodedInfo?.teamId}/submission`)
|
||||
}
|
||||
>
|
||||
Back to Submission
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Certificate Content */}
|
||||
<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_cert.png)',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
width: '1000px',
|
||||
height: `${(1000 * 2480) / 3508}px`,
|
||||
}}
|
||||
>
|
||||
<Icon icon="heroicons:arrow-left" />
|
||||
<span>Back to Dashboard</span>
|
||||
</button>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||
{/* User Name (from session) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '40%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '80%',
|
||||
}}
|
||||
>
|
||||
<Icon icon="heroicons:arrow-down-tray" />
|
||||
<span>Download</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
||||
>
|
||||
<Icon icon="heroicons:printer" />
|
||||
<span>Print</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Certificate */}
|
||||
<div className="max-w-5xl mx-auto">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-2xl shadow-2xl overflow-hidden print:shadow-none print:rounded-none">
|
||||
{/* Decorative header pattern */}
|
||||
<div className="h-3 bg-linear-to-r from-primary-400 via-primary-600 to-primary-400" />
|
||||
|
||||
<div className="p-12 md:p-16 relative">
|
||||
{/* Background pattern */}
|
||||
<div className="absolute inset-0 opacity-5 dark:opacity-10">
|
||||
<div className="absolute inset-0" style={{
|
||||
backgroundImage: `repeating-linear-gradient(
|
||||
45deg,
|
||||
transparent,
|
||||
transparent 35px,
|
||||
currentColor 35px,
|
||||
currentColor 70px
|
||||
)`
|
||||
}} />
|
||||
<h3
|
||||
ref={userNameRef}
|
||||
style={{
|
||||
fontWeight: 'bold',
|
||||
color: '#111827',
|
||||
textAlign: 'center',
|
||||
fontSize: userNameFontSize,
|
||||
lineHeight: '1.2',
|
||||
wordBreak: 'break-word',
|
||||
textShadow: '0 1px 2px rgba(0,0,0,0.1)',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{session?.user?.fullname || 'N/A'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start mb-12">
|
||||
<div>
|
||||
<h1 className="text-2xl md:text-3xl font-bold text-primary-600 dark:text-primary-400 mb-2">
|
||||
IMPHNEN x KOLOSAL
|
||||
</h1>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400">
|
||||
Hackathon 2025
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-1">
|
||||
PARTICIPANT
|
||||
</div>
|
||||
<div className="text-lg font-bold text-gray-700 dark:text-gray-300">
|
||||
Certificate of Completion
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Badge/Icon */}
|
||||
<div className="flex justify-center mb-8">
|
||||
<div className="relative">
|
||||
<div className="w-32 h-32 rounded-full bg-linear-to-br from-primary-400 to-primary-600 flex items-center justify-center shadow-lg">
|
||||
<div className="bg-white dark:bg-slate-800 w-28 h-28 rounded-full flex items-center justify-center">
|
||||
<Icon icon="heroicons:academic-cap-solid" className="text-6xl text-primary-600 dark:text-primary-400" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Laurel wreath effect */}
|
||||
<div className="absolute -top-2 -left-2 text-yellow-500">
|
||||
<Icon icon="game-icons:laurel-crown" className="text-4xl" />
|
||||
</div>
|
||||
<div className="absolute -bottom-2 -right-2 text-yellow-500">
|
||||
<Icon icon="game-icons:laurel-crown" className="text-4xl transform rotate-180" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Certificate text */}
|
||||
<div className="text-center mb-12">
|
||||
<p className="text-lg text-gray-600 dark:text-gray-400 mb-6">
|
||||
This award is presented to
|
||||
</p>
|
||||
|
||||
<h2 className="text-4xl md:text-5xl font-bold text-gray-900 dark:text-white mb-8 tracking-wide">
|
||||
{user.fullname}
|
||||
</h2>
|
||||
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<p className="text-lg text-gray-700 dark:text-gray-300 leading-relaxed">
|
||||
for active participation and successful completion in the IMPHNEN x KOLOSAL Hackathon 2025
|
||||
as a member of team <span className="font-semibold text-primary-600 dark:text-primary-400">"{validTeam.name}"</span> from{' '}
|
||||
<span className="font-semibold">{validTeam.city}</span>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Signature and date */}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-8 mt-12">
|
||||
<div className="flex justify-between items-end">
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<div className="font-signature text-3xl text-gray-700 dark:text-gray-300 mb-2" style={{ fontFamily: 'cursive' }}>
|
||||
IMPHNEN Team
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||
IMPHNEN Team
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Hackathon Organizer
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right">
|
||||
<div className="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-1">
|
||||
{formatDate(validTeam.created_at)}
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Completion Date
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Certificate ID footer */}
|
||||
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<p className="text-xs text-center text-gray-500 dark:text-gray-400 font-mono break-all">
|
||||
Certificate ID: {certId}
|
||||
</p>
|
||||
</div>
|
||||
{/* Team Name */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '46%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '70%',
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
ref={teamNameRef}
|
||||
style={{
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
textAlign: 'center',
|
||||
fontSize: teamNameFontSize,
|
||||
lineHeight: '1.2',
|
||||
wordBreak: 'break-word',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{team?.name}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Decorative footer pattern */}
|
||||
<div className="h-3 bg-linear-to-r from-primary-400 via-primary-600 to-primary-400" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Participation Text */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '60%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: '70%',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
color: '#374151',
|
||||
fontSize: '18px',
|
||||
fontWeight: '500',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 'bold' }}>
|
||||
Peserta Hackathon IMPHNEN x KOLOSAL AI
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Certificate verification info - hidden on print */}
|
||||
<div className="max-w-5xl mx-auto mt-8 print:hidden">
|
||||
<div className="bg-white dark:bg-slate-800 rounded-lg p-6 shadow-md">
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon icon="heroicons:information-circle" className="text-2xl text-primary-600 mt-1 shrink-0" />
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
<p className="font-semibold mb-2">Certificate Verification</p>
|
||||
<p>
|
||||
This certificate is cryptographically verified using HMAC-SHA256.
|
||||
The certificate ID is generated from your user ID and team ID, ensuring authenticity and uniqueness.
|
||||
{/* QR Code */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '8%',
|
||||
left: '8%',
|
||||
}}
|
||||
>
|
||||
{qrCodeUrl && (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={qrCodeUrl}
|
||||
alt="Certificate QR Code"
|
||||
style={{ width: '96px', height: '96px' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Date */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: '8%',
|
||||
right: '8%',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: '#374151',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{submission.submitted_at
|
||||
? new Date(submission.submitted_at).toLocaleDateString(
|
||||
'id-ID',
|
||||
{
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
}
|
||||
)
|
||||
: 'N/A'}
|
||||
</p>
|
||||
</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="Certificate"
|
||||
className="w-full h-auto"
|
||||
style={{ maxWidth: '100%', height: 'auto' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<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(`/teams/${decodedInfo?.teamId}/submission`)
|
||||
}
|
||||
variant="secondary"
|
||||
className="col-span-2 flex items-center gap-2 xl:col-span-1"
|
||||
>
|
||||
View Submission
|
||||
</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 participation
|
||||
and project submission. You can print or save this page as a PDF for
|
||||
your records.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,9 @@ import ProfilePage from '../profile/page';
|
||||
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
|
||||
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
||||
|
||||
// Submission deadline: 2025-12-07 23:59:00 WIB (UTC+7)
|
||||
const SUBMISSION_DEADLINE = new Date('2025-12-07T16:59:00Z');
|
||||
|
||||
type Invitation = {
|
||||
id: string;
|
||||
team: {
|
||||
@@ -37,9 +40,46 @@ type Invitation = {
|
||||
const DashboardPage: FC = (): ReactElement => {
|
||||
const { session } = useAuthStore();
|
||||
const [showProfileModal, setShowProfileModal] = useState(false);
|
||||
const [timeLeft, setTimeLeft] = useState<{
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
} | null>(null);
|
||||
|
||||
// Check if team features are closed
|
||||
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
||||
|
||||
// Check if submission deadline passed
|
||||
const isSubmissionDeadlinePassed = new Date() >= SUBMISSION_DEADLINE;
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
if (isSubmissionDeadlinePassed) return;
|
||||
|
||||
const calculateTimeLeft = () => {
|
||||
const now = new Date();
|
||||
const difference = SUBMISSION_DEADLINE.getTime() - now.getTime();
|
||||
|
||||
if (difference <= 0) {
|
||||
setTimeLeft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const days = Math.floor(difference / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((difference / (1000 * 60 * 60)) % 24);
|
||||
const minutes = Math.floor((difference / 1000 / 60) % 60);
|
||||
const seconds = Math.floor((difference / 1000) % 60);
|
||||
|
||||
setTimeLeft({ days, hours, minutes, seconds });
|
||||
};
|
||||
|
||||
calculateTimeLeft();
|
||||
const timer = setInterval(calculateTimeLeft, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isSubmissionDeadlinePassed]);
|
||||
|
||||
// Lock background scroll when profile modal is open
|
||||
useEffect(() => {
|
||||
if (showProfileModal) {
|
||||
@@ -94,6 +134,57 @@ const DashboardPage: FC = (): ReactElement => {
|
||||
)}
|
||||
</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">
|
||||
<div className="flex items-start space-x-3">
|
||||
<span className="text-3xl">⏰</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-blue-900 dark:text-blue-100 text-lg">
|
||||
Submission Deadline
|
||||
</h3>
|
||||
<p className="text-blue-800 dark:text-blue-200 mt-2 text-sm font-sans">
|
||||
Project submissions close on December 7, 2025 at 23:59 WIB
|
||||
</p>
|
||||
<div className="mt-4 grid grid-cols-4 gap-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{timeLeft.days}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Days
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{timeLeft.hours.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Hours
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{timeLeft.minutes.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Minutes
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{timeLeft.seconds.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Seconds
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invitations.length > 0 && (
|
||||
<div className="mb-8 bg-primary-50 dark:bg-blue-900/20 border border-primary-200 dark:border-blue-800 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950">
|
||||
<div className="text-gray-600 dark:text-neutral-400">Loading submission...</div>
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-gray-600 dark:text-gray-400">Loading submission...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!submission) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950">
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-6xl mb-4">📄</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">No Submission Yet</h2>
|
||||
<p className="text-gray-600 dark:text-neutral-400 mb-4">Your team hasn't submitted a project</p>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">Your team hasn't submitted a project</p>
|
||||
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -43,13 +44,13 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
: 'Not submitted';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950">
|
||||
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||
<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">Project Submission</h1>
|
||||
<p className="text-gray-600 dark:text-neutral-400 mt-1">{team?.name}</p>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">{team?.name}</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
|
||||
Back to Team
|
||||
@@ -101,9 +102,37 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-950/50 overflow-hidden">
|
||||
{/* Certificate Banner - Only show when submission is submitted */}
|
||||
{submission.status === 'submitted' && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-lg p-6 mb-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">
|
||||
View Your Certificate
|
||||
</h3>
|
||||
<p className="text-amber-700 dark:text-amber-300 text-sm">
|
||||
Congratulations! Your certificate is ready to download and share.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const certId = await encodeCertificateId(teamId || '', submission.id);
|
||||
navigate(`/certificate/${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"
|
||||
>
|
||||
Get Certificate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 overflow-hidden">
|
||||
{/* Project Header */}
|
||||
<div className="bg-gradient-to-r from-blue-600 to-blue-800 text-white p-8">
|
||||
<div className="bg-linear-to-r from-blue-600 to-blue-800 text-white p-8">
|
||||
<h2 className="text-3xl font-bold mb-2">{submission.project_name}</h2>
|
||||
<p className="text-blue-100">Team: {team?.name}</p>
|
||||
</div>
|
||||
@@ -113,8 +142,8 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
{/* Description */}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Project Description</h3>
|
||||
<div className="bg-gray-50 dark:bg-neutral-800 rounded-lg p-4">
|
||||
<p className="text-gray-700 dark:text-neutral-300 whitespace-pre-wrap">{submission.description}</p>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{submission.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -170,7 +199,7 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
<img
|
||||
src={url}
|
||||
alt={`Screenshot ${index + 1}`}
|
||||
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 dark:border-neutral-700 hover:border-blue-500 dark:hover:border-primary-500 transition-colors cursor-pointer"
|
||||
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-primary-500 transition-colors cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
@@ -179,11 +208,11 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
)}
|
||||
|
||||
{/* Submission Info */}
|
||||
<div className="bg-gray-50 dark:bg-neutral-800 rounded-lg p-4 border-t-4 border-blue-600 dark:border-primary-500">
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border-t-4 border-blue-600 dark:border-primary-500">
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-white mb-2">Submission Information</h3>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-neutral-400">Status:</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">Status:</span>
|
||||
<span className={`font-medium ${
|
||||
submission.status === 'submitted'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
@@ -199,11 +228,11 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-neutral-400">Submitted:</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">Submitted:</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{submittedDate}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-neutral-400">Submission ID:</span>
|
||||
<span className="text-gray-600 dark:text-gray-400">Submission ID:</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white font-mono text-xs">
|
||||
{submission.id}
|
||||
</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { FC, ReactElement, useState, useEffect } from 'react';
|
||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
@@ -14,10 +14,14 @@ import {
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from 'sonner';
|
||||
import { Icon } from '@iconify/react';
|
||||
|
||||
const MIN_TEAM_MEMBERS = 2; // Minimum members required to submit (including leader)
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
|
||||
// Submission deadline: 2025-12-07 23:59:00 WIB (UTC+7)
|
||||
const SUBMISSION_DEADLINE = new Date('2025-12-07T16:59:00Z');
|
||||
|
||||
const SubmitProjectPage: FC = (): ReactElement => {
|
||||
const { teamId } = useParams<{ teamId: string }>();
|
||||
const navigate = useNavigate();
|
||||
@@ -25,6 +29,42 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const [screenshots, setScreenshots] = useState<string[]>([]);
|
||||
const [timeLeft, setTimeLeft] = useState<{
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
} | null>(null);
|
||||
|
||||
// Check if deadline passed
|
||||
const isDeadlinePassed = new Date() >= SUBMISSION_DEADLINE;
|
||||
|
||||
// Countdown timer
|
||||
useEffect(() => {
|
||||
if (isDeadlinePassed) return;
|
||||
|
||||
const calculateTimeLeft = () => {
|
||||
const now = new Date();
|
||||
const difference = SUBMISSION_DEADLINE.getTime() - now.getTime();
|
||||
|
||||
if (difference <= 0) {
|
||||
setTimeLeft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const days = Math.floor(difference / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((difference / (1000 * 60 * 60)) % 24);
|
||||
const minutes = Math.floor((difference / 1000 / 60) % 60);
|
||||
const seconds = Math.floor((difference / 1000) % 60);
|
||||
|
||||
setTimeLeft({ days, hours, minutes, seconds });
|
||||
};
|
||||
|
||||
calculateTimeLeft();
|
||||
const timer = setInterval(calculateTimeLeft, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isDeadlinePassed]);
|
||||
|
||||
const { data: teamData } = useTeamById(teamId || '');
|
||||
const { data: submissionData } = useTeamSubmission(teamId || '', !!teamId);
|
||||
@@ -85,6 +125,50 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
);
|
||||
}
|
||||
|
||||
// Show deadline passed screen
|
||||
if (isDeadlinePassed) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 text-center">
|
||||
<div className="mb-6">
|
||||
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<Icon
|
||||
icon="mdi:clock-alert"
|
||||
className="text-3xl text-red-600 dark:text-red-400"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Submission Closed
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Project submissions are no longer accepted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
The submission deadline was December 7, 2025 at 23:59 WIB.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => navigate(`/teams/${teamId}`)}
|
||||
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors cursor-pointer"
|
||||
>
|
||||
Back to Team
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleScreenshotUpload = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
@@ -138,6 +222,57 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Countdown Timer */}
|
||||
{timeLeft && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<span className="text-3xl">⏰</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-blue-900 dark:text-blue-100 text-lg">
|
||||
Submission Deadline
|
||||
</h3>
|
||||
<p className="text-blue-800 dark:text-blue-200 mt-2 text-sm font-sans">
|
||||
Submissions close on December 7, 2025 at 23:59 WIB
|
||||
</p>
|
||||
<div className="mt-4 grid grid-cols-4 gap-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{timeLeft.days}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Days
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{timeLeft.hours.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Hours
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{timeLeft.minutes.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Minutes
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{timeLeft.seconds.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Seconds
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Minimum Members Warning */}
|
||||
{!hasEnoughMembers && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-500 rounded-lg p-6 mb-6">
|
||||
@@ -200,6 +335,9 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
<label className="block text-[15px] font-medium text-gray-700 dark:text-gray-300">
|
||||
Project Description <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
Describe your project, its features, and what problem it solves. You can also paste your demo video link here.
|
||||
</p>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="description"
|
||||
@@ -207,7 +345,7 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
<div>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="Describe your project, its features, and what problem it solves..."
|
||||
placeholder="Describe your project, its features, and what problem it solves... You can paste your demo video link (YouTube, Loom, etc.) here as well."
|
||||
rows={6}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
|
||||
@@ -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 (
|
||||
<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 (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-6xl mb-4">⚠️</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Error Loading Winners
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Unable to load winners at this time. Please try again later.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<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">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">🏆</div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Hackathon Winners
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Congratulations to all the winning teams!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Winners List */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{winners.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<div className="text-6xl mb-4">🎯</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
No Winners Announced Yet
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Winners will be announced here once the hackathon concludes.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* Top 3 Winners - Mobile View */}
|
||||
<div className="md:hidden space-y-6">
|
||||
{[1, 2, 3].map((position) => {
|
||||
const winner = sortedWinners[position - 1];
|
||||
|
||||
if(!winner) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={winner.id}
|
||||
className={`bg-white dark:bg-gray-800 rounded-lg p-3 shadow-lg ${
|
||||
getRankColor(winner.rank).includes('yellow')
|
||||
? 'border-4 border-yellow-400 dark:border-yellow-600'
|
||||
: getRankColor(winner.rank).includes('gray')
|
||||
? 'border-4 border-gray-400 dark:border-gray-600'
|
||||
: 'border-4 border-amber-600 dark:border-amber-500'
|
||||
}}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={`aspect-square p-1 rounded-full bg-linear-to-br ${getRankColor(
|
||||
winner.rank
|
||||
)} flex items-center justify-center text-white font-bold text-2xl`}
|
||||
>
|
||||
<div className="text-4xl">
|
||||
{getMedalEmoji(winner.rank)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{winner.team.logo && (
|
||||
<img
|
||||
src={winner.team.logo}
|
||||
alt={`${winner.team.name} logo`}
|
||||
className="w-20 h-20 rounded-full object-cover border-3 border-white dark:border-gray-700 shadow-lg"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{winner.team.name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{winner.team.city}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Top 3 Winners - Tablet/Desktop Podium View */}
|
||||
<div className="hidden md:flex gap-8 items-end justify-center w-full">
|
||||
{[2, 1, 3].map((position) => {
|
||||
const winner = sortedWinners[position - 1];
|
||||
if(!winner) return null;
|
||||
return (
|
||||
<div
|
||||
key={winner.id}
|
||||
className="w-full max-w-xs flex flex-col items-center space-y-4"
|
||||
>
|
||||
<div className="text-5xl">{getMedalEmoji(winner.rank)}</div>
|
||||
|
||||
{/* Team Logo */}
|
||||
{winner.team.logo && (
|
||||
<img
|
||||
src={winner.team.logo}
|
||||
alt={`${winner.team.name} logo`}
|
||||
className="w-24 h-24 rounded-full object-cover border-4 border-white dark:border-gray-700 shadow-lg"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="text-center px-2">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{winner.team.name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{winner.team.city}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Podium */}
|
||||
<div
|
||||
className={`${
|
||||
winner.rank === 1
|
||||
? 'h-42 bg-yellow-500'
|
||||
: winner.rank === 2
|
||||
? 'h-32 bg-gray-400'
|
||||
: 'h-16 bg-amber-600'
|
||||
} w-full flex items-end justify-center rounded-t-lg shadow-lg`}
|
||||
>
|
||||
<div className="text-white font-bold text-3xl pb-4">
|
||||
#{winner.rank}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Ranks 4-23: Prize Winners */}
|
||||
{sortedWinners.filter((w) => w.rank >= 4 && w.rank <= 23).length >
|
||||
0 && (
|
||||
<div className="mt-12">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center">
|
||||
Favorite
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sortedWinners
|
||||
.filter((w) => w.rank >= 4 && w.rank <= 23)
|
||||
.map((winner) => (
|
||||
<div
|
||||
key={winner.id}
|
||||
className="bg-white dark:bg-gray-800 border-2 border-gray-400 dark:border-gray-600 rounded-lg p-4 hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="shrink-0">
|
||||
<div className="w-12 h-12 rounded-full bg-linear-to-br from-gray-400 to-gray-600 flex items-center justify-center text-white font-bold text-lg">
|
||||
#{winner.rank}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{winner.team.logo && (
|
||||
<img
|
||||
src={winner.team.logo}
|
||||
alt={`${winner.team.name} logo`}
|
||||
className="w-16 h-16 rounded-full object-cover border-2 border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-gray-900 dark:text-white truncate">
|
||||
{winner.team.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{winner.team.city}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className="text-xs bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300 px-2 py-1 rounded-full">
|
||||
🎁 Prize Winner
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Rank 24+: Remaining Participants */}
|
||||
{sortedWinners.filter((w) => w.rank >= 24).length > 0 && (
|
||||
<div className="mt-12">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center">
|
||||
All Participants
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{sortedWinners
|
||||
.filter((w) => w.rank >= 24)
|
||||
.map((participant) => (
|
||||
<div
|
||||
key={participant.id}
|
||||
className="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="shrink-0 w-10 text-center">
|
||||
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">
|
||||
#{participant.rank}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{participant.team.logo && (
|
||||
<img
|
||||
src={participant.team.logo}
|
||||
alt={`${participant.team.name} logo`}
|
||||
className="w-12 h-12 rounded-full object-cover border-2 border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">
|
||||
{participant.team.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{participant.team.city}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Info */}
|
||||
{winners.length > 0 && (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-8">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
|
||||
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
|
||||
Congratulations! 🎉
|
||||
</h3>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
Thank you to all participants for making this hackathon a success.
|
||||
Every project and idea contributed to an incredible showcase of
|
||||
innovation and creativity.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default WinnerPage;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<string> => {
|
||||
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');
|
||||
};
|
||||
@@ -5,3 +5,4 @@ export * from './mentors';
|
||||
export * from './upload';
|
||||
export * from './teams';
|
||||
export * from './messages';
|
||||
export * from './winners';
|
||||
|
||||
@@ -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<HackathonApiResponse<Winner[]>>({
|
||||
queryKey: winnerKeys.lists(),
|
||||
queryFn: async () => {
|
||||
const response = await hackathonApi.get('/winners');
|
||||
return response.data;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -7,5 +7,3 @@ export * from './cookies';
|
||||
export * from './session';
|
||||
export * from './constants';
|
||||
export * from './logic';
|
||||
export * from './lib/crypto';
|
||||
export * from './lib/certificate';
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { generateCertificateHash } from './crypto';
|
||||
|
||||
/**
|
||||
* Generate a certificate URL for a user and team
|
||||
* @param userId - User ID
|
||||
* @param teamId - Team ID
|
||||
* @param baseUrl - Base URL (optional, defaults to current origin)
|
||||
* @returns Certificate URL
|
||||
*/
|
||||
export const generateCertificateUrl = (
|
||||
userId: string,
|
||||
teamId: string,
|
||||
baseUrl?: string
|
||||
): string => {
|
||||
const certId = generateCertificateHash(userId, teamId);
|
||||
const base = baseUrl || (typeof window !== 'undefined' ? window.location.origin : '');
|
||||
return `${base}/certificate/${certId}`;
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
import CryptoJS from 'crypto-js';
|
||||
|
||||
/**
|
||||
* Generate certificate hash using HMAC-SHA256
|
||||
* @param userId - User ID
|
||||
* @param teamId - Team ID
|
||||
* @returns Hash string
|
||||
*/
|
||||
export const generateCertificateHash = (userId: string, teamId: string): string => {
|
||||
const key = 'imphnenxkolosal';
|
||||
const message = userId + teamId;
|
||||
|
||||
// Repeat key to match required length (32 bytes for SHA256)
|
||||
const repeatedKey = key.repeat(Math.ceil(32 / key.length)).substring(0, 32);
|
||||
|
||||
// Create HMAC-SHA256 hash
|
||||
const hash = CryptoJS.HmacSHA256(message, repeatedKey);
|
||||
return hash.toString(CryptoJS.enc.Hex);
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify if a certificate hash is valid for a given user and team
|
||||
* @param certId - Certificate ID to verify
|
||||
* @param userId - User ID
|
||||
* @param teamId - Team ID
|
||||
* @returns True if the certificate is valid
|
||||
*/
|
||||
export const verifyCertificateHash = (certId: string, userId: string, teamId: string): boolean => {
|
||||
const generatedHash = generateCertificateHash(userId, teamId);
|
||||
return generatedHash === certId;
|
||||
};
|
||||
Generated
+128
-171
@@ -78,7 +78,7 @@
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"@vitest/coverage-v8": "^3.0.5",
|
||||
"@vitest/ui": "^3.0.0",
|
||||
"ajv": "^8.13.0",
|
||||
"ajv": "^6.12.6",
|
||||
"eslint": "^9.8.0",
|
||||
"eslint-config-next": "^15.2.4",
|
||||
"eslint-config-prettier": "10.0.0",
|
||||
@@ -216,7 +216,6 @@
|
||||
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.27.1",
|
||||
"@babel/generator": "^7.28.5",
|
||||
@@ -2689,28 +2688,6 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "9.39.1",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.1.tgz",
|
||||
@@ -4142,7 +4119,6 @@
|
||||
"integrity": "sha512-fnP+ZOZTFeBGiTAnxve+axGmiYn2D60h86nUISXjXClK3LUY1krUfPgf6MaD4YDJ4i51OGXZWPekeMe16pkd8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@module-federation/runtime": "0.21.6",
|
||||
"@module-federation/webpack-bundler-runtime": "0.21.6"
|
||||
@@ -5275,6 +5251,23 @@
|
||||
"vitest": "^1.3.1 || ^2.0.0 || ^3.0.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nx/vite/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@nx/vite/node_modules/semver": {
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
@@ -5387,6 +5380,23 @@
|
||||
"webpack-subresource-integrity": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@nx/webpack/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@nx/workspace": {
|
||||
"version": "22.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@nx/workspace/-/workspace-22.1.1.tgz",
|
||||
@@ -5779,7 +5789,6 @@
|
||||
"integrity": "sha512-vSMYtL/zOcFpvJCW71Q/OEGQb7KYBPAdKh35WNSkaZA75JlAO8ED8UN6GUNTm3drWomcbcqRPFqQbLae8yBTdg==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"playwright": "1.56.1"
|
||||
},
|
||||
@@ -7121,7 +7130,6 @@
|
||||
"integrity": "sha512-5F1+MQD8rfbFbUHnaiZe4jqOu9pnSb+PliqQvi0lj+uvpMpcS3sJDIs/mz6P1u87lfkfBXChIT4zSLAzeOgMWw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@module-federation/runtime-tools": "0.21.4",
|
||||
"@rspack/binding": "1.6.4",
|
||||
@@ -7250,7 +7258,6 @@
|
||||
"integrity": "sha512-PRA911Blj99jR5RMeTunVbNXMF6Lp4vZXnk5GQjcnUWUTsrXtekg/pnmFFI2u/I36Y/2bITGS30GZCXei6uNkA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
@@ -7732,7 +7739,6 @@
|
||||
"integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.21.3",
|
||||
"@svgr/babel-preset": "8.1.0",
|
||||
@@ -7999,7 +8005,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@swc/counter": "^0.1.3",
|
||||
"@swc/types": "^0.1.8"
|
||||
@@ -8215,7 +8220,6 @@
|
||||
"integrity": "sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
@@ -8226,7 +8230,6 @@
|
||||
"integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@swc/counter": "^0.1.3"
|
||||
}
|
||||
@@ -8540,7 +8543,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.10.tgz",
|
||||
"integrity": "sha512-BKLss9Y8PQ9IUjPYQiv3/Zmlx92uxffUOX8ZZNoQlCIZBJPT5M+GOMQj7xislvVQ6l1BstBjcX0XB/aHfFYVNw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.90.10"
|
||||
},
|
||||
@@ -8619,7 +8621,6 @@
|
||||
"integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.10.4",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
@@ -8808,7 +8809,6 @@
|
||||
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.20.7",
|
||||
"@babel/types": "^7.20.7",
|
||||
@@ -9136,7 +9136,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz",
|
||||
"integrity": "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -9184,7 +9183,6 @@
|
||||
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
}
|
||||
@@ -9195,7 +9193,6 @@
|
||||
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
@@ -9362,7 +9359,6 @@
|
||||
"integrity": "sha512-jCzKdm/QK0Kg4V4IK/oMlRZlY+QOcdjv89U2NgKHZk1CYTj82/RVSx1mV/0gqCVMJ/DA+Zf/S4NBWNF8GQ+eqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.48.0",
|
||||
"@typescript-eslint/types": "8.48.0",
|
||||
@@ -9947,6 +9943,23 @@
|
||||
"url": "https://opencollective.com/verdaccio"
|
||||
}
|
||||
},
|
||||
"node_modules/@verdaccio/core/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@verdaccio/core/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
@@ -10089,6 +10102,23 @@
|
||||
"url": "https://opencollective.com/verdaccio"
|
||||
}
|
||||
},
|
||||
"node_modules/@verdaccio/local-storage-legacy/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/@verdaccio/local-storage-legacy/node_modules/brace-expansion": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
@@ -10559,7 +10589,6 @@
|
||||
"integrity": "sha512-hGISOaP18plkzbWEcP/QvtRW1xDXF2+96HbEX6byqQhAUbiS5oH6/9JwW+QsQCIYON2bI6QZBF+2PvOmrRZ9wA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@vitest/utils": "3.2.4",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -11104,7 +11133,6 @@
|
||||
"integrity": "sha512-nrUSn7hzt7J6JWgWGz78ZYI8wj+gdIJdk0Ynjpp8l+trkn58Uqsf6RYrYkEK+3X18EX+TNdtJI0WxAtc+L84SQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
},
|
||||
@@ -11152,7 +11180,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -11229,17 +11256,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@@ -11264,6 +11289,39 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-keywords": {
|
||||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
|
||||
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"ajv": "^6.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv/node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/alien-signals": {
|
||||
"version": "0.4.14",
|
||||
"resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-0.4.14.tgz",
|
||||
@@ -12388,7 +12446,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.8.25",
|
||||
"caniuse-lite": "^1.0.30001754",
|
||||
@@ -12720,7 +12777,6 @@
|
||||
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"readdirp": "^4.0.1"
|
||||
},
|
||||
@@ -14906,7 +14962,6 @@
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.1.tgz",
|
||||
"integrity": "sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -15057,7 +15112,6 @@
|
||||
"deprecated": "wrong version missing main field",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"eslint-config-prettier": "build/bin/cli.js"
|
||||
},
|
||||
@@ -15156,7 +15210,6 @@
|
||||
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@rtsao/scc": "^1.1.0",
|
||||
"array-includes": "^3.1.8",
|
||||
@@ -15420,22 +15473,6 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/eslint-visitor-keys": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
||||
@@ -15448,12 +15485,6 @@
|
||||
"url": "https://opencollective.com/eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/espree": {
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz",
|
||||
@@ -15940,41 +15971,6 @@
|
||||
"webpack": "^4.0.0 || ^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-loader/node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/file-loader/node_modules/ajv-keywords": {
|
||||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
|
||||
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"ajv": "^6.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/file-loader/node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/file-loader/node_modules/schema-utils": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz",
|
||||
@@ -16339,34 +16335,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fork-ts-checker-webpack-plugin/node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
"json-schema-traverse": "^0.4.1",
|
||||
"uri-js": "^4.2.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/fork-ts-checker-webpack-plugin/node_modules/ajv-keywords": {
|
||||
"version": "3.5.2",
|
||||
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
|
||||
"integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"ajv": "^6.9.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fork-ts-checker-webpack-plugin/node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -16437,13 +16405,6 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fork-ts-checker-webpack-plugin/node_modules/json-schema-traverse": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||
"integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fork-ts-checker-webpack-plugin/node_modules/picomatch": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||
@@ -19547,7 +19508,6 @@
|
||||
"integrity": "sha512-j1n1IuTX1VQjIy3tT7cyGbX7nvQOsFLoIqobZv4ttI5axP923gA44zUj6miiA6R5Aoms4sEGVIIcucXUbRI14g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"copy-anything": "^2.0.1",
|
||||
"parse-node-version": "^1.0.1",
|
||||
@@ -21029,7 +20989,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@napi-rs/wasm-runtime": "0.2.4",
|
||||
"@yarnpkg/lockfile": "^1.1.0",
|
||||
@@ -21396,7 +21355,6 @@
|
||||
"resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.15.0.tgz",
|
||||
"integrity": "sha512-OjQUdi61WO4HYhr9+byCPMj0+bgste/LtSBEcV6FzDdONTs7x0fWn8/ndoYwzqCsKWIxEZwo4FN/TG1c1rI8IQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"openapi-typescript-helpers": "^0.0.15"
|
||||
}
|
||||
@@ -22269,7 +22227,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -23337,7 +23294,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz",
|
||||
"integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -23347,7 +23303,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz",
|
||||
"integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -23360,7 +23315,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.66.1.tgz",
|
||||
"integrity": "sha512-2KnjpgG2Rhbi+CIiIBQQ9Df6sMGH5ExNyFl4Hw9qO7pIqMBR8Bvu9RQyjl3JM4vehzCh9soiNUM/xYMswb2EiA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
@@ -23393,7 +23347,6 @@
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
@@ -23642,8 +23595,7 @@
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
@@ -23944,7 +23896,6 @@
|
||||
"integrity": "sha512-w8GmOxZfBmKknvdXU1sdM9NHcoQejwF/4mNgj2JuEEdRaHwwF12K7e9eXn1nLZ07ad+du76mkVsyeb2rKGllsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.8"
|
||||
},
|
||||
@@ -25229,7 +25180,6 @@
|
||||
"integrity": "sha512-N+7WK20/wOr7CzA2snJcUSSNTCzeCGUTFY3OgeQP3mZ1aj9NMQ0mSTXwlrnd89j33zzQJGqIN52GIOmYrfq46A==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"chokidar": "^4.0.0",
|
||||
"immutable": "^5.0.2",
|
||||
@@ -25251,7 +25201,6 @@
|
||||
"integrity": "sha512-+VUy01yfDqNmIVMd/LLKl2TTtY0ovZN0rTonh+FhKr65mFwIYgU9WzgIZKS7U9/SPCQvWTsTGx9jyt+qRm/XFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@bufbuild/protobuf": "^2.5.0",
|
||||
"buffer-builder": "^0.2.0",
|
||||
@@ -25743,6 +25692,23 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/schema-utils/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/schema-utils/node_modules/ajv-keywords": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
|
||||
@@ -27848,7 +27814,6 @@
|
||||
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@cspotcode/source-map-support": "^0.8.0",
|
||||
"@tsconfig/node10": "^1.0.7",
|
||||
@@ -27929,8 +27894,7 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD",
|
||||
"peer": true
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsscmp": {
|
||||
"version": "1.0.6",
|
||||
@@ -28112,7 +28076,6 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -28181,7 +28144,6 @@
|
||||
"integrity": "sha512-jCNyAuXx8dr5KJMkecGmZ8KI61KBUhkCob+SD+C+I5+Y1FWI2Y3QmY4/cxMCC5WAsZqoEtEETVhUiUMIGCf6Bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.40.0",
|
||||
"@typescript-eslint/types": "8.40.0",
|
||||
@@ -28867,7 +28829,6 @@
|
||||
"integrity": "sha512-hMIcPES81Cx+9xMJtBrPvLDODV433CRcen+akIu4NRQEb6rBHuZfWMhPZi1J7Ri3wSKLgp3ahexVKrkb8tTbjQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@cypress/request": "3.0.9",
|
||||
"@verdaccio/auth": "8.0.0-next-8.27",
|
||||
@@ -29051,7 +29012,6 @@
|
||||
"integrity": "sha512-OOUi5zjkDxYrKhTV3V7iKsoS37VUM7v40+HuwEmcrsf11Cdx9y3DIr2Px6liIcZFwt3XSRpQvFpL3WVy7ApkGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.25.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -29190,7 +29150,6 @@
|
||||
"integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/expect": "3.2.4",
|
||||
@@ -29341,7 +29300,6 @@
|
||||
"integrity": "sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/eslint-scope": "^3.7.7",
|
||||
"@types/estree": "^1.0.8",
|
||||
@@ -30053,7 +30011,6 @@
|
||||
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
|
||||
+4
-3
@@ -46,15 +46,16 @@
|
||||
"axios": "^1.8.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"crypto-js": "^4.2.0",
|
||||
"dayjs": "^1.11.13",
|
||||
"framer-motion": "^12.9.2",
|
||||
"graphql": "^16.11.0",
|
||||
"html2canvas": "^1.4.1",
|
||||
"js-cookie": "^3.0.5",
|
||||
"next": "~16.0.3",
|
||||
"next-themes": "^0.4.6",
|
||||
"openapi-fetch": "^0.15.0",
|
||||
"openapi-react-query": "^0.5.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-hook-form": "^7.56.4",
|
||||
@@ -95,15 +96,15 @@
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^22.12.0",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"@vitest/coverage-v8": "^3.0.5",
|
||||
"@vitest/ui": "^3.0.0",
|
||||
"ajv": "^8.13.0",
|
||||
"ajv": "^6.12.6",
|
||||
"eslint": "^9.8.0",
|
||||
"eslint-config-next": "^15.2.4",
|
||||
"eslint-config-prettier": "10.0.0",
|
||||
|
||||
Reference in New Issue
Block a user