Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5cfed24ca | ||
|
|
f018da21c1 | ||
|
|
a610e180ba | ||
|
|
ce8cb8188c | ||
|
|
3aaf022393 | ||
|
|
ebe27a3f05 | ||
|
|
f846a5a785 | ||
|
|
708cbde596 | ||
|
|
9d37cba65f | ||
|
|
8728d0abd7 | ||
|
|
1f4f4ddaa1 | ||
|
|
4fcc249ff4 | ||
|
|
c2bed98b06 | ||
|
|
ae1864c134 | ||
|
|
3f30fbe89c | ||
|
|
951db0f28a | ||
|
|
ad59d39f55 | ||
|
|
1283689d84 | ||
|
|
7d1370d7f2 | ||
|
|
ab0b56780f | ||
|
|
1a39ac37db | ||
|
|
e5dc784301 | ||
|
|
b2de22b0ef | ||
|
|
ad20eda182 | ||
|
|
da65aa4109 | ||
|
|
1f0798752c | ||
|
|
5f28b9b406 | ||
|
|
9e8fc7c07f | ||
|
|
ca51f8aca2 | ||
|
|
c086b1371c | ||
|
|
398350795d | ||
|
|
5be9ec1edf | ||
|
|
f77967a092 | ||
|
|
fe172cc128 | ||
|
|
38ee9a9549 |
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
if [[ ! -d "/Users/ms/Development/personal/imphnen-frontend-service" ]]; then
|
||||||
|
echo "Cannot find source directory; Did you move it?"
|
||||||
|
echo "(Looking for "/Users/ms/Development/personal/imphnen-frontend-service")"
|
||||||
|
echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# rebuild the cache forcefully
|
||||||
|
_nix_direnv_force_reload=1 direnv exec "/Users/ms/Development/personal/imphnen-frontend-service" true
|
||||||
|
|
||||||
|
# Update the mtime for .envrc.
|
||||||
|
# This will cause direnv to reload again - but without re-building.
|
||||||
|
touch "/Users/ms/Development/personal/imphnen-frontend-service/.envrc"
|
||||||
|
|
||||||
|
# Also update the timestamp of whatever profile_rc we have.
|
||||||
|
# This makes sure that we know we are up to date.
|
||||||
|
touch -r "/Users/ms/Development/personal/imphnen-frontend-service/.envrc" "/Users/ms/Development/personal/imphnen-frontend-service/.direnv"/*.rc
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/8hx8jwa24q6rkzhca9iqy5ckk9rbphi1-source
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/jizz7ld5r2w11y03wrmnvwsskc45krj9-source
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/y4p9szcab9xd7v1c7k9ghr86hb6myyg4-nix-shell-env
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,3 +0,0 @@
|
|||||||
|
|
||||||
VITE_SUPABASE_URL=https://your-project-id.supabase.co
|
|
||||||
VITE_SUPABASE_ANON_KEY=your-anon-key-here
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
name: Nix Build & Cache
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['develop']
|
||||||
|
pull_request:
|
||||||
|
branches: ['develop']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Nix
|
||||||
|
uses: DeterminateSystems/nix-installer-action@main
|
||||||
|
|
||||||
|
- name: Setup Cachix
|
||||||
|
uses: cachix/cachix-action@v15
|
||||||
|
with:
|
||||||
|
name: msdqn
|
||||||
|
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
|
||||||
|
|
||||||
|
- name: Build all packages
|
||||||
|
run: |
|
||||||
|
nix build .#landing -o result-landing
|
||||||
|
nix build .#backoffice -o result-backoffice
|
||||||
|
nix build .#gacha -o result-gacha
|
||||||
|
nix build .#dimentorin -o result-dimentorin
|
||||||
|
nix build .#hackathon -o result-hackathon
|
||||||
|
nix build .#infra -o result-infra
|
||||||
|
|
||||||
|
- name: Show build outputs
|
||||||
|
run: |
|
||||||
|
echo "Landing: $(readlink result-landing)"
|
||||||
|
echo "Backoffice: $(readlink result-backoffice)"
|
||||||
|
echo "Gacha: $(readlink result-gacha)"
|
||||||
|
echo "Dimentorin: $(readlink result-dimentorin)"
|
||||||
|
echo "Hackathon: $(readlink result-hackathon)"
|
||||||
|
echo "Infra: $(readlink result-infra)"
|
||||||
@@ -10,6 +10,7 @@ This repository is a **monorepo** for all frontend services of IMPHNEN. The mono
|
|||||||
2. **Backoffice** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Internal Management Website</a>.
|
2. **Backoffice** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Internal Management Website</a>.
|
||||||
3. **Dimentorin** - Application for <a href="https://dimentorin.imphnen.dev/" target="_blank">Mentoring Service</a>.
|
3. **Dimentorin** - Application for <a href="https://dimentorin.imphnen.dev/" target="_blank">Mentoring Service</a>.
|
||||||
4. **Landing Page** - Application for <a href="https://imphnen.dev/" target="_blank">Landing Page</a>.
|
4. **Landing Page** - Application for <a href="https://imphnen.dev/" target="_blank">Landing Page</a>.
|
||||||
|
5. **QR Campaign** - Application for QR Campaign Management.
|
||||||
|
|
||||||
## How to install
|
## How to install
|
||||||
|
|
||||||
@@ -54,6 +55,10 @@ Use the following commands to run in development mode:
|
|||||||
```sh
|
```sh
|
||||||
npm run landing:dev
|
npm run landing:dev
|
||||||
```
|
```
|
||||||
|
- **QR Campaign**:
|
||||||
|
```sh
|
||||||
|
npm run qrcampaign:dev
|
||||||
|
```
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
||||||
@@ -75,6 +80,10 @@ Use the following commands to build the applications:
|
|||||||
```sh
|
```sh
|
||||||
npm run landing:build
|
npm run landing:build
|
||||||
```
|
```
|
||||||
|
- **QR Campaign**:
|
||||||
|
```sh
|
||||||
|
npm run qrcampaign:build
|
||||||
|
```
|
||||||
|
|
||||||
### Production
|
### Production
|
||||||
|
|
||||||
@@ -96,6 +105,10 @@ Use the following commands to run the applications in production mode:
|
|||||||
```sh
|
```sh
|
||||||
npm run landing:prod
|
npm run landing:prod
|
||||||
```
|
```
|
||||||
|
- **QR Campaign**:
|
||||||
|
```sh
|
||||||
|
npm run qrcampaign:prod
|
||||||
|
```
|
||||||
|
|
||||||
### Storybook
|
### Storybook
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 818 KiB |
@@ -42,10 +42,8 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
}, [certId]);
|
}, [certId]);
|
||||||
|
|
||||||
// Fetch certificate data using the new endpoint
|
// Fetch certificate data using the new endpoint
|
||||||
const { data: certificateData, isLoading: isLoadingCertificate } = useCertificatePublicData(
|
const { data: certificateData, isLoading: isLoadingCertificate } =
|
||||||
decodedInfo?.userId || '',
|
useCertificatePublicData(decodedInfo?.userId || '', !!decodedInfo?.userId);
|
||||||
!!decodedInfo?.userId
|
|
||||||
);
|
|
||||||
|
|
||||||
// Generate QR Code
|
// Generate QR Code
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -292,9 +290,7 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
{team && isTeamMember && (
|
{team && isTeamMember && (
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() =>
|
onClick={() => navigate(`/teams/${team.id}/submission`)}
|
||||||
navigate(`/teams/${team.id}/submission`)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Back to Submission
|
Back to Submission
|
||||||
</Button>
|
</Button>
|
||||||
@@ -425,7 +421,7 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
{isTeamMember && (
|
{isTeamMember && (
|
||||||
<div className="bg-gray-50 dark:bg-gray-800 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
|
<div className="bg-gray-50 dark:bg-gray-900 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={handleDownloadCertificate}
|
onClick={handleDownloadCertificate}
|
||||||
@@ -444,9 +440,7 @@ const CertificatePage: FC = (): ReactElement => {
|
|||||||
</Button>
|
</Button>
|
||||||
{team && (
|
{team && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() =>
|
onClick={() => navigate(`/teams/${team.id}/submission`)}
|
||||||
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"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ export const encodeCertificateId = async (teamId: string, submissionId: string,
|
|||||||
return encryptText(combined, SECRET_KEY);
|
return encryptText(combined, SECRET_KEY);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode winner certificate ID (team-based)
|
||||||
|
* @param teamId - Winner team ID
|
||||||
|
* @returns Encoded winner certificate ID
|
||||||
|
*/
|
||||||
|
export const encodeWinnerCertificateId = async (teamId: string): Promise<string> => {
|
||||||
|
const combined = `winner::${teamId}`;
|
||||||
|
return encryptText(combined, SECRET_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decode certificate ID back to teamId, submissionId, and userId
|
* Decode certificate ID back to teamId, submissionId, and userId
|
||||||
* @param certId - The encoded certificate ID
|
* @param certId - The encoded certificate ID
|
||||||
@@ -40,6 +50,27 @@ export const decodeCertificateId = async (certId: string): Promise<{ teamId: str
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode winner certificate ID back to teamId
|
||||||
|
* @param certId - The encoded winner certificate ID
|
||||||
|
* @returns Object containing teamId
|
||||||
|
*/
|
||||||
|
export const decodeWinnerCertificateId = async (certId: string): Promise<{ teamId: string }> => {
|
||||||
|
try {
|
||||||
|
const decoded = await decryptText(certId, SECRET_KEY);
|
||||||
|
const parts = decoded.split('::');
|
||||||
|
|
||||||
|
// winner::teamId
|
||||||
|
if (parts.length === 2 && parts[0] === 'winner') {
|
||||||
|
return { teamId: parts[1] };
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Invalid winner certificate format');
|
||||||
|
} catch {
|
||||||
|
throw new Error('Invalid certificate ID');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* For development: Create a certId using created_at timestamp
|
* For development: Create a certId using created_at timestamp
|
||||||
* @param teamId - The team ID
|
* @param teamId - The team ID
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import baseConfig from '../../eslint.config.mjs';
|
||||||
|
|
||||||
|
export default [...baseConfig];
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>IMPHNEN Infrastructure</title>
|
||||||
|
<base href="/" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/logos/simple.svg" />
|
||||||
|
<link rel="stylesheet" href="/src/index.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {
|
||||||
|
base: join(import.meta.dirname, '../../'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "infra",
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"sourceRoot": "apps/infra/src",
|
||||||
|
"projectType": "application",
|
||||||
|
"tags": [],
|
||||||
|
"targets": {}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 351 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 511 KiB |
@@ -0,0 +1,248 @@
|
|||||||
|
const apps = [
|
||||||
|
{ name: 'Landing', domain: 'imphnen.dev', type: 'Next.js', color: 'blue' },
|
||||||
|
{ name: 'WWW', domain: 'www.imphnen.dev', type: 'Redirect', color: 'purple' },
|
||||||
|
{ name: 'Gacha', domain: 'gacha.imphnen.dev', type: 'Vite', color: 'green' },
|
||||||
|
{ name: 'Backoffice', domain: 'backoffice.imphnen.dev', type: 'Vite', color: 'green' },
|
||||||
|
{ name: 'Dimentorin', domain: 'dimentorin.imphnen.dev', type: 'Vite', color: 'green' },
|
||||||
|
{ name: 'Hackathon', domain: 'hackathon.imphnen.dev', type: 'Vite', color: 'green' },
|
||||||
|
{ name: 'QR Campaign', domain: 'qr.imphnen.dev', type: 'Vite', color: 'green' },
|
||||||
|
{ name: 'Infra', domain: 'infra.imphnen.dev', type: 'Vite', color: 'green' },
|
||||||
|
{ name: 'API QR', domain: 'api-qr.imphnen.dev', type: 'Backend', color: 'orange' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const techStack = [
|
||||||
|
{ category: 'OS', items: ['NixOS 25.05'] },
|
||||||
|
{ category: 'Web Server', items: ['Nginx'] },
|
||||||
|
{ category: 'SSL', items: ['Let\'s Encrypt (ACME)'] },
|
||||||
|
{ category: 'Build', items: ['Nix Flakes', 'Nx Monorepo'] },
|
||||||
|
{ category: 'CI/CD', items: ['GitHub Actions', 'Cachix'] },
|
||||||
|
{ category: 'Frontend', items: ['React', 'Next.js', 'Vite', 'TailwindCSS'] },
|
||||||
|
{ category: 'Backend', items: ['Go', 'Supabase'] },
|
||||||
|
{ category: 'Secrets', items: ['sops-nix'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#0a0a0a]">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="border-b border-zinc-800 bg-[#0a0a0a]/80 backdrop-blur-sm sticky top-0 z-50">
|
||||||
|
<div className="container mx-auto px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center font-bold">
|
||||||
|
I
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-semibold">IMPHNEN Infrastructure</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="https://github.com/IMPHNEN"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="container mx-auto px-6 py-12">
|
||||||
|
{/* Hero */}
|
||||||
|
<section className="text-center mb-16">
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
||||||
|
Infrastructure Overview
|
||||||
|
</h1>
|
||||||
|
<p className="text-zinc-400 text-lg max-w-2xl mx-auto">
|
||||||
|
Declarative NixOS infrastructure powering IMPHNEN's frontend applications
|
||||||
|
on Hetzner Cloud.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Architecture Diagram */}
|
||||||
|
<section className="mb-16">
|
||||||
|
<h2 className="text-2xl font-semibold mb-6">Architecture</h2>
|
||||||
|
<div className="card bg-[#141414] border-zinc-800 p-8">
|
||||||
|
<div className="flex flex-col items-center gap-6">
|
||||||
|
{/* Internet */}
|
||||||
|
<div className="flex items-center gap-2 text-zinc-400">
|
||||||
|
<span className="text-2xl">🌐</span>
|
||||||
|
<span>Internet</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-8 bg-zinc-700" />
|
||||||
|
|
||||||
|
{/* Cloudflare */}
|
||||||
|
<div className="card bg-orange-500/10 border-orange-500/30 px-6 py-3">
|
||||||
|
<span className="badge badge-orange">Cloudflare</span>
|
||||||
|
<p className="text-sm text-zinc-400 mt-1">DNS + CDN + DDoS Protection</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-8 bg-zinc-700" />
|
||||||
|
|
||||||
|
{/* Hetzner VPS */}
|
||||||
|
<div className="card bg-[#1a1a1a] border-zinc-700 w-full max-w-3xl">
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<span className="badge badge-purple">Hetzner Cloud VPS</span>
|
||||||
|
<p className="text-sm text-zinc-400 mt-1">NixOS | 167.235.70.37</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nginx */}
|
||||||
|
<div className="card bg-green-500/10 border-green-500/30 mb-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="badge badge-green">Nginx</span>
|
||||||
|
<p className="text-sm text-zinc-400 mt-1">Reverse Proxy + Static Files + SSL Termination</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Apps Grid */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
{apps.map((app) => (
|
||||||
|
<div
|
||||||
|
key={app.name}
|
||||||
|
className="card bg-[#0a0a0a] border-zinc-800 text-center p-3"
|
||||||
|
>
|
||||||
|
<p className="font-medium text-sm">{app.name}</p>
|
||||||
|
<p className="text-xs text-zinc-500">{app.domain}</p>
|
||||||
|
<span className={`badge badge-${app.color} mt-2 text-xs`}>
|
||||||
|
{app.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Deployed Apps */}
|
||||||
|
<section className="mb-16">
|
||||||
|
<h2 className="text-2xl font-semibold mb-6">Deployed Applications</h2>
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{apps.map((app) => (
|
||||||
|
<a
|
||||||
|
key={app.name}
|
||||||
|
href={`https://${app.domain}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="card bg-[#141414] border-zinc-800 hover:border-zinc-600 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold group-hover:text-blue-400 transition-colors">
|
||||||
|
{app.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-zinc-500">{app.domain}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`badge badge-${app.color}`}>{app.type}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Tech Stack */}
|
||||||
|
<section className="mb-16">
|
||||||
|
<h2 className="text-2xl font-semibold mb-6">Technology Stack</h2>
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{techStack.map((tech) => (
|
||||||
|
<div key={tech.category} className="card bg-[#141414] border-zinc-800">
|
||||||
|
<h3 className="text-sm text-zinc-500 uppercase tracking-wider mb-2">
|
||||||
|
{tech.category}
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tech.items.map((item) => (
|
||||||
|
<span key={item} className="badge badge-blue">
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Deployment Flow */}
|
||||||
|
<section className="mb-16">
|
||||||
|
<h2 className="text-2xl font-semibold mb-6">Deployment Flow</h2>
|
||||||
|
<div className="card bg-[#141414] border-zinc-800">
|
||||||
|
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-12 h-12 bg-zinc-800 rounded-full flex items-center justify-center mx-auto mb-2">
|
||||||
|
<span>1</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">Push to GitHub</p>
|
||||||
|
<p className="text-xs text-zinc-500">develop branch</p>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block text-zinc-600">→</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-12 h-12 bg-zinc-800 rounded-full flex items-center justify-center mx-auto mb-2">
|
||||||
|
<span>2</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">GitHub Actions</p>
|
||||||
|
<p className="text-xs text-zinc-500">nix build</p>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block text-zinc-600">→</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-12 h-12 bg-orange-500 rounded-full flex items-center justify-center mx-auto mb-2">
|
||||||
|
<span>3</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">Push to Cachix</p>
|
||||||
|
<p className="text-xs text-zinc-500">binary cache</p>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block text-zinc-600">→</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-12 h-12 bg-zinc-800 rounded-full flex items-center justify-center mx-auto mb-2">
|
||||||
|
<span>4</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">Server Pull</p>
|
||||||
|
<p className="text-xs text-zinc-500">nixos-rebuild</p>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block text-zinc-600">→</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-12 h-12 bg-blue-500 rounded-full flex items-center justify-center mx-auto mb-2">
|
||||||
|
<span>✓</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">Live</p>
|
||||||
|
<p className="text-xs text-zinc-500">Zero downtime</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Repositories */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-2xl font-semibold mb-6">Repositories</h2>
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<a
|
||||||
|
href="https://github.com/IMPHNEN/imphnen-frontend-service"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="card bg-[#141414] border-zinc-800 hover:border-zinc-600 transition-colors"
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold mb-1">imphnen-frontend-service</h3>
|
||||||
|
<p className="text-sm text-zinc-400">
|
||||||
|
Nx monorepo containing all frontend applications
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://github.com/IMPHNEN/imphnen-infrastructure"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="card bg-[#141414] border-zinc-800 hover:border-zinc-600 transition-colors"
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold mb-1">imphnen-infrastructure</h3>
|
||||||
|
<p className="text-sm text-zinc-400">
|
||||||
|
NixOS flake configuration for server deployment
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="border-t border-zinc-800 mt-16">
|
||||||
|
<div className="container mx-auto px-6 py-8 text-center text-zinc-500 text-sm">
|
||||||
|
<p>IMPHNEN Infrastructure © {new Date().getFullYear()}</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-primary: #3b82f6;
|
||||||
|
--color-primary-hover: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background-color: #0a0a0a;
|
||||||
|
color: #ffffff;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.card {
|
||||||
|
background: #141414;
|
||||||
|
border: 1px solid #27272a;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-blue {
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-green {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-purple {
|
||||||
|
background: rgba(168, 85, 247, 0.2);
|
||||||
|
color: #c084fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-orange {
|
||||||
|
background: rgba(249, 115, 22, 0.2);
|
||||||
|
color: #fb923c;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { StrictMode } from 'react';
|
||||||
|
import App from './app/App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('root');
|
||||||
|
|
||||||
|
if (!rootElement) throw new Error('Failed to find the root element');
|
||||||
|
|
||||||
|
createRoot(rootElement).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"types": ["node", "vite/client"]
|
||||||
|
},
|
||||||
|
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts", "src/**/*.spec.tsx", "src/**/*.test.tsx"],
|
||||||
|
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowJs": false,
|
||||||
|
"esModuleInterop": false,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"extends": "../../tsconfig.base.json"
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/// <reference types='vitest' />
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||||
|
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
|
||||||
|
|
||||||
|
export default defineConfig(() => ({
|
||||||
|
root: __dirname,
|
||||||
|
cacheDir: '../../node_modules/.vite/apps/infra',
|
||||||
|
server: {
|
||||||
|
port: 3006,
|
||||||
|
host: 'localhost',
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
port: 3007,
|
||||||
|
host: 'localhost',
|
||||||
|
},
|
||||||
|
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||||
|
build: {
|
||||||
|
outDir: '../../dist/apps/infra',
|
||||||
|
emptyOutDir: true,
|
||||||
|
reportCompressedSize: true,
|
||||||
|
commonjsOptions: {
|
||||||
|
transformMixedEsModules: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -54,9 +54,19 @@ export function HeroSection() {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center rounded-full border px-3 py-1 text-sm w-fit">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{communityLabel}
|
<span className="inline-flex items-center rounded-full border px-3 py-1 text-sm">
|
||||||
</span>
|
{communityLabel}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href="https://ancikri.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center rounded-full border px-3 py-1 text-sm hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
Powered By Ancikri
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-foreground via-foreground to-foreground/70">
|
<h1 className="text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-foreground via-foreground to-foreground/70">
|
||||||
|
|||||||
@@ -104,11 +104,21 @@ export default function Footer() {
|
|||||||
<ul className="space-y-2"></ul>
|
<ul className="space-y-2"></ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8 border-t pt-8 text-center">
|
<div className="mt-8 border-t pt-8 text-center space-y-2">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
© {new Date().getFullYear()} IMPHNEN - Ingin Menjadi Programmer
|
© {new Date().getFullYear()} IMPHNEN - Ingin Menjadi Programmer
|
||||||
Handal, Namun Enggan Ngoding. All rights reserved.
|
Handal, Namun Enggan Ngoding. All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Powered by{' '}
|
||||||
|
<Link
|
||||||
|
href="https://ancikri.com"
|
||||||
|
target="_blank"
|
||||||
|
className="font-medium text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
Ancikri
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import { Bai_Jamjuree, Poppins } from 'next/font/google';
|
import localFont from 'next/font/local';
|
||||||
|
|
||||||
export const baiJamjureeFont = Bai_Jamjuree({
|
export const baiJamjureeFont = localFont({
|
||||||
subsets: ['latin'],
|
src: [
|
||||||
weight: ['300', '400', '500', '600', '700'],
|
{ path: '../../public/fonts/BaiJamjuree-Light.ttf', weight: '300', style: 'normal' },
|
||||||
|
{ path: '../../public/fonts/BaiJamjuree-Regular.ttf', weight: '400', style: 'normal' },
|
||||||
|
{ path: '../../public/fonts/BaiJamjuree-Medium.ttf', weight: '500', style: 'normal' },
|
||||||
|
{ path: '../../public/fonts/BaiJamjuree-SemiBold.ttf', weight: '600', style: 'normal' },
|
||||||
|
{ path: '../../public/fonts/BaiJamjuree-Bold.ttf', weight: '700', style: 'normal' },
|
||||||
|
],
|
||||||
display: 'swap',
|
display: 'swap',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const poppinsFont = Poppins({
|
export const poppinsFont = localFont({
|
||||||
subsets: ['latin'],
|
src: [
|
||||||
weight: ['300', '400', '500', '600', '700'],
|
{ path: '../../public/fonts/Poppins-Light.ttf', weight: '300', style: 'normal' },
|
||||||
|
{ path: '../../public/fonts/Poppins-Regular.ttf', weight: '400', style: 'normal' },
|
||||||
|
{ path: '../../public/fonts/Poppins-Medium.ttf', weight: '500', style: 'normal' },
|
||||||
|
{ path: '../../public/fonts/Poppins-SemiBold.ttf', weight: '600', style: 'normal' },
|
||||||
|
{ path: '../../public/fonts/Poppins-Bold.ttf', weight: '700', style: 'normal' },
|
||||||
|
],
|
||||||
display: 'swap',
|
display: 'swap',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --prefer-offline --no-audit --no-fund
|
||||||
|
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ENV NX_DAEMON=false
|
||||||
|
RUN npm run qrcampaign:build
|
||||||
|
|
||||||
|
FROM nginx:alpine AS runner
|
||||||
|
RUN rm -rf /usr/share/nginx/html/*
|
||||||
|
COPY --from=builder /app/dist/apps/qrcampaign /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import nx from '@nx/eslint-plugin';
|
||||||
|
import baseConfig from '../../eslint.config.mjs';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...baseConfig,
|
||||||
|
...nx.configs['flat/react'],
|
||||||
|
{
|
||||||
|
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
|
||||||
|
// Override or add rules here
|
||||||
|
rules: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>QR Code Generator - IMPHNEN</title>
|
||||||
|
<base href="/" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="description" content="QR Code Generator - IMPHNEN" />
|
||||||
|
<meta name="keywords" content="qr, campaign, imphnen" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/images/imphnen-logo.svg" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {
|
||||||
|
base: join(import.meta.dirname, '../../'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "qrcampaign",
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"sourceRoot": "apps/qrcampaign/src",
|
||||||
|
"projectType": "application",
|
||||||
|
"tags": [],
|
||||||
|
"// targets": "to see all targets run: nx show project qrcampaign --web",
|
||||||
|
"targets": {}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 351 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
export default function NotFoundPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-9xl font-bold text-gray-200 mb-4">404</h1>
|
||||||
|
<h2 className="text-3xl font-semibold text-gray-900 mb-4">
|
||||||
|
Page Not Found
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mb-8">
|
||||||
|
The page you are looking for doesn't exist or has been moved.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
||||||
|
>
|
||||||
|
Go Back Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { DataTable } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import {
|
||||||
|
campaignService,
|
||||||
|
Campaign,
|
||||||
|
CreateCampaignRequest,
|
||||||
|
} from '../../features/admin/api/campaign.service';
|
||||||
|
import {
|
||||||
|
DeleteOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
FileOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
export default function CampaignsPage() {
|
||||||
|
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<CreateCampaignRequest>({
|
||||||
|
name: '',
|
||||||
|
url: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchCampaigns = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await campaignService.getCampaigns();
|
||||||
|
setCampaigns(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to load campaigns');
|
||||||
|
console.error('Error fetching campaigns:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCampaigns();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateCampaign = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
setCreateLoading(true);
|
||||||
|
await campaignService.createCampaign(formData);
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setFormData({ name: '', url: '' });
|
||||||
|
await fetchCampaigns();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating campaign:', err);
|
||||||
|
alert('Failed to create campaign');
|
||||||
|
} finally {
|
||||||
|
setCreateLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActivateCampaign = async (campaignId: string) => {
|
||||||
|
try {
|
||||||
|
await campaignService.activateCampaign(campaignId);
|
||||||
|
await fetchCampaigns();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error activating campaign:', err);
|
||||||
|
alert('Failed to activate campaign');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCampaign = async (
|
||||||
|
campaignId: string,
|
||||||
|
campaignName: string
|
||||||
|
) => {
|
||||||
|
if (!confirm(`Are you sure you want to delete "${campaignName}"?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await campaignService.deleteCampaign(campaignId);
|
||||||
|
await fetchCampaigns();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting campaign:', err);
|
||||||
|
alert('Failed to delete campaign');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<Campaign>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="font-medium text-gray-900">{row.original.name}</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'url',
|
||||||
|
header: 'URL',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<a
|
||||||
|
href={row.original.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary-600 hover:underline"
|
||||||
|
>
|
||||||
|
{row.original.url}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'is_active',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded-2xl text-xs font-medium ${
|
||||||
|
row.original.is_active
|
||||||
|
? 'bg-success-100 text-success-800'
|
||||||
|
: 'bg-gray-100 text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{row.original.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'created_at',
|
||||||
|
header: 'Created At',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{new Date(row.original.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!row.original.is_active && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleActivateCampaign(row.original.id)}
|
||||||
|
className="p-2 text-success-600 hover:bg-success-50 rounded-md transition-colors cursor-pointer"
|
||||||
|
title="Activate Campaign"
|
||||||
|
>
|
||||||
|
<CheckCircleOutlined />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
handleDeleteCampaign(row.original.id, row.original.name)
|
||||||
|
}
|
||||||
|
className="p-2 text-danger-600 hover:bg-danger-50 rounded-md transition-colors cursor-pointer"
|
||||||
|
title="Delete Campaign"
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-gray-600">Loading campaigns...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
Campaign Management
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 mt-1">Manage your QR campaigns</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="flex items-center gap-2 bg-primary-500 text-white px-4 py-2 rounded-lg hover:bg-primary-600 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
Create Campaign
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!campaigns || campaigns.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 p-12 text-center shadow-sm">
|
||||||
|
<div className="max-w-sm mx-auto">
|
||||||
|
<div className="text-gray-400 mb-4">
|
||||||
|
<FileOutlined className="text-[48px] mx-auto" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
No campaigns yet
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 mb-6">
|
||||||
|
Get started by creating your first QR campaign.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="inline-flex items-center gap-2 bg-primary-500 text-white px-4 py-2 rounded-lg hover:bg-primary-600 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
Create Your First Campaign
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable data={campaigns} columns={columns} pageSize={10} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Campaign Modal */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||||
|
<h2 className="text-xl font-bold mb-4">Create New Campaign</h2>
|
||||||
|
<form onSubmit={handleCreateCampaign}>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Campaign Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, name: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="IMPHNEN Promo Campaign"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Campaign URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={formData.url}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, url: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="https://imphnen.dev/promo"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setFormData({ name: '', url: '' });
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors cursor-pointer"
|
||||||
|
disabled={createLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 bg-primary-500 text-white rounded-md hover:bg-primary-600 transition-colors disabled:opacity-50 cursor-pointer"
|
||||||
|
disabled={createLoading}
|
||||||
|
>
|
||||||
|
{createLoading ? 'Creating...' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user