Compare commits

...
Author SHA1 Message Date
Hafid Nur 5f9a537c20 Merge branch 'develop' into hackathon/update 2025-12-16 13:56:05 +07:00
Hafid Nur 040f414e6d feat(hackathon): update certificate
- add submission project name to the certificate
2025-12-16 13:52:55 +07:00
Hafid NurandGitHub fe172cc128 feat(hackathon): winner certificate (#73)
* update hackathon landing page

* feat(hackathon): draft winner certificate

* feat(hackathon): refine winner certificate
2025-12-16 12:33:23 +07:00
Hafid Nur 3eb86c6ef4 feat(hackathon): refine winner certificate 2025-12-16 12:06:29 +07:00
Hafid Nur 48d97af044 feat(hackathon): draft winner certificate 2025-12-16 11:26:00 +07:00
Hafid NurandGitHub 38ee9a9549 update hackathon landing page (#72) 2025-12-15 14:43:11 +07:00
Hafid Nur 36f7d10e7a update hackathon landing page 2025-12-15 14:40:49 +07:00
Maulana SodiqinandClaude 3ad828e759 feat: update certificate design with SVG template and Poppins font
- Changed certificate background from PNG to SVG for better quality
- Added Poppins font import to index.html
- Updated certificate layout with Team Name and Person Name in blue (#59bef5)
- Repositioned QR code to fit in designated white box placeholder
- Removed generated copyright and date overlays (now part of SVG template)
- Adjusted text positioning for better alignment with new design
- Increased canvas scale to 4x and wait time to 1500ms for sharper rendering

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-13 23:41:56 +07:00
Maulana SodiqinandClaude 782cce820b feat: hide certificate buttons for non-team members
- Added isTeamMember check (session user ID === certificate user ID)
- Hide all action buttons (Download, Print, View Submission) for non-members
- Hide "Back to Submission" button in header for non-members
- Unauthenticated users and other users can view certificate but cannot interact with buttons

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 00:48:21 +07:00
Maulana SodiqinandClaude f5882e3cdc feat: integrate new certificate endpoint from backend
- Updated certificate page to use useCertificatePublicData hook
- Removed old multi-API-call approach
- Now uses single /certificates/{userId} endpoint
- Simplified data fetching for user, team, submission data
- Certificate now works for unauthenticated users
- Removed duplicate certificate hook file

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 00:16:29 +07:00
Maulana SodiqinandClaude afaaff4a7f feat(service): add public certificate data hook
Add new useCertificatePublicData hook to fetch certificate data from public endpoint without authentication. This hook combines user, team, submission, and winner data in a single request.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 00:02:31 +07:00
Maulana SodiqinandClaude da8b08cd73 fix: allow unauthenticated users to view certificates
- Updated API error interceptor to skip redirect on certificate pages
- Prevents 401 errors from redirecting to login on public certificate pages
- Certificate pages can now be viewed without authentication

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 22:01:37 +07:00
Maulana SodiqinandClaude 1dbe704230 feat: implement persistent personalized certificates
- Modified encodeCertificateId to include userId parameter
- Updated decodeCertificateId to extract and return userId
- Added backward compatibility for old 2-part certificate IDs
- Certificate page now fetches and displays certificate owner's name
- Submission page passes current user's ID when generating certificate
- Fixed certificate template to use certificate owner's name instead of team leader
- Certificates now show the encoded user's name regardless of who views them

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 21:40:34 +07:00
Maulana SodiqinandClaude 59c1bb0366 feat: make certificate page publicly accessible
- Add /certificate/* to public routes in root layout
- Display team leader name instead of current user
- Remove authentication requirement for viewing certificates
- Certificates now work without login

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 21:30:28 +07:00
Maulana SodiqinandClaude c518784810 fix: encode certificate ID in QR code URL
- Use encodeURIComponent to properly encode certId
- Fixes QR code URLs with special characters like /, +, =
- QR codes now generate with properly encoded URLs (%2F, %2B, %3D)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-10 21:23:35 +07:00
Maulana SodiqinandGitHub 0267de664b Delete .github/workflows/deploy-landing.yml 2025-12-10 12:28:05 +07:00
Maulana SodiqinandGitHub 134ff14946 Delete .github/workflows/deploy-hackathon.yml 2025-12-10 12:27:55 +07:00
Maulana SodiqinandGitHub ed3b711c89 Delete .github/workflows/deploy-gacha.yml 2025-12-10 12:27:44 +07:00
Maulana SodiqinandGitHub 6b7b1c4d20 Delete .github/workflows/deploy-dimentorin.yml 2025-12-10 12:27:31 +07:00
Maulana SodiqinandGitHub b4b19e02fa Delete .github/workflows/deploy-backoffice.yml 2025-12-10 12:27:20 +07:00
DentaandGitHub e61979cbb6 chore(landing): adjust landing page (#71)
* chore: adjust landing page
- change typo text in footer "patners" -> "partnerts"
- add github in community section, and footer
- update the value of stat members in hero and cta section

* chore: adjust landing page
- fix logo github in community section
2025-12-10 11:22:37 +07:00
Hafid NurandGitHub 0c7887268c feat(backoffice): FE integration (data fetch) for dashboard, teams, submission, and user management page (#70)
* feat(backoffice): create a boilerplate page for Hackathon dashboard

- Create an empty page for Hackathon dashboard
- Comment out and hide the existing backoffice sidebar

* 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

* test datatable with mock data

* base UI for Hackathon backoffice

TODO:
- Organize table schema for users, teams, and submissions management
- Create API Contract for additional back-end endpoint

* feat(hackathon): draft data table column & API Contract

* update endpoint

* 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

* feat(backoffice): add API contract for hackathon users

* feat(backoffice): little adjustment in hackathon users management UI and API contract

* 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

* feat(backoffice): add searchable city filter

- add component for city filter
- apply to user management and team management pages

* 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

* feat(backoffice): authentication middleware, error pages, and 404 page

* feat(backoffice): hackathon dashboard integration

* 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

* 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

* feat(backoffice): submission page integration

- Fetch submissions data from API
- Move submission modal to hackathon-submissions page
2025-12-10 08:41:30 +07:00
Maulana Sodiqin 76933c0be7 chore: upgrade react version 2025-12-09 16:28:32 +07:00
47 changed files with 5247 additions and 922 deletions
-44
View File
@@ -1,44 +0,0 @@
name: Deploy Backoffice
on:
push:
branches:
- develop
paths:
- 'apps/backoffice/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: |
set -e
if [ ! -d ~/imphnen-frontend-service-backoffice ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-backoffice
else
cd ~/imphnen-frontend-service-backoffice && git fetch origin && git reset --hard origin/develop && git pull
fi
cat > ~/imphnen-frontend-service-backoffice/apps/backoffice/.env << EOF
VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml down || true
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml build --progress=plain
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml up -d
docker image prune -af
-44
View File
@@ -1,44 +0,0 @@
name: Deploy Dimentorin
on:
push:
branches:
- develop
paths:
- 'apps/dimentorin/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: |
set -e
if [ ! -d ~/imphnen-frontend-service-dimentorin ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-dimentorin
else
cd ~/imphnen-frontend-service-dimentorin && git fetch origin && git reset --hard origin/develop && git pull
fi
cat > ~/imphnen-frontend-service-dimentorin/apps/dimentorin/.env << EOF
VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml down || true
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml build --progress=plain
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml up -d
docker image prune -af
-44
View File
@@ -1,44 +0,0 @@
name: Deploy Gacha
on:
push:
branches:
- develop
paths:
- 'apps/gacha/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: |
set -e
if [ ! -d ~/imphnen-frontend-service-gacha ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-gacha
else
cd ~/imphnen-frontend-service-gacha && git fetch origin && git reset --hard origin/develop && git pull
fi
cat > ~/imphnen-frontend-service-gacha/apps/gacha/.env << EOF
VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml down || true
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml build --progress=plain
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml up -d
docker image prune -af
-45
View File
@@ -1,45 +0,0 @@
name: Deploy Hackathon
on:
push:
branches:
- develop
paths:
- 'apps/hackathon/**' # Auto deploy when changes pushed to apps/hackathon
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: |
set -e
if [ ! -d ~/imphnen-frontend-service-hackathon ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-hackathon
else
cd ~/imphnen-frontend-service-hackathon && git fetch origin && git reset --hard origin/develop && git pull
fi
cat > ~/imphnen-frontend-service-hackathon/.env.local << EOF
VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml down || true
docker builder prune -af || true
DOCKER_BUILDKIT=1 docker compose --env-file ~/imphnen-frontend-service-hackathon/.env.local -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml build --progress=plain
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml up -d
docker image prune -af
-44
View File
@@ -1,44 +0,0 @@
name: Deploy Landing
on:
push:
branches:
- develop
paths:
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: |
set -e
if [ ! -d ~/imphnen-frontend-service ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service
else
cd ~/imphnen-frontend-service && git fetch origin && git reset --hard origin/develop && git pull
fi
cat > ~/imphnen-frontend-service/apps/landing/.env << EOF
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
EOF
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build --progress=plain
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
docker image prune -af
+1 -1
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en" data-theme="light">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>Backoffice</title> <title>Backoffice</title>
@@ -1,7 +1,35 @@
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms'; import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
import { FC, ReactElement } from 'react'; import { FC, ReactElement } from 'react';
import { useQuery } from '@tanstack/react-query';
import {
getAdminUsers,
getAdminTeams,
getAdminSubmissions,
} from '@imphnen-frontend-service/service';
export const HackathonDashboardPage: FC = (): ReactElement => { export const HackathonDashboardPage: FC = (): ReactElement => {
// Fetch total participants
const { data: usersData } = useQuery({
queryKey: ['admin-users-count'],
queryFn: () => getAdminUsers({ page: 1, per_page: 1 }),
});
// Fetch total teams
const { data: teamsData } = useQuery({
queryKey: ['admin-teams-count'],
queryFn: () => getAdminTeams({ page: 1, per_page: 1 }),
});
// Fetch total submissions
const { data: submissionsData } = useQuery({
queryKey: ['admin-submissions-count'],
queryFn: () => getAdminSubmissions({ page: 1, per_page: 1 }),
});
const totalParticipants = usersData?.meta?.total_data ?? '??';
const totalTeams = teamsData?.meta?.total_data ?? '??';
const totalSubmissions = submissionsData?.meta?.total_data ?? '??';
return ( return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025"> <BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1> <h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1>
@@ -10,18 +38,22 @@ export const HackathonDashboardPage: FC = (): ReactElement => {
{/* Participant */} {/* Participant */}
<div className="bg-white px-6 py-4 rounded-md shadow"> <div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5"> <h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
1261 {totalParticipants}
</h3> </h3>
<p className="text-neutral-400 text-p3">Total Participants</p> <p className="text-neutral-400 text-p3">Total Participants</p>
</div> </div>
{/* Team */} {/* Team */}
<div className="bg-white px-6 py-4 rounded-md shadow"> <div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">206</h3> <h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
{totalTeams}
</h3>
<p className="text-neutral-400 text-p3">Total Teams</p> <p className="text-neutral-400 text-p3">Total Teams</p>
</div> </div>
{/* Project Submitted */} {/* Project Submitted */}
<div className="bg-white px-6 py-4 rounded-md shadow"> <div className="bg-white px-6 py-4 rounded-md shadow">
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">0</h3> <h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
{totalSubmissions}
</h3>
<p className="text-neutral-400 text-p3">Total Project Submitted</p> <p className="text-neutral-400 text-p3">Total Project Submitted</p>
</div> </div>
</section> </section>
@@ -0,0 +1,249 @@
import { FC } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import {
CloseOutlined,
LinkOutlined,
ProjectOutlined,
FileImageOutlined,
} from '@ant-design/icons';
import { TAdminSubmissionItem } from '@imphnen-frontend-service/service';
import { cn } from '@imphnen-frontend-service/utils';
interface SubmissionModalProps {
isOpen: boolean;
onClose: () => void;
submission: TAdminSubmissionItem;
}
const SubmissionModal: FC<SubmissionModalProps> = ({
isOpen,
onClose,
submission,
}) => {
if (!isOpen) return null;
const getStatusColor = (status: string) => {
switch (status) {
case 'submitted':
return 'bg-success-50 border-success-200 text-success-800';
case 'pending':
return 'bg-orange-50 border-orange-200 text-orange-800';
case 'approved':
return 'bg-blue-50 border-blue-200 text-blue-800';
case 'rejected':
return 'bg-error-50 border-error-200 text-error-800';
default:
return 'bg-neutral-50 border-neutral-200 text-neutral-800';
}
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-success-100 flex items-center justify-center">
<ProjectOutlined className="text-success-600 text-lg" />
</div>
<div>
<h2 className="text-xl font-semibold text-neutral-900">
{submission.project_name}
</h2>
<p className="text-sm text-neutral-500">
Team ID: {submission.team_id} Submitted{' '}
{new Date(submission.submitted_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</p>
</div>
</div>
<button
onClick={onClose}
className="text-neutral-400 hover:text-neutral-600 transition-colors cursor-pointer"
>
<CloseOutlined className="text-xl" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6">
{/* Submission Status */}
<div
className={cn(
'flex items-center gap-3 p-4 border rounded-lg',
getStatusColor(submission.status)
)}
>
<div
className={cn(
'w-3 h-3 rounded-full',
submission.status === 'submitted' && 'bg-success-500',
submission.status === 'pending' && 'bg-orange-500',
submission.status === 'approved' && 'bg-blue-500',
submission.status === 'rejected' && 'bg-error-500'
)}
></div>
<div>
<p className="text-sm font-medium">
Status:{' '}
{submission.status.charAt(0).toUpperCase() +
submission.status.slice(1)}
</p>
<p className="text-xs">Submitted by: {submission.submitted_by}</p>
</div>
</div>
{/* Project Description */}
<div>
<h3 className="text-sm font-medium text-neutral-700 mb-2">
Project Description
</h3>
<p className="text-sm text-neutral-600 leading-relaxed">
{submission.description}
</p>
</div>
{/* Project Links */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-neutral-700">
Project Links
</h3>
{/* Repository URL */}
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
<LinkOutlined className="text-primary-500 mt-1" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-neutral-600 mb-1">
Repository
</p>
<a
href={submission.repository_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary-600 hover:text-primary-700 hover:underline break-all"
>
{submission.repository_url}
</a>
</div>
</div>
{/* Demo URL */}
{submission.demo_url && (
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
<LinkOutlined className="text-primary-500 mt-1" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-neutral-600 mb-1">
Live Demo
</p>
<a
href={submission.demo_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary-600 hover:text-primary-700 hover:underline break-all"
>
{submission.demo_url}
</a>
</div>
</div>
)}
{/* Presentation URL */}
{submission.presentation_url && (
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
<LinkOutlined className="text-primary-500 mt-1" />
<div className="flex-1 min-w-0">
<p className="text-xs font-medium text-neutral-600 mb-1">
Presentation
</p>
<a
href={submission.presentation_url}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary-600 hover:text-primary-700 hover:underline break-all"
>
{submission.presentation_url}
</a>
</div>
</div>
)}
</div>
{/* Screenshots */}
{submission.screenshots && submission.screenshots.length > 0 && (
<div className="space-y-3">
<h3 className="text-sm font-medium text-neutral-700 flex items-center gap-2">
<FileImageOutlined className="text-primary-500" />
Screenshots ({submission.screenshots.length})
</h3>
<div className="grid grid-cols-2 gap-3">
{submission.screenshots.map((screenshot, index) => (
<a
key={index}
href={screenshot}
target="_blank"
rel="noopener noreferrer"
className="block rounded-lg overflow-hidden border border-neutral-200 hover:border-primary-300 transition-colors"
>
<img
src={screenshot}
alt={`Screenshot ${index + 1}`}
className="w-full h-40 object-cover"
/>
</a>
))}
</div>
</div>
)}
{/* Metadata */}
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-neutral-200">
<div>
<p className="text-xs text-neutral-500 mb-1">Created</p>
<p className="text-sm text-neutral-900">
{new Date(submission.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
<div>
<p className="text-xs text-neutral-500 mb-1">Last Updated</p>
<p className="text-sm text-neutral-900">
{new Date(submission.updated_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-neutral-200 bg-neutral-50">
<Button variant="secondary" onClick={onClose}>
Close
</Button>
{/* <Button
variant="primary"
onClick={() => {
console.log('Edit submission:', submission.id);
}}
>
Edit Status
</Button> */}
</div>
</div>
</div>
);
};
export default SubmissionModal;
@@ -1,128 +1,375 @@
import { FC, ReactElement, useState } from 'react'; import {
FC,
ReactElement,
useState,
useEffect,
useMemo,
useCallback,
} from 'react';
import SubmissionModal from './_components/submission-modal';
import { import {
BackofficeWrapper, BackofficeWrapper,
DataTable, DataTable,
} from '@imphnen-frontend-service/ui/organisms'; } from '@imphnen-frontend-service/ui/organisms';
import { import { ColumnDef } from '@tanstack/react-table';
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils'; import { cn } from '@imphnen-frontend-service/utils';
import { EditOutlined } from '@ant-design/icons'; import {
SearchOutlined,
FilterOutlined,
LoadingOutlined,
EyeOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import {
getAdminSubmissions,
TAdminSubmissionItem,
} from '@imphnen-frontend-service/service';
import { useSearchParams } from 'react-router-dom';
export const HackathonUsersPage: FC = (): ReactElement => { type SubmissionType = TAdminSubmissionItem;
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [pagination, setPagination] = useState<PaginationState>({ export const HackathonSubmissionsPage: FC = (): ReactElement => {
pageIndex: 0, const [searchParams, setSearchParams] = useSearchParams();
pageSize: 9, const currentPage = Math.max(
1,
parseInt(searchParams.get('page') || '1', 10)
);
const searchQuery = searchParams.get('search') || '';
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
const statusFilter = searchParams.get('status') || 'all';
const [showSubmissionModal, setShowSubmissionModal] = useState(false);
const [selectedSubmission, setSelectedSubmission] =
useState<SubmissionType | null>(null);
const [globalFilter, setGlobalFilter] = useState(searchQuery);
// Fetch submissions from API
const {
data: submissionsResponse,
isLoading,
isFetching,
} = useQuery({
queryKey: [
'admin-submissions',
currentPage,
perPage,
statusFilter,
searchQuery,
],
queryFn: () =>
getAdminSubmissions({
page: currentPage,
per_page: perPage,
status: statusFilter !== 'all' ? statusFilter : undefined,
search: searchQuery || undefined,
}),
staleTime: 30000, // 30 seconds cache
gcTime: 5 * 60 * 1000, // 5 minutes
}); });
const mockData: any[] = Array.from({ length: 90 }, (_, i) => ({ const totalData = submissionsResponse?.meta?.total_data || 0;
id: i + 1, const totalPages = submissionsResponse?.meta?.total_page || 1;
project_name: `Project ${i + 1}`,
repository_url: `https://github.com/user/repo${i + 1}`,
demo_url: `https://demo.example.com/project${i + 1}`,
presentation_url: `https://slides.example.com/project${i + 1}`,
}));
type UserStatus = 'active' | 'inactive'; // Handle page change
const handlePageChange = useCallback(
(newPage: number) => {
const params = new URLSearchParams();
params.set('page', newPage.toString());
if (perPage !== 10) params.set('per_page', perPage.toString());
if (searchQuery) params.set('search', searchQuery);
if (statusFilter !== 'all') params.set('status', statusFilter);
setSearchParams(params);
window.scrollTo({ top: 0, behavior: 'smooth' });
},
[setSearchParams, perPage, searchQuery, statusFilter]
);
interface SubmissionType { // Validate page number
id: number; useEffect(() => {
project_name: string; if (!isLoading && totalPages > 0 && currentPage > totalPages) {
repository_url: string; setSearchParams({ page: totalPages.toString() });
demo_url: string; }
presentation_url: string; }, [currentPage, totalPages, setSearchParams, isLoading]);
}
const columns: ColumnDef<SubmissionType>[] = [ // Sync globalFilter with URL
{ useEffect(() => {
header: 'Project Name', setGlobalFilter(searchQuery);
accessorKey: 'project_name', }, [searchQuery]);
},
{
header: 'Repository URL',
accessorKey: 'repository_url',
},
{
header: 'Demo URL',
accessorKey: 'demo_url',
},
{
header: 'Presentation URL',
accessorKey: 'presentation_url',
},
{
header: 'Action',
meta: { cellClassName: cn('w-72') },
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 w-max"
onClick={() => {
// View detail logic
}}
>
<EditOutlined className="text-base" /> View & Manage
</Button>
),
},
];
const table = useReactTable({ // Handle search
data: mockData, const handleSearch = useCallback(() => {
columns, const params = new URLSearchParams();
state: { params.set('page', '1');
pagination, if (perPage !== 10) params.set('per_page', perPage.toString());
rowSelection, if (globalFilter.trim()) {
params.set('search', globalFilter.trim());
}
if (statusFilter !== 'all') params.set('status', statusFilter);
setSearchParams(params);
}, [globalFilter, setSearchParams, perPage, statusFilter]);
const handleSearchKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
}, },
enableRowSelection: true, [handleSearch]
onRowSelectionChange: setRowSelection, );
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(), // Handle per page change
onPaginationChange: setPagination, const handlePerPageChange = useCallback(
pageCount: Math.ceil(mockData.length / pagination.pageSize), (newPerPage: number) => {
manualPagination: false, const params = new URLSearchParams();
}); params.set('page', '1');
params.set('per_page', newPerPage.toString());
if (searchQuery) params.set('search', searchQuery);
if (statusFilter !== 'all') params.set('status', statusFilter);
setSearchParams(params);
},
[setSearchParams, searchQuery, statusFilter]
);
// Handle status filter change
// const handleStatusFilterChange = useCallback(
// (newStatus: string) => {
// const params = new URLSearchParams();
// params.set('page', '1');
// if (perPage !== 10) params.set('per_page', perPage.toString());
// if (searchQuery) params.set('search', searchQuery);
// if (newStatus !== 'all') params.set('status', newStatus);
// setSearchParams(params);
// },
// [setSearchParams, perPage, searchQuery]
// );
// Handle modal
const handleShowSubmissionModal = useCallback(
(submission: SubmissionType) => {
setSelectedSubmission(submission);
setShowSubmissionModal(true);
},
[]
);
const handleCloseSubmissionModal = useCallback(() => {
setShowSubmissionModal(false);
setSelectedSubmission(null);
}, []);
// Get submissions data
const filteredData = useMemo(() => {
return submissionsResponse?.data || [];
}, [submissionsResponse]);
// Memoize columns
const columns: ColumnDef<SubmissionType>[] = useMemo(
() => [
{
accessorKey: 'project_name',
header: 'Project Name',
cell: ({ row }) => (
<span className="font-medium text-neutral-900">
{row.original.project_name}
</span>
),
enableSorting: true,
},
{
accessorKey: 'team_id',
header: 'Team ID',
cell: ({ row }) => (
<span className="text-sm text-neutral-700 font-mono">
{row.original.team_id}
</span>
),
enableSorting: false,
},
{
accessorKey: 'status',
header: 'Status',
cell: ({ row }) => {
const status = row.original.status;
return (
<span
className={cn(
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium',
status === 'submitted'
? 'bg-success-100 text-success-800'
: status === 'pending'
? 'bg-orange-100 text-orange-800'
: 'bg-neutral-100 text-neutral-700'
)}
>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
);
},
enableSorting: true,
},
{
accessorKey: 'submitted_at',
header: 'Submitted',
cell: ({ row }) => (
<span className="text-neutral-900 text-sm">
{new Date(row.original.submitted_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})}
</span>
),
enableSorting: true,
sortingFn: 'datetime',
},
{
id: 'actions',
header: 'Actions',
meta: { cellClassName: cn('w-48') },
cell: ({ row }) => (
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 text-sm px-4 py-2"
onClick={() => handleShowSubmissionModal(row.original)}
>
<EyeOutlined className="text-sm" />
View
</Button>
),
enableSorting: false,
},
],
[handleShowSubmissionModal]
);
return ( return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025"> <BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
<h1 className="mb-8 text-p1 font-semibold text-neutral-700"> <h1 className="mb-8 text-p1 font-semibold text-neutral-700">
Project Submission Project Submissions
</h1> </h1>
{/* Filters and actions */}
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6"> <section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
<div className="flex flex-wrap gap-3 items-center"> <div className="flex flex-wrap gap-3 items-center justify-between">
<input <div className="flex flex-wrap gap-3 items-center">
type="text" {/* Search bar */}
className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-64" <div className="relative">
placeholder="Search name or email" <SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
/> <input
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40"> type="text"
<option value="all">All Status</option> className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
<option value="active">Active</option> placeholder="Search by project name..."
<option value="suspended">Suspended</option> value={globalFilter}
</select> onChange={(e) => setGlobalFilter(e.target.value)}
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40"> onKeyPress={handleSearchKeyPress}
<option value="all">All City</option> />
<option value="jakarta">Jakarta</option> </div>
<option value="bandung">Bandung</option>
</select> {/* Per Page Dropdown */}
<div className="relative">
<select
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={perPage}
onChange={(e) =>
handlePerPageChange(parseInt(e.target.value, 10))
}
>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
<option value={50}>50 / page</option>
<option value={100}>100 / page</option>
</select>
</div>
{/* Status Filter */}
{/* <div className="relative">
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
<select
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-40 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={statusFilter}
onChange={(e) => handleStatusFilterChange(e.target.value)}
>
<option value="all">All Status</option>
<option value="submitted">Submitted</option>
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
</select>
</div> */}
</div>
</div> </div>
{/* Table */} {/* Active filters */}
<DataTable data={mockData} columns={columns} table={table} /> {/* {statusFilter !== 'all' && (
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-neutral-600">Active filters:</span>
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
Status: {statusFilter}
<button
onClick={() => handleStatusFilterChange('all')}
className="text-info-600 hover:text-info-800 cursor-pointer"
>
</button>
</span>
<Button
variant="secondary"
size="sm"
onClick={() => {
handleStatusFilterChange('all');
setGlobalFilter('');
}}
className="text-sm text-neutral-600"
>
Clear All
</Button>
</div>
)} */}
{/* Loading & results */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
<span className="ml-3 text-neutral-600">
Loading submissions...
</span>
</div>
) : filteredData.length > 0 ? (
<>
<div className="text-sm text-neutral-600">
Showing {filteredData.length} of {totalData} submissions (Page{' '}
{currentPage} of {totalPages})
{isFetching && (
<span className="ml-2 text-primary-500">(Updating...)</span>
)}
</div>
<DataTable
data={filteredData}
columns={columns}
pageSize={perPage}
manualPagination={true}
pageCount={totalPages}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</>
) : (
<div className="text-center py-12 text-neutral-500">
No submissions found. Try adjusting your filters.
</div>
)}
</section> </section>
{/* Modals extracted into shared backoffice components */} {/* Submission Modal */}
{selectedSubmission && (
<SubmissionModal
isOpen={showSubmissionModal}
onClose={handleCloseSubmissionModal}
submission={selectedSubmission}
/>
)}
</BackofficeWrapper> </BackofficeWrapper>
); );
}; };
export default HackathonUsersPage; export default HackathonSubmissionsPage;
@@ -0,0 +1,514 @@
import { FC, useState, useEffect, useMemo, useRef } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { CityFilterSelect } from '../../../../components/city-filter-select';
import TeamBannerPlaceholder from './team-banner-placeholder';
import { cn } from '@imphnen-frontend-service/utils';
import { TAdminTeamItem } from '@imphnen-frontend-service/service';
import {
TeamOutlined,
CloseOutlined,
DeleteOutlined,
SaveOutlined,
CalendarOutlined,
CrownOutlined,
ExclamationOutlined,
UploadOutlined,
CameraOutlined,
EyeOutlined,
EyeInvisibleOutlined,
} from '@ant-design/icons';
type TeamType = TAdminTeamItem;
interface ModalProps {
isOpen: boolean;
onClose: () => void;
team: TeamType | null;
}
const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
const [formData, setFormData] = useState<TeamType | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showLogoMenu, setShowLogoMenu] = useState(false);
const logoInputRef = useRef<HTMLInputElement>(null);
const bannerInputRef = useRef<HTMLInputElement>(null);
// Initialize form data when modal opens
useEffect(() => {
if (isOpen) {
if (team) {
setFormData({ ...team });
} else {
setFormData({
id: '',
name: '',
description: '',
city: '',
banner: null,
logo: null,
visibility: 'public',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
leader_id: '',
});
}
}
}, [isOpen, team]);
// Check if form has changes
const hasChanges = useMemo(() => {
if (!formData || !team) return !!formData;
return (
formData.name !== team.name ||
formData.description !== team.description ||
formData.city !== team.city ||
formData.visibility !== team.visibility ||
formData.logo !== team.logo ||
formData.banner !== team.banner
);
}, [formData, team]);
// Check if required fields are filled
const isFormValid = useMemo(() => {
if (!formData) return false;
return (
formData.name.trim() !== '' &&
formData.city.trim() !== '' &&
formData.description.trim() !== ''
);
}, [formData]);
const canSave = hasChanges && isFormValid;
if (!isOpen || !formData) return null;
const handleInputChange = (field: keyof TeamType, value: string | null) => {
setFormData((prev) => (prev ? { ...prev, [field]: value } : null));
};
const handleSave = () => {
console.log('Saving team:', formData);
onClose();
};
const handleDelete = () => {
if (!team) return;
console.log('Deleting team:', team.id);
setShowDeleteConfirm(false);
onClose();
};
const handleLogoUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
alert('Please select an image file');
return;
}
if (file.size > 5 * 1024 * 1024) {
alert('Image size must be less than 5MB');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const logoUrl = e.target?.result as string;
handleInputChange('logo', logoUrl);
setShowLogoMenu(false);
};
reader.readAsDataURL(file);
};
const handleBannerUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
alert('Please select an image file');
return;
}
if (file.size > 5 * 1024 * 1024) {
alert('Image size must be less than 5MB');
return;
}
const reader = new FileReader();
reader.onload = (e) => {
const bannerUrl = e.target?.result as string;
handleInputChange('banner', bannerUrl);
};
reader.readAsDataURL(file);
};
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-primary-100 flex items-center justify-center">
<TeamOutlined className="text-primary-600 text-lg" />
</div>
<div>
<h2 className="text-xl font-semibold text-neutral-900">
{team ? 'Team Details' : 'Create New Team'}
</h2>
<p className="text-sm text-neutral-500">
{team
? 'View and manage team information'
: 'Add a new team to the hackathon'}
</p>
</div>
</div>
<button
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors cursor-pointer"
onClick={() => {
setShowLogoMenu(false);
onClose();
}}
>
<CloseOutlined className="text-neutral-400 text-lg" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6" onClick={() => setShowLogoMenu(false)}>
<input
type="file"
ref={logoInputRef}
onChange={handleLogoUpload}
accept="image/*"
className="hidden"
/>
<input
type="file"
ref={bannerInputRef}
onChange={handleBannerUpload}
accept="image/*"
className="hidden"
/>
{/* Banner Section */}
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Team Banner{' '}
<span className="text-xs text-neutral-500">
(3:1 aspect ratio recommended)
</span>
</label>
<div className="relative group">
<TeamBannerPlaceholder
banner={formData.banner || undefined}
teamName={formData.name || 'Team Name'}
className="rounded-lg border border-neutral-200 transition-all group-hover:border-primary-300"
/>
<div className="absolute inset-0 bg-black/40 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
<Button
variant="primary"
size="sm"
onClick={(e) => {
e.stopPropagation();
bannerInputRef.current?.click();
}}
className="bg-white/90 hover:bg-white text-neutral-700 border-transparent shadow-sm gap-2"
>
<UploadOutlined className="text-sm" />
{formData.banner ? 'Change Banner' : 'Add Banner'}
</Button>
{formData.banner && (
<Button
variant="secondary"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleInputChange('banner', null);
}}
className="bg-white/90 hover:bg-white text-red-600 border-transparent shadow-sm hover:text-red-700 gap-2"
>
<DeleteOutlined className="text-sm" />
Delete
</Button>
)}
</div>
</div>
</div>
{/* Logo & Name */}
<div className="grid grid-cols-12 gap-4 items-start">
<div className="col-span-2">
<label className="block text-sm font-medium text-neutral-700 mb-2">
Logo
</label>
<div className="relative group">
<div className="w-24 h-24 rounded-full bg-neutral-100 flex items-center justify-center overflow-hidden border border-neutral-200 group-hover:border-primary-300 transition-colors">
{formData.logo ? (
<img
src={formData.logo}
alt={formData.name || 'Team Logo'}
className="w-full h-full object-cover"
/>
) : (
<TeamOutlined className="text-neutral-400 text-xl" />
)}
</div>
<button
onClick={(e) => {
e.stopPropagation();
setShowLogoMenu(!showLogoMenu);
}}
className="absolute inset-0 bg-neutral-300/80 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center w-24 h-24"
>
<CameraOutlined className="text-white text-lg" />
</button>
{showLogoMenu && (
<div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[140px] z-10">
<button
onClick={(e) => {
e.stopPropagation();
logoInputRef.current?.click();
}}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-2 cursor-pointer"
>
<UploadOutlined className="text-sm" />
{formData.logo ? 'Change Logo' : 'Upload Logo'}
</button>
{formData.logo && (
<button
onClick={(e) => {
e.stopPropagation();
handleInputChange('logo', null);
setShowLogoMenu(false);
}}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 cursor-pointer"
>
<DeleteOutlined className="text-sm" />
Remove Logo
</button>
)}
</div>
)}
</div>
</div>
<div className="col-span-10 space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Team Name <span className="text-danger-500">*</span>
</label>
<input
type="text"
className="w-full border border-neutral-200 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none"
placeholder="Enter team name"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
/>
</div>
</div>
{/* Description */}
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Description <span className="text-danger-500">*</span>
</label>
<textarea
className="w-full border border-neutral-200 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none resize-none"
placeholder="Enter team description"
rows={3}
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
/>
</div>
{/* City */}
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
City <span className="text-danger-500">*</span>
</label>
<CityFilterSelect
value={formData.city || 'all'}
onChange={(city) =>
handleInputChange('city', city === 'all' ? '' : city)
}
className="w-full"
placeholder="Search cities..."
allOptionLabel="Select a city"
filterIcon={false}
/>
</div>
{/* Visibility */}
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Team Visibility
</label>
<div className="flex gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="visibility"
value="public"
checked={formData.visibility === 'public'}
onChange={(e) =>
handleInputChange('visibility', e.target.value)
}
className="text-primary-600"
/>
<EyeOutlined className="text-info-600" />
<span className="text-sm">Public</span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="radio"
name="visibility"
value="private"
checked={formData.visibility === 'private'}
onChange={(e) =>
handleInputChange('visibility', e.target.value)
}
className="text-primary-600"
/>
<EyeInvisibleOutlined className="text-neutral-600" />
<span className="text-sm">Private</span>
</label>
</div>
</div>
{/* Team Details */}
{team && (
<div className="space-y-4 border-t border-neutral-200 pt-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Team Leader ID
</label>
<div className="p-3 bg-neutral-50 rounded-lg flex items-center gap-3">
<CrownOutlined className="text-yellow-600 text-lg" />
<span className="text-sm text-neutral-700 font-mono">
{team.leader_id}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Created
</label>
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
<CalendarOutlined className="text-neutral-500" />
<span className="text-sm text-neutral-700">
{new Date(team.created_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Last Updated
</label>
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
<CalendarOutlined className="text-neutral-500" />
<span className="text-sm text-neutral-700">
{new Date(team.updated_at).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
</div>
</div>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-neutral-200">
<div>
{team && (
<Button
variant="danger"
size="md"
onClick={() => setShowDeleteConfirm(true)}
className="flex items-center gap-2"
>
<DeleteOutlined />
Delete Team
</Button>
)}
</div>
<div className="flex items-center gap-3">
<Button variant="secondary" size="md" onClick={onClose}>
Cancel
</Button>
<Button
variant="primary"
size="md"
onClick={handleSave}
disabled={!canSave}
className="flex items-center gap-2"
>
<SaveOutlined />
{team ? 'Save Changes' : 'Create Team'}
</Button>
</div>
</div>
</div>
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-60 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<div className="flex items-center gap-4 mb-4">
<div className="w-12 h-12 rounded-full bg-danger-100 flex items-center justify-center">
<ExclamationOutlined className="text-danger-600 text-xl" />
</div>
<div>
<h3 className="text-lg font-semibold text-neutral-900">
Delete Team
</h3>
<p className="text-sm text-neutral-500">
This action cannot be undone.
</p>
</div>
</div>
<p className="text-sm text-neutral-700 mb-6">
Are you sure you want to delete "{team?.name}"? This will
permanently remove the team and all associated data.
</p>
<div className="flex items-center gap-3 justify-end">
<Button
variant="secondary"
size="md"
onClick={() => setShowDeleteConfirm(false)}
>
Cancel
</Button>
<Button
variant="danger"
size="md"
onClick={handleDelete}
className="flex items-center gap-2"
>
<DeleteOutlined />
Delete Team
</Button>
</div>
</div>
</div>
)}
</div>
);
};
export default ModalTeamDetail;
@@ -0,0 +1,82 @@
import { FC } from 'react';
import { TeamOutlined } from '@ant-design/icons';
import { cn } from '@imphnen-frontend-service/utils';
interface TeamBannerPlaceholderProps {
banner?: string;
teamName: string;
className?: string;
showPlaceholder?: boolean;
}
const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
banner,
teamName,
className = '',
showPlaceholder = true,
}) => {
const aspectRatioClass = 'aspect-[3/1]'; // 3:1 aspect ratio
if (!banner && !showPlaceholder) {
return null;
}
if (banner) {
return (
<div
className={cn(
'w-full bg-gray-100 overflow-hidden relative',
aspectRatioClass,
className
)}
>
<img
src={banner}
alt={`${teamName} banner`}
className="w-full h-full object-cover"
onError={(e) => {
// Fallback to placeholder if image fails to load
const target = e.target as HTMLImageElement;
target.style.display = 'none';
const placeholder = target.nextElementSibling as HTMLElement;
if (placeholder) {
placeholder.style.display = 'flex';
}
}}
/>
{/* Fallback placeholder (hidden by default, shown on image error) */}
<div
className={cn(
'absolute inset-0 bg-linear-to-r from-gray-100 to-gray-200 flex items-center justify-center',
'hidden' // Hidden by default
)}
>
<div className="text-center">
<TeamOutlined className="text-4xl text-gray-400 mb-2" />
<p className="text-sm text-gray-500 font-medium">{teamName}</p>
<p className="text-xs text-gray-400">Team Banner</p>
</div>
</div>
</div>
);
}
// No banner - show placeholder
return (
<div
className={cn(
'w-full bg-linear-to-r from-gray-100 to-gray-200 flex items-center justify-center',
aspectRatioClass,
className
)}
>
<div className="text-center">
<TeamOutlined className="text-4xl text-gray-400 mb-2" />
<p className="text-sm text-gray-500 font-medium">{teamName}</p>
<p className="text-xs text-gray-400">No Banner</p>
</div>
</div>
);
};
export default TeamBannerPlaceholder;
@@ -1,179 +1,275 @@
import { FC, ReactElement, useState } from 'react'; import {
FC,
ReactElement,
useState,
useEffect,
useMemo,
useCallback,
} from 'react';
import ModalTeamDetail from './_components/modal-team-detail-new';
import { CityFilterSelect } from '../../../components/city-filter-select';
import { import {
BackofficeWrapper, BackofficeWrapper,
DataTable, DataTable,
} from '@imphnen-frontend-service/ui/organisms'; } from '@imphnen-frontend-service/ui/organisms';
import { import { ColumnDef } from '@tanstack/react-table';
ColumnDef,
getCoreRowModel,
getPaginationRowModel,
PaginationState,
RowSelectionState,
useReactTable,
} from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils'; import { cn } from '@imphnen-frontend-service/utils';
import { useTeams } from '@imphnen-frontend-service/service'; import {
import { EditOutlined } from '@ant-design/icons'; EditOutlined,
TeamOutlined,
SearchOutlined,
FilterOutlined,
PlusOutlined,
LoadingOutlined,
} from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import {
getAdminTeams,
TAdminTeamItem,
} from '@imphnen-frontend-service/service';
import { useSearchParams } from 'react-router-dom';
type TeamType = TAdminTeamItem;
export const HackathonTeamsPage: FC = (): ReactElement => { export const HackathonTeamsPage: FC = (): ReactElement => {
const { data: teamsData } = useTeams(); const [searchParams, setSearchParams] = useSearchParams();
const currentPage = Math.max(
1,
parseInt(searchParams.get('page') || '1', 10)
);
const searchQuery = searchParams.get('search') || '';
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
const [showDetailModal, setShowDetailModal] = useState(false);
const [showNewTeamModal, setShowNewTeamModal] = useState(false);
const [selectedTeam, setSelectedTeam] = useState<TeamType | null>(null);
useState<TeamType | null>(null);
const [globalFilter, setGlobalFilter] = useState(searchQuery);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); // Advanced filtering states
const [pagination, setPagination] = useState<PaginationState>({ const [visibilityFilter, setVisibilityFilter] = useState('all');
pageIndex: 0, const [cityFilter, setCityFilter] = useState('all');
pageSize: 9,
// Fetch teams from API
const {
data: teamsResponse,
isLoading,
isFetching,
} = useQuery({
queryKey: [
'admin-teams',
currentPage,
perPage,
cityFilter,
visibilityFilter,
searchQuery,
],
queryFn: () =>
getAdminTeams({
page: currentPage,
per_page: perPage,
search: searchQuery || undefined,
}),
staleTime: 30000, // 30 seconds cache
gcTime: 5 * 60 * 1000, // 5 minutes
}); });
const mockData: TeamType[] = Array.from({ length: 90 }, (_, i) => ({ const totalData = teamsResponse?.meta?.total_data || 0;
id: `team-${i + 1}`, const totalPages = teamsResponse?.meta?.total_page || 1;
name: `Team ${i + 1} - ${i % 3 === 0 ? 'Innovators' : 'Hackers'}`,
city: i % 2 === 0 ? 'Jakarta' : 'Bandung',
visibility: i % 4 === 0 ? 'private' : 'public',
member_count: Math.floor(Math.random() * 4) + 1,
has_submission: i % 3 !== 0,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
leader: {
user: {
fullname: `Leader User ${i}`,
email: `leader${i}@example.com`,
},
},
}));
interface TeamType { // Handle page change - update URL query params
id: string; const handlePageChange = useCallback(
name: string; (newPage: number) => {
city: string; const params = new URLSearchParams();
visibility: 'public' | 'private'; params.set('page', newPage.toString());
member_count: number; if (perPage !== 10) params.set('per_page', perPage.toString());
has_submission: boolean; if (searchQuery) params.set('search', searchQuery);
created_at: string; setSearchParams(params);
updated_at: string; window.scrollTo({ top: 0, behavior: 'smooth' });
leader?: { },
user: { [setSearchParams, perPage, searchQuery]
fullname: string; );
email: string;
};
};
}
const columns: ColumnDef<TeamType>[] = [ // Validate page number doesn't exceed total pages
{ useEffect(() => {
accessorKey: 'id', if (!isLoading && totalPages > 0 && currentPage > totalPages) {
header: 'ID', setSearchParams({ page: totalPages.toString() });
}
}, [currentPage, totalPages, setSearchParams, isLoading]);
// Sync globalFilter with URL search param on mount
useEffect(() => {
setGlobalFilter(searchQuery);
}, [searchQuery]);
// Handle search teams
const handleSearch = useCallback(() => {
const params = new URLSearchParams();
params.set('page', '1');
if (perPage !== 10) params.set('per_page', perPage.toString());
if (globalFilter.trim()) {
params.set('search', globalFilter.trim());
}
setSearchParams(params);
}, [globalFilter, setSearchParams, perPage]);
// Handle Enter key press in search input
const handleSearchKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
}, },
{ [handleSearch]
accessorKey: 'name', );
header: 'Team Name',
// Handle per page change
const handlePerPageChange = useCallback(
(newPerPage: number) => {
const params = new URLSearchParams();
params.set('page', '1');
params.set('per_page', newPerPage.toString());
if (searchQuery) params.set('search', searchQuery);
setSearchParams(params);
}, },
{ [setSearchParams, searchQuery]
accessorKey: 'city', );
header: 'City',
}, // Memoize the callback to prevent recreation
{ const handleShowDetailModal = useCallback((team: TeamType) => {
accessorKey: 'visibility', setSelectedTeam(team);
header: 'Visibility', setShowDetailModal(true);
cell: ({ row }) => { }, []);
const isPublic = row.original.visibility === 'public';
return ( const handleCloseDetailModal = useCallback(() => {
<span setShowDetailModal(false);
className={cn( setSelectedTeam(null);
'py-2 px-4 text-sm rounded-2xl text-center', }, []);
isPublic
? 'bg-info-200 text-info-700' const handleShowNewTeamModal = useCallback(() => {
: 'bg-gray-200 text-gray-700' setShowNewTeamModal(true);
)} }, []);
>
{isPublic ? 'Public' : 'Private'} const handleCloseNewTeamModal = useCallback(() => {
</span> setShowNewTeamModal(false);
); }, []);
},
}, // Get teams data from API response
{ const filteredData = useMemo(() => {
accessorKey: 'member_count', return teamsResponse?.data || [];
header: 'Members', }, [teamsResponse]);
},
{ // Memoize columns to prevent recreation on every render
id: 'leader', const columns: ColumnDef<TeamType>[] = useMemo(
header: 'Leader', () => [
cell: ({ row }) => { {
const leader = row.original.leader?.user; accessorKey: 'name',
return leader ? ( header: 'Team',
<div> cell: ({ row }) => {
<div className="text-sm font-medium text-gray-900"> const team = row.original;
{leader.fullname} return (
<div className="flex items-center gap-3">
{/* Team Logo */}
<div className="w-10 h-10 rounded-full bg-neutral-100 flex items-center justify-center shrink-0 overflow-hidden">
{team.logo ? (
<img
src={team.logo}
alt={team.name}
className="w-full h-full object-cover"
/>
) : (
<TeamOutlined className="text-neutral-400 text-lg" />
)}
</div>
{/* Team Name */}
<div className="min-w-0 flex-1">
<p
className="font-medium text-neutral-900 truncate max-w-sm"
title={team.name}
>
{team.name}
</p>
</div>
</div> </div>
<div className="text-xs text-gray-500">{leader.email}</div> );
},
enableSorting: true,
},
{
accessorKey: 'city',
header: 'City',
cell: ({ row }) => (
<span className="text-neutral-700">{row.original.city}</span>
),
enableSorting: true,
},
{
accessorKey: 'visibility',
header: 'Visibility',
cell: ({ row }) => {
const isPublic = row.original.visibility === 'public';
return (
<span
className={cn(
'inline-flex items-center gap-1 px-2 py-1 rounded-2xl text-xs font-medium',
isPublic
? 'bg-success-100 text-success-800'
: 'bg-neutral-100 text-neutral-700'
)}
>
{isPublic ? 'Public' : 'Private'}
</span>
);
},
enableSorting: true,
},
{
id: 'leader',
header: 'Leader ID',
cell: ({ row }) => (
<div className="text-sm text-neutral-700 font-mono">
{row.original.leader_id}
</div> </div>
) : ( ),
<span className="text-gray-400 italic">-</span> enableSorting: false,
);
}, },
}, {
{ accessorKey: 'created_at',
accessorKey: 'has_submission', header: 'Created',
header: 'Submitted', cell: ({ row }) => (
cell: ({ row }) => { <span className="text-neutral-900 text-sm">
const hasSubmission = row.original.has_submission; {new Date(row.original.created_at).toLocaleDateString('en-UK', {
return ( year: 'numeric',
<span month: 'short',
className={cn( day: 'numeric',
'py-2 px-4 text-sm rounded-2xl text-center', })}
hasSubmission
? 'bg-success-200 text-success-700'
: 'bg-danger-200 text-danger-700'
)}
>
{hasSubmission ? 'Yes' : 'No'}
</span> </span>
); ),
enableSorting: true,
sortingFn: 'datetime',
}, },
}, {
{ id: 'actions',
accessorKey: 'updated_at', header: 'Actions',
header: 'Last Updated', meta: { cellClassName: cn('w-48') },
cell: ({ row }) => { cell: ({ row }) => (
return new Date(row.original.updated_at).toLocaleDateString(); <div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 text-sm px-4 py-2"
onClick={() => handleShowDetailModal(row.original)}
>
<EditOutlined className="text-sm" />
Manage
</Button>
</div>
),
enableSorting: false,
}, },
}, ],
{ [handleShowDetailModal]
id: 'actions', );
header: 'Action',
meta: { cellClassName: cn('w-48') },
cell: ({ row }) => (
<div className="flex items-center gap-2">
<Button
variant="primary"
size="sm"
className="flex items-center gap-2 w-max"
onClick={() => {
// View detail logic
}}
>
<EditOutlined className="text-base" /> View & Manage
</Button>
</div>
),
},
];
const table = useReactTable({
data: mockData,
columns,
state: {
pagination,
rowSelection,
},
enableRowSelection: true,
onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(),
onPaginationChange: setPagination,
pageCount: Math.ceil(mockData.length / pagination.pageSize),
manualPagination: false,
});
return ( return (
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025"> <BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
@@ -182,27 +278,168 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
</h1> </h1>
{/* Filters and actions */} {/* Filters and actions */}
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6"> <section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
<div className="flex flex-wrap gap-3 items-center"> <div className="flex flex-wrap gap-3 items-center justify-between">
<input {/* Left side - Search & filters */}
type="text" <div className="flex flex-wrap gap-3 items-center">
className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-64" {/* Search bar */}
placeholder="Search name or email" <div className="relative">
/> <SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40"> <input
<option value="all">All Status</option> type="text"
<option value="active">Active</option> className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
<option value="suspended">Suspended</option> placeholder="Search teams by name or city..."
</select> value={globalFilter}
<select className="border border-neutral-200 rounded-md px-3 py-2 text-label1 w-full sm:w-40"> onChange={(e) => setGlobalFilter(e.target.value)}
<option value="all">All City</option> onKeyPress={handleSearchKeyPress}
<option value="jakarta">Jakarta</option> />
<option value="bandung">Bandung</option> </div>
</select>
{/* Per Page Dropdown */}
<div className="relative">
<select
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={perPage}
onChange={(e) =>
handlePerPageChange(parseInt(e.target.value, 10))
}
>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
<option value={50}>50 / page</option>
<option value={100}>100 / page</option>
</select>
</div>
{/* Visibility Filter */}
{/* <div className="relative">
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
<select
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-40 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={visibilityFilter}
onChange={(e) => setVisibilityFilter(e.target.value)}
>
<option value="all">All Visibility</option>
<option value="public">Public</option>
<option value="private">Private</option>
</select>
</div> */}
{/* City Filter */}
{/* <CityFilterSelect
value={cityFilter}
onChange={setCityFilter}
className="w-full sm:w-44"
placeholder="Search cities..."
allOptionLabel="All Cities"
/> */}
</div>
{/* Right side - Add Team Button */}
<div className="flex items-center gap-3">
<Button
variant="primary"
size="md"
className="flex items-center gap-2 px-4 py-2"
onClick={handleShowNewTeamModal}
>
<PlusOutlined className="text-sm" />
Add Team
</Button>
</div>
</div> </div>
{/* Table */}
<DataTable data={mockData} columns={columns} table={table} /> {/* Active filters display */}
{(visibilityFilter !== 'all' || cityFilter !== 'all') && (
<div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-neutral-600">Active filters:</span>
{/* Visibility filter badge */}
{visibilityFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
Visibility: {visibilityFilter}
<button
onClick={() => setVisibilityFilter('all')}
className="text-info-600 hover:text-info-800 cursor-pointer"
>
</button>
</span>
)}
{/* City filter badge */}
{cityFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
City: {cityFilter}
<button
onClick={() => setCityFilter('all')}
className="text-green-600 hover:text-green-800 cursor-pointer"
>
</button>
</span>
)}
{/* Clear all filters */}
<Button
variant="secondary"
size="sm"
onClick={() => {
setVisibilityFilter('all');
setCityFilter('all');
setGlobalFilter('');
}}
className="text-sm text-neutral-600"
>
Clear All
</Button>
</div>
)}
{/* Loading & results display */}
{isLoading ? (
<div className="flex items-center justify-center py-12">
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
<span className="ml-3 text-neutral-600">Loading teams...</span>
</div>
) : filteredData.length > 0 ? (
<>
<div className="text-sm text-neutral-600">
Showing {filteredData.length} of {totalData} teams (Page{' '}
{currentPage} of {totalPages})
{isFetching && (
<span className="ml-2 text-primary-500">(Updating...)</span>
)}
</div>
<DataTable
data={filteredData}
columns={columns}
pageSize={perPage}
manualPagination={true}
pageCount={totalPages}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</>
) : (
<div className="text-center py-12 text-neutral-500">
No teams found. Try adjusting your filters.
</div>
)}
</section> </section>
{/* Modals extracted into shared backoffice components */}
{/* Modals component */}
<ModalTeamDetail
isOpen={showDetailModal}
onClose={handleCloseDetailModal}
team={selectedTeam}
/>
{/* New Team Modal */}
<ModalTeamDetail
isOpen={showNewTeamModal}
onClose={handleCloseNewTeamModal}
team={null} // null indicates creating new team
/>
</BackofficeWrapper> </BackofficeWrapper>
); );
}; };
@@ -15,10 +15,10 @@ import {
interface UserType { interface UserType {
id: string; id: string;
avatar?: string; avatar?: string | null;
fullname: string; fullname: string;
bio?: string; bio?: string;
location: string; location: string | null;
is_active: boolean; is_active: boolean;
skills: string[]; skills: string[];
created_at: string; created_at: string;
@@ -77,7 +77,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
// Check if required fields are filled // Check if required fields are filled
const isFormValid = useMemo(() => { const isFormValid = useMemo(() => {
if (!formData) return false; if (!formData) return false;
return formData.fullname.trim() !== '' && formData.location.trim() !== ''; return formData.fullname?.trim() !== '' && formData.location?.trim() !== '';
}, [formData]); }, [formData]);
const canSave = hasChanges && isFormValid; const canSave = hasChanges && isFormValid;
@@ -259,13 +259,13 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
</div> </div>
</div> </div>
<button <button
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors" className="p-2 hover:bg-neutral-100 rounded-lg transition-colors cursor-pointer"
onClick={() => { onClick={() => {
setShowAvatarMenu(false); setShowAvatarMenu(false);
handleCancel(); handleCancel();
}} }}
> >
<CloseOutlined className="text-neutral-400 text-lg cursor-pointer" /> <CloseOutlined className="text-neutral-400 text-lg" />
</button> </button>
</div> </div>
@@ -294,13 +294,15 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
} }
className={cn( className={cn(
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none', 'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none',
formData.fullname.trim() === '' !formData.fullname ||
formData.fullname.trim() === ''
? 'border-red-300 bg-red-50' ? 'border-red-300 bg-red-50'
: 'border-neutral-300' : 'border-neutral-300'
)} )}
placeholder="Enter full name" placeholder="Enter full name"
/> />
{formData.fullname.trim() === '' && ( {(!formData.fullname ||
formData.fullname.trim() === '') && (
<p className="text-red-500 text-xs mt-1"> <p className="text-red-500 text-xs mt-1">
Full name is required Full name is required
</p> </p>
@@ -316,13 +318,14 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
Location <span className="text-red-500">*</span> Location <span className="text-red-500">*</span>
</label> </label>
<select <select
value={formData.location} value={formData.location || ''}
onChange={(e) => onChange={(e) =>
handleInputChange('location', e.target.value) handleInputChange('location', e.target.value)
} }
className={cn( className={cn(
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white', 'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white',
formData.location.trim() === '' !formData.location ||
formData.location.trim() === ''
? 'border-red-300 bg-red-50' ? 'border-red-300 bg-red-50'
: 'border-neutral-300' : 'border-neutral-300'
)} )}
@@ -334,7 +337,8 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
<option value="Medan">Medan</option> <option value="Medan">Medan</option>
<option value="Yogyakarta">Yogyakarta</option> <option value="Yogyakarta">Yogyakarta</option>
</select> </select>
{formData.location.trim() === '' && ( {(!formData.location ||
formData.location.trim() === '') && (
<p className="text-red-500 text-xs mt-1"> <p className="text-red-500 text-xs mt-1">
Location is required Location is required
</p> </p>
@@ -1,4 +1,11 @@
import { FC, ReactElement, useState, useMemo, useCallback } from 'react'; import {
FC,
ReactElement,
useState,
useEffect,
useMemo,
useCallback,
} from 'react';
import ModalUserDetail from './_components/modal-user-detail'; import ModalUserDetail from './_components/modal-user-detail';
import { import {
BackofficeWrapper, BackofficeWrapper,
@@ -13,23 +20,19 @@ import {
SearchOutlined, SearchOutlined,
FilterOutlined, FilterOutlined,
PlusOutlined, PlusOutlined,
LoadingOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
// Removed unused SearchOutlined icon after schema revision import { CityFilterSelect } from '../../../components/city-filter-select';
import { useQuery } from '@tanstack/react-query';
import {
getAdminUsers,
TAdminUserItem,
} from '@imphnen-frontend-service/service';
import { useSearchParams } from 'react-router-dom';
// Define interface outside component type UserType = TAdminUserItem;
interface UserType {
id: string; // UUID
avatar?: string;
fullname: string;
bio?: string;
location: string;
is_active: boolean; // admin can deactivate
skills: string[]; // Frontend Developer, Backend Developer, etc.
created_at: string;
updated_at: string;
}
// Move mock data outside component to prevent recreation // Skills options for filter
const skillsOptions = [ const skillsOptions = [
'Frontend Developer', 'Frontend Developer',
'Backend Developer', 'Backend Developer',
@@ -41,59 +44,108 @@ const skillsOptions = [
'Mobile Developer', 'Mobile Developer',
]; ];
const locations = ['Jakarta', 'Bandung', 'Surabaya', 'Medan', 'Yogyakarta'];
const bios = [
'Passionate developer with 5+ years experience',
'Tech enthusiast and problem solver',
'Building scalable solutions for modern problems',
'Creative designer with technical background',
'Data-driven decision maker',
];
const mockData: UserType[] = Array.from({ length: 50 }, (_, i) => {
const randomSkillsCount = Math.floor(Math.random() * 3) + 1; // 1-3 skills
const randomSkills = skillsOptions
.sort(() => 0.5 - Math.random())
.slice(0, randomSkillsCount);
return {
id: `24db9e4d-ca4c-46aa-ac36-8ef04bbe01${String(i).padStart(2, '0')}`,
avatar:
i % 4 === 0
? `https://ui-avatars.com/api/?name=${encodeURIComponent(
i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana'
)}&background=random`
: undefined,
fullname:
i % 3 === 0
? 'Ahmad Wijuana'
: i % 3 === 1
? 'Sofia Wijuana'
: 'Budi Santoso',
bio: i % 4 === 0 ? bios[i % bios.length] : undefined,
location: locations[i % locations.length],
is_active: i % 7 !== 0, // More realistic distribution
skills: randomSkills,
created_at: new Date(
Date.now() - i * 86400000 * (Math.random() * 30 + 1)
).toISOString(), // Random within last 30-60 days
updated_at: new Date().toISOString(),
};
});
export const HackathonUsersPage: FC = (): ReactElement => { export const HackathonUsersPage: FC = (): ReactElement => {
const [searchParams, setSearchParams] = useSearchParams();
const currentPage = Math.max(
1,
parseInt(searchParams.get('page') || '1', 10)
);
const searchQuery = searchParams.get('search') || '';
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
const [showDetailModal, setShowDetailModal] = useState(false); const [showDetailModal, setShowDetailModal] = useState(false);
const [showNewUserModal, setShowNewUserModal] = useState(false); const [showNewUserModal, setShowNewUserModal] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserType | null>(null); const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
const [globalFilter, setGlobalFilter] = useState(''); const [globalFilter, setGlobalFilter] = useState(searchQuery);
// Advanced filtering states // Advanced filtering states
const [statusFilter, setStatusFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all');
const [locationFilter, setLocationFilter] = useState('all'); const [cityFilter, setCityFilter] = useState('all');
const [skillsFilter, setSkillsFilter] = useState<string[]>([]); const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
// Constants // Fetch users from API
const pageSize = 10; const {
data: usersResponse,
isLoading,
isFetching,
} = useQuery({
queryKey: [
'admin-users',
currentPage,
perPage,
cityFilter,
statusFilter,
searchQuery,
],
queryFn: () =>
getAdminUsers({
page: currentPage,
per_page: perPage,
search: searchQuery || undefined,
}),
staleTime: 30000, // 30 seconds cache
gcTime: 5 * 60 * 1000, // 5 minutes
});
const totalData = usersResponse?.meta?.total_data || 0;
const totalPages = usersResponse?.meta?.total_page || 1;
// Handle page change - update URL query params
const handlePageChange = useCallback(
(newPage: number) => {
const params = new URLSearchParams();
params.set('page', newPage.toString());
if (perPage !== 10) params.set('per_page', perPage.toString());
if (searchQuery) params.set('search', searchQuery);
setSearchParams(params);
window.scrollTo({ top: 0, behavior: 'smooth' });
},
[setSearchParams, perPage, searchQuery]
);
// Validate page number doesn't exceed total pages
useEffect(() => {
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
setSearchParams({ page: totalPages.toString() });
}
}, [currentPage, totalPages, setSearchParams, isLoading]);
// Sync globalFilter with URL search param on mount
useEffect(() => {
setGlobalFilter(searchQuery);
}, [searchQuery]);
// Handle search users
const handleSearch = useCallback(() => {
const params = new URLSearchParams();
params.set('page', '1');
if (perPage !== 10) params.set('per_page', perPage.toString());
if (globalFilter.trim()) {
params.set('search', globalFilter.trim());
}
setSearchParams(params);
}, [globalFilter, setSearchParams, perPage]);
// Handle Enter key press in search input
const handleSearchKeyPress = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
},
[handleSearch]
);
// Handle per page change
const handlePerPageChange = useCallback(
(newPerPage: number) => {
const params = new URLSearchParams();
params.set('page', '1');
params.set('per_page', newPerPage.toString());
if (searchQuery) params.set('search', searchQuery);
setSearchParams(params);
},
[setSearchParams, searchQuery]
);
// Memoize the callback to prevent recreation // Memoize the callback to prevent recreation
const handleShowDetailModal = useCallback((user: UserType) => { const handleShowDetailModal = useCallback((user: UserType) => {
@@ -116,29 +168,26 @@ export const HackathonUsersPage: FC = (): ReactElement => {
// Filter data based on current filter states // Filter data based on current filter states
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
return mockData.filter((user) => { const usersData = usersResponse?.data || [];
return usersData.filter((user: UserType) => {
// Status filter // Status filter
if (statusFilter !== 'all') { if (statusFilter !== 'all') {
const isActive = statusFilter === 'active'; const isActive = statusFilter === 'active';
if (user.is_active !== isActive) return false; if (user.is_active !== isActive) return false;
} }
// Location filter
if (locationFilter !== 'all' && user.location !== locationFilter) {
return false;
}
// Skills filter // Skills filter
if (skillsFilter.length > 0) { if (skillsFilter.length > 0) {
const userSkills = user.skills || [];
const hasMatchingSkill = skillsFilter.some((skill) => const hasMatchingSkill = skillsFilter.some((skill) =>
user.skills.includes(skill) userSkills.includes(skill)
); );
if (!hasMatchingSkill) return false; if (!hasMatchingSkill) return false;
} }
return true; return true;
}); });
}, [statusFilter, locationFilter, skillsFilter]); }, [usersResponse, statusFilter, skillsFilter]);
// Memoize columns to prevent recreation on every render // Memoize columns to prevent recreation on every render
const columns: ColumnDef<UserType>[] = useMemo( const columns: ColumnDef<UserType>[] = useMemo(
@@ -173,23 +222,32 @@ export const HackathonUsersPage: FC = (): ReactElement => {
{ {
accessorKey: 'skills', accessorKey: 'skills',
header: 'Skills', header: 'Skills',
cell: ({ row }) => ( cell: ({ row }) => {
<div className="flex flex-wrap gap-1 max-w-xs"> const skills = row.original.skills || [];
{row.original.skills.slice(0, 2).map((skill, index) => ( return (
<span <div className="flex flex-wrap gap-1 max-w-xs">
key={index} {skills.length > 0 ? (
className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-100 text-success-800" <>
> {skills.slice(0, 2).map((skill, index) => (
{skill.replace(' Developer', '').replace(' Engineer', '')} <span
</span> key={index}
))} className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-100 text-success-800"
{row.original.skills.length > 2 && ( >
<span className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-200 text-success-700"> {skill.replace(' Developer', '').replace(' Engineer', '')}
+{row.original.skills.length - 2} </span>
</span> ))}
)} {skills.length > 2 && (
</div> <span className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-200 text-success-700">
), +{skills.length - 2}
</span>
)}
</>
) : (
<span className="text-neutral-400">-</span>
)}
</div>
);
},
enableSorting: false, enableSorting: false,
}, },
{ {
@@ -260,7 +318,7 @@ export const HackathonUsersPage: FC = (): ReactElement => {
<EditOutlined className="text-sm" /> <EditOutlined className="text-sm" />
Manage Manage
</Button> </Button>
<Button {/* <Button
variant="secondary" variant="secondary"
size="sm" size="sm"
className="text-sm px-4 py-2" className="text-sm px-4 py-2"
@@ -270,7 +328,7 @@ export const HackathonUsersPage: FC = (): ReactElement => {
}} }}
> >
{row.original.is_active ? 'Deactivate' : 'Activate'} {row.original.is_active ? 'Deactivate' : 'Activate'}
</Button> </Button> */}
</div> </div>
), ),
enableSorting: false, enableSorting: false,
@@ -298,11 +356,28 @@ export const HackathonUsersPage: FC = (): ReactElement => {
placeholder="Search users by name or location..." placeholder="Search users by name or location..."
value={globalFilter} value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)} onChange={(e) => setGlobalFilter(e.target.value)}
onKeyPress={handleSearchKeyPress}
/> />
</div> </div>
{/* Status Filter */} {/* Per Page Dropdown */}
<div className="relative"> <div className="relative">
<select
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={perPage}
onChange={(e) =>
handlePerPageChange(parseInt(e.target.value, 10))
}
>
<option value={10}>10 / page</option>
<option value={20}>20 / page</option>
<option value={50}>50 / page</option>
<option value={100}>100 / page</option>
</select>
</div>
{/* Status Filter */}
{/* <div className="relative">
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" /> <FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
<select <select
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-36 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer" className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-36 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
@@ -313,27 +388,30 @@ export const HackathonUsersPage: FC = (): ReactElement => {
<option value="active">Active</option> <option value="active">Active</option>
<option value="inactive">Inactive</option> <option value="inactive">Inactive</option>
</select> </select>
</div> </div> */}
{/* Location Filter */} {/* City Filter */}
<div className="relative"> {/* <CityFilterSelect
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" /> value={cityFilter}
<select onChange={setCityFilter}
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer" className="w-full sm:w-44"
value={locationFilter} placeholder="Search cities..."
onChange={(e) => setLocationFilter(e.target.value)} allOptionLabel="All Cities"
> />
<option value="all">All Locations</option> {cityFilter !== 'all' && (
{locations.map((location) => ( <span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
<option key={location} value={location}> Location: {cityFilter}
{location} <button
</option> onClick={() => setCityFilter('all')}
))} className="text-green-600 hover:text-green-800 cursor-pointer"
</select> >
</div>
</button>
</span>
)} */}
{/* Skills Filter with Icon */} {/* Skills Filter with Icon */}
<div className="relative"> {/* <div className="relative">
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" /> <FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
<select <select
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer" className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
@@ -358,7 +436,7 @@ export const HackathonUsersPage: FC = (): ReactElement => {
</option> </option>
))} ))}
</select> </select>
</div> </div> */}
</div> </div>
{/* Right side - Add User Button */} {/* Right side - Add User Button */}
@@ -378,7 +456,7 @@ export const HackathonUsersPage: FC = (): ReactElement => {
{/* Active filters display */} {/* Active filters display */}
{(skillsFilter.length > 0 || {(skillsFilter.length > 0 ||
statusFilter !== 'all' || statusFilter !== 'all' ||
locationFilter !== 'all') && ( cityFilter !== 'all') && (
<div className="flex flex-wrap gap-2 items-center"> <div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-neutral-600">Active filters:</span> <span className="text-sm text-neutral-600">Active filters:</span>
@@ -396,11 +474,11 @@ export const HackathonUsersPage: FC = (): ReactElement => {
)} )}
{/* Location filter badge */} {/* Location filter badge */}
{locationFilter !== 'all' && ( {cityFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm"> <span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
Location: {locationFilter} City: {cityFilter}
<button <button
onClick={() => setLocationFilter('all')} onClick={() => setCityFilter('all')}
className="text-green-600 hover:text-green-800 cursor-pointer" className="text-green-600 hover:text-green-800 cursor-pointer"
> >
@@ -432,7 +510,7 @@ export const HackathonUsersPage: FC = (): ReactElement => {
size="sm" size="sm"
onClick={() => { onClick={() => {
setStatusFilter('all'); setStatusFilter('all');
setLocationFilter('all'); setCityFilter('all');
setSkillsFilter([]); setSkillsFilter([]);
setGlobalFilter(''); setGlobalFilter('');
}} }}
@@ -443,17 +521,36 @@ export const HackathonUsersPage: FC = (): ReactElement => {
</div> </div>
)} )}
{/* Pagination-aware results display */} {/* Loading & results display */}
{filteredData.length > 0 && ( {isLoading ? (
<div className="text-sm text-neutral-600"> <div className="flex items-center justify-center py-12">
Showing {Math.min(pageSize, filteredData.length)} of{' '} <LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
{filteredData.length} users <span className="ml-3 text-neutral-600">Loading users...</span>
{filteredData.length > pageSize} </div>
) : filteredData.length > 0 ? (
<>
<div className="text-sm text-neutral-600">
Showing {filteredData.length} of {totalData} users (Page{' '}
{currentPage} of {totalPages})
{isFetching && (
<span className="ml-2 text-primary-500">(Updating...)</span>
)}
</div>
<DataTable
data={filteredData}
columns={columns}
pageSize={perPage}
manualPagination={true}
pageCount={totalPages}
currentPage={currentPage}
onPageChange={handlePageChange}
/>
</>
) : (
<div className="text-center py-12 text-neutral-500">
No users found. Try adjusting your filters.
</div> </div>
)} )}
{/* Table */}
<DataTable data={filteredData} columns={columns} pageSize={10} />
</section> </section>
{/* Modals component */} {/* Modals component */}
@@ -23,7 +23,7 @@ export const AppLayout: FC = (): ReactElement => {
{/* Mobile menu button (shown on small screens) */} {/* Mobile menu button (shown on small screens) */}
<button <button
type="button" type="button"
className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700" className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700 cursor-pointer"
onClick={() => setMobileSidebarOpen(true)} onClick={() => setMobileSidebarOpen(true)}
aria-label="Open sidebar" aria-label="Open sidebar"
> >
@@ -2,22 +2,39 @@ import { useForm } from 'react-hook-form';
import { import {
authLoginSchema, authLoginSchema,
TLoginRequest, TLoginRequest,
useBackofficeLogin,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { useSession } from '@imphnen-frontend-service/utils'; import { useNavigate } from 'react-router';
import { toast } from 'sonner';
export const useLogin = () => { export const useLogin = () => {
const navigate = useNavigate();
const loginMutation = useBackofficeLogin();
const form = useForm<TLoginRequest>({ const form = useForm<TLoginRequest>({
resolver: zodResolver(authLoginSchema), resolver: zodResolver(authLoginSchema),
mode: 'all', mode: 'all',
defaultValues: {
email: '',
password: '',
},
}); });
const { signIn } = useSession(); const onSubmit = form.handleSubmit(async (data) => {
try {
const onSubmit = form.handleSubmit((data) => signIn(data)); await loginMutation.mutateAsync(data);
toast.success('Login berhasil!');
navigate('/hackathon-dashboard');
} catch (error) {
console.error('[Backoffice Login] Error:', error);
toast.error((error as Error).message || 'Login gagal');
}
});
return { return {
form, form,
onSubmit, onSubmit,
isLoading: loginMutation.isPending,
}; };
}; };
@@ -4,7 +4,7 @@ import { useLogin } from './_hooks/use-login';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms'; import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
export const Components: FC = (): ReactElement => { export const Components: FC = (): ReactElement => {
const { form, onSubmit } = useLogin(); const { form, onSubmit, isLoading } = useLogin();
return ( return (
<div className="flex justify-center items-center min-h-screen"> <div className="flex justify-center items-center min-h-screen">
@@ -22,6 +22,7 @@ export const Components: FC = (): ReactElement => {
name="email" name="email"
size="lg" size="lg"
className="w-full" className="w-full"
disabled={isLoading}
/> />
<ControlledInputField <ControlledInputField
control={form.control} control={form.control}
@@ -31,10 +32,11 @@ export const Components: FC = (): ReactElement => {
name="password" name="password"
size="lg" size="lg"
className="w-full" className="w-full"
disabled={isLoading}
/> />
<Button <Button
disabled={ disabled={
form.formState.isSubmitting || isLoading ||
form.formState.isValidating || form.formState.isValidating ||
!form.formState.isValid !form.formState.isValid
} }
@@ -42,7 +44,7 @@ export const Components: FC = (): ReactElement => {
size="md" size="md"
className="w-full" className="w-full"
> >
Login {isLoading ? 'Loading...' : 'Login'}
</Button> </Button>
</form> </form>
</div> </div>
+23
View File
@@ -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 dark:bg-gray-950">
<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 dark:text-gray-300 mb-4">
Page Not Found
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">
The page you are looking for doesn't exist or has been moved.
</p>
<Link
to="/hackathon-dashboard"
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>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
export default function ErrorPage() {
const error = useRouteError();
let errorMessage: string;
if (isRouteErrorResponse(error)) {
errorMessage = error.statusText;
} else if (error instanceof Error) {
errorMessage = error.message;
} else if (typeof error === 'string') {
errorMessage = error;
} else {
console.error(error);
errorMessage = 'Unknown error';
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-6xl font-bold text-red-600 mb-4">Oops!</h1>
<p className="text-xl text-gray-700 mb-2">
Sorry, an unexpected error has occurred.
</p>
<p className="text-gray-500 italic">{errorMessage}</p>
</div>
</div>
);
}
@@ -0,0 +1,148 @@
import { FC, useState, useRef, useEffect } from 'react';
import { FilterOutlined } from '@ant-design/icons';
import INDONESIAN_CITIES from '../constants/cities';
interface CityFilterSelectProps {
value: string;
onChange: (value: string) => void;
className?: string;
placeholder?: string;
allOptionLabel?: string;
filterIcon?: boolean;
}
export const CityFilterSelect: FC<CityFilterSelectProps> = ({
value,
onChange,
className = '',
placeholder = 'Search cities...',
allOptionLabel = 'All Cities',
filterIcon = true,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Filter cities based on search query
const filteredCities = INDONESIAN_CITIES.filter((city) =>
city.toLowerCase().includes(searchQuery.toLowerCase())
);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
setSearchQuery('');
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelectCity = (city: string) => {
onChange(city);
setSearchQuery('');
setIsOpen(false);
};
const handleInputClick = () => {
setIsOpen(true);
setSearchQuery('');
};
const handleClearSelection = () => {
onChange('all');
setSearchQuery('');
setIsOpen(false);
};
const displayValue = value === 'all' ? allOptionLabel : value;
const showClearButton = value !== 'all' && !isOpen;
return (
<div className={`relative ${className}`} ref={dropdownRef}>
<div className="relative">
{filterIcon && (
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
)}
<input
ref={inputRef}
type="text"
value={isOpen ? searchQuery : displayValue}
onChange={(e) => {
setSearchQuery(e.target.value);
if (!isOpen) setIsOpen(true);
}}
onClick={handleInputClick}
onFocus={handleInputClick}
placeholder={isOpen ? placeholder : displayValue}
className={`border border-neutral-200 rounded-lg pr-10 py-2.5 text-sm w-full focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer ${
filterIcon ? ' pl-10' : 'pl-3'
}`}
/>
{showClearButton && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleClearSelection();
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 text-xs cursor-pointer z-20"
>
</button>
)}
</div>
{isOpen && (
<div className="absolute z-50 w-full mt-1 bg-white border border-neutral-200 rounded-lg shadow-lg max-h-60 overflow-y-auto">
{/* All Cities Option */}
<div
onClick={() => handleSelectCity('all')}
className={`px-3 py-2 cursor-pointer hover:bg-neutral-50 border-b border-neutral-100 ${
value === 'all'
? 'bg-primary-50 text-primary-700 font-medium'
: 'text-neutral-900'
}`}
>
{allOptionLabel}
</div>
{/* Filtered Cities */}
{filteredCities.length > 0 ? (
<div className="py-1">
{filteredCities.slice(0, 100).map((city) => (
<div
key={city}
onClick={() => handleSelectCity(city)}
className={`px-3 py-2 cursor-pointer hover:bg-neutral-50 text-sm ${
value === city
? 'bg-primary-50 text-primary-700 font-medium'
: 'text-neutral-700'
}`}
>
{city}
</div>
))}
{filteredCities.length > 100 && (
<div className="px-3 py-2 text-xs text-neutral-500 border-t border-neutral-100">
Showing first 100 results. Continue typing to refine...
</div>
)}
</div>
) : searchQuery ? (
<div className="px-3 py-2 text-neutral-500 text-sm">
No cities found matching "{searchQuery}"
</div>
) : null}
</div>
)}
</div>
);
};
+518
View File
@@ -0,0 +1,518 @@
const INDONESIAN_CITIES: string[] = [
'Aceh Selatan',
'Aceh Tenggara',
'Aceh Timur',
'Aceh Tengah',
'Aceh Barat',
'Aceh Besar',
'Pidie',
'Aceh Utara',
'Simeulue',
'Aceh Singkil',
'Bireuen',
'Aceh Barat Daya',
'Gayo Lues',
'Aceh Jaya',
'Nagan Raya',
'Aceh Tamiang',
'Bener Meriah',
'Pidie Jaya',
'Kota Banda Aceh',
'Kota Sabang',
'Kota Lhokseumawe',
'Kota Langsa',
'Kota Subulussalam',
'Tapanuli Tengah',
'Tapanuli Utara',
'Tapanuli Selatan',
'Nias',
'Langkat',
'Karo',
'Deli Serdang',
'Simalungun',
'Asahan',
'Labuhanbatu',
'Dairi',
'Toba',
'Mandailing Natal',
'Nias Selatan',
'Pakpak Bharat',
'Humbang Hasundutan',
'Samosir',
'Serdang Bedagai',
'Batu Bara',
'Padang Lawas Utara',
'Padang Lawas',
'Labuhanbatu Selatan',
'Labuhanbatu Utara',
'Nias Utara',
'Nias Barat',
'Kota Medan',
'Kota Pematangsiantar',
'Kota Sibolga',
'Kota Tanjung Balai',
'Kota Binjai',
'Kota Tebing Tinggi',
'Kota Padangsidimpuan',
'Kota Gunungsitoli',
'Pesisir Selatan',
'Solok',
'Sijunjung',
'Tanah Datar',
'Padang Pariaman',
'Agam',
'Lima Puluh Kota',
'Pasaman',
'Kepulauan Mentawai',
'Dharmasraya',
'Solok Selatan',
'Pasaman Barat',
'Kota Padang',
'Kota Solok',
'Kota Sawahlunto',
'Kota Padang Panjang',
'Kota Bukittinggi',
'Kota Payakumbuh',
'Kota Pariaman',
'Kampar',
'Indragiri Hulu',
'Bengkalis',
'Indragiri Hilir',
'Pelalawan',
'Rokan Hulu',
'Rokan Hilir',
'Siak',
'Kuantan Singingi',
'Kepulauan Meranti',
'Kota Pekanbaru',
'Kota Dumai',
'Kerinci',
'Merangin',
'Sarolangun',
'Batanghari',
'Muaro Jambi',
'Tanjung Jabung Barat',
'Tanjung Jabung Timur',
'Bungo',
'Tebo',
'Kota Jambi',
'Kota Sungai Penuh',
'Ogan Komering Ulu',
'Ogan Komering Ilir',
'Muara Enim',
'Lahat',
'Musi Rawas',
'Musi Banyuasin',
'Banyuasin',
'Ogan Komering Ulu Timur',
'Ogan Komering Ulu Selatan',
'Ogan Ilir',
'Empat Lawang',
'Penukal Abab Lematang Ilir',
'Musi Rawas Utara',
'Kota Palembang',
'Kota Pagar Alam',
'Kota Lubuk Linggau',
'Kota Prabumulih',
'Bengkulu Selatan',
'Rejang Lebong',
'Bengkulu Utara',
'Kaur',
'Seluma',
'Muko Muko',
'Lebong',
'Kepahiang',
'Bengkulu Tengah',
'Kota Bengkulu',
'Lampung Selatan',
'Lampung Tengah',
'Lampung Utara',
'Lampung Barat',
'Tulang Bawang',
'Tanggamus',
'Lampung Timur',
'Way Kanan',
'Pesawaran',
'Pringsewu',
'Mesuji',
'Tulang Bawang Barat',
'Pesisir Barat',
'Kota Bandar Lampung',
'Kota Metro',
'Bangka',
'Belitung',
'Bangka Selatan',
'Bangka Tengah',
'Bangka Barat',
'Belitung Timur',
'Kota Pangkal Pinang',
'Bintan',
'Karimun',
'Natuna',
'Lingga',
'Kepulauan Anambas',
'Kota Batam',
'Kota Tanjung Pinang',
'Kepulauan Seribu',
'Kota Jakarta Pusat',
'Kota Jakarta Utara',
'Kota Jakarta Barat',
'Kota Jakarta Selatan',
'Kota Jakarta Timur',
'Bogor',
'Sukabumi',
'Cianjur',
'Bandung',
'Garut',
'Tasikmalaya',
'Ciamis',
'Kuningan',
'Cirebon',
'Majalengka',
'Sumedang',
'Indramayu',
'Subang',
'Purwakarta',
'Karawang',
'Bekasi',
'Bandung Barat',
'Pangandaran',
'Kota Bogor',
'Kota Sukabumi',
'Kota Bandung',
'Kota Cirebon',
'Kota Bekasi',
'Kota Depok',
'Kota Cimahi',
'Kota Tasikmalaya',
'Kota Banjar',
'Cilacap',
'Banyumas',
'Purbalingga',
'Banjarnegara',
'Kebumen',
'Purworejo',
'Wonosobo',
'Magelang',
'Boyolali',
'Klaten',
'Sukoharjo',
'Wonogiri',
'Karanganyar',
'Sragen',
'Grobogan',
'Blora',
'Rembang',
'Pati',
'Kudus',
'Jepara',
'Demak',
'Semarang',
'Temanggung',
'Kendal',
'Batang',
'Pekalongan',
'Pemalang',
'Tegal',
'Brebes',
'Kota Magelang',
'Kota Surakarta',
'Kota Salatiga',
'Kota Semarang',
'Kota Pekalongan',
'Kota Tegal',
'Kulon Progo',
'Bantul',
'Gunungkidul',
'Sleman',
'Kota Yogyakarta',
'Pacitan',
'Ponorogo',
'Trenggalek',
'Tulungagung',
'Blitar',
'Kediri',
'Malang',
'Lumajang',
'Jember',
'Banyuwangi',
'Bondowoso',
'Situbondo',
'Probolinggo',
'Pasuruan',
'Sidoarjo',
'Mojokerto',
'Jombang',
'Nganjuk',
'Madiun',
'Magetan',
'Ngawi',
'Bojonegoro',
'Tuban',
'Lamongan',
'Gresik',
'Bangkalan',
'Sampang',
'Pamekasan',
'Sumenep',
'Kota Kediri',
'Kota Blitar',
'Kota Malang',
'Kota Probolinggo',
'Kota Pasuruan',
'Kota Mojokerto',
'Kota Madiun',
'Kota Surabaya',
'Kota Batu',
'Pandeglang',
'Lebak',
'Tangerang',
'Serang',
'Kota Tangerang',
'Kota Cilegon',
'Kota Serang',
'Kota Tangerang Selatan',
'Jembrana',
'Tabanan',
'Badung',
'Gianyar',
'Klungkung',
'Bangli',
'Karangasem',
'Buleleng',
'Kota Denpasar',
'Lombok Barat',
'Lombok Tengah',
'Lombok Timur',
'Sumbawa',
'Dompu',
'Bima',
'Sumbawa Barat',
'Lombok Utara',
'Kota Mataram',
'Kota Bima',
'Kupang',
'Timor Tengah Selatan',
'Timor Tengah Utara',
'Belu',
'Alor',
'Flores Timur',
'Sikka',
'Ende',
'Ngada',
'Manggarai',
'Sumba Timur',
'Sumba Barat',
'Lembata',
'Rote Ndao',
'Manggarai Barat',
'Nagekeo',
'Sumba Tengah',
'Sumba Barat Daya',
'Manggarai Timur',
'Sabu Raijua',
'Malaka',
'Kota Kupang',
'Sambas',
'Mempawah',
'Sanggau',
'Ketapang',
'Sintang',
'Kapuas Hulu',
'Bengkayang',
'Landak',
'Sekadau',
'Melawi',
'Kayong Utara',
'Kubu Raya',
'Kota Pontianak',
'Kota Singkawang',
'Kotawaringin Barat',
'Kotawaringin Timur',
'Kapuas',
'Barito Selatan',
'Barito Utara',
'Katingan',
'Seruyan',
'Sukamara',
'Lamandau',
'Gunung Mas',
'Pulang Pisau',
'Murung Raya',
'Barito Timur',
'Kota Palangkaraya',
'Tanah Laut',
'Kotabaru',
'Banjar',
'Barito Kuala',
'Tapin',
'Hulu Sungai Selatan',
'Hulu Sungai Tengah',
'Hulu Sungai Utara',
'Tabalong',
'Tanah Bumbu',
'Balangan',
'Kota Banjarmasin',
'Kota Banjarbaru',
'Paser',
'Kutai Kartanegara',
'Berau',
'Kutai Barat',
'Kutai Timur',
'Penajam Paser Utara',
'Mahakam Ulu',
'Kota Balikpapan',
'Kota Samarinda',
'Kota Bontang',
'Bulungan',
'Malinau',
'Nunukan',
'Tana Tidung',
'Kota Tarakan',
'Bolaang Mongondow',
'Minahasa',
'Kepulauan Sangihe',
'Kepulauan Talaud',
'Minahasa Selatan',
'Minahasa Utara',
'Minahasa Tenggara',
'Bolaang Mongondow Utara',
'Kepulauan Siau Tagulandang Biaro (Sitaro)',
'Bolaang Mongondow Timur',
'Bolaang Mongondow Selatan',
'Kota Manado',
'Kota Bitung',
'Kota Tomohon',
'Kota Kotamobagu',
'Banggai',
'Poso',
'Donggala',
'Toli Toli',
'Buol',
'Morowali',
'Banggai Kepulauan',
'Parigi Moutong',
'Tojo Una Una',
'Sigi',
'Banggai Laut',
'Morowali Utara',
'Kota Palu',
'Kepulauan Selayar',
'Bulukumba',
'Bantaeng',
'Jeneponto',
'Takalar',
'Gowa',
'Sinjai',
'Bone',
'Maros',
'Pangkajene Kepulauan',
'Barru',
'Soppeng',
'Wajo',
'Sidenreng Rappang',
'Pinrang',
'Enrekang',
'Luwu',
'Tana Toraja',
'Luwu Utara',
'Luwu Timur',
'Toraja Utara',
'Kota Makassar',
'Kota Pare Pare',
'Kota Palopo',
'Kolaka',
'Konawe',
'Muna',
'Buton',
'Konawe Selatan',
'Bombana',
'Wakatobi',
'Kolaka Utara',
'Konawe Utara',
'Buton Utara',
'Kolaka Timur',
'Konawe Kepulauan',
'Muna Barat',
'Buton Tengah',
'Buton Selatan',
'Kota Kendari',
'Kota Bau Bau',
'Gorontalo',
'Boalemo',
'Bone Bolango',
'Pahuwato',
'Gorontalo Utara',
'Kota Gorontalo',
'Pasangkayu (Mamuju Utara)',
'Mamuju',
'Mamasa',
'Polewali Mandar',
'Majene',
'Mamuju Tengah',
'Maluku Tengah',
'Maluku Tenggara',
'Kepulauan Tanimbar (Maluku Tenggara Barat)',
'Buru',
'Seram Bagian Timur',
'Seram Bagian Barat',
'Kepulauan Aru',
'Maluku Barat Daya',
'Buru Selatan',
'Kota Ambon',
'Kota Tual',
'Halmahera Barat',
'Halmahera Tengah',
'Halmahera Utara',
'Halmahera Selatan',
'Kepulauan Sula',
'Halmahera Timur',
'Pulau Morotai',
'Pulau Taliabu',
'Kota Ternate',
'Kota Tidore Kepulauan',
'Jayapura',
'Kepulauan Yapen',
'Biak Numfor',
'Sarmi',
'Keerom',
'Waropen',
'Supiori',
'Mamberamo Raya',
'Kota Jayapura',
'Manokwari',
'Fak Fak',
'Teluk Bintuni',
'Teluk Wondama',
'Kaimana',
'Manokwari Selatan',
'Pegunungan Arfak',
'Merauke',
'Boven Digoel',
'Mappi',
'Asmat',
'Nabire',
'Puncak Jaya',
'Paniai',
'Mimika',
'Puncak',
'Dogiyai',
'Intan Jaya',
'Deiyai',
'Jayawijaya',
'Pegunungan Bintang',
'Yahukimo',
'Tolikara',
'Mamberamo Tengah',
'Yalimo',
'Lanny Jaya',
'Nduga',
'Sorong',
'Sorong Selatan',
'Raja Ampat',
'Tambrauw',
'Maybrat',
'Kota Sorong',
];
export default INDONESIAN_CITIES;
+3 -3
View File
@@ -84,11 +84,11 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
session?.role?.permissions?.map?.((perm) => perm?.name) ?? []; session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
if (mappingPublicRoutes.includes(pathname)) { if (mappingPublicRoutes.includes(pathname)) {
if (token) return redirect('/dashboard'); if (token) return redirect('/hackathon-dashboard');
return null; return null;
} }
// if (!session) return redirect('/auth/login'); if (!session) return redirect('/auth/login');
const matchedRoute = mappingRoutePermissions.find( const matchedRoute = mappingRoutePermissions.find(
(route) => route.path === pathname (route) => route.path === pathname
@@ -100,7 +100,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
matchedRoute.permissions.some((perm) => userPermissions.includes(perm)); matchedRoute.permissions.some((perm) => userPermissions.includes(perm));
if (!hasPermission) { if (!hasPermission) {
return '/dashboard'; return '/hackathon-dashboard';
} }
} }
+1 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig(() => ({
root: __dirname, root: __dirname,
cacheDir: '../../node_modules/.vite/apps/backoffice', cacheDir: '../../node_modules/.vite/apps/backoffice',
server: { server: {
port: 3000, port: 3003,
host: 'localhost', host: 'localhost',
}, },
preview: { preview: {
+3
View File
@@ -18,6 +18,9 @@
type="image/svg+xml" type="image/svg+xml"
href="/images/imphnen-logo-simple.svg" href="/images/imphnen-logo-simple.svg"
/> />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@400;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/src/index.css" /> <link rel="stylesheet" href="/src/index.css" />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:url" content="https://hackathon.imphnen.dev/" /> <meta property="og:url" content="https://hackathon.imphnen.dev/" />
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 263 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 506 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 818 KiB

@@ -3,8 +3,7 @@ import { useParams, useNavigate } from 'react-router';
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { decodeCertificateId } from '../../../utils/certificate'; import { decodeCertificateId } from '../../../utils/certificate';
import { import {
useTeamById, useCertificatePublicData,
useTeamSubmission,
useAuthStore, useAuthStore,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import QRCode from 'qrcode'; import QRCode from 'qrcode';
@@ -13,6 +12,7 @@ import html2canvas from 'html2canvas';
interface DecodedCert { interface DecodedCert {
teamId: string; teamId: string;
submissionId: string; submissionId: string;
userId: string;
} }
const CertificatePage: FC = (): ReactElement => { const CertificatePage: FC = (): ReactElement => {
@@ -41,10 +41,16 @@ const CertificatePage: FC = (): ReactElement => {
} }
}, [certId]); }, [certId]);
// Fetch certificate data using the new endpoint
const { data: certificateData, isLoading: isLoadingCertificate } =
useCertificatePublicData(decodedInfo?.userId || '', !!decodedInfo?.userId);
// Generate QR Code // Generate QR Code
useEffect(() => { useEffect(() => {
if (certId) { if (certId) {
const certificateUrl = `${window.location.origin}/certificate/${certId}`; // Use encodeURIComponent to properly encode the certId for the URL
const encodedCertId = encodeURIComponent(certId);
const certificateUrl = `${window.location.origin}/certificate/${encodedCertId}`;
QRCode.toDataURL(certificateUrl, { QRCode.toDataURL(certificateUrl, {
width: 200, width: 200,
margin: 1, margin: 1,
@@ -58,18 +64,18 @@ const CertificatePage: FC = (): ReactElement => {
} }
}, [certId]); }, [certId]);
const { data: teamData, isLoading: isLoadingTeam } = useTeamById( const certificate = certificateData?.data;
decodedInfo?.teamId || '', const team = certificate?.team;
!!decodedInfo?.teamId const submission = certificate?.submission;
); const certificateUser = certificate?.user;
const { data: submissionData, isLoading: isLoadingSubmission } =
useTeamSubmission(decodedInfo?.teamId || '', !!decodedInfo?.teamId);
const team = teamData?.data; const isLoading = (!decodedInfo && !error) || isLoadingCertificate;
const submission = submissionData?.data;
const isLoading = // Certificate name from the user data
(!decodedInfo && !error) || isLoadingTeam || isLoadingSubmission; const certificateName = certificateUser?.fullname;
// Check if current user is viewing their own certificate (team member)
const isTeamMember = session?.user?.id === decodedInfo?.userId;
// Dynamic font sizing: shrink by 2px if height exceeds 80px // Dynamic font sizing: shrink by 2px if height exceeds 80px
useEffect(() => { useEffect(() => {
@@ -98,7 +104,7 @@ const CertificatePage: FC = (): ReactElement => {
}, 0); }, 0);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [team?.name, session?.user?.fullname]); }, [team?.name, certificateName]);
// Generate certificate canvas screenshot // Generate certificate canvas screenshot
useEffect(() => { useEffect(() => {
@@ -107,19 +113,22 @@ const CertificatePage: FC = (): ReactElement => {
setIsGenerating(true); setIsGenerating(true);
try { try {
// Wait a bit for fonts and images to load // Wait longer for fonts and images to load properly
await new Promise((resolve) => setTimeout(resolve, 500)); await new Promise((resolve) => setTimeout(resolve, 1500));
const canvas = await html2canvas(certificateRef.current, { const canvas = await html2canvas(certificateRef.current, {
scale: 2, scale: 4,
useCORS: true, useCORS: true,
backgroundColor: '#ffffff', backgroundColor: '#ffffff',
logging: false, logging: false,
width: 1000, width: 1000,
height: (1000 * 2480) / 3508, height: (1000 * 2480) / 3508,
allowTaint: true,
imageTimeout: 0,
removeContainer: true,
}); });
const imageUrl = canvas.toDataURL('image/png'); const imageUrl = canvas.toDataURL('image/png', 1.0);
setCertificateImage(imageUrl); setCertificateImage(imageUrl);
setShowTemplate(false); setShowTemplate(false);
} catch (error) { } catch (error) {
@@ -130,7 +139,7 @@ const CertificatePage: FC = (): ReactElement => {
}; };
generateCertificate(); generateCertificate();
}, [team, submission, qrCodeUrl, session?.user?.fullname]); }, [team, submission, qrCodeUrl]);
// Download certificate // Download certificate
const handleDownloadCertificate = () => { const handleDownloadCertificate = () => {
@@ -202,7 +211,7 @@ const CertificatePage: FC = (): ReactElement => {
); );
} }
if (!submission || submission.id !== decodedInfo?.submissionId) { if (!certificateUser) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-6xl mb-4">📄</div> <div className="text-6xl mb-4">📄</div>
@@ -210,7 +219,7 @@ const CertificatePage: FC = (): ReactElement => {
Certificate Not Found Certificate Not Found
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400 mb-6"> <p className="text-gray-600 dark:text-gray-400 mb-6">
The submission associated with this certificate could not be found. The user associated with this certificate could not be found.
</p> </p>
<Button onClick={() => navigate('/')}>Back to Home</Button> <Button onClick={() => navigate('/')}>Back to Home</Button>
</div> </div>
@@ -278,14 +287,14 @@ const CertificatePage: FC = (): ReactElement => {
{team?.name} {team?.name}
</p> </p>
</div> </div>
<Button {team && isTeamMember && (
variant="secondary" <Button
onClick={() => variant="secondary"
navigate(`/teams/${decodedInfo?.teamId}/submission`) onClick={() => navigate(`/teams/${team.id}/submission`)}
} >
> Back to Submission
Back to Submission </Button>
</Button> )}
</div> </div>
</div> </div>
</div> </div>
@@ -305,57 +314,30 @@ const CertificatePage: FC = (): ReactElement => {
id="certificate-template" id="certificate-template"
style={{ style={{
position: 'relative', position: 'relative',
backgroundImage: 'url(/images/blank_cert.png)', backgroundImage: 'url(/images/blank_cert.svg)',
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center', backgroundPosition: 'center',
width: '1000px', width: '1000px',
height: `${(1000 * 2480) / 3508}px`, height: `${(1000 * 2480) / 3508}px`,
}} }}
> >
{/* User Name (from session) */} {/* Team Name - positioned in middle between "Diberikan Kepada" and "Telah Berpartisipasi" */}
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
top: '40%', top: '41%',
left: '50%', left: '3.5%',
transform: 'translateX(-50%)', width: '55%',
width: '80%',
}}
>
<h3
ref={userNameRef}
style={{
fontWeight: 'bold',
color: '#111827',
textAlign: 'center',
fontSize: userNameFontSize,
lineHeight: '1.2',
wordBreak: 'break-word',
textShadow: '0 1px 2px rgba(0,0,0,0.1)',
margin: 0,
}}
>
{session?.user?.fullname || 'N/A'}
</h3>
</div>
{/* Team Name */}
<div
style={{
position: 'absolute',
top: '46%',
left: '50%',
transform: 'translateX(-50%)',
width: '70%',
}} }}
> >
<h3 <h3
ref={teamNameRef} ref={teamNameRef}
style={{ style={{
fontWeight: '600', fontFamily: 'Poppins, sans-serif',
color: '#1f2937', fontWeight: 'bold',
textAlign: 'center', color: '#59bef5',
fontSize: teamNameFontSize, textAlign: 'left',
fontSize: '32px',
lineHeight: '1.2', lineHeight: '1.2',
wordBreak: 'break-word', wordBreak: 'break-word',
margin: 0, margin: 0,
@@ -365,84 +347,53 @@ const CertificatePage: FC = (): ReactElement => {
</h3> </h3>
</div> </div>
{/* Participation Text */} {/* User Name - positioned below team name */}
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
top: '60%', top: '45%',
left: '50%', left: '3.5%',
transform: 'translateX(-50%)', width: '55%',
width: '70%',
}} }}
> >
<p <h3
ref={userNameRef}
style={{ style={{
textAlign: 'center', fontFamily: 'Poppins, sans-serif',
color: '#374151', fontWeight: 'bold',
fontSize: '18px', color: '#59bef5',
fontWeight: '500', textAlign: 'left',
fontSize: '40px',
lineHeight: '1.2',
wordBreak: 'break-word',
margin: 0, margin: 0,
}} }}
> >
<span style={{ fontWeight: 'bold' }}> {certificateName || 'N/A'}
Peserta Hackathon IMPHNEN x KOLOSAL AI </h3>
</span>
</p>
</div> </div>
{/* QR Code */} {/* QR Code - positioned in the white box area */}
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
bottom: '8%', top: '33%',
left: '8%', right: '9.3%',
width: '190px',
height: '190px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}} }}
> >
{qrCodeUrl && ( {qrCodeUrl && (
<div <img
style={{ src={qrCodeUrl}
backgroundColor: '#ffffff', alt="Certificate QR Code"
padding: '8px', style={{ width: '190px', height: '190px', display: 'block' }}
borderRadius: '4px', />
boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
}}
>
<img
src={qrCodeUrl}
alt="Certificate QR Code"
style={{ width: '96px', height: '96px' }}
/>
</div>
)} )}
</div> </div>
{/* Date */}
<div
style={{
position: 'absolute',
bottom: '8%',
right: '8%',
}}
>
<p
style={{
fontSize: '14px',
color: '#374151',
margin: 0,
}}
>
{submission.submitted_at
? new Date(submission.submitted_at).toLocaleDateString(
'id-ID',
{
day: 'numeric',
month: 'long',
year: 'numeric',
}
)
: 'N/A'}
</p>
</div>
</div> </div>
</div> </div>
@@ -469,33 +420,35 @@ const CertificatePage: FC = (): ReactElement => {
)} )}
{/* Actions */} {/* Actions */}
<div className="bg-gray-50 dark:bg-gray-800 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print"> {isTeamMember && (
<Button <div className="bg-gray-50 dark:bg-gray-900 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
variant="secondary" <Button
onClick={handleDownloadCertificate} variant="secondary"
className="flex items-center gap-2" onClick={handleDownloadCertificate}
disabled={isGenerating} className="flex items-center gap-2"
> disabled={isGenerating}
{isGenerating ? '⏳ Generating...' : '📥 Download'} >
</Button> {isGenerating ? '⏳ Generating...' : '📥 Download'}
<Button </Button>
variant="secondary" <Button
onClick={handlePrintCertificate} variant="secondary"
className="flex items-center gap-2" onClick={handlePrintCertificate}
disabled={isGenerating} className="flex items-center gap-2"
> disabled={isGenerating}
{isGenerating ? '⏳ Generating...' : '🖨️ Print'} >
</Button> {isGenerating ? '⏳ Generating...' : '🖨️ Print'}
<Button </Button>
onClick={() => {team && (
navigate(`/teams/${decodedInfo?.teamId}/submission`) <Button
} onClick={() => navigate(`/teams/${team.id}/submission`)}
variant="secondary" variant="secondary"
className="col-span-2 flex items-center gap-2 xl:col-span-1" className="col-span-2 flex items-center gap-2 xl:col-span-1"
> >
View Submission View Submission
</Button> </Button>
</div> )}
</div>
)}
</div> </div>
{/* Info Box */} {/* Info Box */}
@@ -0,0 +1,605 @@
import { FC, ReactElement, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Icon } from '@iconify/react';
import { decodeWinnerCertificateId } from '../../../../utils/certificate';
import {
useAuthStore,
useMyTeams,
useTeamById,
useTeamSubmission,
useWinners,
} from '@imphnen-frontend-service/service';
import QRCode from 'qrcode';
import html2canvas from 'html2canvas';
type WinnerEntry = {
team_id: string;
rank: number;
team?: {
id: string;
name: string;
};
};
const formatOrdinalRank = (rank: number): string => {
const mod100 = rank % 100;
if (mod100 >= 11 && mod100 <= 13) return `${rank}th`;
switch (rank % 10) {
case 1:
return `${rank}st`;
case 2:
return `${rank}nd`;
case 3:
return `${rank}rd`;
default:
return `${rank}th`;
}
};
// Keep the template size aligned with the SVG native size (842x595) using an integer multiplier.
// This reduces sub-pixel scaling artifacts (blur) on thin lines when rasterizing with html2canvas.
const CERT_WIDTH = 842 * 2;
const CERT_HEIGHT = 595 * 2;
// Balance between output sharpness and file size.
// Output resolution will be (CERT_WIDTH * EXPORT_SCALE) x (CERT_HEIGHT * EXPORT_SCALE).
const EXPORT_SCALE = 2;
// The original layout was tuned around a ~1000px-wide template.
// We keep the same visual proportions by scaling fixed pixel values.
const LAYOUT_BASE_WIDTH = 1000;
const LAYOUT_SCALE = CERT_WIDTH / LAYOUT_BASE_WIDTH;
const s = (px: number) => Math.round(px * LAYOUT_SCALE);
const CertificateWinnerPage: FC = (): ReactElement => {
const { certId } = useParams<{ certId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const { data: myTeamsData } = useMyTeams();
const {
data: winnersResponse,
isLoading: isLoadingWinners,
isError: isWinnersError,
} = useWinners();
const [decodedTeamId, setDecodedTeamId] = useState<string>('');
const [error, setError] = useState<string | null>(null);
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
const certificateRef = useRef<HTMLDivElement>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [certificateImage, setCertificateImage] = useState<string>('');
const [showTemplate, setShowTemplate] = useState(true);
useEffect(() => {
if (!certId) return;
decodeWinnerCertificateId(certId)
.then((decoded) => setDecodedTeamId(decoded.teamId))
.catch(() => setError('Invalid certificate ID'));
}, [certId]);
const winners = useMemo(
() => (winnersResponse?.data || []) as WinnerEntry[],
[winnersResponse?.data]
);
const winnerEntry = useMemo(() => {
if (!decodedTeamId) return undefined;
return winners.find((w) => w.team_id === decodedTeamId);
}, [decodedTeamId, winners]);
const rankLabel = useMemo(() => {
if (!winnerEntry?.rank) return '';
return formatOrdinalRank(winnerEntry.rank);
}, [winnerEntry?.rank]);
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
decodedTeamId,
!!decodedTeamId
);
const { data: submissionData, isLoading: isLoadingSubmission } =
useTeamSubmission(decodedTeamId, !!decodedTeamId);
const team = teamData?.data;
const submission = submissionData?.data;
const submissionName = submission?.project_name || '(Submission unavailable)';
const memberNames = useMemo(() => {
const members = team?.members || [];
return members
.map((m) => m.user?.fullname)
.filter((name): name is string => !!name);
}, [team?.members]);
const isWinnerTeam = !!winnerEntry;
const isTeamMember =
!!session?.user?.id &&
!!decodedTeamId &&
(myTeamsData?.data || []).some(
(t) => (t as { id?: string } | null | undefined)?.id === decodedTeamId
);
// Generate QR Code (public link)
useEffect(() => {
if (!certId) return;
const encodedCertId = encodeURIComponent(certId);
const certificateUrl = `${window.location.origin}/certificate/winner/${encodedCertId}`;
QRCode.toDataURL(certificateUrl, {
width: s(200),
margin: 1,
color: {
dark: '#000000',
light: '#ffffff',
},
})
.then(setQrCodeUrl)
.catch((err) => console.error('QR Code generation failed:', err));
}, [certId]);
// Generate certificate canvas screenshot
useEffect(() => {
const generateCertificate = async () => {
if (!certificateRef.current) return;
if (!team?.name) return;
if (!qrCodeUrl) return;
if (!winnerEntry?.rank) return;
if (isLoadingSubmission) return;
setIsGenerating(true);
try {
setShowTemplate(true);
await new Promise((resolve) => setTimeout(resolve, 1500));
const canvas = await html2canvas(certificateRef.current, {
scale: EXPORT_SCALE,
useCORS: true,
backgroundColor: '#ffffff',
logging: false,
width: CERT_WIDTH,
height: CERT_HEIGHT,
allowTaint: true,
imageTimeout: 0,
removeContainer: true,
});
const imageUrl = canvas.toDataURL('image/png', 1.0);
setCertificateImage(imageUrl);
setShowTemplate(false);
} catch (e) {
console.error('Failed to generate certificate:', e);
} finally {
setIsGenerating(false);
}
};
generateCertificate();
}, [
team?.name,
memberNames,
qrCodeUrl,
winnerEntry?.rank,
isLoadingSubmission,
submissionName,
]);
const handleDownloadCertificate = () => {
if (!certificateImage) return;
const link = document.createElement('a');
link.href = certificateImage;
link.download = `winner-certificate-${team?.name || 'hackathon'}.png`;
link.click();
};
const handlePrintCertificate = () => {
if (!certificateImage) return;
const printWindow = window.open('', '_blank');
if (!printWindow) return;
printWindow.document.write(`
<html>
<head>
<title>Certificate - ${team?.name}</title>
<style>
body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
img { max-width: 100%; height: auto; }
@media print {
@page { size: A4 landscape; margin: 0; }
body { margin: 0; }
img { width: 100%; height: auto; }
}
</style>
</head>
<body>
<img src="${certificateImage}" />
</body>
</html>
`);
printWindow.document.close();
printWindow.onload = () => {
printWindow.print();
};
};
if (error || !certId) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Invalid Certificate
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
{error || 'The certificate ID is invalid or malformed.'}
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
</div>
);
}
if (!decodedTeamId || isLoadingTeam) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<div className="text-gray-600 dark:text-gray-400">
Loading certificate...
</div>
</div>
</div>
);
}
if (isLoadingWinners) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<div className="text-gray-600 dark:text-gray-400">
Loading winners...
</div>
</div>
</div>
);
}
if (isWinnersError) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Unable to Load Winners
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
Please try again later.
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
</div>
);
}
if (!isWinnerTeam) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Certificate Not Found
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
This team is not listed as a hackathon winner.
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
</div>
);
}
if (!team?.name) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Team Not Found
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6">
The team associated with this certificate could not be loaded.
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
{/* Header */}
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 no-print">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Winner Certificate
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{team.name}
</p>
</div>
{isTeamMember && (
<Button
variant="secondary"
onClick={() => navigate('/dashboard')}
>
Back to Dashboard
</Button>
)}
</div>
</div>
</div>
<div
className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12"
id="certificate-wrapper"
>
{/* Hidden Template for Canvas Generation */}
<div
className={showTemplate ? 'block' : 'hidden'}
style={{ position: 'absolute', left: '-9999px' }}
>
<div
ref={certificateRef}
id="certificate-template"
style={{
position: 'relative',
backgroundImage: 'url(/images/blank_winner_cert.svg)',
backgroundSize: '100% 100%',
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
width: `${CERT_WIDTH}px`,
height: `${CERT_HEIGHT}px`,
}}
>
<style>{`
#winner-members li::marker {
color: #59bef5;
}
`}</style>
{/* Team Name */}
<div
style={{
position: 'absolute',
top: '35%',
left: '3.5%',
width: '55%',
}}
>
<h3
style={{
fontFamily: 'Poppins, sans-serif',
fontWeight: 'bold',
color: '#59bef5',
textAlign: 'left',
fontSize: `${s(28)}px`,
lineHeight: '1.2',
wordBreak: 'break-word',
margin: 0,
}}
>
{team.name}
</h3>
</div>
{/* Members list */}
<div
style={{
position: 'absolute',
top: '40.5%',
left: '3.5%',
width: '55%',
}}
>
<ul
id="winner-members"
style={{
margin: 0,
fontFamily: 'Poppins, sans-serif',
fontSize: `${s(18)}px`,
lineHeight: '1.35',
color: '#59bef5',
}}
>
{(memberNames.length
? memberNames
: ['(Members unavailable)']
).map((name) => (
<li key={name}> {name}</li>
))}
</ul>
</div>
{/* Award text */}
<div
style={{
position: 'absolute',
top: '60%',
left: '3.5%',
width: '60%',
}}
>
<p
style={{
margin: 0,
fontFamily: 'Poppins, sans-serif',
fontSize: `${s(18)}px`,
lineHeight: '1.35',
color: '#6B6B6B',
}}
>
Diberikan sebagai penghargaan atas pencapaian meraih
<br />
<b>JUARA {winnerEntry.rank}</b> pada Hackathon IMPHNEN x
Kolosal.ai
<br />
<span>
dengan nama project: <b>{submissionName}</b>
</span>
</p>
</div>
{/* Rank badge */}
{!!rankLabel && (
<div
style={{
position: 'absolute',
top: '8%',
right: '8.5%',
width: `${s(190)}px`,
display: 'flex',
justifyContent: 'center',
}}
>
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: `${s(6)}px ${s(16)}px`,
textAlign: 'center',
fontFamily: 'Poppins, sans-serif',
fontWeight: 700,
color: '#78350F',
fontSize: `${s(24)}px`,
lineHeight: '1',
}}
>
{rankLabel}
</div>
</div>
)}
{/* QR Code (same placement as existing certificate page) */}
<div
style={{
position: 'absolute',
top: '33%',
right: '9.3%',
width: `${s(190)}px`,
height: `${s(190)}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{qrCodeUrl && (
<img
src={qrCodeUrl}
alt="Certificate QR Code"
style={{
width: `${s(190)}px`,
height: `${s(190)}px`,
display: 'block',
}}
/>
)}
</div>
</div>
</div>
{/* Display Certificate Image */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 overflow-hidden p-2">
{isGenerating && (
<div className="flex items-center justify-center p-12">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<div className="text-gray-600 dark:text-gray-400">
Generating certificate...
</div>
</div>
</div>
)}
{certificateImage && !isGenerating && (
<img
src={certificateImage}
alt="Winner Certificate"
className="w-full h-auto"
style={{ maxWidth: '100%', height: 'auto' }}
/>
)}
{/* Actions */}
{isTeamMember && (
<div className="bg-gray-50 dark:bg-gray-900 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
<Button
variant="secondary"
onClick={handleDownloadCertificate}
className="flex items-center gap-2"
disabled={isGenerating}
>
{isGenerating ? (
<>
<Icon
icon="svg-spinners:ring-resize"
width="18"
height="18"
/>
Generating...
</>
) : (
<>
<Icon
icon="heroicons:arrow-down-tray"
width="18"
height="18"
/>
Download
</>
)}
</Button>
<Button
variant="secondary"
onClick={handlePrintCertificate}
className="flex items-center gap-2"
disabled={isGenerating}
>
{isGenerating ? (
<>
<Icon
icon="svg-spinners:ring-resize"
width="18"
height="18"
/>
Generating...
</>
) : (
<>
<Icon icon="mdi:printer" width="18" height="18" />
Print
</>
)}
</Button>
<Button
onClick={() => navigate('/dashboard')}
variant="secondary"
className="col-span-2 flex items-center gap-2 xl:col-span-1"
>
Back to Dashboard
</Button>
</div>
)}
</div>
{/* Info Box */}
<div className="mt-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6 no-print">
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
Certificate Information
</h3>
<p className="text-sm text-blue-800 dark:text-blue-200">
This certificate is a digital record of your hackathon achievement.
</p>
</div>
</div>
</div>
);
};
export default CertificateWinnerPage;
+67 -7
View File
@@ -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>
+6
View File
@@ -52,6 +52,12 @@ export default function RootLayout() {
return; return;
} }
// Certificate page - allow public access
if (pathname.startsWith('/certificate/')) {
setIsChecking(false);
return;
}
// Require authentication for all other routes // Require authentication for all other routes
if (!session) { if (!session) {
navigate('/auth/login', { replace: true }); navigate('/auth/login', { replace: true });
+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">
Anka Tama Muhammad Alif Ramadhan
</h3> </h3>
<div> <div>
<p className="text-primary-500 font-semibold mb-1">Admin</p> <p className="text-primary-500 font-semibold mb-1">Admin</p>
@@ -1,12 +1,13 @@
import { FC, ReactElement } from 'react'; import { FC, ReactElement } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router'; import { useNavigate, useParams } from 'react-router';
import { useTeamById, useTeamSubmission } from '@imphnen-frontend-service/service'; import { useTeamById, useTeamSubmission, useAuthStore } from '@imphnen-frontend-service/service';
import { encodeCertificateId } from '../../../../utils/certificate'; import { encodeCertificateId } from '../../../../utils/certificate';
const SubmissionViewPage: FC = (): ReactElement => { const SubmissionViewPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>(); const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
const { session } = useAuthStore();
const { data: teamData } = useTeamById(teamId || ''); const { data: teamData } = useTeamById(teamId || '');
const { data: submissionData, isLoading } = useTeamSubmission(teamId || '', !!teamId); const { data: submissionData, isLoading } = useTeamSubmission(teamId || '', !!teamId);
@@ -113,13 +114,17 @@ const SubmissionViewPage: FC = (): ReactElement => {
View Your Certificate View Your Certificate
</h3> </h3>
<p className="text-amber-700 dark:text-amber-300 text-sm"> <p className="text-amber-700 dark:text-amber-300 text-sm">
Congratulations! Your certificate is ready to download and share. Congratulations! Your personalized certificate is ready to download and share.
</p> </p>
</div> </div>
</div> </div>
<button <button
onClick={async () => { onClick={async () => {
const certId = await encodeCertificateId(teamId || '', submission.id); const certId = await encodeCertificateId(
teamId || '',
submission.id,
session?.user?.id || ''
);
navigate(`/certificate/${encodeURIComponent(certId)}`); navigate(`/certificate/${encodeURIComponent(certId)}`);
}} }}
className="shrink-0 px-6 py-2 bg-amber-600 hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-700 text-white font-medium rounded-lg transition-colors" className="shrink-0 px-6 py-2 bg-amber-600 hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-700 text-white font-medium rounded-lg transition-colors"
+52 -10
View File
@@ -3,27 +3,69 @@ import { decryptText, encryptText } from "./aesclient";
const SECRET_KEY = 'imphnen-hackathon-2025'; const SECRET_KEY = 'imphnen-hackathon-2025';
/** /**
* Encode teamId and submissionId into a certificate ID * Encode teamId, submissionId, and userId into a certificate ID
* Uses base64 encoding for simple obfuscation * Uses AES encryption for secure encoding
* @param teamId - The team ID * @param teamId - The team ID
* @param submissionId - The submission ID * @param submissionId - The submission ID
* @param userId - The user ID (team member)
* @returns Encoded certificate ID * @returns Encoded certificate ID
*/ */
export const encodeCertificateId = async (teamId: string, submissionId: string): Promise<string> => { export const encodeCertificateId = async (teamId: string, submissionId: string, userId: string): Promise<string> => {
const combined = `${teamId}::${submissionId}`; const combined = `${teamId}::${submissionId}::${userId}`;
return encryptText(combined, SECRET_KEY); return encryptText(combined, SECRET_KEY);
}; };
/** /**
* Decode certificate ID back to teamId and submissionId * Encode winner certificate ID (team-based)
* @param certId - The encoded certificate ID * @param teamId - Winner team ID
* @returns Object containing teamId and submissionId * @returns Encoded winner certificate ID
*/ */
export const decodeCertificateId = async (certId: string): Promise<{ teamId: string; submissionId: string }> => { export const encodeWinnerCertificateId = async (teamId: string): Promise<string> => {
const combined = `winner::${teamId}`;
return encryptText(combined, SECRET_KEY);
};
/**
* Decode certificate ID back to teamId, submissionId, and userId
* @param certId - The encoded certificate ID
* @returns Object containing teamId, submissionId, and userId
*/
export const decodeCertificateId = async (certId: string): Promise<{ teamId: string; submissionId: string; userId: string }> => {
try { try {
const decoded = await decryptText(certId, SECRET_KEY); const decoded = await decryptText(certId, SECRET_KEY);
const [teamId, submissionId] = decoded.split('::'); const parts = decoded.split('::');
return { teamId, submissionId };
// Handle both old format (teamId::submissionId) and new format (teamId::submissionId::userId)
if (parts.length === 2) {
const [teamId, submissionId] = parts;
return { teamId, submissionId, userId: '' };
} else if (parts.length === 3) {
const [teamId, submissionId, userId] = parts;
return { teamId, submissionId, userId };
}
throw new Error('Invalid certificate format');
} catch {
throw new Error('Invalid certificate ID');
}
};
/**
* Decode winner certificate ID back to teamId
* @param certId - The encoded winner certificate ID
* @returns Object containing teamId
*/
export const decodeWinnerCertificateId = async (certId: string): Promise<{ teamId: string }> => {
try {
const decoded = await decryptText(certId, SECRET_KEY);
const parts = decoded.split('::');
// winner::teamId
if (parts.length === 2 && parts[0] === 'winner') {
return { teamId: parts[1] };
}
throw new Error('Invalid winner certificate format');
} catch { } catch {
throw new Error('Invalid certificate ID'); throw new Error('Invalid certificate ID');
} }
@@ -47,7 +47,7 @@ export function CommunitySection() {
case 'FaDiscord': case 'FaDiscord':
return FaDiscord; return FaDiscord;
case 'FaGithub': case 'FaGithub':
return FaDiscord; return FaGithub;
case 'FaInstagram': case 'FaInstagram':
return FaInstagram; return FaInstagram;
case 'FaTiktok': case 'FaTiktok':
+1 -1
View File
@@ -16,7 +16,7 @@
{ {
"name": "Github", "name": "Github",
"icon": "FaGithub", "icon": "FaGithub",
"color": "#5865F2", "color": "#000000",
"description": "Platforms to collaborate on your code", "description": "Platforms to collaborate on your code",
"link": "https://github.com/IMPHNEN/" "link": "https://github.com/IMPHNEN/"
}, },
+54
View File
@@ -0,0 +1,54 @@
import { api } from '../index';
import type {
TAdminUsersResponse,
TAdminTeamsResponse,
TAdminSubmissionsResponse,
} from '../../types/admin';
const ADMIN_BASE_URL = '/admin';
// Admin Users
export const getAdminUsers = async (params?: {
page?: number;
per_page?: number;
search?: string;
is_admin?: boolean;
}) => {
const response = await api.get<TAdminUsersResponse>(
`${ADMIN_BASE_URL}/users`,
{
params: {
...params,
is_admin: params?.is_admin ?? false,
},
}
);
return response.data;
};
// Admin Teams
export const getAdminTeams = async (params?: {
page?: number;
per_page?: number;
search?: string;
}) => {
const response = await api.get<TAdminTeamsResponse>(
`${ADMIN_BASE_URL}/teams`,
{ params }
);
return response.data;
};
// Admin Submissions
export const getAdminSubmissions = async (params?: {
page?: number;
per_page?: number;
search?: string;
status?: string;
}) => {
const response = await api.get<TAdminSubmissionsResponse>(
`${ADMIN_BASE_URL}/submissions`,
{ params }
);
return response.data;
};
+63
View File
@@ -0,0 +1,63 @@
import axios from 'axios';
import { useAuthStore } from '../hooks/auth';
// Backoffice Backend API Base URL
// In development, use proxy; in production, use full URL
const BACKOFFICE_API_URL = 'https://api.hackathon.imphnen.dev/api/v1';
// Create axios instance for backoffice backend
export const backofficeApi = axios.create({
baseURL: BACKOFFICE_API_URL,
headers: {
'Content-Type': 'application/json',
},
});
// Add auth token interceptor
backofficeApi.interceptors.request.use(
(config) => {
const { session } = useAuthStore.getState();
if (session?.token?.access_token) {
config.headers.Authorization = `Bearer ${session.token.access_token}`;
}
return config;
},
(error) => {
return Promise.reject(new Error(error.message || 'Request failed'));
}
);
// Error handling interceptor
backofficeApi.interceptors.response.use(
(response) => response,
(error) => {
// Handle 401 - clear session and redirect to login
if (error.response?.status === 401) {
const isAuthPage =
globalThis.window !== undefined &&
globalThis.location.pathname.startsWith('/auth');
if (!isAuthPage) {
useAuthStore.getState().clearSession();
if (globalThis.window !== undefined) {
globalThis.location.href = '/auth/login';
}
}
}
// If backend sends a message, use it
const backendMsg = error?.response?.data?.message;
if (backendMsg && typeof backendMsg === 'string') {
return Promise.reject(new Error(backendMsg));
}
// Fallback error message
return Promise.reject(new Error(error.message || 'An error occurred'));
}
);
// Response type
export interface BackofficeApiResponse<T> {
data: T;
message: string;
}
+4 -3
View File
@@ -31,12 +31,13 @@ hackathonApi.interceptors.response.use(
(response) => response, (response) => response,
(error) => { (error) => {
// Handle 401 - clear session and redirect to login // Handle 401 - clear session and redirect to login
// But skip redirect if already on auth pages (to avoid reload on login failure) // But skip redirect if already on auth pages or certificate pages (to avoid reload on login failure)
if (error.response?.status === 401) { if (error.response?.status === 401) {
const isAuthPage = globalThis.window !== undefined && globalThis.location.pathname.startsWith('/auth'); const isAuthPage = globalThis.window !== undefined && globalThis.location.pathname.startsWith('/auth');
const isCertificatePage = globalThis.window !== undefined && globalThis.location.pathname.startsWith('/certificate/');
// Only clear session and redirect if not on auth page // Only clear session and redirect if not on auth page or certificate page
if (!isAuthPage) { if (!isAuthPage && !isCertificatePage) {
useAuthStore.getState().clearSession(); useAuthStore.getState().clearSession();
if (globalThis.window !== undefined) { if (globalThis.window !== undefined) {
globalThis.location.href = '/auth/login'; globalThis.location.href = '/auth/login';
+10 -3
View File
@@ -6,6 +6,7 @@ export * from './users';
export * from './mentors'; export * from './mentors';
export * from './upload'; export * from './upload';
export * from './hackathon'; export * from './hackathon';
export * from './admin';
// Common API response wrapper interface // Common API response wrapper interface
export interface ApiResponse<T> { export interface ApiResponse<T> {
@@ -20,7 +21,9 @@ const getSessionTokenFromCookies = () => {
if (typeof document === 'undefined') return null; if (typeof document === 'undefined') return null;
const cookies = document.cookie.split(';'); const cookies = document.cookie.split(';');
const tokenCookie = cookies.find(cookie => cookie.trim().startsWith(`${TOKEN_KEY}=`)); const tokenCookie = cookies.find((cookie) =>
cookie.trim().startsWith(`${TOKEN_KEY}=`)
);
if (!tokenCookie) return null; if (!tokenCookie) return null;
@@ -32,13 +35,17 @@ const getSessionTokenFromCookies = () => {
} }
}; };
const setSessionTokenToCookies = (tokenData: { token: { access_token: string; refresh_token: string } }) => { const setSessionTokenToCookies = (tokenData: {
token: { access_token: string; refresh_token: string };
}) => {
if (typeof document === 'undefined') return; if (typeof document === 'undefined') return;
const expires = new Date(); const expires = new Date();
expires.setDate(expires.getDate() + 7); expires.setDate(expires.getDate() + 7);
document.cookie = `${TOKEN_KEY}=${encodeURIComponent(JSON.stringify(tokenData))}; expires=${expires.toUTCString()}; path=/; secure; samesite=strict`; document.cookie = `${TOKEN_KEY}=${encodeURIComponent(
JSON.stringify(tokenData)
)}; expires=${expires.toUTCString()}; path=/; secure; samesite=strict`;
}; };
const removeSessionTokenFromCookies = () => { const removeSessionTokenFromCookies = () => {
+77 -34
View File
@@ -1,5 +1,6 @@
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import { hackathonApi, HackathonApiResponse } from '../../api/hackathon'; import { hackathonApi, HackathonApiResponse } from '../../api/hackathon';
import { backofficeApi, BackofficeApiResponse } from '../../api/backoffice';
import { useAuthStore } from './use-auth-store'; import { useAuthStore } from './use-auth-store';
export * from './use-auth-store'; export * from './use-auth-store';
@@ -77,10 +78,9 @@ export const useLogin = () => {
return useMutation({ return useMutation({
mutationFn: async (data: LoginRequest) => { mutationFn: async (data: LoginRequest) => {
const response = await hackathonApi.post<HackathonApiResponse<AuthResponse>>( const response = await hackathonApi.post<
'/auth/login', HackathonApiResponse<AuthResponse>
data >('/auth/login', data);
);
return response.data.data; return response.data.data;
}, },
onSuccess: (data) => { onSuccess: (data) => {
@@ -115,10 +115,9 @@ export const useLogin = () => {
export const useSignup = () => { export const useSignup = () => {
return useMutation({ return useMutation({
mutationFn: async (data: SignupRequest) => { mutationFn: async (data: SignupRequest) => {
const response = await hackathonApi.post<HackathonApiResponse<MessageResponse>>( const response = await hackathonApi.post<
'/auth/signup', HackathonApiResponse<MessageResponse>
data >('/auth/signup', data);
);
return response.data.data; return response.data.data;
}, },
}); });
@@ -130,10 +129,9 @@ export const useGitHubCallback = () => {
return useMutation({ return useMutation({
mutationFn: async (data: GitHubAuthRequest) => { mutationFn: async (data: GitHubAuthRequest) => {
const response = await hackathonApi.post<HackathonApiResponse<AuthResponse>>( const response = await hackathonApi.post<
'/auth/github', HackathonApiResponse<AuthResponse>
data >('/auth/github', data);
);
return response.data.data; return response.data.data;
}, },
onSuccess: (data) => { onSuccess: (data) => {
@@ -171,9 +169,9 @@ export const useSession = () => {
return useQuery({ return useQuery({
queryKey: ['auth-session'], queryKey: ['auth-session'],
queryFn: async () => { queryFn: async () => {
const response = await hackathonApi.get<HackathonApiResponse<SessionResponse>>( const response = await hackathonApi.get<
'/auth/session' HackathonApiResponse<SessionResponse>
); >('/auth/session');
return response.data.data; return response.data.data;
}, },
enabled: !!session?.token, enabled: !!session?.token,
@@ -184,10 +182,9 @@ export const useSession = () => {
export const useForgotPassword = () => { export const useForgotPassword = () => {
return useMutation({ return useMutation({
mutationFn: async (data: ForgotPasswordRequest) => { mutationFn: async (data: ForgotPasswordRequest) => {
const response = await hackathonApi.post<HackathonApiResponse<MessageResponse>>( const response = await hackathonApi.post<
'/auth/forgot-password', HackathonApiResponse<MessageResponse>
data >('/auth/forgot-password', data);
);
return response.data.data; return response.data.data;
}, },
}); });
@@ -197,10 +194,9 @@ export const useForgotPassword = () => {
export const useResetPassword = () => { export const useResetPassword = () => {
return useMutation({ return useMutation({
mutationFn: async (data: ResetPasswordRequest) => { mutationFn: async (data: ResetPasswordRequest) => {
const response = await hackathonApi.post<HackathonApiResponse<MessageResponse>>( const response = await hackathonApi.post<
'/auth/reset-password', HackathonApiResponse<MessageResponse>
data >('/auth/reset-password', data);
);
return response.data.data; return response.data.data;
}, },
}); });
@@ -219,6 +215,45 @@ export const useSignOut = () => {
}); });
}; };
// Backoffice Login
export const useBackofficeLogin = () => {
const { setSession } = useAuthStore();
return useMutation({
mutationFn: async (data: LoginRequest) => {
const response = await backofficeApi.post<
BackofficeApiResponse<AuthResponse>
>('/auth/login', data);
return response.data.data;
},
onSuccess: (data) => {
setSession({
token: data.token,
user: {
id: data.user.id,
email: data.user.email,
fullname: data.user.fullname,
phone_number: data.user.phone_number || '',
avatar: data.user.avatar || '',
birthdate: data.user.birthdate || '',
gender: data.user.gender || '',
is_active: data.user.is_active,
location: data.user.location,
bio: data.user.bio,
skills: data.user.skills,
role: {
id: data.user.role_id || '',
name: 'admin',
permissions: [],
created_at: '',
updated_at: '',
},
},
});
},
});
};
// GitHub OAuth URL helper // GitHub OAuth URL helper
// The frontend needs to redirect to GitHub with the client_id // The frontend needs to redirect to GitHub with the client_id
// After GitHub redirects back with a code, use useGitHubCallback // After GitHub redirects back with a code, use useGitHubCallback
@@ -239,7 +274,9 @@ export const useGitHubAuth = () => {
// Get GitHub client ID from environment // Get GitHub client ID from environment
const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID || ''; const clientId = import.meta.env.VITE_GITHUB_CLIENT_ID || '';
if (!clientId) { if (!clientId) {
throw new Error('GitHub Client ID not configured. Set VITE_GITHUB_CLIENT_ID environment variable.'); throw new Error(
'GitHub Client ID not configured. Set VITE_GITHUB_CLIENT_ID environment variable.'
);
} }
const redirectUri = `${globalThis.location.origin}/auth/callback`; const redirectUri = `${globalThis.location.origin}/auth/callback`;
@@ -270,8 +307,16 @@ export const useEmailAuth = () => {
}; };
}; };
const signUpWithEmail = async (email: string, password: string, fullname: string) => { const signUpWithEmail = async (
const result = await signupMutation.mutateAsync({ email, password, fullname }); email: string,
password: string,
fullname: string
) => {
const result = await signupMutation.mutateAsync({
email,
password,
fullname,
});
// Signup only returns a message (user needs to verify email first) // Signup only returns a message (user needs to verify email first)
return { return {
message: result.message, message: result.message,
@@ -295,10 +340,9 @@ export const useEmailAuth = () => {
export const usePostLogin = () => { export const usePostLogin = () => {
return useMutation({ return useMutation({
mutationFn: async (data: LoginRequest) => { mutationFn: async (data: LoginRequest) => {
const response = await hackathonApi.post<HackathonApiResponse<AuthResponse>>( const response = await hackathonApi.post<
'/auth/login', HackathonApiResponse<AuthResponse>
data >('/auth/login', data);
);
return { data: response.data.data }; return { data: response.data.data };
}, },
}); });
@@ -308,10 +352,9 @@ export const usePostLogin = () => {
export const usePostRegister = () => { export const usePostRegister = () => {
return useMutation({ return useMutation({
mutationFn: async (data: SignupRequest) => { mutationFn: async (data: SignupRequest) => {
const response = await hackathonApi.post<HackathonApiResponse<AuthResponse>>( const response = await hackathonApi.post<
'/auth/signup', HackathonApiResponse<AuthResponse>
data >('/auth/signup', data);
);
return { data: response.data.data }; return { data: response.data.data };
}, },
}); });
+49
View File
@@ -15,6 +15,41 @@ interface User {
updated_at?: string; updated_at?: string;
} }
// Certificate public data types
interface CertificateUserData {
id: string;
fullname: string;
email: string;
avatar?: string;
}
interface CertificateTeamData {
id: string;
name: string;
logo?: string;
is_leader: boolean;
}
interface CertificateSubmissionData {
id: string;
title: string;
description: string;
repository_url?: string;
demo_url?: string;
}
interface CertificateWinnerData {
rank: number;
prize?: string;
}
export interface CertificatePublicData {
user: CertificateUserData;
team?: CertificateTeamData;
submission?: CertificateSubmissionData;
winner?: CertificateWinnerData;
}
// Update user request type // Update user request type
interface UpdateUserRequest { interface UpdateUserRequest {
fullname?: string; fullname?: string;
@@ -118,3 +153,17 @@ export const useUserDetailsById = (userId: string) => {
enabled: !!userId, enabled: !!userId,
}); });
}; };
// Public certificate data hook (no authentication required)
export const useCertificatePublicData = (userId: string, enabled = true) => {
return useQuery({
queryKey: ['certificate-public-data', userId],
queryFn: async () => {
const response = await hackathonApi.get<HackathonApiResponse<CertificatePublicData>>(
`/certificates/${userId}`
);
return { data: response.data.data };
},
enabled: enabled && !!userId,
});
};
+64
View File
@@ -0,0 +1,64 @@
export type TAdminMetaResponse = {
page: number;
per_page: number;
total_data: number;
total_page: number;
};
export type TAdminListResponse<T = unknown> = {
data: T[];
meta: TAdminMetaResponse;
};
// Admin Users
export type TAdminUserItem = {
id: string;
email: string;
fullname: string;
avatar: string | null;
phone_number: string | null;
location: string | null;
bio: string;
skills: string[];
is_active: boolean;
created_at: string;
updated_at: string;
};
export type TAdminUsersResponse = TAdminListResponse<TAdminUserItem>;
// Admin Teams
export type TAdminTeamItem = {
id: string;
name: string;
description: string;
city: string;
visibility: string;
logo: string | null;
banner: string | null;
leader_id: string;
created_at: string;
updated_at: string;
};
export type TAdminTeamsResponse = TAdminListResponse<TAdminTeamItem>;
// Admin Submissions
export type TAdminSubmissionItem = {
id: string;
team_id: string;
project_name: string;
description: string;
repository_url: string;
demo_url: string | null;
presentation_url: string | null;
screenshots: string[];
status: string;
submitted_at: string;
submitted_by: string;
created_at: string;
updated_at: string;
};
export type TAdminSubmissionsResponse =
TAdminListResponse<TAdminSubmissionItem>;
+1
View File
@@ -5,3 +5,4 @@ export * from './roles';
export * from './permissions'; export * from './permissions';
export * from './mentors'; export * from './mentors';
export * from './teams'; export * from './teams';
export * from './admin';
+132 -2
View File
@@ -23,6 +23,11 @@ interface DataTableProps<T extends RowData> {
columns?: ColumnDef<T, unknown>[]; columns?: ColumnDef<T, unknown>[];
pageSize?: number; pageSize?: number;
className?: string; className?: string;
// server-side pagination props
manualPagination?: boolean;
pageCount?: number;
currentPage?: number;
onPageChange?: (page: number) => void;
} }
export const DataTable = <T extends RowData>({ export const DataTable = <T extends RowData>({
@@ -31,6 +36,10 @@ export const DataTable = <T extends RowData>({
columns = [], columns = [],
pageSize = 9, pageSize = 9,
className, className,
manualPagination = false,
pageCount,
currentPage = 1,
onPageChange,
}: DataTableProps<T>) => { }: DataTableProps<T>) => {
const [pagination, setPagination] = React.useState<PaginationState>({ const [pagination, setPagination] = React.useState<PaginationState>({
pageIndex: 0, pageIndex: 0,
@@ -75,10 +84,20 @@ export const DataTable = <T extends RowData>({
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
// server-side pagination config
manualPagination,
pageCount: manualPagination ? pageCount : undefined,
}; };
return config; return config;
}, [memoizedData, memoizedColumns, pagination, sorting]); }, [
memoizedData,
memoizedColumns,
pagination,
sorting,
manualPagination,
pageCount,
]);
// Prefer external table instance if provided; otherwise create an internal one // Prefer external table instance if provided; otherwise create an internal one
const internalTable = useReactTable(tableConfig); const internalTable = useReactTable(tableConfig);
@@ -166,7 +185,118 @@ export const DataTable = <T extends RowData>({
</tbody> </tbody>
</table> </table>
</div> </div>
<Pagination table={t} /> {manualPagination && onPageChange && pageCount ? (
// Server-side pagination controls with numbered pages
<div className="flex items-center justify-center gap-10">
<button
className="disabled:opacity-50 cursor-pointer"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
aria-label="Previous page"
>
<svg
className="w-4 h-4 text-neutral-800"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 19l-7-7 7-7"
/>
</svg>
</button>
<div className="flex gap-4 items-baseline">
{pageCount <= 8 ? (
// Show all pages if 8 or fewer
Array.from({ length: pageCount }, (_, index) => (
<button
key={index}
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
currentPage === index + 1
? 'bg-primary-500 text-white'
: 'bg-primary-100 hover:bg-primary-200'
}`}
onClick={() => onPageChange(index + 1)}
>
{index + 1}
</button>
))
) : (
// Show ellipsis for many pages
<>
<button
onClick={() => onPageChange(1)}
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
currentPage === 1
? 'bg-primary-500 text-white'
: 'bg-primary-100 hover:bg-primary-200'
}`}
>
1
</button>
{currentPage > 3 && <span>...</span>}
{Array.from(
{ length: 5 },
(_, index) => currentPage - 2 + index
)
.filter((page) => page > 1 && page < pageCount)
.map((page) => (
<button
key={page}
onClick={() => onPageChange(page)}
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
currentPage === page
? 'bg-primary-500 text-white'
: 'bg-primary-100 hover:bg-primary-200'
}`}
>
{page}
</button>
))}
{currentPage < pageCount - 2 && <span>...</span>}
<button
onClick={() => onPageChange(pageCount)}
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
currentPage === pageCount
? 'bg-primary-500 text-white'
: 'bg-primary-100 hover:bg-primary-200'
}`}
>
{pageCount}
</button>
</>
)}
</div>
<button
className="disabled:opacity-50 cursor-pointer"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === pageCount}
aria-label="Next page"
>
<svg
className="w-4 h-4 text-neutral-800"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 5l7 7-7 7"
/>
</svg>
</button>
</div>
) : (
// Client-side pagination (default)
<Pagination table={t} />
)}
</div> </div>
); );
}; };
+1045 -59
View File
File diff suppressed because it is too large Load Diff
+6 -5
View File
@@ -51,13 +51,13 @@
"graphql": "^16.11.0", "graphql": "^16.11.0",
"html2canvas": "^1.4.1", "html2canvas": "^1.4.1",
"js-cookie": "^3.0.5", "js-cookie": "^3.0.5",
"next": "~16.0.3", "next": "^16.0.8",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"openapi-fetch": "^0.15.0", "openapi-fetch": "^0.15.0",
"openapi-react-query": "^0.5.0", "openapi-react-query": "^0.5.0",
"qrcode": "^1.5.4", "qrcode": "^1.5.4",
"react": "^19.1.0", "react": "^19.2.1",
"react-dom": "^19.1.0", "react-dom": "^19.2.1",
"react-hook-form": "^7.56.4", "react-hook-form": "^7.56.4",
"react-icons": "^5.5.0", "react-icons": "^5.5.0",
"react-router-dom": "^7.3.0", "react-router-dom": "^7.3.0",
@@ -99,8 +99,8 @@
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/node": "^22.12.0", "@types/node": "^22.12.0",
"@types/qrcode": "^1.5.6", "@types/qrcode": "^1.5.6",
"@types/react": "^19.1.2", "@types/react": "^19.2.7",
"@types/react-dom": "^19.1.2", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.2.0", "@vitejs/plugin-react": "^4.2.0",
"@vitest/coverage-v8": "^3.0.5", "@vitest/coverage-v8": "^3.0.5",
"@vitest/ui": "^3.0.0", "@vitest/ui": "^3.0.0",
@@ -108,6 +108,7 @@
"eslint": "^9.8.0", "eslint": "^9.8.0",
"eslint-config-next": "^15.2.4", "eslint-config-next": "^15.2.4",
"eslint-config-prettier": "10.0.0", "eslint-config-prettier": "10.0.0",
"eslint-next-config": "^1.0.3",
"eslint-plugin-import": "2.31.0", "eslint-plugin-import": "2.31.0",
"eslint-plugin-jsx-a11y": "6.10.1", "eslint-plugin-jsx-a11y": "6.10.1",
"eslint-plugin-playwright": "^1.6.2", "eslint-plugin-playwright": "^1.6.2",