Compare commits

..
Author SHA1 Message Date
Hafid Nur a5c1655256 Merge branch 'develop' into backoffice/hackathon-teams-submission 2025-12-09 23:05:28 +07:00
Hafid Nur 3d970aab45 feat(backoffice): submission page integration
- Fetch submissions data from API
- Move submission modal to hackathon-submissions page
2025-12-09 22:58:31 +07:00
Hafid Nur 93df14169c feat(backoffice): teams page integration
- Get teams data from API
- Hide filter that doesn't exists in back-end
- Simplify modal according to the back-end
2025-12-09 22:20:41 +07:00
Hafid Nur 7fc43d0f09 feat(backoffice): users page integration
- Get users data from API
- Set up server-side pagination and match URL params
- Hide filter that doesn't exist in back-end
2025-12-09 21:29:53 +07:00
Hafid Nur 254a2f154d feat(backoffice): hackathon dashboard integration 2025-12-09 19:51:43 +07:00
Hafid Nur b74ea9307e feat(backoffice): authentication middleware, error pages, and 404 page 2025-12-09 18:35:25 +07:00
Hafid Nur 53c45da00a feat(backoffice): improve hackathon team modal UI and add API contract
- Add feature to select city in team detail modal using CityFilterSelect component
- Add feature to change team logo and banner
- Add API contract documentation for hackathon teams in backoffice
2025-12-02 23:14:40 +07:00
Hafid Nur 136af9caf2 feat(backoffice): add searchable city filter
- add component for city filter
- apply to user management and team management pages
2025-12-02 22:49:08 +07:00
Hafid Nur fbc8e0b16a feat(backoffice): update hackathon team management page
- add modal for manage team, add new team, and view project submission
- reorganize the table column and data table
2025-12-02 22:45:35 +07:00
Hafid Nur aa4686254b Merge branch 'develop' of github.com:IMPHNEN/imphnen-frontend-service into backoffice/for-hackathon 2025-12-02 18:13:03 +07:00
Hafid Nur 2d80ed05db feat(backoffice): little adjustment in hackathon users management UI and API contract 2025-12-02 18:11:34 +07:00
Hafid Nur d927b95457 feat(backoffice): add API contract for hackathon users 2025-12-01 11:41:56 +07:00
Hafid Nur 2dc9dd985a feat(backoffice): update page hackathon user management
- update data table component
- update filtering & pagination
- add modal display to edit and add user
- hide notification icon in backoffice wrapper
2025-12-01 11:41:18 +07:00
Hafid Nur 80a6aa9fb5 update endpoint 2025-11-30 18:57:04 +07:00
Hafid Nur 1343404e01 feat(hackathon): draft data table column & API Contract 2025-11-30 18:54:34 +07:00
Hafid Nur 5faba43728 base UI for Hackathon backoffice
TODO:
- Organize table schema for users, teams, and submissions management
- Create API Contract for additional back-end endpoint
2025-11-30 17:22:47 +07:00
Hafid Nur a20f5eff85 test datatable with mock data 2025-11-30 16:55:11 +07:00
Hafid Nur 18e835450a feat(backoffice): Create a nested/dropdown sidebar list
- Create a dropdown sidebar list
- Show back the old navigation and group them
- Make the sidebar responsive for mobile view
2025-11-30 12:59:18 +07:00
Hafid Nur f5ad55235c feat(backoffice): create a boilerplate page for Hackathon dashboard
- Create an empty page for Hackathon dashboard
- Comment out and hide the existing backoffice sidebar
2025-11-30 12:29:58 +07:00
59 changed files with 641 additions and 3314 deletions
-19
View File
@@ -1,19 +0,0 @@
#!/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
+3
View File
@@ -0,0 +1,3 @@
VITE_SUPABASE_URL=https://your-project-id.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key-here
-1
View File
@@ -1 +0,0 @@
use flake
+44
View File
@@ -0,0 +1,44 @@
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
+44
View File
@@ -0,0 +1,44 @@
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
+44
View File
@@ -0,0 +1,44 @@
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
+45
View File
@@ -0,0 +1,45 @@
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
+44
View File
@@ -0,0 +1,44 @@
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
-40
View File
@@ -1,40 +0,0 @@
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)"
-3
View File
@@ -18,9 +18,6 @@
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: 263 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 506 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 818 KiB

@@ -3,7 +3,8 @@ 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 {
useCertificatePublicData, useTeamById,
useTeamSubmission,
useAuthStore, useAuthStore,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
@@ -12,7 +13,6 @@ 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,16 +41,10 @@ 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) {
// Use encodeURIComponent to properly encode the certId for the URL const certificateUrl = `${window.location.origin}/certificate/${certId}`;
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,
@@ -64,18 +58,18 @@ const CertificatePage: FC = (): ReactElement => {
} }
}, [certId]); }, [certId]);
const certificate = certificateData?.data; const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
const team = certificate?.team; decodedInfo?.teamId || '',
const submission = certificate?.submission; !!decodedInfo?.teamId
const certificateUser = certificate?.user; );
const { data: submissionData, isLoading: isLoadingSubmission } =
useTeamSubmission(decodedInfo?.teamId || '', !!decodedInfo?.teamId);
const isLoading = (!decodedInfo && !error) || isLoadingCertificate; const team = teamData?.data;
const submission = submissionData?.data;
// Certificate name from the user data const isLoading =
const certificateName = certificateUser?.fullname; (!decodedInfo && !error) || isLoadingTeam || isLoadingSubmission;
// 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(() => {
@@ -104,7 +98,7 @@ const CertificatePage: FC = (): ReactElement => {
}, 0); }, 0);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [team?.name, certificateName]); }, [team?.name, session?.user?.fullname]);
// Generate certificate canvas screenshot // Generate certificate canvas screenshot
useEffect(() => { useEffect(() => {
@@ -113,22 +107,19 @@ const CertificatePage: FC = (): ReactElement => {
setIsGenerating(true); setIsGenerating(true);
try { try {
// Wait longer for fonts and images to load properly // Wait a bit for fonts and images to load
await new Promise((resolve) => setTimeout(resolve, 1500)); await new Promise((resolve) => setTimeout(resolve, 500));
const canvas = await html2canvas(certificateRef.current, { const canvas = await html2canvas(certificateRef.current, {
scale: 4, scale: 2,
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', 1.0); const imageUrl = canvas.toDataURL('image/png');
setCertificateImage(imageUrl); setCertificateImage(imageUrl);
setShowTemplate(false); setShowTemplate(false);
} catch (error) { } catch (error) {
@@ -139,7 +130,7 @@ const CertificatePage: FC = (): ReactElement => {
}; };
generateCertificate(); generateCertificate();
}, [team, submission, qrCodeUrl]); }, [team, submission, qrCodeUrl, session?.user?.fullname]);
// Download certificate // Download certificate
const handleDownloadCertificate = () => { const handleDownloadCertificate = () => {
@@ -211,7 +202,7 @@ const CertificatePage: FC = (): ReactElement => {
); );
} }
if (!certificateUser) { if (!submission || submission.id !== decodedInfo?.submissionId) {
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>
@@ -219,7 +210,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 user associated with this certificate could not be found. The submission 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>
@@ -287,14 +278,14 @@ const CertificatePage: FC = (): ReactElement => {
{team?.name} {team?.name}
</p> </p>
</div> </div>
{team && isTeamMember && ( <Button
<Button variant="secondary"
variant="secondary" onClick={() =>
onClick={() => navigate(`/teams/${team.id}/submission`)} navigate(`/teams/${decodedInfo?.teamId}/submission`)
> }
Back to Submission >
</Button> Back to Submission
)} </Button>
</div> </div>
</div> </div>
</div> </div>
@@ -314,30 +305,57 @@ const CertificatePage: FC = (): ReactElement => {
id="certificate-template" id="certificate-template"
style={{ style={{
position: 'relative', position: 'relative',
backgroundImage: 'url(/images/blank_cert.svg)', backgroundImage: 'url(/images/blank_cert.png)',
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
width: '1000px', width: '1000px',
height: `${(1000 * 2480) / 3508}px`, height: `${(1000 * 2480) / 3508}px`,
}} }}
> >
{/* Team Name - positioned in middle between "Diberikan Kepada" and "Telah Berpartisipasi" */} {/* User Name (from session) */}
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
top: '41%', top: '40%',
left: '3.5%', left: '50%',
width: '55%', transform: 'translateX(-50%)',
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={{
fontFamily: 'Poppins, sans-serif', fontWeight: '600',
fontWeight: 'bold', color: '#1f2937',
color: '#59bef5', textAlign: 'center',
textAlign: 'left', fontSize: teamNameFontSize,
fontSize: '32px',
lineHeight: '1.2', lineHeight: '1.2',
wordBreak: 'break-word', wordBreak: 'break-word',
margin: 0, margin: 0,
@@ -347,53 +365,84 @@ const CertificatePage: FC = (): ReactElement => {
</h3> </h3>
</div> </div>
{/* User Name - positioned below team name */} {/* Participation Text */}
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
top: '45%', top: '60%',
left: '3.5%', left: '50%',
width: '55%', transform: 'translateX(-50%)',
width: '70%',
}} }}
> >
<h3 <p
ref={userNameRef}
style={{ style={{
fontFamily: 'Poppins, sans-serif', textAlign: 'center',
fontWeight: 'bold', color: '#374151',
color: '#59bef5', fontSize: '18px',
textAlign: 'left', fontWeight: '500',
fontSize: '40px',
lineHeight: '1.2',
wordBreak: 'break-word',
margin: 0, margin: 0,
}} }}
> >
{certificateName || 'N/A'} <span style={{ fontWeight: 'bold' }}>
</h3> Peserta Hackathon IMPHNEN x KOLOSAL AI
</span>
</p>
</div> </div>
{/* QR Code - positioned in the white box area */} {/* QR Code */}
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
top: '33%', bottom: '8%',
right: '9.3%', left: '8%',
width: '190px',
height: '190px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}} }}
> >
{qrCodeUrl && ( {qrCodeUrl && (
<img <div
src={qrCodeUrl} style={{
alt="Certificate QR Code" backgroundColor: '#ffffff',
style={{ width: '190px', height: '190px', display: 'block' }} padding: '8px',
/> borderRadius: '4px',
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
}}
>
<img
src={qrCodeUrl}
alt="Certificate QR Code"
style={{ width: '96px', height: '96px' }}
/>
</div>
)} )}
</div> </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>
@@ -420,35 +469,33 @@ const CertificatePage: FC = (): ReactElement => {
)} )}
{/* Actions */} {/* Actions */}
{isTeamMember && ( <div className="bg-gray-50 dark:bg-gray-800 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
<div className="bg-gray-50 dark:bg-gray-900 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print"> <Button
<Button variant="secondary"
variant="secondary" onClick={handleDownloadCertificate}
onClick={handleDownloadCertificate} className="flex items-center gap-2"
className="flex items-center gap-2" disabled={isGenerating}
disabled={isGenerating} >
> {isGenerating ? '⏳ Generating...' : '📥 Download'}
{isGenerating ? '⏳ Generating...' : '📥 Download'} </Button>
</Button> <Button
<Button variant="secondary"
variant="secondary" onClick={handlePrintCertificate}
onClick={handlePrintCertificate} className="flex items-center gap-2"
className="flex items-center gap-2" disabled={isGenerating}
disabled={isGenerating} >
> {isGenerating ? '⏳ Generating...' : '🖨️ Print'}
{isGenerating ? '⏳ Generating...' : '🖨️ Print'} </Button>
</Button> <Button
{team && ( onClick={() =>
<Button navigate(`/teams/${decodedInfo?.teamId}/submission`)
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 */}
@@ -1,605 +0,0 @@
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;
+7 -67
View File
@@ -1,17 +1,15 @@
import { FC, ReactElement, useEffect, useMemo, useState } from 'react'; import { FC, ReactElement, useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router'; import { Link } 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');
@@ -41,7 +39,6 @@ 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;
@@ -94,7 +91,6 @@ const DashboardPage: FC = (): ReactElement => {
} }
}, [showProfileModal]); }, [showProfileModal]);
const { data: teamsData } = useMyTeams(); const { data: teamsData } = useMyTeams();
const { data: winnersResponse } = useWinners();
const { data: invitationsData } = useMyInvitations(); const { data: invitationsData } = useMyInvitations();
const { mutateAsync: respondToInvitation } = useRespondToInvitation(); const { mutateAsync: respondToInvitation } = useRespondToInvitation();
@@ -103,13 +99,6 @@ 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' });
@@ -145,37 +134,6 @@ 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">
@@ -236,8 +194,7 @@ 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 Team features are closed. You can no longer accept invitations.
invitations.
</p> </p>
</div> </div>
)} )}
@@ -306,10 +263,7 @@ 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 icon="mdi:account-group" className="text-gray-500 dark:text-gray-400 text-2xl" />
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">
@@ -319,33 +273,19 @@ 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 icon="mdi:check-circle" className="text-sm" />
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 icon="mdi:map-marker" className="shrink-0" />
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.member_count || team.members?.length || 0} member{(team.member_count || team.members?.length || 0) !== 1 ? 's' : ''}
team.members?.length ||
0}{' '}
member
{(team.member_count ||
team.members?.length ||
0) !== 1
? 's'
: ''}
</span> </span>
</div> </div>
</div> </div>
-6
View File
@@ -52,12 +52,6 @@ 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 });
+1 -1
View File
@@ -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">
Muhammad Alif Ramadhan Anka Tama
</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,13 +1,12 @@
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, useAuthStore } from '@imphnen-frontend-service/service'; import { useTeamById, useTeamSubmission } 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);
@@ -114,17 +113,13 @@ 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 personalized certificate is ready to download and share. Congratulations! Your certificate is ready to download and share.
</p> </p>
</div> </div>
</div> </div>
<button <button
onClick={async () => { onClick={async () => {
const certId = await encodeCertificateId( const certId = await encodeCertificateId(teamId || '', submission.id);
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"
+9 -51
View File
@@ -3,69 +3,27 @@ import { decryptText, encryptText } from "./aesclient";
const SECRET_KEY = 'imphnen-hackathon-2025'; const SECRET_KEY = 'imphnen-hackathon-2025';
/** /**
* Encode teamId, submissionId, and userId into a certificate ID * Encode teamId and submissionId into a certificate ID
* Uses AES encryption for secure encoding * Uses base64 encoding for simple obfuscation
* @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, userId: string): Promise<string> => { export const encodeCertificateId = async (teamId: string, submissionId: string): Promise<string> => {
const combined = `${teamId}::${submissionId}::${userId}`; const combined = `${teamId}::${submissionId}`;
return encryptText(combined, SECRET_KEY); return encryptText(combined, SECRET_KEY);
}; };
/** /**
* Encode winner certificate ID (team-based) * Decode certificate ID back to teamId and submissionId
* @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
* @param certId - The encoded certificate ID * @param certId - The encoded certificate ID
* @returns Object containing teamId, submissionId, and userId * @returns Object containing teamId and submissionId
*/ */
export const decodeCertificateId = async (certId: string): Promise<{ teamId: string; submissionId: string; userId: string }> => { export const decodeCertificateId = async (certId: string): Promise<{ teamId: string; submissionId: string }> => {
try { try {
const decoded = await decryptText(certId, SECRET_KEY); const decoded = await decryptText(certId, SECRET_KEY);
const parts = decoded.split('::'); const [teamId, submissionId] = 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');
} }
-3
View File
@@ -1,3 +0,0 @@
import baseConfig from '../../eslint.config.mjs';
export default [...baseConfig];
-15
View File
@@ -1,15 +0,0 @@
<!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>
-9
View File
@@ -1,9 +0,0 @@
import { join } from 'path';
export default {
plugins: {
'@tailwindcss/postcss': {
base: join(import.meta.dirname, '../../'),
},
},
};
-8
View File
@@ -1,8 +0,0 @@
{
"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

Before

Width:  |  Height:  |  Size: 351 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 511 KiB

-235
View File
@@ -1,235 +0,0 @@
const apps = [
{ name: 'Landing', domain: 'imphnen.dev', type: 'Next.js', color: 'blue' },
{ 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: 'Infra', domain: 'infra.imphnen.dev', type: 'Vite', color: 'green' },
];
const techStack = [
{ category: 'OS', items: ['NixOS 24.11'] },
{ category: 'Web Server', items: ['Nginx'] },
{ category: 'SSL', items: ['Let\'s Encrypt (ACME)'] },
{ category: 'Build', items: ['Nix Flakes', 'Nx Monorepo'] },
{ category: 'Frontend', items: ['React', 'Next.js', 'Vite', 'TailwindCSS'] },
{ 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">Update Flake</p>
<p className="text-xs text-zinc-500">nix flake update</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>3</span>
</div>
<p className="text-sm font-medium">Remote Build</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 &copy; {new Date().getFullYear()}</p>
</div>
</footer>
</div>
);
}
-58
View File
@@ -1,58 +0,0 @@
@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;
}
}
-14
View File
@@ -1,14 +0,0 @@
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>
);
-9
View File
@@ -1,9 +0,0 @@
{
"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"]
}
-18
View File
@@ -1,18 +0,0 @@
{
"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"
}
-27
View File
@@ -1,27 +0,0 @@
/// <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.
@@ -47,7 +47,7 @@ export function CommunitySection() {
case 'FaDiscord': case 'FaDiscord':
return FaDiscord; return FaDiscord;
case 'FaGithub': case 'FaGithub':
return FaGithub; return FaDiscord;
case 'FaInstagram': case 'FaInstagram':
return FaInstagram; return FaInstagram;
case 'FaTiktok': case 'FaTiktok':
@@ -54,19 +54,9 @@ export function HeroSection() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }} transition={{ duration: 0.5 }}
> >
<div className="flex flex-wrap items-center gap-2"> <span className="inline-flex items-center rounded-full border px-3 py-1 text-sm w-fit">
<span className="inline-flex items-center rounded-full border px-3 py-1 text-sm"> {communityLabel}
{communityLabel} </span>
</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,21 +104,11 @@ 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 space-y-2"> <div className="mt-8 border-t pt-8 text-center">
<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 -1
View File
@@ -16,7 +16,7 @@
{ {
"name": "Github", "name": "Github",
"icon": "FaGithub", "icon": "FaGithub",
"color": "#000000", "color": "#5865F2",
"description": "Platforms to collaborate on your code", "description": "Platforms to collaborate on your code",
"link": "https://github.com/IMPHNEN/" "link": "https://github.com/IMPHNEN/"
}, },
+7 -17
View File
@@ -1,23 +1,13 @@
import localFont from 'next/font/local'; import { Bai_Jamjuree, Poppins } from 'next/font/google';
export const baiJamjureeFont = localFont({ export const baiJamjureeFont = Bai_Jamjuree({
src: [ subsets: ['latin'],
{ path: '../../public/fonts/BaiJamjuree-Light.ttf', weight: '300', style: 'normal' }, weight: ['300', '400', '500', '600', '700'],
{ 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 = localFont({ export const poppinsFont = Poppins({
src: [ subsets: ['latin'],
{ path: '../../public/fonts/Poppins-Light.ttf', weight: '300', style: 'normal' }, weight: ['300', '400', '500', '600', '700'],
{ 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',
}); });
-119
View File
@@ -1,119 +0,0 @@
{
description = "Imphnen Frontend Service - Nx Monorepo";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (system:
let
pkgs = import nixpkgs { inherit system; };
# NPM dependencies hash (update with: nix run nixpkgs#prefetch-npm-deps -- package-lock.json)
npmDepsHash = "sha256-xmG0ej6GJk1HIFFmrB9CpovNA20sJ7RanFLMBCxKdy0=";
# Import helpers
mkViteApp = import ./nix/mkViteApp.nix {
inherit pkgs npmDepsHash;
src = ./.;
};
landingApp = import ./nix/landing.nix {
inherit pkgs npmDepsHash;
src = ./.;
};
in {
packages = {
# Static Vite apps
backoffice = mkViteApp { name = "backoffice"; buildScript = "backoffice:build"; };
gacha = mkViteApp { name = "gacha"; buildScript = "gacha:build"; };
dimentorin = mkViteApp { name = "dimentorin"; buildScript = "dimentorin:build"; };
hackathon = mkViteApp { name = "hackathon"; buildScript = "hackathon:build"; };
infra = mkViteApp { name = "infra"; buildScript = "infra:build"; };
# Next.js app
landing = landingApp;
# Default package
default = self.packages.${system}.landing;
};
# Development shell
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
nodejs_22
nodePackages.npm
bun
# Useful dev tools
git
jq
];
shellHook = ''
echo "🚀 Imphnen Frontend Development Shell"
echo "Node.js: $(node --version)"
echo "npm: $(npm --version)"
echo ""
echo "Available commands:"
echo " npm install - Install dependencies"
echo " nx dev <app> - Start development server"
echo " nx build <app> - Build an app"
echo ""
echo "Apps: landing, backoffice, gacha, dimentorin, hackathon"
echo ""
echo "Build packages with:"
echo " nix build .#landing"
echo " nix build .#backoffice"
echo " nix build .#gacha"
echo " nix build .#dimentorin"
echo " nix build .#hackathon"
'';
};
}
) // {
# NixOS modules for deployment
nixosModules = {
landing = import ./nix/modules/landing.nix { inherit self; };
backoffice = import ./nix/modules/static-app.nix { inherit self; } { appName = "backoffice"; };
gacha = import ./nix/modules/static-app.nix { inherit self; } { appName = "gacha"; };
dimentorin = import ./nix/modules/static-app.nix { inherit self; } { appName = "dimentorin"; };
hackathon = import ./nix/modules/static-app.nix { inherit self; } { appName = "hackathon"; };
infra = import ./nix/modules/static-app.nix { inherit self; } { appName = "infra"; };
# All-in-one module that enables all apps
all = { config, lib, pkgs, ... }: {
imports = [
self.nixosModules.landing
self.nixosModules.backoffice
self.nixosModules.gacha
self.nixosModules.dimentorin
self.nixosModules.hackathon
self.nixosModules.infra
];
};
};
# Overlay for easy integration
overlays.default = final: prev: {
imphnen = {
landing = self.packages.${final.system}.landing;
backoffice = self.packages.${final.system}.backoffice;
gacha = self.packages.${final.system}.gacha;
dimentorin = self.packages.${final.system}.dimentorin;
hackathon = self.packages.${final.system}.hackathon;
infra = self.packages.${final.system}.infra;
# Function to build hackathon with custom environment variables (e.g., Supabase)
mkHackathonWithEnv = envVars: import ./nix/mkViteApp.nix {
pkgs = final;
src = self;
npmDepsHash = "sha256-xmG0ej6GJk1HIFFmrB9CpovNA20sJ7RanFLMBCxKdy0=";
} { name = "hackathon"; buildScript = "hackathon:build"; inherit envVars; };
};
};
};
}
+3 -4
View File
@@ -31,13 +31,12 @@ 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 or certificate pages (to avoid reload on login failure) // But skip redirect if already on auth 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 or certificate page // Only clear session and redirect if not on auth page
if (!isAuthPage && !isCertificatePage) { if (!isAuthPage) {
useAuthStore.getState().clearSession(); useAuthStore.getState().clearSession();
if (globalThis.window !== undefined) { if (globalThis.window !== undefined) {
globalThis.location.href = '/auth/login'; globalThis.location.href = '/auth/login';
-49
View File
@@ -15,41 +15,6 @@ 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;
@@ -153,17 +118,3 @@ 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,
});
};
+41
View File
@@ -0,0 +1,41 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Missing Supabase environment variables');
}
// Create Supabase client with proper session management enabled
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
autoRefreshToken: true, // ✅ Auto-refresh expired tokens
persistSession: true, // ✅ Persist session in storage
detectSessionInUrl: true, // ✅ Auto-detect OAuth callback
storage: typeof window !== 'undefined' ? window.localStorage : undefined,
},
global: {
headers: {
'X-Client-Info': 'supabase-js-web',
},
},
});
// Helper to create authenticated client (kept for backward compatibility)
// NOTE: With proper session management, this should no longer be needed
// Once session is set via supabase.auth.setSession(), the base client will have auth context
export const getAuthenticatedClient = (accessToken: string) => {
return createClient(supabaseUrl, supabaseAnonKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
detectSessionInUrl: false,
},
global: {
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
});
};
+1
View File
@@ -0,0 +1 @@
export * from './client';
-89
View File
@@ -1,89 +0,0 @@
# Example: How to use in imphnen-infrastructure
#
# In your imphnen-infrastructure flake.nix:
#
# {
# inputs = {
# nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
# imphnen-frontend = {
# url = "github:your-org/imphnen-frontend-service";
# # Or use local path during development:
# # url = "path:/path/to/imphnen-frontend-service";
# };
# };
#
# outputs = { self, nixpkgs, imphnen-frontend, ... }: {
# nixosConfigurations.your-server = nixpkgs.lib.nixosSystem {
# system = "x86_64-linux";
# modules = [
# # Import all modules at once
# imphnen-frontend.nixosModules.all
#
# # Or import individually:
# # imphnen-frontend.nixosModules.landing
# # imphnen-frontend.nixosModules.backoffice
#
# ./your-server-config.nix
# ];
# };
# };
# }
#
# Then in your-server-config.nix:
{ config, pkgs, ... }:
{
# Enable Landing (Next.js) - runs as systemd service
services.imphnen-landing = {
enable = true;
port = 3000;
hostname = "0.0.0.0";
openFirewall = true;
# environmentFile = /run/secrets/landing-env; # For secrets
};
# Enable Backoffice (Static) - served by nginx
services.imphnen-backoffice = {
enable = true;
domain = "backoffice.imphnen.dev";
enableSSL = true;
};
# Enable Gacha (Static)
services.imphnen-gacha = {
enable = true;
domain = "gacha.imphnen.dev";
enableSSL = true;
};
# Enable Dimentorin (Static)
services.imphnen-dimentorin = {
enable = true;
domain = "dimentorin.imphnen.dev";
enableSSL = true;
};
# Enable Hackathon (Static)
services.imphnen-hackathon = {
enable = true;
domain = "hackathon.imphnen.dev";
enableSSL = true;
};
# Nginx reverse proxy for landing (if needed)
services.nginx.virtualHosts."imphnen.dev" = {
forceSSL = true;
enableACME = true;
locations."/" = {
proxyPass = "http://127.0.0.1:3000";
proxyWebsockets = true;
};
};
# Enable ACME for SSL
security.acme = {
acceptTerms = true;
defaults.email = "admin@imphnen.dev";
};
}
-59
View File
@@ -1,59 +0,0 @@
# Next.js Landing App Package
{ pkgs, src, npmDepsHash }:
pkgs.buildNpmPackage {
pname = "imphnen-landing";
version = "0.0.1";
inherit src npmDepsHash;
npmFlags = [ "--legacy-peer-deps" ];
makeCacheWritable = true;
nativeBuildInputs = with pkgs; [
nodejs_22
nodePackages.npm
python3 # Required for node-gyp (sharp, etc.)
];
buildPhase = ''
runHook preBuild
export NX_DAEMON=false
export HOME=$TMPDIR
export CI=true
export NO_COLOR=1
export TERM=dumb
export NX_SKIP_NX_CACHE=true
export NX_TASKS_RUNNER_DYNAMIC_OUTPUT=false
export NX_NATIVE=false
./node_modules/.bin/nx build landing --output-style=static
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out/bin $out/share/landing
# Copy standalone server
cp -r dist/apps/landing/.next/standalone/* $out/share/landing/
# Copy public assets
mkdir -p $out/share/landing/apps/landing/public
cp -r dist/apps/landing/public/* $out/share/landing/apps/landing/public/ || true
# Copy static files
mkdir -p $out/share/landing/dist/apps/landing/.next/static
cp -r dist/apps/landing/.next/static/* $out/share/landing/dist/apps/landing/.next/static/
# Create wrapper script
cat > $out/bin/imphnen-landing <<EOF
#!${pkgs.bash}/bin/bash
cd $out/share/landing
exec ${pkgs.nodejs_22}/bin/node --jitless apps/landing/server.js "\$@"
EOF
chmod +x $out/bin/imphnen-landing
runHook postInstall
'';
dontNpmBuild = true;
}
-47
View File
@@ -1,47 +0,0 @@
# Helper function to build Vite-based static apps
{ pkgs, src, npmDepsHash }:
{ name, buildScript, envVars ? {} }:
pkgs.buildNpmPackage {
pname = "imphnen-${name}";
version = "0.0.1";
inherit src npmDepsHash;
npmFlags = [ "--legacy-peer-deps" ];
makeCacheWritable = true;
nativeBuildInputs = with pkgs; [
nodejs_22
nodePackages.npm
util-linux # For script command to create pseudo-terminal
];
buildPhase = ''
runHook preBuild
export NX_DAEMON=false
export HOME=$TMPDIR
export CI=true
export NO_COLOR=1
export TERM=dumb
export NX_SKIP_NX_CACHE=true
export NX_TASKS_RUNNER_DYNAMIC_OUTPUT=false
export NX_NATIVE=false
${pkgs.lib.concatStringsSep "\n" (pkgs.lib.mapAttrsToList (k: v: "export ${k}=\"${v}\"") envVars)}
# Run nx build with pseudo-terminal to prevent terminal access errors
# Use script to create a pty, preventing Nx from crashing on raw terminal mode
script -q -c "./node_modules/.bin/nx build ${name} --output-style=static" /dev/null || true
# Verify the build output exists
test -d dist/apps/${name}
runHook postBuild
'';
installPhase = ''
runHook preInstall
mkdir -p $out
cp -r dist/apps/${name}/* $out/
runHook postInstall
'';
dontNpmBuild = true;
}
-83
View File
@@ -1,83 +0,0 @@
# NixOS Module for Imphnen Landing
{ self }:
{ config, lib, pkgs, ... }:
let
cfg = config.services.imphnen-landing;
in {
options.services.imphnen-landing = {
enable = lib.mkEnableOption "Imphnen Landing Page";
port = lib.mkOption {
type = lib.types.port;
default = 3000;
description = "Port to run the landing server on";
};
hostname = lib.mkOption {
type = lib.types.str;
default = "0.0.0.0";
description = "Hostname to bind to";
};
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.system}.landing;
description = "The landing package to use";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Open firewall for the landing port";
};
environmentFile = lib.mkOption {
type = lib.types.nullOr lib.types.path;
default = null;
description = "Environment file for secrets";
};
};
config = lib.mkIf cfg.enable {
systemd.services.imphnen-landing = {
description = "Imphnen Landing Page";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
environment = {
NODE_ENV = "production";
PORT = toString cfg.port;
HOSTNAME = cfg.hostname;
};
serviceConfig = {
Type = "simple";
ExecStart = "${cfg.package}/bin/imphnen-landing";
Restart = "on-failure";
RestartSec = "5s";
# Hardening
DynamicUser = true;
NoNewPrivileges = true;
ProtectSystem = "strict";
ProtectHome = true;
PrivateTmp = true;
ProtectKernelTunables = true;
ProtectKernelModules = true;
ProtectControlGroups = true;
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
RestrictNamespaces = true;
LockPersonality = true;
MemoryDenyWriteExecute = true;
RestrictRealtime = true;
RestrictSUIDSGID = true;
} // lib.optionalAttrs (cfg.environmentFile != null) {
EnvironmentFile = cfg.environmentFile;
};
};
networking.firewall.allowedTCPPorts = lib.mkIf cfg.openFirewall [ cfg.port ];
};
}
-76
View File
@@ -1,76 +0,0 @@
# NixOS Module for Static Vite Apps (nginx-based)
{ self }:
{ appName }:
{ config, lib, pkgs, ... }:
let
cfg = config.services."imphnen-${appName}";
in {
options.services."imphnen-${appName}" = {
enable = lib.mkEnableOption "Imphnen ${appName}";
domain = lib.mkOption {
type = lib.types.str;
description = "Domain name for the app";
};
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.system}.${appName};
description = "The ${appName} package to use";
};
enableSSL = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Enable ACME SSL";
};
extraLocations = lib.mkOption {
type = lib.types.attrsOf lib.types.anything;
default = {};
description = "Additional nginx locations";
};
extraConfig = lib.mkOption {
type = lib.types.lines;
default = "";
description = "Extra nginx configuration";
};
};
config = lib.mkIf cfg.enable {
services.nginx = {
enable = true;
recommendedGzipSettings = true;
recommendedOptimisation = true;
recommendedProxySettings = true;
recommendedTlsSettings = true;
virtualHosts.${cfg.domain} = {
forceSSL = cfg.enableSSL;
enableACME = cfg.enableSSL;
root = cfg.package;
locations = {
"/" = {
tryFiles = "$uri $uri/ /index.html";
};
# Cache static assets
"~* \\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$" = {
tryFiles = "$uri =404";
extraConfig = ''
expires 1y;
add_header Cache-Control "public, immutable";
'';
};
} // cfg.extraLocations;
extraConfig = cfg.extraConfig;
};
};
};
}
+181 -1144
View File
File diff suppressed because it is too large Load Diff
+6 -7
View File
@@ -39,6 +39,7 @@
"@radix-ui/react-label": "^2.1.7", "@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-slot": "^1.2.0", "@radix-ui/react-slot": "^1.2.0",
"@redocly/ajv": "^8.11.2", "@redocly/ajv": "^8.11.2",
"@supabase/supabase-js": "^2.84.0",
"@tanstack/react-query": "^5.74.4", "@tanstack/react-query": "^5.74.4",
"@tanstack/react-store": "^0.8.0", "@tanstack/react-store": "^0.8.0",
"@tanstack/react-table": "^8.21.2", "@tanstack/react-table": "^8.21.2",
@@ -50,16 +51,15 @@
"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.10", "next": "~16.0.3",
"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.2.1", "react": "^19.1.0",
"react-dom": "^19.2.1", "react-dom": "^19.1.0",
"react-hook-form": "^7.56.4", "react-hook-form": "^7.56.4",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-is": "^19.2.3",
"react-router-dom": "^7.3.0", "react-router-dom": "^7.3.0",
"recharts": "^3.1.2", "recharts": "^3.1.2",
"sharp": "^0.34.1", "sharp": "^0.34.1",
@@ -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.2.7", "@types/react": "^19.1.2",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.1.2",
"@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,7 +108,6 @@
"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",