Compare commits

..
Author SHA1 Message Date
xirf 4d4b92b357 feat(landing): login register 2025-09-21 15:42:58 +07:00
xirf 8e7c6e6c21 feat: update hackathon data and enhance components
- Removed existing submissions data from hackathons.json.
- Added new components for Hackathon details including HackathonContent, HackathonHeader, HackathonHero, HackathonPartners, HackathonQuickInfo, HackathonRequirements, HackathonSidebar, HackathonStatusBar, HackathonSubmissions, HackathonTabs, and ServerHackathonContent.
- Integrated framer-motion for animations in Hackathon components.
- Updated package.json and package-lock.json to include nextjs-toploader dependency.
2025-09-17 02:36:02 +07:00
xirf 6960b17c41 feat(WIP): hackatons management and pages 2025-09-17 01:26:05 +07:00
277 changed files with 17038 additions and 59411 deletions
+2 -17
View File
@@ -1,24 +1,9 @@
{
"permissions": {
"allow": [
"Bash(npx nx run-many:*)",
"Bash(npm install:*)",
"Bash(npm view:*)",
"Bash(npx nx build landing)",
"Bash(npm uninstall:*)",
"Bash(dir:*)",
"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(npm run typecheck:*)"
],
"deny": [],
"ask": []
}
}
}
-19
View File
@@ -1,19 +0,0 @@
#!/usr/bin/env bash
set -e
if [[ ! -d "/Users/ms/Development/personal/imphnen-frontend-service" ]]; then
echo "Cannot find source directory; Did you move it?"
echo "(Looking for "/Users/ms/Development/personal/imphnen-frontend-service")"
echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
exit 1
fi
# rebuild the cache forcefully
_nix_direnv_force_reload=1 direnv exec "/Users/ms/Development/personal/imphnen-frontend-service" true
# Update the mtime for .envrc.
# This will cause direnv to reload again - but without re-building.
touch "/Users/ms/Development/personal/imphnen-frontend-service/.envrc"
# Also update the timestamp of whatever profile_rc we have.
# This makes sure that we know we are up to date.
touch -r "/Users/ms/Development/personal/imphnen-frontend-service/.envrc" "/Users/ms/Development/personal/imphnen-frontend-service/.direnv"/*.rc
-55
View File
@@ -1,55 +0,0 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
dist
.next
out
build
coverage
# IDE
.vscode
.idea
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Environment
.env
.env.local
.env.*.local
# Testing
coverage
.nyc_output
# Logs
logs
*.log
# Nx
.nx
# Docker
Dockerfile
docker-compose*.yml
.dockerignore
# Misc
*.md
!README.md
.editorconfig
.prettierrc
.eslintrc
-1
View File
@@ -1 +0,0 @@
use flake
+35
View File
@@ -0,0 +1,35 @@
name: Deploy Backoffice
on:
push:
branches:
- develop
paths:
- 'apps/backoffice/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
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 }}
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 pull
fi
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-backoffice/apps/backoffice/.env
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml down || true
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml build
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml up -d
docker image prune -af
+35
View File
@@ -0,0 +1,35 @@
name: Deploy Dimentorin
on:
push:
branches:
- develop
paths:
- 'apps/dimentorin/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
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 }}
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 pull
fi
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-dimentorin/apps/dimentorin/.env
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml down || true
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml build
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml up -d
docker image prune -af
+35
View File
@@ -0,0 +1,35 @@
name: Deploy Gacha
on:
push:
branches:
- develop
paths:
- 'apps/gacha/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
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 }}
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 pull
fi
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-gacha/apps/gacha/.env
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml down || true
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml build
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml up -d
docker image prune -af
+35
View File
@@ -0,0 +1,35 @@
name: Deploy Landing
on:
push:
branches:
- develop
paths:
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
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 }}
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 pull
fi
echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" > ~/imphnen-frontend-service/apps/landing/.env
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
docker image prune -af
-40
View File
@@ -1,40 +0,0 @@
name: Nix Build & Cache
on:
push:
branches: ['develop']
pull_request:
branches: ['develop']
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
- name: Setup Cachix
uses: cachix/cachix-action@v15
with:
name: msdqn
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
- name: Build all packages
run: |
nix build .#landing -o result-landing
nix build .#backoffice -o result-backoffice
nix build .#gacha -o result-gacha
nix build .#dimentorin -o result-dimentorin
nix build .#hackathon -o result-hackathon
nix build .#infra -o result-infra
- name: Show build outputs
run: |
echo "Landing: $(readlink result-landing)"
echo "Backoffice: $(readlink result-backoffice)"
echo "Gacha: $(readlink result-gacha)"
echo "Dimentorin: $(readlink result-dimentorin)"
echo "Hackathon: $(readlink result-hackathon)"
echo "Infra: $(readlink result-infra)"
-4
View File
@@ -62,7 +62,3 @@ storybook-static
# Next.js
.next
out
.cursor/rules/nx-rules.mdc
.github/instructions/nx.instructions.md
.env.local
-9
View File
@@ -1,9 +0,0 @@
{
"mcpServers": {
"nx-mcp": {
"type": "stdio",
"command": "npx",
"args": ["nx", "mcp"]
}
}
}
-13
View File
@@ -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-->
-13
View File
@@ -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
View File
@@ -1 +0,0 @@
Tue, Nov 25, 2025 4:21:27 PM
-2
View File
@@ -1,2 +0,0 @@
# Deployment trigger
# Updated to deploy circular dependency fixes and auth hooks
+1 -1
View File
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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;
+2 -40
View File
@@ -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>
-23
View File
@@ -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>
);
}
-29
View File
@@ -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>
);
};
-518
View File
@@ -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;
+2 -2
View File
@@ -130,7 +130,7 @@
html {
font-family: 'Bai Jamjuree', sans-serif;
font-weight: 400;
font-size: 14px;
font-size: 12px;
line-height: 1.2;
}
}
}
+2 -2
View File
@@ -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';
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../node_modules/.vite/apps/backoffice',
server: {
port: 3003,
port: 3000,
host: 'localhost',
},
preview: {
-1
View File
@@ -1 +0,0 @@
Tue, Nov 25, 2025 4:21:27 PM
-2
View File
@@ -1,2 +0,0 @@
# Deployment trigger
# Updated to deploy circular dependency fixes and auth hooks
@@ -3,8 +3,9 @@
import React, { createContext, useContext, useMemo, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '@imphnen-frontend-service/utils';
import {
useAuthStore,
useUserMe,
useUserById,
useUpdateUserMe,
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { toast } from 'sonner';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '@imphnen-frontend-service/service';
import { useAuthStore } from '@imphnen-frontend-service/utils';
export interface GoogleLoginResponse {
access_token?: string;
@@ -1,6 +1,7 @@
import { FC, ReactElement, useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useGoogleCallback, useAuthStore } from '@imphnen-frontend-service/service';
import { useAuthStore } from '@imphnen-frontend-service/utils';
import { useGoogleCallback } from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
export const GoogleCallbackPage: FC = (): ReactElement => {
-1
View File
@@ -1 +0,0 @@
Tue, Nov 25, 2025 4:21:27 PM
-2
View File
@@ -1,2 +0,0 @@
# Deployment trigger
# Updated to deploy circular dependency fixes and auth hooks
+1 -1
View File
@@ -3,9 +3,9 @@ import {
authLoginSchema,
TLoginRequest,
usePostLogin,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAuthStore } from '@imphnen-frontend-service/utils';
import { toast } from 'sonner';
import { useNavigate } from 'react-router';
import { useVerifyEmail } from './use-verify-email';
-1
View File
@@ -1 +0,0 @@
Tue, Nov 25, 2025 16:42:27 PM
-14
View File
@@ -1,14 +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: {
'jsx-a11y/accessible-emoji': 'off',
},
},
];
-43
View File
@@ -1,43 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>IMPHNEN x Kolosal.ai Hackathon 2025</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="description"
content="Ikuti IMPHNEN x Kolosal.ai Hackathon 2025! Kompetisi coding online gratis dengan total hadiah Rp14.500.000. Tema: Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif."
/>
<meta
name="keywords"
content="hackathon, lomba coding, kompetisi it, imphnen, kolosal.ai, ai hackathon, lomba programming 2025"
/>
<link
rel="icon"
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/" />
<meta property="og:title" content="IMPHNEN x Kolosal.ai Hackathon 2025" />
<meta
property="og:description"
content="Total hadiah Rp14.500.000! Daftar sekarang dan wujudkan inovasi AI-mu untuk membantu usaha lokal."
/>
<meta property="twitter:card" content="summary_large_image" />
<meta property="twitter:url" content="https://hackathon.imphnen.dev/" />
<meta
property="twitter:title"
content="IMPHNEN x Kolosal.ai Hackathon 2025"
/>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
-9
View File
@@ -1,9 +0,0 @@
import { join } from 'path';
export default {
plugins: {
'@tailwindcss/postcss': {
base: join(import.meta.dirname, '../../'),
},
},
};
-1
View File
@@ -1 +0,0 @@
{}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

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

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 511 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 351 KiB

@@ -1,10 +0,0 @@
<svg width="82" height="24" viewBox="0 0 82 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.9814 11.6032C15.0691 11.7133 15.1239 11.8495 15.124 12C15.124 12.1501 15.0698 12.2867 14.9825 12.3968L26.8264 24H7.79044L7.76667 23.9757L0.0248413 16.3911L0 16.3679V7.63213L0.0248413 7.60885L7.76667 0.0243365L7.79044 0H26.8264L14.9814 11.6032ZM14.8659 12.5111C14.7535 12.5968 14.6143 12.6507 14.4609 12.6507C14.3071 12.6507 14.1673 12.5971 14.0548 12.5111L10.3977 16.0938C10.5164 16.2113 10.5899 16.3726 10.5899 16.5509C10.5899 16.6459 10.5668 16.7352 10.5295 16.8165L17.6956 23.8371H25.8608C25.8683 23.78 25.8804 23.7233 25.903 23.6699C25.9358 23.5923 25.9831 23.5211 26.0434 23.4614L14.8659 12.5111ZM10.3307 8.06807C10.2184 8.15365 10.0801 8.20766 9.92678 8.20774C9.80008 8.20774 9.68295 8.17075 9.58225 8.1104L5.61305 12L9.65353 15.9584C9.73677 15.9215 9.82951 15.9012 9.92678 15.9012C10.0509 15.9013 10.1655 15.9372 10.2648 15.9954L13.9381 12.3968C13.8511 12.2868 13.7977 12.1499 13.7977 12C13.7978 11.8498 13.8518 11.7132 13.9392 11.6032L10.3307 8.06807ZM10.4906 7.22053C10.5522 7.3192 10.5899 7.4339 10.5899 7.55806C10.5899 7.70823 10.5357 7.84479 10.4485 7.95485L14.0558 11.4889C14.1682 11.4034 14.3076 11.3503 14.4609 11.3503C14.6139 11.3503 14.7536 11.4026 14.8659 11.4879L26.0434 0.537519C25.9835 0.477938 25.9356 0.407383 25.903 0.33013C25.8804 0.276608 25.8682 0.220166 25.8608 0.162949H17.6956L10.4906 7.22053Z" fill="#0D0E0F"/>
<path d="M82 5.69697V17.3333H80.291V5.69697H82Z" fill="#0D0E0F"/>
<path d="M73.9503 17.5265C73.3939 17.5265 72.8909 17.4242 72.4413 17.2197C71.9916 17.0114 71.6353 16.7102 71.3724 16.3163C71.1133 15.9223 70.9837 15.4394 70.9837 14.8674C70.9837 14.375 71.079 13.9697 71.2695 13.6515C71.4601 13.3333 71.7173 13.0814 72.0412 12.8958C72.3651 12.7102 72.7271 12.5701 73.1272 12.4754C73.5273 12.3807 73.935 12.3087 74.3504 12.2595C74.8762 12.1989 75.303 12.1496 75.6307 12.1117C75.9584 12.0701 76.1966 12.0038 76.3452 11.9129C76.4938 11.822 76.5681 11.6742 76.5681 11.4697V11.4299C76.5681 10.9337 76.4271 10.5492 76.1452 10.2765C75.867 10.0038 75.4516 9.86742 74.8991 9.86742C74.3237 9.86742 73.8702 9.99432 73.5387 10.2481C73.211 10.4981 72.9843 10.7765 72.8585 11.0833L71.2524 10.7197C71.4429 10.1894 71.7211 9.76136 72.0869 9.4356C72.4565 9.10606 72.8814 8.86742 73.3615 8.71969C73.8417 8.56818 74.3466 8.49242 74.8762 8.49242C75.2268 8.49242 75.5983 8.53409 75.9908 8.61742C76.3871 8.69697 76.7568 8.84469 77.0997 9.0606C77.4465 9.27651 77.7304 9.58523 77.9514 9.98674C78.1724 10.3845 78.2829 10.9015 78.2829 11.5379V17.3333H76.6139V16.1401H76.5453C76.4348 16.3598 76.269 16.5758 76.048 16.7879C75.827 17 75.5431 17.1761 75.1963 17.3163C74.8496 17.4564 74.4342 17.5265 73.9503 17.5265ZM74.3218 16.1629C74.7943 16.1629 75.1982 16.0701 75.5336 15.8845C75.8727 15.6989 76.1299 15.4564 76.3052 15.1572C76.4843 14.8542 76.5738 14.5303 76.5738 14.1856V13.0606C76.5129 13.1212 76.3947 13.178 76.2195 13.2311C76.048 13.2803 75.8517 13.3239 75.6307 13.3617C75.4097 13.3958 75.1944 13.428 74.9848 13.4583C74.7753 13.4848 74.6 13.5076 74.459 13.5265C74.1275 13.5682 73.8245 13.6383 73.5502 13.7367C73.2796 13.8352 73.0624 13.9773 72.8986 14.1629C72.7385 14.3447 72.6585 14.5871 72.6585 14.8901C72.6585 15.3106 72.8147 15.6288 73.1272 15.8447C73.4397 16.0568 73.8379 16.1629 74.3218 16.1629Z" fill="#0D0E0F"/>
<path d="M69.613 10.7367L68.064 11.0095C67.9992 10.8125 67.8963 10.625 67.7553 10.447C67.6182 10.2689 67.4314 10.1231 67.1952 10.0095C66.9589 9.89583 66.6636 9.83901 66.3092 9.83901C65.8253 9.83901 65.4214 9.94697 65.0975 10.1629C64.7736 10.375 64.6116 10.6496 64.6116 10.9867C64.6116 11.2784 64.7202 11.5133 64.9374 11.6913C65.1546 11.8693 65.5052 12.0151 65.9891 12.1288L67.3838 12.447C68.1916 12.6326 68.7937 12.9186 69.19 13.3049C69.5863 13.6913 69.7845 14.1932 69.7845 14.8106C69.7845 15.3333 69.632 15.7992 69.3272 16.2083C69.0262 16.6136 68.6051 16.9318 68.064 17.1629C67.5267 17.3939 66.9037 17.5095 66.1949 17.5095C65.2118 17.5095 64.4097 17.3011 63.7885 16.8845C63.1674 16.464 62.7864 15.8674 62.6454 15.0947L64.2972 14.8447C64.4001 15.2727 64.6116 15.5966 64.9317 15.8163C65.2518 16.0322 65.6691 16.1401 66.1835 16.1401C66.7436 16.1401 67.1914 16.0246 67.5267 15.7936C67.862 15.5587 68.0297 15.2727 68.0297 14.9356C68.0297 14.6629 67.9268 14.4337 67.721 14.2481C67.5191 14.0625 67.2085 13.9223 66.7894 13.8276L65.3032 13.5038C64.484 13.3182 63.8781 13.0227 63.4856 12.6174C63.0969 12.2121 62.9026 11.6989 62.9026 11.0776C62.9026 10.5625 63.0474 10.1117 63.337 9.72538C63.6266 9.33901 64.0267 9.03788 64.5373 8.82197C65.0479 8.60227 65.6328 8.49242 66.2921 8.49242C67.2409 8.49242 67.9878 8.69697 68.5327 9.10606C69.0776 9.51136 69.4377 10.0549 69.613 10.7367Z" fill="#0D0E0F"/>
<path d="M57.3021 17.5095C56.479 17.5095 55.7607 17.322 55.1472 16.947C54.5337 16.572 54.0574 16.0473 53.7182 15.3731C53.3791 14.6989 53.2095 13.911 53.2095 13.0095C53.2095 12.1042 53.3791 11.3125 53.7182 10.6345C54.0574 9.95644 54.5337 9.42992 55.1472 9.05492C55.7607 8.67992 56.479 8.49242 57.3021 8.49242C58.1252 8.49242 58.8435 8.67992 59.457 9.05492C60.0705 9.42992 60.5468 9.95644 60.8859 10.6345C61.2251 11.3125 61.3946 12.1042 61.3946 13.0095C61.3946 13.911 61.2251 14.6989 60.8859 15.3731C60.5468 16.0473 60.0705 16.572 59.457 16.947C58.8435 17.322 58.1252 17.5095 57.3021 17.5095ZM57.3078 16.0833C57.8413 16.0833 58.2833 15.9432 58.6339 15.6629C58.9845 15.3826 59.2436 15.0095 59.4112 14.5436C59.5827 14.0776 59.6685 13.5644 59.6685 13.0038C59.6685 12.447 59.5827 11.9356 59.4112 11.4697C59.2436 11 58.9845 10.6231 58.6339 10.339C58.2833 10.0549 57.8413 9.91288 57.3078 9.91288C56.7705 9.91288 56.3247 10.0549 55.9703 10.339C55.6197 10.6231 55.3587 11 55.1872 11.4697C55.0196 11.9356 54.9357 12.447 54.9357 13.0038C54.9357 13.5644 55.0196 14.0776 55.1872 14.5436C55.3587 15.0095 55.6197 15.3826 55.9703 15.6629C56.3247 15.9432 56.7705 16.0833 57.3078 16.0833Z" fill="#0D0E0F"/>
<path d="M51.573 5.69697V17.3333H49.864V5.69697H51.573Z" fill="#0D0E0F"/>
<path d="M44.132 17.5095C43.3089 17.5095 42.5906 17.322 41.9771 16.947C41.3636 16.572 40.8873 16.0473 40.5482 15.3731C40.209 14.6989 40.0395 13.911 40.0395 13.0095C40.0395 12.1042 40.209 11.3125 40.5482 10.6345C40.8873 9.95644 41.3636 9.42992 41.9771 9.05492C42.5906 8.67992 43.3089 8.49242 44.132 8.49242C44.9551 8.49242 45.6734 8.67992 46.2869 9.05492C46.9004 9.42992 47.3767 9.95644 47.7158 10.6345C48.055 11.3125 48.2245 12.1042 48.2245 13.0095C48.2245 13.911 48.055 14.6989 47.7158 15.3731C47.3767 16.0473 46.9004 16.572 46.2869 16.947C45.6734 17.322 44.9551 17.5095 44.132 17.5095ZM44.1377 16.0833C44.6712 16.0833 45.1132 15.9432 45.4638 15.6629C45.8144 15.3826 46.0735 15.0095 46.2412 14.5436C46.4126 14.0776 46.4984 13.5644 46.4984 13.0038C46.4984 12.447 46.4126 11.9356 46.2412 11.4697C46.0735 11 45.8144 10.6231 45.4638 10.339C45.1132 10.0549 44.6712 9.91288 44.1377 9.91288C43.6004 9.91288 43.1546 10.0549 42.8002 10.339C42.4496 10.6231 42.1886 11 42.0171 11.4697C41.8495 11.9356 41.7656 12.447 41.7656 13.0038C41.7656 13.5644 41.8495 14.0776 42.0171 14.5436C42.1886 15.0095 42.4496 15.3826 42.8002 15.6629C43.1546 15.9432 43.6004 16.0833 44.1377 16.0833Z" fill="#0D0E0F"/>
<path d="M30.7951 17.3333V5.69697H32.5613V11.2538H32.7042L37.6141 5.69697H39.8376L35.1163 10.9356L39.8547 17.3333H37.7284L33.9445 12.1345L32.5613 13.714V17.3333H30.7951Z" fill="#0D0E0F"/>
</svg>

Before

Width:  |  Height:  |  Size: 7.2 KiB

-23
View File
@@ -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="/"
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,172 +0,0 @@
import { FC, ReactElement, useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router';
import { useGitHubCallback } from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
const CallbackPage: FC = (): ReactElement => {
const navigate = useNavigate();
const { mutateAsync: exchangeGitHubCode } = useGitHubCallback();
const [isProcessing, setIsProcessing] = useState(true);
const [error, setError] = useState<string | null>(null);
const hasRunRef = useRef(false);
useEffect(() => {
const handleCallback = async () => {
if (hasRunRef.current) {
return;
}
hasRunRef.current = true;
try {
// Check URL hash for Supabase email confirmation callback
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
const urlParams = new URLSearchParams(globalThis.location.search);
const type = hashParams.get('type') || urlParams.get('type');
const accessToken = hashParams.get('access_token') || urlParams.get('access_token');
// Debug: log what we received
console.log('[Callback] Params:', { type, accessToken: !!accessToken, hash: globalThis.location.hash, search: globalThis.location.search });
// Handle Supabase email callbacks (has access_token in hash or query)
// This includes: signup confirmation, email confirmation, password recovery
if (accessToken) {
setIsProcessing(false);
// Password recovery - type is 'recovery' or we have access_token from reset email
if (type === 'recovery' || type === 'magiclink') {
toast.success('Email verified! Please set your new password.');
navigate('/auth/reset-password?access_token=' + accessToken);
return;
}
// Signup/Email confirmation
if (type === 'signup' || type === 'email_confirmation') {
toast.success('Email verified successfully! Please log in to continue.');
navigate('/auth/login');
return;
}
// If we have access_token but unknown type, assume it's password recovery
// (Supabase sometimes sends without explicit type)
toast.success('Email verified! Please set your new password.');
navigate('/auth/reset-password?access_token=' + accessToken);
return;
}
// Get the code from URL query params (GitHub OAuth)
const code = urlParams.get('code');
if (!code) {
throw new Error('No authorization code received');
}
// Exchange the code for tokens using backend API (GitHub OAuth)
const result = await exchangeGitHubCode({ code });
toast.success('Login successful!');
setIsProcessing(false);
// Check if user has completed onboarding (has location)
if (result.user.location) {
globalThis.location.replace('/dashboard');
} else {
globalThis.location.replace('/onboarding/user');
}
} catch (err) {
console.error('[Callback] Error:', err);
setError((err as Error).message);
setIsProcessing(false);
toast.error('An error occurred during login');
setTimeout(() => {
navigate('/auth/login');
}, 3000);
}
};
handleCallback();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (error) {
// Check if error is related to private email
const isPrivateEmailError =
error.toLowerCase().includes('failed to create user') ||
error.toLowerCase().includes('email') ||
error.toLowerCase().includes('user record');
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 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="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">
GitHub Login Failed
</h2>
<p className="text-red-600 dark:text-red-400 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">
Redirecting to login page in 3 seconds...
</p>
</div>
</div>
);
}
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>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
Completing login...
</h2>
<p className="text-gray-600 dark:text-gray-400">Please wait</p>
</div>
</div>
);
};
export default CallbackPage;
@@ -1,125 +0,0 @@
import { useState } from 'react';
import { useForgotPassword } from '@imphnen-frontend-service/service';
import { Link, useNavigate } from 'react-router';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
import ThemeToggle from '../../../components/theme-toggle';
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [emailSent, setEmailSent] = useState(false);
const navigate = useNavigate();
const forgotPasswordMutation = useForgotPassword();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) {
toast.error('Please enter your email');
return;
}
try {
await forgotPasswordMutation.mutateAsync({ email });
setEmailSent(true);
toast.success('Password reset email sent! Check your inbox.');
} catch (err) {
toast.error((err as Error).message || 'Failed to send reset email');
}
};
if (emailSent) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 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">
<span className="text-3xl"></span>
</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 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">
Click the link in the email to reset your password. The link will
expire in 1 hour.
</p>
<Link to="/auth/login">
<button className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors">
Back to Login
</button>
</Link>
<button
onClick={() => setEmailSent(false)}
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
>
Send another email
</button>
</div>
</div>
</div>
);
}
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">
<div className="flex items-center justify-between mb-6">
<button
onClick={() => navigate('/auth/login')}
className="cursor-pointer text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300 text-base font-sans flex items-center"
>
<Icon icon="ic:baseline-chevron-left" width="24" height="24" />
Back to Login
</button>
<ThemeToggle />
</div>
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Forgot Password?
</h2>
<p className="text-gray-600 dark:text-gray-400">
No worries, we'll send you reset instructions
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Email Address
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
disabled={forgotPasswordMutation.isPending}
className="bg-white 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"
required
/>
</div>
<button
type="submit"
disabled={forgotPasswordMutation.isPending}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{forgotPasswordMutation.isPending ? 'Sending...' : 'Send Reset Link'}
</button>
</form>
</div>
</div>
);
}
-235
View File
@@ -1,235 +0,0 @@
import { useState, useEffect } from 'react';
import {
useGitHubAuth,
useLogin,
} from '@imphnen-frontend-service/service';
import { GithubOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
import { ThemeToggle } from '../../../components/theme-toggle';
export default function LoginPage() {
const navigate = useNavigate();
const { signInWithGitHub } = useGitHubAuth();
const loginMutation = useLogin();
const [isGithubLoading, setIsGithubLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
// Check for password reset tokens in URL and redirect to reset-password page
useEffect(() => {
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
const urlParams = new URLSearchParams(globalThis.location.search);
const accessToken = hashParams.get('access_token') || urlParams.get('access_token');
const type = hashParams.get('type') || urlParams.get('type');
// If we have an access_token, this is likely a password reset redirect that landed on the wrong page
if (accessToken) {
console.log('[Login] Detected access_token, redirecting to reset-password page');
// Check if it's a password recovery
if (type === 'recovery' || type === 'magiclink' || !type) {
toast.info('Redirecting to password reset...');
navigate('/auth/reset-password?access_token=' + accessToken);
}
}
}, [navigate]);
const handleEmailLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!email || !password) {
setError('Please enter both email and password');
return;
}
try {
const result = await loginMutation.mutateAsync({ email, password });
toast.success('Login successful!');
// Redirect based on onboarding status
if (result.user.location) {
navigate('/dashboard');
} else {
navigate('/onboarding/user');
}
} catch (err) {
console.error('[Login] Email login failed:', err);
setError((err as Error).message || 'Login failed');
}
};
const handleGithubLogin = async () => {
try {
setIsGithubLoading(true);
const result = await signInWithGitHub();
// Check if we got a redirect URL
if (result?.url) {
globalThis.location.href = result.url;
} else {
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');
setIsGithubLoading(false);
}
};
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">
<div className="flex items-center justify-between mb-6">
<button
onClick={() => navigate('/')}
className="cursor-pointer text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300 text-base font-sans flex items-center"
>
<Icon icon="ic:baseline-chevron-left" width="24" height="24" />
Back to Homepage
</button>
<ThemeToggle />
</div>
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Welcome Back
</h2>
<p className="text-gray-600 dark:text-gray-400 font-sans">
Sign in to join or create your hackathon team
</p>
</div>
{error && (
<div className="mb-6 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p>
</div>
)}
<form onSubmit={handleEmailLogin} className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
disabled={loginMutation.isPending}
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>
<div className="flex items-center justify-between mb-1">
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Password
</label>
<Link
to="/auth/forgot-password"
className="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300"
>
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>
</div>
<button
type="submit"
disabled={loginMutation.isPending}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 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'}
</button>
</form>
<div className="my-6 flex items-center">
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div>
<span className="px-4 text-sm text-gray-500 dark:text-gray-400">
OR
</span>
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div>
</div>
<button
onClick={handleGithubLogin}
disabled={isGithubLoading}
type="button"
className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg font-semibold text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:bg-gray-100 dark:disabled:bg-gray-800 disabled:cursor-not-allowed transition-colors cursor-pointer"
>
<GithubOutlined className="text-xl" />
<span>
{isGithubLoading ? 'Connecting...' : 'Sign in with GitHub'}
</span>
</button>
<p className="mt-3 text-xs text-center text-gray-500 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"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
>
Sign up
</Link>
</p>
</div>
<div className="mt-6 text-center">
<p className="text-gray-500 dark:text-gray-500 text-xs">
By signing in, you agree to our Terms of Service and Privacy Policy
</p>
</div>
</div>
</div>
);
}
@@ -1,161 +0,0 @@
import { useState, useEffect } from 'react';
import { useResetPassword, useAuthStore } from '@imphnen-frontend-service/service';
import { useNavigate } from 'react-router';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
export default function ResetPasswordPage() {
const navigate = useNavigate();
const { clearSession } = useAuthStore();
const resetPasswordMutation = useResetPassword();
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [accessToken, setAccessToken] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
useEffect(() => {
// Get the access_token from URL hash (Supabase sends it as hash fragment)
// or from query params (when redirected from callback page)
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
const queryParams = new URLSearchParams(globalThis.location.search);
const token = hashParams.get('access_token') || queryParams.get('access_token');
if (token) {
setAccessToken(token);
} else {
toast.error('Invalid or expired reset link');
setTimeout(() => navigate('/auth/forgot-password'), 2000);
}
}, [navigate]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirmPassword) {
toast.error('Passwords do not match');
return;
}
if (password.length < 6) {
toast.error('Password must be at least 6 characters');
return;
}
if (!accessToken) {
toast.error('Invalid reset token');
return;
}
try {
await resetPasswordMutation.mutateAsync({
access_token: accessToken,
new_password: password,
});
toast.success('Password updated successfully!');
// Clear session and redirect to login
clearSession();
navigate('/auth/login');
} catch (err) {
toast.error((err as Error).message || 'Failed to reset password');
}
};
if (!accessToken) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 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">
<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">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Set New Password
</h2>
<p className="text-gray-600 dark:text-gray-400">
Enter your new password below
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
New Password
</label>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
disabled={resetPasswordMutation.isPending}
className="w-full px-4 py-2.5 pr-12 border border-gray-300 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>
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Confirm New Password
</label>
<div className="relative">
<input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
disabled={resetPasswordMutation.isPending}
className="w-full px-4 py-2.5 pr-12 border border-gray-300 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>
</div>
<button
type="submit"
disabled={resetPasswordMutation.isPending}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{resetPasswordMutation.isPending ? 'Updating...' : 'Update Password'}
</button>
</form>
</div>
</div>
);
}
-406
View File
@@ -1,406 +0,0 @@
import { useState } from 'react';
import { useGitHubAuth, useSignup } from '@imphnen-frontend-service/service';
import { GithubOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
import { 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 { signInWithGitHub } = useGitHubAuth();
const signupMutation = useSignup();
const [isGithubLoading, setIsGithubLoading] = useState(false);
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) => {
setError(null);
try {
const result = await signupMutation.mutateAsync({
email: data.email,
password: data.password,
fullname: data.fullname,
});
toast.success(result.message);
setRegisteredEmail(data.email);
setRegistrationSuccess(true);
} catch (err) {
console.error('[Signup] Email signup failed:', err);
setError((err as Error).message || 'Signup failed');
}
};
// 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);
const result = await signInWithGitHub();
if (result?.url) {
globalThis.location.href = result.url;
} else {
setIsGithubLoading(false);
setError('Failed to get GitHub OAuth URL');
}
} catch (err) {
console.error('[Signup] GitHub login failed:', err);
setError((err as Error).message || 'GitHub login failed');
setIsGithubLoading(false);
}
};
const inputBaseClass =
'w-full px-4 py-2.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white 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">
<div className="flex items-center justify-between mb-6">
<button
onClick={() => navigate('/')}
className="cursor-pointer text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300 text-base font-sans flex items-center"
>
<Icon icon="ic:baseline-chevron-left" width="24" height="24" />
Back to Homepage
</button>
<ThemeToggle />
</div>
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Create Account
</h2>
<p className="text-gray-600 dark:text-gray-400">
Join the hackathon community
</p>
</div>
{error && (
<div className="mb-6 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p>
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label
htmlFor="fullname"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Full Name
</label>
<input
id="fullname"
type="text"
{...register('fullname')}
placeholder="John Doe"
disabled={signupMutation.isPending}
className={`${inputBaseClass} ${
errors.fullname ? inputErrorClass : inputNormalClass
}`}
/>
{errors.fullname && (
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
{errors.fullname.message}
</p>
)}
</div>
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Email
</label>
<input
id="email"
type="email"
{...register('email')}
placeholder="your@email.com"
disabled={signupMutation.isPending}
className={`${inputBaseClass} ${
errors.email ? inputErrorClass : inputNormalClass
}`}
/>
{errors.email && (
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
{errors.email.message}
</p>
)}
</div>
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
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>
)}
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
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>
)}
</div>
<button
type="submit"
disabled={!isValid || signupMutation.isPending}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 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'}
</button>
</form>
<div className="my-6 flex items-center">
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div>
<span className="px-4 text-sm text-gray-500 dark:text-gray-400">
OR
</span>
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div>
</div>
<button
onClick={handleGithubLogin}
disabled={isGithubLoading}
type="button"
className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg font-semibold text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:bg-gray-100 dark:disabled:bg-gray-800 disabled:cursor-not-allowed transition-colors cursor-pointer"
>
<GithubOutlined className="text-xl" />
<span>
{isGithubLoading ? 'Connecting...' : 'Sign up with GitHub'}
</span>
</button>
<p className="mt-3 text-xs text-center text-gray-500 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"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
>
Sign in
</Link>
</p>
</div>
<div className="mt-6 text-center">
<p className="text-gray-500 dark:text-gray-500 text-xs">
By signing up, you agree to our Terms of Service and Privacy Policy
</p>
</div>
</div>
</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,46 +0,0 @@
import { FC, ReactElement, useState } from 'react';
import { Outlet } from 'react-router';
import { Sidebar } from '../../components/sidebar';
const DashboardLayout: FC = (): ReactElement => {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex-1 flex flex-col">
{/* Mobile Header with Hamburger */}
<div className="lg:hidden sticky top-0 bg-white dark:bg-gray-900 border-b dark:border-gray-700 px-4 py-3 flex items-center">
<button
onClick={() => setSidebarOpen(true)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<svg
className="w-6 h-6 text-gray-600 dark:text-gray-400"
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="ml-3 text-lg font-bold text-gray-900 dark:text-white">
Hackathon
</h1>
</div>
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
</div>
);
};
export default DashboardLayout;
-486
View File
@@ -1,486 +0,0 @@
import { FC, ReactElement, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } 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;
team: {
id?: string;
name?: string;
logo?: string;
banner?: string;
description?: string;
city?: string;
visibility?: string;
leader_id?: string;
};
inviter: {
id?: string;
fullname?: string;
email?: string;
avatar?: string;
};
};
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) {
const originalOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = originalOverflow || '';
};
}
}, [showProfileModal]);
const { data: teamsData } = useMyTeams();
const { data: winnersResponse } = useWinners();
const { data: invitationsData } = useMyInvitations();
const { mutateAsync: respondToInvitation } = useRespondToInvitation();
const user = session?.user;
const myTeams = teamsData?.data || [];
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' });
toast.success('Invitation accepted! You are now a team member.');
} catch (error) {
console.error('Failed to accept invitation:', error);
toast.error('Failed to accept invitation');
}
};
const handleRejectInvitation = async (invitationId: string) => {
try {
await respondToInvitation({ invitationId, action: 'reject' });
toast.success('Invitation declined');
} catch (error) {
console.error('Failed to reject invitation:', error);
toast.error('Failed to decline invitation');
}
};
return (
<>
<div className="p-6 md:p-8 md:max-w-7xl w-full mx-auto">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Welcome, {user?.fullname || user?.email?.split('@')[0] || 'User'}!
</h1>
{user?.location && (
<p className="text-gray-600 dark:text-gray-400 mt-1 font-sans flex items-center space-x-1">
<Icon icon="heroicons:map-pin-16-solid" width="24" height="24" />
<span>{user.location}</span>
</p>
)}
</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
key={invitation.id}
className="bg-white dark:bg-gray-900 p-4 rounded-lg shadow-sm flex items-center justify-between"
>
<div>
<p className="font-medium text-gray-900 dark:text-white">
{invitation.team?.name ?? 'Unnamed Team'}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
Invited by{' '}
{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>
))}
</div>
</div>
)}
{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>
<Link
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"
>
<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 ? (
<img
src={team.logo}
alt={team.name}
className="w-16 h-16 rounded-full object-cover shrink-0"
/>
) : (
<div className="w-16 h-16 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0">
<Icon
icon="mdi:account-group"
className="text-gray-500 dark:text-gray-400 text-2xl"
/>
</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">
{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>
</div>
</div>
{team.description && (
<p className="text-gray-600 dark:text-gray-400 line-clamp-3 font-sans">
{team.description}
</p>
)}
</div>
</Link>
</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">
<div className="text-3xl md:text-4xl mb-2 md:mb-3">👋</div>
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-1 md:mb-2">
You are not in a team yet
</h2>
<p className="text-sm md:text-base text-gray-600 dark:text-gray-400 font-sans">
Use the sidebar to browse teams or create your own
</p>
</div>
</div>
)}
<div className="mt-8 bg-white dark:bg-gray-900 rounded-xl shadow-lg">
<div className="bg-linear-to-r from-primary-600 to-primary-500 h-24 rounded-t-xl"></div>
<div className="px-4 md:px-8 pb-4 md:pb-8 max-w-7xl">
<div className="flex flex-col md:flex-row md:items-start -mt-12 mb-4 md:mb-6">
<div className="flex flex-col md:flex-row items-start">
{user?.avatar ? (
<img
src={user.avatar}
alt={user.fullname || 'User'}
className="w-20 h-20 md:w-24 md:h-24 rounded-full border-4 border-white dark:border-gray-700 shadow-lg object-cover"
/>
) : (
<div className="w-20 h-20 md:w-24 md:h-24 rounded-full border-4 border-white dark:border-gray-700 shadow-lg bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span className="text-gray-400 dark:text-gray-500 text-3xl md:text-4xl font-sans">
U
</span>
</div>
)}
<div className="md:ml-6 mt-4 md:mt-14">
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white">
{user?.fullname ||
user?.email?.split('@')[0] ||
'Unnamed User'}
</h2>
{user?.location ? (
<p className="text-gray-600 dark:text-gray-400 flex items-center mt-1 text-sm md:text-base font-sans">
<Icon
icon="heroicons:map-pin-16-solid"
width="16"
height="16"
/>
<span className="ml-1">{user.location}</span>
</p>
) : (
<Link
to="/onboarding/user"
className="text-primary-500 dark:text-blue-400 hover:text-primary-600 dark:hover:text-blue-300 text-sm md:text-sm mt-1 flex items-center"
>
<span className="font-sans">Complete your profile</span>
<Icon
icon="ic:baseline-chevron-right"
width="24"
height="24"
/>
</Link>
)}
</div>
</div>
{user?.location && (
<button
type="button"
onClick={() => setShowProfileModal(true)}
className="mt-3 md:mt-14 md:ml-auto inline-flex items-center justify-center px-3 py-1.5 md:px-4 md:py-2 bg-primary-500 text-white rounded-lg text md:text-sm font-medium shadow-sm hover:bg-primary-600 transition-colors w-full md:w-auto cursor-pointer"
>
Edit Profile
</button>
)}
</div>
<div className="space-y-4 md:space-y-6 w-full max-w-full">
{user?.bio && (
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm w-full max-w-full overflow-x-hidden">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
About
</h3>
<p className="text-gray-800 dark:text-gray-200 leading-relaxed line-clamp-3 wrap-break-word break-all overflow-hidden w-full max-w-full">
{user.bio}
</p>
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm">
<h3 className="font-semibold text-gray-700 dark:text-gray-300 tracking-wide mb-3">
Contact
</h3>
<div className="flex items-center text-gray-700 dark:text-gray-300">
<span className="text-gray-900 dark:text-white">
{user?.email}
</span>
</div>
</div>
{user?.skills && user.skills.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm">
<h3 className="font-semibold text-gray-700 dark:text-gray-300 tracking-wide mb-3">
Skills
</h3>
<div className="flex flex-wrap gap-2">
{user.skills.map((skill: string) => (
<span
key={skill}
className="inline-flex items-center px-4 py-2 border border-primary-600 dark:border-gray-500 text-primary-600 dark:text-white rounded-4xl text-xs font-medium dark:bg-gray-600"
>
{skill}
</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
<ProfilePage
open={showProfileModal}
onClose={() => setShowProfileModal(false)}
/>
</>
);
};
export default DashboardPage;
-29
View File
@@ -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>
);
}
-108
View File
@@ -1,108 +0,0 @@
import {
Outlet,
ScrollRestoration,
useLocation,
useNavigate,
} from 'react-router-dom';
import { useEffect, useState } from 'react';
import { useAuthStore, useUserMe } from '@imphnen-frontend-service/service';
// Define onboarding routes
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(() => {
const checkAuth = async () => {
const pathname = location.pathname;
// Allow hackathon pages without checks
if (pathname.startsWith('/hackathons')) {
setIsChecking(false);
return;
}
// Allow auth callback without checks
if (pathname === '/auth/callback') {
setIsChecking(false);
return;
}
// Public auth pages - allow unauthenticated access
if (pathname.startsWith('/auth')) {
// 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
setIsChecking(false);
return;
}
// Home page - allow everyone to view the landing page
if (pathname === '/') {
setIsChecking(false);
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 });
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;
if (!hasLocation) {
navigate('/onboarding/user', { replace: true });
setIsChecking(false);
return;
}
}
setIsChecking(false);
};
checkAuth();
}, [location.pathname, navigate, session, userData, isUserLoading]);
// Show loading state while checking auth
if (isChecking) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-neutral-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-neutral-400">Loading...</p>
</div>
</div>
);
}
return (
<>
<Outlet />
<ScrollRestoration />
</>
);
}
@@ -1,23 +0,0 @@
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">
<div className="text-center">
<h1 className="text-6xl font-bold text-yellow-600 mb-4">🚧</h1>
<h2 className="text-3xl font-semibold mb-2">We'll be back soon!</h2>
<p className="text-gray-600">
Our site is currently undergoing scheduled maintenance.
<br />
Thank you for your patience.
</p>
<Link
to="/"
className="mt-4 inline-block text-primary-600 hover:underline"
>
Back to Homepage
</Link>
</div>
</div>
);
}
@@ -1,283 +0,0 @@
import { FC, ReactElement, useState, useEffect } from 'react';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate } from 'react-router';
import { useForm, Controller } from 'react-hook-form';
import {
userOnboardingSchema,
TUserOnboardingForm,
useUpdateUserMe,
useUploadAvatar,
useUserMe,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { CitySelect } from '../../../components/city-select';
import { Icon } from '@iconify/react';
const ROLE_OPTIONS = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
];
const UserOnboardingPage: FC = (): ReactElement => {
const navigate = useNavigate();
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string>('');
const { data: userData } = useUserMe();
const { mutateAsync: updateUser, isPending: isUpdating } = useUpdateUserMe();
const { mutateAsync: uploadAvatar, isPending: isUploading } =
useUploadAvatar();
const { session } = useAuthStore();
const form = useForm<TUserOnboardingForm>({
resolver: zodResolver(userOnboardingSchema),
mode: 'all',
defaultValues: {
fullname: session?.user?.fullname || userData?.data?.fullname || '',
location: session?.user?.location || '',
bio: session?.user?.bio || '',
skills: session?.user?.skills || [],
},
});
// Set initial avatar preview from GitHub avatar if available
useEffect(() => {
if (session?.user?.avatar && !avatarPreview) {
setAvatarPreview(session.user.avatar);
}
}, [session?.user?.avatar, avatarPreview]);
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 = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const onSubmit = form.handleSubmit(async (data) => {
try {
let avatarUrl = session?.user?.avatar || null;
// Upload avatar if a new file was selected
if (avatarFile) {
const uploadResult = await uploadAvatar(avatarFile);
avatarUrl = uploadResult.data.url;
}
// Update user in Supabase
await updateUser({
fullname: data.fullname,
avatar: avatarUrl,
location: data.location,
bio: data.bio,
skills: data.skills,
});
// Wait a bit for the onSuccess handler to update localStorage
// The updateUser mutation's onSuccess handler updates the Zustand store and localStorage
await new Promise((resolve) => setTimeout(resolve, 100));
// Use window.location for a full page reload to ensure middleware sees updated localStorage
globalThis.location.href = '/dashboard';
} catch (error) {
toast.error(
error instanceof Error
? error.message
: 'Onboarding failed. Please try again.'
);
}
});
return (
<div className="flex flex-col justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 px-4 py-8">
<div className="bg-white dark:bg-gray-900 w-full max-w-2xl p-8 rounded-xl shadow-lg dark:shadow-gray-950/50">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Complete Your Profile
</h1>
<p className="text-gray-600 font-sans dark:text-gray-400">
Tell us more about yourself to get started
</p>
</div>
<form onSubmit={onSubmit} className="space-y-6">
{/* Avatar Upload */}
<div className="flex flex-col items-center space-y-4">
<div className="relative">
{avatarPreview ? (
<img
src={avatarPreview}
alt="Avatar preview"
className="w-32 h-32 rounded-full object-cover border-4 border-gray-200 dark:border-gray-700"
/>
) : (
<div className="w-32 h-32 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<Icon
icon="ic:baseline-person"
width="48"
height="48"
className="text-gray-400 dark:text-gray-500"
/>
</div>
)}
</div>
<div className="flex flex-col items-center">
<label htmlFor="avatar" className="cursor-pointer">
<span className="px-4 py-2 bg-primary-500 dark:bg-primary-600 text-white rounded-lg hover:bg-primary-600 dark:hover:bg-primary-700 inline-block">
{avatarPreview ? 'Change Photo' : 'Upload Photo'}
</span>
<input
id="avatar"
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarChange}
/>
</label>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2 text-center">
Optional, but highly recommended. Max 2MB
</p>
</div>
</div>
{/* Full Name */}
<ControlledInputField
control={form.control}
label="Full Name"
placeholder="Enter your full name"
name="fullname"
className="px-3 py-2 text-label2 rounded-lg max-h-auto text-base"
size="md"
isRequired={true}
/>
{/* City */}
<div className="space-y-2">
<label className="block text-base font-medium text-gray-700 dark:text-gray-300">
City <span className="text-red-500">*</span>
</label>
<Controller
control={form.control}
name="location"
render={({ field, fieldState }) => (
<CitySelect
value={field.value}
onChange={field.onChange}
error={fieldState.error?.message}
placeholder="Search your city..."
/>
)}
/>
</div>
{/* Role/Skills */}
<div className="space-y-2">
<label className="block text-base font-medium text-gray-700 dark:text-gray-300">
Role / Skills
</label>
<Controller
control={form.control}
name="skills"
render={({ field }) => (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((role) => (
<label
key={role}
className="flex items-center space-x-2 cursor-pointer"
>
<input
type="checkbox"
checked={field.value?.includes(role)}
onChange={(e) => {
const newValue = e.target.checked
? [...(field.value || []), role]
: (field.value || []).filter((v) => v !== role);
field.onChange(newValue);
}}
className="rounded border-gray-300 dark:border-gray-600 text-blue-600 dark:text-primary-500 focus:ring-blue-500 dark:focus:ring-primary-500 dark:bg-gray-800"
/>
<span className="text-sm dark:text-gray-300">
{role}
</span>
</label>
))}
</div>
</div>
)}
/>
</div>
{/* Bio */}
<div className="space-y-2">
<label className="block text-base font-medium text-gray-700 dark:text-gray-300">
Bio{' '}
<span className="text-gray-400 dark:text-gray-500">
(Optional)
</span>
</label>
<Controller
control={form.control}
name="bio"
render={({ field, fieldState }) => (
<div className="flex">
<Textarea
{...field}
placeholder="Tell us about yourself..."
rows={4}
className="w-full rounded-lg text-sm"
style={{ resize: 'vertical' }}
/>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">
{fieldState.error.message}
</p>
)}
</div>
)}
/>
</div>
<Button
className="w-full"
type="submit"
disabled={!form.formState.isValid || isUpdating || isUploading}
>
{isUpdating || isUploading ? 'Saving...' : 'Complete Setup'}
</Button>
</form>
</div>
</div>
);
};
export default UserOnboardingPage;
-869
View File
@@ -1,869 +0,0 @@
import { useNavigate, Link } from 'react-router';
import { useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Icon } from '@iconify/react';
import { ThemeToggle } from '../components/theme-toggle';
import { useAuthStore } from '@imphnen-frontend-service/service';
export default function HomePage() {
const navigate = useNavigate();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [openFaq, setOpenFaq] = useState<number | null>(0);
const { session } = useAuthStore();
const isAuthenticated = !!session?.token;
const faqs = [
{
question: 'Siapa yang bisa mengikuti hackathon ini?',
answer:
'Hackathon ini terbuka untuk mahasiswa, fresh graduate, dan profesional muda yang memiliki passion di bidang teknologi. Peserta dapat mendaftar secara tim.',
},
{
question: 'Apakah ada biaya pendaftaran?',
answer:
'Tidak, hackathon ini GRATIS dan terbuka untuk semua peserta yang memenuhi kriteria.',
},
{
question: 'Apa tema hackathon kali ini?',
answer:
'Tema hackathon kali ini adalah "Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif".',
},
{
question: 'Bagaimana format pelaksanaannya?',
answer:
'Hackathon dilaksanakan secara online dengan berbagai tahap mulai dari pendaftaran, technical meeting, tahap penyisihan, hingga final.',
},
{
question: 'Apa hadiah lombanya?',
answer:
'Total hadiah senilai Rp14.500.000 dengan juara 1 mendapat Rp6.000.000, juara 2 Rp4.000.000, juara 3 Rp2.500.000, dan juara kategori lainnya Rp2.000.000.',
},
{
question: 'Apakah harus membentuk tim? Boleh solo?',
answer:
'Tidak boleh solo. Peserta harus membentuk tim yang terdiri dari 2 - 5 orang per tim. Peserta bisa mencari anggota melalui website ini atau WA Group Hackathon.',
},
{
question: 'Apakah boleh menggunakan AI (vibe coding)?',
answer:
'Ya, peserta diperbolehkan menggunakan AI tools untuk membantu development. Kami merekomendasikan menggunakan Kolosal.ai selama proses development (Free Credit)',
},
{
question: 'Apa project wajib di-deploy?',
answer:
'Diusahakan project agar di-deploy dan dapat diakses secara online untuk meningkatkan penilaian.',
},
{
question: 'Apakah peserta mendapatkan sertifikat?',
answer:
'Kami akan memberikan sertifikat kepada tim yang submit project dan menyelesaikan rangkaian hackathon.',
},
{
question: 'Website error dan terjadi masalah?',
answer:
'Jika menemukan masalah teknis, silakan hubungi kami melalui grup WA Hackathon.',
},
];
return (
<main className="min-h-screen bg-white dark:bg-gray-950">
{/* Navigation */}
<div id="#top" className="hidden"></div>
<nav className="border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 sticky top-0 z-50">
<div className="flex items-center justify-between max-w-7xl mx-auto px-4 md:px-8 py-4">
<div className="flex items-center gap-2">
<span className="text-lg md:text-xl font-bold dark:text-white">
IMPHNEN
</span>
<a
href="#top"
className="text-lg md:text-xl font-bold text-primary-500 hover:cursor-pointer"
>
Hackathon
</a>
</div>
{/* Desktop Menu */}
<div className="hidden md:flex text-label1 items-center gap-4 lg:gap-8">
<a
href="#timeline"
className="text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white transition-colors"
>
Timeline
</a>
<a
href="#hadiah"
className="text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white transition-colors"
>
Hadiah
</a>
<a
href="#faq"
className="text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white transition-colors"
>
FAQ
</a>
<ThemeToggle />
{isAuthenticated ? (
<Button
onClick={() => navigate('/dashboard')}
size="sm"
className="rounded-lg text-base"
>
Dashboard
</Button>
) : (
<>
<Button
onClick={() => navigate('/auth/login')}
size="sm"
variant="bordered"
className="rounded-lg text-base dark:bg-gray-800"
>
Masuk
</Button>
<Button
onClick={() => navigate('/auth/signup')}
size="sm"
className="rounded-lg text-base"
>
Daftar Sekarang
</Button>
</>
)}
</div>
{/* Mobile Menu Button */}
<div className="flex items-center gap-2 md:hidden">
<ThemeToggle />
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="p-2 cursor-pointer text-gray-900 dark:text-white"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
</div>
</div>
</nav>
{/* Mobile Menu */}
{mobileMenuOpen && (
<nav className="md:hidden sticky top-18 border-b border-gray-200 dark:border-gray-800 bg-white z-40 dark:bg-gray-900">
<div className="flex flex-col items-start gap-4 px-4 py-4">
<a
href="#timeline"
className="ms-3 text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white"
>
Timeline
</a>
<a
href="#hadiah"
className="ms-3 text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white"
>
Hadiah
</a>
<a
href="#faq"
className="ms-3 text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white"
>
FAQ
</a>
{isAuthenticated ? (
<button
onClick={() => navigate('/dashboard')}
className="px-4 py-2 bg-primary-500 text-white text-base rounded-lg hover:bg-primary-600 transition-colors text-center cursor-pointer"
>
Dashboard
</button>
) : (
<>
<button
onClick={() => navigate('/auth/login')}
className="ms-3 text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white cursor-pointer"
>
Masuk
</button>
<button
onClick={() => navigate('/auth/login')}
className="px-4 py-2 bg-primary-500 text-white text-base rounded-lg hover:bg-primary-600 transition-colors text-center cursor-pointer"
>
Daftar Sekarang
</button>
</>
)}
</div>
</nav>
)}
{/* Hero Section */}
<section className="relative w-full overflow-hidden py-20">
<div className="absolute inset-0 overflow-hidden">
<div
className="absolute top-1/4 -left-20 w-100 h-100 rounded-full bg-linear-to-r from-primary/20 to-blue-400/20 blur-3xl"
style={{ transform: 'translate(10px, -5px)', opacity: 0.9 }}
></div>
<div
className="absolute bottom-1/3 -right-20 w-100 h-100 rounded-full bg-linear-to-r from-blue-400/20 to-primary/20 blur-3xl"
style={{ transform: 'translate(0px, 0px)', opacity: 1 }}
></div>
<div className="absolute inset-0 bg-[linear-gradient(rgba(59,130,246,0.05)_1px,transparent_1px),linear-gradient(to_right,rgba(59,130,246,0.05)_1px,transparent_1px)] dark:bg-[linear-gradient(rgba(59,130,246,0.1)_1px,transparent_1px),linear-gradient(to_right,rgba(59,130,246,0.1)_1px,transparent_1px)] bg-size-[40px_40px]"></div>
</div>
<div className="mx-auto container px-4 relative flex flex-col items-center">
{/* Logos */}
<div className="flex items-center gap-4 md:gap-8 lg:gap-12 mb-8 md:mb-12 lg:mb-16 flex-wrap justify-center">
<div className="flex items-center">
<img
src="images/imphnen-logo.svg"
alt="IMPHNEN"
className="h-12 md:h-16"
/>
</div>
<span className="text-3xl md:text-5xl font-bold text-gray-400 dark:text-gray-500">
×
</span>
<div className="flex items-center">
<img
src="images/sponsors/kolosal-logo_rlxbck.svg"
alt="Kolosal.ai"
className="h-8 md:h-12 dark:invert"
/>
</div>
</div>
{/* Title */}
<h1 className="text-h1 font-bold text-gray-900 dark:text-white mb-4 text-center">
Hackathon
</h1>
{/* Subtitle */}
<p className="text-p1 text-primary-500 font-semibold mb-6 md:mb-8 text-center px-4">
"Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif"
</p>
{/* Description */}
<p className="text-p3 text-gray-600 dark:text-gray-200 max-w-lg md:max-w-xl text-center mb-8 md:mb-12 px-4 font-sans">
Kompetisi pengembangan teknologi untuk menciptakan solusi inovatif
yang menghadirkan dampak nyata
</p>
{/* Status */}
<div className="flex flex-col md:flex-row items-center gap-4 md:gap-8 mb-10 md:mb-16 text-base text-gray-600 dark:text-gray-200">
<div className="flex items-center gap-2 md:gap-3">
<Icon icon="streamline-plump:web" className="w-4 h-4" />
<span>Online</span>
</div>
<div className="flex items-center gap-2 md:gap-3 text-center">
<Icon icon="heroicons:clock" className="w-4 h-4" />
<span>Pendaftaran hingga 30 November 2025</span>
</div>
</div>
{/* CTA Buttons */}
<div className="flex flex-col md:flex-row items-center gap-4 px-4">
<Button
onClick={() => navigate('/auth/signup')}
className="rounded-lg text-base max-h-auto"
>
Daftar Sekarang
</Button>
<a
href="https://chat.whatsapp.com/BlxrYh9uSC37d7VPhJslGL"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center px-4 py-2.5 border-2 border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500 transition-colors text-center bg-transparent hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-gray-800 text-base text-gray-600 dark:text-gray-200 dark:bg-gray-800 rounded-lg font-bai-jamjuree font-semibold"
>
Gabung Grup WA Hackathon
</a>
</div>
{/* Scroll indicator */}
<div className="mt-12 md:mt-20">
<svg
className="w-6 h-6 text-gray-400 dark:text-gray-500 animate-bounce"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
</div>
</div>
</section>
{/* About Section */}
<section className="py-16 md:py-24 px-4 md:px-8 bg-white dark:bg-gray-950">
<div className="max-w-4xl mx-auto text-center">
<h2 className="text-3xl md:text-5xl font-bold mb-10 dark:text-white">
Tentang <span className="text-primary-500">Hackathon</span>
</h2>
<p className="font-sans text-lg md:text-xl text-left md:text-center text-gray-600 dark:text-gray-200 mb-6">
<span className="font-semibold text-gray-900 dark:text-white">
Hackathon
</span>{' '}
adalah kompetisi pengembangan teknologi yang mengajak talenta muda
untuk berkolaborasi dalam menciptakan solusi inovatif.
</p>
<p className="font-sans text-lg md:text-xl text-left md:text-center text-gray-600 dark:text-gray-200">
IMPHNEN bersama Kolosal.ai mengadakan Hackathon dengan tema lomba{' '}
<span className="font-semibold text-primary-500">
"Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif"
</span>
.
</p>
</div>
</section>
{/* Prizes Section */}
<section
id="hadiah"
className="py-16 md:py-24 px-4 md:px-8 bg-gray-50 dark:bg-linear-to-b dark:from-gray-950 dark:to-gray-900"
>
<div className="max-w-lg md:max-w-6xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-5xl font-bold mb-4 font-bai-jamjuree dark:text-white">
Hadiah <span className="text-primary-500">Menarik</span>
</h2>
<p className="text-xl md:text-2xl text-primary-500 font-semibold font-sans">
Total Prize Pool Rp14.500.000
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Prize 1 */}
<div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-primary-500 dark:hover:border-primary-500 transition-colors">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center">
<Icon
icon="ic:round-star"
className="h-8 w-8 text-primary-500"
/>
</div>
</div>
<h3 className="text-xl font-bold text-center mb-2 dark:text-white">
Juara 1
</h3>
<p className="text-2xl font-bold text-primary-500 text-center font-sans">
Rp6.000.000
</p>
</div>
{/* Prize 2 */}
<div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-gray-500 dark:hover:border-gray-500 transition-colors">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center">
<Icon
icon="ic:round-star"
className="h-8 w-8 text-gray-600 dark:text-gray-200"
/>
</div>
</div>
<h3 className="text-xl font-bold text-center mb-2 dark:text-white">
Juara 2
</h3>
<p className="text-2xl font-bold text-primary-500 text-center font-sans">
Rp4.000.000
</p>
</div>
{/* Prize 3 */}
<div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-orange-300 dark:hover:border-orange-500 transition-colors">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center">
<Icon
icon="ic:round-star"
className="h-8 w-8 text-orange-600 dark:text-orange-500"
/>
</div>
</div>
<h3 className="text-xl font-bold text-center mb-2 dark:text-white">
Juara 3
</h3>
<p className="text-2xl font-bold text-orange-600 dark:text-orange-500 text-center font-sans">
Rp2.500.000
</p>
</div>
{/* Special Prize */}
<div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-purple-300 dark:hover:border-purple-500 transition-colors">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center">
<Icon
icon="ic:round-star"
className="h-8 w-8 text-purple-600 dark:text-purple-500"
/>
</div>
</div>
<h3 className="text-xl font-bold text-center mb-2 dark:text-white">
Juara Kategori Lainnya
</h3>
<p className="text-2xl font-bold text-purple-600 dark:text-purple-500 text-center font-sans">
Rp2.000.000
</p>
</div>
</div>
</div>
</section>
{/* Timeline Section */}
<section
id="timeline"
className="py-16 md:py-24 px-4 md:px-8 bg-white dark:bg-gray-950"
>
<div className="max-w-4xl mx-auto font-sans">
<div className="text-center mb-12 font-bai-jamjuree">
<h2 className="text-3xl md:text-5xl font-bold mb-4 dark:text-white">
Timeline <span className="text-primary-500">Acara</span>
</h2>
<p className="text-lg text-gray-600 dark:text-gray-200">
Jadwal lengkap pelaksanaan hackathon
</p>
</div>
<div>
{/* Timeline Item 1 */}
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
<div className="w-0.5 h-full bg-gray-300 dark:bg-gray-700"></div>
</div>
<div className="flex-1 pb-8">
<p className="text-primary-500 font-semibold mb-2">
30 November 2025
</p>
<h3 className="text-xl font-bold mb-2 dark:text-white">
Penutupan Registrasi
</h3>
<p className="text-gray-600 dark:text-gray-200">
Batas akhir pendaftaran peserta hackathon.
</p>
</div>
</div>
{/* Timeline Item 2 */}
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
<div className="w-0.5 h-full bg-gray-300 dark:bg-gray-700"></div>
</div>
<div className="flex-1 pb-8">
<p className="text-primary-500 font-semibold mb-2">
30 November 2025
</p>
<h3 className="text-xl font-bold mb-2 dark:text-white">
Technical Meeting
</h3>
<p className="text-gray-600 dark:text-gray-200">
Akan diadakan technical meeting terkait lomba melalui Google
Meet. Stay tune di grup WA Hackathon.
</p>
</div>
</div>
{/* Timeline Item 3 */}
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
<div className="w-0.5 h-full bg-gray-300 dark:bg-gray-700"></div>
</div>
<div className="flex-1 pb-8">
<p className="text-primary-500 font-semibold mb-2">
1 - 7 Desember 2025
</p>
<h3 className="text-xl font-bold mb-2 dark:text-white">
Tahap Penyisihan
</h3>
<p className="text-gray-600 dark:text-gray-200">
Peserta mengerjakan tantangan yang diberikan.
</p>
</div>
</div>
{/* Timeline Item 4 */}
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
<div className="w-0.5 h-full bg-gray-300 dark:bg-gray-700"></div>
</div>
<div className="flex-1 pb-8">
<p className="text-primary-500 font-semibold mb-2">
8 - 14 Desember 2025
</p>
<h3 className="text-xl font-bold mb-2 dark:text-white">
Penilaian & Webinar
</h3>
<p className="text-gray-600 dark:text-gray-200">
Proses penilaian oleh juri dan sesi webinar.
</p>
</div>
</div>
{/* Timeline Item 5 */}
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
</div>
<div className="flex-1">
<p className="text-primary-500 font-semibold mb-2">
15 Desember 2025
</p>
<h3 className="text-xl font-bold mb-2 dark:text-white">
Pengumuman & Final
</h3>
<p className="text-gray-600 dark:text-gray-200">
Presentasi final dan pengumuman pemenang.
</p>
</div>
</div>
</div>
</div>
</section>
{/* Judges Section */}
<section className="py-16 md:py-24 px-4 md:px-8 bg-gray-50 dark:bg-gray-900 font-sans">
<div className="md:max-w-6xl mx-auto">
<div className="text-center mb-12 font-bai-jamjuree">
<h2 className="text-3xl md:text-5xl font-bold mb-4 dark:text-white">
Dewan <span className="text-primary-500">Juri</span>
</h2>
<p className="text-lg text-gray-600 dark:text-gray-200">
Perwakilan dari IMPHNEN dan Kolosal.ai yang akan menilai karya
</p>
</div>
<div className="w-full max-w-md md:max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Judge 1 */}
<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">
Alifais Farrel Ramdhani
</h3>
<div>
<p className="text-primary-500 font-semibold mb-1">CTO</p>
<p className="text-gray-600 dark:text-gray-200">Kolosal.ai</p>
</div>
</div>
{/* 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
</h3>
<div>
<p className="text-primary-500 font-semibold mb-1">Admin</p>
<p className="text-gray-600 dark:text-gray-200">IMPHNEN</p>
</div>
</div>
{/* Judge 3 */}
<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">
Hafid Nur
</h3>
<div>
<p className="text-primary-500 font-semibold mb-1">Moderator</p>
<p className="text-gray-600 dark:text-gray-200">IMPHNEN</p>
</div>
</div>
</div>
</div>
</section>
{/* FAQ Section */}
<section
id="faq"
className="py-16 md:py-24 px-4 md:px-8 bg-white dark:bg-gray-950"
>
<div className="max-w-4xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-5xl font-bold mb-4 text-primary-500">
FAQ
</h2>
<p className="text-lg text-gray-600 dark:text-gray-200">
Pertanyaan yang Sering Diajukan
</p>
</div>
<div className="space-y-4 font-sans">
{faqs.map((faq, index) => (
<div
key={index}
className="border border-gray-200 dark:border-gray-700 rounded-lg dark:bg-gray-900"
>
<button
onClick={() => setOpenFaq(openFaq === index ? null : index)}
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
>
<span className="font-semibold text-gray-900 dark:text-white">
{faq.question}
</span>
<svg
className={`w-5 h-5 text-primary-500 transform transition-transform ${
openFaq === index ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{openFaq === index && (
<div className="px-6 pb-4 text-gray-600 dark:text-gray-200">
{faq.answer}
</div>
)}
</div>
))}
</div>
</div>
</section>
{/* Sponsors Section */}
<section className="py-20 md:py-28 px-4 md:px-8 bg-gray-50 dark:bg-gray-900">
<div className="max-w-6xl mx-auto text-center">
<h2 className="text-3xl md:text-5xl font-bold mb-4 dark:text-white">
Sponsor & <span className="text-primary-500">Partner</span>
</h2>
<p className="text-lg text-gray-600 dark:text-gray-200 mb-8">
Acara ini sepenuhnya disponsori oleh
</p>
<div className="flex justify-center">
<a
href="https://kolosal.ai"
target="_blank"
rel="noopener noreferrer"
className="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-lg inline-block border-2 border-transparent hover:border-gray-500 dark:hover:border-gray-500 transition-colors"
>
<img
src="images/sponsors/kolosal-logo_rlxbck.svg"
alt="Kolosal.ai"
className="h-12 md:h-16 dark:invert"
/>
</a>
</div>
</div>
</section>
{/* CTA Section */}
<section
id="masuk"
className="py-20 md:py-28 px-4 md:px-8 bg-linear-to-b from-white to-blue-50 dark:from-gray-950 dark:to-gray-900"
>
<div className="max-w-4xl mx-auto text-center font-sans">
<h2 className="text-3xl md:text-5xl font-bold mb-6 font-bai-jamjuree dark:text-white">
Segera Daftarkan <span className="text-primary-500">Timmu!</span>
</h2>
<p className="text-lg md:text-xl text-left md:text-center text-gray-600 dark:text-gray-200 mb-8">
Jangan lewatkan kesempatan emas untuk bersaing dengan developer
terbaik,
<br className="hidden md:block" /> belajar dari para ahli, dan
memenangkan hadiah jutaan rupiah!
</p>
<div className="flex flex-col md:flex-row gap-4 justify-center">
<button
onClick={() => navigate('/auth/signup')}
className="px-8 py-3 bg-primary-500 text-white text-lg rounded-lg hover:bg-primary-700 transition-colors font-semibold cursor-pointer"
>
Daftar Sekarang
</button>
<a
href="https://chat.whatsapp.com/BlxrYh9uSC37d7VPhJslGL"
target="_blank"
rel="noopener noreferrer"
className="px-8 py-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-lg rounded-lg border-2 border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500 transition-colors font-semibold"
>
Gabung Grup WA Hackathon
</a>
</div>
<p className="text-sm text-gray-500 dark:text-gray-500 mt-6">
Pendaftaran ditutup pada{' '}
<span className="text-primary-500 font-semibold">
30 November 2025
</span>
</p>
</div>
</section>
{/* Footer */}
<footer className="bg-gray-950 text-white py-12 px-4 md:px-8 font-sans">
<div className="max-w-6xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-8">
{/* Brand */}
<div>
<div className="flex items-center gap-2 mb-4 font-bai-jamjuree">
<span className="text-2xl font-bold">IMPHNEN</span>
<span className="text-2xl font-bold text-blue-500">
Hackathon
</span>
</div>
<p className="text-gray-400 text-sm">
Wujudkan ide brilian mu menjadi solusi nyata. Bergabunglah dalam
IMPHNEN Hackathon dan jadilah bagian dari perubahan teknologi
masa depan.
</p>
<div className="flex gap-4 mt-4">
<div className="flex gap-4">
<a
href="https://fb.com/groups/programmerhandal"
className="text-gray-400 hover:text-accent transition-colors"
target="_blank"
rel="noopener noreferrer"
aria-label="Facebook"
>
<Icon icon="ic:baseline-facebook" className="w-6 h-6" />
</a>
<a
href="https://www.instagram.com/imphnen.dev"
className="text-gray-400 hover:text-accent transition-colors"
target="_blank"
rel="noopener noreferrer"
aria-label="Instagram"
>
<Icon icon="mdi:instagram" className="w-6 h-6" />
</a>
<a
href="https://www.linkedin.com/company/imphnen"
className="text-gray-400 hover:text-accent transition-colors"
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn"
>
<Icon icon="mdi:linkedin" className="w-6 h-6" />
</a>
<a
href="https://www.tiktok.com/@imphnen"
className="text-gray-400 hover:text-accent transition-colors"
target="_blank"
rel="noopener noreferrer"
aria-label="TikTok"
>
<Icon icon="ic:baseline-tiktok" className="w-6 h-6" />
</a>
<a
href="https://github.com/IMPHNEN"
className="text-gray-400 hover:text-accent transition-colors"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
>
<Icon icon="mdi:github" className="w-6 h-6" />
</a>
</div>
</div>
</div>
{/* Quick Links */}
<div>
<h3 className="font-bold text-lg mb-4 font-bai-jamjuree">
Quick Links
</h3>
<ul className="space-y-2 text-gray-400">
<li>
<a
href="#timeline"
className="hover:text-white transition-colors"
>
Timeline
</a>
</li>
<li>
<a
href="#hadiah"
className="hover:text-white transition-colors"
>
Hadiah
</a>
</li>
<li>
<a href="#faq" className="hover:text-white transition-colors">
FAQ
</a>
</li>
<li>
<Link
to="/auth/signup"
className="hover:text-white transition-colors"
>
Daftar
</Link>
</li>
</ul>
</div>
{/* Contact */}
<div>
<h3 className="font-bold text-lg mb-4 font-bai-jamjuree">
Contact
</h3>
<ul className="space-y-2 text-gray-400 text-sm">
<li className="flex items-start gap-2">
<Icon
icon="material-symbols:mail-outline-rounded"
className="min-w-5 min-h-5 mt-0.5"
/>
<a
href="mailto:imphnen@gmail.com"
className="hover:text-white transition-colors"
>
imphnen@gmail.com
</a>
</li>
<li className="flex items-start gap-2">
<Icon
icon="solar:phone-linear"
className="min-w-5 min-h-5 mt-0.5"
/>
<a
href="https://chat.whatsapp.com/BlxrYh9uSC37d7VPhJslGL"
className="hover:text-white transition-colors"
target="_blank"
rel="noopener noreferrer"
>
WA Group Hackathon
</a>
</li>
<li className="flex items-start gap-2">
<Icon
icon="streamline-plump:web"
className="min-w-5 min-h-5 mt-0.5"
/>
<a
href="https://imphnen.dev"
className="hover:text-white transition-colors"
target="_blank"
rel="noopener noreferrer"
>
IMPHNEN.dev
</a>
</li>
</ul>
</div>
</div>
<div className="border-t border-gray-800 pt-8 text-center text-gray-400 text-sm">
<p>
© 2025 IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan
Ngoding. All rights reserved.
</p>
</div>
</div>
</footer>
</main>
);
}
-391
View File
@@ -1,391 +0,0 @@
import { FC, ReactElement, useState, useEffect } from 'react';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useForm, Controller } from 'react-hook-form';
import {
userEditProfileSchema,
TUserEditProfileForm,
useUpdateUserMe,
useUploadAvatar,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { CitySelect } from '../../components/city-select';
import { Icon } from '@iconify/react';
const ROLE_OPTIONS = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
];
type ProfileModalProps = {
open: boolean;
onClose: () => void;
};
const ProfilePage: FC<ProfileModalProps> = ({
open,
onClose,
}): ReactElement | null => {
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string>('');
const { mutateAsync: updateUser, isPending: isUpdating } = useUpdateUserMe();
const { mutateAsync: uploadAvatar, isPending: isUploading } =
useUploadAvatar();
const { session } = useAuthStore();
const form = useForm<TUserEditProfileForm>({
resolver: zodResolver(userEditProfileSchema),
mode: 'all',
defaultValues: {
fullname: session?.user?.fullname || '',
avatar: session?.user?.avatar || null,
location: session?.user?.location || '',
bio: session?.user?.bio || '',
skills: session?.user?.skills || [],
},
});
// Set initial avatar preview from current user avatar
useEffect(() => {
if (session?.user?.avatar && !avatarPreview) {
setAvatarPreview(session.user.avatar);
}
if (session?.user?.fullname) {
form.setValue('fullname', session.user.fullname);
}
if (session?.user?.location) {
form.setValue('location', session.user.location);
}
if (session?.user?.bio) {
form.setValue('bio', session.user.bio);
}
if (session?.user?.skills) {
form.setValue('skills', session.user.skills);
}
}, [
session?.user?.avatar,
session?.user?.fullname,
session?.user?.location,
session?.user?.bio,
session?.user?.skills,
avatarPreview,
form,
]);
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 = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const onSubmit = form.handleSubmit(async (data) => {
try {
let avatarUrl = session?.user?.avatar || null;
// Upload avatar if a new file was selected
if (avatarFile) {
const uploadResult = await uploadAvatar(avatarFile);
avatarUrl = uploadResult.data.url;
}
// Update user profile
await updateUser({
fullname: data.fullname,
avatar: avatarUrl,
location: data.location,
bio: data.bio,
skills: data.skills,
});
toast.success('Profile updated successfully!');
// Wait a bit for the onSuccess handler to update localStorage
await new Promise((resolve) => setTimeout(resolve, 100));
// Close modal
onClose();
} catch (error) {
console.error('Profile update failed:', error);
toast.error(
`Failed to update profile: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
});
const isLoading = isUpdating || isUploading;
return open ? (
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-md z-50 overflow-y-auto">
<div className="min-h-full flex items-center justify-center">
<div className="bg-white dark:bg-gray-900 w-full max-w-md mx-4 my-6 sm:my-8 p-8 rounded-xl border dark:boder-gray-800">
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Edit Profile
</h1>
<button
type="button"
onClick={onClose}
className="text-gray-500 dark:text-neutral-400 hover:text-gray-700 dark:hover:text-neutral-200 cursor-pointer"
aria-label="Close profile modal"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<p className="text-gray-600 font-sans dark:text-neutral-400">
Update your photo and name
</p>
</div>
<form onSubmit={onSubmit} className="space-y-6">
{/* Avatar Upload */}
<div className="flex flex-col items-center space-y-4">
<div className="relative group">
{avatarPreview ? (
<img
src={avatarPreview}
alt="Avatar preview"
className="w-24 h-24 rounded-full object-cover border-4 border-gray-200 dark:border-neutral-700 group-hover:border-blue-400 dark:group-hover:border-blue-500 transition-colors"
/>
) : (
<div className="w-24 h-24 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center group-hover:bg-gray-300 dark:group-hover:bg-gray-600 transition-colors">
<Icon
icon="ic:baseline-person"
width="48"
height="48"
className="text-gray-400 dark:text-neutral-500"
/>
</div>
)}
{/* Camera overlay */}
<label
htmlFor="avatar"
className="absolute bottom-0 right-0 bg-primary-500 text-white p-2 rounded-full cursor-pointer hover:bg-primary-600 transition-colors shadow-lg"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<input
id="avatar"
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarChange}
disabled={isLoading}
/>
</label>
</div>
<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
</p>
</div>
{/* Full Name */}
<ControlledInputField
control={form.control}
label="Full Name"
placeholder="Enter your full name"
name="fullname"
size="lg"
/>
{/* City */}
<div className="space-y-2">
<label className="block text-label1 font-medium text-neutral-800 dark:text-neutral-300">
City
</label>
<Controller
control={form.control}
name="location"
render={({ field, fieldState }) => (
<CitySelect
value={field.value ?? ''}
onChange={field.onChange}
error={fieldState.error?.message}
placeholder="Search your city..."
/>
)}
/>
</div>
{/* Role/Skills */}
<div className="space-y-2">
<label className="block text-label1 font-medium text-gray-700 dark:text-neutral-300">
Role / Skills
</label>
<Controller
control={form.control}
name="skills"
render={({ field }) => (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((role) => (
<label
key={role}
className="flex items-center space-x-2 cursor-pointer font-sans"
>
<input
type="checkbox"
checked={field.value?.includes(role)}
onChange={(e) => {
const newValue = e.target.checked
? [...(field.value || []), role]
: (field.value || []).filter((v) => v !== role);
field.onChange(newValue);
}}
className="rounded border-gray-300 dark:border-neutral-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-800"
/>
<span className="text-sm dark:text-neutral-300">
{role}
</span>
</label>
))}
</div>
</div>
)}
/>
</div>
{/* Bio */}
<div className="space-y-2">
<label className="block text-label1 font-medium text-gray-700 dark:text-neutral-300">
Bio{' '}
<span className="text-gray-400 dark:text-neutral-500">
(Optional)
</span>
</label>
<Controller
control={form.control}
name="bio"
render={({ field, fieldState }) => (
<div>
<Textarea
{...field}
placeholder="Tell us about yourself..."
rows={4}
className="w-full font-sans"
size="lg"
/>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">
{fieldState.error.message}
</p>
)}
</div>
)}
/>
</div>
{/* Action Buttons */}
<div className="flex space-x-3 pt-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={onClose}
disabled={isLoading}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={isLoading || !form.formState.isValid}
>
{isLoading ? (
<span className="flex items-center justify-center">
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Saving...
</span>
) : (
'Save'
)}
</Button>
</div>
</form>
</div>
</div>
</div>
) : null;
};
export default ProfilePage;
@@ -1,318 +0,0 @@
import { FC, ReactElement, useState, useRef, useEffect, useMemo } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router';
import {
useTeamById,
useTeamMessages,
useSendMessage,
useDeleteMessage,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
const TeamChatPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const [message, setMessage] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const { data: teamData } = useTeamById(teamId || '');
const { data: messages, isLoading } = useTeamMessages(teamId || '');
const { mutateAsync: sendMessage, isPending: isSending } = useSendMessage(
teamId || ''
);
const { mutateAsync: deleteMessage } = useDeleteMessage(teamId || '');
const team = teamData?.data;
const currentUserId = session?.user?.id;
// Message type used for UI rendering
interface ChatMessage {
id: string;
user_id: string;
user?: {
avatar?: string;
fullname?: string;
};
message: string;
created_at: string;
}
// Inline delete confirmation UI state
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
// Prepare messages: de-duplicate by id and sort by created_at
const displayMessages = useMemo(() => {
const raw: ChatMessage[] = Array.isArray(messages)
? (messages as ChatMessage[])
: [];
const seen = new Set<string>();
const dedup: ChatMessage[] = [];
for (const m of raw) {
const key = m.id ?? `${m.user_id}-${m.created_at}`;
if (!seen.has(key)) {
seen.add(key);
dedup.push(m);
}
}
dedup.sort(
(a, b) =>
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
return dedup;
}, [messages]);
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [displayMessages]);
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim() || isSending) return;
try {
await sendMessage(message.trim());
setMessage('');
} catch (error) {
console.error('Failed to send message:', error);
toast.error('Failed to send message');
}
};
const handleDeleteMessage = async (messageId: string) => {
try {
await deleteMessage(messageId);
toast.success('Message deleted');
setDeleteTargetId(null);
} catch (error) {
console.error('Failed to delete message:', error);
toast.error('Failed to delete message');
}
};
const formatTime = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const diffInMs = now.getTime() - date.getTime();
const diffInMins = Math.floor(diffInMs / 60000);
if (diffInMins < 1) return 'Just now';
if (diffInMins < 60) return `${diffInMins}m ago`;
if (diffInMins < 1440) return `${Math.floor(diffInMins / 60)}h ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
};
return (
<div className="flex flex-col h-screen bg-gray-50 dark:bg-gray-950">
{/* Header */}
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 shadow-sm dark:shadow-gray-950/50">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Team Chat
</h1>
<p className="text-gray-600 dark:text-gray-400 text-sm mt-0.5">
{team?.name}
</p>
</div>
<Button
variant="secondary"
onClick={() => navigate(`/teams/${teamId}`)}
>
Back to Team
</Button>
</div>
</div>
</div>
{/* Messages Container */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 dark:border-primary-400"></div>
</div>
) : displayMessages && displayMessages.length > 0 ? (
<div className="space-y-4">
{displayMessages.map((msg) => {
const isOwnMessage = msg.user_id === currentUserId;
const isLeader = team?.leader_id === currentUserId;
const canDelete = isOwnMessage || isLeader;
return (
<div
key={`${msg.id ?? 'message'}-${msg.created_at}`}
className={`flex ${
isOwnMessage ? 'justify-end' : 'justify-start'
}`}
>
<div
className={`flex gap-3 max-w-lg ${
isOwnMessage ? 'flex-row-reverse' : 'flex-row'
}`}
>
{/* Avatar */}
<div className="shrink-0">
{msg.user?.avatar ? (
<img
src={msg.user.avatar}
alt={msg.user.fullname}
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<div className="w-10 h-10 rounded-full bg-blue-600 flex items-center justify-center text-white font-semibold">
{msg.user?.fullname?.charAt(0) || '?'}
</div>
)}
</div>
{/* Message Bubble */}
<div className={`flex-1 text-left`}>
<div
className={`inline-block ${
isOwnMessage ? 'items-end' : 'items-start'
}`}
>
<div className="flex items-baseline gap-2 mb-1">
<span className="font-semibold text-sm text-gray-900 dark:text-white">
{isOwnMessage ? 'You' : msg.user?.fullname}
</span>
<span className="text-xs text-gray-500 dark:text-gray-500">
{formatTime(msg.created_at)}
</span>
</div>
<div
className={`relative group rounded-2xl px-4 py-2.5 ${
isOwnMessage
? 'bg-blue-600 dark:bg-primary-600 text-white'
: 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white border border-gray-200 dark:border-gray-700'
}`}
>
<p className="text-sm whitespace-pre-wrap wrap-break-word font-sans">
{msg.message}
</p>
{/* Delete button */}
{canDelete && (
<button
onClick={() =>
setDeleteTargetId(
deleteTargetId === msg.id ? null : msg.id
)
}
className={`absolute top-1 ${
isOwnMessage ? 'left-1' : 'right-1'
} opacity-0 hover:opacity-100 p-1 rounded hover:bg-gray-200 dark:hover:bg-neutral-700 cursor-pointer ${
isOwnMessage
? 'hover:bg-blue-700 dark:hover:bg-primary-700'
: ''
}`}
title="Delete message"
>
{/* Icon trash */}
<Icon
icon="mdi:trash-can-outline"
className="w-4 h-4"
/>
</button>
)}
</div>
{canDelete && deleteTargetId === msg.id && (
<div
className={`mt-2 flex gap-2 ${
isOwnMessage ? 'justify-end' : 'justify-start'
}`}
>
<button
onClick={() => handleDeleteMessage(msg.id)}
className="px-2 py-1 text-xs rounded bg-red-600 text-white hover:bg-red-700 cursor-pointer"
>
Delete
</button>
<button
onClick={() => setDeleteTargetId(null)}
className="px-2 py-1 text-xs rounded bg-gray-200 text-gray-800 hover:bg-gray-300 dark:bg-neutral-700 dark:text-white dark:hover:bg-neutral-600 cursor-pointer"
>
Cancel
</button>
</div>
)}
</div>
</div>
</div>
</div>
);
})}
<div ref={messagesEndRef} />
</div>
) : (
<div className="flex flex-col items-center justify-center h-64 text-center">
<Icon
icon="mdi:chat-outline"
className="text-6xl mb-4 text-gray-400"
/>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
No messages yet
</h3>
<p className="text-gray-600 dark:text-gray-400 font-sans">
Be the first to start the conversation!
</p>
</div>
)}
</div>
</div>
{/* Message Input */}
<div className="bg-white dark:bg-gray-900 border-t dark:border-gray-700 shadow-lg dark:shadow-gray-950/50">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<form onSubmit={handleSendMessage} className="flex gap-3">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..."
className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-primary-500 focus:border-transparent"
disabled={isSending}
/>
<Button
type="submit"
disabled={!message.trim() || isSending}
className="px-6 py-3"
>
{isSending ? (
<div className="flex items-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Sending...
</div>
) : (
<div className="flex items-center gap-2">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
/>
</svg>
Send
</div>
)}
</Button>
</form>
</div>
</div>
</div>
);
};
export default TeamChatPage;
@@ -1,395 +0,0 @@
import { FC, ReactElement, useState, useEffect } 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';
import { useForm, Controller } from 'react-hook-form';
import {
teamUpdateSchema,
TTeamUpdateForm,
useUpdateTeam,
useTeamById,
ETeamVisibility,
useUploadFile,
useAuthStore,
} from '@imphnen-frontend-service/service';
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 }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string>('');
const [bannerFile, setBannerFile] = useState<File | null>(null);
const [bannerPreview, setBannerPreview] = useState<string>('');
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
teamId || ''
);
const { mutateAsync: updateTeam, isPending: isUpdating } = useUpdateTeam(
teamId || ''
);
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const team = teamData?.data;
const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id;
const form = useForm<TTeamUpdateForm>({
resolver: zodResolver(teamUpdateSchema),
mode: 'all',
});
useEffect(() => {
if (team) {
form.reset({
name: team.name,
description: team.description,
city: team.city,
visibility: team.visibility,
logo: team.logo,
banner: team.banner,
});
if (team.logo) setLogoPreview(team.logo);
if (team.banner) setBannerPreview(team.banner);
}
}, [team, form]);
if (isLoadingTeam) {
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 team...</div>
</div>
);
}
if (!isLeader) {
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">
Access Denied
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Only the team leader can edit team information
</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team
</Button>
</div>
);
}
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 = () => {
setLogoPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
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 = () => {
setBannerPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const onSubmit = form.handleSubmit(async (data) => {
try {
let logoUrl = data.logo;
let bannerUrl = data.banner;
if (logoFile) {
const logoResult = await uploadFile(logoFile);
logoUrl = logoResult.data.url;
}
if (bannerFile) {
const bannerResult = await uploadFile(bannerFile);
bannerUrl = bannerResult.data.url;
}
await updateTeam({
...data,
logo: logoUrl,
banner: bannerUrl,
});
navigate(`/teams/${teamId}`);
} catch (error) {
console.error('Failed to update team:', error);
}
});
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="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Edit Team Info
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Update your team details
</p>
</div>
</div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-8">
<form onSubmit={onSubmit} className="space-y-6">
{/* Banner Upload */}
<div>
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300 mb-2">
Team Banner
</label>
{bannerPreview ? (
<div
className="relative w-full"
style={{ aspectRatio: '3 / 1' }}
>
<img
src={bannerPreview}
alt="Banner preview"
className="absolute inset-0 w-full h-full object-cover rounded-lg border-2 border-gray-200 dark:border-gray-700"
/>
<button
type="button"
onClick={() => {
setBannerFile(null);
setBannerPreview('');
form.setValue('banner', null);
}}
className="absolute top-2 right-2 bg-danger-600 text-white px-3 py-1 rounded-lg text-sm hover:bg-danger-700 cursor-pointer"
>
Remove
</button>
</div>
) : (
<label className="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800">
<div className="text-center">
<p className="text-gray-500 dark:text-gray-400">
Click to upload banner
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
1200x400 recommended. Max 2MB
</p>
</div>
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleBannerChange}
/>
</label>
)}
</div>
{/* Logo Upload */}
<div>
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300 mb-2">
Team Logo
</label>
<div className="flex items-center space-x-4">
{logoPreview ? (
<img
src={logoPreview}
alt="Logo preview"
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200 dark:border-gray-700"
/>
) : (
<div className="w-24 h-24 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span className="text-gray-400 dark:text-gray-500 text-3xl">
<Icon icon="mdi:account-group" />
</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>
</div>
</div>
{/* Team Name */}
<ControlledInputField
control={form.control}
label="Team Name"
placeholder="Enter team name"
name="name"
size="lg"
isRequired={true}
/>
{/* City */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
City
</label>
<Controller
control={form.control}
name="city"
render={({ field, fieldState }) => (
<CitySelect
value={field.value || ''}
onChange={field.onChange}
error={fieldState.error?.message}
placeholder="Search your city..."
/>
)}
/>
</div>
{/* Description */}
<div className="space-y-2">
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300">
Description
</label>
<Controller
control={form.control}
name="description"
render={({ field, fieldState }) => (
<div>
<Textarea
{...field}
value={field.value || ''}
placeholder="Tell others about your team..."
rows={4}
className="w-full"
size="lg"
/>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">
{fieldState.error.message}
</p>
)}
</div>
)}
/>
</div>
{/* Visibility */}
<div className="space-y-2">
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300">
Team Visibility
</label>
<Controller
control={form.control}
name="visibility"
render={({ field }) => (
<div className="space-y-3">
<label className="flex items-start space-x-3 cursor-pointer border dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800">
<input
type="radio"
{...field}
value={ETeamVisibility.PUBLIC}
checked={field.value === ETeamVisibility.PUBLIC}
className="mt-1"
/>
<div>
<p className="font-medium text-gray-900 dark:text-white">
Public
</p>
<p className="text-sm text-gray-600 dark:text-gray-400 font-sans">
Team will be visible in Browse Teams
</p>
</div>
</label>
<label className="flex items-start space-x-3 cursor-pointer border dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800">
<input
type="radio"
{...field}
value={ETeamVisibility.PRIVATE}
checked={field.value === ETeamVisibility.PRIVATE}
className="mt-1"
/>
<div>
<p className="font-medium text-gray-900 dark:text-white">
Private
</p>
<p className="text-sm text-gray-600 dark:text-gray-400 font-sans">
Team hidden, invite-only
</p>
</div>
</label>
</div>
)}
/>
</div>
{/* Submit Button */}
<div className="flex space-x-3 pt-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => navigate(`/teams/${teamId}`)}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={isUpdating || isUploading}
>
{isUpdating || isUploading ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</div>
</div>
</div>
);
};
export default EditTeamPage;
@@ -1,46 +0,0 @@
import { FC, ReactElement, useState } from 'react';
import { Outlet } from 'react-router';
import { Sidebar } from '../../../components/sidebar';
const TeamDetailLayout: FC = (): ReactElement => {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex-1 flex flex-col">
{/* Mobile Header with Hamburger */}
<div className="lg:hidden sticky top-0 z-30 bg-white dark:bg-gray-900 border-b dark:border-gray-700 px-4 py-3 flex items-center">
<button
onClick={() => setSidebarOpen(true)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<svg
className="w-6 h-6 text-gray-600 dark:text-gray-400"
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="ml-3 text-lg font-bold text-gray-900 dark:text-white">
Hackathon
</h1>
</div>
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
</div>
);
};
export default TeamDetailLayout;
@@ -1,387 +0,0 @@
import { FC, ReactElement, useState } from 'react';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router';
import {
useTeamById,
useTeamMembers,
useTeamJoinRequests,
useInviteMember,
useRemoveMember,
useRespondToJoinRequest,
ETeamMemberStatus,
inviteMemberSchema,
TInviteMemberForm,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
const ManageMembersPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const [showInviteModal, setShowInviteModal] = useState(false);
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
teamId || ''
);
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(
teamId || ''
);
const { data: joinRequestsData } = useTeamJoinRequests(teamId || '');
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(
teamId || ''
);
const { mutateAsync: removeMember, isPending: isRemoving } = useRemoveMember(
teamId || ''
);
const { mutateAsync: respondToRequest, isPending: isResponding } =
useRespondToJoinRequest(teamId || '');
const team = teamData?.data;
const members = membersData?.data || [];
const joinRequests = Array.isArray(joinRequestsData?.data)
? joinRequestsData.data
: [];
const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id;
const hasSubmission = team?.has_submission;
const form = useForm<TInviteMemberForm>({
resolver: zodResolver(inviteMemberSchema),
mode: 'all',
});
// Show loading state while fetching team data
if (isLoadingTeam) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
</div>
);
}
if (!isLeader) {
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">
Access Denied
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Only the team leader can manage members
</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team
</Button>
</div>
);
}
const handleInvite = form.handleSubmit(async (data) => {
try {
await inviteMember(data);
setShowInviteModal(false);
form.reset();
} catch (error) {
console.error('Failed to invite member:', error);
}
});
const handleRemove = async (userId: string) => {
// eslint-disable-next-line no-restricted-globals
if (confirm('Are you sure you want to remove this member?')) {
try {
await removeMember(userId);
} catch (error) {
console.error('Failed to remove member:', error);
}
}
};
const handleApproveRequest = async (requestId: string) => {
try {
await respondToRequest({ requestId, action: 'approve' });
toast.success('Member added successfully!');
} catch (error) {
console.error('Failed to approve request:', error);
toast.error((error as Error).message || 'Failed to approve request');
}
};
const handleRejectRequest = async (requestId: string) => {
try {
await respondToRequest({ requestId, action: 'reject' });
toast.success('Request rejected');
} catch (error) {
console.error('Failed to reject request:', error);
toast.error((error as Error).message || 'Failed to reject request');
}
};
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="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex flex-col md:flex-row gap-y-4 items-start md:items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Manage Members
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{team?.name}
</p>
</div>
<div className="flex space-x-3">
<Button
onClick={() => setShowInviteModal(true)}
disabled={hasSubmission}
title={hasSubmission ? 'Cannot invite members after project submission' : undefined}
>
Invite Member
</Button>
<Button
variant="secondary"
onClick={() => navigate(`/teams/${teamId}`)}
>
Back to Team
</Button>
</div>
</div>
</div>
</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">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
Join Requests ({joinRequests.length})
</h2>
<div className="space-y-3">
{joinRequests.map((request) => (
<div
key={request.id}
className="border dark:border-gray-700 rounded-lg p-4"
>
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3 flex-1">
{request.user.avatar ? (
<img
src={request.user.avatar}
alt={request.user.fullname}
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-400">
👤
</span>
</div>
)}
<div className="flex-1">
<p className="font-medium text-gray-900 dark:text-white">
{request.user.fullname}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{request.user.email}
</p>
{request.user.location && (
<p className="text-sm text-gray-500 dark:text-gray-500">
📍 {request.user.location}
</p>
)}
<p className="text-sm text-gray-700 dark:text-gray-300 mt-2 italic">
"{request.message}"
</p>
</div>
</div>
<div className="flex space-x-2 ml-4">
<Button
size="sm"
onClick={() => handleApproveRequest(request.id)}
disabled={isResponding || hasSubmission}
title={hasSubmission ? 'Cannot accept members after project submission' : undefined}
>
Approve
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => handleRejectRequest(request.id)}
disabled={isResponding}
>
Reject
</Button>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Current Members */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
Current Members ({members.length})
</h2>
{isLoadingMembers ? (
<p className="text-gray-600 dark:text-gray-400">
Loading members...
</p>
) : (
<div className="space-y-3">
{members.map((member) => (
<div
key={member.id}
className="border dark:border-gray-700 rounded-lg p-4 flex items-center justify-between"
>
<div className="flex items-center space-x-3">
{member.user.avatar ? (
<img
src={member.user.avatar}
alt={member.user.fullname}
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-400">
👤
</span>
</div>
)}
<div>
<p className="font-medium text-gray-900 dark:text-white">
{member.user.fullname}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{member.user.email}
</p>
{member.user.location && (
<p className="text-sm text-gray-500 dark:text-gray-500">
📍 {member.user.location}
</p>
)}
<div className="flex items-center space-x-2 mt-1">
{member.role === 'leader' && (
<span className="px-2 py-1 bg-blue-100 dark:bg-primary-900/30 text-blue-800 dark:text-primary-300 rounded text-xs font-medium">
Leader
</span>
)}
{member.status === ETeamMemberStatus.PENDING && (
<span className="px-2 py-1 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300 rounded text-xs font-medium">
Pending Invitation
</span>
)}
</div>
</div>
</div>
{member.role !== 'leader' && !hasSubmission && (
<Button
size="sm"
variant="secondary"
onClick={() => handleRemove(member.user_id)}
disabled={isRemoving}
>
Remove
</Button>
)}
</div>
))}
</div>
)}
</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>
{/* Invite Member Modal */}
{showInviteModal && (
<div className="fixed inset-0 bg-black/20 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 dark:shadow-gray-950/50 max-w-md w-full p-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Invite Member
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4 font-sans">
Send an invitation to join your team. The invited member will see
the invitation on their dashboard after logging in.
</p>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 mb-6">
<p className="text-sm text-yellow-800 dark:text-yellow-200 font-sans">
<strong>Important:</strong> The email you enter must match the
GitHub email address the member uses to sign in.
</p>
</div>
<form onSubmit={handleInvite} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Email Address
</label>
<Input
{...form.register('email')}
type="email"
placeholder="member@example.com"
size="lg"
/>
{form.formState.errors.email && (
<p className="text-sm text-red-500 mt-1">
{form.formState.errors.email.message}
</p>
)}
</div>
<div className="flex space-x-3">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => {
setShowInviteModal(false);
form.reset();
}}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={!form.formState.isValid || isInviting}
>
{isInviting ? 'Sending...' : 'Send Invitation'}
</Button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default ManageMembersPage;
@@ -1,907 +0,0 @@
import { FC, ReactElement, useState, useEffect } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Link, useParams, useNavigate } from 'react-router';
import {
useTeamById,
useTeamMembers,
useInviteMember,
useTeamJoinRequests,
useRespondToJoinRequest,
useLeaveTeam,
useDeleteTeam,
ETeamMemberRole,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
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;
alt: string;
className?: string;
onLoad?: () => void;
}> = ({ src, alt, className, onLoad }) => {
const [isLoaded, setIsLoaded] = useState(false);
return (
<div className="relative">
{!isLoaded && (
<div
className={`absolute inset-0 bg-gray-200 animate-pulse ${className}`}
/>
)}
<img
src={src}
alt={alt}
className={`${className} ${
isLoaded ? 'opacity-100' : 'opacity-0'
} transition-opacity duration-300`}
onLoad={() => {
setIsLoaded(true);
onLoad?.();
}}
/>
</div>
);
};
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);
// Calculate total images to load (banner + logo + member avatars)
const totalImagesToLoad = 0; // Simplified - disable image loading overlay
const handleImageLoad = () => {
setImageLoadCount((prev) => {
const newCount = prev + 1;
if (newCount >= totalImagesToLoad) {
setImagesLoaded(true);
}
return newCount;
});
};
const handleImageError = () => {
// Treat error as loaded to not block the UI
handleImageLoad();
};
// Set images as loaded immediately since we disabled the loading overlay
useEffect(() => {
setImagesLoaded(true);
}, []);
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
teamId || ''
);
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
);
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 joinRequests = joinRequestsData?.data || [];
const pendingJoinRequests = joinRequests.filter(
(req: any) => req.status === 'pending'
);
const isMember = members.some(
(member: any) => member.user_id === currentUserId
);
const canInvite = isLeader && members.length < MAX_TEAM_MEMBERS;
const handleInviteMember = async (e: React.FormEvent) => {
e.preventDefault();
if (!inviteEmail.trim() || isInviting) return;
try {
await inviteMember({ email: inviteEmail.trim() });
toast.success('Invitation sent successfully!');
setInviteEmail('');
setShowInviteModal(false);
} catch (error) {
console.error('Failed to invite member:', error);
toast.error('Failed to send invitation');
}
};
const handleRespondToJoinRequest = async (
requestId: string,
action: 'approve' | 'reject'
) => {
try {
await respondToJoinRequest({ requestId, action });
toast.success(
action === 'approve' ? 'Request approved!' : 'Request rejected'
);
} catch (error) {
console.error('Failed to respond to join request:', error);
toast.error('Failed to process request');
}
};
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">
{/* Skeleton Header */}
<div className="bg-white dark:bg-gray-950 border-b">
<div className="w-full h-48 bg-gray-200 dark:bg-gray-800 animate-pulse" />
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-4">
<div className="w-20 h-20 rounded-full bg-gray-300 dark:bg-gray-700 animate-pulse -mt-10" />
<div>
<div className="h-8 w-48 bg-gray-300 dark:bg-gray-700 rounded animate-pulse" />
<div className="h-4 w-32 bg-gray-200 dark:bg-gray-800 rounded animate-pulse mt-2" />
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-800 rounded animate-pulse mt-2" />
</div>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid gap-6 lg:grid-cols-3">
{/* Main Content Skeleton */}
<div className="lg:col-span-2 space-y-6">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-6">
<div className="h-6 w-32 bg-gray-300 dark:bg-gray-700 rounded animate-pulse mb-4" />
<div className="space-y-2">
<div className="h-4 w-full bg-gray-200 dark:bg-gray-800 rounded animate-pulse" />
<div className="h-4 w-3/4 bg-gray-200 dark:bg-gray-800 rounded animate-pulse" />
<div className="h-4 w-5/6 bg-gray-200 dark:bg-gray-800 rounded animate-pulse" />
</div>
</div>
</div>
{/* Sidebar Skeleton */}
<div className="space-y-6">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-6">
<div className="h-6 w-40 bg-gray-300 dark:bg-gray-700 rounded animate-pulse mb-4" />
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center space-x-3">
<div className="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-700 animate-pulse" />
<div>
<div className="h-4 w-24 bg-gray-300 dark:bg-gray-700 rounded animate-pulse" />
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-800 rounded animate-pulse mt-1" />
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
{/* Loading Overlay */}
<div className="fixed inset-0 bg-white/60 dark:bg-black/50 flex items-center justify-center z-50">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent"></div>
<p className="mt-4 text-gray-600 dark:text-white">
Loading team data...
</p>
</div>
</div>
</div>
);
}
if (!team) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Team not found
</h2>
<Button onClick={() => navigate('/dashboard')}>
Back to Dashboard
</Button>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 relative dark:bg-gray-950">
{/* Loading overlay while images are loading */}
{!imagesLoaded && totalImagesToLoad > 0 && (
<div className="fixed inset-0 bg-white/80 flex items-center justify-center z-50">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent"></div>
<p className="mt-4 text-gray-600 font-medium">Loading images...</p>
<p className="text-sm text-gray-400 mt-1">
{imageLoadCount} / {totalImagesToLoad}
</p>
</div>
</div>
)}
{/* Header with Banner */}
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
{team.banner && (
<div className="relative w-full aspect-3/1 overflow-hidden">
<div
className={`absolute inset-0 bg-gray-200 animate-pulse ${
imagesLoaded ? 'hidden' : ''
}`}
/>
<img
src={team.banner}
alt={team.name}
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${
imagesLoaded ? 'opacity-100' : 'opacity-0'
}`}
onLoad={handleImageLoad}
onError={handleImageError}
/>
<div className="pointer-events-none absolute inset-0 bg-linear-to-b from-transparent via-black/20 to-black/60" />
</div>
)}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-6">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3 md:space-x-4">
{team.logo && (
<div className="relative shrink-0">
{!imagesLoaded && (
<div className="absolute inset-0 w-20 h-20 rounded-full bg-gray-300 dark:bg-gray-700 animate-pulse -mt-10" />
)}
<img
src={team.logo}
alt={team.name}
className={`w-16 h-16 md:w-20 md:h-20 rounded-full object-cover border-4 border-white shadow-lg -mt-8 md:-mt-10 transition-opacity duration-300 shrink-0 ${
imagesLoaded ? 'opacity-100' : 'opacity-0'
}`}
onLoad={handleImageLoad}
onError={handleImageError}
/>
</div>
)}
<div>
<h1 className="text-xl md:text-3xl font-bold text-gray-900 dark:text-white line-clamp-2">
{team.name}
</h1>
<p className="text-sm md:text-base text-gray-600 dark:text-gray-400 mt-1 line-clamp-1 font-sans flex items-center">
<Icon icon="mdi:map-marker" className="inline-block mr-1" />
<span>{team.city}</span>
</p>
<div className="flex flex-wrap items-center gap-2 md:space-x-4 mt-2">
<span className="text-sm text-gray-500 dark:text-gray-400 flex items-center">
<Icon
icon="mdi:account-multiple"
className="inline-block mr-1"
/>
{members.length}{' '}
{members.length === 1 ? 'Member' : 'Members'}
</span>
<span className="text-sm text-gray-500 dark:text-gray-400 flex items-center">
<Icon
icon={
team.visibility === 'public' ? 'mdi:web' : 'mdi:lock'
}
className="inline-block mr-1"
/>
{team.visibility === 'public' ? 'Public' : 'Private'}
</span>
{isLeader && (
<span className="hidden md:inline-flex px-3 py-1 bg-blue-600 text-white rounded-md text-sm font-semibold shadow-sm items-center">
<Icon icon="mdi:crown" className="inline-block mr-1" />
<span>Team Leader</span>
</span>
)}
</div>
</div>
</div>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-8">
<div className="grid gap-4 md:gap-6 xl:grid-cols-3">
{/* Main Content */}
<div className="lg:col-span-2 space-y-4 md:space-y-6">
{/* Team Description */}
<div className="bg-white dark:bg-gray-900 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">
About Team
</h2>
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-all font-sans">
{team.description}
</p>
</div>
{/* Team Actions - Only for Leader */}
{isLeader && (
<div className="bg-white dark:bg-gray-900 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">
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>
)}
<Link to={`/teams/${teamId}/members`}>
<Button className="w-full" variant="secondary">
<Icon
icon="mdi:account-group"
className="inline-block mr-2"
/>
View Members
</Button>
</Link>
<Link to={`/teams/${teamId}/chat`}>
<Button className="w-full" variant="secondary">
<Icon icon="mdi:chat" className="inline-block mr-2" />
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>
)}
</div>
{!team.has_submission && !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">
<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
</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>
)}
{!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>
)}
{/* Submission Status - Only show to members */}
{team.has_submission && isMember && (
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-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-green-900 dark:text-green-400">
Project Submitted
</h3>
<p className="text-green-700 dark:text-green-300 text-sm">
Your team has successfully submitted a project
</p>
</div>
</div>
<Link to={`/teams/${teamId}/submission`}>
<Button className="mt-4 w-full" variant="secondary">
View Submission Details
</Button>
</Link>
</div>
)}
</div>
{/* Sidebar */}
<div className="space-y-4 md:space-y-6">
{/* Team Leader */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
Team Leader
</h3>
{team.leader && (
<button
onClick={() => navigate(`/users/${team.leader.id}`)}
className="w-full flex items-center space-x-3 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg p-2 transition-colors text-left cursor-pointer"
>
{team.leader.avatar ? (
<img
src={team.leader.avatar}
alt={team.leader.fullname}
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-400">
👤
</span>
</div>
)}
<div>
<p className="font-medium text-gray-900 dark:text-white">
{team.leader.fullname}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{team.leader.email}
</p>
</div>
</button>
)}
</div>
{/* Team Members */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
Members ({members.length})
</h3>
<div className="space-y-3">
{members.map((member: any) => (
<button
key={member.id}
onClick={() => navigate(`/users/${member.user.id}`)}
className="w-full flex items-center space-x-3 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg p-2 transition-colors text-left cursor-pointer"
>
{member.user?.avatar ? (
<img
src={member.user.avatar}
alt={member.user.fullname || 'Member'}
className="w-10 h-10 rounded-full object-cover"
onLoad={handleImageLoad}
onError={handleImageError}
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
<span className="text-gray-500 dark:text-gray-400 text-sm">
👤
</span>
</div>
)}
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 dark:text-white truncate">
{member.user?.fullname || 'Unknown'}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
{member.role === ETeamMemberRole.LEADER
? 'Leader'
: 'Member'}
</p>
</div>
</button>
))}
</div>
</div>
</div>
</div>
</div>
{/* Invite Member Modal */}
{showInviteModal && (
<div className="fixed inset-0 bg-black/20 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">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Invite Team Member
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4 font-sans">
Send an invitation to join your team. The invited member will see
the invitation on their dashboard after logging in.
</p>
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 mb-6">
<p className="text-sm text-yellow-800 dark:text-yellow-300 font-sans">
<strong>Important:</strong> The email you enter must match the
GitHub email address the member uses to sign in.
</p>
</div>
<form onSubmit={handleInviteMember} className="space-y-4">
<div>
<label
htmlFor="invite-email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Email Address
</label>
<input
id="invite-email"
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Enter email address..."
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-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"
required
/>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Current members: {members.length}/{MAX_TEAM_MEMBERS}
</p>
</div>
<div className="flex space-x-3">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => {
setShowInviteModal(false);
setInviteEmail('');
}}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={!inviteEmail.trim() || isInviting}
>
{isInviting ? 'Sending...' : 'Send Invitation'}
</Button>
</div>
</form>
</div>
</div>
)}
{/* Join Requests Modal */}
{showJoinRequestsModal && (
<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-2xl w-full p-6 max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
Join Requests ({pendingJoinRequests.length})
</h2>
<button
onClick={() => setShowJoinRequestsModal(false)}
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 text-2xl cursor-pointer"
>
</button>
</div>
{pendingJoinRequests.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-600 dark:text-gray-400 text-lg">
No pending join requests
</p>
<p className="text-gray-500 dark:text-gray-500 text-sm mt-2 font-sans">
When users request to join your team, they'll appear here
</p>
</div>
) : (
<div className="space-y-4">
{pendingJoinRequests.map((request: any) => (
<div
key={request.id}
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex items-start space-x-3 flex-1">
{request.user?.avatar ? (
<img
src={request.user.avatar}
alt={request.user.fullname}
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 shrink-0">
<span className="text-gray-500 dark:text-gray-400 text-xl">
<Icon
icon="mdi:account-circle"
className="text-3xl"
/>
</span>
</div>
)}
<div className="flex-1">
<p className="font-semibold text-gray-900 dark:text-white">
{request.user?.fullname || 'Unknown User'}
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{request.user?.email}
</p>
{request.message && (
<div className="mt-2 bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
<p className="text-sm text-gray-700 dark:text-gray-300">
<strong>Message:</strong> {request.message}
</p>
</div>
)}
<p className="text-sm text-gray-500 dark:text-gray-500 mt-2 font-sans">
Requested{' '}
{new Date(request.created_at).toLocaleDateString()}
</p>
</div>
</div>
</div>
<div className="flex space-x-2 mt-4 justify-end">
<Button
onClick={() =>
handleRespondToJoinRequest(request.id, 'approve')
}
disabled={
isResponding || members.length >= MAX_TEAM_MEMBERS
}
className="px-4 py-2 text-sm"
>
Accept
</Button>
<Button
onClick={() =>
handleRespondToJoinRequest(request.id, 'reject')
}
disabled={isResponding}
variant="secondary"
className="px-4 py-2 text-sm"
>
Reject
</Button>
</div>
{members.length >= MAX_TEAM_MEMBERS && (
<div className="mt-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3">
<p className="text-sm text-yellow-800 dark:text-yellow-300 font-sans">
Team is full ({MAX_TEAM_MEMBERS}/{MAX_TEAM_MEMBERS}{' '}
members). Remove a member before accepting new
requests.
</p>
</div>
)}
</div>
))}
</div>
)}
</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>
);
};
export default TeamDashboardPage;
@@ -1,262 +0,0 @@
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';
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);
const team = teamData?.data;
const submission = submissionData?.data;
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>
);
}
if (!submission) {
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">No Submission Yet</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">Your team hasn't submitted a project</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
</div>
);
}
const submittedDate = submission.submitted_at
? new Date(submission.submitted_at).toLocaleString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: '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="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>
</div>
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team
</Button>
</div>
</div>
</div>
<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>
</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>
)}
{/* Certificate Banner - Only show when submission is submitted */}
{submission.status === 'submitted' && (
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-lg p-6 mb-6">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center space-x-3 flex-1 min-w-0">
<span className="text-4xl shrink-0">🏆</span>
<div className="min-w-0">
<h3 className="font-bold text-amber-900 dark:text-amber-100 text-lg">
View Your Certificate
</h3>
<p className="text-amber-700 dark:text-amber-300 text-sm">
Congratulations! Your personalized certificate is ready to download and share.
</p>
</div>
</div>
<button
onClick={async () => {
const certId = await encodeCertificateId(
teamId || '',
submission.id,
session?.user?.id || ''
);
navigate(`/certificate/${encodeURIComponent(certId)}`);
}}
className="shrink-0 px-6 py-2 bg-amber-600 hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-700 text-white font-medium rounded-lg transition-colors"
>
Get Certificate
</button>
</div>
</div>
)}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 overflow-hidden">
{/* Project Header */}
<div className="bg-linear-to-r from-blue-600 to-blue-800 text-white p-8">
<h2 className="text-3xl font-bold mb-2">{submission.project_name}</h2>
<p className="text-blue-100">Team: {team?.name}</p>
</div>
{/* Project Details */}
<div className="p-8 space-y-6">
{/* 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>
</div>
{/* Links */}
<div className="grid gap-6 md:grid-cols-2">
{/* Repository */}
<div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Repository</h3>
<a
href={submission.repository_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.repository_url}</span>
</a>
</div>
{/* Demo URL */}
{submission.demo_url && (
<div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Live Demo</h3>
<a
href={submission.demo_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.demo_url}</span>
</a>
</div>
)}
</div>
{/* Screenshots */}
{submission.screenshots && submission.screenshots.length > 0 && (
<div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">
Screenshots ({submission.screenshots.length})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{submission.screenshots.map((url, index) => (
<a
key={index}
href={url}
target="_blank"
rel="noopener noreferrer"
className="block"
>
<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"
/>
</a>
))}
</div>
</div>
)}
{/* 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">
<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>
</div>
<div className="flex justify-between">
<span className="text-gray-600 dark:text-gray-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="font-medium text-gray-900 dark:text-white font-mono text-xs">
{submission.id}
</span>
</div>
</div>
</div>
{/* Read-only Notice */}
<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> This submission is now locked and cannot be edited or deleted.
If you need to make changes, please contact the hackathon organizers.
</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default SubmissionViewPage;
@@ -1,518 +0,0 @@
import { FC, ReactElement, useState, useEffect } 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';
import { useForm, Controller } from 'react-hook-form';
import {
projectSubmissionSchema,
TProjectSubmissionForm,
useSubmitProject,
useTeamById,
useTeamSubmission,
useUploadFile,
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);
const { mutateAsync: submitProject, isPending: isSubmitting } =
useSubmitProject(teamId || '');
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const team = teamData?.data;
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),
mode: 'all',
});
if (!isLeader) {
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">
Access Denied
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Only the team leader can submit projects
</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team
</Button>
</div>
);
}
if (hasSubmission) {
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">
Project Already Submitted
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">
Your team has already submitted a project
</p>
<div className="flex space-x-3">
<Button onClick={() => navigate(`/teams/${teamId}/submission`)}>
View Submission
</Button>
<Button
variant="secondary"
onClick={() => navigate(`/teams/${teamId}`)}
>
Back to Team
</Button>
</div>
</div>
);
}
// 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);
const urls = results.map((r) => r.data.url);
setScreenshots([...screenshots, ...urls]);
} catch (error) {
console.error('Failed to upload screenshots:', error);
toast.error('Failed to upload screenshots. Please try again.');
}
};
const removeScreenshot = (index: number) => {
setScreenshots(screenshots.filter((_, i) => i !== index));
};
const onSubmit = form.handleSubmit(async (data) => {
try {
await submitProject({
...data,
screenshots,
});
navigate(`/teams/${teamId}/submission`);
} catch (error) {
console.error('Failed to submit project:', error);
}
});
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="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
Submit Project
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">{team?.name}</p>
</div>
</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">
<span className="text-3xl"></span>
<div>
<h3 className="font-bold text-red-900 dark:text-red-100 text-lg">
IMPORTANT WARNING
</h3>
<ul className="text-red-800 dark:text-red-200 mt-2 space-y-1 text-sm font-sans">
<li> You can only submit your project ONCE</li>
<li> After submission, you CANNOT edit or change anything</li>
<li>
Make sure all information is correct before submitting
</li>
<li> Review your project details carefully</li>
</ul>
</div>
</div>
</div>
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-8 border dark:border-gray-800">
<form
onSubmit={(e) => {
e.preventDefault();
setShowConfirmModal(true);
}}
className="space-y-6"
>
{/* Project Name */}
<ControlledInputField
control={form.control}
label="Project Name"
placeholder="Enter your project name"
name="project_name"
size="lg"
isRequired={true}
/>
{/* Description */}
<div className="space-y-2">
<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"
render={({ field, fieldState }) => (
<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."
rows={6}
className="w-full"
size="lg"
/>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">
{fieldState.error.message}
</p>
)}
</div>
)}
/>
</div>
{/* Repository URL */}
<ControlledInputField
control={form.control}
label="Repository URL (GitHub, GitLab, etc.)"
placeholder="https://github.com/username/project"
name="repository_url"
type="url"
size="lg"
isRequired={true}
/>
{/* Demo URL */}
<ControlledInputField
control={form.control}
label="Demo URL (Optional)"
placeholder="https://your-project-demo.com"
name="demo_url"
type="url"
size="lg"
/>
{/* Screenshots */}
<div>
<label className="block text-label1 font-medium text-neutral-800 dark:text-gray-300">
Project Screenshots (Optional)
</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-4">
{screenshots.map((url, index) => (
<div key={index} className="relative">
<img
src={url}
alt={`Screenshot ${index + 1}`}
className="w-full h-32 object-cover rounded-lg border-2 border-gray-200 dark:border-gray-700"
/>
<button
type="button"
onClick={() => removeScreenshot(index)}
className="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm hover:bg-red-600"
>
×
</button>
</div>
))}
</div>
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800">
<div className="text-center">
<p className="text-gray-500 dark:text-gray-400">
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
</p>
</div>
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleScreenshotUpload}
disabled={isUploading}
/>
</label>
</div>
{/* Submit Button */}
<div className="flex space-x-3 pt-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => navigate(`/teams/${teamId}`)}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={!form.formState.isValid || isUploading || !hasEnoughMembers}
>
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>
{/* Confirmation Modal */}
{showConfirmModal && (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 max-w-md w-full p-6">
<div className="text-center mb-6">
<div className="text-5xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
Final Confirmation
</h2>
<p className="text-red-600 dark:text-red-400 font-medium">
This action is IRREVERSIBLE!
</p>
</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:
</p>
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-2">
<li> All information is correct and complete</li>
<li> You understand this can only be done once</li>
<li> You cannot edit after submission</li>
<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('');
}}
>
Go Back
</Button>
<Button
onClick={onSubmit}
className="flex-1 bg-red-600 hover:bg-red-700"
disabled={isSubmitting || confirmText !== 'SUBMIT'}
>
{isSubmitting ? 'Submitting...' : 'Submit Project'}
</Button>
</div>
</div>
</div>
)}
</div>
);
};
export default SubmitProjectPage;
@@ -1,642 +0,0 @@
import { FC, ReactElement, useState, useEffect, useCallback } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Link, useNavigate, useSearchParams } from 'react-router';
import {
useTeams,
useJoinTeam,
useMyTeams,
ETeamVisibility,
joinTeamSchema,
TJoinTeamForm,
} from '@imphnen-frontend-service/service';
import { useForm } from 'react-hook-form';
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 [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
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,
search: debouncedSearch,
city: selectedCity || undefined,
visibility: ETeamVisibility.PUBLIC,
minMembers: memberFilter?.minMembers,
maxMembers: memberFilter?.maxMembers,
hasSubmission: hasSubmissionFilter,
});
const { data: myTeamsData } = useMyTeams();
const { mutateAsync: joinTeam, isPending: isJoining } = useJoinTeam();
const form = useForm<TJoinTeamForm>({
resolver: zodResolver(joinTeamSchema),
mode: 'all',
});
const teams = teamsData?.teams || [];
const totalPages = teamsData?.totalPages || 1;
const total = teamsData?.total || 0;
const myTeams = myTeamsData?.data || [];
// Helper function to check if user is a member of a team
const isMyTeam = (teamId: string) => {
return myTeams.some((team: any) => team.id === teamId);
};
const handleJoinRequest = (teamId: string) => {
setSelectedTeamId(teamId);
setShowJoinModal(true);
};
const onSubmit = form.handleSubmit(async (data) => {
if (!selectedTeamId) return;
try {
await joinTeam({ teamId: selectedTeamId, data });
setShowJoinModal(false);
form.reset();
setSelectedTeamId(null);
} catch (error) {
console.error('Failed to send join request:', error);
}
});
// 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 */}
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-800">
<div className="max-w-7xl 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">
Browse Teams
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Find and join teams looking for members
</p>
</div>
<Link to="/dashboard" className="hidden md:block">
<Button variant="secondary">Back to Dashboard</Button>
</Link>
</div>
</div>
</div>
{/* 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>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Search Teams
</label>
<input
type="text"
placeholder="Search by team name..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
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 placeholder:text-gray-400 dark:placeholder:text-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Filter by City
</label>
<CitySelect
value={selectedCity}
onChange={setSelectedCity}
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} />
))}
</div>
) : teams.length === 0 ? (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-sm p-12 text-center">
<p className="text-gray-600 dark:text-gray-400 text-lg">
No teams found
</p>
<p className="text-gray-500 dark:text-gray-500 mt-2">
Try adjusting your filters
</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>
</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) ? (
<Button
className="w-full"
variant="secondary"
onClick={() => navigate(`/teams/${team.id}`)}
>
Your 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>
{/* Join Request Modal */}
{showJoinModal && (
<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">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Request to Join Team
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-6 font-sans">
Send a message to the team leader explaining why you want to join
</p>
<form onSubmit={onSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Your Message
</label>
<textarea
{...form.register('message')}
rows={4}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-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"
placeholder="Tell the team leader why you want to join their team..."
/>
{form.formState.errors.message && (
<p className="text-sm text-red-500 mt-1 font-sans">
{form.formState.errors.message.message}
</p>
)}
</div>
<div className="flex space-x-3">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => {
setShowJoinModal(false);
form.reset();
setSelectedTeamId(null);
}}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={!form.formState.isValid || isJoining}
>
{isJoining ? 'Sending...' : 'Send Request'}
</Button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default BrowseTeamsPage;
@@ -1,387 +0,0 @@
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 } 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);
const [bannerPreview, setBannerPreview] = useState<string>('');
const form = useForm<TTeamCreateForm>({
resolver: zodResolver(teamCreateSchema),
mode: 'all',
defaultValues: {
visibility: ETeamVisibility.PUBLIC,
logo: null,
banner: null,
},
});
const { mutateAsync: createTeam, isPending: isCreating } = useCreateTeam();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
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 = () => {
setLogoPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
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 = () => {
setBannerPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const onSubmit = form.handleSubmit(async (data) => {
try {
let logoUrl = null;
let bannerUrl = null;
if (logoFile) {
const logoResult = await uploadFile(logoFile);
logoUrl = logoResult.data.url;
}
if (bannerFile) {
const bannerResult = await uploadFile(bannerFile);
bannerUrl = bannerResult.data.url;
}
const result = await createTeam({
...data,
logo: logoUrl,
banner: bannerUrl,
});
toast.success('Team created successfully!');
navigate(`/teams/${result.data.id}`);
} catch (error: any) {
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 */}
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Create Your Team</h1>
<p className="text-gray-600 dark:text-neutral-400 mt-1">Build your hackathon dream team</p>
</div>
</div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-900/50 p-8">
<form onSubmit={onSubmit} className="space-y-6">
{/* Banner Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-2">
Team Banner <span className="text-gray-400 dark:text-neutral-500">(Optional)</span>
</label>
{bannerPreview ? (
<div className="relative">
<img
src={bannerPreview}
alt="Banner preview"
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 dark:border-neutral-700"
/>
<button
type="button"
onClick={() => {
setBannerFile(null);
setBannerPreview('');
}}
className="absolute top-2 right-2 bg-red-500 text-white px-3 py-1 rounded-lg text-sm hover:bg-red-600"
>
Remove
</button>
</div>
) : (
<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>
</div>
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleBannerChange}
/>
</label>
)}
</div>
{/* Logo Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-2">
Team Logo <span className="text-gray-400 dark:text-neutral-500">(Optional, but highly recommended)</span>
</label>
<div className="flex items-center space-x-4">
{logoPreview ? (
<img
src={logoPreview}
alt="Logo preview"
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200 dark:border-neutral-700"
/>
) : (
<div className="w-24 h-24 rounded-full bg-gray-200 dark:bg-neutral-700 flex items-center justify-center">
<span className="text-gray-400 dark:text-neutral-500 text-3xl">👥</span>
</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>
</div>
</div>
</div>
{/* Team Name */}
<ControlledInputField
control={form.control}
label="Team Name"
placeholder="Enter your team name"
name="name"
/>
{/* City */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300">
City <span className="text-red-500">*</span>
</label>
<Controller
control={form.control}
name="city"
render={({ field, fieldState }) => (
<CitySelect
value={field.value}
onChange={field.onChange}
error={fieldState.error?.message}
placeholder="Search your city..."
/>
)}
/>
</div>
{/* Description */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300">
Description <span className="text-red-500">*</span>
</label>
<Controller
control={form.control}
name="description"
render={({ field, fieldState }) => (
<div>
<Textarea
{...field}
placeholder="Tell others about your team, what you're looking for, your goals..."
rows={4}
className="w-full"
/>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
)}
</div>
)}
/>
</div>
{/* Visibility */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300">
Team Visibility <span className="text-red-500">*</span>
</label>
<Controller
control={form.control}
name="visibility"
render={({ field }) => (
<div className="space-y-3">
<label className="flex items-start space-x-3 cursor-pointer border dark:border-neutral-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-neutral-800">
<input
type="radio"
{...field}
value={ETeamVisibility.PUBLIC}
checked={field.value === ETeamVisibility.PUBLIC}
className="mt-1"
/>
<div>
<p className="font-medium text-gray-900 dark:text-white">Public</p>
<p className="text-sm text-gray-600 dark:text-neutral-400">
Team will be visible in Browse Teams. Anyone can request to join.
</p>
</div>
</label>
<label className="flex items-start space-x-3 cursor-pointer border dark:border-neutral-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-neutral-800">
<input
type="radio"
{...field}
value={ETeamVisibility.PRIVATE}
checked={field.value === ETeamVisibility.PRIVATE}
className="mt-1"
/>
<div>
<p className="font-medium text-gray-900 dark:text-white">Private</p>
<p className="text-sm text-gray-600 dark:text-neutral-400">
Team is hidden from Browse Teams. Members can only join via invitation.
</p>
</div>
</label>
</div>
)}
/>
</div>
{/* Warning */}
<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> As team leader, you cannot leave or join another team after creating this team.
</p>
</div>
{/* Submit Button */}
<div className="flex space-x-3 pt-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => navigate('/dashboard')}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={!form.formState.isValid || isCreating || isUploading}
>
{isCreating || isUploading ? 'Creating Team...' : 'Create Team'}
</Button>
</div>
</form>
</div>
</div>
</div>
);
};
export default CreateTeamPage;
-48
View File
@@ -1,48 +0,0 @@
import { FC, ReactElement, useState } from 'react';
import { Outlet } from 'react-router';
import { Sidebar } from '../../components/sidebar';
const TeamsLayout: FC = (): ReactElement => {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex-1 flex flex-col">
{/* Mobile Header with Hamburger */}
<div className="lg:hidden sticky top-0 bg-white dark:bg-gray-900 border-b dark:border-gray-700 px-4 py-3 z-10">
<div className="flex items-center">
<button
onClick={() => setSidebarOpen(true)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<svg
className="w-6 h-6 text-gray-600 dark:text-gray-400"
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="ml-3 text-lg font-bold text-gray-900 dark:text-white">
Hackathon
</h1>
</div>
</div>
<main className="flex-1">
<Outlet />
</main>
</div>
</div>
);
};
export default TeamsLayout;
@@ -1,46 +0,0 @@
import { FC, ReactElement, useState } from 'react';
import { Outlet } from 'react-router';
import { Sidebar } from '../../../components/sidebar';
const UserDetailLayout: FC = (): ReactElement => {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex-1 flex flex-col overflow-auto">
{/* Mobile Header with Hamburger */}
<div className="lg:hidden bg-white dark:bg-gray-900 border-b dark:border-neutral-700 px-4 py-3 flex items-center">
<button
onClick={() => setSidebarOpen(true)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<svg
className="w-6 h-6 text-gray-600 dark:text-neutral-400"
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="ml-3 text-lg font-bold text-gray-900 dark:text-white">
Hackathon
</h1>
</div>
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
</div>
);
};
export default UserDetailLayout;
@@ -1,240 +0,0 @@
import { FC, ReactElement } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useParams, useNavigate, Link } from 'react-router';
import {
useUserDetailsById,
useTeamsByUserId,
} from '@imphnen-frontend-service/service';
import { Icon } from '@iconify/react';
const UserProfilePage: FC = (): ReactElement => {
const { userId } = useParams<{ userId: string }>();
const navigate = useNavigate();
const { data: userData, isLoading, error } = useUserDetailsById(userId || '');
const { data: teamsData } = useTeamsByUserId(userId || '');
const user = userData?.data;
const userTeams = teamsData?.data || [];
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 user profile...
</div>
</div>
);
}
if (error || !user) {
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">
User not found
</h2>
{error && (
<p className="text-red-600 dark:text-red-400 mb-4">{String(error)}</p>
)}
<Button onClick={() => navigate('/dashboard')}>
Back to Dashboard
</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">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-6">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3 md:space-x-4">
{user.avatar ? (
<img
src={user.avatar}
alt={user.fullname}
className="w-16 h-16 md:w-20 md:h-20 rounded-full object-cover border-4 border-white dark:border-gray-800 shadow-lg"
/>
) : (
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center border-4 border-white dark:border-gray-800 shadow-lg">
<span className="text-gray-500 dark:text-gray-400 text-2xl md:text-3xl">
<Icon icon="mdi:account-circle" className="w-10 h-10" />
</span>
</div>
)}
<div>
<h1 className="text-xl md:text-3xl font-bold text-gray-900 dark:text-white">
{user.fullname}
</h1>
<p className="text-sm md:text-base text-gray-600 dark:text-gray-400 mt-1">
{user.email}
</p>
{user.location && (
<div className="flex items-center space-x-4 mt-2">
<span className="text-sm text-gray-500 dark:text-gray-500">
<Icon
icon="mdi:map-marker"
className="inline-block w-4 h-4 mr-1"
/>
{user.location}
</span>
</div>
)}
</div>
</div>
<Link to="/dashboard" className="hidden md:block">
<Button variant="secondary">Back to Dashboard</Button>
</Link>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-8">
<div className="grid gap-4 md:gap-6 lg:grid-cols-3">
{/* Main Content */}
<div className="lg:col-span-2 space-y-4 md:space-y-6">
{/* About Section */}
{user.bio && (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6 overflow-hidden">
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
About
</h2>
<p className="text-gray-700 dark:text-gray-300 break-all overflow-wrap-anywhere font-sans">
{user.bio}
</p>
</div>
)}
{/* Skills Section */}
{user.skills && user.skills.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">
Skills
</h2>
<div className="flex flex-wrap gap-3">
{user.skills.map((skill: string) => (
<span
key={skill}
className="inline-flex items-center px-4 py-2 border border-primary-600 dark:border-gray-500 text-primary-600 dark:text-white rounded-4xl text-xs font-medium dark:bg-gray-600"
>
{skill}
</span>
))}
</div>
</div>
)}
{/* Team 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
</h2>
<div className="space-y-4">
{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"
>
<img
src={team.banner || '/images/banner-imphnen.webp'}
alt={team.name}
className="w-full aspect-3/1 object-cover"
/>
<div className="p-4">
<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">
<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">
{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>
</div>
</div>
{team.description && (
<p className="text-gray-600 dark:text-gray-400 text-sm line-clamp-2 font-sans">
{team.description}
</p>
)}
</div>
</Link>
))}
</div>
</div>
) : (
<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
</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>
<p className="text-gray-600 dark:text-gray-400">
Not in any team yet
</p>
</div>
</div>
)}
</div>
{/* Sidebar */}
<div className="space-y-4 md:space-y-6">
{/* Contact Info */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
Contact Information
</h3>
<div className="space-y-3">
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">
Email
</p>
<p className="text-gray-900 dark:text-white font-medium">
{user.email}
</p>
</div>
{user.location && (
<div>
<p className="text-sm text-gray-600 dark:text-gray-400">
Location
</p>
<p className="text-gray-900 dark:text-white font-medium">
{user.location}
</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default UserProfilePage;
-318
View File
@@ -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;
@@ -1,117 +0,0 @@
import { FC, useState, useRef, useEffect } from 'react';
import INDONESIAN_CITIES from '../constants/cities';
interface CitySelectProps {
value: string;
onChange: (value: string) => void;
error?: string;
placeholder?: string;
}
export const CitySelect: FC<CitySelectProps> = ({
value,
onChange,
error,
placeholder = 'Search your city...',
}) => {
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);
}
};
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('');
setSearchQuery('');
setIsOpen(true);
inputRef.current?.focus();
};
return (
<div className="relative" ref={dropdownRef}>
<div className="relative">
<input
ref={inputRef}
type="text"
value={isOpen ? searchQuery : value}
onChange={(e) => {
setSearchQuery(e.target.value);
setIsOpen(true);
}}
onClick={handleInputClick}
placeholder={placeholder}
className={`w-full h-[42px] px-3 text-[15px] border rounded-md focus:ring-2 focus:ring-blue-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 ${
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
}`}
/>
{value && !isOpen && (
<button
type="button"
onClick={handleClearSelection}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500 hover:text-gray-600 cursor-pointer dark:hover:text-gray-300"
>
</button>
)}
</div>
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
{isOpen && (
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto text-label2">
{filteredCities.length > 0 ? (
<ul className="py-1">
{filteredCities.map((city) => (
<li
key={city}
onClick={() => handleSelectCity(city)}
className={`px-3 py-2 cursor-pointer hover:bg-blue-50 dark:hover:bg-blue-900/30 ${
value === city
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-400'
: 'text-gray-900 dark:text-gray-200'
}`}
>
{city}
</li>
))}
</ul>
) : (
<div className="px-3 py-2 text-gray-500 dark:text-gray-400 text-sm">
No cities found
</div>
)}
</div>
)}
</div>
);
};
@@ -1,144 +0,0 @@
import { FC, useState } from 'react';
import { Link, useNavigate } from 'react-router';
import { useUserMe, useMyTeams } from '@imphnen-frontend-service/service';
import { useSession } from '@imphnen-frontend-service/utils';
export const Navigation: FC = () => {
const navigate = useNavigate();
const [showUserMenu, setShowUserMenu] = useState(false);
const { data: userData } = useUserMe();
const { data: teamsData } = useMyTeams();
const { signOut } = useSession();
const user = userData?.data;
const myTeams = teamsData?.data || [];
const hasTeam = myTeams.length > 0;
const handleLogout = async () => {
await signOut();
navigate('/auth/login');
};
return (
<nav className="bg-white border-b shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<Link to="/dashboard" className="flex items-center space-x-2">
<span className="text-2xl">🏆</span>
<span className="text-xl font-bold text-gray-900">Hackathon</span>
</Link>
{/* Navigation Links */}
<div className="hidden md:flex items-center space-x-6">
<Link
to="/dashboard"
className="text-gray-700 hover:text-blue-600 font-medium transition-colors"
>
Dashboard
</Link>
<Link
to="/teams/browse"
className="text-gray-700 hover:text-blue-600 font-medium transition-colors"
>
Browse Teams
</Link>
{hasTeam && (
<Link
to={`/teams/${myTeams[0].id}`}
className="text-gray-700 hover:text-blue-600 font-medium transition-colors"
>
My Team
</Link>
)}
</div>
{/* User Menu */}
<div className="relative">
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center space-x-3 focus:outline-none"
>
{user?.avatar ? (
<img
src={user.avatar}
alt={user.fullname}
className="w-10 h-10 rounded-full object-cover border-2 border-gray-200"
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500">👤</span>
</div>
)}
<span className="hidden md:block text-gray-700 font-medium">
{user?.fullname}
</span>
<svg
className="w-4 h-4 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{/* Dropdown Menu */}
{showUserMenu && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
<div className="px-4 py-2 border-b border-gray-200">
<p className="text-sm font-medium text-gray-900">{user?.fullname}</p>
<p className="text-xs text-gray-600 truncate">{user?.email}</p>
</div>
<Link
to="/dashboard"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setShowUserMenu(false)}
>
Dashboard
</Link>
<Link
to="/teams/browse"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setShowUserMenu(false)}
>
Browse Teams
</Link>
{hasTeam && (
<Link
to={`/teams/${myTeams[0].id}`}
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setShowUserMenu(false)}
>
My Team
</Link>
)}
<div className="border-t border-gray-200 mt-2 pt-2">
<button
onClick={handleLogout}
className="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50"
>
Logout
</button>
</div>
</div>
)}
</div>
</div>
</div>
{/* Close dropdown when clicking outside */}
{showUserMenu && (
<div
className="fixed inset-0 z-40"
onClick={() => setShowUserMenu(false)}
/>
)}
</nav>
);
};
-293
View File
@@ -1,293 +0,0 @@
import { FC, useEffect } from 'react';
import { Link, useLocation } from 'react-router';
import {
useMyTeams,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { useNavigate } from 'react-router';
import { toast } from 'sonner';
import { useTheme } from './theme-provider';
import { Icon } from '@iconify/react';
interface NavItem {
name: string;
path: string;
icon: React.ReactNode;
show: boolean;
}
interface SidebarProps {
isOpen?: boolean;
onClose?: () => void;
}
export const Sidebar: FC<SidebarProps> = ({ isOpen = true, onClose }) => {
const location = useLocation();
const navigate = useNavigate();
const { session, clearSession } = useAuthStore();
const { data: teamsData } = useMyTeams();
const { theme, setTheme, resolvedTheme } = useTheme();
const user = session?.user;
const myTeams = teamsData?.data || [];
const hasTeam = myTeams.length > 0;
// Close sidebar on route change (mobile)
useEffect(() => {
if (onClose) {
onClose();
}
}, [location.pathname]);
const handleLogout = () => {
clearSession();
localStorage.clear();
toast.success('Logged out successfully');
navigate('/auth/login');
};
const navItems: NavItem[] = [
{
name: 'Dashboard',
path: '/dashboard',
icon: <Icon icon="heroicons:home" className="w-5 h-5" />,
show: true,
},
{
name: 'My Teams',
path: '/teams/' + myTeams[0]?.id,
icon: <Icon icon="heroicons:users" className="w-5 h-5" />,
show: myTeams.length > 0,
},
{
name: 'Browse Teams',
path: '/teams/browse',
icon: <Icon icon="heroicons-outline:search" className="w-5 h-5" />,
show: true,
},
{
name: 'Create Team',
path: '/teams/create',
icon: <Icon icon="heroicons:plus" className="h-5 w-5" />,
show: !hasTeam,
},
{
name: 'Edit Profile',
path: '/profile',
icon: (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
/>
</svg>
),
show: false,
},
];
const cycleTheme = () => {
if (theme === 'light') setTheme('dark');
else if (theme === 'dark') setTheme('system');
else setTheme('light');
};
const getThemeIcon = () => {
if (theme === 'system') {
return (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
);
}
if (resolvedTheme === 'dark') {
return (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
);
}
return (
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
);
};
const getThemeLabel = () => {
if (theme === 'system') return 'System';
if (theme === 'dark') return 'Dark';
return 'Light';
};
const sidebarContent = (
<div className="w-64 bg-white dark:bg-gray-900 border-r dark:border-gray-800 min-h-screen flex flex-col">
{/* Logo / Brand with Close Button */}
<div className="p-6 flex items-center justify-between border-b dark:border-gray-800">
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
Hackathon
</h1>
{onClose && (
<button
onClick={onClose}
className="lg:hidden p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<svg
className="w-5 h-5 text-gray-500 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
)}
</div>
{/* User Info */}
<div className="p-4 border-b dark:border-gray-800">
<div className="flex items-center space-x-3">
{user?.avatar ? (
<img
src={user.avatar}
alt={user.fullname || 'User'}
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center">
<Icon
icon="ic:baseline-person"
width="24"
height="24"
className="text-gray-400 dark:text-gray-400"
/>
</div>
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{user?.fullname || user?.email?.split('@')[0] || 'User'}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">
{user?.email}
</p>
</div>
</div>
</div>
{/* Navigation */}
<nav className="flex-1 p-4">
<ul className="space-y-2">
{navItems
.filter((item) => item.show)
.map((item) => {
const isActive = location.pathname === item.path;
return (
<li key={item.path}>
<Link
to={item.path}
className={`flex items-center space-x-3 px-4 py-3 rounded-lg transition-colors ${
isActive
? 'bg-primary-50 dark:bg-blue-900/30 text-primary-600 dark:text-blue-400 font-medium'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
{item.icon}
<span>{item.name}</span>
</Link>
</li>
);
})}
</ul>
</nav>
{/* Theme Toggle & Logout */}
<div className="p-4 border-t dark:border-gray-800 space-y-2">
<button
onClick={cycleTheme}
className="flex items-center space-x-3 px-4 py-3 w-full rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors cursor-pointer"
>
{getThemeIcon()}
<span>{getThemeLabel()}</span>
</button>
<button
onClick={handleLogout}
className="flex items-center space-x-3 px-4 py-3 w-full rounded-lg text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors cursor-pointer"
>
<Icon
icon="heroicons:arrow-right-end-on-rectangle"
className="w-5 h-5"
/>
<span>Logout</span>
</button>
</div>
</div>
);
return (
<>
{/* Desktop Sidebar - Always visible on lg+, sticky position */}
<div className="hidden lg:block sticky top-0 h-screen overflow-y-auto">
{sidebarContent}
</div>
{/* Mobile Sidebar - Overlay */}
{isOpen && (
<div className="lg:hidden fixed inset-0 z-50">
{/* Backdrop */}
<div
className="fixed inset-0 bg-black/50 transition-opacity"
onClick={onClose}
/>
{/* Sidebar */}
<div className="fixed inset-y-0 left-0 z-50 transform transition-transform duration-300 ease-in-out">
{sidebarContent}
</div>
</div>
)}
</>
);
};
export default Sidebar;
@@ -1,100 +0,0 @@
import { createContext, useContext, useEffect, useState, FC, ReactNode } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
resolvedTheme: 'light' | 'dark';
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
const STORAGE_KEY = 'hackathon-theme';
function getSystemTheme(): 'light' | 'dark' {
if (typeof window !== 'undefined') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return 'light';
}
interface ThemeProviderProps {
children: ReactNode;
defaultTheme?: Theme;
}
export const ThemeProvider: FC<ThemeProviderProps> = ({ children, defaultTheme = 'system' }) => {
const [theme, setThemeState] = useState<Theme>(() => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null;
return stored || defaultTheme;
}
return defaultTheme;
});
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => {
if (theme === 'system') {
return getSystemTheme();
}
return theme;
});
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem(STORAGE_KEY, newTheme);
};
useEffect(() => {
const root = document.documentElement;
// Calculate the resolved theme
const resolved = theme === 'system' ? getSystemTheme() : theme;
setResolvedTheme(resolved);
// Apply dark class to root
if (resolved === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
}, [theme]);
// Listen for system theme changes
useEffect(() => {
if (theme !== 'system') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
const newTheme = e.matches ? 'dark' : 'light';
setResolvedTheme(newTheme);
const root = document.documentElement;
if (newTheme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
{children}
</ThemeContext.Provider>
);
};
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
export default ThemeProvider;
@@ -1,93 +0,0 @@
import { FC } from 'react';
import { useTheme } from './theme-provider';
interface ThemeToggleProps {
className?: string;
}
// Sun icon for light mode
const SunIcon = () => (
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
);
// Moon icon for dark mode
const MoonIcon = () => (
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
);
// System icon (monitor)
const SystemIcon = () => (
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
);
export const ThemeToggle: FC<ThemeToggleProps> = ({ className = '' }) => {
const { theme, setTheme, resolvedTheme } = useTheme();
const cycleTheme = () => {
if (theme === 'light') setTheme('dark');
else if (theme === 'dark') setTheme('system');
else setTheme('light');
};
const getThemeLabel = () => {
if (theme === 'system') return 'System';
return theme === 'dark' ? 'Dark' : 'Light';
};
const renderIcon = () => {
if (theme === 'system') {
return <SystemIcon />;
}
return resolvedTheme === 'dark' ? <MoonIcon /> : <SunIcon />;
};
return (
<button
type="button"
onClick={cycleTheme}
className={`p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors cursor-pointer text-gray-600 dark:text-neutral-200 ${className}`}
title={`Theme: ${getThemeLabel()}`}
aria-label={`Current theme: ${getThemeLabel()}. Click to change.`}
>
{renderIcon()}
</button>
);
};
export default ThemeToggle;
-518
View File
@@ -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;
@@ -1,76 +0,0 @@
import { useTeamById, useTeamMembers, useUserMe, useTeamSubmission } from '@imphnen-frontend-service/service';
/**
* Hook to check if current user is a member of the team
*/
export const useTeamMembership = (teamId: string) => {
const { data: userData } = useUserMe();
const { data: membersData } = useTeamMembers(teamId);
const currentUser = userData?.data;
const members = membersData?.data || [];
const isMember = members.some(m => m.user_id === currentUser?.id);
const currentMember = members.find(m => m.user_id === currentUser?.id);
return {
isMember,
currentMember,
memberRole: currentMember?.role,
memberStatus: currentMember?.status,
};
};
/**
* Hook to check if current user is the team leader
*/
export const useIsTeamLeader = (teamId: string) => {
const { data: teamData } = useTeamById(teamId);
const { data: userData } = useUserMe();
const team = teamData?.data;
const currentUser = userData?.data;
const isLeader = currentUser?.id === team?.leader_id;
return {
isLeader,
leaderId: team?.leader_id,
};
};
/**
* Hook to get team submission status
*/
export const useTeamSubmissionStatus = (teamId: string) => {
const { data: submissionData, isLoading } = useTeamSubmission(teamId, !!teamId);
const hasSubmission = !!submissionData?.data;
const submission = submissionData?.data;
const isSubmitted = submission?.status === 'submitted';
return {
hasSubmission,
isSubmitted,
submission,
isLoading,
};
};
/**
* Hook to check if current user can perform team actions
*/
export const useTeamPermissions = (teamId: string) => {
const { isLeader } = useIsTeamLeader(teamId);
const { isMember } = useTeamMembership(teamId);
const { hasSubmission } = useTeamSubmissionStatus(teamId);
return {
canEditTeam: isLeader,
canManageMembers: isLeader,
canSubmitProject: isLeader && !hasSubmission,
canViewTeam: isMember,
canViewSubmission: isMember,
canAccessChat: isMember,
};
};
-169
View File
@@ -1,169 +0,0 @@
@import url('https://fonts.googleapis.com/css2?family=Bai+Jamjuree:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap');
@import 'tailwindcss';
@source "../../../libs/ui/**/*.{ts,tsx}";
/* Enable class-based dark mode (using .dark class on html element) */
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-primary-50: #f0f8ff;
--color-primary-100: #e1f0fd;
--color-primary-200: #bce1fb;
--color-primary-300: #81cbf8;
--color-primary-400: #3eb0f2;
--color-primary-500: #23a1eb;
--color-primary-600: #0877c1;
--color-primary-700: #085f9c;
--color-primary-800: #0b5181;
--color-primary-900: #0f446b;
--color-primary-950: #0a2b47;
--color-neutral-50: #f6f6f6;
--color-neutral-100: #e7e7e7;
--color-neutral-200: #d1d1d1;
--color-neutral-300: #b0b0b0;
--color-neutral-400: #888888;
--color-neutral-500: #6e6a86;
--color-neutral-600: #56526e;
--color-neutral-700: #44415a;
--color-neutral-800: #393552;
--color-neutral-900: #2a273f;
--color-neutral-950: #232136;
--color-success-100: #e0fbd8;
--color-success-200: #bcf8b0;
--color-success-300: #8eea85;
--color-success-400: #63d564;
--color-success-500: #35ba43;
--color-success-600: #269f3e;
--color-success-700: #1a8439;
--color-success-800: #106b32;
--color-success-900: #0b592f;
--color-info-100: #ccfcfe;
--color-info-200: #9bf3fd;
--color-info-300: #67e3fb;
--color-info-400: #42cdf8;
--color-info-500: #04acf3;
--color-info-600: #0185d0;
--color-info-700: #0264af;
--color-info-800: #01478d;
--color-info-900: #003375;
--color-warning-100: #fffcd3;
--color-warning-200: #fffaa9;
--color-warning-300: #fff67d;
--color-warning-400: #fff25d;
--color-warning-500: #ffed27;
--color-warning-600: #dbc91d;
--color-warning-700: #b7a714;
--color-warning-800: #93850b;
--color-warning-900: #7a6d07;
--color-danger-100: #ffe8da;
--color-danger-200: #ffcbb3;
--color-danger-300: #ffaa8d;
--color-danger-400: #ff8870;
--color-danger-500: #ff5242;
--color-danger-600: #da3030;
--color-danger-700: #b7212d;
--color-danger-800: #93152a;
--color-danger-900: #7a0c27;
--radius-none: 0px;
--radius-sm: 2px;
--radius-md: 4px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-3xl: 24px;
--radius-full: 50%;
--font-bai-jamjuree: 'Bai Jamjuree', sans-serif;
--text-h1: 3.833rem;
/* ~46px */
--text-h2: 3.083rem;
/* ~37px */
--text-h3: 2.417rem;
/* ~29px */
--text-p1: 1.917rem;
/* ~23px */
--text-p2: 1.583rem;
/* ~19px */
--text-p3: 1.25rem;
/* 15px */
--text-label1: 1rem;
/* 12px */
--text-label2: 0.833rem;
/* ~10px */
--text-label3: 0.677rem;
/* ~8px */
}
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-neutral-200, currentColor);
}
.dark *,
.dark ::after,
.dark ::before,
.dark ::backdrop,
.dark ::file-selector-button {
border-color: var(--color-neutral-700, currentColor);
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--color-neutral-50);
}
.dark ::-webkit-scrollbar-track {
background: var(--color-neutral-900);
}
::-webkit-scrollbar-thumb {
background: #cccccc;
border-radius: 3px;
}
.dark ::-webkit-scrollbar-thumb {
background: var(--color-neutral-600);
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-neutral-300);
}
.dark ::-webkit-scrollbar-thumb:hover {
background: var(--color-neutral-500);
}
html {
font-family: 'Bai Jamjuree', sans-serif;
font-weight: 400;
font-size: 16px;
line-height: 1.2;
}
/* Dark mode base styles */
.dark {
color-scheme: dark;
}
.dark body {
background-color: var(--color-neutral-950);
color: var(--color-neutral-100);
}
}
-49
View File
@@ -1,49 +0,0 @@
import { createRoot } from 'react-dom/client';
import { StrictMode } from 'react';
import { createBrowserRouter, RouteObject, RouterProvider } from 'react-router';
import {
add404PageToRoutesChildren,
addErrorElementToRoutes,
convertPagesToRoute,
ModalLoginProvider,
QueryProvider,
} from '@imphnen-frontend-service/utils';
import { Toaster } from 'sonner';
import { ThemeProvider } from './components/theme-provider';
import './index.css';
const files = import.meta.glob('./app/**/*(page|layout).tsx');
const errorFiles = import.meta.glob('./app/**/*error.tsx');
const notFoundFiles = import.meta.glob('./app/**/*404.tsx');
const loadingFiles = import.meta.glob('./app/**/*loading.tsx');
const routes = convertPagesToRoute(files, loadingFiles) as RouteObject;
addErrorElementToRoutes(errorFiles, routes);
add404PageToRoutesChildren(notFoundFiles, routes);
const router = createBrowserRouter([
{
...routes,
// MIDDLEWARE TEMPORARILY DISABLED - causing infinite loops with React Router v7
// TODO: Implement auth checks at component level or use different pattern
// loader: middleware,
// shouldRevalidate: () => false,
},
]);
const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('Failed to find the root element');
createRoot(rootElement).render(
<StrictMode>
<ThemeProvider defaultTheme="system">
<QueryProvider>
<ModalLoginProvider>
<Toaster position="top-right" richColors />
<RouterProvider router={router} />
</ModalLoginProvider>
</QueryProvider>
</ThemeProvider>
</StrictMode>
);
-136
View File
@@ -1,136 +0,0 @@
import { SessionUser } from '@imphnen-frontend-service/utils';
import { hackathonApi, SessionToken } from '@imphnen-frontend-service/service';
import { LoaderFunctionArgs, redirect } from 'react-router';
const mappingPublicRoutes = [
'/',
];
const mappingOnboardingRoutes = [
'/onboarding/user',
];
const mappingRoutePermissions = [
{
path: '/dashboard',
permissions: [],
},
{
path: '/teams/browse',
permissions: [],
},
{
path: '/teams/create',
permissions: [],
},
];
const mappingPublicPrefixRoutes = [
'/hackathons',
];
// Cache to prevent redundant checks (cache for 5 seconds)
const onboardingCache = new Map<string, { hasLocation: boolean; timestamp: number }>();
const CACHE_DURATION = 5000; // 5 seconds
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;
// Allow to access the hackathon pages without authentication
if (mappingPublicPrefixRoutes.some((prefix) => pathname.startsWith(prefix))) {
return null;
}
// Public routes - allow everyone to view the landing page
if (mappingPublicRoutes.includes(pathname)) {
return null;
}
// Auth callback - allow without authentication check (for OAuth callback)
if (pathname === '/auth/callback') {
return null;
}
// Auth routes (all /auth/* paths) - redirect to dashboard if already authenticated
if (pathname.startsWith('/auth')) {
if (isAuthenticated) return redirect('/dashboard');
return null;
}
// Require authentication for all other routes
if (!isAuthenticated) {
return redirect('/auth/login');
}
// Check if user has completed onboarding
// Skip onboarding check for onboarding routes themselves
if (!mappingOnboardingRoutes.includes(pathname)) {
try {
const userId = user?.id;
if (!userId) {
return redirect('/auth/login');
}
const now = Date.now();
// Check cache first
const cached = onboardingCache.get(userId);
let hasLocation = false;
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;
}
}
// Update cache
onboardingCache.set(userId, { hasLocation, timestamp: now });
}
if (!hasLocation) {
return redirect('/onboarding/user');
}
} catch (error) {
console.error('[Middleware] Unexpected error checking onboarding:', error);
// On error, allow access (fail open)
return null;
}
}
// Check route permissions using user data
const userPermissions =
user?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
const matchedRoute = mappingRoutePermissions.find(
(route) => route.path === pathname
);
if (matchedRoute) {
const hasPermission =
!matchedRoute.permissions ||
matchedRoute.permissions.some((perm) => userPermissions.includes(perm));
if (!hasPermission) {
return redirect('/dashboard');
}
}
return null;
};
-86
View File
@@ -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);
}
-83
View File
@@ -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');
};
-27
View File
@@ -1,27 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"node",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts",
"vite/client"
]
},
"exclude": [
"src/**/*.spec.ts",
"src/**/*.test.ts",
"src/**/*.spec.tsx",
"src/**/*.test.tsx",
"src/**/*.spec.js",
"src/**/*.test.js",
"src/**/*.spec.jsx",
"src/**/*.test.jsx",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts"
],
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
}
-21
View File
@@ -1,21 +0,0 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"allowJs": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"types": ["vite/client", "vitest"]
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../tsconfig.base.json"
}
-30
View File
@@ -1,30 +0,0 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
]
}
-42
View File
@@ -1,42 +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/hackathon',
server: {
port: 3004,
host: 'localhost',
},
preview: {
port: 3005,
host: 'localhost',
},
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
build: {
outDir: '../../dist/apps/hackathon',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
},
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../coverage/apps/hackathon',
provider: 'v8' as const,
},
},
}));

Some files were not shown because too many files have changed in this diff Show More