Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bffa32dd67 |
@@ -10,13 +10,7 @@
|
||||
"Bash(ren page.tsx page-original.tsx)",
|
||||
"Bash(ren:*)",
|
||||
"Bash(npx supabase:*)",
|
||||
"Bash(libs/service/src/types/supabase.ts)",
|
||||
"Bash(npx nx build:*)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push)",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(ls:*)"
|
||||
"Bash(libs/service/src/types/supabase.ts)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -e
|
||||
if [[ ! -d "/Users/ms/Development/personal/imphnen-frontend-service" ]]; then
|
||||
echo "Cannot find source directory; Did you move it?"
|
||||
echo "(Looking for "/Users/ms/Development/personal/imphnen-frontend-service")"
|
||||
echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# rebuild the cache forcefully
|
||||
_nix_direnv_force_reload=1 direnv exec "/Users/ms/Development/personal/imphnen-frontend-service" true
|
||||
|
||||
# Update the mtime for .envrc.
|
||||
# This will cause direnv to reload again - but without re-building.
|
||||
touch "/Users/ms/Development/personal/imphnen-frontend-service/.envrc"
|
||||
|
||||
# Also update the timestamp of whatever profile_rc we have.
|
||||
# This makes sure that we know we are up to date.
|
||||
touch -r "/Users/ms/Development/personal/imphnen-frontend-service/.envrc" "/Users/ms/Development/personal/imphnen-frontend-service/.direnv"/*.rc
|
||||
@@ -1 +0,0 @@
|
||||
/nix/store/01x5k4nlxcpyd85nnr0b9gm89rm8ff4x-source
|
||||
@@ -1 +0,0 @@
|
||||
/nix/store/8hx8jwa24q6rkzhca9iqy5ckk9rbphi1-source
|
||||
@@ -1 +0,0 @@
|
||||
/nix/store/jizz7ld5r2w11y03wrmnvwsskc45krj9-source
|
||||
@@ -1 +0,0 @@
|
||||
/nix/store/yj1wxm9hh8610iyzqnz75kvs6xl8j3my-source
|
||||
@@ -1 +0,0 @@
|
||||
/nix/store/y4p9szcab9xd7v1c7k9ghr86hb6myyg4-nix-shell-env
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,3 @@
|
||||
|
||||
VITE_SUPABASE_URL=https://your-project-id.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=your-anon-key-here
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Deploy Backoffice
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
paths:
|
||||
- 'apps/backoffice/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||
|
||||
concurrency:
|
||||
group: deploy-vps
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
command_timeout: 30m
|
||||
script: |
|
||||
set -e
|
||||
if [ ! -d ~/imphnen-frontend-service-backoffice ]; then
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-backoffice
|
||||
else
|
||||
cd ~/imphnen-frontend-service-backoffice && git fetch origin && git reset --hard origin/develop && git pull
|
||||
fi
|
||||
cat > ~/imphnen-frontend-service-backoffice/apps/backoffice/.env << EOF
|
||||
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
||||
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
EOF
|
||||
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml down || true
|
||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml build --progress=plain
|
||||
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml up -d
|
||||
docker image prune -af
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Deploy Dimentorin
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
paths:
|
||||
- 'apps/dimentorin/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||
|
||||
concurrency:
|
||||
group: deploy-vps
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
command_timeout: 30m
|
||||
script: |
|
||||
set -e
|
||||
if [ ! -d ~/imphnen-frontend-service-dimentorin ]; then
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-dimentorin
|
||||
else
|
||||
cd ~/imphnen-frontend-service-dimentorin && git fetch origin && git reset --hard origin/develop && git pull
|
||||
fi
|
||||
cat > ~/imphnen-frontend-service-dimentorin/apps/dimentorin/.env << EOF
|
||||
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
||||
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
EOF
|
||||
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml down || true
|
||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml build --progress=plain
|
||||
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml up -d
|
||||
docker image prune -af
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Deploy Gacha
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
paths:
|
||||
- 'apps/gacha/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||
|
||||
concurrency:
|
||||
group: deploy-vps
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
command_timeout: 30m
|
||||
script: |
|
||||
set -e
|
||||
if [ ! -d ~/imphnen-frontend-service-gacha ]; then
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-gacha
|
||||
else
|
||||
cd ~/imphnen-frontend-service-gacha && git fetch origin && git reset --hard origin/develop && git pull
|
||||
fi
|
||||
cat > ~/imphnen-frontend-service-gacha/apps/gacha/.env << EOF
|
||||
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
||||
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
EOF
|
||||
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml down || true
|
||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml build --progress=plain
|
||||
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml up -d
|
||||
docker image prune -af
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Deploy Hackathon
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
paths:
|
||||
- 'apps/hackathon/**' # Auto deploy when changes pushed to apps/hackathon
|
||||
|
||||
concurrency:
|
||||
group: deploy-vps
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
command_timeout: 30m
|
||||
script: |
|
||||
set -e
|
||||
if [ ! -d ~/imphnen-frontend-service-hackathon ]; then
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-hackathon
|
||||
else
|
||||
cd ~/imphnen-frontend-service-hackathon && git fetch origin && git reset --hard origin/develop && git pull
|
||||
fi
|
||||
cat > ~/imphnen-frontend-service-hackathon/.env.local << EOF
|
||||
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
||||
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
EOF
|
||||
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml down || true
|
||||
docker builder prune -af || true
|
||||
DOCKER_BUILDKIT=1 docker compose --env-file ~/imphnen-frontend-service-hackathon/.env.local -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml build --progress=plain
|
||||
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml up -d
|
||||
docker image prune -af
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Deploy Landing
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
paths:
|
||||
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||
|
||||
concurrency:
|
||||
group: deploy-vps
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
command_timeout: 30m
|
||||
script: |
|
||||
set -e
|
||||
if [ ! -d ~/imphnen-frontend-service ]; then
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service
|
||||
else
|
||||
cd ~/imphnen-frontend-service && git fetch origin && git reset --hard origin/develop && git pull
|
||||
fi
|
||||
cat > ~/imphnen-frontend-service/apps/landing/.env << EOF
|
||||
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}
|
||||
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||
EOF
|
||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
|
||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build --progress=plain
|
||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
|
||||
docker image prune -af
|
||||
@@ -1,40 +0,0 @@
|
||||
name: Nix Build & Cache
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['develop']
|
||||
pull_request:
|
||||
branches: ['develop']
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
|
||||
- name: Setup Cachix
|
||||
uses: cachix/cachix-action@v15
|
||||
with:
|
||||
name: msdqn
|
||||
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
|
||||
|
||||
- name: Build all packages
|
||||
run: |
|
||||
nix build .#landing -o result-landing
|
||||
nix build .#backoffice -o result-backoffice
|
||||
nix build .#gacha -o result-gacha
|
||||
nix build .#dimentorin -o result-dimentorin
|
||||
nix build .#hackathon -o result-hackathon
|
||||
nix build .#infra -o result-infra
|
||||
|
||||
- name: Show build outputs
|
||||
run: |
|
||||
echo "Landing: $(readlink result-landing)"
|
||||
echo "Backoffice: $(readlink result-backoffice)"
|
||||
echo "Gacha: $(readlink result-gacha)"
|
||||
echo "Dimentorin: $(readlink result-dimentorin)"
|
||||
echo "Hackathon: $(readlink result-hackathon)"
|
||||
echo "Infra: $(readlink result-infra)"
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"nx-mcp": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["nx", "mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<!-- nx configuration start-->
|
||||
<!-- Leave the start & end comments to automatically receive updates. -->
|
||||
|
||||
# General Guidelines for working with Nx
|
||||
|
||||
- When running tasks (for example build, lint, test, e2e, etc.), always prefer running the task through `nx` (i.e. `nx run`, `nx run-many`, `nx affected`) instead of using the underlying tooling directly
|
||||
- You have access to the Nx MCP server and its tools, use them to help the user
|
||||
- When answering questions about the repository, use the `nx_workspace` tool first to gain an understanding of the workspace architecture where applicable.
|
||||
- When working in individual projects, use the `nx_project_details` mcp tool to analyze and understand the specific project structure and dependencies
|
||||
- For questions around nx configuration, best practices or if you're unsure, use the `nx_docs` tool to get relevant, up-to-date docs. Always use this instead of assuming things about nx configuration
|
||||
- If the user needs help with an Nx configuration or project graph error, use the `nx_workspace` tool to get any errors
|
||||
|
||||
<!-- nx configuration end-->
|
||||
@@ -10,7 +10,6 @@ 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>.
|
||||
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>.
|
||||
5. **QR Campaign** - Application for QR Campaign Management.
|
||||
|
||||
## How to install
|
||||
|
||||
@@ -55,10 +54,6 @@ Use the following commands to run in development mode:
|
||||
```sh
|
||||
npm run landing:dev
|
||||
```
|
||||
- **QR Campaign**:
|
||||
```sh
|
||||
npm run qrcampaign:dev
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
@@ -80,10 +75,6 @@ Use the following commands to build the applications:
|
||||
```sh
|
||||
npm run landing:build
|
||||
```
|
||||
- **QR Campaign**:
|
||||
```sh
|
||||
npm run qrcampaign:build
|
||||
```
|
||||
|
||||
### Production
|
||||
|
||||
@@ -105,10 +96,6 @@ Use the following commands to run the applications in production mode:
|
||||
```sh
|
||||
npm run landing:prod
|
||||
```
|
||||
- **QR Campaign**:
|
||||
```sh
|
||||
npm run qrcampaign:prod
|
||||
```
|
||||
|
||||
### Storybook
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="light">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Backoffice</title>
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
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
@@ -1,249 +0,0 @@
|
||||
import { FC } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import {
|
||||
CloseOutlined,
|
||||
LinkOutlined,
|
||||
ProjectOutlined,
|
||||
FileImageOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { TAdminSubmissionItem } from '@imphnen-frontend-service/service';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
interface SubmissionModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
submission: TAdminSubmissionItem;
|
||||
}
|
||||
|
||||
const SubmissionModal: FC<SubmissionModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
submission,
|
||||
}) => {
|
||||
if (!isOpen) return null;
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'submitted':
|
||||
return 'bg-success-50 border-success-200 text-success-800';
|
||||
case 'pending':
|
||||
return 'bg-orange-50 border-orange-200 text-orange-800';
|
||||
case 'approved':
|
||||
return 'bg-blue-50 border-blue-200 text-blue-800';
|
||||
case 'rejected':
|
||||
return 'bg-error-50 border-error-200 text-error-800';
|
||||
default:
|
||||
return 'bg-neutral-50 border-neutral-200 text-neutral-800';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-success-100 flex items-center justify-center">
|
||||
<ProjectOutlined className="text-success-600 text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-neutral-900">
|
||||
{submission.project_name}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-500">
|
||||
Team ID: {submission.team_id} • Submitted{' '}
|
||||
{new Date(submission.submitted_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-neutral-400 hover:text-neutral-600 transition-colors cursor-pointer"
|
||||
>
|
||||
<CloseOutlined className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Submission Status */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-4 border rounded-lg',
|
||||
getStatusColor(submission.status)
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'w-3 h-3 rounded-full',
|
||||
submission.status === 'submitted' && 'bg-success-500',
|
||||
submission.status === 'pending' && 'bg-orange-500',
|
||||
submission.status === 'approved' && 'bg-blue-500',
|
||||
submission.status === 'rejected' && 'bg-error-500'
|
||||
)}
|
||||
></div>
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
Status:{' '}
|
||||
{submission.status.charAt(0).toUpperCase() +
|
||||
submission.status.slice(1)}
|
||||
</p>
|
||||
<p className="text-xs">Submitted by: {submission.submitted_by}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Description */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-neutral-700 mb-2">
|
||||
Project Description
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-600 leading-relaxed">
|
||||
{submission.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Project Links */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-neutral-700">
|
||||
Project Links
|
||||
</h3>
|
||||
|
||||
{/* Repository URL */}
|
||||
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
||||
<LinkOutlined className="text-primary-500 mt-1" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-neutral-600 mb-1">
|
||||
Repository
|
||||
</p>
|
||||
<a
|
||||
href={submission.repository_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary-600 hover:text-primary-700 hover:underline break-all"
|
||||
>
|
||||
{submission.repository_url}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Demo URL */}
|
||||
{submission.demo_url && (
|
||||
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
||||
<LinkOutlined className="text-primary-500 mt-1" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-neutral-600 mb-1">
|
||||
Live Demo
|
||||
</p>
|
||||
<a
|
||||
href={submission.demo_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary-600 hover:text-primary-700 hover:underline break-all"
|
||||
>
|
||||
{submission.demo_url}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Presentation URL */}
|
||||
{submission.presentation_url && (
|
||||
<div className="flex items-start gap-3 p-3 bg-neutral-50 rounded-lg">
|
||||
<LinkOutlined className="text-primary-500 mt-1" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs font-medium text-neutral-600 mb-1">
|
||||
Presentation
|
||||
</p>
|
||||
<a
|
||||
href={submission.presentation_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-primary-600 hover:text-primary-700 hover:underline break-all"
|
||||
>
|
||||
{submission.presentation_url}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Screenshots */}
|
||||
{submission.screenshots && submission.screenshots.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-neutral-700 flex items-center gap-2">
|
||||
<FileImageOutlined className="text-primary-500" />
|
||||
Screenshots ({submission.screenshots.length})
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{submission.screenshots.map((screenshot, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={screenshot}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block rounded-lg overflow-hidden border border-neutral-200 hover:border-primary-300 transition-colors"
|
||||
>
|
||||
<img
|
||||
src={screenshot}
|
||||
alt={`Screenshot ${index + 1}`}
|
||||
className="w-full h-40 object-cover"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata */}
|
||||
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-neutral-200">
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 mb-1">Created</p>
|
||||
<p className="text-sm text-neutral-900">
|
||||
{new Date(submission.created_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-neutral-500 mb-1">Last Updated</p>
|
||||
<p className="text-sm text-neutral-900">
|
||||
{new Date(submission.updated_at).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 p-6 border-t border-neutral-200 bg-neutral-50">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
{/* <Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
console.log('Edit submission:', submission.id);
|
||||
}}
|
||||
>
|
||||
Edit Status
|
||||
</Button> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubmissionModal;
|
||||
@@ -1,375 +0,0 @@
|
||||
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
@@ -1,514 +0,0 @@
|
||||
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
@@ -1,82 +0,0 @@
|
||||
import { FC } from 'react';
|
||||
import { TeamOutlined } from '@ant-design/icons';
|
||||
import { cn } from '@imphnen-frontend-service/utils';
|
||||
|
||||
interface TeamBannerPlaceholderProps {
|
||||
banner?: string;
|
||||
teamName: string;
|
||||
className?: string;
|
||||
showPlaceholder?: boolean;
|
||||
}
|
||||
|
||||
const TeamBannerPlaceholder: FC<TeamBannerPlaceholderProps> = ({
|
||||
banner,
|
||||
teamName,
|
||||
className = '',
|
||||
showPlaceholder = true,
|
||||
}) => {
|
||||
const aspectRatioClass = 'aspect-[3/1]'; // 3:1 aspect ratio
|
||||
|
||||
if (!banner && !showPlaceholder) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (banner) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full bg-gray-100 overflow-hidden relative',
|
||||
aspectRatioClass,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<img
|
||||
src={banner}
|
||||
alt={`${teamName} banner`}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
// Fallback to placeholder if image fails to load
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
const placeholder = target.nextElementSibling as HTMLElement;
|
||||
if (placeholder) {
|
||||
placeholder.style.display = 'flex';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{/* Fallback placeholder (hidden by default, shown on image error) */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 bg-linear-to-r from-gray-100 to-gray-200 flex items-center justify-center',
|
||||
'hidden' // Hidden by default
|
||||
)}
|
||||
>
|
||||
<div className="text-center">
|
||||
<TeamOutlined className="text-4xl text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500 font-medium">{teamName}</p>
|
||||
<p className="text-xs text-gray-400">Team Banner</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No banner - show placeholder
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'w-full bg-linear-to-r from-gray-100 to-gray-200 flex items-center justify-center',
|
||||
aspectRatioClass,
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="text-center">
|
||||
<TeamOutlined className="text-4xl text-gray-400 mb-2" />
|
||||
<p className="text-sm text-gray-500 font-medium">{teamName}</p>
|
||||
<p className="text-xs text-gray-400">No Banner</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamBannerPlaceholder;
|
||||
@@ -1,447 +0,0 @@
|
||||
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
@@ -1,655 +0,0 @@
|
||||
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;
|
||||
@@ -1,573 +0,0 @@
|
||||
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,51 +1,13 @@
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { BackofficeSidebar } from '@imphnen-frontend-service/ui/organisms';
|
||||
|
||||
export const AppLayout: FC = (): ReactElement => {
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="bg-primary-50 min-h-screen flex justify-center">
|
||||
<div className="bg-primary-50 min-h-screen w-full flex">
|
||||
<BackofficeSidebar
|
||||
isOpen={mobileSidebarOpen}
|
||||
onClose={() => setMobileSidebarOpen(false)}
|
||||
/>
|
||||
<BackofficeSidebar />
|
||||
<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 />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,29 +1,19 @@
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms';
|
||||
import {
|
||||
BackofficeWrapper,
|
||||
DataTable,
|
||||
} from '@imphnen-frontend-service/ui/organisms';
|
||||
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';
|
||||
import { SearchOutlined } from "@ant-design/icons";
|
||||
import { Button, Input, Select } from "@imphnen-frontend-service/ui/atoms";
|
||||
import { BackofficeWrapper, DataTable } from "@imphnen-frontend-service/ui/organisms";
|
||||
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';
|
||||
|
||||
interface UserType {
|
||||
id: number;
|
||||
name: string;
|
||||
email: string;
|
||||
rating: number;
|
||||
status: UserStatus;
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
rating: number
|
||||
status: UserStatus
|
||||
}
|
||||
|
||||
const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||
@@ -32,15 +22,15 @@ const mockData: UserType[] = Array.from({ length: 90 }, (_, i) => ({
|
||||
email: 'fullname23@gmail.com',
|
||||
rating: 4.5,
|
||||
status: i % 2 === 0 ? 'active' : 'inactive',
|
||||
}));
|
||||
}))
|
||||
|
||||
export default function Components(): ReactElement {
|
||||
const TABS = ['mentor', 'mentee'] as const;
|
||||
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor');
|
||||
const [showDetail, setShowDetail] = useState(false);
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
||||
const TABS = ['mentor', 'mentee'] as const
|
||||
const [activeTab, setActiveTab] = useState<'mentor' | 'mentee'>('mentor')
|
||||
const [showDetail, setShowDetail] = useState(false)
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null)
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [pagination, setPagination] = useState<PaginationState>({
|
||||
pageIndex: 0,
|
||||
pageSize: 9,
|
||||
@@ -49,7 +39,7 @@ export default function Components(): ReactElement {
|
||||
const columns: ColumnDef<UserType>[] = [
|
||||
{
|
||||
id: 'select',
|
||||
meta: { cellClassName: cn('w-20') },
|
||||
meta: { cellClassName: cn("w-20") },
|
||||
header: ({ table }) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -107,7 +97,7 @@ export default function Components(): ReactElement {
|
||||
},
|
||||
{
|
||||
header: 'Action',
|
||||
meta: { cellClassName: cn('w-72') },
|
||||
meta: { cellClassName: cn("w-72") },
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -123,7 +113,7 @@ export default function Components(): ReactElement {
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
]
|
||||
|
||||
const table = useReactTable({
|
||||
data: mockData,
|
||||
@@ -144,19 +134,14 @@ export default function Components(): ReactElement {
|
||||
return (
|
||||
<BackofficeWrapper title="Dimentorin.dev">
|
||||
<div className="mb-8 flex justify-between items-center">
|
||||
<h1 className="text-p1 font-semibold text-neutral-700 mb-8">
|
||||
User Management
|
||||
</h1>
|
||||
<h1 className="text-p1 font-semibold text-neutral-700">User Management</h1>
|
||||
<div className="flex gap-2 bg-primary-100 p-1.5 rounded-md">
|
||||
<For data={TABS}>
|
||||
{(tab) => (
|
||||
<Button
|
||||
key={tab}
|
||||
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)}
|
||||
>
|
||||
{tab}
|
||||
@@ -178,16 +163,12 @@ export default function Components(): ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
<Select>
|
||||
<option selected disabled>
|
||||
Rating
|
||||
</option>
|
||||
<option selected disabled>Rating</option>
|
||||
<option value="4.5">4.5</option>
|
||||
<option value="5">5</option>
|
||||
</Select>
|
||||
<Select>
|
||||
<option selected disabled>
|
||||
Status
|
||||
</option>
|
||||
<option selected disabled>Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</Select>
|
||||
|
||||
@@ -2,39 +2,22 @@ import { useForm } from 'react-hook-form';
|
||||
import {
|
||||
authLoginSchema,
|
||||
TLoginRequest,
|
||||
useBackofficeLogin,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { useSession } from '@imphnen-frontend-service/utils';
|
||||
|
||||
export const useLogin = () => {
|
||||
const navigate = useNavigate();
|
||||
const loginMutation = useBackofficeLogin();
|
||||
|
||||
const form = useForm<TLoginRequest>({
|
||||
resolver: zodResolver(authLoginSchema),
|
||||
mode: 'all',
|
||||
defaultValues: {
|
||||
email: '',
|
||||
password: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = form.handleSubmit(async (data) => {
|
||||
try {
|
||||
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');
|
||||
}
|
||||
});
|
||||
const { signIn } = useSession();
|
||||
|
||||
const onSubmit = form.handleSubmit((data) => signIn(data));
|
||||
|
||||
return {
|
||||
form,
|
||||
onSubmit,
|
||||
isLoading: loginMutation.isPending,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useLogin } from './_hooks/use-login';
|
||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||
|
||||
export const Components: FC = (): ReactElement => {
|
||||
const { form, onSubmit, isLoading } = useLogin();
|
||||
const { form, onSubmit } = useLogin();
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen">
|
||||
@@ -22,7 +22,6 @@ export const Components: FC = (): ReactElement => {
|
||||
name="email"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
@@ -32,11 +31,10 @@ export const Components: FC = (): ReactElement => {
|
||||
name="password"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<Button
|
||||
disabled={
|
||||
isLoading ||
|
||||
form.formState.isSubmitting ||
|
||||
form.formState.isValidating ||
|
||||
!form.formState.isValid
|
||||
}
|
||||
@@ -44,7 +42,7 @@ export const Components: FC = (): ReactElement => {
|
||||
size="md"
|
||||
className="w-full"
|
||||
>
|
||||
{isLoading ? 'Loading...' : 'Login'}
|
||||
Login
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,518 +0,0 @@
|
||||
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 {
|
||||
font-family: 'Bai Jamjuree', sans-serif;
|
||||
font-weight: 400;
|
||||
font-size: 14px;
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
|
||||
|
||||
if (mappingPublicRoutes.includes(pathname)) {
|
||||
if (token) return redirect('/hackathon-dashboard');
|
||||
if (token) return redirect('/dashboard');
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
matchedRoute.permissions.some((perm) => userPermissions.includes(perm));
|
||||
|
||||
if (!hasPermission) {
|
||||
return '/hackathon-dashboard';
|
||||
return '/dashboard';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export default defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../node_modules/.vite/apps/backoffice',
|
||||
server: {
|
||||
port: 3003,
|
||||
port: 3000,
|
||||
host: 'localhost',
|
||||
},
|
||||
preview: {
|
||||
|
||||
@@ -18,9 +18,6 @@
|
||||
type="image/svg+xml"
|
||||
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" />
|
||||
<meta property="og:type" content="website" />
|
||||
<meta property="og:url" content="https://hackathon.imphnen.dev/" />
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 263 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 506 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 818 KiB |
@@ -1,11 +1,11 @@
|
||||
import { FC, ReactElement, useEffect, useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useGitHubCallback } from '@imphnen-frontend-service/service';
|
||||
import { useAuthStore, supabase } from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const CallbackPage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const { mutateAsync: exchangeGitHubCode } = useGitHubCallback();
|
||||
const { setSession } = useAuthStore();
|
||||
const [isProcessing, setIsProcessing] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const hasRunRef = useRef(false);
|
||||
@@ -13,68 +13,134 @@ const CallbackPage: FC = (): ReactElement => {
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
if (hasRunRef.current) {
|
||||
// console.log('[Callback] Already processed, skipping...');
|
||||
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);
|
||||
// console.log('[Callback] Processing OAuth callback...');
|
||||
// console.log('[Callback] Current URL:', globalThis.location.href);
|
||||
|
||||
const type = hashParams.get('type') || urlParams.get('type');
|
||||
const accessToken = hashParams.get('access_token') || urlParams.get('access_token');
|
||||
// Supabase client is configured with detectSessionInUrl: true
|
||||
// This means Supabase automatically detects and processes OAuth tokens from the URL hash
|
||||
// We just need to wait a moment for it to complete, then check for the session
|
||||
|
||||
// Debug: log what we received
|
||||
console.log('[Callback] Params:', { type, accessToken: !!accessToken, hash: globalThis.location.hash, search: globalThis.location.search });
|
||||
// console.log('[Callback] Waiting for Supabase to process OAuth callback...');
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Handle Supabase email callbacks (has access_token in hash or query)
|
||||
// This includes: signup confirmation, email confirmation, password recovery
|
||||
if (accessToken) {
|
||||
setIsProcessing(false);
|
||||
// Get the session that Supabase automatically created from the URL hash
|
||||
const {
|
||||
data: { session: sessionData },
|
||||
error: sessionError,
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
// 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;
|
||||
if (sessionError) {
|
||||
// console.error('[Callback] Session error:', sessionError);
|
||||
throw new Error(sessionError.message || 'Failed to get session');
|
||||
}
|
||||
|
||||
// Get the code from URL query params (GitHub OAuth)
|
||||
const code = urlParams.get('code');
|
||||
|
||||
if (!code) {
|
||||
throw new Error('No authorization code received');
|
||||
if (!sessionData || !sessionData.user) {
|
||||
throw new Error(
|
||||
'No session found after OAuth callback. Please try logging in again.'
|
||||
);
|
||||
}
|
||||
|
||||
// Exchange the code for tokens using backend API (GitHub OAuth)
|
||||
const result = await exchangeGitHubCode({ code });
|
||||
// console.log('[Callback] Supabase session established:', {
|
||||
// userId: sessionData.user.id,
|
||||
// email: sessionData.user.email,
|
||||
// });
|
||||
|
||||
// Create/update user in the users table (for foreign key constraints)
|
||||
// console.log('[Callback] Creating/updating user record...');
|
||||
const { data: userData, error: upsertError } = await supabase
|
||||
.from('users')
|
||||
.upsert(
|
||||
{
|
||||
id: sessionData.user.id,
|
||||
email: sessionData.user.email || '',
|
||||
fullname:
|
||||
sessionData.user.user_metadata?.full_name ||
|
||||
sessionData.user.user_metadata?.name ||
|
||||
sessionData.user.email?.split('@')[0] ||
|
||||
'',
|
||||
avatar: sessionData.user.user_metadata?.avatar_url || '',
|
||||
is_active: true,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
onConflict: 'id',
|
||||
}
|
||||
)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (upsertError) {
|
||||
// console.warn('[Callback] Failed to create user record:', upsertError);
|
||||
// Don't throw - continue with login even if user record creation fails
|
||||
} else {
|
||||
// console.log('[Callback] User record created/updated successfully');
|
||||
}
|
||||
|
||||
// Store user-friendly data in Zustand for UI purposes
|
||||
// Supabase now manages the actual auth session
|
||||
// Use data from database if available, otherwise use OAuth metadata
|
||||
const userRecord = userData || {
|
||||
id: sessionData.user.id,
|
||||
email: sessionData.user.email || '',
|
||||
fullname:
|
||||
sessionData.user.user_metadata?.full_name ||
|
||||
sessionData.user.user_metadata?.name ||
|
||||
sessionData.user.email?.split('@')[0] ||
|
||||
'',
|
||||
avatar: sessionData.user.user_metadata?.avatar_url || '',
|
||||
phone_number: '',
|
||||
birthdate: '',
|
||||
gender: '',
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
setSession({
|
||||
token: {
|
||||
access_token: sessionData.access_token,
|
||||
refresh_token: sessionData.refresh_token || '',
|
||||
},
|
||||
user: {
|
||||
id: userRecord.id,
|
||||
email: userRecord.email,
|
||||
fullname: userRecord.fullname,
|
||||
phone_number: userRecord.phone_number || '',
|
||||
avatar: userRecord.avatar || '',
|
||||
birthdate: userRecord.birthdate || '',
|
||||
gender: userRecord.gender || '',
|
||||
is_active: userRecord.is_active,
|
||||
location: userRecord.location,
|
||||
bio: userRecord.bio,
|
||||
skills: userRecord.skills,
|
||||
role: {
|
||||
id: '',
|
||||
name: 'user',
|
||||
permissions: [],
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// console.log('[Callback] Session stored successfully');
|
||||
toast.success('Login successful!');
|
||||
setIsProcessing(false);
|
||||
|
||||
// Check if user has completed onboarding (has location)
|
||||
if (result.user.location) {
|
||||
// Use globalThis.location.replace for hard redirect to prevent history issues
|
||||
if (userRecord.location) {
|
||||
// console.log('[Callback] User has completed onboarding, redirecting to dashboard...');
|
||||
globalThis.location.replace('/dashboard');
|
||||
} else {
|
||||
// console.log('[Callback] User needs onboarding, redirecting...');
|
||||
globalThis.location.replace('/onboarding/user');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Callback] Error:', err);
|
||||
// console.error('[Callback] Error:', err);
|
||||
setError((err as Error).message);
|
||||
setIsProcessing(false);
|
||||
toast.error('An error occurred during login');
|
||||
@@ -87,68 +153,21 @@ const CallbackPage: FC = (): ReactElement => {
|
||||
|
||||
handleCallback();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, []); // Run only once on mount
|
||||
|
||||
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 dark:bg-gray-950 px-4">
|
||||
<div className="bg-white dark:bg-gray-900 w-full max-w-2xl p-8 rounded-2xl shadow-lg border border-red-200 dark:border-red-800">
|
||||
<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 dark:text-white mb-2">
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
GitHub Login Failed
|
||||
</h2>
|
||||
<p className="text-red-600 dark:text-red-400 mb-4 whitespace-pre-line">{error}</p>
|
||||
<p className="text-red-600 mb-4 whitespace-pre-line">{error}</p>
|
||||
</div>
|
||||
|
||||
{isPrivateEmailError && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-6">
|
||||
<h3 className="font-semibold text-amber-800 dark:text-amber-300 mb-2">
|
||||
Is your GitHub email set to private?
|
||||
</h3>
|
||||
<p className="text-amber-700 dark:text-amber-400 text-sm mb-3">
|
||||
GitHub login requires a public email address. Please follow these steps:
|
||||
</p>
|
||||
<ol className="text-amber-700 dark:text-amber-400 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 dark:hover:text-amber-200"
|
||||
>
|
||||
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 dark:hover:text-amber-200"
|
||||
>
|
||||
Profile Settings
|
||||
</a>{' '}
|
||||
and set a public email
|
||||
</li>
|
||||
<li>Try signing in with GitHub again</li>
|
||||
</ol>
|
||||
<p className="text-amber-600 dark:text-amber-500 text-xs">
|
||||
Alternatively, you can sign up using email and password instead.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm mt-6 text-center">
|
||||
<p className="text-gray-600 text-sm mt-6 text-center">
|
||||
Redirecting to login page in 3 seconds...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useForgotPassword } from '@imphnen-frontend-service/service';
|
||||
import { supabase } from '@imphnen-frontend-service/service';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { Icon } from '@iconify/react';
|
||||
@@ -7,9 +7,9 @@ import ThemeToggle from '../../../components/theme-toggle';
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [emailSent, setEmailSent] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const forgotPasswordMutation = useForgotPassword();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -20,33 +20,44 @@ export default function ForgotPasswordPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
await forgotPasswordMutation.mutateAsync({ email });
|
||||
setIsLoading(true);
|
||||
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${window.location.origin}/auth/reset-password`,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setEmailSent(true);
|
||||
toast.success('Password reset email sent! Check your inbox.');
|
||||
} catch (err) {
|
||||
// console.error('Failed to send reset email:', err);
|
||||
toast.error((err as Error).message || 'Failed to send reset email');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (emailSent) {
|
||||
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="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 dark:bg-green-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<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 dark:text-white mb-2">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
Check Your Email
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
<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 dark:text-gray-400">
|
||||
<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>
|
||||
@@ -59,7 +70,7 @@ export default function ForgotPasswordPage() {
|
||||
|
||||
<button
|
||||
onClick={() => setEmailSent(false)}
|
||||
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||
className="w-full py-3 text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
Send another email
|
||||
</button>
|
||||
@@ -105,18 +116,18 @@ export default function ForgotPasswordPage() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
disabled={forgotPasswordMutation.isPending}
|
||||
className="bg-white dark:bg-gray-800 text-gray-900 dark:text-white w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
disabled={isLoading}
|
||||
className="bg-white dark:bg-gray-800 w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 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}
|
||||
disabled={isLoading}
|
||||
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'}
|
||||
{isLoading ? 'Sending...' : 'Send Reset Link'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
useGitHubAuth,
|
||||
useLogin,
|
||||
useEmailAuth,
|
||||
supabase,
|
||||
useAuthStore,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { GithubOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, Link } from 'react-router';
|
||||
@@ -10,34 +12,17 @@ import { Icon } from '@iconify/react';
|
||||
import { ThemeToggle } from '../../../components/theme-toggle';
|
||||
|
||||
export default function LoginPage() {
|
||||
// console.log('[LoginPage] Rendering...');
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { setSession } = useAuthStore();
|
||||
const { signInWithGitHub } = useGitHubAuth();
|
||||
const loginMutation = useLogin();
|
||||
const { signInWithEmail } = useEmailAuth();
|
||||
const [isGithubLoading, setIsGithubLoading] = useState(false);
|
||||
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// 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();
|
||||
@@ -49,12 +34,52 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await loginMutation.mutateAsync({ email, password });
|
||||
setIsEmailLoading(true);
|
||||
// console.log('[Login] Attempting email login...');
|
||||
|
||||
const result = await signInWithEmail(email, password);
|
||||
// console.log('[Login] Email login successful:', result);
|
||||
|
||||
// Get user data from database
|
||||
const { data: userData } = await supabase
|
||||
.from('users')
|
||||
.select('*')
|
||||
.eq('id', result.user.id)
|
||||
.single();
|
||||
|
||||
// Store session in Zustand
|
||||
setSession({
|
||||
token: {
|
||||
access_token: result.session.access_token,
|
||||
refresh_token: result.session.refresh_token || '',
|
||||
},
|
||||
user: {
|
||||
id: result.user.id,
|
||||
email: result.user.email || '',
|
||||
fullname:
|
||||
userData?.fullname || result.user.user_metadata?.full_name || '',
|
||||
phone_number: userData?.phone_number || '',
|
||||
avatar: userData?.avatar || '',
|
||||
birthdate: userData?.birthdate || '',
|
||||
gender: userData?.gender || '',
|
||||
is_active: userData?.is_active || true,
|
||||
location: userData?.location,
|
||||
bio: userData?.bio,
|
||||
skills: userData?.skills,
|
||||
role: {
|
||||
id: '',
|
||||
name: 'user',
|
||||
permissions: [],
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
toast.success('Login successful!');
|
||||
|
||||
// Redirect based on onboarding status
|
||||
if (result.user.location) {
|
||||
if (userData?.location) {
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
navigate('/onboarding/user');
|
||||
@@ -62,25 +87,29 @@ export default function LoginPage() {
|
||||
} catch (err) {
|
||||
console.error('[Login] Email login failed:', err);
|
||||
setError((err as Error).message || 'Login failed');
|
||||
setIsEmailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGithubLogin = async () => {
|
||||
try {
|
||||
setIsGithubLoading(true);
|
||||
// console.log('[Login] Initiating GitHub OAuth...');
|
||||
|
||||
const result = await signInWithGitHub();
|
||||
// console.log('[Login] OAuth result:', result);
|
||||
|
||||
// Check if we got a redirect URL
|
||||
if (result?.url) {
|
||||
// console.log('[Login] Redirecting to GitHub OAuth:', result.url);
|
||||
// Manually redirect immediately
|
||||
globalThis.location.href = result.url;
|
||||
} else {
|
||||
// console.error('[Login] No OAuth URL returned');
|
||||
setIsGithubLoading(false);
|
||||
setError('Failed to get GitHub OAuth URL');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Login] GitHub login failed:', err);
|
||||
setError((err as Error).message || 'GitHub login failed');
|
||||
} catch (error) {
|
||||
// console.error('[Login] GitHub login failed');
|
||||
setIsGithubLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -128,7 +157,7 @@ export default function LoginPage() {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
disabled={loginMutation.isPending}
|
||||
disabled={isEmailLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
@@ -149,33 +178,24 @@ export default function LoginPage() {
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={loginMutation.isPending}
|
||||
className="w-full px-4 py-2.5 pr-12 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<Icon icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={isEmailLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loginMutation.isPending}
|
||||
disabled={isEmailLoading}
|
||||
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 dark:focus:ring-offset-gray-900 disabled:bg-gray-400 dark:disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||
>
|
||||
{loginMutation.isPending ? 'Signing in...' : 'Sign in with Email'}
|
||||
{isEmailLoading ? 'Signing in...' : 'Sign in with Email'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -199,28 +219,15 @@ export default function LoginPage() {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<p className="mt-3 text-xs text-center text-gray-500 dark:text-gray-500 font-sans">
|
||||
Make sure your GitHub email is{' '}
|
||||
<a
|
||||
href="https://github.com/settings/emails"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 dark:text-primary-400 hover:underline"
|
||||
>
|
||||
set to public
|
||||
</a>{' '}
|
||||
for GitHub sign in to work.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Don't have an account?{' '}
|
||||
<Link
|
||||
to="/auth/signup"
|
||||
<a
|
||||
href="/auth/signup"
|
||||
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,33 +1,26 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useResetPassword, useAuthStore } from '@imphnen-frontend-service/service';
|
||||
import { supabase } 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);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isValidToken, setIsValidToken] = 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]);
|
||||
// useEffect(() => {
|
||||
// // Check if we have a valid session (from the reset link)
|
||||
// supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
// if (session) {
|
||||
// setIsValidToken(true);
|
||||
// } else {
|
||||
// toast.error('Invalid or expired reset link');
|
||||
// setTimeout(() => navigate('/auth/forgot-password'), 2000);
|
||||
// }
|
||||
// });
|
||||
// }, [navigate]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -42,39 +35,42 @@ export default function ResetPasswordPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!accessToken) {
|
||||
toast.error('Invalid reset token');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await resetPasswordMutation.mutateAsync({
|
||||
access_token: accessToken,
|
||||
new_password: password,
|
||||
setIsLoading(true);
|
||||
|
||||
const { error } = await supabase.auth.updateUser({
|
||||
password: password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
toast.success('Password updated successfully!');
|
||||
|
||||
// Clear session and redirect to login
|
||||
clearSession();
|
||||
// Sign out and redirect to login
|
||||
await supabase.auth.signOut();
|
||||
navigate('/auth/login');
|
||||
} catch (err) {
|
||||
// console.error('Failed to reset password:', err);
|
||||
toast.error((err as Error).message || 'Failed to reset password');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!accessToken) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<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 dark:text-gray-400">
|
||||
Verifying reset link...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// if (!isValidToken) {
|
||||
// return (
|
||||
// <div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
// <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 dark:text-gray-400">
|
||||
// Verifying reset link...
|
||||
// </p>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||
@@ -96,26 +92,17 @@ export default function ResetPasswordPage() {
|
||||
>
|
||||
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 dark:border-gray-600 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 dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<Icon icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={isLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 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 dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -125,34 +112,25 @@ export default function ResetPasswordPage() {
|
||||
>
|
||||
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 dark:border-gray-600 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 dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<Icon icon={showConfirmPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={isLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 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 dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={resetPasswordMutation.isPending}
|
||||
disabled={isLoading}
|
||||
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'}
|
||||
{isLoading ? 'Updating...' : 'Update Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -1,200 +1,142 @@
|
||||
import { useState } from 'react';
|
||||
import { useGitHubAuth, useSignup } from '@imphnen-frontend-service/service';
|
||||
import {
|
||||
useGitHubAuth,
|
||||
useEmailAuth,
|
||||
supabase,
|
||||
useAuthStore,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { GithubOutlined } from '@ant-design/icons';
|
||||
import { useNavigate, Link } from 'react-router';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { ThemeToggle } from '../../../components/theme-toggle';
|
||||
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>;
|
||||
|
||||
// Registration deadline: 2025-11-30 23:29:00 WIB (UTC+7)
|
||||
const REGISTRATION_DEADLINE = new Date('2025-11-30T16:29:00Z');
|
||||
|
||||
export default function SignupPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Check if registration is closed
|
||||
const isRegistrationClosed = new Date() >= REGISTRATION_DEADLINE;
|
||||
const { setSession } = useAuthStore();
|
||||
const { signInWithGitHub } = useGitHubAuth();
|
||||
const signupMutation = useSignup();
|
||||
const { signUpWithEmail } = useEmailAuth();
|
||||
const [isGithubLoading, setIsGithubLoading] = useState(false);
|
||||
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
||||
const [fullname, setFullname] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [registrationSuccess, setRegistrationSuccess] = useState(false);
|
||||
const [registeredEmail, setRegisteredEmail] = useState('');
|
||||
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) => {
|
||||
const handleEmailSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!fullname || !email || !password || !confirmPassword) {
|
||||
setError('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters long');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await signupMutation.mutateAsync({
|
||||
email: data.email,
|
||||
password: data.password,
|
||||
fullname: data.fullname,
|
||||
});
|
||||
toast.success(result.message);
|
||||
setRegisteredEmail(data.email);
|
||||
setRegistrationSuccess(true);
|
||||
setIsEmailLoading(true);
|
||||
// console.log('[Signup] Attempting email signup...');
|
||||
|
||||
const result = await signUpWithEmail(email, password, fullname);
|
||||
// console.log('[Signup] Email signup successful:', result);
|
||||
|
||||
if (!result.user) {
|
||||
throw new Error('Signup failed - no user returned');
|
||||
}
|
||||
|
||||
// Create user record in database
|
||||
const { error: upsertError } = await supabase.from('users').upsert(
|
||||
{
|
||||
id: result.user.id,
|
||||
email: result.user.email || '',
|
||||
fullname: fullname,
|
||||
is_active: true,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
onConflict: 'id',
|
||||
}
|
||||
);
|
||||
|
||||
if (upsertError) {
|
||||
console.warn('[Signup] Failed to create user record');
|
||||
}
|
||||
|
||||
// If session is available (email confirmation disabled), store it
|
||||
if (result.session) {
|
||||
setSession({
|
||||
token: {
|
||||
access_token: result.session.access_token,
|
||||
refresh_token: result.session.refresh_token || '',
|
||||
},
|
||||
user: {
|
||||
id: result.user.id,
|
||||
email: result.user.email || '',
|
||||
fullname: fullname,
|
||||
phone_number: '',
|
||||
avatar: '',
|
||||
birthdate: '',
|
||||
gender: '',
|
||||
is_active: true,
|
||||
role: {
|
||||
id: '',
|
||||
name: 'user',
|
||||
permissions: [],
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
toast.success('Account created successfully!');
|
||||
navigate('/onboarding/user');
|
||||
} else {
|
||||
// Email confirmation is enabled
|
||||
toast.success(
|
||||
'Account created! Please check your email to verify your account.'
|
||||
);
|
||||
setTimeout(() => {
|
||||
navigate('/auth/login');
|
||||
}, 2000);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Signup] Email signup failed:', err);
|
||||
// console.error('[Signup] Email signup failed:', err);
|
||||
setError((err as Error).message || 'Signup failed');
|
||||
setIsEmailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Show closed registration screen
|
||||
if (isRegistrationClosed) {
|
||||
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">
|
||||
Registration Closed
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
The registration period for this hackathon has ended.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Thank you for your interest! Registration closed on November 30, 2025 at 23:29 WIB.
|
||||
</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 cursor-pointer">
|
||||
Go to Login
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
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 Homepage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show success screen after registration
|
||||
if (registrationSuccess) {
|
||||
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-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mb-4">
|
||||
<Icon
|
||||
icon="mdi:email-check"
|
||||
className="text-3xl text-green-600 dark:text-green-400"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Check Your Email
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
We've sent an activation link to{' '}
|
||||
<strong>{registeredEmail}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Click the link in the email to activate your account. The link
|
||||
will expire in 24 hours.
|
||||
</p>
|
||||
|
||||
<div className="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">
|
||||
Don't forget to check your spam folder if you don't see the
|
||||
email.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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 cursor-pointer">
|
||||
Go to Login
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => setRegistrationSuccess(false)}
|
||||
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
Register with different email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleGithubLogin = async () => {
|
||||
try {
|
||||
setIsGithubLoading(true);
|
||||
// console.log('[Signup] Initiating GitHub OAuth...');
|
||||
|
||||
const result = await signInWithGitHub();
|
||||
// console.log('[Signup] OAuth result:', result);
|
||||
|
||||
if (result?.url) {
|
||||
// console.log('[Signup] Redirecting to GitHub OAuth:', result.url);
|
||||
globalThis.location.href = result.url;
|
||||
} else {
|
||||
// console.error('[Signup] No OAuth URL returned');
|
||||
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');
|
||||
} catch (error) {
|
||||
// console.error('[Signup] GitHub login failed:', error);
|
||||
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 dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed';
|
||||
const inputErrorClass = 'border-red-500 dark:border-red-500';
|
||||
const inputNormalClass = 'border-gray-300 dark:border-gray-600';
|
||||
|
||||
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">
|
||||
@@ -224,7 +166,7 @@ export default function SignupPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<form onSubmit={handleEmailSignup} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="fullname"
|
||||
@@ -235,18 +177,13 @@ export default function SignupPage() {
|
||||
<input
|
||||
id="fullname"
|
||||
type="text"
|
||||
{...register('fullname')}
|
||||
value={fullname}
|
||||
onChange={(e) => setFullname(e.target.value)}
|
||||
placeholder="John Doe"
|
||||
disabled={signupMutation.isPending}
|
||||
className={`${inputBaseClass} ${
|
||||
errors.fullname ? inputErrorClass : inputNormalClass
|
||||
}`}
|
||||
disabled={isEmailLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
{errors.fullname && (
|
||||
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
|
||||
{errors.fullname.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -259,18 +196,13 @@ export default function SignupPage() {
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
{...register('email')}
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your@email.com"
|
||||
disabled={signupMutation.isPending}
|
||||
className={`${inputBaseClass} ${
|
||||
errors.email ? inputErrorClass : inputNormalClass
|
||||
}`}
|
||||
disabled={isEmailLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
|
||||
{errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -280,30 +212,16 @@ export default function SignupPage() {
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
{...register('password')}
|
||||
placeholder="••••••••"
|
||||
disabled={signupMutation.isPending}
|
||||
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 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<Icon icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
|
||||
{errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={isEmailLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -313,40 +231,24 @@ export default function SignupPage() {
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
{...register('confirmPassword')}
|
||||
placeholder="••••••••"
|
||||
disabled={signupMutation.isPending}
|
||||
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 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
<Icon icon={showConfirmPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
|
||||
{errors.confirmPassword.message}
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={isEmailLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!isValid || signupMutation.isPending}
|
||||
disabled={isEmailLoading}
|
||||
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 dark:focus:ring-offset-gray-900 disabled:bg-gray-400 dark:disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||
>
|
||||
{signupMutation.isPending
|
||||
? 'Creating account...'
|
||||
: 'Create Account'}
|
||||
{isEmailLoading ? 'Creating account...' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -370,28 +272,15 @@ export default function SignupPage() {
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<p className="mt-3 text-xs text-center text-gray-500 dark:text-gray-500 font-sans">
|
||||
Make sure your GitHub email is{' '}
|
||||
<a
|
||||
href="https://github.com/settings/emails"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 dark:text-primary-400 hover:underline"
|
||||
>
|
||||
set to public
|
||||
</a>{' '}
|
||||
for GitHub sign up to work.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Already have an account?{' '}
|
||||
<Link
|
||||
to="/auth/login"
|
||||
<a
|
||||
href="/auth/login"
|
||||
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,470 +0,0 @@
|
||||
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;
|
||||
@@ -1,605 +0,0 @@
|
||||
import { FC, ReactElement, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { decodeWinnerCertificateId } from '../../../../utils/certificate';
|
||||
import {
|
||||
useAuthStore,
|
||||
useMyTeams,
|
||||
useTeamById,
|
||||
useTeamSubmission,
|
||||
useWinners,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import QRCode from 'qrcode';
|
||||
import html2canvas from 'html2canvas';
|
||||
|
||||
type WinnerEntry = {
|
||||
team_id: string;
|
||||
rank: number;
|
||||
team?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
const formatOrdinalRank = (rank: number): string => {
|
||||
const mod100 = rank % 100;
|
||||
if (mod100 >= 11 && mod100 <= 13) return `${rank}th`;
|
||||
|
||||
switch (rank % 10) {
|
||||
case 1:
|
||||
return `${rank}st`;
|
||||
case 2:
|
||||
return `${rank}nd`;
|
||||
case 3:
|
||||
return `${rank}rd`;
|
||||
default:
|
||||
return `${rank}th`;
|
||||
}
|
||||
};
|
||||
|
||||
// Keep the template size aligned with the SVG native size (842x595) using an integer multiplier.
|
||||
// This reduces sub-pixel scaling artifacts (blur) on thin lines when rasterizing with html2canvas.
|
||||
const CERT_WIDTH = 842 * 2;
|
||||
const CERT_HEIGHT = 595 * 2;
|
||||
|
||||
// Balance between output sharpness and file size.
|
||||
// Output resolution will be (CERT_WIDTH * EXPORT_SCALE) x (CERT_HEIGHT * EXPORT_SCALE).
|
||||
const EXPORT_SCALE = 2;
|
||||
|
||||
// The original layout was tuned around a ~1000px-wide template.
|
||||
// We keep the same visual proportions by scaling fixed pixel values.
|
||||
const LAYOUT_BASE_WIDTH = 1000;
|
||||
const LAYOUT_SCALE = CERT_WIDTH / LAYOUT_BASE_WIDTH;
|
||||
const s = (px: number) => Math.round(px * LAYOUT_SCALE);
|
||||
|
||||
const CertificateWinnerPage: FC = (): ReactElement => {
|
||||
const { certId } = useParams<{ certId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { session } = useAuthStore();
|
||||
const { data: myTeamsData } = useMyTeams();
|
||||
const {
|
||||
data: winnersResponse,
|
||||
isLoading: isLoadingWinners,
|
||||
isError: isWinnersError,
|
||||
} = useWinners();
|
||||
|
||||
const [decodedTeamId, setDecodedTeamId] = useState<string>('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
|
||||
const certificateRef = useRef<HTMLDivElement>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [certificateImage, setCertificateImage] = useState<string>('');
|
||||
const [showTemplate, setShowTemplate] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!certId) return;
|
||||
|
||||
decodeWinnerCertificateId(certId)
|
||||
.then((decoded) => setDecodedTeamId(decoded.teamId))
|
||||
.catch(() => setError('Invalid certificate ID'));
|
||||
}, [certId]);
|
||||
|
||||
const winners = useMemo(
|
||||
() => (winnersResponse?.data || []) as WinnerEntry[],
|
||||
[winnersResponse?.data]
|
||||
);
|
||||
const winnerEntry = useMemo(() => {
|
||||
if (!decodedTeamId) return undefined;
|
||||
return winners.find((w) => w.team_id === decodedTeamId);
|
||||
}, [decodedTeamId, winners]);
|
||||
|
||||
const rankLabel = useMemo(() => {
|
||||
if (!winnerEntry?.rank) return '';
|
||||
return formatOrdinalRank(winnerEntry.rank);
|
||||
}, [winnerEntry?.rank]);
|
||||
|
||||
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
|
||||
decodedTeamId,
|
||||
!!decodedTeamId
|
||||
);
|
||||
|
||||
const { data: submissionData, isLoading: isLoadingSubmission } =
|
||||
useTeamSubmission(decodedTeamId, !!decodedTeamId);
|
||||
|
||||
const team = teamData?.data;
|
||||
const submission = submissionData?.data;
|
||||
const submissionName = submission?.project_name || '(Submission unavailable)';
|
||||
|
||||
const memberNames = useMemo(() => {
|
||||
const members = team?.members || [];
|
||||
return members
|
||||
.map((m) => m.user?.fullname)
|
||||
.filter((name): name is string => !!name);
|
||||
}, [team?.members]);
|
||||
|
||||
const isWinnerTeam = !!winnerEntry;
|
||||
const isTeamMember =
|
||||
!!session?.user?.id &&
|
||||
!!decodedTeamId &&
|
||||
(myTeamsData?.data || []).some(
|
||||
(t) => (t as { id?: string } | null | undefined)?.id === decodedTeamId
|
||||
);
|
||||
|
||||
// Generate QR Code (public link)
|
||||
useEffect(() => {
|
||||
if (!certId) return;
|
||||
|
||||
const encodedCertId = encodeURIComponent(certId);
|
||||
const certificateUrl = `${window.location.origin}/certificate/winner/${encodedCertId}`;
|
||||
|
||||
QRCode.toDataURL(certificateUrl, {
|
||||
width: s(200),
|
||||
margin: 1,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff',
|
||||
},
|
||||
})
|
||||
.then(setQrCodeUrl)
|
||||
.catch((err) => console.error('QR Code generation failed:', err));
|
||||
}, [certId]);
|
||||
|
||||
// Generate certificate canvas screenshot
|
||||
useEffect(() => {
|
||||
const generateCertificate = async () => {
|
||||
if (!certificateRef.current) return;
|
||||
if (!team?.name) return;
|
||||
if (!qrCodeUrl) return;
|
||||
if (!winnerEntry?.rank) return;
|
||||
if (isLoadingSubmission) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
setShowTemplate(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
const canvas = await html2canvas(certificateRef.current, {
|
||||
scale: EXPORT_SCALE,
|
||||
useCORS: true,
|
||||
backgroundColor: '#ffffff',
|
||||
logging: false,
|
||||
width: CERT_WIDTH,
|
||||
height: CERT_HEIGHT,
|
||||
allowTaint: true,
|
||||
imageTimeout: 0,
|
||||
removeContainer: true,
|
||||
});
|
||||
|
||||
const imageUrl = canvas.toDataURL('image/png', 1.0);
|
||||
setCertificateImage(imageUrl);
|
||||
setShowTemplate(false);
|
||||
} catch (e) {
|
||||
console.error('Failed to generate certificate:', e);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
generateCertificate();
|
||||
}, [
|
||||
team?.name,
|
||||
memberNames,
|
||||
qrCodeUrl,
|
||||
winnerEntry?.rank,
|
||||
isLoadingSubmission,
|
||||
submissionName,
|
||||
]);
|
||||
|
||||
const handleDownloadCertificate = () => {
|
||||
if (!certificateImage) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = certificateImage;
|
||||
link.download = `winner-certificate-${team?.name || 'hackathon'}.png`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
const handlePrintCertificate = () => {
|
||||
if (!certificateImage) return;
|
||||
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (!printWindow) return;
|
||||
|
||||
printWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Certificate - ${team?.name}</title>
|
||||
<style>
|
||||
body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
@media print {
|
||||
@page { size: A4 landscape; margin: 0; }
|
||||
body { margin: 0; }
|
||||
img { width: 100%; height: auto; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="${certificateImage}" />
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
printWindow.onload = () => {
|
||||
printWindow.print();
|
||||
};
|
||||
};
|
||||
|
||||
if (error || !certId) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Invalid Certificate
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
{error || 'The certificate ID is invalid or malformed.'}
|
||||
</p>
|
||||
<Button onClick={() => navigate('/')}>Back to Home</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!decodedTeamId || isLoadingTeam) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
Loading certificate...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingWinners) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
Loading winners...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isWinnersError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Unable to Load Winners
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Please try again later.
|
||||
</p>
|
||||
<Button onClick={() => navigate('/')}>Back to Home</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isWinnerTeam) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Certificate Not Found
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
This team is not listed as a hackathon winner.
|
||||
</p>
|
||||
<Button onClick={() => navigate('/')}>Back to Home</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!team?.name) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Team Not Found
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
The team associated with this certificate could not be loaded.
|
||||
</p>
|
||||
<Button onClick={() => navigate('/')}>Back to Home</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
{/* Header */}
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 no-print">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Winner Certificate
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{team.name}
|
||||
</p>
|
||||
</div>
|
||||
{isTeamMember && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => navigate('/dashboard')}
|
||||
>
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12"
|
||||
id="certificate-wrapper"
|
||||
>
|
||||
{/* Hidden Template for Canvas Generation */}
|
||||
<div
|
||||
className={showTemplate ? 'block' : 'hidden'}
|
||||
style={{ position: 'absolute', left: '-9999px' }}
|
||||
>
|
||||
<div
|
||||
ref={certificateRef}
|
||||
id="certificate-template"
|
||||
style={{
|
||||
position: 'relative',
|
||||
backgroundImage: 'url(/images/blank_winner_cert.svg)',
|
||||
backgroundSize: '100% 100%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center',
|
||||
width: `${CERT_WIDTH}px`,
|
||||
height: `${CERT_HEIGHT}px`,
|
||||
}}
|
||||
>
|
||||
<style>{`
|
||||
#winner-members li::marker {
|
||||
color: #59bef5;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{/* Team Name */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '35%',
|
||||
left: '3.5%',
|
||||
width: '55%',
|
||||
}}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: 'Poppins, sans-serif',
|
||||
fontWeight: 'bold',
|
||||
color: '#59bef5',
|
||||
textAlign: 'left',
|
||||
fontSize: `${s(28)}px`,
|
||||
lineHeight: '1.2',
|
||||
wordBreak: 'break-word',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
{team.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Members list */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '40.5%',
|
||||
left: '3.5%',
|
||||
width: '55%',
|
||||
}}
|
||||
>
|
||||
<ul
|
||||
id="winner-members"
|
||||
style={{
|
||||
margin: 0,
|
||||
fontFamily: 'Poppins, sans-serif',
|
||||
fontSize: `${s(18)}px`,
|
||||
lineHeight: '1.35',
|
||||
color: '#59bef5',
|
||||
}}
|
||||
>
|
||||
{(memberNames.length
|
||||
? memberNames
|
||||
: ['(Members unavailable)']
|
||||
).map((name) => (
|
||||
<li key={name}>• {name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Award text */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '60%',
|
||||
left: '3.5%',
|
||||
width: '60%',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontFamily: 'Poppins, sans-serif',
|
||||
fontSize: `${s(18)}px`,
|
||||
lineHeight: '1.35',
|
||||
color: '#6B6B6B',
|
||||
}}
|
||||
>
|
||||
Diberikan sebagai penghargaan atas pencapaian meraih
|
||||
<br />
|
||||
<b>JUARA {winnerEntry.rank}</b> pada Hackathon IMPHNEN x
|
||||
Kolosal.ai
|
||||
<br />
|
||||
<span>
|
||||
dengan nama project: <b>{submissionName}</b>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Rank badge */}
|
||||
{!!rankLabel && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '8%',
|
||||
right: '8.5%',
|
||||
width: `${s(190)}px`,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: `${s(6)}px ${s(16)}px`,
|
||||
textAlign: 'center',
|
||||
fontFamily: 'Poppins, sans-serif',
|
||||
fontWeight: 700,
|
||||
color: '#78350F',
|
||||
fontSize: `${s(24)}px`,
|
||||
lineHeight: '1',
|
||||
}}
|
||||
>
|
||||
{rankLabel}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR Code (same placement as existing certificate page) */}
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '33%',
|
||||
right: '9.3%',
|
||||
width: `${s(190)}px`,
|
||||
height: `${s(190)}px`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{qrCodeUrl && (
|
||||
<img
|
||||
src={qrCodeUrl}
|
||||
alt="Certificate QR Code"
|
||||
style={{
|
||||
width: `${s(190)}px`,
|
||||
height: `${s(190)}px`,
|
||||
display: 'block',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Display Certificate Image */}
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 overflow-hidden p-2">
|
||||
{isGenerating && (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
Generating certificate...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{certificateImage && !isGenerating && (
|
||||
<img
|
||||
src={certificateImage}
|
||||
alt="Winner Certificate"
|
||||
className="w-full h-auto"
|
||||
style={{ maxWidth: '100%', height: 'auto' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{isTeamMember && (
|
||||
<div className="bg-gray-50 dark:bg-gray-900 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleDownloadCertificate}
|
||||
className="flex items-center gap-2"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Icon
|
||||
icon="svg-spinners:ring-resize"
|
||||
width="18"
|
||||
height="18"
|
||||
/>
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon
|
||||
icon="heroicons:arrow-down-tray"
|
||||
width="18"
|
||||
height="18"
|
||||
/>
|
||||
Download
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handlePrintCertificate}
|
||||
className="flex items-center gap-2"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Icon
|
||||
icon="svg-spinners:ring-resize"
|
||||
width="18"
|
||||
height="18"
|
||||
/>
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon icon="mdi:printer" width="18" height="18" />
|
||||
Print
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
variant="secondary"
|
||||
className="col-span-2 flex items-center gap-2 xl:col-span-1"
|
||||
>
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
<div className="mt-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6 no-print">
|
||||
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
|
||||
Certificate Information
|
||||
</h3>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
This certificate is a digital record of your hackathon achievement.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CertificateWinnerPage;
|
||||
@@ -1,23 +1,15 @@
|
||||
import { FC, ReactElement, useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { FC, ReactElement, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import {
|
||||
useMyTeams,
|
||||
useMyInvitations,
|
||||
useRespondToInvitation,
|
||||
useAuthStore,
|
||||
useWinners,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Icon } from '@iconify/react';
|
||||
import ProfilePage from '../profile/page';
|
||||
import { encodeWinnerCertificateId } from '../../utils/certificate';
|
||||
|
||||
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
|
||||
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 = {
|
||||
id: string;
|
||||
@@ -41,48 +33,7 @@ type Invitation = {
|
||||
|
||||
const DashboardPage: FC = (): ReactElement => {
|
||||
const { session } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
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
|
||||
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
|
||||
useEffect(() => {
|
||||
if (showProfileModal) {
|
||||
@@ -94,7 +45,6 @@ const DashboardPage: FC = (): ReactElement => {
|
||||
}
|
||||
}, [showProfileModal]);
|
||||
const { data: teamsData } = useMyTeams();
|
||||
const { data: winnersResponse } = useWinners();
|
||||
const { data: invitationsData } = useMyInvitations();
|
||||
const { mutateAsync: respondToInvitation } = useRespondToInvitation();
|
||||
|
||||
@@ -103,13 +53,6 @@ const DashboardPage: FC = (): ReactElement => {
|
||||
const invitations: Invitation[] = (invitationsData?.data ||
|
||||
[]) 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) => {
|
||||
try {
|
||||
await respondToInvitation({ invitationId, action: 'accept' });
|
||||
@@ -145,102 +88,11 @@ const DashboardPage: FC = (): ReactElement => {
|
||||
)}
|
||||
</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 && (
|
||||
<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">
|
||||
Team Invitations ({invitations.length})
|
||||
</h2>
|
||||
{isTeamFeaturesClosed && (
|
||||
<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">
|
||||
<Icon icon="mdi:clock-alert" className="text-lg shrink-0" />
|
||||
Team features are closed. You can no longer accept
|
||||
invitations.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-3 font-sans">
|
||||
{invitations.map((invitation) => (
|
||||
<div
|
||||
@@ -256,23 +108,21 @@ const DashboardPage: FC = (): ReactElement => {
|
||||
{invitation.inviter?.fullname ?? 'Unknown User'}
|
||||
</p>
|
||||
</div>
|
||||
{!isTeamFeaturesClosed && (
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleAcceptInvitation(invitation.id)}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleRejectInvitation(invitation.id)}
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleAcceptInvitation(invitation.id)}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleRejectInvitation(invitation.id)}
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -280,86 +130,57 @@ const DashboardPage: FC = (): ReactElement => {
|
||||
)}
|
||||
|
||||
{myTeams.length > 0 ? (
|
||||
(() => {
|
||||
const team = myTeams[0] as any;
|
||||
return (
|
||||
<div className="mb-6 md:mb-8">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
My Team
|
||||
</h2>
|
||||
<div className="mb-6 md:mb-8">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
My Team
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-4 md:gap-6 w-full md:max-w-lg">
|
||||
{myTeams.map((team) => (
|
||||
<Link
|
||||
key={team.id}
|
||||
to={'/teams/' + team.id}
|
||||
className="block bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow border dark:border-gray-700"
|
||||
className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<img
|
||||
src={team.banner || '/images/banner-imphnen.webp'}
|
||||
alt={team.name}
|
||||
className="w-full aspect-3/1 object-cover"
|
||||
/>
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="flex items-center space-x-4 mb-4">
|
||||
{team.logo ? (
|
||||
{team.banner && (
|
||||
<div className="w-full aspect-3/1">
|
||||
<img
|
||||
src={team.banner}
|
||||
alt={team.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col md:flex-row items-center space-y-3 space-x-3 -mt-14 md:mt-0 mb-2 md:mb-3 min-w-12 min-h-12 ">
|
||||
{team.logo && (
|
||||
<img
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
className="w-16 h-16 rounded-full object-cover shrink-0"
|
||||
className="w-20 h-20 rounded-full object-cover shrink-0 border-black dark:border-gray-700"
|
||||
/>
|
||||
) : (
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white leading-tight line-clamp-1">
|
||||
<div>
|
||||
<h3 className="text-xl md:text-lg font-bold text-gray-900 dark:text-white leading-tight">
|
||||
{team.name}
|
||||
</h3>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-3 font-sans mt-2">
|
||||
{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">
|
||||
<Icon
|
||||
icon="mdi:check-circle"
|
||||
className="text-sm"
|
||||
/>
|
||||
Submitted
|
||||
</span>
|
||||
)}
|
||||
{team.city && (
|
||||
<span className="flex items-center gap-1 truncate">
|
||||
<Icon
|
||||
icon="mdi:map-marker"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="truncate">{team.city}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
<Icon icon="mdi:account-group" />
|
||||
{team.member_count ||
|
||||
team.members?.length ||
|
||||
0}{' '}
|
||||
member
|
||||
{(team.member_count ||
|
||||
team.members?.length ||
|
||||
0) !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 flex items-center">
|
||||
<Icon
|
||||
icon="heroicons:map-pin-16-solid"
|
||||
width="16"
|
||||
height="16"
|
||||
/>
|
||||
<span className="ml-1">{team.city}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{team.description && (
|
||||
<p className="text-gray-600 dark:text-gray-400 line-clamp-3 font-sans">
|
||||
{team.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-gray-600 dark:text-gray-400 line-clamp-3 text-ellipsis font-sans">
|
||||
{team.description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-6 md:mb-8 bg-white dark:bg-gray-900 rounded-lg shadow-md p-6 md:p-8">
|
||||
<div className="text-center">
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
useNavigate,
|
||||
} from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuthStore, useUserMe } from '@imphnen-frontend-service/service';
|
||||
import { supabase } from '@imphnen-frontend-service/service';
|
||||
|
||||
// Define onboarding routes
|
||||
const ONBOARDING_ROUTES = new Set(['/onboarding/user']);
|
||||
@@ -13,8 +13,6 @@ const ONBOARDING_ROUTES = new Set(['/onboarding/user']);
|
||||
export default function RootLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { session } = useAuthStore();
|
||||
const { data: userData, isLoading: isUserLoading } = useUserMe();
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -33,15 +31,31 @@ export default function RootLayout() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Public auth pages - allow unauthenticated access
|
||||
if (pathname.startsWith('/auth')) {
|
||||
// Check Supabase session
|
||||
const {
|
||||
data: { session },
|
||||
error,
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (error) {
|
||||
console.error('[Layout] Session error:', error);
|
||||
}
|
||||
|
||||
// Public auth pages (login, signup, forgot-password, reset-password) - allow unauthenticated access
|
||||
const isPublicAuthPage = pathname === '/maintenance';
|
||||
// pathname === '/auth/login' ||
|
||||
// pathname === '/auth/signup' ||
|
||||
// pathname === '/auth/forgot-password' ||
|
||||
// pathname === '/auth/reset-password';
|
||||
|
||||
if (isPublicAuthPage) {
|
||||
// If already authenticated and not on password reset pages, redirect to dashboard
|
||||
if (session && pathname !== '/auth/reset-password') {
|
||||
navigate('/dashboard', { replace: true });
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
// Allow unauthenticated access to auth pages
|
||||
// Allow unauthenticated access
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
@@ -52,32 +66,38 @@ export default function RootLayout() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Certificate page - allow public access
|
||||
if (pathname.startsWith('/certificate/')) {
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Require authentication for all other routes
|
||||
if (!session) {
|
||||
navigate('/auth/login', { replace: true });
|
||||
navigate('/maintenance', { replace: true });
|
||||
// navigate('/auth/login', { replace: true });
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait for user data to load before checking onboarding
|
||||
if (isUserLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has completed onboarding (skip for onboarding routes)
|
||||
if (!ONBOARDING_ROUTES.has(pathname)) {
|
||||
const hasLocation = !!userData?.data?.location || !!session?.user?.location;
|
||||
try {
|
||||
const { data: userData, error: userError } = await supabase
|
||||
.from('users')
|
||||
.select('location')
|
||||
.eq('id', session.user.id)
|
||||
.single();
|
||||
|
||||
if (!hasLocation) {
|
||||
navigate('/onboarding/user', { replace: true });
|
||||
setIsChecking(false);
|
||||
return;
|
||||
if (userError) {
|
||||
console.error('[Layout] Failed to fetch user data:', userError);
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasLocation = !!userData?.location;
|
||||
|
||||
if (!hasLocation) {
|
||||
navigate('/onboarding/user', { replace: true });
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently handle error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +105,7 @@ export default function RootLayout() {
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [location.pathname, navigate, session, userData, isUserLoading]);
|
||||
}, [location.pathname, navigate]);
|
||||
|
||||
// Show loading state while checking auth
|
||||
if (isChecking) {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Link } from 'react-router';
|
||||
|
||||
export default function MaintenancePage() {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 px-4">
|
||||
@@ -11,12 +9,12 @@ export default function MaintenancePage() {
|
||||
<br />
|
||||
Thank you for your patience.
|
||||
</p>
|
||||
<Link
|
||||
to="/"
|
||||
<a
|
||||
href="/"
|
||||
className="mt-4 inline-block text-primary-600 hover:underline"
|
||||
>
|
||||
Back to Homepage
|
||||
</Link>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -60,20 +60,6 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate file size (max 2MB)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error('The file is too large. Maximum size is 2MB.');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('The file must be an image');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
setAvatarFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
@@ -164,7 +150,7 @@ const UserOnboardingPage: FC = (): ReactElement => {
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2 text-center">
|
||||
Optional, but highly recommended. Max 2MB
|
||||
Optional, but highly recommended
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useNavigate, Link } from 'react-router';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Icon } from '@iconify/react';
|
||||
@@ -554,7 +554,7 @@ export default function HomePage() {
|
||||
{/* 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">
|
||||
<h3 className="text-p3 font-bold mb-1 dark:text-white">
|
||||
Muhammad Alif Ramadhan
|
||||
Anka Tama
|
||||
</h3>
|
||||
<div>
|
||||
<p className="text-primary-500 font-semibold mb-1">Admin</p>
|
||||
@@ -796,12 +796,12 @@ export default function HomePage() {
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<Link
|
||||
to="/auth/signup"
|
||||
<a
|
||||
href="/auth/signup"
|
||||
className="hover:text-white transition-colors"
|
||||
>
|
||||
Daftar
|
||||
</Link>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -84,17 +84,15 @@ const ProfilePage: FC<ProfileModalProps> = ({
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// Validate file size (max 2MB)
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast.error('The file is too large. Maximum size is 2MB.');
|
||||
e.target.value = '';
|
||||
// Validate file size (max 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('The file is too large. Maximum 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('The file must be an image');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -237,7 +235,7 @@ const ProfilePage: FC<ProfileModalProps> = ({
|
||||
<p className="text-sm text-gray-500 dark:text-neutral-400 text-center font-sans">
|
||||
Click the camera icon to change your photo
|
||||
<br />
|
||||
Format: JPG, PNG. Max 2MB
|
||||
Format: JPG, PNG. Max 5MB
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,9 +16,6 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
|
||||
import { CitySelect } from '../../../../components/city-select';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
|
||||
const EditTeamPage: FC = (): ReactElement => {
|
||||
const { teamId } = useParams<{ teamId: string }>();
|
||||
@@ -88,11 +85,6 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error('Logo image is too large. Maximum size is 2MB.');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
setLogoFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
@@ -105,11 +97,6 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error('Banner image is too large. Maximum size is 2MB.');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
setBannerFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
@@ -196,7 +183,7 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
Click to upload banner
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
1200x400 recommended. Max 2MB
|
||||
1200x400 recommended
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
@@ -228,35 +215,32 @@ const EditTeamPage: FC = (): ReactElement => {
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="flex flex-col md:flex-row items-start justify-start gap-1">
|
||||
<label htmlFor="logo" className="cursor-pointer">
|
||||
<span className="px-4 py-2 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700 inline-block">
|
||||
{logoPreview ? 'Change Logo' : 'Upload Logo'}
|
||||
</span>
|
||||
<input
|
||||
id="logo"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleLogoChange}
|
||||
/>
|
||||
</label>
|
||||
{logoPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLogoFile(null);
|
||||
setLogoPreview('');
|
||||
form.setValue('logo', null);
|
||||
}}
|
||||
className="px-4 py-2 text-sm bg-danger-600 text-white rounded-lg hover:bg-danger-700 cursor-pointer"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2">Max 2MB</p>
|
||||
<div className="flex flex-col md:flex-row items-start justify-start gap-1">
|
||||
<label htmlFor="logo" className="cursor-pointer">
|
||||
<span className="px-4 py-2 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700 inline-block">
|
||||
{logoPreview ? 'Change Logo' : 'Upload Logo'}
|
||||
</span>
|
||||
<input
|
||||
id="logo"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleLogoChange}
|
||||
/>
|
||||
</label>
|
||||
{logoPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLogoFile(null);
|
||||
setLogoPreview('');
|
||||
form.setValue('logo', null);
|
||||
}}
|
||||
className="px-4 py-2 text-sm bg-danger-600 text-white rounded-lg hover:bg-danger-700 cursor-pointer"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,7 +47,6 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
: [];
|
||||
const currentUserId = session?.user?.id;
|
||||
const isLeader = currentUserId === team?.leader_id;
|
||||
const hasSubmission = team?.has_submission;
|
||||
|
||||
const form = useForm<TInviteMemberForm>({
|
||||
resolver: zodResolver(inviteMemberSchema),
|
||||
@@ -134,11 +133,7 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
disabled={hasSubmission}
|
||||
title={hasSubmission ? 'Cannot invite members after project submission' : undefined}
|
||||
>
|
||||
<Button onClick={() => setShowInviteModal(true)}>
|
||||
Invite Member
|
||||
</Button>
|
||||
<Button
|
||||
@@ -153,23 +148,6 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
{/* Submission Lock Warning */}
|
||||
{hasSubmission && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-500 rounded-lg p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<span className="text-2xl">🔒</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-amber-900 dark:text-amber-100">
|
||||
Team Locked
|
||||
</h3>
|
||||
<p className="text-amber-800 dark:text-amber-200 text-sm font-sans mt-1">
|
||||
Your team has submitted a project. You cannot add or remove members after submission to maintain competition integrity.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Join Requests */}
|
||||
{joinRequests.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-6">
|
||||
@@ -218,8 +196,7 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleApproveRequest(request.id)}
|
||||
disabled={isResponding || hasSubmission}
|
||||
title={hasSubmission ? 'Cannot accept members after project submission' : undefined}
|
||||
disabled={isResponding}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
@@ -295,7 +272,7 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{member.role !== 'leader' && !hasSubmission && (
|
||||
{member.role !== 'leader' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
@@ -312,14 +289,12 @@ const ManageMembersPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
{!hasSubmission && (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<strong>Note:</strong> Members cannot leave the team without your
|
||||
approval. Only you can remove members from the team.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<strong>Note:</strong> Members cannot leave the team without your
|
||||
approval. Only you can remove members from the team.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Invite Member Modal */}
|
||||
|
||||
@@ -7,8 +7,6 @@ import {
|
||||
useInviteMember,
|
||||
useTeamJoinRequests,
|
||||
useRespondToJoinRequest,
|
||||
useLeaveTeam,
|
||||
useDeleteTeam,
|
||||
ETeamMemberRole,
|
||||
useAuthStore,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
@@ -17,9 +15,6 @@ import { Icon } from '@iconify/react';
|
||||
|
||||
const MAX_TEAM_MEMBERS = 5;
|
||||
|
||||
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
|
||||
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
||||
|
||||
// Image component with loading state
|
||||
const ImageWithLoader: FC<{
|
||||
src: string;
|
||||
@@ -55,14 +50,8 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
const { teamId } = useParams<{ teamId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { session } = useAuthStore();
|
||||
|
||||
// Check if team features are closed
|
||||
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
||||
const [showInviteModal, setShowInviteModal] = useState(false);
|
||||
const [showJoinRequestsModal, setShowJoinRequestsModal] = useState(false);
|
||||
const [showLeaveModal, setShowLeaveModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState('');
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [imagesLoaded, setImagesLoaded] = useState(false);
|
||||
const [imageLoadCount, setImageLoadCount] = useState(0);
|
||||
@@ -96,29 +85,25 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(
|
||||
teamId || ''
|
||||
);
|
||||
|
||||
const team = teamData?.data;
|
||||
const members = membersData?.data || [];
|
||||
const currentUserId = session?.user?.id;
|
||||
const isLeader = currentUserId === team?.leader_id;
|
||||
|
||||
// Only fetch join requests if user is the team leader
|
||||
const { data: joinRequestsData } = useTeamJoinRequests(
|
||||
teamId || '',
|
||||
!!teamId && isLeader
|
||||
!!teamId
|
||||
);
|
||||
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(
|
||||
teamId || ''
|
||||
);
|
||||
const { mutateAsync: respondToJoinRequest, isPending: isResponding } =
|
||||
useRespondToJoinRequest(teamId || '');
|
||||
const { mutateAsync: leaveTeam, isPending: isLeaving } = useLeaveTeam();
|
||||
const { mutateAsync: deleteTeam, isPending: isDeleting } = useDeleteTeam();
|
||||
|
||||
const team = teamData?.data;
|
||||
const members = membersData?.data || [];
|
||||
const joinRequests = joinRequestsData?.data || [];
|
||||
const pendingJoinRequests = joinRequests.filter(
|
||||
(req: any) => req.status === 'pending'
|
||||
);
|
||||
const currentUserId = session?.user?.id;
|
||||
|
||||
const isLeader = currentUserId === team?.leader_id;
|
||||
const isMember = members.some(
|
||||
(member: any) => member.user_id === currentUserId
|
||||
);
|
||||
@@ -154,32 +139,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleLeaveTeam = async () => {
|
||||
if (!teamId) return;
|
||||
|
||||
try {
|
||||
await leaveTeam(teamId);
|
||||
toast.success('You have left the team');
|
||||
navigate('/dashboard');
|
||||
} catch (error) {
|
||||
console.error('Failed to leave team:', error);
|
||||
toast.error('Failed to leave team');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTeam = async () => {
|
||||
if (!teamId) return;
|
||||
|
||||
try {
|
||||
await deleteTeam(teamId);
|
||||
toast.success('Team deleted successfully');
|
||||
navigate('/dashboard');
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete team:', error);
|
||||
toast.error(error?.message || 'Failed to delete team');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingTeam || isLoadingMembers) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
@@ -375,49 +334,42 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
Team Management
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{/* Hide invite/join management after submission or deadline */}
|
||||
{!team.has_submission && !isTeamFeaturesClosed && (
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
disabled={!canInvite}
|
||||
>
|
||||
<Icon icon="mdi:plus" className="inline-block mr-2" />
|
||||
Invite Member{' '}
|
||||
{!canInvite && `(${members.length}/${MAX_TEAM_MEMBERS})`}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full relative"
|
||||
variant="secondary"
|
||||
onClick={() => setShowJoinRequestsModal(true)}
|
||||
>
|
||||
<Icon icon="mdi:email" className="inline-block mr-2" /> Join
|
||||
Requests
|
||||
{pendingJoinRequests.length > 0 && (
|
||||
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs font-bold rounded-full w-6 h-6 flex items-center justify-center">
|
||||
{pendingJoinRequests.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!team.has_submission && !isTeamFeaturesClosed && (
|
||||
<Link to={`/teams/${teamId}/edit`}>
|
||||
<Button className="w-full" variant="secondary">
|
||||
<Icon icon="mdi:pencil" className="inline-block mr-2" />
|
||||
Edit Team Info
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
disabled={!canInvite}
|
||||
>
|
||||
<Icon icon="mdi:plus" className="inline-block mr-2" />
|
||||
Invite Member{' '}
|
||||
{!canInvite && `(${members.length}/${MAX_TEAM_MEMBERS})`}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full relative"
|
||||
variant="secondary"
|
||||
onClick={() => setShowJoinRequestsModal(true)}
|
||||
>
|
||||
<Icon icon="mdi:email" className="inline-block mr-2" /> Join
|
||||
Requests
|
||||
{pendingJoinRequests.length > 0 && (
|
||||
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs font-bold rounded-full w-6 h-6 flex items-center justify-center">
|
||||
{pendingJoinRequests.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Link to={`/teams/${teamId}/edit`}>
|
||||
<Button className="w-full" variant="secondary">
|
||||
<Icon icon="mdi:pencil" className="inline-block mr-2" />
|
||||
Edit Team Info
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to={`/teams/${teamId}/members`}>
|
||||
<Button className="w-full" variant="secondary">
|
||||
<Icon
|
||||
icon="mdi:account-group"
|
||||
className="inline-block mr-2"
|
||||
/>
|
||||
View Members
|
||||
Manage Members
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to={`/teams/${teamId}/chat`}>
|
||||
@@ -426,92 +378,40 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
Team Chat
|
||||
</Button>
|
||||
</Link>
|
||||
{team.has_submission ? (
|
||||
<Link to={`/teams/${teamId}/submission`}>
|
||||
<Button className="w-full" variant="secondary">
|
||||
<Icon icon="mdi:file-document" className="inline-block mr-2" />
|
||||
View Submission
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Link to={`/teams/${teamId}/submit`}>
|
||||
<Button className="w-full" disabled={members.length < 2}>
|
||||
<Icon icon="mdi:rocket" className="inline-block mr-2" />
|
||||
Submit Project
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Link to={`/teams/${teamId}/submit`}>
|
||||
<Button className="w-full">
|
||||
<Icon icon="mdi:rocket" className="inline-block mr-2" />
|
||||
Submit Project
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
{!team.has_submission && !canInvite && members.length >= MAX_TEAM_MEMBERS && (
|
||||
{!canInvite && members.length >= MAX_TEAM_MEMBERS && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-3 text-center">
|
||||
Maximum team size reached ({MAX_TEAM_MEMBERS} members)
|
||||
</p>
|
||||
)}
|
||||
{/* Danger Zone - Only show when leader is alone, no submission, and features not closed */}
|
||||
{members.length === 1 && !team.has_submission && !isTeamFeaturesClosed && (
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-red-600 dark:text-red-400 mb-3">
|
||||
Danger Zone
|
||||
</h3>
|
||||
<Button
|
||||
className="w-full bg-red-600 hover:bg-red-700 text-white"
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
>
|
||||
<Icon icon="mdi:delete" className="inline-block mr-2" />
|
||||
Delete Team
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Actions for Members (non-leaders) */}
|
||||
{!isLeader && isMember && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 md:p-6">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Quick Actions
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Link to={`/teams/${teamId}/chat`}>
|
||||
<Button className="w-full" variant="secondary">
|
||||
<Icon icon="mdi:chat" className="inline-block mr-2" />
|
||||
Team Chat
|
||||
💬 Team Chat
|
||||
</Button>
|
||||
</Link>
|
||||
{team.has_submission && (
|
||||
<Link to={`/teams/${teamId}/submission`}>
|
||||
<Button className="w-full" variant="secondary">
|
||||
<Icon icon="mdi:file-document" className="inline-block mr-2" />
|
||||
View Submission
|
||||
📄 View Submission
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{!team.has_submission && !isTeamFeaturesClosed && (
|
||||
<Button
|
||||
className="w-full bg-red-600 hover:bg-red-700 text-white"
|
||||
onClick={() => setShowLeaveModal(true)}
|
||||
>
|
||||
<Icon icon="mdi:exit-run" className="inline-block mr-2" />
|
||||
Leave Team
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning for single member teams */}
|
||||
{members.length === 1 && !team.has_submission && isMember && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 md:p-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-3xl">⚠️</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-amber-900 dark:text-amber-400">
|
||||
Team Needs More Members
|
||||
</h3>
|
||||
<p className="text-amber-700 dark:text-amber-300 text-sm">
|
||||
Your team needs at least 2 members to submit a project. Invite someone or wait for join requests!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -796,110 +696,6 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Leave Team Confirmation Modal */}
|
||||
{showLeaveModal && (
|
||||
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<div className="text-center 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:exit-run"
|
||||
className="text-3xl text-red-600 dark:text-red-400"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Leave Team?
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Are you sure you want to leave <strong>{team?.name}</strong>?
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-300 font-sans">
|
||||
<strong>Warning:</strong> If you leave, you will need to request to join again or be re-invited by the team leader.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => setShowLeaveModal(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleLeaveTeam}
|
||||
className="flex-1 bg-red-600 hover:bg-red-700"
|
||||
disabled={isLeaving}
|
||||
>
|
||||
{isLeaving ? 'Leaving...' : 'Leave Team'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Delete Team Confirmation Modal */}
|
||||
{showDeleteModal && (
|
||||
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<div className="text-center 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:delete-alert"
|
||||
className="text-3xl text-red-600 dark:text-red-400"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Delete Team?
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
This action is <strong className="text-red-600 dark:text-red-400">permanent</strong> and cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm text-red-800 dark:text-red-300 font-sans">
|
||||
<strong>Warning:</strong> All team data, chat messages, and submissions will be permanently deleted.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Type <span className="font-bold text-red-600 dark:text-red-400">{team?.name}</span> to confirm:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={deleteConfirmText}
|
||||
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
||||
placeholder="Type team name here"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setShowDeleteModal(false);
|
||||
setDeleteConfirmText('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDeleteTeam}
|
||||
className="flex-1 bg-red-600 hover:bg-red-700"
|
||||
disabled={isDeleting || deleteConfirmText !== team?.name}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete Team'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { useTeamById, useTeamSubmission, useAuthStore } from '@imphnen-frontend-service/service';
|
||||
import { encodeCertificateId } from '../../../../utils/certificate';
|
||||
import { useTeamById, useTeamSubmission } from '@imphnen-frontend-service/service';
|
||||
|
||||
const SubmissionViewPage: FC = (): ReactElement => {
|
||||
const { teamId } = useParams<{ teamId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { session } = useAuthStore();
|
||||
|
||||
const { data: teamData } = useTeamById(teamId || '');
|
||||
const { data: submissionData, isLoading } = useTeamSubmission(teamId || '', !!teamId);
|
||||
@@ -17,18 +15,18 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-gray-600 dark:text-gray-400">Loading submission...</div>
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950">
|
||||
<div className="text-gray-600 dark:text-neutral-400">Loading submission...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!submission) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950">
|
||||
<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>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">Your team hasn't submitted a project</p>
|
||||
<p className="text-gray-600 dark:text-neutral-400 mb-4">Your team hasn't submitted a project</p>
|
||||
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -45,13 +43,13 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
: 'Not submitted';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950">
|
||||
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700">
|
||||
<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">Project Submission</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">{team?.name}</p>
|
||||
<p className="text-gray-600 dark:text-neutral-400 mt-1">{team?.name}</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
|
||||
Back to Team
|
||||
@@ -62,82 +60,24 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{/* Status Banner */}
|
||||
{submission.status === 'submitted' ? (
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-4xl">✅</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-green-900 dark:text-green-100 text-lg">Project Submitted Successfully</h3>
|
||||
<p className="text-green-700 dark:text-green-300 text-sm">
|
||||
Submitted on {submittedDate}
|
||||
</p>
|
||||
<p className="text-green-600 dark:text-green-400 text-xs mt-1">
|
||||
This submission is now read-only and cannot be edited
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-4xl">✅</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-green-900 dark:text-green-100 text-lg">Project Submitted Successfully</h3>
|
||||
<p className="text-green-700 dark:text-green-300 text-sm">
|
||||
Submitted on {submittedDate}
|
||||
</p>
|
||||
<p className="text-green-600 dark:text-green-400 text-xs mt-1">
|
||||
This submission is now read-only and cannot be edited
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : submission.status === 'pending_verification' ? (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-4xl">⏳</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-yellow-900 dark:text-yellow-100 text-lg">Submission Pending Verification</h3>
|
||||
<p className="text-yellow-700 dark:text-yellow-300 text-sm">
|
||||
Your submission is being processed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-4xl">📝</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-orange-900 dark:text-orange-100 text-lg">Draft Submission</h3>
|
||||
<p className="text-orange-700 dark:text-orange-300 text-sm">
|
||||
This submission is still in draft and has not been finalized
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-950/50 overflow-hidden">
|
||||
{/* Project Header */}
|
||||
<div className="bg-linear-to-r from-blue-600 to-blue-800 text-white p-8">
|
||||
<div className="bg-gradient-to-r from-blue-600 to-blue-800 text-white p-8">
|
||||
<h2 className="text-3xl font-bold mb-2">{submission.project_name}</h2>
|
||||
<p className="text-blue-100">Team: {team?.name}</p>
|
||||
</div>
|
||||
@@ -147,8 +87,8 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
{/* Description */}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Project Description</h3>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{submission.description}</p>
|
||||
<div className="bg-gray-50 dark:bg-neutral-800 rounded-lg p-4">
|
||||
<p className="text-gray-700 dark:text-neutral-300 whitespace-pre-wrap">{submission.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -184,6 +124,21 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Presentation URL */}
|
||||
{submission.presentation_url && (
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Presentation</h3>
|
||||
<a
|
||||
href={submission.presentation_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center space-x-2 text-blue-600 dark:text-primary-400 hover:text-blue-800 dark:hover:text-primary-300"
|
||||
>
|
||||
<span>📊</span>
|
||||
<span className="break-all">{submission.presentation_url}</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Screenshots */}
|
||||
@@ -204,7 +159,7 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
<img
|
||||
src={url}
|
||||
alt={`Screenshot ${index + 1}`}
|
||||
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"
|
||||
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"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
@@ -213,31 +168,21 @@ const SubmissionViewPage: FC = (): ReactElement => {
|
||||
)}
|
||||
|
||||
{/* Submission Info */}
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border-t-4 border-blue-600 dark:border-primary-500">
|
||||
<div className="bg-gray-50 dark:bg-neutral-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>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Status:</span>
|
||||
<span className={`font-medium ${
|
||||
submission.status === 'submitted'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: submission.status === 'pending_verification'
|
||||
? 'text-yellow-600 dark:text-yellow-400'
|
||||
: 'text-orange-600 dark:text-orange-400'
|
||||
}`}>
|
||||
{submission.status === 'submitted'
|
||||
? '✓ Submitted'
|
||||
: submission.status === 'pending_verification'
|
||||
? '⏳ Pending Verification'
|
||||
: '📝 Draft'}
|
||||
<span className="text-gray-600 dark:text-neutral-400">Status:</span>
|
||||
<span className="font-medium text-green-600 dark:text-green-400">
|
||||
{submission.status === 'submitted' ? '✓ Submitted' : 'Draft'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Submitted:</span>
|
||||
<span className="text-gray-600 dark:text-neutral-400">Submitted:</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{submittedDate}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Submission ID:</span>
|
||||
<span className="text-gray-600 dark:text-neutral-400">Submission ID:</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white font-mono text-xs">
|
||||
{submission.id}
|
||||
</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FC, ReactElement, useState, useEffect } from 'react';
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
@@ -13,58 +13,13 @@ import {
|
||||
useAuthStore,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from 'sonner';
|
||||
import { Icon } from '@iconify/react';
|
||||
|
||||
const MIN_TEAM_MEMBERS = 2; // Minimum members required to submit (including leader)
|
||||
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 { teamId } = useParams<{ teamId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { session } = useAuthStore();
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
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: submissionData } = useTeamSubmission(teamId || '', !!teamId);
|
||||
@@ -76,8 +31,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
const currentUserId = session?.user?.id;
|
||||
const isLeader = currentUserId === team?.leader_id;
|
||||
const hasSubmission = !!submissionData?.data;
|
||||
const memberCount = team?.members?.length || 0;
|
||||
const hasEnoughMembers = memberCount >= MIN_TEAM_MEMBERS;
|
||||
|
||||
const form = useForm<TProjectSubmissionForm>({
|
||||
resolver: zodResolver(projectSubmissionSchema),
|
||||
@@ -125,64 +78,12 @@ 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 (
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
|
||||
// Validate file sizes
|
||||
const oversizedFiles = Array.from(files).filter(file => file.size > MAX_FILE_SIZE);
|
||||
if (oversizedFiles.length > 0) {
|
||||
toast.error(`${oversizedFiles.length} file(s) are too large. Maximum size is 2MB per file.`);
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const uploadPromises = Array.from(files).map((file) => uploadFile(file));
|
||||
const results = await Promise.all(uploadPromises);
|
||||
@@ -190,7 +91,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
setScreenshots([...screenshots, ...urls]);
|
||||
} catch (error) {
|
||||
console.error('Failed to upload screenshots:', error);
|
||||
toast.error('Failed to upload screenshots. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -222,76 +122,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
|
||||
<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 */}
|
||||
{!hasEnoughMembers && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<span className="text-3xl">👥</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-amber-900 dark:text-amber-100 text-lg">
|
||||
Team Members Required
|
||||
</h3>
|
||||
<p className="text-amber-800 dark:text-amber-200 mt-2 text-sm font-sans">
|
||||
Your team needs at least <strong>{MIN_TEAM_MEMBERS} members</strong> to submit a project.
|
||||
Currently you have <strong>{memberCount} member{memberCount !== 1 ? 's' : ''}</strong>.
|
||||
Please invite more members to your team before submitting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning Banner */}
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border-2 border-red-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
@@ -335,9 +165,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
<label className="block text-[15px] font-medium text-gray-700 dark:text-gray-300">
|
||||
Project Description <span className="text-red-500">*</span>
|
||||
</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
|
||||
control={form.control}
|
||||
name="description"
|
||||
@@ -345,7 +172,7 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
<div>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="Describe your project, its features, and what problem it solves... You can paste your demo video link (YouTube, Loom, etc.) here as well."
|
||||
placeholder="Describe your project, its features, and what problem it solves..."
|
||||
rows={6}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
@@ -381,6 +208,16 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{/* Presentation URL */}
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Presentation URL (Optional)"
|
||||
placeholder="https://slides.com/your-presentation or Google Drive link"
|
||||
name="presentation_url"
|
||||
type="url"
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{/* Screenshots */}
|
||||
<div>
|
||||
<label className="block text-label1 font-medium text-neutral-800 dark:text-gray-300">
|
||||
@@ -410,7 +247,7 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
Click to upload screenshots
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1 font-sans">
|
||||
PNG, JPG. Max 2MB each
|
||||
PNG, JPG up to 5MB each
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
@@ -437,16 +274,11 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={!form.formState.isValid || isUploading || !hasEnoughMembers}
|
||||
disabled={!form.formState.isValid || isUploading}
|
||||
>
|
||||
Review & Submit
|
||||
</Button>
|
||||
</div>
|
||||
{!hasEnoughMembers && (
|
||||
<p className="text-center text-sm text-amber-600 dark:text-amber-400 mt-2">
|
||||
You need at least {MIN_TEAM_MEMBERS} team members to submit
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -466,7 +298,7 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
||||
By submitting, you confirm that:
|
||||
By clicking "Submit Project", you confirm that:
|
||||
</p>
|
||||
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-2">
|
||||
<li>✓ All information is correct and complete</li>
|
||||
@@ -475,35 +307,19 @@ const SubmitProjectPage: FC = (): ReactElement => {
|
||||
<li>✓ Your team agrees with this submission</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Type <span className="font-bold text-red-600 dark:text-red-400">SUBMIT</span> to confirm:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder="Type SUBMIT here"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setShowConfirmModal(false);
|
||||
setConfirmText('');
|
||||
}}
|
||||
onClick={() => setShowConfirmModal(false)}
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
className="flex-1 bg-red-600 hover:bg-red-700"
|
||||
disabled={isSubmitting || confirmText !== 'SUBMIT'}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit Project'}
|
||||
</Button>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { FC, ReactElement, useState, useEffect, useCallback } from 'react';
|
||||
import { FC, ReactElement, useState, useEffect } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import {
|
||||
useTeams,
|
||||
useJoinTeam,
|
||||
@@ -14,193 +14,26 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { CitySelect } from '../../../components/city-select';
|
||||
import { Icon } from '@iconify/react';
|
||||
|
||||
const DEFAULT_PER_PAGE = 12;
|
||||
const PER_PAGE_OPTIONS = [6, 12, 24, 48];
|
||||
|
||||
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
|
||||
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
||||
|
||||
// Member filter options
|
||||
const MEMBER_FILTER_OPTIONS = [
|
||||
{ label: 'All Teams', value: '', minMembers: undefined, maxMembers: undefined },
|
||||
{ label: 'Looking for Members (1-4)', value: 'looking', minMembers: 1, maxMembers: 4 },
|
||||
{ label: '1 Member', value: '1', minMembers: 1, maxMembers: 1 },
|
||||
{ label: '2 Members', value: '2', minMembers: 2, maxMembers: 2 },
|
||||
{ label: '3 Members', value: '3', minMembers: 3, maxMembers: 3 },
|
||||
{ label: '4 Members', value: '4', minMembers: 4, maxMembers: 4 },
|
||||
{ label: '5 Members (Full)', value: '5', minMembers: 5, maxMembers: 5 },
|
||||
];
|
||||
|
||||
// Submission status filter options
|
||||
const SUBMISSION_FILTER_OPTIONS = [
|
||||
{ label: 'All Teams', value: '' },
|
||||
{ label: 'Submitted', value: 'true' },
|
||||
{ label: 'Not Submitted', value: 'false' },
|
||||
];
|
||||
|
||||
// Skeleton card component for loading state
|
||||
const TeamCardSkeleton: FC = () => (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden flex flex-col border dark:border-gray-800 animate-pulse">
|
||||
<div className="w-full aspect-3/1 bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="p-6 flex flex-col flex-1">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="flex-1">
|
||||
<div className="h-5 w-32 bg-gray-200 dark:bg-gray-700 rounded mb-2" />
|
||||
<div className="h-4 w-48 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 mb-4 flex-1">
|
||||
<div className="h-4 w-full bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-4 w-5/6 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-4 w-4/6 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
<div className="space-y-3 mt-auto">
|
||||
<div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Check if team features are closed
|
||||
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
||||
|
||||
// Initialize state from URL params
|
||||
const initialPage = parseInt(searchParams.get('page') || '1', 10);
|
||||
const initialPerPage = parseInt(
|
||||
searchParams.get('per_page') || String(DEFAULT_PER_PAGE),
|
||||
10
|
||||
);
|
||||
const initialSearch = searchParams.get('search') || '';
|
||||
const initialCity = searchParams.get('city') || '';
|
||||
const initialMembers = searchParams.get('members') || '';
|
||||
const initialSubmission = searchParams.get('submission') || '';
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(initialSearch);
|
||||
const [debouncedSearch, setDebouncedSearch] = useState(initialSearch);
|
||||
const [selectedCity, setSelectedCity] = useState(initialCity);
|
||||
const [selectedMembers, setSelectedMembers] = useState(initialMembers);
|
||||
const [selectedSubmission, setSelectedSubmission] = useState(initialSubmission);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [selectedCity, setSelectedCity] = useState('');
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||
const [showJoinModal, setShowJoinModal] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(
|
||||
PER_PAGE_OPTIONS.includes(initialPerPage) ? initialPage : 1
|
||||
);
|
||||
const [perPage, setPerPage] = useState(
|
||||
PER_PAGE_OPTIONS.includes(initialPerPage) ? initialPerPage : DEFAULT_PER_PAGE
|
||||
);
|
||||
|
||||
// Update URL when pagination state changes
|
||||
const updateUrlParams = useCallback(
|
||||
(params: {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
search?: string;
|
||||
city?: string;
|
||||
members?: string;
|
||||
submission?: string;
|
||||
}) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
|
||||
if (params.page !== undefined) {
|
||||
if (params.page === 1) {
|
||||
newParams.delete('page');
|
||||
} else {
|
||||
newParams.set('page', String(params.page));
|
||||
}
|
||||
}
|
||||
|
||||
if (params.per_page !== undefined) {
|
||||
if (params.per_page === DEFAULT_PER_PAGE) {
|
||||
newParams.delete('per_page');
|
||||
} else {
|
||||
newParams.set('per_page', String(params.per_page));
|
||||
}
|
||||
}
|
||||
|
||||
if (params.search !== undefined) {
|
||||
if (params.search === '') {
|
||||
newParams.delete('search');
|
||||
} else {
|
||||
newParams.set('search', params.search);
|
||||
}
|
||||
}
|
||||
|
||||
if (params.city !== undefined) {
|
||||
if (params.city === '') {
|
||||
newParams.delete('city');
|
||||
} else {
|
||||
newParams.set('city', params.city);
|
||||
}
|
||||
}
|
||||
|
||||
if (params.members !== undefined) {
|
||||
if (params.members === '') {
|
||||
newParams.delete('members');
|
||||
} else {
|
||||
newParams.set('members', params.members);
|
||||
}
|
||||
}
|
||||
|
||||
if (params.submission !== undefined) {
|
||||
if (params.submission === '') {
|
||||
newParams.delete('submission');
|
||||
} else {
|
||||
newParams.set('submission', params.submission);
|
||||
}
|
||||
}
|
||||
|
||||
setSearchParams(newParams, { replace: true });
|
||||
},
|
||||
[searchParams, setSearchParams]
|
||||
);
|
||||
|
||||
// Debounce search term and reset page
|
||||
// Debounce search term
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearch(searchTerm);
|
||||
if (searchTerm !== debouncedSearch) {
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ search: searchTerm, page: 1 });
|
||||
}
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm]);
|
||||
|
||||
// Update URL when city filter changes
|
||||
useEffect(() => {
|
||||
if (selectedCity !== initialCity) {
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ city: selectedCity, page: 1 });
|
||||
}
|
||||
}, [selectedCity]);
|
||||
|
||||
// Get member filter values
|
||||
const memberFilter = MEMBER_FILTER_OPTIONS.find(
|
||||
(opt) => opt.value === selectedMembers
|
||||
);
|
||||
|
||||
// Convert submission filter value to boolean
|
||||
const hasSubmissionFilter = selectedSubmission === 'true' ? true : selectedSubmission === 'false' ? false : undefined;
|
||||
|
||||
const {
|
||||
data: teamsData,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useTeams({
|
||||
page: currentPage,
|
||||
limit: perPage,
|
||||
const { data: teamsData, isLoading } = useTeams({
|
||||
search: debouncedSearch,
|
||||
city: selectedCity || undefined,
|
||||
visibility: ETeamVisibility.PUBLIC,
|
||||
minMembers: memberFilter?.minMembers,
|
||||
maxMembers: memberFilter?.maxMembers,
|
||||
hasSubmission: hasSubmissionFilter,
|
||||
});
|
||||
|
||||
const { data: myTeamsData } = useMyTeams();
|
||||
@@ -211,9 +44,7 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
mode: 'all',
|
||||
});
|
||||
|
||||
const teams = teamsData?.teams || [];
|
||||
const totalPages = teamsData?.totalPages || 1;
|
||||
const total = teamsData?.total || 0;
|
||||
const teams = teamsData?.data || [];
|
||||
const myTeams = myTeamsData?.data || [];
|
||||
|
||||
// Helper function to check if user is a member of a team
|
||||
@@ -239,43 +70,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
}
|
||||
});
|
||||
|
||||
// Generate page numbers to display
|
||||
const getPageNumbers = () => {
|
||||
const pages: (number | string)[] = [];
|
||||
const maxVisible = 5;
|
||||
|
||||
if (totalPages <= maxVisible + 2) {
|
||||
// Show all pages if total is small
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
// Always show first page
|
||||
pages.push(1);
|
||||
|
||||
if (currentPage > 3) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Show pages around current
|
||||
const start = Math.max(2, currentPage - 1);
|
||||
const end = Math.min(totalPages - 1, currentPage + 1);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (currentPage < totalPages - 2) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
// Always show last page
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
{/* Header */}
|
||||
@@ -300,7 +94,7 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
{/* Filters */}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="bg-white dark:bg-gray-900 p-6 rounded-lg shadow-sm mb-6 border dark:border-gray-800">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Search Teams
|
||||
@@ -323,63 +117,13 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
placeholder="All Cities (search to filter...)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Filter by Members
|
||||
</label>
|
||||
<select
|
||||
value={selectedMembers}
|
||||
onChange={(e) => {
|
||||
setSelectedMembers(e.target.value);
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ members: e.target.value, page: 1 });
|
||||
}}
|
||||
className="w-full h-[42px] px-3 border border-gray-300 dark:border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
>
|
||||
{MEMBER_FILTER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Submission Status
|
||||
</label>
|
||||
<select
|
||||
value={selectedSubmission}
|
||||
onChange={(e) => {
|
||||
setSelectedSubmission(e.target.value);
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ submission: e.target.value, page: 1 });
|
||||
}}
|
||||
className="w-full h-[42px] px-3 border border-gray-300 dark:border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
>
|
||||
{SUBMISSION_FILTER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Teams Count */}
|
||||
{!isLoading && total > 0 && (
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing {(currentPage - 1) * perPage + 1}-
|
||||
{Math.min(currentPage * perPage, total)} of {total} teams
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Teams List */}
|
||||
{isLoading || isFetching ? (
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: perPage }).map((_, index) => (
|
||||
<TeamCardSkeleton key={index} />
|
||||
))}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading teams...</p>
|
||||
</div>
|
||||
) : teams.length === 0 ? (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-sm p-12 text-center">
|
||||
@@ -391,195 +135,85 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{teams.map((team: any) => (
|
||||
<div
|
||||
key={team.id}
|
||||
className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow flex flex-col border dark:border-gray-800"
|
||||
>
|
||||
<img
|
||||
src={team.banner || '/images/banner-imphnen.webp'}
|
||||
alt={team.name}
|
||||
className="w-full aspect-3/1 object-cover"
|
||||
/>
|
||||
<div className="p-6 flex flex-col flex-1">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
{team.logo ? (
|
||||
<img
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-gray-500 dark:text-gray-300 text-xl">
|
||||
<Icon icon="mdi:account-group" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white line-clamp-1 leading-tight">
|
||||
{team.name}
|
||||
</h3>
|
||||
<div className="text-sm font-sans text-gray-600 dark:text-gray-400 flex items-center gap-2 mt-1">
|
||||
{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">
|
||||
<Icon icon="mdi:check-circle" className="text-sm" />
|
||||
Submitted
|
||||
</span>
|
||||
)}
|
||||
<p className="truncate flex-1 min-w-0 flex items-center gap-x-1">
|
||||
<Icon icon="mdi:map-marker" />{' '}
|
||||
<span>{team.city}</span>
|
||||
</p>
|
||||
<p className="whitespace-nowrap shrink-0 flex items-center gap-x-1">
|
||||
<Icon icon="mdi:account-group" />{' '}
|
||||
{team.member_count || 0} members
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{teams.map((team) => (
|
||||
<div
|
||||
key={team.id}
|
||||
className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow flex flex-col border dark:border-gray-800"
|
||||
>
|
||||
<img
|
||||
src={team.banner || '/images/banner-imphnen.webp'}
|
||||
alt={team.name}
|
||||
className="w-full aspect-3/1 object-cover"
|
||||
/>
|
||||
<div className="p-6 flex flex-col flex-1">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
{team.logo ? (
|
||||
<img
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-gray-500 dark:text-gray-300 text-xl">
|
||||
<Icon icon="mdi:account-group" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white line-clamp-2 leading-tight">
|
||||
{team.name}
|
||||
</h3>
|
||||
<div className="text-sm font-sans text-gray-600 dark:text-gray-400 flex gap-2">
|
||||
<p className="truncate flex-1 min-w-0 flex items-center gap-x-1">
|
||||
<Icon icon="mdi:map-marker" />{' '}
|
||||
<span>{team.city}</span>
|
||||
</p>
|
||||
<p className="whitespace-nowrap shrink-0 flex items-center gap-x-1">
|
||||
<Icon icon="mdi:account-group" />{' '}
|
||||
{team.members?.length || 0} members
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm mb-4 line-clamp-3 font-sans flex-1">
|
||||
{team.description}
|
||||
</p>
|
||||
<div className="space-y-3 mt-auto">
|
||||
{(team.member_count || 0) === 1 && !team.has_submission && (
|
||||
<div className="p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400 flex items-center gap-1">
|
||||
<Icon icon="mdi:alert" className="text-sm shrink-0" />
|
||||
<span>This team needs at least 2 members to submit</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isMyTeam(team.id) ? (
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm mb-4 line-clamp-3 font-sans">
|
||||
{team.description}
|
||||
</p>
|
||||
<div className="space-y-3 mt-auto">
|
||||
{isMyTeam(team.id) ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/teams/${team.id}`)}
|
||||
>
|
||||
Your Team
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{myTeams.length === 0 &&
|
||||
(team.members?.length || 0) < 5 && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => handleJoinRequest(team.id)}
|
||||
>
|
||||
Request to Join
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/teams/${team.id}`)}
|
||||
>
|
||||
Your Team
|
||||
View Team
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{myTeams.length === 0 &&
|
||||
(team.member_count || 0) < 5 &&
|
||||
!team.has_submission &&
|
||||
!isTeamFeaturesClosed && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => handleJoinRequest(team.id)}
|
||||
>
|
||||
Request to Join
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/teams/${team.id}`)}
|
||||
>
|
||||
View Team
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{(totalPages > 1 || total > 6) && (
|
||||
<div className="mt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const newPage = Math.max(1, currentPage - 1);
|
||||
setCurrentPage(newPage);
|
||||
updateUrlParams({ page: newPage });
|
||||
}}
|
||||
disabled={currentPage === 1 || isFetching}
|
||||
className="px-3"
|
||||
>
|
||||
<Icon icon="mdi:chevron-left" className="text-xl" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{getPageNumbers().map((page, index) =>
|
||||
typeof page === 'string' ? (
|
||||
<span
|
||||
key={`ellipsis-${index}`}
|
||||
className="px-2 text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => {
|
||||
setCurrentPage(page);
|
||||
updateUrlParams({ page });
|
||||
}}
|
||||
disabled={isFetching}
|
||||
className={`min-w-10 h-10 px-3 rounded-md text-sm font-medium transition-colors ${
|
||||
currentPage === page
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const newPage = Math.min(totalPages, currentPage + 1);
|
||||
setCurrentPage(newPage);
|
||||
updateUrlParams({ page: newPage });
|
||||
}}
|
||||
disabled={currentPage === totalPages || isFetching}
|
||||
className="px-3"
|
||||
>
|
||||
<Icon icon="mdi:chevron-right" className="text-xl" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{totalPages > 1 && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Show:</span>
|
||||
<select
|
||||
value={perPage}
|
||||
onChange={(e) => {
|
||||
const newPerPage = Number(e.target.value);
|
||||
setPerPage(newPerPage);
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ per_page: newPerPage, page: 1 });
|
||||
}}
|
||||
className="h-10 px-3 pr-8 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
{PER_PAGE_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,22 +5,11 @@ import { useNavigate } from 'react-router';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { teamCreateSchema, TTeamCreateForm, useCreateTeam, ETeamVisibility, useUploadFile } from '@imphnen-frontend-service/service';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from 'sonner';
|
||||
import { Icon } from '@iconify/react';
|
||||
|
||||
import { CitySelect } from '../../../components/city-select';
|
||||
|
||||
const MAX_FILE_SIZE = 2 * 1024 * 1024; // 2MB
|
||||
|
||||
// Team features deadline: 2025-11-30 23:59:00 WIB (UTC+7)
|
||||
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
||||
|
||||
const CreateTeamPage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Check if team features are closed
|
||||
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
||||
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string>('');
|
||||
const [bannerFile, setBannerFile] = useState<File | null>(null);
|
||||
@@ -42,11 +31,6 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error('Logo image is too large. Maximum size is 2MB.');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
setLogoFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
@@ -59,11 +43,6 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error('Banner image is too large. Maximum size is 2MB.');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
setBannerFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
@@ -94,67 +73,12 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
banner: bannerUrl,
|
||||
});
|
||||
|
||||
toast.success('Team created successfully!');
|
||||
navigate(`/teams/${result.data.id}`);
|
||||
} catch (error: any) {
|
||||
} catch (error) {
|
||||
console.error('Failed to create team:', error);
|
||||
|
||||
// Handle specific error messages
|
||||
const message = error?.message || '';
|
||||
if (message.includes('413') || message.includes('length limit') || message.includes('too large')) {
|
||||
toast.error('Image file is too large. Please use smaller images (max 2MB each).');
|
||||
} else if (message.includes('already a member')) {
|
||||
toast.error(message);
|
||||
} else {
|
||||
toast.error(message || 'Failed to create team. Please try again.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Show closed screen if team features are closed
|
||||
if (isTeamFeaturesClosed) {
|
||||
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">
|
||||
Team Features Closed
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Team creation is no longer available.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
The deadline for team features was November 30, 2025 at 23:59 WIB.
|
||||
</p>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors cursor-pointer"
|
||||
>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/teams/browse')}
|
||||
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
|
||||
>
|
||||
Browse Teams
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950">
|
||||
{/* Header */}
|
||||
@@ -195,7 +119,7 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
<label className="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-300 dark:border-neutral-600 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-neutral-800">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-500 dark:text-neutral-400">Click to upload banner</p>
|
||||
<p className="text-xs text-gray-400 dark:text-neutral-500 mt-1">1200x400 recommended. Max 2MB</p>
|
||||
<p className="text-xs text-gray-400 dark:text-neutral-500 mt-1">1200x400 recommended</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
@@ -225,33 +149,30 @@ const CreateTeamPage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div>
|
||||
<label htmlFor="logo" className="cursor-pointer">
|
||||
<span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">
|
||||
{logoPreview ? 'Change Logo' : 'Upload Logo'}
|
||||
</span>
|
||||
<input
|
||||
id="logo"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleLogoChange}
|
||||
/>
|
||||
</label>
|
||||
{logoPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLogoFile(null);
|
||||
setLogoPreview('');
|
||||
}}
|
||||
className="ml-3 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-neutral-500 mt-2">Max 2MB</p>
|
||||
<label htmlFor="logo" className="cursor-pointer">
|
||||
<span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">
|
||||
{logoPreview ? 'Change Logo' : 'Upload Logo'}
|
||||
</span>
|
||||
<input
|
||||
id="logo"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleLogoChange}
|
||||
/>
|
||||
</label>
|
||||
{logoPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLogoFile(null);
|
||||
setLogoPreview('');
|
||||
}}
|
||||
className="ml-3 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -124,62 +124,60 @@ const UserProfilePage: FC = (): ReactElement => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team Section */}
|
||||
{/* Teams Section */}
|
||||
{userTeams.length > 0 ? (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Team
|
||||
Teams ({userTeams.length})
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{userTeams.map((team: any) => (
|
||||
<Link
|
||||
key={team.id}
|
||||
to={'/teams/' + team.id}
|
||||
className="block bg-white dark:bg-gray-800 rounded-lg shadow-md dark:shadow-gray-950/50 overflow-hidden hover:shadow-lg transition-shadow border dark:border-gray-700"
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-md dark:shadow-gray-950/50 overflow-hidden hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<img
|
||||
src={team.banner || '/images/banner-imphnen.webp'}
|
||||
alt={team.name}
|
||||
className="w-full aspect-3/1 object-cover"
|
||||
/>
|
||||
<div className="w-full aspect-3/1">
|
||||
<img
|
||||
src={team.banner}
|
||||
alt={team.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
{team.logo ? (
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
{team.logo && (
|
||||
<img
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<Icon icon="mdi:account-group" className="text-gray-500 dark:text-gray-400 text-xl" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-gray-900 dark:text-white line-clamp-1 text-lg">
|
||||
<h3 className="font-bold text-gray-900 dark:text-white line-clamp-2 mb-1">
|
||||
{team.name}
|
||||
</h3>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-3 font-sans mt-1">
|
||||
{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">
|
||||
<Icon icon="mdi:check-circle" className="text-sm" />
|
||||
Submitted
|
||||
</span>
|
||||
)}
|
||||
{team.city && (
|
||||
<span className="flex items-center gap-1 truncate">
|
||||
<Icon icon="mdi:map-marker" className="shrink-0" />
|
||||
<span className="truncate">{team.city}</span>
|
||||
</span>
|
||||
)}
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 flex gap-2 font-sans">
|
||||
<p className="truncate flex-1 min-w-0">
|
||||
<Icon
|
||||
icon="mdi:map-marker"
|
||||
className="inline-block w-4 h-4 mr-1"
|
||||
/>
|
||||
{team.city}
|
||||
</p>
|
||||
<p className="whitespace-nowrap shrink-0">
|
||||
<Icon
|
||||
icon="mdi:account-group"
|
||||
className="inline-block w-4 h-4 mr-1"
|
||||
/>
|
||||
{team.members?.length || 0} members
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{team.description && (
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm line-clamp-2 font-sans">
|
||||
{team.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-gray-600 dark:text-gray-400 text-base line-clamp-2 font-sans">
|
||||
{team.description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
@@ -188,12 +186,10 @@ const UserProfilePage: FC = (): ReactElement => {
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Team
|
||||
Teams
|
||||
</h2>
|
||||
<div className="text-center py-8">
|
||||
<div className="text-4xl mb-3">
|
||||
<Icon icon="mdi:account-group-outline" className="inline-block text-gray-400" />
|
||||
</div>
|
||||
<div className="text-4xl mb-3">👥</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Not in any team yet
|
||||
</p>
|
||||
|
||||
@@ -1,318 +0,0 @@
|
||||
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;
|
||||
@@ -3,6 +3,7 @@ import { Link, useLocation } from 'react-router';
|
||||
import {
|
||||
useMyTeams,
|
||||
useAuthStore,
|
||||
supabase,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
@@ -39,11 +40,19 @@ export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
const handleLogout = () => {
|
||||
clearSession();
|
||||
localStorage.clear();
|
||||
toast.success('Logged out successfully');
|
||||
navigate('/auth/login');
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await supabase.auth.signOut();
|
||||
clearSession();
|
||||
localStorage.clear();
|
||||
toast.success('Logged out successfully');
|
||||
navigate('/auth/login');
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
clearSession();
|
||||
localStorage.clear();
|
||||
navigate('/auth/login');
|
||||
}
|
||||
};
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SessionUser } from '@imphnen-frontend-service/utils';
|
||||
import { hackathonApi, SessionToken } from '@imphnen-frontend-service/service';
|
||||
import { supabase } from '@imphnen-frontend-service/service';
|
||||
import { LoaderFunctionArgs, redirect } from 'react-router';
|
||||
|
||||
const mappingPublicRoutes = [
|
||||
@@ -37,10 +37,14 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
const url = new URL(request.url);
|
||||
const pathname = url.pathname;
|
||||
|
||||
// Get token from cookies and user from local storage
|
||||
const tokenData = SessionToken.get();
|
||||
const user = SessionUser.get();
|
||||
const isAuthenticated = !!tokenData?.token?.access_token;
|
||||
// Get session from Supabase (authoritative source)
|
||||
const { data: { session: supabaseSession }, error: sessionError } = await supabase.auth.getSession();
|
||||
|
||||
// Handle session errors
|
||||
if (sessionError) {
|
||||
console.error('[Middleware] Session error:', sessionError);
|
||||
// Don't redirect on session errors, let the app handle it
|
||||
}
|
||||
|
||||
// Allow to access the hackathon pages without authentication
|
||||
if (mappingPublicPrefixRoutes.some((prefix) => pathname.startsWith(prefix))) {
|
||||
@@ -59,24 +63,20 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
|
||||
// Auth routes (all /auth/* paths) - redirect to dashboard if already authenticated
|
||||
if (pathname.startsWith('/auth')) {
|
||||
if (isAuthenticated) return redirect('/dashboard');
|
||||
if (supabaseSession) return redirect('/dashboard');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Require authentication for all other routes
|
||||
if (!isAuthenticated) {
|
||||
// Require authentication for all other routes - ONLY check Supabase session
|
||||
if (!supabaseSession) {
|
||||
return redirect('/auth/login');
|
||||
}
|
||||
|
||||
// Check if user has completed onboarding
|
||||
// Check if user has completed onboarding by querying database (not localStorage!)
|
||||
// Skip onboarding check for onboarding routes themselves
|
||||
if (!mappingOnboardingRoutes.includes(pathname)) {
|
||||
try {
|
||||
const userId = user?.id;
|
||||
if (!userId) {
|
||||
return redirect('/auth/login');
|
||||
}
|
||||
|
||||
const userId = supabaseSession.user.id;
|
||||
const now = Date.now();
|
||||
|
||||
// Check cache first
|
||||
@@ -86,20 +86,20 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
|
||||
hasLocation = cached.hasLocation;
|
||||
} else {
|
||||
// First check user data (faster)
|
||||
if (user?.location) {
|
||||
hasLocation = true;
|
||||
} else {
|
||||
// Fetch from backend API
|
||||
try {
|
||||
const response = await hackathonApi.get('/users/me');
|
||||
hasLocation = !!response.data?.data?.location;
|
||||
} catch {
|
||||
// If API fails, check user data as fallback
|
||||
hasLocation = !!user?.location;
|
||||
}
|
||||
const { data: userData, error: userError } = await supabase
|
||||
.from('users')
|
||||
.select('location')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
if (userError) {
|
||||
console.error('[Middleware] Failed to fetch user data:', userError);
|
||||
// If we can't fetch user data, allow access (don't break the app)
|
||||
return null;
|
||||
}
|
||||
|
||||
hasLocation = !!userData?.location;
|
||||
|
||||
// Update cache
|
||||
onboardingCache.set(userId, { hasLocation, timestamp: now });
|
||||
}
|
||||
@@ -114,9 +114,10 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Check route permissions using user data
|
||||
// Check route permissions using fresh user data from Zustand (for UI metadata)
|
||||
const session = SessionUser.get();
|
||||
const userPermissions =
|
||||
user?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
|
||||
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
|
||||
|
||||
const matchedRoute = mappingRoutePermissions.find(
|
||||
(route) => route.path === pathname
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
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');
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
import baseConfig from '../../eslint.config.mjs';
|
||||
|
||||
export default [...baseConfig];
|
||||
@@ -1,15 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>IMPHNEN Infrastructure</title>
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" type="image/x-icon" href="/logos/simple.svg" />
|
||||
<link rel="stylesheet" href="/src/index.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,9 +0,0 @@
|
||||
import { join } from 'path';
|
||||
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {
|
||||
base: join(import.meta.dirname, '../../'),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"name": "infra",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "apps/infra/src",
|
||||
"projectType": "application",
|
||||
"tags": [],
|
||||
"targets": {}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 351 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 511 KiB |
@@ -1,248 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
--color-primary: #3b82f6;
|
||||
--color-primary-hover: #2563eb;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: #0a0a0a;
|
||||
color: #ffffff;
|
||||
line-height: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.card {
|
||||
background: #141414;
|
||||
border: 1px solid #27272a;
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-blue {
|
||||
background: rgba(59, 130, 246, 0.2);
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
.badge-green {
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.badge-purple {
|
||||
background: rgba(168, 85, 247, 0.2);
|
||||
color: #c084fc;
|
||||
}
|
||||
|
||||
.badge-orange {
|
||||
background: rgba(249, 115, 22, 0.2);
|
||||
color: #fb923c;
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { StrictMode } from 'react';
|
||||
import App from './app/App';
|
||||
import './index.css';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
|
||||
if (!rootElement) throw new Error('Failed to find the root element');
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../../dist/out-tsc",
|
||||
"types": ["node", "vite/client"]
|
||||
},
|
||||
"exclude": ["src/**/*.spec.ts", "src/**/*.test.ts", "src/**/*.spec.tsx", "src/**/*.test.tsx"],
|
||||
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"allowJs": false,
|
||||
"esModuleInterop": false,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"files": [],
|
||||
"include": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
],
|
||||
"extends": "../../tsconfig.base.json"
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/// <reference types='vitest' />
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
|
||||
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
|
||||
|
||||
export default defineConfig(() => ({
|
||||
root: __dirname,
|
||||
cacheDir: '../../node_modules/.vite/apps/infra',
|
||||
server: {
|
||||
port: 3006,
|
||||
host: 'localhost',
|
||||
},
|
||||
preview: {
|
||||
port: 3007,
|
||||
host: 'localhost',
|
||||
},
|
||||
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||
build: {
|
||||
outDir: '../../dist/apps/infra',
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
commonjsOptions: {
|
||||
transformMixedEsModules: true,
|
||||
},
|
||||
},
|
||||
}));
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -6,7 +6,6 @@ import { useRef } from 'react';
|
||||
import {
|
||||
FaArrowRight,
|
||||
FaDiscord,
|
||||
FaGithub,
|
||||
FaFacebook,
|
||||
FaInstagram,
|
||||
FaLinkedin,
|
||||
@@ -46,8 +45,6 @@ export function CommunitySection() {
|
||||
return FaFacebook;
|
||||
case 'FaDiscord':
|
||||
return FaDiscord;
|
||||
case 'FaGithub':
|
||||
return FaGithub;
|
||||
case 'FaInstagram':
|
||||
return FaInstagram;
|
||||
case 'FaTiktok':
|
||||
@@ -69,10 +66,6 @@ export function CommunitySection() {
|
||||
return {
|
||||
iconColor: 'text-[#5865F2]',
|
||||
};
|
||||
case 'FaGithub':
|
||||
return {
|
||||
iconColor: 'text-[#000000]',
|
||||
};
|
||||
case 'FaInstagram':
|
||||
return {
|
||||
iconColor: 'text-[#E4405F]',
|
||||
|
||||
@@ -78,7 +78,7 @@ export function CTASection() {
|
||||
<div className="flex flex-col items-center gap-6 text-white">
|
||||
<LogoSimple className="w-[240px]" />
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-2xl font-bold">250.000+</h3>
|
||||
<h3 className="text-2xl font-bold">180.000+</h3>
|
||||
<p className="text-lg">Programmer Sudah Bergabung</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,19 +54,9 @@ export function HeroSection() {
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full border px-3 py-1 text-sm">
|
||||
{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>
|
||||
<span className="inline-flex items-center rounded-full border px-3 py-1 text-sm w-fit">
|
||||
{communityLabel}
|
||||
</span>
|
||||
|
||||
<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">
|
||||
|
||||
@@ -5,7 +5,6 @@ import Link from 'next/link';
|
||||
import {
|
||||
FaDiscord,
|
||||
FaFacebook,
|
||||
FaGithub,
|
||||
FaInstagram,
|
||||
FaLinkedinIn,
|
||||
FaTiktok,
|
||||
@@ -38,13 +37,6 @@ export default function Footer() {
|
||||
>
|
||||
<FaDiscord className="size-6" />
|
||||
</Link>
|
||||
<Link
|
||||
href="https://github.com/IMPHNEN/"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
target="_blank"
|
||||
>
|
||||
<FaGithub className="size-6" />
|
||||
</Link>
|
||||
<Link
|
||||
href="https://www.instagram.com/imphnen.dev"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
@@ -100,25 +92,15 @@ export default function Footer() {
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-bold">Partners</h3>
|
||||
<h3 className="text-lg font-bold">Patners</h3>
|
||||
<ul className="space-y-2"></ul>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-8 border-t pt-8 text-center space-y-2">
|
||||
<div className="mt-8 border-t pt-8 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
© {new Date().getFullYear()} IMPHNEN - Ingin Menjadi Programmer
|
||||
Handal, Namun Enggan Ngoding. All rights reserved.
|
||||
</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>
|
||||
</footer>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[
|
||||
{ "value": "250K+", "label": "Member" },
|
||||
{ "value": "180K+", "label": "Member" },
|
||||
{ "value": "500+", "label": "Meme Harian" },
|
||||
{ "value": "24/7", "label": "Yapping" }
|
||||
]
|
||||
|
||||
@@ -13,13 +13,6 @@
|
||||
"description": "Real-time collaboration chat",
|
||||
"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",
|
||||
"icon": "FaInstagram",
|
||||
|
||||
@@ -1,23 +1,13 @@
|
||||
import localFont from 'next/font/local';
|
||||
import { Bai_Jamjuree, Poppins } from 'next/font/google';
|
||||
|
||||
export const baiJamjureeFont = localFont({
|
||||
src: [
|
||||
{ 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' },
|
||||
],
|
||||
export const baiJamjureeFont = Bai_Jamjuree({
|
||||
subsets: ['latin'],
|
||||
weight: ['300', '400', '500', '600', '700'],
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export const poppinsFont = localFont({
|
||||
src: [
|
||||
{ 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' },
|
||||
],
|
||||
export const poppinsFont = Poppins({
|
||||
subsets: ['latin'],
|
||||
weight: ['300', '400', '500', '600', '700'],
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
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;"]
|
||||
@@ -1,12 +0,0 @@
|
||||
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: {},
|
||||
},
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user