Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f9a537c20 | ||
|
|
040f414e6d | ||
|
|
fe172cc128 | ||
|
|
3eb86c6ef4 | ||
|
|
48d97af044 | ||
|
|
38ee9a9549 | ||
|
|
36f7d10e7a | ||
|
|
3ad828e759 | ||
|
|
782cce820b | ||
|
|
f5882e3cdc | ||
|
|
afaaff4a7f | ||
|
|
da8b08cd73 | ||
|
|
1dbe704230 | ||
|
|
59c1bb0366 | ||
|
|
c518784810 | ||
|
|
0267de664b | ||
|
|
134ff14946 | ||
|
|
ed3b711c89 | ||
|
|
6b7b1c4d20 | ||
|
|
b4b19e02fa | ||
|
|
e61979cbb6 | ||
|
|
0c7887268c | ||
|
|
76933c0be7 |
@@ -1,44 +0,0 @@
|
|||||||
name: Deploy Backoffice
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
paths:
|
|
||||||
- 'apps/backoffice/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy-vps
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repo
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Deploy via SSH
|
|
||||||
uses: appleboy/ssh-action@v1.0.3
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.SSH_HOST }}
|
|
||||||
username: ${{ secrets.SSH_USER }}
|
|
||||||
key: ${{ secrets.SSH_KEY }}
|
|
||||||
port: ${{ secrets.SSH_PORT }}
|
|
||||||
command_timeout: 30m
|
|
||||||
script: |
|
|
||||||
set -e
|
|
||||||
if [ ! -d ~/imphnen-frontend-service-backoffice ]; then
|
|
||||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-backoffice
|
|
||||||
else
|
|
||||||
cd ~/imphnen-frontend-service-backoffice && git fetch origin && git reset --hard origin/develop && git pull
|
|
||||||
fi
|
|
||||||
cat > ~/imphnen-frontend-service-backoffice/apps/backoffice/.env << EOF
|
|
||||||
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
|
||||||
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
|
||||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
|
||||||
EOF
|
|
||||||
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml down || true
|
|
||||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml build --progress=plain
|
|
||||||
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml up -d
|
|
||||||
docker image prune -af
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
name: Deploy Dimentorin
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
paths:
|
|
||||||
- 'apps/dimentorin/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy-vps
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repo
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Deploy via SSH
|
|
||||||
uses: appleboy/ssh-action@v1.0.3
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.SSH_HOST }}
|
|
||||||
username: ${{ secrets.SSH_USER }}
|
|
||||||
key: ${{ secrets.SSH_KEY }}
|
|
||||||
port: ${{ secrets.SSH_PORT }}
|
|
||||||
command_timeout: 30m
|
|
||||||
script: |
|
|
||||||
set -e
|
|
||||||
if [ ! -d ~/imphnen-frontend-service-dimentorin ]; then
|
|
||||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-dimentorin
|
|
||||||
else
|
|
||||||
cd ~/imphnen-frontend-service-dimentorin && git fetch origin && git reset --hard origin/develop && git pull
|
|
||||||
fi
|
|
||||||
cat > ~/imphnen-frontend-service-dimentorin/apps/dimentorin/.env << EOF
|
|
||||||
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
|
||||||
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
|
||||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
|
||||||
EOF
|
|
||||||
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml down || true
|
|
||||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml build --progress=plain
|
|
||||||
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml up -d
|
|
||||||
docker image prune -af
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
name: Deploy Gacha
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
paths:
|
|
||||||
- 'apps/gacha/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy-vps
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repo
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Deploy via SSH
|
|
||||||
uses: appleboy/ssh-action@v1.0.3
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.SSH_HOST }}
|
|
||||||
username: ${{ secrets.SSH_USER }}
|
|
||||||
key: ${{ secrets.SSH_KEY }}
|
|
||||||
port: ${{ secrets.SSH_PORT }}
|
|
||||||
command_timeout: 30m
|
|
||||||
script: |
|
|
||||||
set -e
|
|
||||||
if [ ! -d ~/imphnen-frontend-service-gacha ]; then
|
|
||||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-gacha
|
|
||||||
else
|
|
||||||
cd ~/imphnen-frontend-service-gacha && git fetch origin && git reset --hard origin/develop && git pull
|
|
||||||
fi
|
|
||||||
cat > ~/imphnen-frontend-service-gacha/apps/gacha/.env << EOF
|
|
||||||
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
|
||||||
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
|
||||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
|
||||||
EOF
|
|
||||||
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml down || true
|
|
||||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml build --progress=plain
|
|
||||||
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml up -d
|
|
||||||
docker image prune -af
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
name: Deploy Hackathon
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
paths:
|
|
||||||
- 'apps/hackathon/**' # Auto deploy when changes pushed to apps/hackathon
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy-vps
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repo
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Deploy via SSH
|
|
||||||
uses: appleboy/ssh-action@v1.0.3
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.SSH_HOST }}
|
|
||||||
username: ${{ secrets.SSH_USER }}
|
|
||||||
key: ${{ secrets.SSH_KEY }}
|
|
||||||
port: ${{ secrets.SSH_PORT }}
|
|
||||||
command_timeout: 30m
|
|
||||||
script: |
|
|
||||||
set -e
|
|
||||||
if [ ! -d ~/imphnen-frontend-service-hackathon ]; then
|
|
||||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-hackathon
|
|
||||||
else
|
|
||||||
cd ~/imphnen-frontend-service-hackathon && git fetch origin && git reset --hard origin/develop && git pull
|
|
||||||
fi
|
|
||||||
cat > ~/imphnen-frontend-service-hackathon/.env.local << EOF
|
|
||||||
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
|
||||||
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
|
||||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
|
||||||
EOF
|
|
||||||
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml down || true
|
|
||||||
docker builder prune -af || true
|
|
||||||
DOCKER_BUILDKIT=1 docker compose --env-file ~/imphnen-frontend-service-hackathon/.env.local -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml build --progress=plain
|
|
||||||
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml up -d
|
|
||||||
docker image prune -af
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
name: Deploy Landing
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
paths:
|
|
||||||
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy-vps
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repo
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Deploy via SSH
|
|
||||||
uses: appleboy/ssh-action@v1.0.3
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.SSH_HOST }}
|
|
||||||
username: ${{ secrets.SSH_USER }}
|
|
||||||
key: ${{ secrets.SSH_KEY }}
|
|
||||||
port: ${{ secrets.SSH_PORT }}
|
|
||||||
command_timeout: 30m
|
|
||||||
script: |
|
|
||||||
set -e
|
|
||||||
if [ ! -d ~/imphnen-frontend-service ]; then
|
|
||||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service
|
|
||||||
else
|
|
||||||
cd ~/imphnen-frontend-service && git fetch origin && git reset --hard origin/develop && git pull
|
|
||||||
fi
|
|
||||||
cat > ~/imphnen-frontend-service/apps/landing/.env << EOF
|
|
||||||
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}
|
|
||||||
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
|
||||||
EOF
|
|
||||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
|
|
||||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build --progress=plain
|
|
||||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
|
|
||||||
docker image prune -af
|
|
||||||
@@ -18,6 +18,9 @@
|
|||||||
type="image/svg+xml"
|
type="image/svg+xml"
|
||||||
href="/images/imphnen-logo-simple.svg"
|
href="/images/imphnen-logo-simple.svg"
|
||||||
/>
|
/>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700&display=swap" rel="stylesheet" />
|
||||||
<link rel="stylesheet" href="/src/index.css" />
|
<link rel="stylesheet" href="/src/index.css" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:url" content="https://hackathon.imphnen.dev/" />
|
<meta property="og:url" content="https://hackathon.imphnen.dev/" />
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 263 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 506 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 818 KiB |
@@ -3,8 +3,7 @@ import { useParams, useNavigate } from 'react-router';
|
|||||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { decodeCertificateId } from '../../../utils/certificate';
|
import { decodeCertificateId } from '../../../utils/certificate';
|
||||||
import {
|
import {
|
||||||
useTeamById,
|
useCertificatePublicData,
|
||||||
useTeamSubmission,
|
|
||||||
useAuthStore,
|
useAuthStore,
|
||||||
} from '@imphnen-frontend-service/service';
|
} from '@imphnen-frontend-service/service';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
@@ -13,6 +12,7 @@ import html2canvas from 'html2canvas';
|
|||||||
interface DecodedCert {
|
interface DecodedCert {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
submissionId: string;
|
submissionId: string;
|
||||||
|
userId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CertificatePage: FC = (): ReactElement => {
|
const CertificatePage: FC = (): ReactElement => {
|
||||||
@@ -41,10 +41,16 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
}, [certId]);
|
}, [certId]);
|
||||||
|
|
||||||
|
// Fetch certificate data using the new endpoint
|
||||||
|
const { data: certificateData, isLoading: isLoadingCertificate } =
|
||||||
|
useCertificatePublicData(decodedInfo?.userId || '', !!decodedInfo?.userId);
|
||||||
|
|
||||||
// Generate QR Code
|
// Generate QR Code
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (certId) {
|
if (certId) {
|
||||||
const certificateUrl = `${window.location.origin}/certificate/${certId}`;
|
// Use encodeURIComponent to properly encode the certId for the URL
|
||||||
|
const encodedCertId = encodeURIComponent(certId);
|
||||||
|
const certificateUrl = `${window.location.origin}/certificate/${encodedCertId}`;
|
||||||
QRCode.toDataURL(certificateUrl, {
|
QRCode.toDataURL(certificateUrl, {
|
||||||
width: 200,
|
width: 200,
|
||||||
margin: 1,
|
margin: 1,
|
||||||
@@ -58,18 +64,18 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
}, [certId]);
|
}, [certId]);
|
||||||
|
|
||||||
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
|
const certificate = certificateData?.data;
|
||||||
decodedInfo?.teamId || '',
|
const team = certificate?.team;
|
||||||
!!decodedInfo?.teamId
|
const submission = certificate?.submission;
|
||||||
);
|
const certificateUser = certificate?.user;
|
||||||
const { data: submissionData, isLoading: isLoadingSubmission } =
|
|
||||||
useTeamSubmission(decodedInfo?.teamId || '', !!decodedInfo?.teamId);
|
|
||||||
|
|
||||||
const team = teamData?.data;
|
const isLoading = (!decodedInfo && !error) || isLoadingCertificate;
|
||||||
const submission = submissionData?.data;
|
|
||||||
|
|
||||||
const isLoading =
|
// Certificate name from the user data
|
||||||
(!decodedInfo && !error) || isLoadingTeam || isLoadingSubmission;
|
const certificateName = certificateUser?.fullname;
|
||||||
|
|
||||||
|
// Check if current user is viewing their own certificate (team member)
|
||||||
|
const isTeamMember = session?.user?.id === decodedInfo?.userId;
|
||||||
|
|
||||||
// Dynamic font sizing: shrink by 2px if height exceeds 80px
|
// Dynamic font sizing: shrink by 2px if height exceeds 80px
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -98,7 +104,7 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [team?.name, session?.user?.fullname]);
|
}, [team?.name, certificateName]);
|
||||||
|
|
||||||
// Generate certificate canvas screenshot
|
// Generate certificate canvas screenshot
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -107,19 +113,22 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
|
|
||||||
setIsGenerating(true);
|
setIsGenerating(true);
|
||||||
try {
|
try {
|
||||||
// Wait a bit for fonts and images to load
|
// Wait longer for fonts and images to load properly
|
||||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
const canvas = await html2canvas(certificateRef.current, {
|
const canvas = await html2canvas(certificateRef.current, {
|
||||||
scale: 2,
|
scale: 4,
|
||||||
useCORS: true,
|
useCORS: true,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
logging: false,
|
logging: false,
|
||||||
width: 1000,
|
width: 1000,
|
||||||
height: (1000 * 2480) / 3508,
|
height: (1000 * 2480) / 3508,
|
||||||
|
allowTaint: true,
|
||||||
|
imageTimeout: 0,
|
||||||
|
removeContainer: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const imageUrl = canvas.toDataURL('image/png');
|
const imageUrl = canvas.toDataURL('image/png', 1.0);
|
||||||
setCertificateImage(imageUrl);
|
setCertificateImage(imageUrl);
|
||||||
setShowTemplate(false);
|
setShowTemplate(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -130,7 +139,7 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
generateCertificate();
|
generateCertificate();
|
||||||
}, [team, submission, qrCodeUrl, session?.user?.fullname]);
|
}, [team, submission, qrCodeUrl]);
|
||||||
|
|
||||||
// Download certificate
|
// Download certificate
|
||||||
const handleDownloadCertificate = () => {
|
const handleDownloadCertificate = () => {
|
||||||
@@ -202,7 +211,7 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!submission || submission.id !== decodedInfo?.submissionId) {
|
if (!certificateUser) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
<div className="text-6xl mb-4">📄</div>
|
<div className="text-6xl mb-4">📄</div>
|
||||||
@@ -210,7 +219,7 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
Certificate Not Found
|
Certificate Not Found
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
The submission associated with this certificate could not be found.
|
The user associated with this certificate could not be found.
|
||||||
</p>
|
</p>
|
||||||
<Button onClick={() => navigate('/')}>Back to Home</Button>
|
<Button onClick={() => navigate('/')}>Back to Home</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -278,14 +287,14 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
{team?.name}
|
{team?.name}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{team && isTeamMember && (
|
||||||
variant="secondary"
|
<Button
|
||||||
onClick={() =>
|
variant="secondary"
|
||||||
navigate(`/teams/${decodedInfo?.teamId}/submission`)
|
onClick={() => navigate(`/teams/${team.id}/submission`)}
|
||||||
}
|
>
|
||||||
>
|
Back to Submission
|
||||||
Back to Submission
|
</Button>
|
||||||
</Button>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -305,57 +314,30 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
id="certificate-template"
|
id="certificate-template"
|
||||||
style={{
|
style={{
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
backgroundImage: 'url(/images/blank_cert.png)',
|
backgroundImage: 'url(/images/blank_cert.svg)',
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center',
|
backgroundPosition: 'center',
|
||||||
width: '1000px',
|
width: '1000px',
|
||||||
height: `${(1000 * 2480) / 3508}px`,
|
height: `${(1000 * 2480) / 3508}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* User Name (from session) */}
|
{/* Team Name - positioned in middle between "Diberikan Kepada" and "Telah Berpartisipasi" */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '40%',
|
top: '41%',
|
||||||
left: '50%',
|
left: '3.5%',
|
||||||
transform: 'translateX(-50%)',
|
width: '55%',
|
||||||
width: '80%',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Team Name */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '46%',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
width: '70%',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h3
|
<h3
|
||||||
ref={teamNameRef}
|
ref={teamNameRef}
|
||||||
style={{
|
style={{
|
||||||
fontWeight: '600',
|
fontFamily: 'Poppins, sans-serif',
|
||||||
color: '#1f2937',
|
fontWeight: 'bold',
|
||||||
textAlign: 'center',
|
color: '#59bef5',
|
||||||
fontSize: teamNameFontSize,
|
textAlign: 'left',
|
||||||
|
fontSize: '32px',
|
||||||
lineHeight: '1.2',
|
lineHeight: '1.2',
|
||||||
wordBreak: 'break-word',
|
wordBreak: 'break-word',
|
||||||
margin: 0,
|
margin: 0,
|
||||||
@@ -365,84 +347,53 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Participation Text */}
|
{/* User Name - positioned below team name */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: '60%',
|
top: '45%',
|
||||||
left: '50%',
|
left: '3.5%',
|
||||||
transform: 'translateX(-50%)',
|
width: '55%',
|
||||||
width: '70%',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<p
|
<h3
|
||||||
|
ref={userNameRef}
|
||||||
style={{
|
style={{
|
||||||
textAlign: 'center',
|
fontFamily: 'Poppins, sans-serif',
|
||||||
color: '#374151',
|
fontWeight: 'bold',
|
||||||
fontSize: '18px',
|
color: '#59bef5',
|
||||||
fontWeight: '500',
|
textAlign: 'left',
|
||||||
|
fontSize: '40px',
|
||||||
|
lineHeight: '1.2',
|
||||||
|
wordBreak: 'break-word',
|
||||||
margin: 0,
|
margin: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span style={{ fontWeight: 'bold' }}>
|
{certificateName || 'N/A'}
|
||||||
Peserta Hackathon IMPHNEN x KOLOSAL AI
|
</h3>
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* QR Code */}
|
{/* QR Code - positioned in the white box area */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: '8%',
|
top: '33%',
|
||||||
left: '8%',
|
right: '9.3%',
|
||||||
|
width: '190px',
|
||||||
|
height: '190px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{qrCodeUrl && (
|
{qrCodeUrl && (
|
||||||
<div
|
<img
|
||||||
style={{
|
src={qrCodeUrl}
|
||||||
backgroundColor: '#ffffff',
|
alt="Certificate QR Code"
|
||||||
padding: '8px',
|
style={{ width: '190px', height: '190px', display: 'block' }}
|
||||||
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>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -469,33 +420,35 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* 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">
|
{isTeamMember && (
|
||||||
<Button
|
<div className="bg-gray-50 dark:bg-gray-900 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
|
||||||
variant="secondary"
|
<Button
|
||||||
onClick={handleDownloadCertificate}
|
variant="secondary"
|
||||||
className="flex items-center gap-2"
|
onClick={handleDownloadCertificate}
|
||||||
disabled={isGenerating}
|
className="flex items-center gap-2"
|
||||||
>
|
disabled={isGenerating}
|
||||||
{isGenerating ? '⏳ Generating...' : '📥 Download'}
|
>
|
||||||
</Button>
|
{isGenerating ? '⏳ Generating...' : '📥 Download'}
|
||||||
<Button
|
</Button>
|
||||||
variant="secondary"
|
<Button
|
||||||
onClick={handlePrintCertificate}
|
variant="secondary"
|
||||||
className="flex items-center gap-2"
|
onClick={handlePrintCertificate}
|
||||||
disabled={isGenerating}
|
className="flex items-center gap-2"
|
||||||
>
|
disabled={isGenerating}
|
||||||
{isGenerating ? '⏳ Generating...' : '🖨️ Print'}
|
>
|
||||||
</Button>
|
{isGenerating ? '⏳ Generating...' : '🖨️ Print'}
|
||||||
<Button
|
</Button>
|
||||||
onClick={() =>
|
{team && (
|
||||||
navigate(`/teams/${decodedInfo?.teamId}/submission`)
|
<Button
|
||||||
}
|
onClick={() => navigate(`/teams/${team.id}/submission`)}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="col-span-2 flex items-center gap-2 xl:col-span-1"
|
className="col-span-2 flex items-center gap-2 xl:col-span-1"
|
||||||
>
|
>
|
||||||
View Submission
|
View Submission
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Info Box */}
|
{/* Info Box */}
|
||||||
|
|||||||
@@ -0,0 +1,605 @@
|
|||||||
|
import { FC, ReactElement, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import { decodeWinnerCertificateId } from '../../../../utils/certificate';
|
||||||
|
import {
|
||||||
|
useAuthStore,
|
||||||
|
useMyTeams,
|
||||||
|
useTeamById,
|
||||||
|
useTeamSubmission,
|
||||||
|
useWinners,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
import html2canvas from 'html2canvas';
|
||||||
|
|
||||||
|
type WinnerEntry = {
|
||||||
|
team_id: string;
|
||||||
|
rank: number;
|
||||||
|
team?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatOrdinalRank = (rank: number): string => {
|
||||||
|
const mod100 = rank % 100;
|
||||||
|
if (mod100 >= 11 && mod100 <= 13) return `${rank}th`;
|
||||||
|
|
||||||
|
switch (rank % 10) {
|
||||||
|
case 1:
|
||||||
|
return `${rank}st`;
|
||||||
|
case 2:
|
||||||
|
return `${rank}nd`;
|
||||||
|
case 3:
|
||||||
|
return `${rank}rd`;
|
||||||
|
default:
|
||||||
|
return `${rank}th`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep the template size aligned with the SVG native size (842x595) using an integer multiplier.
|
||||||
|
// This reduces sub-pixel scaling artifacts (blur) on thin lines when rasterizing with html2canvas.
|
||||||
|
const CERT_WIDTH = 842 * 2;
|
||||||
|
const CERT_HEIGHT = 595 * 2;
|
||||||
|
|
||||||
|
// Balance between output sharpness and file size.
|
||||||
|
// Output resolution will be (CERT_WIDTH * EXPORT_SCALE) x (CERT_HEIGHT * EXPORT_SCALE).
|
||||||
|
const EXPORT_SCALE = 2;
|
||||||
|
|
||||||
|
// The original layout was tuned around a ~1000px-wide template.
|
||||||
|
// We keep the same visual proportions by scaling fixed pixel values.
|
||||||
|
const LAYOUT_BASE_WIDTH = 1000;
|
||||||
|
const LAYOUT_SCALE = CERT_WIDTH / LAYOUT_BASE_WIDTH;
|
||||||
|
const s = (px: number) => Math.round(px * LAYOUT_SCALE);
|
||||||
|
|
||||||
|
const CertificateWinnerPage: FC = (): ReactElement => {
|
||||||
|
const { certId } = useParams<{ certId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { session } = useAuthStore();
|
||||||
|
const { data: myTeamsData } = useMyTeams();
|
||||||
|
const {
|
||||||
|
data: winnersResponse,
|
||||||
|
isLoading: isLoadingWinners,
|
||||||
|
isError: isWinnersError,
|
||||||
|
} = useWinners();
|
||||||
|
|
||||||
|
const [decodedTeamId, setDecodedTeamId] = useState<string>('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
|
||||||
|
const certificateRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [certificateImage, setCertificateImage] = useState<string>('');
|
||||||
|
const [showTemplate, setShowTemplate] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!certId) return;
|
||||||
|
|
||||||
|
decodeWinnerCertificateId(certId)
|
||||||
|
.then((decoded) => setDecodedTeamId(decoded.teamId))
|
||||||
|
.catch(() => setError('Invalid certificate ID'));
|
||||||
|
}, [certId]);
|
||||||
|
|
||||||
|
const winners = useMemo(
|
||||||
|
() => (winnersResponse?.data || []) as WinnerEntry[],
|
||||||
|
[winnersResponse?.data]
|
||||||
|
);
|
||||||
|
const winnerEntry = useMemo(() => {
|
||||||
|
if (!decodedTeamId) return undefined;
|
||||||
|
return winners.find((w) => w.team_id === decodedTeamId);
|
||||||
|
}, [decodedTeamId, winners]);
|
||||||
|
|
||||||
|
const rankLabel = useMemo(() => {
|
||||||
|
if (!winnerEntry?.rank) return '';
|
||||||
|
return formatOrdinalRank(winnerEntry.rank);
|
||||||
|
}, [winnerEntry?.rank]);
|
||||||
|
|
||||||
|
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
|
||||||
|
decodedTeamId,
|
||||||
|
!!decodedTeamId
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: submissionData, isLoading: isLoadingSubmission } =
|
||||||
|
useTeamSubmission(decodedTeamId, !!decodedTeamId);
|
||||||
|
|
||||||
|
const team = teamData?.data;
|
||||||
|
const submission = submissionData?.data;
|
||||||
|
const submissionName = submission?.project_name || '(Submission unavailable)';
|
||||||
|
|
||||||
|
const memberNames = useMemo(() => {
|
||||||
|
const members = team?.members || [];
|
||||||
|
return members
|
||||||
|
.map((m) => m.user?.fullname)
|
||||||
|
.filter((name): name is string => !!name);
|
||||||
|
}, [team?.members]);
|
||||||
|
|
||||||
|
const isWinnerTeam = !!winnerEntry;
|
||||||
|
const isTeamMember =
|
||||||
|
!!session?.user?.id &&
|
||||||
|
!!decodedTeamId &&
|
||||||
|
(myTeamsData?.data || []).some(
|
||||||
|
(t) => (t as { id?: string } | null | undefined)?.id === decodedTeamId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate QR Code (public link)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!certId) return;
|
||||||
|
|
||||||
|
const encodedCertId = encodeURIComponent(certId);
|
||||||
|
const certificateUrl = `${window.location.origin}/certificate/winner/${encodedCertId}`;
|
||||||
|
|
||||||
|
QRCode.toDataURL(certificateUrl, {
|
||||||
|
width: s(200),
|
||||||
|
margin: 1,
|
||||||
|
color: {
|
||||||
|
dark: '#000000',
|
||||||
|
light: '#ffffff',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(setQrCodeUrl)
|
||||||
|
.catch((err) => console.error('QR Code generation failed:', err));
|
||||||
|
}, [certId]);
|
||||||
|
|
||||||
|
// Generate certificate canvas screenshot
|
||||||
|
useEffect(() => {
|
||||||
|
const generateCertificate = async () => {
|
||||||
|
if (!certificateRef.current) return;
|
||||||
|
if (!team?.name) return;
|
||||||
|
if (!qrCodeUrl) return;
|
||||||
|
if (!winnerEntry?.rank) return;
|
||||||
|
if (isLoadingSubmission) return;
|
||||||
|
|
||||||
|
setIsGenerating(true);
|
||||||
|
try {
|
||||||
|
setShowTemplate(true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
const canvas = await html2canvas(certificateRef.current, {
|
||||||
|
scale: EXPORT_SCALE,
|
||||||
|
useCORS: true,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
logging: false,
|
||||||
|
width: CERT_WIDTH,
|
||||||
|
height: CERT_HEIGHT,
|
||||||
|
allowTaint: true,
|
||||||
|
imageTimeout: 0,
|
||||||
|
removeContainer: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageUrl = canvas.toDataURL('image/png', 1.0);
|
||||||
|
setCertificateImage(imageUrl);
|
||||||
|
setShowTemplate(false);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to generate certificate:', e);
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
generateCertificate();
|
||||||
|
}, [
|
||||||
|
team?.name,
|
||||||
|
memberNames,
|
||||||
|
qrCodeUrl,
|
||||||
|
winnerEntry?.rank,
|
||||||
|
isLoadingSubmission,
|
||||||
|
submissionName,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDownloadCertificate = () => {
|
||||||
|
if (!certificateImage) return;
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = certificateImage;
|
||||||
|
link.download = `winner-certificate-${team?.name || 'hackathon'}.png`;
|
||||||
|
link.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrintCertificate = () => {
|
||||||
|
if (!certificateImage) return;
|
||||||
|
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
if (!printWindow) return;
|
||||||
|
|
||||||
|
printWindow.document.write(`
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Certificate - ${team?.name}</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
||||||
|
img { max-width: 100%; height: auto; }
|
||||||
|
@media print {
|
||||||
|
@page { size: A4 landscape; margin: 0; }
|
||||||
|
body { margin: 0; }
|
||||||
|
img { width: 100%; height: auto; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<img src="${certificateImage}" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
printWindow.document.close();
|
||||||
|
printWindow.onload = () => {
|
||||||
|
printWindow.print();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error || !certId) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Invalid Certificate
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
{error || 'The certificate ID is invalid or malformed.'}
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => navigate('/')}>Back to Home</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!decodedTeamId || isLoadingTeam) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">
|
||||||
|
Loading certificate...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoadingWinners) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">
|
||||||
|
Loading winners...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWinnersError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Unable to Load Winners
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
Please try again later.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => navigate('/')}>Back to Home</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isWinnerTeam) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Certificate Not Found
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
This team is not listed as a hackathon winner.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => navigate('/')}>Back to Home</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!team?.name) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Team Not Found
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
The team associated with this certificate could not be loaded.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => navigate('/')}>Back to Home</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 no-print">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Winner Certificate
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{team.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isTeamMember && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => navigate('/dashboard')}
|
||||||
|
>
|
||||||
|
Back to Dashboard
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12"
|
||||||
|
id="certificate-wrapper"
|
||||||
|
>
|
||||||
|
{/* Hidden Template for Canvas Generation */}
|
||||||
|
<div
|
||||||
|
className={showTemplate ? 'block' : 'hidden'}
|
||||||
|
style={{ position: 'absolute', left: '-9999px' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={certificateRef}
|
||||||
|
id="certificate-template"
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
backgroundImage: 'url(/images/blank_winner_cert.svg)',
|
||||||
|
backgroundSize: '100% 100%',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
width: `${CERT_WIDTH}px`,
|
||||||
|
height: `${CERT_HEIGHT}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<style>{`
|
||||||
|
#winner-members li::marker {
|
||||||
|
color: #59bef5;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{/* Team Name */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '35%',
|
||||||
|
left: '3.5%',
|
||||||
|
width: '55%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Poppins, sans-serif',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#59bef5',
|
||||||
|
textAlign: 'left',
|
||||||
|
fontSize: `${s(28)}px`,
|
||||||
|
lineHeight: '1.2',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{team.name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Members list */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '40.5%',
|
||||||
|
left: '3.5%',
|
||||||
|
width: '55%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
id="winner-members"
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontFamily: 'Poppins, sans-serif',
|
||||||
|
fontSize: `${s(18)}px`,
|
||||||
|
lineHeight: '1.35',
|
||||||
|
color: '#59bef5',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(memberNames.length
|
||||||
|
? memberNames
|
||||||
|
: ['(Members unavailable)']
|
||||||
|
).map((name) => (
|
||||||
|
<li key={name}>• {name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Award text */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '60%',
|
||||||
|
left: '3.5%',
|
||||||
|
width: '60%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontFamily: 'Poppins, sans-serif',
|
||||||
|
fontSize: `${s(18)}px`,
|
||||||
|
lineHeight: '1.35',
|
||||||
|
color: '#6B6B6B',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Diberikan sebagai penghargaan atas pencapaian meraih
|
||||||
|
<br />
|
||||||
|
<b>JUARA {winnerEntry.rank}</b> pada Hackathon IMPHNEN x
|
||||||
|
Kolosal.ai
|
||||||
|
<br />
|
||||||
|
<span>
|
||||||
|
dengan nama project: <b>{submissionName}</b>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rank badge */}
|
||||||
|
{!!rankLabel && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '8%',
|
||||||
|
right: '8.5%',
|
||||||
|
width: `${s(190)}px`,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: `${s(6)}px ${s(16)}px`,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontFamily: 'Poppins, sans-serif',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#78350F',
|
||||||
|
fontSize: `${s(24)}px`,
|
||||||
|
lineHeight: '1',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rankLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR Code (same placement as existing certificate page) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '33%',
|
||||||
|
right: '9.3%',
|
||||||
|
width: `${s(190)}px`,
|
||||||
|
height: `${s(190)}px`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{qrCodeUrl && (
|
||||||
|
<img
|
||||||
|
src={qrCodeUrl}
|
||||||
|
alt="Certificate QR Code"
|
||||||
|
style={{
|
||||||
|
width: `${s(190)}px`,
|
||||||
|
height: `${s(190)}px`,
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Display Certificate Image */}
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 overflow-hidden p-2">
|
||||||
|
{isGenerating && (
|
||||||
|
<div className="flex items-center justify-center p-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">
|
||||||
|
Generating certificate...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{certificateImage && !isGenerating && (
|
||||||
|
<img
|
||||||
|
src={certificateImage}
|
||||||
|
alt="Winner Certificate"
|
||||||
|
className="w-full h-auto"
|
||||||
|
style={{ maxWidth: '100%', height: 'auto' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{isTeamMember && (
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleDownloadCertificate}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
disabled={isGenerating}
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<>
|
||||||
|
<Icon
|
||||||
|
icon="svg-spinners:ring-resize"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
/>
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon
|
||||||
|
icon="heroicons:arrow-down-tray"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
/>
|
||||||
|
Download
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handlePrintCertificate}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
disabled={isGenerating}
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<>
|
||||||
|
<Icon
|
||||||
|
icon="svg-spinners:ring-resize"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
/>
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon icon="mdi:printer" width="18" height="18" />
|
||||||
|
Print
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate('/dashboard')}
|
||||||
|
variant="secondary"
|
||||||
|
className="col-span-2 flex items-center gap-2 xl:col-span-1"
|
||||||
|
>
|
||||||
|
Back to Dashboard
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className="mt-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6 no-print">
|
||||||
|
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
|
||||||
|
Certificate Information
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
This certificate is a digital record of your hackathon achievement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CertificateWinnerPage;
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
import { FC, ReactElement, useEffect, useState } from 'react';
|
import { FC, ReactElement, useEffect, useMemo, useState } from 'react';
|
||||||
import { Link } from 'react-router';
|
import { Link, useNavigate } from 'react-router';
|
||||||
import {
|
import {
|
||||||
useMyTeams,
|
useMyTeams,
|
||||||
useMyInvitations,
|
useMyInvitations,
|
||||||
useRespondToInvitation,
|
useRespondToInvitation,
|
||||||
useAuthStore,
|
useAuthStore,
|
||||||
|
useWinners,
|
||||||
} from '@imphnen-frontend-service/service';
|
} from '@imphnen-frontend-service/service';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import ProfilePage from '../profile/page';
|
import ProfilePage from '../profile/page';
|
||||||
|
import { encodeWinnerCertificateId } from '../../utils/certificate';
|
||||||
|
|
||||||
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
|
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
|
||||||
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
||||||
@@ -39,6 +41,7 @@ type Invitation = {
|
|||||||
|
|
||||||
const DashboardPage: FC = (): ReactElement => {
|
const DashboardPage: FC = (): ReactElement => {
|
||||||
const { session } = useAuthStore();
|
const { session } = useAuthStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
const [showProfileModal, setShowProfileModal] = useState(false);
|
const [showProfileModal, setShowProfileModal] = useState(false);
|
||||||
const [timeLeft, setTimeLeft] = useState<{
|
const [timeLeft, setTimeLeft] = useState<{
|
||||||
days: number;
|
days: number;
|
||||||
@@ -91,6 +94,7 @@ const DashboardPage: FC = (): ReactElement => {
|
|||||||
}
|
}
|
||||||
}, [showProfileModal]);
|
}, [showProfileModal]);
|
||||||
const { data: teamsData } = useMyTeams();
|
const { data: teamsData } = useMyTeams();
|
||||||
|
const { data: winnersResponse } = useWinners();
|
||||||
const { data: invitationsData } = useMyInvitations();
|
const { data: invitationsData } = useMyInvitations();
|
||||||
const { mutateAsync: respondToInvitation } = useRespondToInvitation();
|
const { mutateAsync: respondToInvitation } = useRespondToInvitation();
|
||||||
|
|
||||||
@@ -99,6 +103,13 @@ const DashboardPage: FC = (): ReactElement => {
|
|||||||
const invitations: Invitation[] = (invitationsData?.data ||
|
const invitations: Invitation[] = (invitationsData?.data ||
|
||||||
[]) as Invitation[];
|
[]) as Invitation[];
|
||||||
|
|
||||||
|
const winnerEntry = useMemo(() => {
|
||||||
|
const team = (myTeams[0] as { id?: string } | null | undefined) || null;
|
||||||
|
const winners = winnersResponse?.data || [];
|
||||||
|
if (!team?.id) return null;
|
||||||
|
return winners.find((w) => w.team_id === team.id) || null;
|
||||||
|
}, [myTeams, winnersResponse?.data]);
|
||||||
|
|
||||||
const handleAcceptInvitation = async (invitationId: string) => {
|
const handleAcceptInvitation = async (invitationId: string) => {
|
||||||
try {
|
try {
|
||||||
await respondToInvitation({ invitationId, action: 'accept' });
|
await respondToInvitation({ invitationId, action: 'accept' });
|
||||||
@@ -134,6 +145,37 @@ const DashboardPage: FC = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Winner Banner */}
|
||||||
|
{winnerEntry && myTeams.length > 0 && (
|
||||||
|
<div className="mb-8 bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-lg p-6">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||||
|
<span className="text-4xl shrink-0">🏆</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="font-bold text-amber-900 dark:text-amber-100 text-lg">
|
||||||
|
Selamat! Tim Anda meraih JUARA {winnerEntry.rank}
|
||||||
|
</h3>
|
||||||
|
<p className="text-amber-700 dark:text-amber-300 text-sm">
|
||||||
|
Anda dapat generate sertifikat penghargaan dan
|
||||||
|
membagikannya.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const team = myTeams[0] as { id?: string } | null | undefined;
|
||||||
|
if (!team?.id) return;
|
||||||
|
const certId = await encodeWinnerCertificateId(team.id);
|
||||||
|
navigate(`/certificate/winner/${encodeURIComponent(certId)}`);
|
||||||
|
}}
|
||||||
|
className="shrink-0 px-6 py-2 bg-amber-600 hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-700 text-white font-medium rounded-lg transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Generate Sertifikat Juara
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Countdown Timer */}
|
{/* Countdown Timer */}
|
||||||
{timeLeft && !isSubmissionDeadlinePassed && (
|
{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="mb-8 bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 rounded-lg p-6">
|
||||||
@@ -194,7 +236,8 @@ const DashboardPage: FC = (): ReactElement => {
|
|||||||
<div className="mb-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
<div className="mb-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||||
<p className="text-sm text-amber-700 dark:text-amber-300 flex items-center gap-2">
|
<p className="text-sm text-amber-700 dark:text-amber-300 flex items-center gap-2">
|
||||||
<Icon icon="mdi:clock-alert" className="text-lg shrink-0" />
|
<Icon icon="mdi:clock-alert" className="text-lg shrink-0" />
|
||||||
Team features are closed. You can no longer accept invitations.
|
Team features are closed. You can no longer accept
|
||||||
|
invitations.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -263,7 +306,10 @@ const DashboardPage: FC = (): ReactElement => {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-16 h-16 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0">
|
<div className="w-16 h-16 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0">
|
||||||
<Icon icon="mdi:account-group" className="text-gray-500 dark:text-gray-400 text-2xl" />
|
<Icon
|
||||||
|
icon="mdi:account-group"
|
||||||
|
className="text-gray-500 dark:text-gray-400 text-2xl"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
@@ -273,19 +319,33 @@ const DashboardPage: FC = (): ReactElement => {
|
|||||||
<div className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-3 font-sans mt-2">
|
<div className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-3 font-sans mt-2">
|
||||||
{team.has_submission && (
|
{team.has_submission && (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 shrink-0">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 shrink-0">
|
||||||
<Icon icon="mdi:check-circle" className="text-sm" />
|
<Icon
|
||||||
|
icon="mdi:check-circle"
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
Submitted
|
Submitted
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{team.city && (
|
{team.city && (
|
||||||
<span className="flex items-center gap-1 truncate">
|
<span className="flex items-center gap-1 truncate">
|
||||||
<Icon icon="mdi:map-marker" className="shrink-0" />
|
<Icon
|
||||||
|
icon="mdi:map-marker"
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
<span className="truncate">{team.city}</span>
|
<span className="truncate">{team.city}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="flex items-center gap-1 shrink-0">
|
<span className="flex items-center gap-1 shrink-0">
|
||||||
<Icon icon="mdi:account-group" />
|
<Icon icon="mdi:account-group" />
|
||||||
{team.member_count || team.members?.length || 0} member{(team.member_count || team.members?.length || 0) !== 1 ? 's' : ''}
|
{team.member_count ||
|
||||||
|
team.members?.length ||
|
||||||
|
0}{' '}
|
||||||
|
member
|
||||||
|
{(team.member_count ||
|
||||||
|
team.members?.length ||
|
||||||
|
0) !== 1
|
||||||
|
? 's'
|
||||||
|
: ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ export default function RootLayout() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Certificate page - allow public access
|
||||||
|
if (pathname.startsWith('/certificate/')) {
|
||||||
|
setIsChecking(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Require authentication for all other routes
|
// Require authentication for all other routes
|
||||||
if (!session) {
|
if (!session) {
|
||||||
navigate('/auth/login', { replace: true });
|
navigate('/auth/login', { replace: true });
|
||||||
|
|||||||
@@ -554,7 +554,7 @@ export default function HomePage() {
|
|||||||
{/* Judge 2 */}
|
{/* Judge 2 */}
|
||||||
<div className="flex flex-col justify-between bg-white dark:bg-gray-800 rounded-xl px-4 py-8 shadow-lg text-center">
|
<div className="flex flex-col justify-between bg-white dark:bg-gray-800 rounded-xl px-4 py-8 shadow-lg text-center">
|
||||||
<h3 className="text-p3 font-bold mb-1 dark:text-white">
|
<h3 className="text-p3 font-bold mb-1 dark:text-white">
|
||||||
Anka Tama
|
Muhammad Alif Ramadhan
|
||||||
</h3>
|
</h3>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-primary-500 font-semibold mb-1">Admin</p>
|
<p className="text-primary-500 font-semibold mb-1">Admin</p>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { FC, ReactElement } from 'react';
|
import { FC, ReactElement } from 'react';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { useNavigate, useParams } from 'react-router';
|
import { useNavigate, useParams } from 'react-router';
|
||||||
import { useTeamById, useTeamSubmission } from '@imphnen-frontend-service/service';
|
import { useTeamById, useTeamSubmission, useAuthStore } from '@imphnen-frontend-service/service';
|
||||||
import { encodeCertificateId } from '../../../../utils/certificate';
|
import { encodeCertificateId } from '../../../../utils/certificate';
|
||||||
|
|
||||||
const SubmissionViewPage: FC = (): ReactElement => {
|
const SubmissionViewPage: FC = (): ReactElement => {
|
||||||
const { teamId } = useParams<{ teamId: string }>();
|
const { teamId } = useParams<{ teamId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { session } = useAuthStore();
|
||||||
|
|
||||||
const { data: teamData } = useTeamById(teamId || '');
|
const { data: teamData } = useTeamById(teamId || '');
|
||||||
const { data: submissionData, isLoading } = useTeamSubmission(teamId || '', !!teamId);
|
const { data: submissionData, isLoading } = useTeamSubmission(teamId || '', !!teamId);
|
||||||
@@ -113,13 +114,17 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
|||||||
View Your Certificate
|
View Your Certificate
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-amber-700 dark:text-amber-300 text-sm">
|
<p className="text-amber-700 dark:text-amber-300 text-sm">
|
||||||
Congratulations! Your certificate is ready to download and share.
|
Congratulations! Your personalized certificate is ready to download and share.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
const certId = await encodeCertificateId(teamId || '', submission.id);
|
const certId = await encodeCertificateId(
|
||||||
|
teamId || '',
|
||||||
|
submission.id,
|
||||||
|
session?.user?.id || ''
|
||||||
|
);
|
||||||
navigate(`/certificate/${encodeURIComponent(certId)}`);
|
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"
|
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"
|
||||||
|
|||||||
@@ -3,27 +3,69 @@ import { decryptText, encryptText } from "./aesclient";
|
|||||||
const SECRET_KEY = 'imphnen-hackathon-2025';
|
const SECRET_KEY = 'imphnen-hackathon-2025';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encode teamId and submissionId into a certificate ID
|
* Encode teamId, submissionId, and userId into a certificate ID
|
||||||
* Uses base64 encoding for simple obfuscation
|
* Uses AES encryption for secure encoding
|
||||||
* @param teamId - The team ID
|
* @param teamId - The team ID
|
||||||
* @param submissionId - The submission ID
|
* @param submissionId - The submission ID
|
||||||
|
* @param userId - The user ID (team member)
|
||||||
* @returns Encoded certificate ID
|
* @returns Encoded certificate ID
|
||||||
*/
|
*/
|
||||||
export const encodeCertificateId = async (teamId: string, submissionId: string): Promise<string> => {
|
export const encodeCertificateId = async (teamId: string, submissionId: string, userId: string): Promise<string> => {
|
||||||
const combined = `${teamId}::${submissionId}`;
|
const combined = `${teamId}::${submissionId}::${userId}`;
|
||||||
return encryptText(combined, SECRET_KEY);
|
return encryptText(combined, SECRET_KEY);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decode certificate ID back to teamId and submissionId
|
* Encode winner certificate ID (team-based)
|
||||||
* @param certId - The encoded certificate ID
|
* @param teamId - Winner team ID
|
||||||
* @returns Object containing teamId and submissionId
|
* @returns Encoded winner certificate ID
|
||||||
*/
|
*/
|
||||||
export const decodeCertificateId = async (certId: string): Promise<{ teamId: string; submissionId: string }> => {
|
export const encodeWinnerCertificateId = async (teamId: string): Promise<string> => {
|
||||||
|
const combined = `winner::${teamId}`;
|
||||||
|
return encryptText(combined, SECRET_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode certificate ID back to teamId, submissionId, and userId
|
||||||
|
* @param certId - The encoded certificate ID
|
||||||
|
* @returns Object containing teamId, submissionId, and userId
|
||||||
|
*/
|
||||||
|
export const decodeCertificateId = async (certId: string): Promise<{ teamId: string; submissionId: string; userId: string }> => {
|
||||||
try {
|
try {
|
||||||
const decoded = await decryptText(certId, SECRET_KEY);
|
const decoded = await decryptText(certId, SECRET_KEY);
|
||||||
const [teamId, submissionId] = decoded.split('::');
|
const parts = decoded.split('::');
|
||||||
return { teamId, submissionId };
|
|
||||||
|
// Handle both old format (teamId::submissionId) and new format (teamId::submissionId::userId)
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const [teamId, submissionId] = parts;
|
||||||
|
return { teamId, submissionId, userId: '' };
|
||||||
|
} else if (parts.length === 3) {
|
||||||
|
const [teamId, submissionId, userId] = parts;
|
||||||
|
return { teamId, submissionId, userId };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Invalid certificate format');
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid certificate ID');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode winner certificate ID back to teamId
|
||||||
|
* @param certId - The encoded winner certificate ID
|
||||||
|
* @returns Object containing teamId
|
||||||
|
*/
|
||||||
|
export const decodeWinnerCertificateId = async (certId: string): Promise<{ teamId: string }> => {
|
||||||
|
try {
|
||||||
|
const decoded = await decryptText(certId, SECRET_KEY);
|
||||||
|
const parts = decoded.split('::');
|
||||||
|
|
||||||
|
// winner::teamId
|
||||||
|
if (parts.length === 2 && parts[0] === 'winner') {
|
||||||
|
return { teamId: parts[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Invalid winner certificate format');
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error('Invalid certificate ID');
|
throw new Error('Invalid certificate ID');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function CommunitySection() {
|
|||||||
case 'FaDiscord':
|
case 'FaDiscord':
|
||||||
return FaDiscord;
|
return FaDiscord;
|
||||||
case 'FaGithub':
|
case 'FaGithub':
|
||||||
return FaDiscord;
|
return FaGithub;
|
||||||
case 'FaInstagram':
|
case 'FaInstagram':
|
||||||
return FaInstagram;
|
return FaInstagram;
|
||||||
case 'FaTiktok':
|
case 'FaTiktok':
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
{
|
{
|
||||||
"name": "Github",
|
"name": "Github",
|
||||||
"icon": "FaGithub",
|
"icon": "FaGithub",
|
||||||
"color": "#5865F2",
|
"color": "#000000",
|
||||||
"description": "Platforms to collaborate on your code",
|
"description": "Platforms to collaborate on your code",
|
||||||
"link": "https://github.com/IMPHNEN/"
|
"link": "https://github.com/IMPHNEN/"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,12 +31,13 @@ hackathonApi.interceptors.response.use(
|
|||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
(error) => {
|
||||||
// Handle 401 - clear session and redirect to login
|
// Handle 401 - clear session and redirect to login
|
||||||
// But skip redirect if already on auth pages (to avoid reload on login failure)
|
// But skip redirect if already on auth pages or certificate pages (to avoid reload on login failure)
|
||||||
if (error.response?.status === 401) {
|
if (error.response?.status === 401) {
|
||||||
const isAuthPage = globalThis.window !== undefined && globalThis.location.pathname.startsWith('/auth');
|
const isAuthPage = globalThis.window !== undefined && globalThis.location.pathname.startsWith('/auth');
|
||||||
|
const isCertificatePage = globalThis.window !== undefined && globalThis.location.pathname.startsWith('/certificate/');
|
||||||
|
|
||||||
// Only clear session and redirect if not on auth page
|
// Only clear session and redirect if not on auth page or certificate page
|
||||||
if (!isAuthPage) {
|
if (!isAuthPage && !isCertificatePage) {
|
||||||
useAuthStore.getState().clearSession();
|
useAuthStore.getState().clearSession();
|
||||||
if (globalThis.window !== undefined) {
|
if (globalThis.window !== undefined) {
|
||||||
globalThis.location.href = '/auth/login';
|
globalThis.location.href = '/auth/login';
|
||||||
|
|||||||
@@ -15,6 +15,41 @@ interface User {
|
|||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Certificate public data types
|
||||||
|
interface CertificateUserData {
|
||||||
|
id: string;
|
||||||
|
fullname: string;
|
||||||
|
email: string;
|
||||||
|
avatar?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CertificateTeamData {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
logo?: string;
|
||||||
|
is_leader: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CertificateSubmissionData {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
repository_url?: string;
|
||||||
|
demo_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CertificateWinnerData {
|
||||||
|
rank: number;
|
||||||
|
prize?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CertificatePublicData {
|
||||||
|
user: CertificateUserData;
|
||||||
|
team?: CertificateTeamData;
|
||||||
|
submission?: CertificateSubmissionData;
|
||||||
|
winner?: CertificateWinnerData;
|
||||||
|
}
|
||||||
|
|
||||||
// Update user request type
|
// Update user request type
|
||||||
interface UpdateUserRequest {
|
interface UpdateUserRequest {
|
||||||
fullname?: string;
|
fullname?: string;
|
||||||
@@ -118,3 +153,17 @@ export const useUserDetailsById = (userId: string) => {
|
|||||||
enabled: !!userId,
|
enabled: !!userId,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Public certificate data hook (no authentication required)
|
||||||
|
export const useCertificatePublicData = (userId: string, enabled = true) => {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['certificate-public-data', userId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await hackathonApi.get<HackathonApiResponse<CertificatePublicData>>(
|
||||||
|
`/certificates/${userId}`
|
||||||
|
);
|
||||||
|
return { data: response.data.data };
|
||||||
|
},
|
||||||
|
enabled: enabled && !!userId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
Generated
+1045
-59
File diff suppressed because it is too large
Load Diff
+6
-5
@@ -51,13 +51,13 @@
|
|||||||
"graphql": "^16.11.0",
|
"graphql": "^16.11.0",
|
||||||
"html2canvas": "^1.4.1",
|
"html2canvas": "^1.4.1",
|
||||||
"js-cookie": "^3.0.5",
|
"js-cookie": "^3.0.5",
|
||||||
"next": "~16.0.3",
|
"next": "^16.0.8",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"openapi-fetch": "^0.15.0",
|
"openapi-fetch": "^0.15.0",
|
||||||
"openapi-react-query": "^0.5.0",
|
"openapi-react-query": "^0.5.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
"react": "^19.1.0",
|
"react": "^19.2.1",
|
||||||
"react-dom": "^19.1.0",
|
"react-dom": "^19.2.1",
|
||||||
"react-hook-form": "^7.56.4",
|
"react-hook-form": "^7.56.4",
|
||||||
"react-icons": "^5.5.0",
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^7.3.0",
|
"react-router-dom": "^7.3.0",
|
||||||
@@ -99,8 +99,8 @@
|
|||||||
"@types/js-cookie": "^3.0.6",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"@types/node": "^22.12.0",
|
"@types/node": "^22.12.0",
|
||||||
"@types/qrcode": "^1.5.6",
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/react": "^19.1.2",
|
"@types/react": "^19.2.7",
|
||||||
"@types/react-dom": "^19.1.2",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^4.2.0",
|
"@vitejs/plugin-react": "^4.2.0",
|
||||||
"@vitest/coverage-v8": "^3.0.5",
|
"@vitest/coverage-v8": "^3.0.5",
|
||||||
"@vitest/ui": "^3.0.0",
|
"@vitest/ui": "^3.0.0",
|
||||||
@@ -108,6 +108,7 @@
|
|||||||
"eslint": "^9.8.0",
|
"eslint": "^9.8.0",
|
||||||
"eslint-config-next": "^15.2.4",
|
"eslint-config-next": "^15.2.4",
|
||||||
"eslint-config-prettier": "10.0.0",
|
"eslint-config-prettier": "10.0.0",
|
||||||
|
"eslint-next-config": "^1.0.3",
|
||||||
"eslint-plugin-import": "2.31.0",
|
"eslint-plugin-import": "2.31.0",
|
||||||
"eslint-plugin-jsx-a11y": "6.10.1",
|
"eslint-plugin-jsx-a11y": "6.10.1",
|
||||||
"eslint-plugin-playwright": "^1.6.2",
|
"eslint-plugin-playwright": "^1.6.2",
|
||||||
|
|||||||
Reference in New Issue
Block a user