Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d5cfed24ca | ||
|
|
f018da21c1 | ||
|
|
a610e180ba | ||
|
|
ce8cb8188c | ||
|
|
3aaf022393 | ||
|
|
ebe27a3f05 | ||
|
|
f846a5a785 | ||
|
|
708cbde596 | ||
|
|
9d37cba65f | ||
|
|
8728d0abd7 | ||
|
|
1f4f4ddaa1 | ||
|
|
4fcc249ff4 | ||
|
|
c2bed98b06 | ||
|
|
ae1864c134 | ||
|
|
3f30fbe89c | ||
|
|
951db0f28a | ||
|
|
ad59d39f55 | ||
|
|
1283689d84 | ||
|
|
7d1370d7f2 | ||
|
|
ab0b56780f | ||
|
|
1a39ac37db | ||
|
|
e5dc784301 | ||
|
|
b2de22b0ef | ||
|
|
ad20eda182 | ||
|
|
da65aa4109 | ||
|
|
1f0798752c | ||
|
|
5f28b9b406 | ||
|
|
9e8fc7c07f | ||
|
|
ca51f8aca2 | ||
|
|
c086b1371c | ||
|
|
398350795d | ||
|
|
5be9ec1edf | ||
|
|
f77967a092 | ||
|
|
fe172cc128 | ||
|
|
38ee9a9549 | ||
|
|
3ad828e759 | ||
|
|
782cce820b | ||
|
|
f5882e3cdc | ||
|
|
afaaff4a7f | ||
|
|
da8b08cd73 | ||
|
|
1dbe704230 | ||
|
|
59c1bb0366 | ||
|
|
c518784810 | ||
|
|
0267de664b | ||
|
|
134ff14946 | ||
|
|
ed3b711c89 | ||
|
|
6b7b1c4d20 | ||
|
|
b4b19e02fa | ||
|
|
e61979cbb6 | ||
|
|
0c7887268c | ||
|
|
76933c0be7 | ||
|
|
abc4c26d5d | ||
|
|
a3d35716a6 | ||
|
|
ac22bd6cab | ||
|
|
27ed5605ce | ||
|
|
2c6389a67a | ||
|
|
a4c75770ae |
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
if [[ ! -d "/Users/ms/Development/personal/imphnen-frontend-service" ]]; then
|
||||||
|
echo "Cannot find source directory; Did you move it?"
|
||||||
|
echo "(Looking for "/Users/ms/Development/personal/imphnen-frontend-service")"
|
||||||
|
echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# rebuild the cache forcefully
|
||||||
|
_nix_direnv_force_reload=1 direnv exec "/Users/ms/Development/personal/imphnen-frontend-service" true
|
||||||
|
|
||||||
|
# Update the mtime for .envrc.
|
||||||
|
# This will cause direnv to reload again - but without re-building.
|
||||||
|
touch "/Users/ms/Development/personal/imphnen-frontend-service/.envrc"
|
||||||
|
|
||||||
|
# Also update the timestamp of whatever profile_rc we have.
|
||||||
|
# This makes sure that we know we are up to date.
|
||||||
|
touch -r "/Users/ms/Development/personal/imphnen-frontend-service/.envrc" "/Users/ms/Development/personal/imphnen-frontend-service/.direnv"/*.rc
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/8hx8jwa24q6rkzhca9iqy5ckk9rbphi1-source
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/jizz7ld5r2w11y03wrmnvwsskc45krj9-source
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/nix/store/y4p9szcab9xd7v1c7k9ghr86hb6myyg4-nix-shell-env
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
|||||||
|
|
||||||
VITE_SUPABASE_URL=https://your-project-id.supabase.co
|
|
||||||
VITE_SUPABASE_ANON_KEY=your-anon-key-here
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
name: Deploy Backoffice
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
paths:
|
|
||||||
- 'apps/backoffice/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy-vps
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repo
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Deploy via SSH
|
|
||||||
uses: appleboy/ssh-action@v1.0.3
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.SSH_HOST }}
|
|
||||||
username: ${{ secrets.SSH_USER }}
|
|
||||||
key: ${{ secrets.SSH_KEY }}
|
|
||||||
port: ${{ secrets.SSH_PORT }}
|
|
||||||
command_timeout: 30m
|
|
||||||
script: |
|
|
||||||
set -e
|
|
||||||
if [ ! -d ~/imphnen-frontend-service-backoffice ]; then
|
|
||||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-backoffice
|
|
||||||
else
|
|
||||||
cd ~/imphnen-frontend-service-backoffice && git fetch origin && git reset --hard origin/develop && git pull
|
|
||||||
fi
|
|
||||||
cat > ~/imphnen-frontend-service-backoffice/apps/backoffice/.env << EOF
|
|
||||||
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
|
||||||
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
|
||||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
|
||||||
EOF
|
|
||||||
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml down || true
|
|
||||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml build --progress=plain
|
|
||||||
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml up -d
|
|
||||||
docker image prune -af
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
name: Deploy Dimentorin
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
paths:
|
|
||||||
- 'apps/dimentorin/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy-vps
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repo
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Deploy via SSH
|
|
||||||
uses: appleboy/ssh-action@v1.0.3
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.SSH_HOST }}
|
|
||||||
username: ${{ secrets.SSH_USER }}
|
|
||||||
key: ${{ secrets.SSH_KEY }}
|
|
||||||
port: ${{ secrets.SSH_PORT }}
|
|
||||||
command_timeout: 30m
|
|
||||||
script: |
|
|
||||||
set -e
|
|
||||||
if [ ! -d ~/imphnen-frontend-service-dimentorin ]; then
|
|
||||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-dimentorin
|
|
||||||
else
|
|
||||||
cd ~/imphnen-frontend-service-dimentorin && git fetch origin && git reset --hard origin/develop && git pull
|
|
||||||
fi
|
|
||||||
cat > ~/imphnen-frontend-service-dimentorin/apps/dimentorin/.env << EOF
|
|
||||||
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
|
||||||
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
|
||||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
|
||||||
EOF
|
|
||||||
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml down || true
|
|
||||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml build --progress=plain
|
|
||||||
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml up -d
|
|
||||||
docker image prune -af
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
name: Deploy Gacha
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
paths:
|
|
||||||
- 'apps/gacha/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy-vps
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repo
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Deploy via SSH
|
|
||||||
uses: appleboy/ssh-action@v1.0.3
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.SSH_HOST }}
|
|
||||||
username: ${{ secrets.SSH_USER }}
|
|
||||||
key: ${{ secrets.SSH_KEY }}
|
|
||||||
port: ${{ secrets.SSH_PORT }}
|
|
||||||
command_timeout: 30m
|
|
||||||
script: |
|
|
||||||
set -e
|
|
||||||
if [ ! -d ~/imphnen-frontend-service-gacha ]; then
|
|
||||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-gacha
|
|
||||||
else
|
|
||||||
cd ~/imphnen-frontend-service-gacha && git fetch origin && git reset --hard origin/develop && git pull
|
|
||||||
fi
|
|
||||||
cat > ~/imphnen-frontend-service-gacha/apps/gacha/.env << EOF
|
|
||||||
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
|
||||||
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
|
||||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
|
||||||
EOF
|
|
||||||
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml down || true
|
|
||||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml build --progress=plain
|
|
||||||
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml up -d
|
|
||||||
docker image prune -af
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
name: Deploy Hackathon
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
paths:
|
|
||||||
- 'apps/hackathon/**' # Auto deploy when changes pushed to apps/hackathon
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy-vps
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repo
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Deploy via SSH
|
|
||||||
uses: appleboy/ssh-action@v1.0.3
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.SSH_HOST }}
|
|
||||||
username: ${{ secrets.SSH_USER }}
|
|
||||||
key: ${{ secrets.SSH_KEY }}
|
|
||||||
port: ${{ secrets.SSH_PORT }}
|
|
||||||
command_timeout: 30m
|
|
||||||
script: |
|
|
||||||
set -e
|
|
||||||
if [ ! -d ~/imphnen-frontend-service-hackathon ]; then
|
|
||||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-hackathon
|
|
||||||
else
|
|
||||||
cd ~/imphnen-frontend-service-hackathon && git fetch origin && git reset --hard origin/develop && git pull
|
|
||||||
fi
|
|
||||||
cat > ~/imphnen-frontend-service-hackathon/.env.local << EOF
|
|
||||||
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
|
||||||
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
|
||||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
|
||||||
EOF
|
|
||||||
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml down || true
|
|
||||||
docker builder prune -af || true
|
|
||||||
DOCKER_BUILDKIT=1 docker compose --env-file ~/imphnen-frontend-service-hackathon/.env.local -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml build --progress=plain
|
|
||||||
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml up -d
|
|
||||||
docker image prune -af
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
name: Deploy Landing
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- develop
|
|
||||||
paths:
|
|
||||||
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy-vps
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout repo
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Deploy via SSH
|
|
||||||
uses: appleboy/ssh-action@v1.0.3
|
|
||||||
with:
|
|
||||||
host: ${{ secrets.SSH_HOST }}
|
|
||||||
username: ${{ secrets.SSH_USER }}
|
|
||||||
key: ${{ secrets.SSH_KEY }}
|
|
||||||
port: ${{ secrets.SSH_PORT }}
|
|
||||||
command_timeout: 30m
|
|
||||||
script: |
|
|
||||||
set -e
|
|
||||||
if [ ! -d ~/imphnen-frontend-service ]; then
|
|
||||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service
|
|
||||||
else
|
|
||||||
cd ~/imphnen-frontend-service && git fetch origin && git reset --hard origin/develop && git pull
|
|
||||||
fi
|
|
||||||
cat > ~/imphnen-frontend-service/apps/landing/.env << EOF
|
|
||||||
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}
|
|
||||||
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
|
||||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
|
||||||
EOF
|
|
||||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
|
|
||||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build --progress=plain
|
|
||||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
|
|
||||||
docker image prune -af
|
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
name: Nix Build & Cache
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: ['develop']
|
||||||
|
pull_request:
|
||||||
|
branches: ['develop']
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Nix
|
||||||
|
uses: DeterminateSystems/nix-installer-action@main
|
||||||
|
|
||||||
|
- name: Setup Cachix
|
||||||
|
uses: cachix/cachix-action@v15
|
||||||
|
with:
|
||||||
|
name: msdqn
|
||||||
|
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
|
||||||
|
|
||||||
|
- name: Build all packages
|
||||||
|
run: |
|
||||||
|
nix build .#landing -o result-landing
|
||||||
|
nix build .#backoffice -o result-backoffice
|
||||||
|
nix build .#gacha -o result-gacha
|
||||||
|
nix build .#dimentorin -o result-dimentorin
|
||||||
|
nix build .#hackathon -o result-hackathon
|
||||||
|
nix build .#infra -o result-infra
|
||||||
|
|
||||||
|
- name: Show build outputs
|
||||||
|
run: |
|
||||||
|
echo "Landing: $(readlink result-landing)"
|
||||||
|
echo "Backoffice: $(readlink result-backoffice)"
|
||||||
|
echo "Gacha: $(readlink result-gacha)"
|
||||||
|
echo "Dimentorin: $(readlink result-dimentorin)"
|
||||||
|
echo "Hackathon: $(readlink result-hackathon)"
|
||||||
|
echo "Infra: $(readlink result-infra)"
|
||||||
@@ -10,6 +10,7 @@ This repository is a **monorepo** for all frontend services of IMPHNEN. The mono
|
|||||||
2. **Backoffice** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Internal Management Website</a>.
|
2. **Backoffice** - Application for <a href="https://gacha.imphnen.dev/" target="_blank">Internal Management Website</a>.
|
||||||
3. **Dimentorin** - Application for <a href="https://dimentorin.imphnen.dev/" target="_blank">Mentoring Service</a>.
|
3. **Dimentorin** - Application for <a href="https://dimentorin.imphnen.dev/" target="_blank">Mentoring Service</a>.
|
||||||
4. **Landing Page** - Application for <a href="https://imphnen.dev/" target="_blank">Landing Page</a>.
|
4. **Landing Page** - Application for <a href="https://imphnen.dev/" target="_blank">Landing Page</a>.
|
||||||
|
5. **QR Campaign** - Application for QR Campaign Management.
|
||||||
|
|
||||||
## How to install
|
## How to install
|
||||||
|
|
||||||
@@ -54,6 +55,10 @@ Use the following commands to run in development mode:
|
|||||||
```sh
|
```sh
|
||||||
npm run landing:dev
|
npm run landing:dev
|
||||||
```
|
```
|
||||||
|
- **QR Campaign**:
|
||||||
|
```sh
|
||||||
|
npm run qrcampaign:dev
|
||||||
|
```
|
||||||
|
|
||||||
### Build
|
### Build
|
||||||
|
|
||||||
@@ -75,6 +80,10 @@ Use the following commands to build the applications:
|
|||||||
```sh
|
```sh
|
||||||
npm run landing:build
|
npm run landing:build
|
||||||
```
|
```
|
||||||
|
- **QR Campaign**:
|
||||||
|
```sh
|
||||||
|
npm run qrcampaign:build
|
||||||
|
```
|
||||||
|
|
||||||
### Production
|
### Production
|
||||||
|
|
||||||
@@ -96,6 +105,10 @@ Use the following commands to run the applications in production mode:
|
|||||||
```sh
|
```sh
|
||||||
npm run landing:prod
|
npm run landing:prod
|
||||||
```
|
```
|
||||||
|
- **QR Campaign**:
|
||||||
|
```sh
|
||||||
|
npm run qrcampaign:prod
|
||||||
|
```
|
||||||
|
|
||||||
### Storybook
|
### Storybook
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { BackofficeWrapper } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
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 => {
|
||||||
|
// 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 (
|
||||||
|
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||||
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">Dashboard</h1>
|
||||||
|
|
||||||
|
<section className="grid grid-cols-5 gap-5">
|
||||||
|
{/* Participant */}
|
||||||
|
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||||
|
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
||||||
|
{totalParticipants}
|
||||||
|
</h3>
|
||||||
|
<p className="text-neutral-400 text-p3">Total Participants</p>
|
||||||
|
</div>
|
||||||
|
{/* Team */}
|
||||||
|
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||||
|
<h3 className="text-primary-500 text-p2 font-semibold mb-2.5">
|
||||||
|
{totalTeams}
|
||||||
|
</h3>
|
||||||
|
<p className="text-neutral-400 text-p3">Total Teams</p>
|
||||||
|
</div>
|
||||||
|
{/* Project Submitted */}
|
||||||
|
<div className="bg-white px-6 py-4 rounded-md shadow">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HackathonDashboardPage;
|
||||||
+249
@@ -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;
|
||||||
@@ -0,0 +1,375 @@
|
|||||||
|
import {
|
||||||
|
FC,
|
||||||
|
ReactElement,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useCallback,
|
||||||
|
} from 'react';
|
||||||
|
import SubmissionModal from './_components/submission-modal';
|
||||||
|
import {
|
||||||
|
BackofficeWrapper,
|
||||||
|
DataTable,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
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';
|
||||||
|
|
||||||
|
type SubmissionType = TAdminSubmissionItem;
|
||||||
|
|
||||||
|
export const HackathonSubmissionsPage: 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 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 totalData = submissionsResponse?.meta?.total_data || 0;
|
||||||
|
const totalPages = submissionsResponse?.meta?.total_page || 1;
|
||||||
|
|
||||||
|
// 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]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Validate page number
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||||
|
setSearchParams({ page: totalPages.toString() });
|
||||||
|
}
|
||||||
|
}, [currentPage, totalPages, setSearchParams, isLoading]);
|
||||||
|
|
||||||
|
// Sync globalFilter with URL
|
||||||
|
useEffect(() => {
|
||||||
|
setGlobalFilter(searchQuery);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
// Handle search
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[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);
|
||||||
|
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 (
|
||||||
|
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||||
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||||
|
Project Submissions
|
||||||
|
</h1>
|
||||||
|
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||||
|
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
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"
|
||||||
|
placeholder="Search by project name..."
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
onKeyPress={handleSearchKeyPress}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{/* Active filters */}
|
||||||
|
{/* {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>
|
||||||
|
|
||||||
|
{/* Submission Modal */}
|
||||||
|
{selectedSubmission && (
|
||||||
|
<SubmissionModal
|
||||||
|
isOpen={showSubmissionModal}
|
||||||
|
onClose={handleCloseSubmissionModal}
|
||||||
|
submission={selectedSubmission}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HackathonSubmissionsPage;
|
||||||
+514
@@ -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;
|
||||||
+82
@@ -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;
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
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 {
|
||||||
|
BackofficeWrapper,
|
||||||
|
DataTable,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import {
|
||||||
|
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 => {
|
||||||
|
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);
|
||||||
|
|
||||||
|
// Advanced filtering states
|
||||||
|
const [visibilityFilter, setVisibilityFilter] = useState('all');
|
||||||
|
const [cityFilter, setCityFilter] = useState('all');
|
||||||
|
|
||||||
|
// 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 totalData = teamsResponse?.meta?.total_data || 0;
|
||||||
|
const totalPages = teamsResponse?.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 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]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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
|
||||||
|
const handleShowDetailModal = useCallback((team: TeamType) => {
|
||||||
|
setSelectedTeam(team);
|
||||||
|
setShowDetailModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseDetailModal = useCallback(() => {
|
||||||
|
setShowDetailModal(false);
|
||||||
|
setSelectedTeam(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleShowNewTeamModal = useCallback(() => {
|
||||||
|
setShowNewTeamModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseNewTeamModal = useCallback(() => {
|
||||||
|
setShowNewTeamModal(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get teams data from API response
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
return teamsResponse?.data || [];
|
||||||
|
}, [teamsResponse]);
|
||||||
|
|
||||||
|
// Memoize columns to prevent recreation on every render
|
||||||
|
const columns: ColumnDef<TeamType>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Team',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const team = row.original;
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
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>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'created_at',
|
||||||
|
header: 'Created',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-neutral-900 text-sm">
|
||||||
|
{new Date(row.original.created_at).toLocaleDateString('en-UK', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: 'datetime',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
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 text-sm px-4 py-2"
|
||||||
|
onClick={() => handleShowDetailModal(row.original)}
|
||||||
|
>
|
||||||
|
<EditOutlined className="text-sm" />
|
||||||
|
Manage
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[handleShowDetailModal]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||||
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||||
|
Team Management
|
||||||
|
</h1>
|
||||||
|
{/* Filters and actions */}
|
||||||
|
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||||
|
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||||
|
{/* Left side - Search & filters */}
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
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"
|
||||||
|
placeholder="Search teams by name or city..."
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
onKeyPress={handleSearchKeyPress}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HackathonTeamsPage;
|
||||||
+655
@@ -0,0 +1,655 @@
|
|||||||
|
import { FC, useState, useEffect, useMemo, useRef } from 'react';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import {
|
||||||
|
UserOutlined,
|
||||||
|
EnvironmentOutlined,
|
||||||
|
CalendarOutlined,
|
||||||
|
SaveOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
ExclamationOutlined,
|
||||||
|
CameraOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
UploadOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
interface UserType {
|
||||||
|
id: string;
|
||||||
|
avatar?: string | null;
|
||||||
|
fullname: string;
|
||||||
|
bio?: string;
|
||||||
|
location: string | null;
|
||||||
|
is_active: boolean;
|
||||||
|
skills: string[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
user: UserType | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
||||||
|
const [formData, setFormData] = useState<UserType | null>(null);
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
|
const [showAvatarMenu, setShowAvatarMenu] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Initialize form data when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
if (user) {
|
||||||
|
// Edit existing user
|
||||||
|
setFormData({ ...user });
|
||||||
|
} else {
|
||||||
|
// Create new user
|
||||||
|
setFormData({
|
||||||
|
id: '', // Will be generated by backend
|
||||||
|
fullname: '',
|
||||||
|
bio: '',
|
||||||
|
location: '',
|
||||||
|
is_active: true,
|
||||||
|
skills: [],
|
||||||
|
avatar: undefined,
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isOpen, user]);
|
||||||
|
|
||||||
|
// Check if form has changes
|
||||||
|
const hasChanges = useMemo(() => {
|
||||||
|
if (!formData) return false;
|
||||||
|
if (!user) return true; // New user always has changes
|
||||||
|
return (
|
||||||
|
formData.fullname !== user.fullname ||
|
||||||
|
formData.location !== user.location ||
|
||||||
|
formData.is_active !== user.is_active ||
|
||||||
|
formData.avatar !== user.avatar ||
|
||||||
|
JSON.stringify(formData.skills) !== JSON.stringify(user.skills) ||
|
||||||
|
formData.bio !== user.bio
|
||||||
|
);
|
||||||
|
}, [formData, user]);
|
||||||
|
|
||||||
|
// Check if required fields are filled
|
||||||
|
const isFormValid = useMemo(() => {
|
||||||
|
if (!formData) return false;
|
||||||
|
return formData.fullname?.trim() !== '' && formData.location?.trim() !== '';
|
||||||
|
}, [formData]);
|
||||||
|
|
||||||
|
const canSave = hasChanges && isFormValid;
|
||||||
|
|
||||||
|
if (!isOpen || !formData) return null;
|
||||||
|
|
||||||
|
const handleInputChange = (
|
||||||
|
field: keyof UserType,
|
||||||
|
value: string | boolean | string[] | undefined
|
||||||
|
) => {
|
||||||
|
setFormData((prev) => (prev ? { ...prev, [field]: value } : null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSkillsChange = (skills: string[]) => {
|
||||||
|
setFormData((prev) => (prev ? { ...prev, skills } : null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!formData) return;
|
||||||
|
|
||||||
|
if (user) {
|
||||||
|
// Update existing user
|
||||||
|
console.log('Update user data:', formData);
|
||||||
|
} else {
|
||||||
|
// Create new user
|
||||||
|
console.log('Create new user:', formData);
|
||||||
|
}
|
||||||
|
// Here you would typically make an API call to save the data
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
if (user) {
|
||||||
|
setFormData({ ...user }); // Reset to original for edit mode
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAccount = () => {
|
||||||
|
if (!user) return; // Can't delete new user
|
||||||
|
console.log('Delete user:', user.id);
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAvatarUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = event.target.files?.[0];
|
||||||
|
if (file) {
|
||||||
|
// Validate file type
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
alert('Please select an image file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate file size (max 5MB)
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
alert('Image size must be less than 5MB');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create preview URL
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const avatarUrl = e.target?.result as string;
|
||||||
|
handleInputChange('avatar', avatarUrl);
|
||||||
|
setShowAvatarMenu(false);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveAvatar = () => {
|
||||||
|
handleInputChange('avatar', undefined);
|
||||||
|
setShowAvatarMenu(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerFileUpload = () => {
|
||||||
|
fileInputRef.current?.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableSkills = [
|
||||||
|
'Frontend Developer',
|
||||||
|
'Backend Developer',
|
||||||
|
'Full Stack Developer',
|
||||||
|
'DevOps Engineer',
|
||||||
|
'UI/UX Designer',
|
||||||
|
'Product Manager',
|
||||||
|
'Data Scientist',
|
||||||
|
'Mobile Developer',
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50"
|
||||||
|
onClick={(e) => {
|
||||||
|
setShowAvatarMenu(false);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<div
|
||||||
|
className="bg-white rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{/* Hidden File Input */}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleAvatarUpload}
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="border-b border-neutral-200 px-8 py-6 flex justify-between items-start">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* Interactive User Avatar */}
|
||||||
|
<div className="relative group ">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden border-2 border-transparent group-hover:border-primary-300 transition-colors">
|
||||||
|
{formData.avatar ? (
|
||||||
|
<img
|
||||||
|
src={formData.avatar}
|
||||||
|
alt={formData.fullname}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<UserOutlined className="text-neutral-500 text-2xl" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Avatar Hover Overlay */}
|
||||||
|
<button
|
||||||
|
onClick={() => setShowAvatarMenu(!showAvatarMenu)}
|
||||||
|
className="absolute inset-0 bg-neutral-400 cursor-pointer rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<CameraOutlined className="text-white text-lg" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Avatar Menu Dropdown */}
|
||||||
|
{showAvatarMenu && (
|
||||||
|
<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={triggerFileUpload}
|
||||||
|
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.avatar ? 'Change Photo' : 'Upload Photo'}
|
||||||
|
</button>
|
||||||
|
{formData.avatar && (
|
||||||
|
<button
|
||||||
|
onClick={handleRemoveAvatar}
|
||||||
|
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 Photo
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<h2 className="text-2xl font-bold text-neutral-900">
|
||||||
|
{user ? 'Edit User Profile' : 'Create New User'}
|
||||||
|
</h2>
|
||||||
|
{user && (
|
||||||
|
<span className="px-3 py-1 bg-info-100 text-info-700 text-xs font-medium rounded-2xl">
|
||||||
|
Hover avatar to change
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-neutral-500">
|
||||||
|
{user
|
||||||
|
? `Make changes to ${
|
||||||
|
formData.fullname || 'this user'
|
||||||
|
}'s profile information`
|
||||||
|
: 'Fill in the information below to create a new user account'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors cursor-pointer"
|
||||||
|
onClick={() => {
|
||||||
|
setShowAvatarMenu(false);
|
||||||
|
handleCancel();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseOutlined className="text-neutral-400 text-lg" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="p-8" onClick={() => setShowAvatarMenu(false)}>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
|
{/* Left Column - Basic Info */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
|
Basic Information
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Full Name - Required */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<UserOutlined className="text-neutral-400" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-sm text-neutral-500 block mb-1">
|
||||||
|
Full Name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.fullname}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange('fullname', e.target.value)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none',
|
||||||
|
!formData.fullname ||
|
||||||
|
formData.fullname.trim() === ''
|
||||||
|
? 'border-red-300 bg-red-50'
|
||||||
|
: 'border-neutral-300'
|
||||||
|
)}
|
||||||
|
placeholder="Enter full name"
|
||||||
|
/>
|
||||||
|
{(!formData.fullname ||
|
||||||
|
formData.fullname.trim() === '') && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">
|
||||||
|
Full name is required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location - Required */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<EnvironmentOutlined className="text-neutral-400" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-sm text-neutral-500 block mb-1">
|
||||||
|
Location <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={formData.location || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange('location', e.target.value)
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white',
|
||||||
|
!formData.location ||
|
||||||
|
formData.location.trim() === ''
|
||||||
|
? 'border-red-300 bg-red-50'
|
||||||
|
: 'border-neutral-300'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="">Select location</option>
|
||||||
|
<option value="Jakarta">Jakarta</option>
|
||||||
|
<option value="Bandung">Bandung</option>
|
||||||
|
<option value="Surabaya">Surabaya</option>
|
||||||
|
<option value="Medan">Medan</option>
|
||||||
|
<option value="Yogyakarta">Yogyakarta</option>
|
||||||
|
</select>
|
||||||
|
{(!formData.location ||
|
||||||
|
formData.location.trim() === '') && (
|
||||||
|
<p className="text-red-500 text-xs mt-1">
|
||||||
|
Location is required
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Joined Date - Read Only - Only show for existing users */}
|
||||||
|
{user && (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<CalendarOutlined className="text-neutral-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
Joined Date
|
||||||
|
</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{new Date(formData.created_at).toLocaleDateString(
|
||||||
|
'en-US',
|
||||||
|
{
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bio Section - Optional */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-3">
|
||||||
|
Bio{' '}
|
||||||
|
<span className="text-neutral-400 text-sm font-normal">
|
||||||
|
(Optional)
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<textarea
|
||||||
|
value={formData.bio || ''}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleInputChange('bio', e.target.value || undefined)
|
||||||
|
}
|
||||||
|
placeholder="Tell us about yourself..."
|
||||||
|
rows={4}
|
||||||
|
className="w-full border border-neutral-300 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none resize-none"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column - Skills & Status */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Account Status - Enhanced Tab Design */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
|
Account Status
|
||||||
|
</h3>
|
||||||
|
<div className="flex bg-neutral-100 p-1 rounded-lg">
|
||||||
|
<button
|
||||||
|
onClick={() => handleInputChange('is_active', true)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer',
|
||||||
|
formData.is_active
|
||||||
|
? 'bg-white text-success-700 shadow-sm ring-1 ring-success-200'
|
||||||
|
: 'text-neutral-600 hover:text-neutral-800'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
formData.is_active
|
||||||
|
? 'bg-success-500'
|
||||||
|
: 'bg-neutral-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
Active
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleInputChange('is_active', false)}
|
||||||
|
className={cn(
|
||||||
|
'flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 cursor-pointer',
|
||||||
|
!formData.is_active
|
||||||
|
? 'bg-white text-neutral-700 shadow-sm ring-1 ring-neutral-200'
|
||||||
|
: 'text-neutral-600 hover:text-neutral-800'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
!formData.is_active
|
||||||
|
? 'bg-neutral-500'
|
||||||
|
: 'bg-neutral-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
Inactive
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-500 mt-2">
|
||||||
|
{formData.is_active
|
||||||
|
? 'User can access their account and participate in activities'
|
||||||
|
: 'User account is suspended and cannot access services'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skills Section - Optional */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
|
Skills & Expertise{' '}
|
||||||
|
<span className="text-neutral-400 text-sm font-normal">
|
||||||
|
(Optional)
|
||||||
|
</span>
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2 min-h-10 p-3 border border-neutral-300 rounded-lg bg-neutral-50">
|
||||||
|
{formData.skills.length > 0 ? (
|
||||||
|
formData.skills.map((skill, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="inline-flex items-center gap-2 px-3 py-1.5 rounded-2xl text-sm font-medium bg-blue-100 text-blue-800"
|
||||||
|
>
|
||||||
|
{skill}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
handleSkillsChange(
|
||||||
|
formData.skills.filter((_, i) => i !== index)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
className="text-blue-600 hover:text-blue-800 ml-1 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-neutral-400 text-sm">
|
||||||
|
No skills added yet
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value=""
|
||||||
|
onChange={(e) => {
|
||||||
|
if (
|
||||||
|
e.target.value &&
|
||||||
|
!formData.skills.includes(e.target.value)
|
||||||
|
) {
|
||||||
|
handleSkillsChange([
|
||||||
|
...formData.skills,
|
||||||
|
e.target.value,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full border border-neutral-300 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white"
|
||||||
|
>
|
||||||
|
<option value="">Add a skill...</option>
|
||||||
|
{availableSkills
|
||||||
|
.filter((skill) => !formData.skills.includes(skill))
|
||||||
|
.map((skill) => (
|
||||||
|
<option key={skill} value={skill}>
|
||||||
|
{skill}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account Details - Read Only - Only show for existing users */}
|
||||||
|
{user && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
|
Account Details
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-3 bg-neutral-50 p-4 rounded-lg">
|
||||||
|
<div className="flex justify-between items-center py-1">
|
||||||
|
<span className="text-neutral-600 text-sm">
|
||||||
|
User ID
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-sm text-neutral-800">
|
||||||
|
{formData.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center py-1">
|
||||||
|
<span className="text-neutral-600 text-sm">
|
||||||
|
Last Updated
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-neutral-800">
|
||||||
|
{new Date(formData.updated_at).toLocaleDateString(
|
||||||
|
'en-US',
|
||||||
|
{
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
}
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<div className="border-t border-neutral-200 px-8 py-6">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="text-sm text-neutral-500">
|
||||||
|
{canSave
|
||||||
|
? 'Ready to save changes'
|
||||||
|
: hasChanges
|
||||||
|
? 'Please fill required fields'
|
||||||
|
: 'No changes made'}
|
||||||
|
</div>
|
||||||
|
{/* Delete Account Button - Only show for existing users */}
|
||||||
|
{user && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="text-red-600 hover:text-red-700 text-sm font-medium transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Delete Account
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="px-6"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!canSave}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 px-6',
|
||||||
|
!canSave && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<SaveOutlined className="text-sm" />
|
||||||
|
{user ? 'Save Changes' : 'Create User'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Modal */}
|
||||||
|
{showDeleteConfirm && (
|
||||||
|
<div className="fixed inset-0 z-60">
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/50"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
/>
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<div className="bg-white rounded-xl shadow-2xl w-full max-w-md">
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<div className="w-10 h-10 bg-red-100 rounded-full flex items-center justify-center">
|
||||||
|
<ExclamationOutlined className="text-red-600 text-lg" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-neutral-900">
|
||||||
|
Delete Account
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-neutral-500">
|
||||||
|
This action cannot be undone
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-neutral-700 mb-6">
|
||||||
|
Are you sure you want to permanently delete{' '}
|
||||||
|
<strong>{formData.fullname}</strong>'s account? This will
|
||||||
|
remove all their data and cannot be reversed.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
className="px-4"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDeleteAccount}
|
||||||
|
className="px-4 bg-red-600 hover:bg-red-700 border-red-600"
|
||||||
|
>
|
||||||
|
Delete Account
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ModalUserDetail;
|
||||||
@@ -0,0 +1,573 @@
|
|||||||
|
import {
|
||||||
|
FC,
|
||||||
|
ReactElement,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useCallback,
|
||||||
|
} from 'react';
|
||||||
|
import ModalUserDetail from './_components/modal-user-detail';
|
||||||
|
import {
|
||||||
|
BackofficeWrapper,
|
||||||
|
DataTable,
|
||||||
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { cn } from '@imphnen-frontend-service/utils';
|
||||||
|
import {
|
||||||
|
EditOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
FilterOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
LoadingOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
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';
|
||||||
|
|
||||||
|
type UserType = TAdminUserItem;
|
||||||
|
|
||||||
|
// Skills options for filter
|
||||||
|
const skillsOptions = [
|
||||||
|
'Frontend Developer',
|
||||||
|
'Backend Developer',
|
||||||
|
'Full Stack Developer',
|
||||||
|
'DevOps Engineer',
|
||||||
|
'UI/UX Designer',
|
||||||
|
'Product Manager',
|
||||||
|
'Data Scientist',
|
||||||
|
'Mobile Developer',
|
||||||
|
];
|
||||||
|
|
||||||
|
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 [showNewUserModal, setShowNewUserModal] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
|
||||||
|
const [globalFilter, setGlobalFilter] = useState(searchQuery);
|
||||||
|
|
||||||
|
// Advanced filtering states
|
||||||
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
|
const [cityFilter, setCityFilter] = useState('all');
|
||||||
|
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Fetch users from API
|
||||||
|
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
|
||||||
|
const handleShowDetailModal = useCallback((user: UserType) => {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setShowDetailModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseDetailModal = useCallback(() => {
|
||||||
|
setShowDetailModal(false);
|
||||||
|
setSelectedUser(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleShowNewUserModal = useCallback(() => {
|
||||||
|
setShowNewUserModal(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCloseNewUserModal = useCallback(() => {
|
||||||
|
setShowNewUserModal(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Filter data based on current filter states
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
const usersData = usersResponse?.data || [];
|
||||||
|
return usersData.filter((user: UserType) => {
|
||||||
|
// Status filter
|
||||||
|
if (statusFilter !== 'all') {
|
||||||
|
const isActive = statusFilter === 'active';
|
||||||
|
if (user.is_active !== isActive) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skills filter
|
||||||
|
if (skillsFilter.length > 0) {
|
||||||
|
const userSkills = user.skills || [];
|
||||||
|
const hasMatchingSkill = skillsFilter.some((skill) =>
|
||||||
|
userSkills.includes(skill)
|
||||||
|
);
|
||||||
|
if (!hasMatchingSkill) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [usersResponse, statusFilter, skillsFilter]);
|
||||||
|
|
||||||
|
// Memoize columns to prevent recreation on every render
|
||||||
|
const columns: ColumnDef<UserType>[] = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'fullname',
|
||||||
|
header: 'User',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* Avatar */}
|
||||||
|
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden shrink-0">
|
||||||
|
{row.original.avatar ? (
|
||||||
|
<img
|
||||||
|
src={row.original.avatar}
|
||||||
|
alt={row.original.fullname}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<UserOutlined className="text-neutral-500 text-lg" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* Name only */}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-medium text-neutral-900 truncate">
|
||||||
|
{row.original.fullname}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'skills',
|
||||||
|
header: 'Skills',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const skills = row.original.skills || [];
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1 max-w-xs">
|
||||||
|
{skills.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{skills.slice(0, 2).map((skill, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-100 text-success-800"
|
||||||
|
>
|
||||||
|
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{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">
|
||||||
|
+{skills.length - 2}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-neutral-400">-</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'location',
|
||||||
|
header: 'Location',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-neutral-700">{row.original.location}</span>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'is_active',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-2 h-2 rounded-full',
|
||||||
|
row.original.is_active ? 'bg-success-500' : 'bg-neutral-400'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium',
|
||||||
|
row.original.is_active ? 'text-success-700' : 'text-neutral-500'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{row.original.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: true,
|
||||||
|
sortingFn: (rowA, rowB) => {
|
||||||
|
const aActive = rowA.original.is_active;
|
||||||
|
const bActive = rowB.original.is_active;
|
||||||
|
if (aActive && !bActive) return -1;
|
||||||
|
if (!aActive && bActive) return 1;
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'created_at',
|
||||||
|
header: 'Joined',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-neutral-900 text-sm">
|
||||||
|
{new Date(row.original.created_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 }) => (
|
||||||
|
<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>
|
||||||
|
{/* <Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
className="text-sm px-4 py-2"
|
||||||
|
onClick={() => {
|
||||||
|
// Toggle user status - implement later
|
||||||
|
console.log(`Toggle status for ${row.original.fullname}`);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{row.original.is_active ? 'Deactivate' : 'Activate'}
|
||||||
|
</Button> */}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[handleShowDetailModal]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||||
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
||||||
|
User Management
|
||||||
|
</h1>
|
||||||
|
{/* Filters and actions */}
|
||||||
|
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
||||||
|
<div className="flex flex-wrap gap-3 items-center justify-between">
|
||||||
|
{/* Left side - Search & filters */}
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="relative">
|
||||||
|
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
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"
|
||||||
|
placeholder="Search users by name or location..."
|
||||||
|
value={globalFilter}
|
||||||
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
onKeyPress={handleSearchKeyPress}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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-36 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="inactive">Inactive</option>
|
||||||
|
</select>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
{/* City Filter */}
|
||||||
|
{/* <CityFilterSelect
|
||||||
|
value={cityFilter}
|
||||||
|
onChange={setCityFilter}
|
||||||
|
className="w-full sm:w-44"
|
||||||
|
placeholder="Search cities..."
|
||||||
|
allOptionLabel="All Cities"
|
||||||
|
/>
|
||||||
|
{cityFilter !== 'all' && (
|
||||||
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
||||||
|
Location: {cityFilter}
|
||||||
|
<button
|
||||||
|
onClick={() => setCityFilter('all')}
|
||||||
|
className="text-green-600 hover:text-green-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)} */}
|
||||||
|
|
||||||
|
{/* Skills Filter with Icon */}
|
||||||
|
{/* <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-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
|
value=""
|
||||||
|
onChange={(e) => {
|
||||||
|
if (
|
||||||
|
e.target.value &&
|
||||||
|
!skillsFilter.includes(e.target.value)
|
||||||
|
) {
|
||||||
|
setSkillsFilter((prev) => [...prev, e.target.value]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Add Skill Filter</option>
|
||||||
|
{skillsOptions.map((skill) => (
|
||||||
|
<option
|
||||||
|
key={skill}
|
||||||
|
value={skill}
|
||||||
|
disabled={skillsFilter.includes(skill)}
|
||||||
|
>
|
||||||
|
{skill}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side - Add User Button */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="md"
|
||||||
|
className="flex items-center gap-2 px-4 py-2"
|
||||||
|
onClick={handleShowNewUserModal}
|
||||||
|
>
|
||||||
|
<PlusOutlined className="text-sm" />
|
||||||
|
Add User
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active filters display */}
|
||||||
|
{(skillsFilter.length > 0 ||
|
||||||
|
statusFilter !== 'all' ||
|
||||||
|
cityFilter !== 'all') && (
|
||||||
|
<div className="flex flex-wrap gap-2 items-center">
|
||||||
|
<span className="text-sm text-neutral-600">Active filters:</span>
|
||||||
|
|
||||||
|
{/* Status filter badge */}
|
||||||
|
{statusFilter !== 'all' && (
|
||||||
|
<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={() => setStatusFilter('all')}
|
||||||
|
className="text-info-600 hover:text-info-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Location 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skills filter badges */}
|
||||||
|
{skillsFilter.map((skill) => (
|
||||||
|
<span
|
||||||
|
key={skill}
|
||||||
|
className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded-2xl text-sm"
|
||||||
|
>
|
||||||
|
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
|
||||||
|
}
|
||||||
|
className="text-purple-600 hover:text-purple-800 cursor-pointer"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Clear all filters */}
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setStatusFilter('all');
|
||||||
|
setCityFilter('all');
|
||||||
|
setSkillsFilter([]);
|
||||||
|
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 users...</span>
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Modals component */}
|
||||||
|
<ModalUserDetail
|
||||||
|
isOpen={showDetailModal}
|
||||||
|
onClose={handleCloseDetailModal}
|
||||||
|
user={selectedUser}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* New User Modal */}
|
||||||
|
<ModalUserDetail
|
||||||
|
isOpen={showNewUserModal}
|
||||||
|
onClose={handleCloseNewUserModal}
|
||||||
|
user={null} // null indicates creating new user
|
||||||
|
/>
|
||||||
|
</BackofficeWrapper>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HackathonUsersPage;
|
||||||
@@ -1,13 +1,51 @@
|
|||||||
import { FC, ReactElement } from 'react';
|
import { FC, ReactElement, useState } from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
|
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
|
||||||
export const AppLayout: FC = (): ReactElement => {
|
export const AppLayout: FC = (): ReactElement => {
|
||||||
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-primary-50 min-h-screen flex justify-center">
|
<div className="bg-primary-50 min-h-screen flex justify-center">
|
||||||
<div className="bg-primary-50 min-h-screen w-full flex">
|
<div className="bg-primary-50 min-h-screen w-full flex">
|
||||||
<BackofficeSidebar />
|
<BackofficeSidebar
|
||||||
|
isOpen={mobileSidebarOpen}
|
||||||
|
onClose={() => setMobileSidebarOpen(false)}
|
||||||
|
/>
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
|
{/* Sticky top header */}
|
||||||
|
<header
|
||||||
|
className={
|
||||||
|
'lg:hidden sticky top-0 bg-white border-b border-primary-200 px-4 py-3 flex items-center gap-3 ' +
|
||||||
|
(mobileSidebarOpen ? 'z-0' : 'z-30')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{/* Mobile menu button (shown on small screens) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lg:hidden p-2 rounded-md hover:bg-gray-100 text-gray-700 cursor-pointer"
|
||||||
|
onClick={() => setMobileSidebarOpen(true)}
|
||||||
|
aria-label="Open sidebar"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-5 h-5"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 6h16M4 12h16M4 18h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<h1 className="text-p3 font-semibold text-primary-700">
|
||||||
|
IMPHNEN Backoffice
|
||||||
|
</h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
import { SearchOutlined } from "@ant-design/icons";
|
import { SearchOutlined } from '@ant-design/icons';
|
||||||
import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
|
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
|
import {
|
||||||
import { cn, For } from "@imphnen-frontend-service/utils";
|
BackofficeWrapper,
|
||||||
import { ColumnDef, getCoreRowModel, getPaginationRowModel, PaginationState, RowSelectionState, useReactTable } from "@tanstack/react-table";
|
DataTable,
|
||||||
import { ReactElement, useState } from "react";
|
} from '@imphnen-frontend-service/ui/organisms';
|
||||||
import { ModalDetailUser } from "./_components/modal/detail";
|
import { cn, For } from '@imphnen-frontend-service/utils';
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
PaginationState,
|
||||||
|
RowSelectionState,
|
||||||
|
useReactTable,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import { ReactElement, useState } from 'react';
|
||||||
|
import { ModalDetailUser } from './_components/modal/detail';
|
||||||
|
|
||||||
type UserStatus = 'active' | 'inactive';
|
type UserStatus = 'active' | 'inactive';
|
||||||
|
|
||||||
interface UserType {
|
interface UserType {
|
||||||
id: number
|
id: number;
|
||||||
name: string
|
name: string;
|
||||||
email: string
|
email: string;
|
||||||
rating: number
|
rating: number;
|
||||||
status: UserStatus
|
status: UserStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
|
const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||||
@@ -22,15 +32,15 @@ const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
|
|||||||
email: 'fullname23@gmail.com',
|
email: 'fullname23@gmail.com',
|
||||||
rating: 4.5,
|
rating: 4.5,
|
||||||
status: i % 2 === 0 ? 'active' : 'inactive',
|
status: i % 2 === 0 ? 'active' : 'inactive',
|
||||||
}))
|
}));
|
||||||
|
|
||||||
export default function Components(): ReactElement {
|
export default function Components(): ReactElement {
|
||||||
const TABS = ['mentor', 'mentee'] as const
|
const TABS = ['mentor', 'mentee'] as const;
|
||||||
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor')
|
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor');
|
||||||
const [showDetail, setShowDetail] = useState(false)
|
const [showDetail, setShowDetail] = useState(false);
|
||||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
|
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
||||||
|
|
||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
const [pagination, setPagination] = useState<PaginationState>({
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
pageSize: 9,
|
pageSize: 9,
|
||||||
@@ -39,7 +49,7 @@ export default function Components(): ReactElement {
|
|||||||
const columns: ColumnDef<UserType>[] = [
|
const columns: ColumnDef<UserType>[] = [
|
||||||
{
|
{
|
||||||
id: 'select',
|
id: 'select',
|
||||||
meta: { cellClassName: cn("w-20") },
|
meta: { cellClassName: cn('w-20') },
|
||||||
header: ({ table }) => (
|
header: ({ table }) => (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -97,7 +107,7 @@ export default function Components(): ReactElement {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Action',
|
header: 'Action',
|
||||||
meta: { cellClassName: cn("w-72") },
|
meta: { cellClassName: cn('w-72') },
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -113,7 +123,7 @@ export default function Components(): ReactElement {
|
|||||||
</Button>
|
</Button>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data: mockData,
|
data: mockData,
|
||||||
@@ -134,14 +144,19 @@ export default function Components(): ReactElement {
|
|||||||
return (
|
return (
|
||||||
<BackofficeWrapper title="Dimentorin.dev">
|
<BackofficeWrapper title="Dimentorin.dev">
|
||||||
<div className="mb-8 flex justify-between items-center">
|
<div className="mb-8 flex justify-between items-center">
|
||||||
<h1 className="text-p1 font-semibold text-neutral-700">User Management</h1>
|
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">
|
||||||
|
User Management
|
||||||
|
</h1>
|
||||||
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
|
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
|
||||||
<For data={TABS}>
|
<For data={TABS}>
|
||||||
{(tab) => (
|
{(tab) => (
|
||||||
<Button
|
<Button
|
||||||
key={tab}
|
key={tab}
|
||||||
variant="text"
|
variant="text"
|
||||||
className={cn("px-3 py-2 capitalize", activeTab === tab && "bg-white")}
|
className={cn(
|
||||||
|
'px-3 py-2 capitalize',
|
||||||
|
activeTab === tab && 'bg-white'
|
||||||
|
)}
|
||||||
onClick={() => setActiveTab(tab)}
|
onClick={() => setActiveTab(tab)}
|
||||||
>
|
>
|
||||||
{tab}
|
{tab}
|
||||||
@@ -163,12 +178,16 @@ export default function Components(): ReactElement {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Select>
|
<Select>
|
||||||
<option selected disabled>Rating</option>
|
<option selected disabled>
|
||||||
|
Rating
|
||||||
|
</option>
|
||||||
<option value="4.5">4.5</option>
|
<option value="4.5">4.5</option>
|
||||||
<option value="5">5</option>
|
<option value="5">5</option>
|
||||||
</Select>
|
</Select>
|
||||||
<Select>
|
<Select>
|
||||||
<option selected disabled>Status</option>
|
<option selected disabled>
|
||||||
|
Status
|
||||||
|
</option>
|
||||||
<option value="active">Active</option>
|
<option value="active">Active</option>
|
||||||
<option value="inactive">Inactive</option>
|
<option value="inactive">Inactive</option>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
@@ -130,7 +130,7 @@
|
|||||||
html {
|
html {
|
||||||
font-family: 'Bai Jamjuree', sans-serif;
|
font-family: 'Bai Jamjuree', sans-serif;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 12px;
|
font-size: 14px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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.
|
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 |
@@ -0,0 +1,470 @@
|
|||||||
|
import { FC, ReactElement, useState, useEffect, useRef } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { decodeCertificateId } from '../../../utils/certificate';
|
||||||
|
import {
|
||||||
|
useCertificatePublicData,
|
||||||
|
useAuthStore,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
import html2canvas from 'html2canvas';
|
||||||
|
|
||||||
|
interface DecodedCert {
|
||||||
|
teamId: string;
|
||||||
|
submissionId: string;
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CertificatePage: FC = (): ReactElement => {
|
||||||
|
const { certId } = useParams<{ certId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { session } = useAuthStore();
|
||||||
|
const [decodedInfo, setDecodedInfo] = useState<DecodedCert | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const teamNameRef = useRef<HTMLHeadingElement>(null);
|
||||||
|
const userNameRef = useRef<HTMLHeadingElement>(null);
|
||||||
|
const [teamNameFontSize, setTeamNameFontSize] = useState('2.25rem');
|
||||||
|
const [userNameFontSize, setUserNameFontSize] = useState('2.25rem');
|
||||||
|
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
|
||||||
|
const certificateRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [certificateImage, setCertificateImage] = useState<string>('');
|
||||||
|
const [showTemplate, setShowTemplate] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (certId) {
|
||||||
|
decodeCertificateId(certId)
|
||||||
|
.then(setDecodedInfo)
|
||||||
|
.catch(() => {
|
||||||
|
setError('Invalid certificate ID');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [certId]);
|
||||||
|
|
||||||
|
// Fetch certificate data using the new endpoint
|
||||||
|
const { data: certificateData, isLoading: isLoadingCertificate } =
|
||||||
|
useCertificatePublicData(decodedInfo?.userId || '', !!decodedInfo?.userId);
|
||||||
|
|
||||||
|
// Generate QR Code
|
||||||
|
useEffect(() => {
|
||||||
|
if (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, {
|
||||||
|
width: 200,
|
||||||
|
margin: 1,
|
||||||
|
color: {
|
||||||
|
dark: '#000000',
|
||||||
|
light: '#ffffff',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(setQrCodeUrl)
|
||||||
|
.catch((err) => console.error('QR Code generation failed:', err));
|
||||||
|
}
|
||||||
|
}, [certId]);
|
||||||
|
|
||||||
|
const certificate = certificateData?.data;
|
||||||
|
const team = certificate?.team;
|
||||||
|
const submission = certificate?.submission;
|
||||||
|
const certificateUser = certificate?.user;
|
||||||
|
|
||||||
|
const isLoading = (!decodedInfo && !error) || isLoadingCertificate;
|
||||||
|
|
||||||
|
// Certificate name from the user data
|
||||||
|
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
|
||||||
|
useEffect(() => {
|
||||||
|
const adjustFontSize = (
|
||||||
|
element: HTMLElement | null,
|
||||||
|
maxHeight: number,
|
||||||
|
startSize: number,
|
||||||
|
setter: (size: string) => void
|
||||||
|
) => {
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
let currentSize = startSize;
|
||||||
|
element.style.fontSize = `${currentSize}px`;
|
||||||
|
|
||||||
|
while (element.offsetHeight > maxHeight && currentSize > 1) {
|
||||||
|
currentSize -= 2;
|
||||||
|
element.style.fontSize = `${currentSize}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setter(`${currentSize}px`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
adjustFontSize(teamNameRef.current, 80, 20, setTeamNameFontSize);
|
||||||
|
adjustFontSize(userNameRef.current, 80, 36, setUserNameFontSize);
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [team?.name, certificateName]);
|
||||||
|
|
||||||
|
// Generate certificate canvas screenshot
|
||||||
|
useEffect(() => {
|
||||||
|
const generateCertificate = async () => {
|
||||||
|
if (!certificateRef.current || !team || !submission || !qrCodeUrl) return;
|
||||||
|
|
||||||
|
setIsGenerating(true);
|
||||||
|
try {
|
||||||
|
// Wait longer for fonts and images to load properly
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
const canvas = await html2canvas(certificateRef.current, {
|
||||||
|
scale: 4,
|
||||||
|
useCORS: true,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
logging: false,
|
||||||
|
width: 1000,
|
||||||
|
height: (1000 * 2480) / 3508,
|
||||||
|
allowTaint: true,
|
||||||
|
imageTimeout: 0,
|
||||||
|
removeContainer: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageUrl = canvas.toDataURL('image/png', 1.0);
|
||||||
|
setCertificateImage(imageUrl);
|
||||||
|
setShowTemplate(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate certificate:', error);
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
generateCertificate();
|
||||||
|
}, [team, submission, qrCodeUrl]);
|
||||||
|
|
||||||
|
// Download certificate
|
||||||
|
const handleDownloadCertificate = () => {
|
||||||
|
if (!certificateImage) return;
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = certificateImage;
|
||||||
|
link.download = `certificate-${team?.name || 'hackathon'}.png`;
|
||||||
|
link.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Print certificate
|
||||||
|
const handlePrintCertificate = () => {
|
||||||
|
if (!certificateImage) return;
|
||||||
|
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
if (printWindow) {
|
||||||
|
printWindow.document.write(`
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Certificate - ${team?.name}</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
||||||
|
img { max-width: 100%; height: auto; }
|
||||||
|
@media print {
|
||||||
|
@page { size: A4 landscape; margin: 0; }
|
||||||
|
body { margin: 0; }
|
||||||
|
img { width: 100%; height: auto; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<img src="${certificateImage}" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
printWindow.document.close();
|
||||||
|
printWindow.onload = () => {
|
||||||
|
printWindow.print();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error || !certId) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<div className="text-6xl mb-4">❌</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Invalid Certificate
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
{error || 'The certificate ID is invalid or malformed.'}
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => navigate('/')}>Back to Home</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<div className="text-center">
|
||||||
|
<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 (!certificateUser) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<div className="text-6xl mb-4">📄</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Certificate Not Found
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
The user associated with this certificate could not be found.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => navigate('/')}>Back to Home</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
{/* Print Styles */}
|
||||||
|
<style>{`
|
||||||
|
@media print {
|
||||||
|
@page {
|
||||||
|
size: A4 landscape;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
body * {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
#certificate-wrapper {
|
||||||
|
visibility: visible;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
#certificate, #certificate * {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
#certificate {
|
||||||
|
position: relative;
|
||||||
|
max-width: 100%;
|
||||||
|
page-break-after: avoid;
|
||||||
|
}
|
||||||
|
.no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile responsive - zoom out to fit */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#certificate-container {
|
||||||
|
transform-origin: top center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 no-print">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Certificate
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{team?.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{team && isTeamMember && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => navigate(`/teams/${team.id}/submission`)}
|
||||||
|
>
|
||||||
|
Back to Submission
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Certificate Content */}
|
||||||
|
<div
|
||||||
|
className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12"
|
||||||
|
id="certificate-wrapper"
|
||||||
|
>
|
||||||
|
{/* Hidden Template for Canvas Generation */}
|
||||||
|
<div
|
||||||
|
className={showTemplate ? 'block' : 'hidden'}
|
||||||
|
style={{ position: 'absolute', left: '-9999px' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={certificateRef}
|
||||||
|
id="certificate-template"
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
backgroundImage: 'url(/images/blank_cert.svg)',
|
||||||
|
backgroundSize: 'cover',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
width: '1000px',
|
||||||
|
height: `${(1000 * 2480) / 3508}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Team Name - positioned in middle between "Diberikan Kepada" and "Telah Berpartisipasi" */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '41%',
|
||||||
|
left: '3.5%',
|
||||||
|
width: '55%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
ref={teamNameRef}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Poppins, sans-serif',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#59bef5',
|
||||||
|
textAlign: 'left',
|
||||||
|
fontSize: '32px',
|
||||||
|
lineHeight: '1.2',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{team?.name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Name - positioned below team name */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '45%',
|
||||||
|
left: '3.5%',
|
||||||
|
width: '55%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
ref={userNameRef}
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Poppins, sans-serif',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#59bef5',
|
||||||
|
textAlign: 'left',
|
||||||
|
fontSize: '40px',
|
||||||
|
lineHeight: '1.2',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{certificateName || 'N/A'}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* QR Code - positioned in the white box area */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '33%',
|
||||||
|
right: '9.3%',
|
||||||
|
width: '190px',
|
||||||
|
height: '190px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{qrCodeUrl && (
|
||||||
|
<img
|
||||||
|
src={qrCodeUrl}
|
||||||
|
alt="Certificate QR Code"
|
||||||
|
style={{ width: '190px', height: '190px', 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="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 ? '⏳ Generating...' : '📥 Download'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handlePrintCertificate}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
disabled={isGenerating}
|
||||||
|
>
|
||||||
|
{isGenerating ? '⏳ Generating...' : '🖨️ Print'}
|
||||||
|
</Button>
|
||||||
|
{team && (
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate(`/teams/${team.id}/submission`)}
|
||||||
|
variant="secondary"
|
||||||
|
className="col-span-2 flex items-center gap-2 xl:col-span-1"
|
||||||
|
>
|
||||||
|
View Submission
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className="mt-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6 no-print">
|
||||||
|
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
|
||||||
|
Certificate Information
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
This certificate is a digital record of your hackathon participation
|
||||||
|
and project submission. You can print or save this page as a PDF for
|
||||||
|
your records.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CertificatePage;
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
import { FC, ReactElement, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { useNavigate, useParams } from 'react-router';
|
||||||
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import { decodeWinnerCertificateId } from '../../../../utils/certificate';
|
||||||
|
import {
|
||||||
|
useAuthStore,
|
||||||
|
useMyTeams,
|
||||||
|
useTeamById,
|
||||||
|
useTeamSubmission,
|
||||||
|
useWinners,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import QRCode from 'qrcode';
|
||||||
|
import html2canvas from 'html2canvas';
|
||||||
|
|
||||||
|
type WinnerEntry = {
|
||||||
|
team_id: string;
|
||||||
|
rank: number;
|
||||||
|
team?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatOrdinalRank = (rank: number): string => {
|
||||||
|
const mod100 = rank % 100;
|
||||||
|
if (mod100 >= 11 && mod100 <= 13) return `${rank}th`;
|
||||||
|
|
||||||
|
switch (rank % 10) {
|
||||||
|
case 1:
|
||||||
|
return `${rank}st`;
|
||||||
|
case 2:
|
||||||
|
return `${rank}nd`;
|
||||||
|
case 3:
|
||||||
|
return `${rank}rd`;
|
||||||
|
default:
|
||||||
|
return `${rank}th`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Keep the template size aligned with the SVG native size (842x595) using an integer multiplier.
|
||||||
|
// This reduces sub-pixel scaling artifacts (blur) on thin lines when rasterizing with html2canvas.
|
||||||
|
const CERT_WIDTH = 842 * 2;
|
||||||
|
const CERT_HEIGHT = 595 * 2;
|
||||||
|
|
||||||
|
// Balance between output sharpness and file size.
|
||||||
|
// Output resolution will be (CERT_WIDTH * EXPORT_SCALE) x (CERT_HEIGHT * EXPORT_SCALE).
|
||||||
|
const EXPORT_SCALE = 2;
|
||||||
|
|
||||||
|
// The original layout was tuned around a ~1000px-wide template.
|
||||||
|
// We keep the same visual proportions by scaling fixed pixel values.
|
||||||
|
const LAYOUT_BASE_WIDTH = 1000;
|
||||||
|
const LAYOUT_SCALE = CERT_WIDTH / LAYOUT_BASE_WIDTH;
|
||||||
|
const s = (px: number) => Math.round(px * LAYOUT_SCALE);
|
||||||
|
|
||||||
|
const CertificateWinnerPage: FC = (): ReactElement => {
|
||||||
|
const { certId } = useParams<{ certId: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { session } = useAuthStore();
|
||||||
|
const { data: myTeamsData } = useMyTeams();
|
||||||
|
const {
|
||||||
|
data: winnersResponse,
|
||||||
|
isLoading: isLoadingWinners,
|
||||||
|
isError: isWinnersError,
|
||||||
|
} = useWinners();
|
||||||
|
|
||||||
|
const [decodedTeamId, setDecodedTeamId] = useState<string>('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
|
||||||
|
const certificateRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [isGenerating, setIsGenerating] = useState(false);
|
||||||
|
const [certificateImage, setCertificateImage] = useState<string>('');
|
||||||
|
const [showTemplate, setShowTemplate] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!certId) return;
|
||||||
|
|
||||||
|
decodeWinnerCertificateId(certId)
|
||||||
|
.then((decoded) => setDecodedTeamId(decoded.teamId))
|
||||||
|
.catch(() => setError('Invalid certificate ID'));
|
||||||
|
}, [certId]);
|
||||||
|
|
||||||
|
const winners = useMemo(
|
||||||
|
() => (winnersResponse?.data || []) as WinnerEntry[],
|
||||||
|
[winnersResponse?.data]
|
||||||
|
);
|
||||||
|
const winnerEntry = useMemo(() => {
|
||||||
|
if (!decodedTeamId) return undefined;
|
||||||
|
return winners.find((w) => w.team_id === decodedTeamId);
|
||||||
|
}, [decodedTeamId, winners]);
|
||||||
|
|
||||||
|
const rankLabel = useMemo(() => {
|
||||||
|
if (!winnerEntry?.rank) return '';
|
||||||
|
return formatOrdinalRank(winnerEntry.rank);
|
||||||
|
}, [winnerEntry?.rank]);
|
||||||
|
|
||||||
|
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
|
||||||
|
decodedTeamId,
|
||||||
|
!!decodedTeamId
|
||||||
|
);
|
||||||
|
|
||||||
|
const { data: submissionData, isLoading: isLoadingSubmission } =
|
||||||
|
useTeamSubmission(decodedTeamId, !!decodedTeamId);
|
||||||
|
|
||||||
|
const team = teamData?.data;
|
||||||
|
const submission = submissionData?.data;
|
||||||
|
const submissionName = submission?.project_name || '(Submission unavailable)';
|
||||||
|
|
||||||
|
const memberNames = useMemo(() => {
|
||||||
|
const members = team?.members || [];
|
||||||
|
return members
|
||||||
|
.map((m) => m.user?.fullname)
|
||||||
|
.filter((name): name is string => !!name);
|
||||||
|
}, [team?.members]);
|
||||||
|
|
||||||
|
const isWinnerTeam = !!winnerEntry;
|
||||||
|
const isTeamMember =
|
||||||
|
!!session?.user?.id &&
|
||||||
|
!!decodedTeamId &&
|
||||||
|
(myTeamsData?.data || []).some(
|
||||||
|
(t) => (t as { id?: string } | null | undefined)?.id === decodedTeamId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Generate QR Code (public link)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!certId) return;
|
||||||
|
|
||||||
|
const encodedCertId = encodeURIComponent(certId);
|
||||||
|
const certificateUrl = `${window.location.origin}/certificate/winner/${encodedCertId}`;
|
||||||
|
|
||||||
|
QRCode.toDataURL(certificateUrl, {
|
||||||
|
width: s(200),
|
||||||
|
margin: 1,
|
||||||
|
color: {
|
||||||
|
dark: '#000000',
|
||||||
|
light: '#ffffff',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(setQrCodeUrl)
|
||||||
|
.catch((err) => console.error('QR Code generation failed:', err));
|
||||||
|
}, [certId]);
|
||||||
|
|
||||||
|
// Generate certificate canvas screenshot
|
||||||
|
useEffect(() => {
|
||||||
|
const generateCertificate = async () => {
|
||||||
|
if (!certificateRef.current) return;
|
||||||
|
if (!team?.name) return;
|
||||||
|
if (!qrCodeUrl) return;
|
||||||
|
if (!winnerEntry?.rank) return;
|
||||||
|
if (isLoadingSubmission) return;
|
||||||
|
|
||||||
|
setIsGenerating(true);
|
||||||
|
try {
|
||||||
|
setShowTemplate(true);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||||
|
|
||||||
|
const canvas = await html2canvas(certificateRef.current, {
|
||||||
|
scale: EXPORT_SCALE,
|
||||||
|
useCORS: true,
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
logging: false,
|
||||||
|
width: CERT_WIDTH,
|
||||||
|
height: CERT_HEIGHT,
|
||||||
|
allowTaint: true,
|
||||||
|
imageTimeout: 0,
|
||||||
|
removeContainer: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const imageUrl = canvas.toDataURL('image/png', 1.0);
|
||||||
|
setCertificateImage(imageUrl);
|
||||||
|
setShowTemplate(false);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to generate certificate:', e);
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
generateCertificate();
|
||||||
|
}, [
|
||||||
|
team?.name,
|
||||||
|
memberNames,
|
||||||
|
qrCodeUrl,
|
||||||
|
winnerEntry?.rank,
|
||||||
|
isLoadingSubmission,
|
||||||
|
submissionName,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const handleDownloadCertificate = () => {
|
||||||
|
if (!certificateImage) return;
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = certificateImage;
|
||||||
|
link.download = `winner-certificate-${team?.name || 'hackathon'}.png`;
|
||||||
|
link.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePrintCertificate = () => {
|
||||||
|
if (!certificateImage) return;
|
||||||
|
|
||||||
|
const printWindow = window.open('', '_blank');
|
||||||
|
if (!printWindow) return;
|
||||||
|
|
||||||
|
printWindow.document.write(`
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Certificate - ${team?.name}</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
||||||
|
img { max-width: 100%; height: auto; }
|
||||||
|
@media print {
|
||||||
|
@page { size: A4 landscape; margin: 0; }
|
||||||
|
body { margin: 0; }
|
||||||
|
img { width: 100%; height: auto; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<img src="${certificateImage}" />
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
printWindow.document.close();
|
||||||
|
printWindow.onload = () => {
|
||||||
|
printWindow.print();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
if (error || !certId) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Invalid Certificate
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
{error || 'The certificate ID is invalid or malformed.'}
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => navigate('/')}>Back to Home</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!decodedTeamId || isLoadingTeam) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">
|
||||||
|
Loading certificate...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoadingWinners) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">
|
||||||
|
Loading winners...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWinnersError) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Unable to Load Winners
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
Please try again later.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => navigate('/')}>Back to Home</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isWinnerTeam) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Certificate Not Found
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
This team is not listed as a hackathon winner.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => navigate('/')}>Back to Home</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!team?.name) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Team Not Found
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
The team associated with this certificate could not be loaded.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => navigate('/')}>Back to Home</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 no-print">
|
||||||
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Winner Certificate
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{team.name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{isTeamMember && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => navigate('/dashboard')}
|
||||||
|
>
|
||||||
|
Back to Dashboard
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12"
|
||||||
|
id="certificate-wrapper"
|
||||||
|
>
|
||||||
|
{/* Hidden Template for Canvas Generation */}
|
||||||
|
<div
|
||||||
|
className={showTemplate ? 'block' : 'hidden'}
|
||||||
|
style={{ position: 'absolute', left: '-9999px' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={certificateRef}
|
||||||
|
id="certificate-template"
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
backgroundImage: 'url(/images/blank_winner_cert.svg)',
|
||||||
|
backgroundSize: '100% 100%',
|
||||||
|
backgroundRepeat: 'no-repeat',
|
||||||
|
backgroundPosition: 'center',
|
||||||
|
width: `${CERT_WIDTH}px`,
|
||||||
|
height: `${CERT_HEIGHT}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<style>{`
|
||||||
|
#winner-members li::marker {
|
||||||
|
color: #59bef5;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
|
||||||
|
{/* Team Name */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '35%',
|
||||||
|
left: '3.5%',
|
||||||
|
width: '55%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
style={{
|
||||||
|
fontFamily: 'Poppins, sans-serif',
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#59bef5',
|
||||||
|
textAlign: 'left',
|
||||||
|
fontSize: `${s(28)}px`,
|
||||||
|
lineHeight: '1.2',
|
||||||
|
wordBreak: 'break-word',
|
||||||
|
margin: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{team.name}
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Members list */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '40.5%',
|
||||||
|
left: '3.5%',
|
||||||
|
width: '55%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
id="winner-members"
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontFamily: 'Poppins, sans-serif',
|
||||||
|
fontSize: `${s(18)}px`,
|
||||||
|
lineHeight: '1.35',
|
||||||
|
color: '#59bef5',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{(memberNames.length
|
||||||
|
? memberNames
|
||||||
|
: ['(Members unavailable)']
|
||||||
|
).map((name) => (
|
||||||
|
<li key={name}>• {name}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Award text */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '60%',
|
||||||
|
left: '3.5%',
|
||||||
|
width: '60%',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontFamily: 'Poppins, sans-serif',
|
||||||
|
fontSize: `${s(18)}px`,
|
||||||
|
lineHeight: '1.35',
|
||||||
|
color: '#6B6B6B',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Diberikan sebagai penghargaan atas pencapaian meraih
|
||||||
|
<br />
|
||||||
|
<b>JUARA {winnerEntry.rank}</b> pada Hackathon IMPHNEN x
|
||||||
|
Kolosal.ai
|
||||||
|
<br />
|
||||||
|
<span>
|
||||||
|
dengan nama project: <b>{submissionName}</b>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rank badge */}
|
||||||
|
{!!rankLabel && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '8%',
|
||||||
|
right: '8.5%',
|
||||||
|
width: `${s(190)}px`,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: `${s(6)}px ${s(16)}px`,
|
||||||
|
textAlign: 'center',
|
||||||
|
fontFamily: 'Poppins, sans-serif',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: '#78350F',
|
||||||
|
fontSize: `${s(24)}px`,
|
||||||
|
lineHeight: '1',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{rankLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* QR Code (same placement as existing certificate page) */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '33%',
|
||||||
|
right: '9.3%',
|
||||||
|
width: `${s(190)}px`,
|
||||||
|
height: `${s(190)}px`,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{qrCodeUrl && (
|
||||||
|
<img
|
||||||
|
src={qrCodeUrl}
|
||||||
|
alt="Certificate QR Code"
|
||||||
|
style={{
|
||||||
|
width: `${s(190)}px`,
|
||||||
|
height: `${s(190)}px`,
|
||||||
|
display: 'block',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Display Certificate Image */}
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 overflow-hidden p-2">
|
||||||
|
{isGenerating && (
|
||||||
|
<div className="flex items-center justify-center p-12">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">
|
||||||
|
Generating certificate...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{certificateImage && !isGenerating && (
|
||||||
|
<img
|
||||||
|
src={certificateImage}
|
||||||
|
alt="Winner Certificate"
|
||||||
|
className="w-full h-auto"
|
||||||
|
style={{ maxWidth: '100%', height: 'auto' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
{isTeamMember && (
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-900 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handleDownloadCertificate}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
disabled={isGenerating}
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<>
|
||||||
|
<Icon
|
||||||
|
icon="svg-spinners:ring-resize"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
/>
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon
|
||||||
|
icon="heroicons:arrow-down-tray"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
/>
|
||||||
|
Download
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={handlePrintCertificate}
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
disabled={isGenerating}
|
||||||
|
>
|
||||||
|
{isGenerating ? (
|
||||||
|
<>
|
||||||
|
<Icon
|
||||||
|
icon="svg-spinners:ring-resize"
|
||||||
|
width="18"
|
||||||
|
height="18"
|
||||||
|
/>
|
||||||
|
Generating...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Icon icon="mdi:printer" width="18" height="18" />
|
||||||
|
Print
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => navigate('/dashboard')}
|
||||||
|
variant="secondary"
|
||||||
|
className="col-span-2 flex items-center gap-2 xl:col-span-1"
|
||||||
|
>
|
||||||
|
Back to Dashboard
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info Box */}
|
||||||
|
<div className="mt-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6 no-print">
|
||||||
|
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
|
||||||
|
Certificate Information
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
This certificate is a digital record of your hackathon achievement.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CertificateWinnerPage;
|
||||||
@@ -1,19 +1,24 @@
|
|||||||
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');
|
||||||
|
|
||||||
|
// Submission deadline: 2025-12-07 23:59:00 WIB (UTC+7)
|
||||||
|
const SUBMISSION_DEADLINE = new Date('2025-12-07T16:59:00Z');
|
||||||
|
|
||||||
type Invitation = {
|
type Invitation = {
|
||||||
id: string;
|
id: string;
|
||||||
team: {
|
team: {
|
||||||
@@ -36,10 +41,48 @@ 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<{
|
||||||
|
days: number;
|
||||||
|
hours: number;
|
||||||
|
minutes: number;
|
||||||
|
seconds: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
// Check if team features are closed
|
// Check if team features are closed
|
||||||
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
||||||
|
|
||||||
|
// Check if submission deadline passed
|
||||||
|
const isSubmissionDeadlinePassed = new Date() >= SUBMISSION_DEADLINE;
|
||||||
|
|
||||||
|
// Countdown timer
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSubmissionDeadlinePassed) return;
|
||||||
|
|
||||||
|
const calculateTimeLeft = () => {
|
||||||
|
const now = new Date();
|
||||||
|
const difference = SUBMISSION_DEADLINE.getTime() - now.getTime();
|
||||||
|
|
||||||
|
if (difference <= 0) {
|
||||||
|
setTimeLeft(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = Math.floor(difference / (1000 * 60 * 60 * 24));
|
||||||
|
const hours = Math.floor((difference / (1000 * 60 * 60)) % 24);
|
||||||
|
const minutes = Math.floor((difference / 1000 / 60) % 60);
|
||||||
|
const seconds = Math.floor((difference / 1000) % 60);
|
||||||
|
|
||||||
|
setTimeLeft({ days, hours, minutes, seconds });
|
||||||
|
};
|
||||||
|
|
||||||
|
calculateTimeLeft();
|
||||||
|
const timer = setInterval(calculateTimeLeft, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [isSubmissionDeadlinePassed]);
|
||||||
|
|
||||||
// Lock background scroll when profile modal is open
|
// Lock background scroll when profile modal is open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (showProfileModal) {
|
if (showProfileModal) {
|
||||||
@@ -51,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();
|
||||||
|
|
||||||
@@ -59,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' });
|
||||||
@@ -94,6 +145,88 @@ 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 */}
|
||||||
|
{timeLeft && !isSubmissionDeadlinePassed && (
|
||||||
|
<div className="mb-8 bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 rounded-lg p-6">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<span className="text-3xl">⏰</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-bold text-blue-900 dark:text-blue-100 text-lg">
|
||||||
|
Submission Deadline
|
||||||
|
</h3>
|
||||||
|
<p className="text-blue-800 dark:text-blue-200 mt-2 text-sm font-sans">
|
||||||
|
Project submissions close on December 7, 2025 at 23:59 WIB
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 grid grid-cols-4 gap-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{timeLeft.days}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Days
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{timeLeft.hours.toString().padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Hours
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{timeLeft.minutes.toString().padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Minutes
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{timeLeft.seconds.toString().padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Seconds
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{invitations.length > 0 && (
|
{invitations.length > 0 && (
|
||||||
<div className="mb-8 bg-primary-50 dark:bg-blue-900/20 border border-primary-200 dark:border-blue-800 rounded-lg p-6">
|
<div className="mb-8 bg-primary-50 dark:bg-blue-900/20 border border-primary-200 dark:border-blue-800 rounded-lg p-6">
|
||||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
@@ -103,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>
|
||||||
)}
|
)}
|
||||||
@@ -172,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">
|
||||||
@@ -182,19 +319,33 @@ const DashboardPage: FC = (): ReactElement => {
|
|||||||
<div className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-3 font-sans mt-2">
|
<div className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-3 font-sans mt-2">
|
||||||
{team.has_submission && (
|
{team.has_submission && (
|
||||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 shrink-0">
|
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 shrink-0">
|
||||||
<Icon icon="mdi:check-circle" className="text-sm" />
|
<Icon
|
||||||
|
icon="mdi:check-circle"
|
||||||
|
className="text-sm"
|
||||||
|
/>
|
||||||
Submitted
|
Submitted
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{team.city && (
|
{team.city && (
|
||||||
<span className="flex items-center gap-1 truncate">
|
<span className="flex items-center gap-1 truncate">
|
||||||
<Icon icon="mdi:map-marker" className="shrink-0" />
|
<Icon
|
||||||
|
icon="mdi:map-marker"
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
<span className="truncate">{team.city}</span>
|
<span className="truncate">{team.city}</span>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<span className="flex items-center gap-1 shrink-0">
|
<span className="flex items-center gap-1 shrink-0">
|
||||||
<Icon icon="mdi:account-group" />
|
<Icon icon="mdi:account-group" />
|
||||||
{team.member_count || team.members?.length || 0} member{(team.member_count || team.members?.length || 0) !== 1 ? 's' : ''}
|
{team.member_count ||
|
||||||
|
team.members?.length ||
|
||||||
|
0}{' '}
|
||||||
|
member
|
||||||
|
{(team.member_count ||
|
||||||
|
team.members?.length ||
|
||||||
|
0) !== 1
|
||||||
|
? 's'
|
||||||
|
: ''}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,6 +52,12 @@ export default function RootLayout() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Certificate page - allow public access
|
||||||
|
if (pathname.startsWith('/certificate/')) {
|
||||||
|
setIsChecking(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Require authentication for all other routes
|
// Require authentication for all other routes
|
||||||
if (!session) {
|
if (!session) {
|
||||||
navigate('/auth/login', { replace: true });
|
navigate('/auth/login', { replace: true });
|
||||||
|
|||||||
@@ -554,7 +554,7 @@ export default function HomePage() {
|
|||||||
{/* Judge 2 */}
|
{/* Judge 2 */}
|
||||||
<div className="flex flex-col justify-between bg-white dark:bg-gray-800 rounded-xl px-4 py-8 shadow-lg text-center">
|
<div className="flex flex-col justify-between bg-white dark:bg-gray-800 rounded-xl px-4 py-8 shadow-lg text-center">
|
||||||
<h3 className="text-p3 font-bold mb-1 dark:text-white">
|
<h3 className="text-p3 font-bold mb-1 dark:text-white">
|
||||||
Anka Tama
|
Muhammad Alif Ramadhan
|
||||||
</h3>
|
</h3>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-primary-500 font-semibold mb-1">Admin</p>
|
<p className="text-primary-500 font-semibold mb-1">Admin</p>
|
||||||
|
|||||||
@@ -1,11 +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';
|
||||||
|
|
||||||
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);
|
||||||
@@ -15,18 +17,18 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950">
|
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
<div className="text-gray-600 dark:text-neutral-400">Loading submission...</div>
|
<div className="text-gray-600 dark:text-gray-400">Loading submission...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!submission) {
|
if (!submission) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950">
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
<div className="text-6xl mb-4">📄</div>
|
<div className="text-6xl mb-4">📄</div>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">No Submission Yet</h2>
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">No Submission Yet</h2>
|
||||||
<p className="text-gray-600 dark:text-neutral-400 mb-4">Your team hasn't submitted a project</p>
|
<p className="text-gray-600 dark:text-gray-400 mb-4">Your team hasn't submitted a project</p>
|
||||||
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
|
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -43,13 +45,13 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
|||||||
: 'Not submitted';
|
: 'Not submitted';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950">
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700">
|
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Project Submission</h1>
|
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Project Submission</h1>
|
||||||
<p className="text-gray-600 dark:text-neutral-400 mt-1">{team?.name}</p>
|
<p className="text-gray-600 dark:text-gray-400 mt-1">{team?.name}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
|
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
|
||||||
Back to Team
|
Back to Team
|
||||||
@@ -101,9 +103,41 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-950/50 overflow-hidden">
|
{/* Certificate Banner - Only show when submission is submitted */}
|
||||||
|
{submission.status === 'submitted' && (
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-lg p-6 mb-6">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||||
|
<span className="text-4xl shrink-0">🏆</span>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="font-bold text-amber-900 dark:text-amber-100 text-lg">
|
||||||
|
View Your Certificate
|
||||||
|
</h3>
|
||||||
|
<p className="text-amber-700 dark:text-amber-300 text-sm">
|
||||||
|
Congratulations! Your personalized certificate is ready to download and share.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
const certId = await encodeCertificateId(
|
||||||
|
teamId || '',
|
||||||
|
submission.id,
|
||||||
|
session?.user?.id || ''
|
||||||
|
);
|
||||||
|
navigate(`/certificate/${encodeURIComponent(certId)}`);
|
||||||
|
}}
|
||||||
|
className="shrink-0 px-6 py-2 bg-amber-600 hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-700 text-white font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Get Certificate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 overflow-hidden">
|
||||||
{/* Project Header */}
|
{/* Project Header */}
|
||||||
<div className="bg-gradient-to-r from-blue-600 to-blue-800 text-white p-8">
|
<div className="bg-linear-to-r from-blue-600 to-blue-800 text-white p-8">
|
||||||
<h2 className="text-3xl font-bold mb-2">{submission.project_name}</h2>
|
<h2 className="text-3xl font-bold mb-2">{submission.project_name}</h2>
|
||||||
<p className="text-blue-100">Team: {team?.name}</p>
|
<p className="text-blue-100">Team: {team?.name}</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,8 +147,8 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
|||||||
{/* Description */}
|
{/* Description */}
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Project Description</h3>
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Project Description</h3>
|
||||||
<div className="bg-gray-50 dark:bg-neutral-800 rounded-lg p-4">
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||||
<p className="text-gray-700 dark:text-neutral-300 whitespace-pre-wrap">{submission.description}</p>
|
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{submission.description}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -170,7 +204,7 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
|||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
alt={`Screenshot ${index + 1}`}
|
alt={`Screenshot ${index + 1}`}
|
||||||
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 dark:border-neutral-700 hover:border-blue-500 dark:hover:border-primary-500 transition-colors cursor-pointer"
|
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-primary-500 transition-colors cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
@@ -179,11 +213,11 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Submission Info */}
|
{/* Submission Info */}
|
||||||
<div className="bg-gray-50 dark:bg-neutral-800 rounded-lg p-4 border-t-4 border-blue-600 dark:border-primary-500">
|
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border-t-4 border-blue-600 dark:border-primary-500">
|
||||||
<h3 className="text-sm font-bold text-gray-900 dark:text-white mb-2">Submission Information</h3>
|
<h3 className="text-sm font-bold text-gray-900 dark:text-white mb-2">Submission Information</h3>
|
||||||
<div className="grid gap-2 text-sm">
|
<div className="grid gap-2 text-sm">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600 dark:text-neutral-400">Status:</span>
|
<span className="text-gray-600 dark:text-gray-400">Status:</span>
|
||||||
<span className={`font-medium ${
|
<span className={`font-medium ${
|
||||||
submission.status === 'submitted'
|
submission.status === 'submitted'
|
||||||
? 'text-green-600 dark:text-green-400'
|
? 'text-green-600 dark:text-green-400'
|
||||||
@@ -199,11 +233,11 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600 dark:text-neutral-400">Submitted:</span>
|
<span className="text-gray-600 dark:text-gray-400">Submitted:</span>
|
||||||
<span className="font-medium text-gray-900 dark:text-white">{submittedDate}</span>
|
<span className="font-medium text-gray-900 dark:text-white">{submittedDate}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-gray-600 dark:text-neutral-400">Submission ID:</span>
|
<span className="text-gray-600 dark:text-gray-400">Submission ID:</span>
|
||||||
<span className="font-medium text-gray-900 dark:text-white font-mono text-xs">
|
<span className="font-medium text-gray-900 dark:text-white font-mono text-xs">
|
||||||
{submission.id}
|
{submission.id}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { FC, ReactElement, useState } from 'react';
|
import { FC, ReactElement, useState, useEffect } from 'react';
|
||||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||||
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
|
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { useNavigate, useParams } from 'react-router';
|
import { useNavigate, useParams } from 'react-router';
|
||||||
@@ -14,10 +14,14 @@ import {
|
|||||||
} from '@imphnen-frontend-service/service';
|
} from '@imphnen-frontend-service/service';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
|
||||||
const MIN_TEAM_MEMBERS = 2; // Minimum members required to submit (including leader)
|
const MIN_TEAM_MEMBERS = 2; // Minimum members required to submit (including leader)
|
||||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||||
|
|
||||||
|
// Submission deadline: 2025-12-07 23:59:00 WIB (UTC+7)
|
||||||
|
const SUBMISSION_DEADLINE = new Date('2025-12-07T16:59:00Z');
|
||||||
|
|
||||||
const SubmitProjectPage: FC = (): ReactElement => {
|
const SubmitProjectPage: FC = (): ReactElement => {
|
||||||
const { teamId } = useParams<{ teamId: string }>();
|
const { teamId } = useParams<{ teamId: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -25,6 +29,42 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
|||||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||||
const [confirmText, setConfirmText] = useState('');
|
const [confirmText, setConfirmText] = useState('');
|
||||||
const [screenshots, setScreenshots] = useState<string[]>([]);
|
const [screenshots, setScreenshots] = useState<string[]>([]);
|
||||||
|
const [timeLeft, setTimeLeft] = useState<{
|
||||||
|
days: number;
|
||||||
|
hours: number;
|
||||||
|
minutes: number;
|
||||||
|
seconds: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Check if deadline passed
|
||||||
|
const isDeadlinePassed = new Date() >= SUBMISSION_DEADLINE;
|
||||||
|
|
||||||
|
// Countdown timer
|
||||||
|
useEffect(() => {
|
||||||
|
if (isDeadlinePassed) return;
|
||||||
|
|
||||||
|
const calculateTimeLeft = () => {
|
||||||
|
const now = new Date();
|
||||||
|
const difference = SUBMISSION_DEADLINE.getTime() - now.getTime();
|
||||||
|
|
||||||
|
if (difference <= 0) {
|
||||||
|
setTimeLeft(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = Math.floor(difference / (1000 * 60 * 60 * 24));
|
||||||
|
const hours = Math.floor((difference / (1000 * 60 * 60)) % 24);
|
||||||
|
const minutes = Math.floor((difference / 1000 / 60) % 60);
|
||||||
|
const seconds = Math.floor((difference / 1000) % 60);
|
||||||
|
|
||||||
|
setTimeLeft({ days, hours, minutes, seconds });
|
||||||
|
};
|
||||||
|
|
||||||
|
calculateTimeLeft();
|
||||||
|
const timer = setInterval(calculateTimeLeft, 1000);
|
||||||
|
|
||||||
|
return () => clearInterval(timer);
|
||||||
|
}, [isDeadlinePassed]);
|
||||||
|
|
||||||
const { data: teamData } = useTeamById(teamId || '');
|
const { data: teamData } = useTeamById(teamId || '');
|
||||||
const { data: submissionData } = useTeamSubmission(teamId || '', !!teamId);
|
const { data: submissionData } = useTeamSubmission(teamId || '', !!teamId);
|
||||||
@@ -85,6 +125,50 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show deadline passed screen
|
||||||
|
if (isDeadlinePassed) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 text-center">
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="mx-auto w-16 h-16 bg-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<Icon
|
||||||
|
icon="mdi:clock-alert"
|
||||||
|
className="text-3xl text-red-600 dark:text-red-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Submission Closed
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Project submissions are no longer accepted.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
The submission deadline was December 7, 2025 at 23:59 WIB.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/teams/${teamId}`)}
|
||||||
|
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Back to Team
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/dashboard')}
|
||||||
|
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Back to Dashboard
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const handleScreenshotUpload = async (
|
const handleScreenshotUpload = async (
|
||||||
e: React.ChangeEvent<HTMLInputElement>
|
e: React.ChangeEvent<HTMLInputElement>
|
||||||
) => {
|
) => {
|
||||||
@@ -138,6 +222,57 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{/* Countdown Timer */}
|
||||||
|
{timeLeft && (
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 rounded-lg p-6 mb-6">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<span className="text-3xl">⏰</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-bold text-blue-900 dark:text-blue-100 text-lg">
|
||||||
|
Submission Deadline
|
||||||
|
</h3>
|
||||||
|
<p className="text-blue-800 dark:text-blue-200 mt-2 text-sm font-sans">
|
||||||
|
Submissions close on December 7, 2025 at 23:59 WIB
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 grid grid-cols-4 gap-4">
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{timeLeft.days}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Days
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{timeLeft.hours.toString().padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Hours
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{timeLeft.minutes.toString().padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Minutes
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||||
|
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{timeLeft.seconds.toString().padStart(2, '0')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Seconds
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Minimum Members Warning */}
|
{/* Minimum Members Warning */}
|
||||||
{!hasEnoughMembers && (
|
{!hasEnoughMembers && (
|
||||||
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-500 rounded-lg p-6 mb-6">
|
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-500 rounded-lg p-6 mb-6">
|
||||||
@@ -200,6 +335,9 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
|||||||
<label className="block text-[15px] font-medium text-gray-700 dark:text-gray-300">
|
<label className="block text-[15px] font-medium text-gray-700 dark:text-gray-300">
|
||||||
Project Description <span className="text-red-500">*</span>
|
Project Description <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||||
|
Describe your project, its features, and what problem it solves. You can also paste your demo video link here.
|
||||||
|
</p>
|
||||||
<Controller
|
<Controller
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="description"
|
name="description"
|
||||||
@@ -207,7 +345,7 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
|||||||
<div>
|
<div>
|
||||||
<Textarea
|
<Textarea
|
||||||
{...field}
|
{...field}
|
||||||
placeholder="Describe your project, its features, and what problem it solves..."
|
placeholder="Describe your project, its features, and what problem it solves... You can paste your demo video link (YouTube, Loom, etc.) here as well."
|
||||||
rows={6}
|
rows={6}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
size="lg"
|
size="lg"
|
||||||
|
|||||||
@@ -0,0 +1,318 @@
|
|||||||
|
import { FC, ReactElement } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useWinners } from '@imphnen-frontend-service/service';
|
||||||
|
|
||||||
|
const WinnerPage: FC = (): ReactElement => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data, isLoading, error } = useWinners();
|
||||||
|
|
||||||
|
const winners = data?.data ?? [];
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||||
|
<div className="text-gray-600 dark:text-gray-400">
|
||||||
|
Loading winners...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
<div className="text-6xl mb-4">⚠️</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
Error Loading Winners
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||||
|
Unable to load winners at this time. Please try again later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort winners by rank
|
||||||
|
const sortedWinners = [...winners].sort((a, b) => a.rank - b.rank);
|
||||||
|
|
||||||
|
// Medal emojis for top 3
|
||||||
|
const getMedalEmoji = (rank: number) => {
|
||||||
|
switch (rank) {
|
||||||
|
case 1:
|
||||||
|
return '🥇';
|
||||||
|
case 2:
|
||||||
|
return '🥈';
|
||||||
|
case 3:
|
||||||
|
return '🥉';
|
||||||
|
default:
|
||||||
|
return '🏆';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get rank color
|
||||||
|
const getRankColor = (rank: number) => {
|
||||||
|
switch (rank) {
|
||||||
|
case 1:
|
||||||
|
return 'from-yellow-400 to-yellow-600';
|
||||||
|
case 2:
|
||||||
|
return 'from-gray-300 to-gray-500';
|
||||||
|
case 3:
|
||||||
|
return 'from-amber-600 to-amber-800';
|
||||||
|
default:
|
||||||
|
return 'from-blue-500 to-blue-700';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-6xl mb-4">🏆</div>
|
||||||
|
<h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Hackathon Winners
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Congratulations to all the winning teams!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Winners List */}
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
{winners.length === 0 ? (
|
||||||
|
<div className="text-center py-16">
|
||||||
|
<div className="text-6xl mb-4">🎯</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||||
|
No Winners Announced Yet
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
Winners will be announced here once the hackathon concludes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-8">
|
||||||
|
{/* Top 3 Winners - Mobile View */}
|
||||||
|
<div className="md:hidden space-y-6">
|
||||||
|
{[1, 2, 3].map((position) => {
|
||||||
|
const winner = sortedWinners[position - 1];
|
||||||
|
|
||||||
|
if(!winner) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={winner.id}
|
||||||
|
className={`bg-white dark:bg-gray-800 rounded-lg p-3 shadow-lg ${
|
||||||
|
getRankColor(winner.rank).includes('yellow')
|
||||||
|
? 'border-4 border-yellow-400 dark:border-yellow-600'
|
||||||
|
: getRankColor(winner.rank).includes('gray')
|
||||||
|
? 'border-4 border-gray-400 dark:border-gray-600'
|
||||||
|
: 'border-4 border-amber-600 dark:border-amber-500'
|
||||||
|
}}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
className={`aspect-square p-1 rounded-full bg-linear-to-br ${getRankColor(
|
||||||
|
winner.rank
|
||||||
|
)} flex items-center justify-center text-white font-bold text-2xl`}
|
||||||
|
>
|
||||||
|
<div className="text-4xl">
|
||||||
|
{getMedalEmoji(winner.rank)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{winner.team.logo && (
|
||||||
|
<img
|
||||||
|
src={winner.team.logo}
|
||||||
|
alt={`${winner.team.name} logo`}
|
||||||
|
className="w-20 h-20 rounded-full object-cover border-3 border-white dark:border-gray-700 shadow-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{winner.team.name}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{winner.team.city}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Top 3 Winners - Tablet/Desktop Podium View */}
|
||||||
|
<div className="hidden md:flex gap-8 items-end justify-center w-full">
|
||||||
|
{[2, 1, 3].map((position) => {
|
||||||
|
const winner = sortedWinners[position - 1];
|
||||||
|
if(!winner) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={winner.id}
|
||||||
|
className="w-full max-w-xs flex flex-col items-center space-y-4"
|
||||||
|
>
|
||||||
|
<div className="text-5xl">{getMedalEmoji(winner.rank)}</div>
|
||||||
|
|
||||||
|
{/* Team Logo */}
|
||||||
|
{winner.team.logo && (
|
||||||
|
<img
|
||||||
|
src={winner.team.logo}
|
||||||
|
alt={`${winner.team.name} logo`}
|
||||||
|
className="w-24 h-24 rounded-full object-cover border-4 border-white dark:border-gray-700 shadow-lg"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-center px-2">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{winner.team.name}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{winner.team.city}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Podium */}
|
||||||
|
<div
|
||||||
|
className={`${
|
||||||
|
winner.rank === 1
|
||||||
|
? 'h-42 bg-yellow-500'
|
||||||
|
: winner.rank === 2
|
||||||
|
? 'h-32 bg-gray-400'
|
||||||
|
: 'h-16 bg-amber-600'
|
||||||
|
} w-full flex items-end justify-center rounded-t-lg shadow-lg`}
|
||||||
|
>
|
||||||
|
<div className="text-white font-bold text-3xl pb-4">
|
||||||
|
#{winner.rank}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Ranks 4-23: Prize Winners */}
|
||||||
|
{sortedWinners.filter((w) => w.rank >= 4 && w.rank <= 23).length >
|
||||||
|
0 && (
|
||||||
|
<div className="mt-12">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center">
|
||||||
|
Favorite
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{sortedWinners
|
||||||
|
.filter((w) => w.rank >= 4 && w.rank <= 23)
|
||||||
|
.map((winner) => (
|
||||||
|
<div
|
||||||
|
key={winner.id}
|
||||||
|
className="bg-white dark:bg-gray-800 border-2 border-gray-400 dark:border-gray-600 rounded-lg p-4 hover:shadow-lg transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="shrink-0">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-linear-to-br from-gray-400 to-gray-600 flex items-center justify-center text-white font-bold text-lg">
|
||||||
|
#{winner.rank}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{winner.team.logo && (
|
||||||
|
<img
|
||||||
|
src={winner.team.logo}
|
||||||
|
alt={`${winner.team.name} logo`}
|
||||||
|
className="w-16 h-16 rounded-full object-cover border-2 border-gray-200 dark:border-gray-700"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-bold text-gray-900 dark:text-white truncate">
|
||||||
|
{winner.team.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{winner.team.city}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1 mt-1">
|
||||||
|
<span className="text-xs bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300 px-2 py-1 rounded-full">
|
||||||
|
🎁 Prize Winner
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rank 24+: Remaining Participants */}
|
||||||
|
{sortedWinners.filter((w) => w.rank >= 24).length > 0 && (
|
||||||
|
<div className="mt-12">
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center">
|
||||||
|
All Participants
|
||||||
|
</h2>
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
||||||
|
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{sortedWinners
|
||||||
|
.filter((w) => w.rank >= 24)
|
||||||
|
.map((participant) => (
|
||||||
|
<div
|
||||||
|
key={participant.id}
|
||||||
|
className="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="shrink-0 w-10 text-center">
|
||||||
|
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">
|
||||||
|
#{participant.rank}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{participant.team.logo && (
|
||||||
|
<img
|
||||||
|
src={participant.team.logo}
|
||||||
|
alt={`${participant.team.name} logo`}
|
||||||
|
className="w-12 h-12 rounded-full object-cover border-2 border-gray-200 dark:border-gray-700"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<h3 className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{participant.team.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{participant.team.city}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Info */}
|
||||||
|
{winners.length > 0 && (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-8">
|
||||||
|
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
|
||||||
|
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
|
||||||
|
Congratulations! 🎉
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||||
|
Thank you to all participants for making this hackathon a success.
|
||||||
|
Every project and idea contributed to an incredible showcase of
|
||||||
|
innovation and creativity.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WinnerPage;
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
const enc = new TextEncoder();
|
||||||
|
const dec = new TextDecoder();
|
||||||
|
|
||||||
|
function randBytes(len: number): Uint8Array {
|
||||||
|
const b = new Uint8Array(len);
|
||||||
|
crypto.getRandomValues(b);
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufToBase64(buf: ArrayBuffer): string {
|
||||||
|
const bytes = new Uint8Array(buf);
|
||||||
|
let s = '';
|
||||||
|
for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);
|
||||||
|
return btoa(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function base64ToBuf(b64: string): ArrayBuffer {
|
||||||
|
const s = atob(b64);
|
||||||
|
const arr = new Uint8Array(s.length);
|
||||||
|
for (let i = 0; i < s.length; i++) arr[i] = s.charCodeAt(i);
|
||||||
|
return arr.buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deriveKeyFromPassphrase(passphrase: string, salt: Uint8Array, iterations = 100_000) {
|
||||||
|
const passKey = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
enc.encode(passphrase),
|
||||||
|
{ name: 'PBKDF2' },
|
||||||
|
false,
|
||||||
|
['deriveKey']
|
||||||
|
);
|
||||||
|
|
||||||
|
return crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: 'PBKDF2',
|
||||||
|
salt,
|
||||||
|
iterations,
|
||||||
|
hash: 'SHA-256'
|
||||||
|
},
|
||||||
|
passKey,
|
||||||
|
{ name: 'AES-GCM', length: 256 },
|
||||||
|
false,
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts plaintext with passphrase -> returns base64(salt||iv||ciphertext)
|
||||||
|
*/
|
||||||
|
export async function encryptText(plaintext: string, passphrase: string) {
|
||||||
|
const salt = randBytes(16); // 128-bit salt
|
||||||
|
const iv = randBytes(12); // 96-bit IV recommended for GCM
|
||||||
|
const key = await deriveKeyFromPassphrase(passphrase, salt);
|
||||||
|
|
||||||
|
const cipher = await crypto.subtle.encrypt(
|
||||||
|
{ name: 'AES-GCM', iv },
|
||||||
|
key,
|
||||||
|
enc.encode(plaintext)
|
||||||
|
);
|
||||||
|
|
||||||
|
// concat salt + iv + ciphertext
|
||||||
|
const out = new Uint8Array(salt.length + iv.length + cipher.byteLength);
|
||||||
|
out.set(salt, 0);
|
||||||
|
out.set(iv, salt.length);
|
||||||
|
out.set(new Uint8Array(cipher), salt.length + iv.length);
|
||||||
|
|
||||||
|
return bufToBase64(out.buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts base64(salt||iv||ciphertext) with passphrase -> plaintext
|
||||||
|
*/
|
||||||
|
export async function decryptText(b64combined: string, passphrase: string) {
|
||||||
|
const combined = new Uint8Array(base64ToBuf(b64combined));
|
||||||
|
const salt = combined.slice(0, 16);
|
||||||
|
const iv = combined.slice(16, 28);
|
||||||
|
const cipher = combined.slice(28);
|
||||||
|
|
||||||
|
const key = await deriveKeyFromPassphrase(passphrase, salt);
|
||||||
|
const plainBuf = await crypto.subtle.decrypt(
|
||||||
|
{ name: 'AES-GCM', iv },
|
||||||
|
key,
|
||||||
|
cipher
|
||||||
|
);
|
||||||
|
return dec.decode(plainBuf);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { decryptText, encryptText } from "./aesclient";
|
||||||
|
|
||||||
|
const SECRET_KEY = 'imphnen-hackathon-2025';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode teamId, submissionId, and userId into a certificate ID
|
||||||
|
* Uses AES encryption for secure encoding
|
||||||
|
* @param teamId - The team ID
|
||||||
|
* @param submissionId - The submission ID
|
||||||
|
* @param userId - The user ID (team member)
|
||||||
|
* @returns Encoded certificate ID
|
||||||
|
*/
|
||||||
|
export const encodeCertificateId = async (teamId: string, submissionId: string, userId: string): Promise<string> => {
|
||||||
|
const combined = `${teamId}::${submissionId}::${userId}`;
|
||||||
|
return encryptText(combined, SECRET_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode winner certificate ID (team-based)
|
||||||
|
* @param teamId - Winner team ID
|
||||||
|
* @returns Encoded winner certificate ID
|
||||||
|
*/
|
||||||
|
export const encodeWinnerCertificateId = async (teamId: string): Promise<string> => {
|
||||||
|
const combined = `winner::${teamId}`;
|
||||||
|
return encryptText(combined, SECRET_KEY);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode certificate ID back to teamId, submissionId, and userId
|
||||||
|
* @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 {
|
||||||
|
const decoded = await decryptText(certId, SECRET_KEY);
|
||||||
|
const parts = decoded.split('::');
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
throw new Error('Invalid certificate ID');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For development: Create a certId using created_at timestamp
|
||||||
|
* @param teamId - The team ID
|
||||||
|
* @param createdAt - The creation timestamp
|
||||||
|
* @returns Encoded certificate ID
|
||||||
|
*/
|
||||||
|
export const encodeCertificateIdWithTimestamp = (teamId: string, createdAt: string): string => {
|
||||||
|
const combined = `${teamId}::${createdAt}`;
|
||||||
|
return Buffer.from(combined).toString('base64');
|
||||||
|
};
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import baseConfig from '../../eslint.config.mjs';
|
||||||
|
|
||||||
|
export default [...baseConfig];
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>IMPHNEN Infrastructure</title>
|
||||||
|
<base href="/" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" type="image/x-icon" href="/logos/simple.svg" />
|
||||||
|
<link rel="stylesheet" href="/src/index.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {
|
||||||
|
base: join(import.meta.dirname, '../../'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"name": "infra",
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"sourceRoot": "apps/infra/src",
|
||||||
|
"projectType": "application",
|
||||||
|
"tags": [],
|
||||||
|
"targets": {}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 351 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 511 KiB |
@@ -0,0 +1,248 @@
|
|||||||
|
const apps = [
|
||||||
|
{ name: 'Landing', domain: 'imphnen.dev', type: 'Next.js', color: 'blue' },
|
||||||
|
{ name: 'WWW', domain: 'www.imphnen.dev', type: 'Redirect', color: 'purple' },
|
||||||
|
{ name: 'Gacha', domain: 'gacha.imphnen.dev', type: 'Vite', color: 'green' },
|
||||||
|
{ name: 'Backoffice', domain: 'backoffice.imphnen.dev', type: 'Vite', color: 'green' },
|
||||||
|
{ name: 'Dimentorin', domain: 'dimentorin.imphnen.dev', type: 'Vite', color: 'green' },
|
||||||
|
{ name: 'Hackathon', domain: 'hackathon.imphnen.dev', type: 'Vite', color: 'green' },
|
||||||
|
{ name: 'QR Campaign', domain: 'qr.imphnen.dev', type: 'Vite', color: 'green' },
|
||||||
|
{ name: 'Infra', domain: 'infra.imphnen.dev', type: 'Vite', color: 'green' },
|
||||||
|
{ name: 'API QR', domain: 'api-qr.imphnen.dev', type: 'Backend', color: 'orange' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const techStack = [
|
||||||
|
{ category: 'OS', items: ['NixOS 25.05'] },
|
||||||
|
{ category: 'Web Server', items: ['Nginx'] },
|
||||||
|
{ category: 'SSL', items: ['Let\'s Encrypt (ACME)'] },
|
||||||
|
{ category: 'Build', items: ['Nix Flakes', 'Nx Monorepo'] },
|
||||||
|
{ category: 'CI/CD', items: ['GitHub Actions', 'Cachix'] },
|
||||||
|
{ category: 'Frontend', items: ['React', 'Next.js', 'Vite', 'TailwindCSS'] },
|
||||||
|
{ category: 'Backend', items: ['Go', 'Supabase'] },
|
||||||
|
{ category: 'Secrets', items: ['sops-nix'] },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-[#0a0a0a]">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="border-b border-zinc-800 bg-[#0a0a0a]/80 backdrop-blur-sm sticky top-0 z-50">
|
||||||
|
<div className="container mx-auto px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-8 h-8 bg-blue-500 rounded-lg flex items-center justify-center font-bold">
|
||||||
|
I
|
||||||
|
</div>
|
||||||
|
<span className="text-xl font-semibold">IMPHNEN Infrastructure</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
href="https://github.com/IMPHNEN"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-zinc-400 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="container mx-auto px-6 py-12">
|
||||||
|
{/* Hero */}
|
||||||
|
<section className="text-center mb-16">
|
||||||
|
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
||||||
|
Infrastructure Overview
|
||||||
|
</h1>
|
||||||
|
<p className="text-zinc-400 text-lg max-w-2xl mx-auto">
|
||||||
|
Declarative NixOS infrastructure powering IMPHNEN's frontend applications
|
||||||
|
on Hetzner Cloud.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Architecture Diagram */}
|
||||||
|
<section className="mb-16">
|
||||||
|
<h2 className="text-2xl font-semibold mb-6">Architecture</h2>
|
||||||
|
<div className="card bg-[#141414] border-zinc-800 p-8">
|
||||||
|
<div className="flex flex-col items-center gap-6">
|
||||||
|
{/* Internet */}
|
||||||
|
<div className="flex items-center gap-2 text-zinc-400">
|
||||||
|
<span className="text-2xl">🌐</span>
|
||||||
|
<span>Internet</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-8 bg-zinc-700" />
|
||||||
|
|
||||||
|
{/* Cloudflare */}
|
||||||
|
<div className="card bg-orange-500/10 border-orange-500/30 px-6 py-3">
|
||||||
|
<span className="badge badge-orange">Cloudflare</span>
|
||||||
|
<p className="text-sm text-zinc-400 mt-1">DNS + CDN + DDoS Protection</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-px h-8 bg-zinc-700" />
|
||||||
|
|
||||||
|
{/* Hetzner VPS */}
|
||||||
|
<div className="card bg-[#1a1a1a] border-zinc-700 w-full max-w-3xl">
|
||||||
|
<div className="text-center mb-4">
|
||||||
|
<span className="badge badge-purple">Hetzner Cloud VPS</span>
|
||||||
|
<p className="text-sm text-zinc-400 mt-1">NixOS | 167.235.70.37</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Nginx */}
|
||||||
|
<div className="card bg-green-500/10 border-green-500/30 mb-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="badge badge-green">Nginx</span>
|
||||||
|
<p className="text-sm text-zinc-400 mt-1">Reverse Proxy + Static Files + SSL Termination</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Apps Grid */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
||||||
|
{apps.map((app) => (
|
||||||
|
<div
|
||||||
|
key={app.name}
|
||||||
|
className="card bg-[#0a0a0a] border-zinc-800 text-center p-3"
|
||||||
|
>
|
||||||
|
<p className="font-medium text-sm">{app.name}</p>
|
||||||
|
<p className="text-xs text-zinc-500">{app.domain}</p>
|
||||||
|
<span className={`badge badge-${app.color} mt-2 text-xs`}>
|
||||||
|
{app.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Deployed Apps */}
|
||||||
|
<section className="mb-16">
|
||||||
|
<h2 className="text-2xl font-semibold mb-6">Deployed Applications</h2>
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{apps.map((app) => (
|
||||||
|
<a
|
||||||
|
key={app.name}
|
||||||
|
href={`https://${app.domain}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="card bg-[#141414] border-zinc-800 hover:border-zinc-600 transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="font-semibold group-hover:text-blue-400 transition-colors">
|
||||||
|
{app.name}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-zinc-500">{app.domain}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`badge badge-${app.color}`}>{app.type}</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Tech Stack */}
|
||||||
|
<section className="mb-16">
|
||||||
|
<h2 className="text-2xl font-semibold mb-6">Technology Stack</h2>
|
||||||
|
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{techStack.map((tech) => (
|
||||||
|
<div key={tech.category} className="card bg-[#141414] border-zinc-800">
|
||||||
|
<h3 className="text-sm text-zinc-500 uppercase tracking-wider mb-2">
|
||||||
|
{tech.category}
|
||||||
|
</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{tech.items.map((item) => (
|
||||||
|
<span key={item} className="badge badge-blue">
|
||||||
|
{item}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Deployment Flow */}
|
||||||
|
<section className="mb-16">
|
||||||
|
<h2 className="text-2xl font-semibold mb-6">Deployment Flow</h2>
|
||||||
|
<div className="card bg-[#141414] border-zinc-800">
|
||||||
|
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-12 h-12 bg-zinc-800 rounded-full flex items-center justify-center mx-auto mb-2">
|
||||||
|
<span>1</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">Push to GitHub</p>
|
||||||
|
<p className="text-xs text-zinc-500">develop branch</p>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block text-zinc-600">→</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-12 h-12 bg-zinc-800 rounded-full flex items-center justify-center mx-auto mb-2">
|
||||||
|
<span>2</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">GitHub Actions</p>
|
||||||
|
<p className="text-xs text-zinc-500">nix build</p>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block text-zinc-600">→</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-12 h-12 bg-orange-500 rounded-full flex items-center justify-center mx-auto mb-2">
|
||||||
|
<span>3</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">Push to Cachix</p>
|
||||||
|
<p className="text-xs text-zinc-500">binary cache</p>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block text-zinc-600">→</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-12 h-12 bg-zinc-800 rounded-full flex items-center justify-center mx-auto mb-2">
|
||||||
|
<span>4</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">Server Pull</p>
|
||||||
|
<p className="text-xs text-zinc-500">nixos-rebuild</p>
|
||||||
|
</div>
|
||||||
|
<div className="hidden md:block text-zinc-600">→</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="w-12 h-12 bg-blue-500 rounded-full flex items-center justify-center mx-auto mb-2">
|
||||||
|
<span>✓</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">Live</p>
|
||||||
|
<p className="text-xs text-zinc-500">Zero downtime</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Repositories */}
|
||||||
|
<section>
|
||||||
|
<h2 className="text-2xl font-semibold mb-6">Repositories</h2>
|
||||||
|
<div className="grid md:grid-cols-2 gap-4">
|
||||||
|
<a
|
||||||
|
href="https://github.com/IMPHNEN/imphnen-frontend-service"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="card bg-[#141414] border-zinc-800 hover:border-zinc-600 transition-colors"
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold mb-1">imphnen-frontend-service</h3>
|
||||||
|
<p className="text-sm text-zinc-400">
|
||||||
|
Nx monorepo containing all frontend applications
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://github.com/IMPHNEN/imphnen-infrastructure"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="card bg-[#141414] border-zinc-800 hover:border-zinc-600 transition-colors"
|
||||||
|
>
|
||||||
|
<h3 className="font-semibold mb-1">imphnen-infrastructure</h3>
|
||||||
|
<p className="text-sm text-zinc-400">
|
||||||
|
NixOS flake configuration for server deployment
|
||||||
|
</p>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<footer className="border-t border-zinc-800 mt-16">
|
||||||
|
<div className="container mx-auto px-6 py-8 text-center text-zinc-500 text-sm">
|
||||||
|
<p>IMPHNEN Infrastructure © {new Date().getFullYear()}</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-primary: #3b82f6;
|
||||||
|
--color-primary-hover: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
background-color: #0a0a0a;
|
||||||
|
color: #ffffff;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.card {
|
||||||
|
background: #141414;
|
||||||
|
border: 1px solid #27272a;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-blue {
|
||||||
|
background: rgba(59, 130, 246, 0.2);
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-green {
|
||||||
|
background: rgba(34, 197, 94, 0.2);
|
||||||
|
color: #4ade80;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-purple {
|
||||||
|
background: rgba(168, 85, 247, 0.2);
|
||||||
|
color: #c084fc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-orange {
|
||||||
|
background: rgba(249, 115, 22, 0.2);
|
||||||
|
color: #fb923c;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { StrictMode } from 'react';
|
||||||
|
import App from './app/App';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
const rootElement = document.getElementById('root');
|
||||||
|
|
||||||
|
if (!rootElement) throw new Error('Failed to find the root element');
|
||||||
|
|
||||||
|
createRoot(rootElement).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "../../dist/out-tsc",
|
||||||
|
"types": ["node", "vite/client"]
|
||||||
|
},
|
||||||
|
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts", "src/**/*.spec.tsx", "src/**/*.test.tsx"],
|
||||||
|
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowJs": false,
|
||||||
|
"esModuleInterop": false,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true,
|
||||||
|
"types": ["vite/client"]
|
||||||
|
},
|
||||||
|
"files": [],
|
||||||
|
"include": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"extends": "../../tsconfig.base.json"
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
/// <reference types='vitest' />
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||||
|
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
|
||||||
|
|
||||||
|
export default defineConfig(() => ({
|
||||||
|
root: __dirname,
|
||||||
|
cacheDir: '../../node_modules/.vite/apps/infra',
|
||||||
|
server: {
|
||||||
|
port: 3006,
|
||||||
|
host: 'localhost',
|
||||||
|
},
|
||||||
|
preview: {
|
||||||
|
port: 3007,
|
||||||
|
host: 'localhost',
|
||||||
|
},
|
||||||
|
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||||
|
build: {
|
||||||
|
outDir: '../../dist/apps/infra',
|
||||||
|
emptyOutDir: true,
|
||||||
|
reportCompressedSize: true,
|
||||||
|
commonjsOptions: {
|
||||||
|
transformMixedEsModules: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -6,6 +6,7 @@ import { useRef } from 'react';
|
|||||||
import {
|
import {
|
||||||
FaArrowRight,
|
FaArrowRight,
|
||||||
FaDiscord,
|
FaDiscord,
|
||||||
|
FaGithub,
|
||||||
FaFacebook,
|
FaFacebook,
|
||||||
FaInstagram,
|
FaInstagram,
|
||||||
FaLinkedin,
|
FaLinkedin,
|
||||||
@@ -45,6 +46,8 @@ export function CommunitySection() {
|
|||||||
return FaFacebook;
|
return FaFacebook;
|
||||||
case 'FaDiscord':
|
case 'FaDiscord':
|
||||||
return FaDiscord;
|
return FaDiscord;
|
||||||
|
case 'FaGithub':
|
||||||
|
return FaGithub;
|
||||||
case 'FaInstagram':
|
case 'FaInstagram':
|
||||||
return FaInstagram;
|
return FaInstagram;
|
||||||
case 'FaTiktok':
|
case 'FaTiktok':
|
||||||
@@ -66,6 +69,10 @@ export function CommunitySection() {
|
|||||||
return {
|
return {
|
||||||
iconColor: 'text-[#5865F2]',
|
iconColor: 'text-[#5865F2]',
|
||||||
};
|
};
|
||||||
|
case 'FaGithub':
|
||||||
|
return {
|
||||||
|
iconColor: 'text-[#000000]',
|
||||||
|
};
|
||||||
case 'FaInstagram':
|
case 'FaInstagram':
|
||||||
return {
|
return {
|
||||||
iconColor: 'text-[#E4405F]',
|
iconColor: 'text-[#E4405F]',
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ export function CTASection() {
|
|||||||
<div className="flex flex-col items-center gap-6 text-white">
|
<div className="flex flex-col items-center gap-6 text-white">
|
||||||
<LogoSimple className="w-[240px]" />
|
<LogoSimple className="w-[240px]" />
|
||||||
<div className="text-center space-y-2">
|
<div className="text-center space-y-2">
|
||||||
<h3 className="text-2xl font-bold">180.000+</h3>
|
<h3 className="text-2xl font-bold">250.000+</h3>
|
||||||
<p className="text-lg">Programmer Sudah Bergabung</p>
|
<p className="text-lg">Programmer Sudah Bergabung</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,9 +54,19 @@ export function HeroSection() {
|
|||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 0.5 }}
|
transition={{ duration: 0.5 }}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center rounded-full border px-3 py-1 text-sm w-fit">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{communityLabel}
|
<span className="inline-flex items-center rounded-full border px-3 py-1 text-sm">
|
||||||
</span>
|
{communityLabel}
|
||||||
|
</span>
|
||||||
|
<a
|
||||||
|
href="https://ancikri.com"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center rounded-full border px-3 py-1 text-sm hover:bg-muted transition-colors"
|
||||||
|
>
|
||||||
|
Powered By Ancikri
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-foreground via-foreground to-foreground/70">
|
<h1 className="text-4xl md:text-5xl lg:text-6xl xl:text-7xl font-bold tracking-tighter bg-clip-text text-transparent bg-gradient-to-r from-foreground via-foreground to-foreground/70">
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import Link from 'next/link';
|
|||||||
import {
|
import {
|
||||||
FaDiscord,
|
FaDiscord,
|
||||||
FaFacebook,
|
FaFacebook,
|
||||||
|
FaGithub,
|
||||||
FaInstagram,
|
FaInstagram,
|
||||||
FaLinkedinIn,
|
FaLinkedinIn,
|
||||||
FaTiktok,
|
FaTiktok,
|
||||||
@@ -37,6 +38,13 @@ export default function Footer() {
|
|||||||
>
|
>
|
||||||
<FaDiscord className="size-6" />
|
<FaDiscord className="size-6" />
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="https://github.com/IMPHNEN/"
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<FaGithub className="size-6" />
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
href="https://www.instagram.com/imphnen.dev"
|
href="https://www.instagram.com/imphnen.dev"
|
||||||
className="text-muted-foreground hover:text-foreground"
|
className="text-muted-foreground hover:text-foreground"
|
||||||
@@ -92,15 +100,25 @@ export default function Footer() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-bold">Patners</h3>
|
<h3 className="text-lg font-bold">Partners</h3>
|
||||||
<ul className="space-y-2"></ul>
|
<ul className="space-y-2"></ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-8 border-t pt-8 text-center">
|
<div className="mt-8 border-t pt-8 text-center space-y-2">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
© {new Date().getFullYear()} IMPHNEN - Ingin Menjadi Programmer
|
© {new Date().getFullYear()} IMPHNEN - Ingin Menjadi Programmer
|
||||||
Handal, Namun Enggan Ngoding. All rights reserved.
|
Handal, Namun Enggan Ngoding. All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Powered by{' '}
|
||||||
|
<Link
|
||||||
|
href="https://ancikri.com"
|
||||||
|
target="_blank"
|
||||||
|
className="font-medium text-foreground hover:underline"
|
||||||
|
>
|
||||||
|
Ancikri
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[
|
[
|
||||||
{ "value": "180K+", "label": "Member" },
|
{ "value": "250K+", "label": "Member" },
|
||||||
{ "value": "500+", "label": "Meme Harian" },
|
{ "value": "500+", "label": "Meme Harian" },
|
||||||
{ "value": "24/7", "label": "Yapping" }
|
{ "value": "24/7", "label": "Yapping" }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -13,6 +13,13 @@
|
|||||||
"description": "Real-time collaboration chat",
|
"description": "Real-time collaboration chat",
|
||||||
"link": "https://discord.gg/imphnen"
|
"link": "https://discord.gg/imphnen"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Github",
|
||||||
|
"icon": "FaGithub",
|
||||||
|
"color": "#000000",
|
||||||
|
"description": "Platforms to collaborate on your code",
|
||||||
|
"link": "https://github.com/IMPHNEN/"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Instagram",
|
"name": "Instagram",
|
||||||
"icon": "FaInstagram",
|
"icon": "FaInstagram",
|
||||||
|
|||||||
@@ -1,13 +1,23 @@
|
|||||||
import { Bai_Jamjuree, Poppins } from 'next/font/google';
|
import localFont from 'next/font/local';
|
||||||
|
|
||||||
export const baiJamjureeFont = Bai_Jamjuree({
|
export const baiJamjureeFont = localFont({
|
||||||
subsets: ['latin'],
|
src: [
|
||||||
weight: ['300', '400', '500', '600', '700'],
|
{ path: '../../public/fonts/BaiJamjuree-Light.ttf', weight: '300', style: 'normal' },
|
||||||
|
{ path: '../../public/fonts/BaiJamjuree-Regular.ttf', weight: '400', style: 'normal' },
|
||||||
|
{ path: '../../public/fonts/BaiJamjuree-Medium.ttf', weight: '500', style: 'normal' },
|
||||||
|
{ path: '../../public/fonts/BaiJamjuree-SemiBold.ttf', weight: '600', style: 'normal' },
|
||||||
|
{ path: '../../public/fonts/BaiJamjuree-Bold.ttf', weight: '700', style: 'normal' },
|
||||||
|
],
|
||||||
display: 'swap',
|
display: 'swap',
|
||||||
});
|
});
|
||||||
|
|
||||||
export const poppinsFont = Poppins({
|
export const poppinsFont = localFont({
|
||||||
subsets: ['latin'],
|
src: [
|
||||||
weight: ['300', '400', '500', '600', '700'],
|
{ path: '../../public/fonts/Poppins-Light.ttf', weight: '300', style: 'normal' },
|
||||||
|
{ path: '../../public/fonts/Poppins-Regular.ttf', weight: '400', style: 'normal' },
|
||||||
|
{ path: '../../public/fonts/Poppins-Medium.ttf', weight: '500', style: 'normal' },
|
||||||
|
{ path: '../../public/fonts/Poppins-SemiBold.ttf', weight: '600', style: 'normal' },
|
||||||
|
{ path: '../../public/fonts/Poppins-Bold.ttf', weight: '700', style: 'normal' },
|
||||||
|
],
|
||||||
display: 'swap',
|
display: 'swap',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
FROM node:22-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --prefer-offline --no-audit --no-fund
|
||||||
|
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ENV NX_DAEMON=false
|
||||||
|
RUN npm run qrcampaign:build
|
||||||
|
|
||||||
|
FROM nginx:alpine AS runner
|
||||||
|
RUN rm -rf /usr/share/nginx/html/*
|
||||||
|
COPY --from=builder /app/dist/apps/qrcampaign /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import nx from '@nx/eslint-plugin';
|
||||||
|
import baseConfig from '../../eslint.config.mjs';
|
||||||
|
|
||||||
|
export default [
|
||||||
|
...baseConfig,
|
||||||
|
...nx.configs['flat/react'],
|
||||||
|
{
|
||||||
|
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
|
||||||
|
// Override or add rules here
|
||||||
|
rules: {},
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>QR Code Generator - IMPHNEN</title>
|
||||||
|
<base href="/" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="description" content="QR Code Generator - IMPHNEN" />
|
||||||
|
<meta name="keywords" content="qr, campaign, imphnen" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/images/imphnen-logo.svg" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {
|
||||||
|
base: join(import.meta.dirname, '../../'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"name": "qrcampaign",
|
||||||
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
|
"sourceRoot": "apps/qrcampaign/src",
|
||||||
|
"projectType": "application",
|
||||||
|
"tags": [],
|
||||||
|
"// targets": "to see all targets run: nx show project qrcampaign --web",
|
||||||
|
"targets": {}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 351 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
export default function NotFoundPage() {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<h1 className="text-9xl font-bold text-gray-200 mb-4">404</h1>
|
||||||
|
<h2 className="text-3xl font-semibold text-gray-900 mb-4">
|
||||||
|
Page Not Found
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 mb-8">
|
||||||
|
The page you are looking for doesn't exist or has been moved.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
|
||||||
|
>
|
||||||
|
Go Back Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { DataTable } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import {
|
||||||
|
campaignService,
|
||||||
|
Campaign,
|
||||||
|
CreateCampaignRequest,
|
||||||
|
} from '../../features/admin/api/campaign.service';
|
||||||
|
import {
|
||||||
|
DeleteOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
PlusOutlined,
|
||||||
|
FileOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
export default function CampaignsPage() {
|
||||||
|
const [campaigns, setCampaigns] = useState<Campaign[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [createLoading, setCreateLoading] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<CreateCampaignRequest>({
|
||||||
|
name: '',
|
||||||
|
url: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchCampaigns = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await campaignService.getCampaigns();
|
||||||
|
setCampaigns(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to load campaigns');
|
||||||
|
console.error('Error fetching campaigns:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchCampaigns();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleCreateCampaign = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
setCreateLoading(true);
|
||||||
|
await campaignService.createCampaign(formData);
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setFormData({ name: '', url: '' });
|
||||||
|
await fetchCampaigns();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error creating campaign:', err);
|
||||||
|
alert('Failed to create campaign');
|
||||||
|
} finally {
|
||||||
|
setCreateLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleActivateCampaign = async (campaignId: string) => {
|
||||||
|
try {
|
||||||
|
await campaignService.activateCampaign(campaignId);
|
||||||
|
await fetchCampaigns();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error activating campaign:', err);
|
||||||
|
alert('Failed to activate campaign');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteCampaign = async (
|
||||||
|
campaignId: string,
|
||||||
|
campaignName: string
|
||||||
|
) => {
|
||||||
|
if (!confirm(`Are you sure you want to delete "${campaignName}"?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await campaignService.deleteCampaign(campaignId);
|
||||||
|
await fetchCampaigns();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting campaign:', err);
|
||||||
|
alert('Failed to delete campaign');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<Campaign>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="font-medium text-gray-900">{row.original.name}</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'url',
|
||||||
|
header: 'URL',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<a
|
||||||
|
href={row.original.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary-600 hover:underline"
|
||||||
|
>
|
||||||
|
{row.original.url}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'is_active',
|
||||||
|
header: 'Status',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded-2xl text-xs font-medium ${
|
||||||
|
row.original.is_active
|
||||||
|
? 'bg-success-100 text-success-800'
|
||||||
|
: 'bg-gray-100 text-gray-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{row.original.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'created_at',
|
||||||
|
header: 'Created At',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{new Date(row.original.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!row.original.is_active && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleActivateCampaign(row.original.id)}
|
||||||
|
className="p-2 text-success-600 hover:bg-success-50 rounded-md transition-colors cursor-pointer"
|
||||||
|
title="Activate Campaign"
|
||||||
|
>
|
||||||
|
<CheckCircleOutlined />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
handleDeleteCampaign(row.original.id, row.original.name)
|
||||||
|
}
|
||||||
|
className="p-2 text-danger-600 hover:bg-danger-50 rounded-md transition-colors cursor-pointer"
|
||||||
|
title="Delete Campaign"
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-gray-600">Loading campaigns...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
Campaign Management
|
||||||
|
</h1>
|
||||||
|
<p className="text-gray-600 mt-1">Manage your QR campaigns</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="flex items-center gap-2 bg-primary-500 text-white px-4 py-2 rounded-lg hover:bg-primary-600 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
Create Campaign
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!campaigns || campaigns.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 p-12 text-center shadow-sm">
|
||||||
|
<div className="max-w-sm mx-auto">
|
||||||
|
<div className="text-gray-400 mb-4">
|
||||||
|
<FileOutlined className="text-[48px] mx-auto" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
No campaigns yet
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500 mb-6">
|
||||||
|
Get started by creating your first QR campaign.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="inline-flex items-center gap-2 bg-primary-500 text-white px-4 py-2 rounded-lg hover:bg-primary-600 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<PlusOutlined />
|
||||||
|
Create Your First Campaign
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable data={campaigns} columns={columns} pageSize={10} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Campaign Modal */}
|
||||||
|
{showCreateModal && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||||
|
<h2 className="text-xl font-bold mb-4">Create New Campaign</h2>
|
||||||
|
<form onSubmit={handleCreateCampaign}>
|
||||||
|
<div className="mb-4">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Campaign Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, name: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="IMPHNEN Promo Campaign"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mb-6">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Campaign URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="url"
|
||||||
|
value={formData.url}
|
||||||
|
onChange={(e) =>
|
||||||
|
setFormData({ ...formData, url: e.target.value })
|
||||||
|
}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
placeholder="https://imphnen.dev/promo"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-3 justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setFormData({ name: '', url: '' });
|
||||||
|
}}
|
||||||
|
className="px-4 py-2 text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors cursor-pointer"
|
||||||
|
disabled={createLoading}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-4 py-2 bg-primary-500 text-white rounded-md hover:bg-primary-600 transition-colors disabled:opacity-50 cursor-pointer"
|
||||||
|
disabled={createLoading}
|
||||||
|
>
|
||||||
|
{createLoading ? 'Creating...' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { Outlet } from 'react-router-dom';
|
||||||
|
import { RequireAdmin } from '../features/admin/components/RequireAdmin';
|
||||||
|
|
||||||
|
export default function AdminLayout() {
|
||||||
|
return (
|
||||||
|
<RequireAdmin>
|
||||||
|
<Outlet />
|
||||||
|
</RequireAdmin>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default function AdminPage() {
|
||||||
|
return <div className="p-4">Select a menu item from the sidebar.</div>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { ColumnDef } from '@tanstack/react-table';
|
||||||
|
import { DataTable } from '@imphnen-frontend-service/ui/organisms';
|
||||||
|
import { userService, User } from '../../features/admin/api/user.service';
|
||||||
|
import { DeleteOutlined, EditOutlined, UserOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [editingUserId, setEditingUserId] = useState<string | null>(null);
|
||||||
|
const [selectedRole, setSelectedRole] = useState<string>('');
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await userService.getUsers();
|
||||||
|
setUsers(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError('Failed to load users');
|
||||||
|
console.error('Error fetching users:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUpdateRole = async (userId: string, currentRole: string) => {
|
||||||
|
if (editingUserId === userId) {
|
||||||
|
// Save the role
|
||||||
|
try {
|
||||||
|
await userService.updateUserRole(userId, selectedRole);
|
||||||
|
setEditingUserId(null);
|
||||||
|
setSelectedRole('');
|
||||||
|
await fetchUsers();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error updating user role:', err);
|
||||||
|
alert('Failed to update user role');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Start editing
|
||||||
|
setEditingUserId(userId);
|
||||||
|
setSelectedRole(currentRole);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteUser = async (userId: string, userName: string) => {
|
||||||
|
if (!confirm(`Are you sure you want to delete user "${userName}"?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await userService.deleteUser(userId);
|
||||||
|
await fetchUsers();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error deleting user:', err);
|
||||||
|
alert('Failed to delete user');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns: ColumnDef<User>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="font-medium text-gray-900">{row.original.name}</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'email',
|
||||||
|
header: 'Email',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="text-gray-600">{row.original.email}</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'role',
|
||||||
|
header: 'Role',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const isEditing = editingUserId === row.original.id;
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<select
|
||||||
|
value={selectedRole}
|
||||||
|
onChange={(e) => setSelectedRole(e.target.value)}
|
||||||
|
className="px-2 py-1 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={`px-2 py-1 rounded-2xl text-xs font-medium ${
|
||||||
|
row.original.role === 'admin'
|
||||||
|
? 'bg-danger-100 text-danger-800'
|
||||||
|
: 'bg-primary-100 text-primary-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{row.original.role.charAt(0).toUpperCase() +
|
||||||
|
row.original.role.slice(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'created_at',
|
||||||
|
header: 'Created At',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<span className="text-gray-600">
|
||||||
|
{new Date(row.original.created_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const isEditing = editingUserId === row.original.id;
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
handleUpdateRole(row.original.id, row.original.role)
|
||||||
|
}
|
||||||
|
className={`p-2 rounded-md transition-colors cursor-pointer ${
|
||||||
|
isEditing
|
||||||
|
? 'text-success-600 hover:bg-success-50'
|
||||||
|
: 'text-primary-600 hover:bg-primary-50'
|
||||||
|
}`}
|
||||||
|
title={isEditing ? 'Save Role' : 'Change Role'}
|
||||||
|
>
|
||||||
|
<EditOutlined />
|
||||||
|
</button>
|
||||||
|
{isEditing && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingUserId(null);
|
||||||
|
setSelectedRole('');
|
||||||
|
}}
|
||||||
|
className="p-2 text-gray-600 hover:bg-gray-50 rounded-md transition-colors cursor-pointer"
|
||||||
|
title="Cancel"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{!isEditing && (
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
handleDeleteUser(row.original.id, row.original.name)
|
||||||
|
}
|
||||||
|
className="p-2 text-danger-600 hover:bg-danger-50 rounded-md transition-colors cursor-pointer"
|
||||||
|
title="Delete User"
|
||||||
|
>
|
||||||
|
<DeleteOutlined />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<div className="text-gray-600">Loading users...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-red-800">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">User Management</h1>
|
||||||
|
<p className="text-gray-600 mt-1">Manage users and their roles</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!users || users.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 p-12 text-center shadown-sm">
|
||||||
|
<div className="max-w-sm mx-auto">
|
||||||
|
<div className="text-gray-400 mb-4">
|
||||||
|
<UserOutlined className="text-[48px]" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||||
|
No users found
|
||||||
|
</h3>
|
||||||
|
<p className="text-gray-500">
|
||||||
|
There are currently no users in the system.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<DataTable data={users} columns={columns} pageSize={10} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { FC, ReactElement, useEffect, useState, useRef } from 'react';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { useGitHubCallback } from '@imphnen-frontend-service/service';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
const CallbackPage: FC = (): ReactElement => {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { mutateAsync: exchangeGitHubCode } = useGitHubCallback();
|
||||||
|
const [isProcessing, setIsProcessing] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const hasRunRef = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleCallback = async () => {
|
||||||
|
if (hasRunRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
hasRunRef.current = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check URL hash for Supabase email confirmation callback
|
||||||
|
const hashParams = new URLSearchParams(
|
||||||
|
globalThis.location.hash.substring(1)
|
||||||
|
);
|
||||||
|
const urlParams = new URLSearchParams(globalThis.location.search);
|
||||||
|
|
||||||
|
const type = hashParams.get('type') || urlParams.get('type');
|
||||||
|
const accessToken =
|
||||||
|
hashParams.get('access_token') || urlParams.get('access_token');
|
||||||
|
|
||||||
|
// Debug: log what we received
|
||||||
|
console.log('[Callback] Params:', {
|
||||||
|
type,
|
||||||
|
accessToken: !!accessToken,
|
||||||
|
hash: globalThis.location.hash,
|
||||||
|
search: globalThis.location.search,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle Supabase email callbacks (has access_token in hash or query)
|
||||||
|
// This includes: signup confirmation, email confirmation, password recovery
|
||||||
|
if (accessToken) {
|
||||||
|
setIsProcessing(false);
|
||||||
|
|
||||||
|
// Password recovery - type is 'recovery' or we have access_token from reset email
|
||||||
|
if (type === 'recovery' || type === 'magiclink') {
|
||||||
|
toast.success('Email verified! Please set your new password.');
|
||||||
|
navigate('/auth/reset-password?access_token=' + accessToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signup/Email confirmation
|
||||||
|
if (type === 'signup' || type === 'email_confirmation') {
|
||||||
|
toast.success(
|
||||||
|
'Email verified successfully! Please log in to continue.'
|
||||||
|
);
|
||||||
|
navigate('/auth/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have access_token but unknown type, assume it's password recovery
|
||||||
|
// (Supabase sometimes sends without explicit type)
|
||||||
|
toast.success('Email verified! Please set your new password.');
|
||||||
|
navigate('/auth/reset-password?access_token=' + accessToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the code from URL query params (GitHub OAuth)
|
||||||
|
const code = urlParams.get('code');
|
||||||
|
|
||||||
|
if (!code) {
|
||||||
|
throw new Error('No authorization code received');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange the code for tokens using backend API (GitHub OAuth)
|
||||||
|
const result = await exchangeGitHubCode({ code });
|
||||||
|
|
||||||
|
toast.success('Login successful!');
|
||||||
|
setIsProcessing(false);
|
||||||
|
|
||||||
|
// Check if user has completed onboarding (has location)
|
||||||
|
if (result.user.location) {
|
||||||
|
globalThis.location.replace('/dashboard');
|
||||||
|
} else {
|
||||||
|
globalThis.location.replace('/onboarding/user');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Callback] Error:', err);
|
||||||
|
setError((err as Error).message);
|
||||||
|
setIsProcessing(false);
|
||||||
|
toast.error('An error occurred during login');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
navigate('/auth/login');
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleCallback();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
// Check if error is related to private email
|
||||||
|
const isPrivateEmailError =
|
||||||
|
error.toLowerCase().includes('failed to create user') ||
|
||||||
|
error.toLowerCase().includes('email') ||
|
||||||
|
error.toLowerCase().includes('user record');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center min-h-screen bg-gray-50 px-4">
|
||||||
|
<div className="bg-white w-full max-w-2xl p-8 rounded-2xl shadow-lg border border-red-200">
|
||||||
|
<div className="text-center mb-6">
|
||||||
|
<div className="text-red-500 text-5xl mb-4">⚠️</div>
|
||||||
|
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||||
|
GitHub Login Failed
|
||||||
|
</h2>
|
||||||
|
<p className="text-red-600 mb-4 whitespace-pre-line">{error}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isPrivateEmailError && (
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6">
|
||||||
|
<h3 className="font-semibold text-amber-800 mb-2">
|
||||||
|
Is your GitHub email set to private?
|
||||||
|
</h3>
|
||||||
|
<p className="text-amber-700 text-sm mb-3">
|
||||||
|
GitHub login requires a public email address. Please follow
|
||||||
|
these steps:
|
||||||
|
</p>
|
||||||
|
<ol className="text-amber-700 text-sm list-decimal list-inside space-y-1 mb-3">
|
||||||
|
<li>
|
||||||
|
Go to{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/settings/emails"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:text-amber-900"
|
||||||
|
>
|
||||||
|
GitHub Email Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>Uncheck "Keep my email addresses private"</li>
|
||||||
|
<li>
|
||||||
|
Or go to{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/settings/profile"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:text-amber-900"
|
||||||
|
>
|
||||||
|
Profile Settings
|
||||||
|
</a>{' '}
|
||||||
|
and set a public email
|
||||||
|
</li>
|
||||||
|
<li>Try signing in with GitHub again</li>
|
||||||
|
</ol>
|
||||||
|
<p className="text-amber-600 text-xs">
|
||||||
|
Alternatively, you can sign up using email and password instead.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="text-gray-600 text-sm mt-6 text-center">
|
||||||
|
Redirecting to login page in 3 seconds...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center min-h-screen bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
|
||||||
|
<h2 className="text-xl font-semibold text-gray-900 mb-2">
|
||||||
|
Completing login...
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600">Please wait</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CallbackPage;
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useForgotPassword } from '@imphnen-frontend-service/service';
|
||||||
|
import { Link, useNavigate } from 'react-router';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
|
||||||
|
export default function ForgotPasswordPage() {
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [emailSent, setEmailSent] = useState(false);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const forgotPasswordMutation = useForgotPassword();
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!email) {
|
||||||
|
toast.error('Please enter your email');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await forgotPasswordMutation.mutateAsync({ email });
|
||||||
|
|
||||||
|
setEmailSent(true);
|
||||||
|
toast.success('Password reset email sent! Check your inbox.');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message || 'Failed to send reset email');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (emailSent) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
|
||||||
|
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 text-center">
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="mx-auto w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<span className="text-3xl">✓</span>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 mb-2">
|
||||||
|
Check Your Email
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
We've sent a password reset link to <strong>{email}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
Click the link in the email to reset your password. The link will
|
||||||
|
expire in 1 hour.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Link to="/auth/login">
|
||||||
|
<button className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors">
|
||||||
|
Back to Login
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setEmailSent(false)}
|
||||||
|
className="w-full py-3 text-gray-600 hover:text-gray-900 transition-colors"
|
||||||
|
>
|
||||||
|
Send another email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
|
||||||
|
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/auth/login')}
|
||||||
|
className="cursor-pointer text-primary-500 hover:text-primary-600 text-base font-sans flex items-center"
|
||||||
|
>
|
||||||
|
<Icon icon="ic:baseline-chevron-left" width="24" height="24" />
|
||||||
|
Back to Login
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 mb-2">
|
||||||
|
Forgot Password?
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
No worries, we'll send you reset instructions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Email Address
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="your@email.com"
|
||||||
|
disabled={forgotPasswordMutation.isPending}
|
||||||
|
className="bg-white text-gray-900 w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={forgotPasswordMutation.isPending}
|
||||||
|
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{forgotPasswordMutation.isPending
|
||||||
|
? 'Sending...'
|
||||||
|
: 'Send Reset Link'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { GithubOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate, Link } from 'react-router';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import { useAuthStore } from '../../features/auth/store/auth.store';
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const login = useAuthStore((state) => state.login);
|
||||||
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
|
|
||||||
|
const [isGithubLoading, setIsGithubLoading] = useState(false);
|
||||||
|
const [email, setEmail] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isAuthenticated) {
|
||||||
|
navigate('/');
|
||||||
|
}
|
||||||
|
}, [isAuthenticated, navigate]);
|
||||||
|
|
||||||
|
// Check for password reset tokens in URL and redirect to reset-password page
|
||||||
|
useEffect(() => {
|
||||||
|
const hashParams = new URLSearchParams(
|
||||||
|
globalThis.location.hash.substring(1)
|
||||||
|
);
|
||||||
|
const urlParams = new URLSearchParams(globalThis.location.search);
|
||||||
|
|
||||||
|
const accessToken =
|
||||||
|
hashParams.get('access_token') || urlParams.get('access_token');
|
||||||
|
const type = hashParams.get('type') || urlParams.get('type');
|
||||||
|
|
||||||
|
// If we have an access_token, this is likely a password reset redirect that landed on the wrong page
|
||||||
|
if (accessToken) {
|
||||||
|
console.log(
|
||||||
|
'[Login] Detected access_token, redirecting to reset-password page'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if it's a password recovery
|
||||||
|
if (type === 'recovery' || type === 'magiclink' || !type) {
|
||||||
|
toast.info('Redirecting to password reset...');
|
||||||
|
navigate('/auth/reset-password?access_token=' + accessToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const handleEmailLogin = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!email || !password) {
|
||||||
|
setError('Please enter both email and password');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
try {
|
||||||
|
const success = await login(email, password);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
toast.success('Login successful!');
|
||||||
|
navigate('/');
|
||||||
|
} else {
|
||||||
|
setError('Login failed. Please check your credentials.');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Login] Email login failed:', err);
|
||||||
|
setError('Login failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGithubLogin = async () => {
|
||||||
|
// TODO: Implement GitHub login with new auth service if needed
|
||||||
|
toast.info('GitHub login coming soon');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
|
||||||
|
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
|
||||||
|
{/* <div className="flex items-center justify-between mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="cursor-pointer text-primary-500 hover:text-primary-600 text-base font-sans flex items-center"
|
||||||
|
>
|
||||||
|
<Icon icon="ic:baseline-chevron-left" width="24" height="24" />
|
||||||
|
Back to Homepage
|
||||||
|
</button>
|
||||||
|
</div> */}
|
||||||
|
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 mb-2">
|
||||||
|
Welcome Back
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600">Sign in to continue to IMPHNEN</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<p className="text-red-600 text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Traditional Login Form */}
|
||||||
|
<form onSubmit={handleEmailLogin} className="space-y-4 mb-6">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="yourname@example.com"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="block text-sm font-medium text-gray-700"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<Link
|
||||||
|
to="/auth/forgot-password"
|
||||||
|
className="text-sm text-primary-600 hover:text-primary-700 font-medium"
|
||||||
|
>
|
||||||
|
Forgot password?
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed pr-10"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'}
|
||||||
|
className="text-xl"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Signing in...' : 'Sign in'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="relative my-6">
|
||||||
|
<div className="absolute inset-0 flex items-center">
|
||||||
|
<div className="w-full border-t border-gray-300"></div>
|
||||||
|
</div>
|
||||||
|
<div className="relative flex justify-center text-sm">
|
||||||
|
<span className="px-2 bg-white text-gray-500">
|
||||||
|
Or continue with
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleGithubLogin}
|
||||||
|
disabled={isGithubLoading}
|
||||||
|
type="button"
|
||||||
|
className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 border border-gray-300 rounded-lg font-semibold text-gray-900 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:bg-gray-100 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<GithubOutlined className="text-xl" />
|
||||||
|
<span>
|
||||||
|
{isGithubLoading ? 'Connecting...' : 'Sign in with GitHub'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="mt-3 text-xs text-center text-gray-500">
|
||||||
|
Make sure your GitHub email is{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/settings/emails"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary-600 hover:underline"
|
||||||
|
>
|
||||||
|
set to public
|
||||||
|
</a>{' '}
|
||||||
|
for GitHub sign in to work.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
Don't have an account?{' '}
|
||||||
|
<Link
|
||||||
|
to="/auth/signup"
|
||||||
|
className="text-primary-600 hover:text-primary-700 font-semibold"
|
||||||
|
>
|
||||||
|
Sign up
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-gray-500 text-xs">
|
||||||
|
By signing in, you agree to our Terms of Service and Privacy Policy
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
useResetPassword,
|
||||||
|
useAuthStore,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
|
||||||
|
export default function ResetPasswordPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { clearSession } = useAuthStore();
|
||||||
|
const resetPasswordMutation = useResetPassword();
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [accessToken, setAccessToken] = useState<string | null>(null);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Get the access_token from URL hash (Supabase sends it as hash fragment)
|
||||||
|
// or from query params (when redirected from callback page)
|
||||||
|
const hashParams = new URLSearchParams(
|
||||||
|
globalThis.location.hash.substring(1)
|
||||||
|
);
|
||||||
|
const queryParams = new URLSearchParams(globalThis.location.search);
|
||||||
|
const token =
|
||||||
|
hashParams.get('access_token') || queryParams.get('access_token');
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
setAccessToken(token);
|
||||||
|
} else {
|
||||||
|
toast.error('Invalid or expired reset link');
|
||||||
|
setTimeout(() => navigate('/auth/forgot-password'), 2000);
|
||||||
|
}
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
toast.error('Passwords do not match');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password.length < 6) {
|
||||||
|
toast.error('Password must be at least 6 characters');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
toast.error('Invalid reset token');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await resetPasswordMutation.mutateAsync({
|
||||||
|
access_token: accessToken,
|
||||||
|
new_password: password,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success('Password updated successfully!');
|
||||||
|
|
||||||
|
// Clear session and redirect to login
|
||||||
|
clearSession();
|
||||||
|
navigate('/auth/login');
|
||||||
|
} catch (err) {
|
||||||
|
toast.error((err as Error).message || 'Failed to reset password');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!accessToken) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center min-h-screen bg-gray-50">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
|
||||||
|
<p className="text-gray-600">Verifying reset link...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
|
||||||
|
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 mb-2">
|
||||||
|
Set New Password
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600">Enter your new password below</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
New Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
disabled={resetPasswordMutation.isPending}
|
||||||
|
className="w-full px-4 py-2.5 pr-12 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed bg-white text-gray-900"
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'}
|
||||||
|
className="text-xl"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="confirmPassword"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Confirm New Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
|
value={confirmPassword}
|
||||||
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
|
placeholder="••••••••"
|
||||||
|
disabled={resetPasswordMutation.isPending}
|
||||||
|
className="w-full px-4 py-2.5 pr-12 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed bg-white text-gray-900"
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={showConfirmPassword ? 'mdi:eye-off' : 'mdi:eye'}
|
||||||
|
className="text-xl"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={resetPasswordMutation.isPending}
|
||||||
|
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{resetPasswordMutation.isPending
|
||||||
|
? 'Updating...'
|
||||||
|
: 'Update Password'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,305 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useGitHubAuth } from '@imphnen-frontend-service/service';
|
||||||
|
import { GithubOutlined } from '@ant-design/icons';
|
||||||
|
import { useNavigate, Link } from 'react-router';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Icon } from '@iconify/react';
|
||||||
|
import { useAuthStore } from '../../features/auth/store/auth.store';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const signupSchema = z
|
||||||
|
.object({
|
||||||
|
fullname: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Full name is required')
|
||||||
|
.min(2, 'Full name must be at least 2 characters'),
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Email is required')
|
||||||
|
.email('Please enter a valid email address'),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Password is required')
|
||||||
|
.min(6, 'Password must be at least 6 characters'),
|
||||||
|
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: 'Passwords do not match',
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
});
|
||||||
|
|
||||||
|
type SignupFormData = z.infer<typeof signupSchema>;
|
||||||
|
|
||||||
|
export default function SignupPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const registerUser = useAuthStore((state) => state.register);
|
||||||
|
|
||||||
|
const { signInWithGitHub } = useGitHubAuth();
|
||||||
|
const [isGithubLoading, setIsGithubLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isValid },
|
||||||
|
} = useForm<SignupFormData>({
|
||||||
|
resolver: zodResolver(signupSchema),
|
||||||
|
mode: 'onChange',
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: SignupFormData) => {
|
||||||
|
setError(null);
|
||||||
|
setIsSubmitting(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await registerUser(data.fullname, data.email, data.password);
|
||||||
|
toast.success('Registration successful! Redirecting...');
|
||||||
|
navigate('/');
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[Signup] Email signup failed:', err);
|
||||||
|
// Construct a user-friendly error message
|
||||||
|
const errorMessage =
|
||||||
|
err.response?.data?.message || err.message || 'Signup failed';
|
||||||
|
setError(errorMessage);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGithubLogin = async () => {
|
||||||
|
try {
|
||||||
|
setIsGithubLoading(true);
|
||||||
|
|
||||||
|
const result = await signInWithGitHub();
|
||||||
|
|
||||||
|
if (result?.url) {
|
||||||
|
globalThis.location.href = result.url;
|
||||||
|
} else {
|
||||||
|
setIsGithubLoading(false);
|
||||||
|
setError('Failed to get GitHub OAuth URL');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Signup] GitHub login failed:', err);
|
||||||
|
setError((err as Error).message || 'GitHub login failed');
|
||||||
|
setIsGithubLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputBaseClass =
|
||||||
|
'w-full px-4 py-2.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed';
|
||||||
|
const inputErrorClass = 'border-red-500';
|
||||||
|
const inputNormalClass = 'border-gray-300';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
|
||||||
|
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/')}
|
||||||
|
className="cursor-pointer text-primary-500 hover:text-primary-600 text-base font-sans flex items-center"
|
||||||
|
>
|
||||||
|
<Icon icon="ic:baseline-chevron-left" width="24" height="24" />
|
||||||
|
Back to Homepage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 mb-2">
|
||||||
|
Create Account
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600">Join IMPHNEN community</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="mb-6 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||||
|
<p className="text-red-600 text-sm">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="fullname"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Full Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="fullname"
|
||||||
|
type="text"
|
||||||
|
{...register('fullname')}
|
||||||
|
placeholder="John Doe"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={`${inputBaseClass} ${
|
||||||
|
errors.fullname ? inputErrorClass : inputNormalClass
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{errors.fullname && (
|
||||||
|
<p className="mt-1 text-sm text-red-500">
|
||||||
|
{errors.fullname.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="email"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
{...register('email')}
|
||||||
|
placeholder="yourname@example.com"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={`${inputBaseClass} ${
|
||||||
|
errors.email ? inputErrorClass : inputNormalClass
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="mt-1 text-sm text-red-500">
|
||||||
|
{errors.email.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="password"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
{...register('password')}
|
||||||
|
placeholder="••••••••"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={`${inputBaseClass} pr-12 ${
|
||||||
|
errors.password ? inputErrorClass : inputNormalClass
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'}
|
||||||
|
className="text-xl"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="mt-1 text-sm text-red-500">
|
||||||
|
{errors.password.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
htmlFor="confirmPassword"
|
||||||
|
className="block text-sm font-medium text-gray-700 mb-1"
|
||||||
|
>
|
||||||
|
Confirm Password
|
||||||
|
</label>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id="confirmPassword"
|
||||||
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
|
{...register('confirmPassword')}
|
||||||
|
placeholder="••••••••"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className={`${inputBaseClass} pr-12 ${
|
||||||
|
errors.confirmPassword ? inputErrorClass : inputNormalClass
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700"
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
icon={showConfirmPassword ? 'mdi:eye-off' : 'mdi:eye'}
|
||||||
|
className="text-xl"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{errors.confirmPassword && (
|
||||||
|
<p className="mt-1 text-sm text-red-500">
|
||||||
|
{errors.confirmPassword.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!isValid || isSubmitting}
|
||||||
|
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Creating account...' : 'Create Account'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="my-6 flex items-center">
|
||||||
|
<div className="flex-1 border-t border-gray-300"></div>
|
||||||
|
<span className="px-4 text-sm text-gray-500">OR</span>
|
||||||
|
<div className="flex-1 border-t border-gray-300"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleGithubLogin}
|
||||||
|
disabled={isGithubLoading}
|
||||||
|
type="button"
|
||||||
|
className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 border border-gray-300 rounded-lg font-semibold text-gray-900 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:bg-gray-100 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
<GithubOutlined className="text-xl" />
|
||||||
|
<span>
|
||||||
|
{isGithubLoading ? 'Connecting...' : 'Sign up with GitHub'}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="mt-3 text-xs text-center text-gray-500">
|
||||||
|
Make sure your GitHub email is{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/settings/emails"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="text-primary-600 hover:underline"
|
||||||
|
>
|
||||||
|
set to public
|
||||||
|
</a>{' '}
|
||||||
|
for GitHub sign up to work.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-gray-600 text-sm">
|
||||||
|
Already have an account?{' '}
|
||||||
|
<Link
|
||||||
|
to="/auth/login"
|
||||||
|
className="text-primary-600 hover:text-primary-700 font-semibold"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 text-center">
|
||||||
|
<p className="text-gray-500 text-xs">
|
||||||
|
By signing up, you agree to our Terms of Service and Privacy Policy
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,56 @@
|
|||||||
|
import { api } from '../../auth/api/auth.service';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface Campaign {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateCampaignRequest {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CampaignsResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data: Campaign[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CampaignResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data: Campaign;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeleteResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const campaignService = {
|
||||||
|
getCampaigns: async (): Promise<Campaign[]> => {
|
||||||
|
const response = await api.get<CampaignsResponse>('/campaigns');
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createCampaign: async (data: CreateCampaignRequest): Promise<Campaign> => {
|
||||||
|
const response = await api.post<CampaignResponse>('/campaigns', data);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
activateCampaign: async (campaignId: string): Promise<Campaign> => {
|
||||||
|
const response = await api.put<CampaignResponse>(
|
||||||
|
`/campaigns/${campaignId}/activate`
|
||||||
|
);
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteCampaign: async (campaignId: string): Promise<void> => {
|
||||||
|
await api.delete<DeleteResponse>(`/campaigns/${campaignId}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { api } from '../../auth/api/auth.service';
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface User {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
role: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserRoleRequest {
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsersResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data: User[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DeleteResponse {
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const userService = {
|
||||||
|
getUsers: async (): Promise<User[]> => {
|
||||||
|
const response = await api.get<UsersResponse>('/users');
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateUserRole: async (
|
||||||
|
userId: string,
|
||||||
|
role: string
|
||||||
|
): Promise<User> => {
|
||||||
|
const response = await api.put<UserResponse>(`/users/${userId}/role`, {
|
||||||
|
role,
|
||||||
|
});
|
||||||
|
return response.data.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteUser: async (userId: string): Promise<void> => {
|
||||||
|
await api.delete<DeleteResponse>(`/users/${userId}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '../../auth/store/auth.store';
|
||||||
|
|
||||||
|
interface RequireAdminProps {
|
||||||
|
children: JSX.Element;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RequireAdmin = ({ children }: RequireAdminProps) => {
|
||||||
|
const user = useAuthStore((state) => state.user);
|
||||||
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
if (!isAuthenticated) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has admin role
|
||||||
|
// user.role is now an object { id, name, permissions }
|
||||||
|
const userRole = user?.role?.name;
|
||||||
|
if (userRole !== 'Admin' && userRole !== 'Super Admin') {
|
||||||
|
// Redirect non-admins to home
|
||||||
|
return <Navigate to="/" replace />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return children;
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Outlet, Link, useLocation } from 'react-router-dom';
|
||||||
|
import { useAuthStore } from '../../auth/store/auth.store';
|
||||||
|
|
||||||
|
export const AdminDashboard = () => {
|
||||||
|
const logout = useAuthStore((state) => state.logout);
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
const isActive = (path: string) => location.pathname.startsWith(path);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-100 flex">
|
||||||
|
{/* Sidebar */}
|
||||||
|
<aside className="w-64 bg-white border-r border-slate-200 flex flex-col">
|
||||||
|
<div className="p-6 border-b border-slate-200">
|
||||||
|
<h1 className="text-xl font-bold text-slate-800">Admin Panel</h1>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">QR Campaign Manager</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav className="flex-1 p-4 space-y-1">
|
||||||
|
<Link
|
||||||
|
to="/admin/campaigns"
|
||||||
|
className={`block px-4 py-2 rounded-md transition-colors ${
|
||||||
|
isActive('/admin/campaigns')
|
||||||
|
? 'bg-blue-50 text-blue-700'
|
||||||
|
: 'text-slate-600 hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Campaigns
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/admin/users"
|
||||||
|
className={`block px-4 py-2 rounded-md transition-colors ${
|
||||||
|
isActive('/admin/users')
|
||||||
|
? 'bg-blue-50 text-blue-700'
|
||||||
|
: 'text-slate-600 hover:bg-slate-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Users
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="p-4 border-t border-slate-200">
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="block px-4 py-2 text-sm text-slate-600 hover:text-slate-900 mb-2"
|
||||||
|
>
|
||||||
|
← Back to App
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="w-full px-4 py-2 text-sm text-red-600 hover:bg-red-50 rounded-md transition-colors text-left"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex-1 p-8 overflow-auto">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user