Compare commits
77
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0017aff5f4 | ||
|
|
e52776e68a | ||
|
|
cd06a71a9e | ||
|
|
bc5b072ad8 | ||
|
|
0fcd93cb7d | ||
|
|
3470853858 | ||
|
|
ff9a02c020 | ||
|
|
f81a7c334e | ||
|
|
2700f7cbb7 | ||
|
|
cfcdfa527c | ||
|
|
c6579d5295 | ||
|
|
66b8b064ba | ||
|
|
63643cc286 | ||
|
|
19bd44a7ca | ||
|
|
a1640cfe1d | ||
|
|
00df69fb93 | ||
|
|
b3bdf25b5f | ||
|
|
810dbaa0ca | ||
|
|
cdd4fd4703 | ||
|
|
7d1b86acdb | ||
|
|
6e6c361eb8 | ||
|
|
cb09709800 | ||
|
|
62780c8889 | ||
|
|
1839f4cab0 | ||
|
|
7e385c6385 | ||
|
|
19aa5bb452 | ||
|
|
c9f5b9e4a5 | ||
|
|
33365affa2 | ||
|
|
63db7ff6ad | ||
|
|
7f8b53edd4 | ||
|
|
e8de897dc9 | ||
|
|
eb0628ac3e | ||
|
|
cbb880a053 | ||
|
|
083a912e91 | ||
|
|
eff89d28ed | ||
|
|
ab744b411d | ||
|
|
b75a88677a | ||
|
|
8947b4d05e | ||
|
|
7e3eaa6584 | ||
|
|
98a459c7d7 | ||
|
|
7642fef3e6 | ||
|
|
f40e0e35b9 | ||
|
|
c6c0d6fded | ||
|
|
9d24a11c05 | ||
|
|
ee60787100 | ||
|
|
301a349776 | ||
|
|
81488ceb6b | ||
|
|
9e99fd61e7 | ||
|
|
d36c693615 | ||
|
|
20125a6f4a | ||
|
|
4bc5006c91 | ||
|
|
71769b71aa | ||
|
|
a2cf67d210 | ||
|
|
d9c4217fd3 | ||
|
|
fcaa449630 | ||
|
|
4988980384 | ||
|
|
9547d34f5c | ||
|
|
aeb23a7255 | ||
|
|
719e36e46c | ||
|
|
bd052f4866 | ||
|
|
734e8540f2 | ||
|
|
1289555749 | ||
|
|
971fe7160f | ||
|
|
722caeec9d | ||
|
|
7404afe466 | ||
|
|
400daea60f | ||
|
|
40cd47c6be | ||
|
|
529d065a75 | ||
|
|
69d6d86342 | ||
|
|
31736cc403 | ||
|
|
1cb2443b58 | ||
|
|
9e9bfc2793 | ||
|
|
5a99f216cb | ||
|
|
efdbc65be6 | ||
|
|
f5905c0acc | ||
|
|
6348a4998b | ||
|
|
e067450fcb |
@@ -1,9 +1,18 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm run typecheck:*)"
|
||||
"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)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# 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
|
||||
@@ -0,0 +1,3 @@
|
||||
|
||||
VITE_SUPABASE_URL=https://your-project-id.supabase.co
|
||||
VITE_SUPABASE_ANON_KEY=your-anon-key-here
|
||||
@@ -7,6 +7,10 @@ on:
|
||||
paths:
|
||||
- 'apps/backoffice/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||
|
||||
concurrency:
|
||||
group: deploy-vps
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -21,15 +25,20 @@ jobs:
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
command_timeout: 30m
|
||||
script: |
|
||||
set -e
|
||||
if [ ! -d ~/imphnen-frontend-service-backoffice ]; then
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-backoffice
|
||||
else
|
||||
cd ~/imphnen-frontend-service-backoffice && git pull
|
||||
cd ~/imphnen-frontend-service-backoffice && git fetch origin && git reset --hard origin/develop && git pull
|
||||
fi
|
||||
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-backoffice/apps/backoffice/.env
|
||||
cat > ~/imphnen-frontend-service-backoffice/apps/backoffice/.env << EOF
|
||||
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
||||
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
EOF
|
||||
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml down || true
|
||||
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml build
|
||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml build --progress=plain
|
||||
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml up -d
|
||||
docker image prune -af
|
||||
|
||||
@@ -7,6 +7,10 @@ on:
|
||||
paths:
|
||||
- 'apps/dimentorin/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||
|
||||
concurrency:
|
||||
group: deploy-vps
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -21,15 +25,20 @@ jobs:
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
command_timeout: 30m
|
||||
script: |
|
||||
set -e
|
||||
if [ ! -d ~/imphnen-frontend-service-dimentorin ]; then
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-dimentorin
|
||||
else
|
||||
cd ~/imphnen-frontend-service-dimentorin && git pull
|
||||
cd ~/imphnen-frontend-service-dimentorin && git fetch origin && git reset --hard origin/develop && git pull
|
||||
fi
|
||||
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-dimentorin/apps/dimentorin/.env
|
||||
cat > ~/imphnen-frontend-service-dimentorin/apps/dimentorin/.env << EOF
|
||||
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
||||
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
EOF
|
||||
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml down || true
|
||||
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml build
|
||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml build --progress=plain
|
||||
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml up -d
|
||||
docker image prune -af
|
||||
|
||||
@@ -7,6 +7,10 @@ on:
|
||||
paths:
|
||||
- 'apps/gacha/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||
|
||||
concurrency:
|
||||
group: deploy-vps
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -21,15 +25,20 @@ jobs:
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
command_timeout: 30m
|
||||
script: |
|
||||
set -e
|
||||
if [ ! -d ~/imphnen-frontend-service-gacha ]; then
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-gacha
|
||||
else
|
||||
cd ~/imphnen-frontend-service-gacha && git pull
|
||||
cd ~/imphnen-frontend-service-gacha && git fetch origin && git reset --hard origin/develop && git pull
|
||||
fi
|
||||
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-gacha/apps/gacha/.env
|
||||
cat > ~/imphnen-frontend-service-gacha/apps/gacha/.env << EOF
|
||||
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
||||
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
EOF
|
||||
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml down || true
|
||||
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml build
|
||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml build --progress=plain
|
||||
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml up -d
|
||||
docker image prune -af
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Deploy Hackathon
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
paths:
|
||||
- 'apps/hackathon/**' # Auto deploy when changes pushed to apps/hackathon
|
||||
|
||||
concurrency:
|
||||
group: deploy-vps
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1.0.3
|
||||
with:
|
||||
host: ${{ secrets.SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
command_timeout: 30m
|
||||
script: |
|
||||
set -e
|
||||
if [ ! -d ~/imphnen-frontend-service-hackathon ]; then
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-hackathon
|
||||
else
|
||||
cd ~/imphnen-frontend-service-hackathon && git fetch origin && git reset --hard origin/develop && git pull
|
||||
fi
|
||||
cat > ~/imphnen-frontend-service-hackathon/.env.local << EOF
|
||||
VITE_API_URL=${{ secrets.VITE_API_URL }}
|
||||
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
|
||||
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
|
||||
EOF
|
||||
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml down || true
|
||||
docker builder prune -af || true
|
||||
DOCKER_BUILDKIT=1 docker compose --env-file ~/imphnen-frontend-service-hackathon/.env.local -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml build --progress=plain
|
||||
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml up -d
|
||||
docker image prune -af
|
||||
@@ -7,6 +7,10 @@ on:
|
||||
paths:
|
||||
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
|
||||
|
||||
concurrency:
|
||||
group: deploy-vps
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -21,15 +25,20 @@ jobs:
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
port: ${{ secrets.SSH_PORT }}
|
||||
command_timeout: 30m
|
||||
script: |
|
||||
set -e
|
||||
if [ ! -d ~/imphnen-frontend-service ]; then
|
||||
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service
|
||||
else
|
||||
cd ~/imphnen-frontend-service && git pull
|
||||
cd ~/imphnen-frontend-service && git fetch origin && git reset --hard origin/develop && git pull
|
||||
fi
|
||||
echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" > ~/imphnen-frontend-service/apps/landing/.env
|
||||
cat > ~/imphnen-frontend-service/apps/landing/.env << EOF
|
||||
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}
|
||||
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
|
||||
EOF
|
||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
|
||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build
|
||||
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build --progress=plain
|
||||
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
|
||||
docker image prune -af
|
||||
|
||||
@@ -62,3 +62,7 @@ storybook-static
|
||||
# Next.js
|
||||
.next
|
||||
out
|
||||
.cursor/rules/nx-rules.mdc
|
||||
.github/instructions/nx.instructions.md
|
||||
|
||||
.env.local
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Tue, Nov 25, 2025 4:21:27 PM
|
||||
@@ -0,0 +1,2 @@
|
||||
# Deployment trigger
|
||||
# Updated to deploy circular dependency fixes and auth hooks
|
||||
@@ -0,0 +1 @@
|
||||
Tue, Nov 25, 2025 4:21:27 PM
|
||||
@@ -0,0 +1,2 @@
|
||||
# Deployment trigger
|
||||
# Updated to deploy circular dependency fixes and auth hooks
|
||||
@@ -3,9 +3,8 @@
|
||||
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/utils';
|
||||
import { useAuthStore } from '@imphnen-frontend-service/service';
|
||||
|
||||
export interface GoogleLoginResponse {
|
||||
access_token?: string;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { FC, ReactElement, useEffect } from 'react';
|
||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@imphnen-frontend-service/utils';
|
||||
import { useGoogleCallback } from '@imphnen-frontend-service/service';
|
||||
import { useGoogleCallback, useAuthStore } from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const GoogleCallbackPage: FC = (): ReactElement => {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Tue, Nov 25, 2025 4:21:27 PM
|
||||
@@ -0,0 +1,2 @@
|
||||
# Deployment trigger
|
||||
# Updated to deploy circular dependency fixes and auth hooks
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Tue, Nov 25, 2025 16:42:27 PM
|
||||
@@ -0,0 +1,14 @@
|
||||
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',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,40 @@
|
||||
<!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="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>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { join } from 'path';
|
||||
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {
|
||||
base: join(import.meta.dirname, '../../'),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 511 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 351 KiB |
@@ -0,0 +1,10 @@
|
||||
<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>
|
||||
|
After Width: | Height: | Size: 7.2 KiB |
@@ -0,0 +1,23 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export default function NotFoundPage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50 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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { FC, ReactElement, useEffect, useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useAuthStore, supabase } from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const CallbackPage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const { setSession } = useAuthStore();
|
||||
const [isProcessing, setIsProcessing] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const hasRunRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCallback = async () => {
|
||||
if (hasRunRef.current) {
|
||||
// console.log('[Callback] Already processed, skipping...');
|
||||
return;
|
||||
}
|
||||
hasRunRef.current = true;
|
||||
try {
|
||||
// console.log('[Callback] Processing OAuth callback...');
|
||||
// console.log('[Callback] Current URL:', globalThis.location.href);
|
||||
|
||||
// Supabase client is configured with detectSessionInUrl: true
|
||||
// This means Supabase automatically detects and processes OAuth tokens from the URL hash
|
||||
// We just need to wait a moment for it to complete, then check for the session
|
||||
|
||||
// console.log('[Callback] Waiting for Supabase to process OAuth callback...');
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// Get the session that Supabase automatically created from the URL hash
|
||||
const {
|
||||
data: { session: sessionData },
|
||||
error: sessionError,
|
||||
} = await supabase.auth.getSession();
|
||||
|
||||
if (sessionError) {
|
||||
// console.error('[Callback] Session error:', sessionError);
|
||||
throw new Error(sessionError.message || 'Failed to get session');
|
||||
}
|
||||
|
||||
if (!sessionData || !sessionData.user) {
|
||||
throw new Error(
|
||||
'No session found after OAuth callback. Please try logging in again.'
|
||||
);
|
||||
}
|
||||
|
||||
// console.log('[Callback] Supabase session established:', {
|
||||
// userId: sessionData.user.id,
|
||||
// email: sessionData.user.email,
|
||||
// });
|
||||
|
||||
// Create/update user in the users table (for foreign key constraints)
|
||||
// console.log('[Callback] Creating/updating user record...');
|
||||
const { data: userData, error: upsertError } = await supabase
|
||||
.from('users')
|
||||
.upsert(
|
||||
{
|
||||
id: sessionData.user.id,
|
||||
email: sessionData.user.email || '',
|
||||
fullname:
|
||||
sessionData.user.user_metadata?.full_name ||
|
||||
sessionData.user.user_metadata?.name ||
|
||||
sessionData.user.email?.split('@')[0] ||
|
||||
'',
|
||||
avatar: sessionData.user.user_metadata?.avatar_url || '',
|
||||
is_active: true,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
onConflict: 'id',
|
||||
}
|
||||
)
|
||||
.select()
|
||||
.single();
|
||||
|
||||
if (upsertError) {
|
||||
// console.warn('[Callback] Failed to create user record:', upsertError);
|
||||
// Don't throw - continue with login even if user record creation fails
|
||||
} else {
|
||||
// console.log('[Callback] User record created/updated successfully');
|
||||
}
|
||||
|
||||
// Store user-friendly data in Zustand for UI purposes
|
||||
// Supabase now manages the actual auth session
|
||||
// Use data from database if available, otherwise use OAuth metadata
|
||||
const userRecord = userData || {
|
||||
id: sessionData.user.id,
|
||||
email: sessionData.user.email || '',
|
||||
fullname:
|
||||
sessionData.user.user_metadata?.full_name ||
|
||||
sessionData.user.user_metadata?.name ||
|
||||
sessionData.user.email?.split('@')[0] ||
|
||||
'',
|
||||
avatar: sessionData.user.user_metadata?.avatar_url || '',
|
||||
phone_number: '',
|
||||
birthdate: '',
|
||||
gender: '',
|
||||
is_active: true,
|
||||
};
|
||||
|
||||
setSession({
|
||||
token: {
|
||||
access_token: sessionData.access_token,
|
||||
refresh_token: sessionData.refresh_token || '',
|
||||
},
|
||||
user: {
|
||||
id: userRecord.id,
|
||||
email: userRecord.email,
|
||||
fullname: userRecord.fullname,
|
||||
phone_number: userRecord.phone_number || '',
|
||||
avatar: userRecord.avatar || '',
|
||||
birthdate: userRecord.birthdate || '',
|
||||
gender: userRecord.gender || '',
|
||||
is_active: userRecord.is_active,
|
||||
location: userRecord.location,
|
||||
bio: userRecord.bio,
|
||||
skills: userRecord.skills,
|
||||
role: {
|
||||
id: '',
|
||||
name: 'user',
|
||||
permissions: [],
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// console.log('[Callback] Session stored successfully');
|
||||
toast.success('Login successful!');
|
||||
setIsProcessing(false);
|
||||
|
||||
// Check if user has completed onboarding (has location)
|
||||
// Use globalThis.location.replace for hard redirect to prevent history issues
|
||||
if (userRecord.location) {
|
||||
// console.log('[Callback] User has completed onboarding, redirecting to dashboard...');
|
||||
globalThis.location.replace('/dashboard');
|
||||
} else {
|
||||
// console.log('[Callback] User needs onboarding, redirecting...');
|
||||
globalThis.location.replace('/onboarding/user');
|
||||
}
|
||||
} catch (err) {
|
||||
// console.error('[Callback] Error:', err);
|
||||
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
|
||||
}, []); // Run only once on mount
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 px-4">
|
||||
<div className="bg-white w-full max-w-2xl p-8 rounded-2xl shadow-lg border border-red-200">
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-red-500 text-5xl mb-4">⚠️</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">
|
||||
GitHub Login Failed
|
||||
</h2>
|
||||
<p className="text-red-600 mb-4 whitespace-pre-line">{error}</p>
|
||||
</div>
|
||||
|
||||
<p className="text-gray-600 text-sm mt-6 text-center">
|
||||
Redirecting to login page in 3 seconds...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 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;
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useState } from 'react';
|
||||
import { supabase } 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 [isLoading, setIsLoading] = useState(false);
|
||||
const [emailSent, setEmailSent] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!email) {
|
||||
toast.error('Please enter your email');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${window.location.origin}/auth/reset-password`,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
setEmailSent(true);
|
||||
toast.success('Password reset email sent! Check your inbox.');
|
||||
} catch (err) {
|
||||
// console.error('Failed to send reset email:', err);
|
||||
toast.error((err as Error).message || 'Failed to send reset email');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (emailSent) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
|
||||
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 text-center">
|
||||
<div className="mb-6">
|
||||
<div className="mx-auto w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
|
||||
<span className="text-3xl">✓</span>
|
||||
</div>
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">
|
||||
Check Your Email
|
||||
</h2>
|
||||
<p className="text-gray-600">
|
||||
We've sent a password reset link to <strong>{email}</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Click the link in the email to reset your password. The link will
|
||||
expire in 1 hour.
|
||||
</p>
|
||||
|
||||
<Link to="/auth/login">
|
||||
<button className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors">
|
||||
Back to Login
|
||||
</button>
|
||||
</Link>
|
||||
|
||||
<button
|
||||
onClick={() => setEmailSent(false)}
|
||||
className="w-full py-3 text-gray-600 hover:text-gray-900 transition-colors"
|
||||
>
|
||||
Send another email
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 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={isLoading}
|
||||
className="bg-white dark:bg-gray-800 w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isLoading ? 'Sending...' : 'Send Reset Link'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
useGitHubAuth,
|
||||
useEmailAuth,
|
||||
supabase,
|
||||
useAuthStore,
|
||||
} 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() {
|
||||
// console.log('[LoginPage] Rendering...');
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { setSession } = useAuthStore();
|
||||
const { signInWithGitHub } = useGitHubAuth();
|
||||
const { signInWithEmail } = useEmailAuth();
|
||||
const [isGithubLoading, setIsGithubLoading] = useState(false);
|
||||
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleEmailLogin = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!email || !password) {
|
||||
setError('Please enter both email and password');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsEmailLoading(true);
|
||||
// console.log('[Login] Attempting email login...');
|
||||
|
||||
const result = await signInWithEmail(email, password);
|
||||
// console.log('[Login] Email login successful:', result);
|
||||
|
||||
// Get user data from database
|
||||
const { data: userData } = await supabase
|
||||
.from('users')
|
||||
.select('*')
|
||||
.eq('id', result.user.id)
|
||||
.single();
|
||||
|
||||
// Store session in Zustand
|
||||
setSession({
|
||||
token: {
|
||||
access_token: result.session.access_token,
|
||||
refresh_token: result.session.refresh_token || '',
|
||||
},
|
||||
user: {
|
||||
id: result.user.id,
|
||||
email: result.user.email || '',
|
||||
fullname:
|
||||
userData?.fullname || result.user.user_metadata?.full_name || '',
|
||||
phone_number: userData?.phone_number || '',
|
||||
avatar: userData?.avatar || '',
|
||||
birthdate: userData?.birthdate || '',
|
||||
gender: userData?.gender || '',
|
||||
is_active: userData?.is_active || true,
|
||||
location: userData?.location,
|
||||
bio: userData?.bio,
|
||||
skills: userData?.skills,
|
||||
role: {
|
||||
id: '',
|
||||
name: 'user',
|
||||
permissions: [],
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
toast.success('Login successful!');
|
||||
|
||||
// Redirect based on onboarding status
|
||||
if (userData?.location) {
|
||||
navigate('/dashboard');
|
||||
} else {
|
||||
navigate('/onboarding/user');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Login] Email login failed:', err);
|
||||
setError((err as Error).message || 'Login failed');
|
||||
setIsEmailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGithubLogin = async () => {
|
||||
try {
|
||||
setIsGithubLoading(true);
|
||||
// console.log('[Login] Initiating GitHub OAuth...');
|
||||
|
||||
const result = await signInWithGitHub();
|
||||
// console.log('[Login] OAuth result:', result);
|
||||
|
||||
// Check if we got a redirect URL
|
||||
if (result?.url) {
|
||||
// console.log('[Login] Redirecting to GitHub OAuth:', result.url);
|
||||
// Manually redirect immediately
|
||||
globalThis.location.href = result.url;
|
||||
} else {
|
||||
// console.error('[Login] No OAuth URL returned');
|
||||
setIsGithubLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error('[Login] 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={isEmailLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={isEmailLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isEmailLoading}
|
||||
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:bg-gray-400 dark:disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||
>
|
||||
{isEmailLoading ? '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>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Don't have an account?{' '}
|
||||
<a
|
||||
href="/auth/signup"
|
||||
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
|
||||
>
|
||||
Sign up
|
||||
</a>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@imphnen-frontend-service/service';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function ResetPasswordPage() {
|
||||
const navigate = useNavigate();
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isValidToken, setIsValidToken] = useState(false);
|
||||
|
||||
// useEffect(() => {
|
||||
// // Check if we have a valid session (from the reset link)
|
||||
// supabase.auth.getSession().then(({ data: { session } }) => {
|
||||
// if (session) {
|
||||
// setIsValidToken(true);
|
||||
// } else {
|
||||
// toast.error('Invalid or expired reset link');
|
||||
// setTimeout(() => navigate('/auth/forgot-password'), 2000);
|
||||
// }
|
||||
// });
|
||||
// }, [navigate]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
toast.error('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
toast.error('Password must be at least 6 characters');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const { error } = await supabase.auth.updateUser({
|
||||
password: password,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
toast.success('Password updated successfully!');
|
||||
|
||||
// Sign out and redirect to login
|
||||
await supabase.auth.signOut();
|
||||
navigate('/auth/login');
|
||||
} catch (err) {
|
||||
// console.error('Failed to reset password:', err);
|
||||
toast.error((err as Error).message || 'Failed to reset password');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// if (!isValidToken) {
|
||||
// return (
|
||||
// <div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
// <div className="text-center">
|
||||
// <div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
|
||||
// <p className="text-gray-600 dark:text-gray-400">
|
||||
// Verifying reset link...
|
||||
// </p>
|
||||
// </div>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||
<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>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={isLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Confirm New Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={isLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
required
|
||||
minLength={6}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isLoading ? 'Updating...' : 'Update Password'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
useGitHubAuth,
|
||||
useEmailAuth,
|
||||
supabase,
|
||||
useAuthStore,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { GithubOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { Icon } from '@iconify/react';
|
||||
import { ThemeToggle } from '../../../components/theme-toggle';
|
||||
|
||||
export default function SignupPage() {
|
||||
const navigate = useNavigate();
|
||||
const { setSession } = useAuthStore();
|
||||
const { signInWithGitHub } = useGitHubAuth();
|
||||
const { signUpWithEmail } = useEmailAuth();
|
||||
const [isGithubLoading, setIsGithubLoading] = useState(false);
|
||||
const [isEmailLoading, setIsEmailLoading] = useState(false);
|
||||
const [fullname, setFullname] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleEmailSignup = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!fullname || !email || !password || !confirmPassword) {
|
||||
setError('Please fill in all fields');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
setError('Passwords do not match');
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
setError('Password must be at least 6 characters long');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsEmailLoading(true);
|
||||
// console.log('[Signup] Attempting email signup...');
|
||||
|
||||
const result = await signUpWithEmail(email, password, fullname);
|
||||
// console.log('[Signup] Email signup successful:', result);
|
||||
|
||||
if (!result.user) {
|
||||
throw new Error('Signup failed - no user returned');
|
||||
}
|
||||
|
||||
// Create user record in database
|
||||
const { error: upsertError } = await supabase.from('users').upsert(
|
||||
{
|
||||
id: result.user.id,
|
||||
email: result.user.email || '',
|
||||
fullname: fullname,
|
||||
is_active: true,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
onConflict: 'id',
|
||||
}
|
||||
);
|
||||
|
||||
if (upsertError) {
|
||||
console.warn('[Signup] Failed to create user record');
|
||||
}
|
||||
|
||||
// If session is available (email confirmation disabled), store it
|
||||
if (result.session) {
|
||||
setSession({
|
||||
token: {
|
||||
access_token: result.session.access_token,
|
||||
refresh_token: result.session.refresh_token || '',
|
||||
},
|
||||
user: {
|
||||
id: result.user.id,
|
||||
email: result.user.email || '',
|
||||
fullname: fullname,
|
||||
phone_number: '',
|
||||
avatar: '',
|
||||
birthdate: '',
|
||||
gender: '',
|
||||
is_active: true,
|
||||
role: {
|
||||
id: '',
|
||||
name: 'user',
|
||||
permissions: [],
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
toast.success('Account created successfully!');
|
||||
navigate('/onboarding/user');
|
||||
} else {
|
||||
// Email confirmation is enabled
|
||||
toast.success(
|
||||
'Account created! Please check your email to verify your account.'
|
||||
);
|
||||
setTimeout(() => {
|
||||
navigate('/auth/login');
|
||||
}, 2000);
|
||||
}
|
||||
} catch (err) {
|
||||
// console.error('[Signup] Email signup failed:', err);
|
||||
setError((err as Error).message || 'Signup failed');
|
||||
setIsEmailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGithubLogin = async () => {
|
||||
try {
|
||||
setIsGithubLoading(true);
|
||||
// console.log('[Signup] Initiating GitHub OAuth...');
|
||||
|
||||
const result = await signInWithGitHub();
|
||||
// console.log('[Signup] OAuth result:', result);
|
||||
|
||||
if (result?.url) {
|
||||
// console.log('[Signup] Redirecting to GitHub OAuth:', result.url);
|
||||
globalThis.location.href = result.url;
|
||||
} else {
|
||||
// console.error('[Signup] No OAuth URL returned');
|
||||
setIsGithubLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
// console.error('[Signup] GitHub login failed:', error);
|
||||
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">
|
||||
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={handleEmailSignup} 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"
|
||||
value={fullname}
|
||||
onChange={(e) => setFullname(e.target.value)}
|
||||
placeholder="John Doe"
|
||||
disabled={isEmailLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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={isEmailLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={isEmailLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label
|
||||
htmlFor="confirmPassword"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
|
||||
>
|
||||
Confirm Password
|
||||
</label>
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
disabled={isEmailLoading}
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isEmailLoading}
|
||||
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:bg-gray-400 dark:disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||
>
|
||||
{isEmailLoading ? '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>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||
Already have an account?{' '}
|
||||
<a
|
||||
href="/auth/login"
|
||||
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
|
||||
>
|
||||
Sign in
|
||||
</a>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
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;
|
||||
@@ -0,0 +1,307 @@
|
||||
import { FC, ReactElement, useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import {
|
||||
useMyTeams,
|
||||
useMyInvitations,
|
||||
useRespondToInvitation,
|
||||
useAuthStore,
|
||||
} 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';
|
||||
|
||||
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 [showProfileModal, setShowProfileModal] = useState(false);
|
||||
// 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: invitationsData } = useMyInvitations();
|
||||
const { mutateAsync: respondToInvitation } = useRespondToInvitation();
|
||||
|
||||
const user = session?.user;
|
||||
const myTeams = teamsData?.data || [];
|
||||
const invitations: Invitation[] = (invitationsData?.data ||
|
||||
[]) as Invitation[];
|
||||
|
||||
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>
|
||||
|
||||
{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>
|
||||
<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>
|
||||
<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 ? (
|
||||
<div className="mb-6 md:mb-8">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
My Team
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-4 md:gap-6 w-full md:max-w-lg">
|
||||
{myTeams.map((team) => (
|
||||
<Link
|
||||
key={team.id}
|
||||
to={'/teams/' + team.id}
|
||||
className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow"
|
||||
>
|
||||
{team.banner && (
|
||||
<div className="w-full aspect-3/1">
|
||||
<img
|
||||
src={team.banner}
|
||||
alt={team.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4">
|
||||
<div className="flex flex-col md:flex-row items-center space-y-3 space-x-3 -mt-14 md:mt-0 mb-2 md:mb-3 min-w-12 min-h-12 ">
|
||||
{team.logo && (
|
||||
<img
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
className="w-20 h-20 rounded-full object-cover shrink-0 border-black dark:border-gray-700"
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<h3 className="text-xl md:text-lg font-bold text-gray-900 dark:text-white leading-tight">
|
||||
{team.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 flex items-center">
|
||||
<Icon
|
||||
icon="heroicons:map-pin-16-solid"
|
||||
width="16"
|
||||
height="16"
|
||||
/>
|
||||
<span className="ml-1">{team.city}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400 line-clamp-3 text-ellipsis font-sans">
|
||||
{team.description}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mb-6 md:mb-8 bg-white dark:bg-gray-900 rounded-lg shadow-md p-6 md:p-8">
|
||||
<div className="text-center">
|
||||
<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;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
|
||||
|
||||
export default function ErrorPage() {
|
||||
const error = useRouteError();
|
||||
let errorMessage: string;
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
errorMessage = error.statusText;
|
||||
} else if (error instanceof Error) {
|
||||
errorMessage = error.message;
|
||||
} else if (typeof error === 'string') {
|
||||
errorMessage = error;
|
||||
} else {
|
||||
console.error(error);
|
||||
errorMessage = 'Unknown error';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<div className="text-center">
|
||||
<h1 className="text-6xl font-bold text-red-600 mb-4">Oops!</h1>
|
||||
<p className="text-xl text-gray-700 mb-2">
|
||||
Sorry, an unexpected error has occurred.
|
||||
</p>
|
||||
<p className="text-gray-500 italic">{errorMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Outlet, ScrollRestoration, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { supabase } 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 [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;
|
||||
}
|
||||
|
||||
// Check Supabase session
|
||||
const { data: { session }, error } = await supabase.auth.getSession();
|
||||
|
||||
if (error) {
|
||||
console.error('[Layout] Session error:', error);
|
||||
}
|
||||
|
||||
// Public auth pages (login, signup, forgot-password, reset-password) - allow unauthenticated access
|
||||
const isPublicAuthPage = pathname === '/auth/login' || pathname === '/auth/signup' || pathname === '/auth/forgot-password' || pathname === '/auth/reset-password';
|
||||
|
||||
if (isPublicAuthPage) {
|
||||
// If already authenticated and not on password reset pages, redirect to dashboard
|
||||
if (session && pathname !== '/auth/reset-password') {
|
||||
navigate('/dashboard', { replace: true });
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
// Allow unauthenticated access
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Home page - allow everyone to view the landing page
|
||||
if (pathname === '/') {
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Require authentication for all other routes
|
||||
if (!session) {
|
||||
navigate('/auth/login', { replace: true });
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if user has completed onboarding (skip for onboarding routes)
|
||||
if (!ONBOARDING_ROUTES.has(pathname)) {
|
||||
try {
|
||||
const { data: userData, error: userError } = await supabase
|
||||
.from('users')
|
||||
.select('location')
|
||||
.eq('id', session.user.id)
|
||||
.single();
|
||||
|
||||
if (userError) {
|
||||
console.error('[Layout] Failed to fetch user data:', userError);
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasLocation = !!userData?.location;
|
||||
|
||||
if (!hasLocation) {
|
||||
navigate('/onboarding/user', { replace: true });
|
||||
setIsChecking(false);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
// Silently handle error
|
||||
}
|
||||
}
|
||||
|
||||
setIsChecking(false);
|
||||
};
|
||||
|
||||
checkAuth();
|
||||
}, [location.pathname, navigate]);
|
||||
|
||||
// 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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
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) {
|
||||
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
|
||||
</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;
|
||||
@@ -0,0 +1,869 @@
|
||||
import { useNavigate } 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">
|
||||
Anka Tama
|
||||
</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>
|
||||
<a
|
||||
href="/auth/signup"
|
||||
className="hover:text-white transition-colors"
|
||||
>
|
||||
Daftar
|
||||
</a>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
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 5MB)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('The file is too large. Maximum 5MB');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!file.type.startsWith('image/')) {
|
||||
toast.error('The file must be an image');
|
||||
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 5MB
|
||||
</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;
|
||||
@@ -0,0 +1,318 @@
|
||||
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;
|
||||
@@ -0,0 +1,379 @@
|
||||
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';
|
||||
|
||||
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) {
|
||||
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) {
|
||||
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
|
||||
</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 className="flex flex-col md:flex-row items-start justify-start gap-1">
|
||||
<label htmlFor="logo" className="cursor-pointer">
|
||||
<span className="px-4 py-2 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700 inline-block">
|
||||
{logoPreview ? 'Change Logo' : 'Upload Logo'}
|
||||
</span>
|
||||
<input
|
||||
id="logo"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleLogoChange}
|
||||
/>
|
||||
</label>
|
||||
{logoPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLogoFile(null);
|
||||
setLogoPreview('');
|
||||
form.setValue('logo', null);
|
||||
}}
|
||||
className="px-4 py-2 text-sm bg-danger-600 text-white rounded-lg hover:bg-danger-700 cursor-pointer"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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;
|
||||
@@ -0,0 +1,46 @@
|
||||
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;
|
||||
@@ -0,0 +1,362 @@
|
||||
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 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)}>
|
||||
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">
|
||||
{/* 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}
|
||||
>
|
||||
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' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleRemove(member.user_id)}
|
||||
disabled={isRemoving}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</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> 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;
|
||||
@@ -0,0 +1,703 @@
|
||||
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,
|
||||
ETeamMemberRole,
|
||||
useAuthStore,
|
||||
} from '@imphnen-frontend-service/service';
|
||||
import { toast } from 'sonner';
|
||||
import { Icon } from '@iconify/react';
|
||||
|
||||
const MAX_TEAM_MEMBERS = 5;
|
||||
|
||||
// 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();
|
||||
const [showInviteModal, setShowInviteModal] = useState(false);
|
||||
const [showJoinRequestsModal, setShowJoinRequestsModal] = useState(false);
|
||||
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 { data: joinRequestsData } = useTeamJoinRequests(
|
||||
teamId || '',
|
||||
!!teamId
|
||||
);
|
||||
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(
|
||||
teamId || ''
|
||||
);
|
||||
const { mutateAsync: respondToJoinRequest, isPending: isResponding } =
|
||||
useRespondToJoinRequest(teamId || '');
|
||||
|
||||
const team = teamData?.data;
|
||||
const members = membersData?.data || [];
|
||||
const joinRequests = joinRequestsData?.data || [];
|
||||
const pendingJoinRequests = joinRequests.filter(
|
||||
(req: any) => req.status === 'pending'
|
||||
);
|
||||
const currentUserId = session?.user?.id;
|
||||
|
||||
const isLeader = currentUserId === team?.leader_id;
|
||||
const isMember = members.some(
|
||||
(member: any) => member.user_id === currentUserId
|
||||
);
|
||||
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');
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
disabled={!canInvite}
|
||||
>
|
||||
<Icon icon="mdi:plus" className="inline-block mr-2" />
|
||||
Invite Member{' '}
|
||||
{!canInvite && `(${members.length}/${MAX_TEAM_MEMBERS})`}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full relative"
|
||||
variant="secondary"
|
||||
onClick={() => setShowJoinRequestsModal(true)}
|
||||
>
|
||||
<Icon icon="mdi:email" className="inline-block mr-2" /> Join
|
||||
Requests
|
||||
{pendingJoinRequests.length > 0 && (
|
||||
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs font-bold rounded-full w-6 h-6 flex items-center justify-center">
|
||||
{pendingJoinRequests.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Link to={`/teams/${teamId}/edit`}>
|
||||
<Button className="w-full" variant="secondary">
|
||||
<Icon icon="mdi:pencil" className="inline-block mr-2" />
|
||||
Edit Team Info
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to={`/teams/${teamId}/members`}>
|
||||
<Button className="w-full" variant="secondary">
|
||||
<Icon
|
||||
icon="mdi:account-group"
|
||||
className="inline-block mr-2"
|
||||
/>
|
||||
Manage 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>
|
||||
<Link to={`/teams/${teamId}/submit`}>
|
||||
<Button className="w-full">
|
||||
<Icon icon="mdi:rocket" className="inline-block mr-2" />
|
||||
Submit Project
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
{!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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Actions for Members (non-leaders) */}
|
||||
{!isLeader && isMember && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 md:p-6">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Quick Actions
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Link to={`/teams/${teamId}/chat`}>
|
||||
<Button className="w-full" variant="secondary">
|
||||
💬 Team Chat
|
||||
</Button>
|
||||
</Link>
|
||||
{team.has_submission && (
|
||||
<Link to={`/teams/${teamId}/submission`}>
|
||||
<Button className="w-full" variant="secondary">
|
||||
📄 View Submission
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamDashboardPage;
|
||||
@@ -0,0 +1,207 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
import { useTeamById, useTeamSubmission } from '@imphnen-frontend-service/service';
|
||||
|
||||
const SubmissionViewPage: FC = (): ReactElement => {
|
||||
const { teamId } = useParams<{ teamId: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
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-neutral-950">
|
||||
<div className="text-gray-600 dark:text-neutral-400">Loading submission...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!submission) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950">
|
||||
<div className="text-6xl mb-4">📄</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">No Submission Yet</h2>
|
||||
<p className="text-gray-600 dark:text-neutral-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-neutral-950">
|
||||
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Project Submission</h1>
|
||||
<p className="text-gray-600 dark:text-neutral-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 */}
|
||||
<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>
|
||||
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-950/50 overflow-hidden">
|
||||
{/* Project Header */}
|
||||
<div className="bg-gradient-to-r from-blue-600 to-blue-800 text-white p-8">
|
||||
<h2 className="text-3xl font-bold mb-2">{submission.project_name}</h2>
|
||||
<p className="text-blue-100">Team: {team?.name}</p>
|
||||
</div>
|
||||
|
||||
{/* 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-neutral-800 rounded-lg p-4">
|
||||
<p className="text-gray-700 dark:text-neutral-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>
|
||||
)}
|
||||
|
||||
{/* Presentation URL */}
|
||||
{submission.presentation_url && (
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Presentation</h3>
|
||||
<a
|
||||
href={submission.presentation_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center space-x-2 text-blue-600 dark:text-primary-400 hover:text-blue-800 dark:hover:text-primary-300"
|
||||
>
|
||||
<span>📊</span>
|
||||
<span className="break-all">{submission.presentation_url}</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Screenshots */}
|
||||
{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-neutral-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-neutral-800 rounded-lg p-4 border-t-4 border-blue-600 dark:border-primary-500">
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-white mb-2">Submission Information</h3>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-neutral-400">Status:</span>
|
||||
<span className="font-medium text-green-600 dark:text-green-400">
|
||||
{submission.status === 'submitted' ? '✓ Submitted' : 'Draft'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-neutral-400">Submitted:</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{submittedDate}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-neutral-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;
|
||||
@@ -0,0 +1,334 @@
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { useNavigate, useParams } from 'react-router';
|
||||
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';
|
||||
|
||||
const SubmitProjectPage: FC = (): ReactElement => {
|
||||
const { teamId } = useParams<{ teamId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { session } = useAuthStore();
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [screenshots, setScreenshots] = useState<string[]>([]);
|
||||
|
||||
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 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>
|
||||
);
|
||||
}
|
||||
|
||||
const handleScreenshotUpload = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const files = e.target.files;
|
||||
if (!files) 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);
|
||||
}
|
||||
};
|
||||
|
||||
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">
|
||||
{/* 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>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="Describe your project, its features, and what problem it solves..."
|
||||
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"
|
||||
/>
|
||||
|
||||
{/* Presentation URL */}
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Presentation URL (Optional)"
|
||||
placeholder="https://slides.com/your-presentation or Google Drive link"
|
||||
name="presentation_url"
|
||||
type="url"
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
{/* Screenshots */}
|
||||
<div>
|
||||
<label className="block text-label1 font-medium text-neutral-800 dark:text-gray-300">
|
||||
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 up to 5MB 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}
|
||||
>
|
||||
Review & Submit
|
||||
</Button>
|
||||
</div>
|
||||
</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 clicking "Submit Project", you confirm that:
|
||||
</p>
|
||||
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-2">
|
||||
<li>✓ All information is correct and complete</li>
|
||||
<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="flex space-x-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => setShowConfirmModal(false)}
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
className="flex-1 bg-red-600 hover:bg-red-700"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit Project'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubmitProjectPage;
|
||||
@@ -0,0 +1,276 @@
|
||||
import { FC, ReactElement, useState, useEffect } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { Link, useNavigate } 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 BrowseTeamsPage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [selectedCity, setSelectedCity] = useState('');
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||
const [showJoinModal, setShowJoinModal] = useState(false);
|
||||
|
||||
// Debounce search term
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearch(searchTerm);
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm]);
|
||||
|
||||
const { data: teamsData, isLoading } = useTeams({
|
||||
search: debouncedSearch,
|
||||
city: selectedCity || undefined,
|
||||
visibility: ETeamVisibility.PUBLIC,
|
||||
});
|
||||
|
||||
const { data: myTeamsData } = useMyTeams();
|
||||
const { mutateAsync: joinTeam, isPending: isJoining } = useJoinTeam();
|
||||
|
||||
const form = useForm<TJoinTeamForm>({
|
||||
resolver: zodResolver(joinTeamSchema),
|
||||
mode: 'all',
|
||||
});
|
||||
|
||||
const teams = teamsData?.data || [];
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
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">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Teams List */}
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading teams...</p>
|
||||
</div>
|
||||
) : teams.length === 0 ? (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-sm p-12 text-center">
|
||||
<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) => (
|
||||
<div
|
||||
key={team.id}
|
||||
className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow flex flex-col border dark:border-gray-800"
|
||||
>
|
||||
<img
|
||||
src={team.banner || '/images/banner-imphnen.webp'}
|
||||
alt={team.name}
|
||||
className="w-full aspect-3/1 object-cover"
|
||||
/>
|
||||
<div className="p-6 flex flex-col flex-1">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
{team.logo ? (
|
||||
<img
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-gray-500 dark:text-gray-300 text-xl">
|
||||
<Icon icon="mdi:account-group" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white line-clamp-2 leading-tight">
|
||||
{team.name}
|
||||
</h3>
|
||||
<div className="text-sm font-sans text-gray-600 dark:text-gray-400 flex gap-2">
|
||||
<p className="truncate flex-1 min-w-0 flex items-center gap-x-1">
|
||||
<Icon icon="mdi:map-marker" />{' '}
|
||||
<span>{team.city}</span>
|
||||
</p>
|
||||
<p className="whitespace-nowrap shrink-0 flex items-center gap-x-1">
|
||||
<Icon icon="mdi:account-group" />{' '}
|
||||
{team.members?.length || 0} members
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm mb-4 line-clamp-3 font-sans">
|
||||
{team.description}
|
||||
</p>
|
||||
<div className="space-y-3 mt-auto">
|
||||
{isMyTeam(team.id) ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/teams/${team.id}`)}
|
||||
>
|
||||
Your Team
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{myTeams.length === 0 &&
|
||||
(team.members?.length || 0) < 5 && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => handleJoinRequest(team.id)}
|
||||
>
|
||||
Request to Join
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
onClick={() => navigate(`/teams/${team.id}`)}
|
||||
>
|
||||
View Team
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</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;
|
||||
@@ -0,0 +1,308 @@
|
||||
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 { CitySelect } from '../../../components/city-select';
|
||||
|
||||
const CreateTeamPage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
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) {
|
||||
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) {
|
||||
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,
|
||||
});
|
||||
|
||||
navigate(`/teams/${result.data.id}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to create team:', error);
|
||||
}
|
||||
});
|
||||
|
||||
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</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>
|
||||
<label htmlFor="logo" className="cursor-pointer">
|
||||
<span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">
|
||||
{logoPreview ? 'Change Logo' : 'Upload Logo'}
|
||||
</span>
|
||||
<input
|
||||
id="logo"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleLogoChange}
|
||||
/>
|
||||
</label>
|
||||
{logoPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLogoFile(null);
|
||||
setLogoPreview('');
|
||||
}}
|
||||
className="ml-3 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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;
|
||||
@@ -0,0 +1,48 @@
|
||||
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;
|
||||
@@ -0,0 +1,46 @@
|
||||
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;
|
||||
@@ -0,0 +1,236 @@
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* Teams Section */}
|
||||
{userTeams.length > 0 ? (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Teams ({userTeams.length})
|
||||
</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{userTeams.map((team: any) => (
|
||||
<Link
|
||||
key={team.id}
|
||||
to={'/teams/' + team.id}
|
||||
className="bg-white dark:bg-gray-800 rounded-lg shadow-md dark:shadow-gray-950/50 overflow-hidden hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="w-full aspect-3/1">
|
||||
<img
|
||||
src={team.banner}
|
||||
alt={team.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center space-x-3 mb-2">
|
||||
{team.logo && (
|
||||
<img
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-gray-900 dark:text-white line-clamp-2 mb-1">
|
||||
{team.name}
|
||||
</h3>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 flex gap-2 font-sans">
|
||||
<p className="truncate flex-1 min-w-0">
|
||||
<Icon
|
||||
icon="mdi:map-marker"
|
||||
className="inline-block w-4 h-4 mr-1"
|
||||
/>
|
||||
{team.city}
|
||||
</p>
|
||||
<p className="whitespace-nowrap shrink-0">
|
||||
<Icon
|
||||
icon="mdi:account-group"
|
||||
className="inline-block w-4 h-4 mr-1"
|
||||
/>
|
||||
{team.members?.length || 0} members
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-base 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">
|
||||
Teams
|
||||
</h2>
|
||||
<div className="text-center py-8">
|
||||
<div className="text-4xl mb-3">👥</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;
|
||||
@@ -0,0 +1,117 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,302 @@
|
||||
import { FC, useEffect } from 'react';
|
||||
import { Link, useLocation } from 'react-router';
|
||||
import {
|
||||
useMyTeams,
|
||||
useAuthStore,
|
||||
supabase,
|
||||
} 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 = async () => {
|
||||
try {
|
||||
await supabase.auth.signOut();
|
||||
clearSession();
|
||||
localStorage.clear();
|
||||
toast.success('Logged out successfully');
|
||||
navigate('/auth/login');
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
clearSession();
|
||||
localStorage.clear();
|
||||
navigate('/auth/login');
|
||||
}
|
||||
};
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{
|
||||
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;
|
||||
@@ -0,0 +1,100 @@
|
||||
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;
|
||||
@@ -0,0 +1,93 @@
|
||||
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;
|
||||
@@ -0,0 +1,518 @@
|
||||
const INDONESIAN_CITIES: string[] = [
|
||||
'Aceh Selatan',
|
||||
'Aceh Tenggara',
|
||||
'Aceh Timur',
|
||||
'Aceh Tengah',
|
||||
'Aceh Barat',
|
||||
'Aceh Besar',
|
||||
'Pidie',
|
||||
'Aceh Utara',
|
||||
'Simeulue',
|
||||
'Aceh Singkil',
|
||||
'Bireuen',
|
||||
'Aceh Barat Daya',
|
||||
'Gayo Lues',
|
||||
'Aceh Jaya',
|
||||
'Nagan Raya',
|
||||
'Aceh Tamiang',
|
||||
'Bener Meriah',
|
||||
'Pidie Jaya',
|
||||
'Kota Banda Aceh',
|
||||
'Kota Sabang',
|
||||
'Kota Lhokseumawe',
|
||||
'Kota Langsa',
|
||||
'Kota Subulussalam',
|
||||
'Tapanuli Tengah',
|
||||
'Tapanuli Utara',
|
||||
'Tapanuli Selatan',
|
||||
'Nias',
|
||||
'Langkat',
|
||||
'Karo',
|
||||
'Deli Serdang',
|
||||
'Simalungun',
|
||||
'Asahan',
|
||||
'Labuhanbatu',
|
||||
'Dairi',
|
||||
'Toba',
|
||||
'Mandailing Natal',
|
||||
'Nias Selatan',
|
||||
'Pakpak Bharat',
|
||||
'Humbang Hasundutan',
|
||||
'Samosir',
|
||||
'Serdang Bedagai',
|
||||
'Batu Bara',
|
||||
'Padang Lawas Utara',
|
||||
'Padang Lawas',
|
||||
'Labuhanbatu Selatan',
|
||||
'Labuhanbatu Utara',
|
||||
'Nias Utara',
|
||||
'Nias Barat',
|
||||
'Kota Medan',
|
||||
'Kota Pematangsiantar',
|
||||
'Kota Sibolga',
|
||||
'Kota Tanjung Balai',
|
||||
'Kota Binjai',
|
||||
'Kota Tebing Tinggi',
|
||||
'Kota Padangsidimpuan',
|
||||
'Kota Gunungsitoli',
|
||||
'Pesisir Selatan',
|
||||
'Solok',
|
||||
'Sijunjung',
|
||||
'Tanah Datar',
|
||||
'Padang Pariaman',
|
||||
'Agam',
|
||||
'Lima Puluh Kota',
|
||||
'Pasaman',
|
||||
'Kepulauan Mentawai',
|
||||
'Dharmasraya',
|
||||
'Solok Selatan',
|
||||
'Pasaman Barat',
|
||||
'Kota Padang',
|
||||
'Kota Solok',
|
||||
'Kota Sawahlunto',
|
||||
'Kota Padang Panjang',
|
||||
'Kota Bukittinggi',
|
||||
'Kota Payakumbuh',
|
||||
'Kota Pariaman',
|
||||
'Kampar',
|
||||
'Indragiri Hulu',
|
||||
'Bengkalis',
|
||||
'Indragiri Hilir',
|
||||
'Pelalawan',
|
||||
'Rokan Hulu',
|
||||
'Rokan Hilir',
|
||||
'Siak',
|
||||
'Kuantan Singingi',
|
||||
'Kepulauan Meranti',
|
||||
'Kota Pekanbaru',
|
||||
'Kota Dumai',
|
||||
'Kerinci',
|
||||
'Merangin',
|
||||
'Sarolangun',
|
||||
'Batanghari',
|
||||
'Muaro Jambi',
|
||||
'Tanjung Jabung Barat',
|
||||
'Tanjung Jabung Timur',
|
||||
'Bungo',
|
||||
'Tebo',
|
||||
'Kota Jambi',
|
||||
'Kota Sungai Penuh',
|
||||
'Ogan Komering Ulu',
|
||||
'Ogan Komering Ilir',
|
||||
'Muara Enim',
|
||||
'Lahat',
|
||||
'Musi Rawas',
|
||||
'Musi Banyuasin',
|
||||
'Banyuasin',
|
||||
'Ogan Komering Ulu Timur',
|
||||
'Ogan Komering Ulu Selatan',
|
||||
'Ogan Ilir',
|
||||
'Empat Lawang',
|
||||
'Penukal Abab Lematang Ilir',
|
||||
'Musi Rawas Utara',
|
||||
'Kota Palembang',
|
||||
'Kota Pagar Alam',
|
||||
'Kota Lubuk Linggau',
|
||||
'Kota Prabumulih',
|
||||
'Bengkulu Selatan',
|
||||
'Rejang Lebong',
|
||||
'Bengkulu Utara',
|
||||
'Kaur',
|
||||
'Seluma',
|
||||
'Muko Muko',
|
||||
'Lebong',
|
||||
'Kepahiang',
|
||||
'Bengkulu Tengah',
|
||||
'Kota Bengkulu',
|
||||
'Lampung Selatan',
|
||||
'Lampung Tengah',
|
||||
'Lampung Utara',
|
||||
'Lampung Barat',
|
||||
'Tulang Bawang',
|
||||
'Tanggamus',
|
||||
'Lampung Timur',
|
||||
'Way Kanan',
|
||||
'Pesawaran',
|
||||
'Pringsewu',
|
||||
'Mesuji',
|
||||
'Tulang Bawang Barat',
|
||||
'Pesisir Barat',
|
||||
'Kota Bandar Lampung',
|
||||
'Kota Metro',
|
||||
'Bangka',
|
||||
'Belitung',
|
||||
'Bangka Selatan',
|
||||
'Bangka Tengah',
|
||||
'Bangka Barat',
|
||||
'Belitung Timur',
|
||||
'Kota Pangkal Pinang',
|
||||
'Bintan',
|
||||
'Karimun',
|
||||
'Natuna',
|
||||
'Lingga',
|
||||
'Kepulauan Anambas',
|
||||
'Kota Batam',
|
||||
'Kota Tanjung Pinang',
|
||||
'Kepulauan Seribu',
|
||||
'Kota Jakarta Pusat',
|
||||
'Kota Jakarta Utara',
|
||||
'Kota Jakarta Barat',
|
||||
'Kota Jakarta Selatan',
|
||||
'Kota Jakarta Timur',
|
||||
'Bogor',
|
||||
'Sukabumi',
|
||||
'Cianjur',
|
||||
'Bandung',
|
||||
'Garut',
|
||||
'Tasikmalaya',
|
||||
'Ciamis',
|
||||
'Kuningan',
|
||||
'Cirebon',
|
||||
'Majalengka',
|
||||
'Sumedang',
|
||||
'Indramayu',
|
||||
'Subang',
|
||||
'Purwakarta',
|
||||
'Karawang',
|
||||
'Bekasi',
|
||||
'Bandung Barat',
|
||||
'Pangandaran',
|
||||
'Kota Bogor',
|
||||
'Kota Sukabumi',
|
||||
'Kota Bandung',
|
||||
'Kota Cirebon',
|
||||
'Kota Bekasi',
|
||||
'Kota Depok',
|
||||
'Kota Cimahi',
|
||||
'Kota Tasikmalaya',
|
||||
'Kota Banjar',
|
||||
'Cilacap',
|
||||
'Banyumas',
|
||||
'Purbalingga',
|
||||
'Banjarnegara',
|
||||
'Kebumen',
|
||||
'Purworejo',
|
||||
'Wonosobo',
|
||||
'Magelang',
|
||||
'Boyolali',
|
||||
'Klaten',
|
||||
'Sukoharjo',
|
||||
'Wonogiri',
|
||||
'Karanganyar',
|
||||
'Sragen',
|
||||
'Grobogan',
|
||||
'Blora',
|
||||
'Rembang',
|
||||
'Pati',
|
||||
'Kudus',
|
||||
'Jepara',
|
||||
'Demak',
|
||||
'Semarang',
|
||||
'Temanggung',
|
||||
'Kendal',
|
||||
'Batang',
|
||||
'Pekalongan',
|
||||
'Pemalang',
|
||||
'Tegal',
|
||||
'Brebes',
|
||||
'Kota Magelang',
|
||||
'Kota Surakarta',
|
||||
'Kota Salatiga',
|
||||
'Kota Semarang',
|
||||
'Kota Pekalongan',
|
||||
'Kota Tegal',
|
||||
'Kulon Progo',
|
||||
'Bantul',
|
||||
'Gunungkidul',
|
||||
'Sleman',
|
||||
'Kota Yogyakarta',
|
||||
'Pacitan',
|
||||
'Ponorogo',
|
||||
'Trenggalek',
|
||||
'Tulungagung',
|
||||
'Blitar',
|
||||
'Kediri',
|
||||
'Malang',
|
||||
'Lumajang',
|
||||
'Jember',
|
||||
'Banyuwangi',
|
||||
'Bondowoso',
|
||||
'Situbondo',
|
||||
'Probolinggo',
|
||||
'Pasuruan',
|
||||
'Sidoarjo',
|
||||
'Mojokerto',
|
||||
'Jombang',
|
||||
'Nganjuk',
|
||||
'Madiun',
|
||||
'Magetan',
|
||||
'Ngawi',
|
||||
'Bojonegoro',
|
||||
'Tuban',
|
||||
'Lamongan',
|
||||
'Gresik',
|
||||
'Bangkalan',
|
||||
'Sampang',
|
||||
'Pamekasan',
|
||||
'Sumenep',
|
||||
'Kota Kediri',
|
||||
'Kota Blitar',
|
||||
'Kota Malang',
|
||||
'Kota Probolinggo',
|
||||
'Kota Pasuruan',
|
||||
'Kota Mojokerto',
|
||||
'Kota Madiun',
|
||||
'Kota Surabaya',
|
||||
'Kota Batu',
|
||||
'Pandeglang',
|
||||
'Lebak',
|
||||
'Tangerang',
|
||||
'Serang',
|
||||
'Kota Tangerang',
|
||||
'Kota Cilegon',
|
||||
'Kota Serang',
|
||||
'Kota Tangerang Selatan',
|
||||
'Jembrana',
|
||||
'Tabanan',
|
||||
'Badung',
|
||||
'Gianyar',
|
||||
'Klungkung',
|
||||
'Bangli',
|
||||
'Karangasem',
|
||||
'Buleleng',
|
||||
'Kota Denpasar',
|
||||
'Lombok Barat',
|
||||
'Lombok Tengah',
|
||||
'Lombok Timur',
|
||||
'Sumbawa',
|
||||
'Dompu',
|
||||
'Bima',
|
||||
'Sumbawa Barat',
|
||||
'Lombok Utara',
|
||||
'Kota Mataram',
|
||||
'Kota Bima',
|
||||
'Kupang',
|
||||
'Timor Tengah Selatan',
|
||||
'Timor Tengah Utara',
|
||||
'Belu',
|
||||
'Alor',
|
||||
'Flores Timur',
|
||||
'Sikka',
|
||||
'Ende',
|
||||
'Ngada',
|
||||
'Manggarai',
|
||||
'Sumba Timur',
|
||||
'Sumba Barat',
|
||||
'Lembata',
|
||||
'Rote Ndao',
|
||||
'Manggarai Barat',
|
||||
'Nagekeo',
|
||||
'Sumba Tengah',
|
||||
'Sumba Barat Daya',
|
||||
'Manggarai Timur',
|
||||
'Sabu Raijua',
|
||||
'Malaka',
|
||||
'Kota Kupang',
|
||||
'Sambas',
|
||||
'Mempawah',
|
||||
'Sanggau',
|
||||
'Ketapang',
|
||||
'Sintang',
|
||||
'Kapuas Hulu',
|
||||
'Bengkayang',
|
||||
'Landak',
|
||||
'Sekadau',
|
||||
'Melawi',
|
||||
'Kayong Utara',
|
||||
'Kubu Raya',
|
||||
'Kota Pontianak',
|
||||
'Kota Singkawang',
|
||||
'Kotawaringin Barat',
|
||||
'Kotawaringin Timur',
|
||||
'Kapuas',
|
||||
'Barito Selatan',
|
||||
'Barito Utara',
|
||||
'Katingan',
|
||||
'Seruyan',
|
||||
'Sukamara',
|
||||
'Lamandau',
|
||||
'Gunung Mas',
|
||||
'Pulang Pisau',
|
||||
'Murung Raya',
|
||||
'Barito Timur',
|
||||
'Kota Palangkaraya',
|
||||
'Tanah Laut',
|
||||
'Kotabaru',
|
||||
'Banjar',
|
||||
'Barito Kuala',
|
||||
'Tapin',
|
||||
'Hulu Sungai Selatan',
|
||||
'Hulu Sungai Tengah',
|
||||
'Hulu Sungai Utara',
|
||||
'Tabalong',
|
||||
'Tanah Bumbu',
|
||||
'Balangan',
|
||||
'Kota Banjarmasin',
|
||||
'Kota Banjarbaru',
|
||||
'Paser',
|
||||
'Kutai Kartanegara',
|
||||
'Berau',
|
||||
'Kutai Barat',
|
||||
'Kutai Timur',
|
||||
'Penajam Paser Utara',
|
||||
'Mahakam Ulu',
|
||||
'Kota Balikpapan',
|
||||
'Kota Samarinda',
|
||||
'Kota Bontang',
|
||||
'Bulungan',
|
||||
'Malinau',
|
||||
'Nunukan',
|
||||
'Tana Tidung',
|
||||
'Kota Tarakan',
|
||||
'Bolaang Mongondow',
|
||||
'Minahasa',
|
||||
'Kepulauan Sangihe',
|
||||
'Kepulauan Talaud',
|
||||
'Minahasa Selatan',
|
||||
'Minahasa Utara',
|
||||
'Minahasa Tenggara',
|
||||
'Bolaang Mongondow Utara',
|
||||
'Kepulauan Siau Tagulandang Biaro (Sitaro)',
|
||||
'Bolaang Mongondow Timur',
|
||||
'Bolaang Mongondow Selatan',
|
||||
'Kota Manado',
|
||||
'Kota Bitung',
|
||||
'Kota Tomohon',
|
||||
'Kota Kotamobagu',
|
||||
'Banggai',
|
||||
'Poso',
|
||||
'Donggala',
|
||||
'Toli Toli',
|
||||
'Buol',
|
||||
'Morowali',
|
||||
'Banggai Kepulauan',
|
||||
'Parigi Moutong',
|
||||
'Tojo Una Una',
|
||||
'Sigi',
|
||||
'Banggai Laut',
|
||||
'Morowali Utara',
|
||||
'Kota Palu',
|
||||
'Kepulauan Selayar',
|
||||
'Bulukumba',
|
||||
'Bantaeng',
|
||||
'Jeneponto',
|
||||
'Takalar',
|
||||
'Gowa',
|
||||
'Sinjai',
|
||||
'Bone',
|
||||
'Maros',
|
||||
'Pangkajene Kepulauan',
|
||||
'Barru',
|
||||
'Soppeng',
|
||||
'Wajo',
|
||||
'Sidenreng Rappang',
|
||||
'Pinrang',
|
||||
'Enrekang',
|
||||
'Luwu',
|
||||
'Tana Toraja',
|
||||
'Luwu Utara',
|
||||
'Luwu Timur',
|
||||
'Toraja Utara',
|
||||
'Kota Makassar',
|
||||
'Kota Pare Pare',
|
||||
'Kota Palopo',
|
||||
'Kolaka',
|
||||
'Konawe',
|
||||
'Muna',
|
||||
'Buton',
|
||||
'Konawe Selatan',
|
||||
'Bombana',
|
||||
'Wakatobi',
|
||||
'Kolaka Utara',
|
||||
'Konawe Utara',
|
||||
'Buton Utara',
|
||||
'Kolaka Timur',
|
||||
'Konawe Kepulauan',
|
||||
'Muna Barat',
|
||||
'Buton Tengah',
|
||||
'Buton Selatan',
|
||||
'Kota Kendari',
|
||||
'Kota Bau Bau',
|
||||
'Gorontalo',
|
||||
'Boalemo',
|
||||
'Bone Bolango',
|
||||
'Pahuwato',
|
||||
'Gorontalo Utara',
|
||||
'Kota Gorontalo',
|
||||
'Pasangkayu (Mamuju Utara)',
|
||||
'Mamuju',
|
||||
'Mamasa',
|
||||
'Polewali Mandar',
|
||||
'Majene',
|
||||
'Mamuju Tengah',
|
||||
'Maluku Tengah',
|
||||
'Maluku Tenggara',
|
||||
'Kepulauan Tanimbar (Maluku Tenggara Barat)',
|
||||
'Buru',
|
||||
'Seram Bagian Timur',
|
||||
'Seram Bagian Barat',
|
||||
'Kepulauan Aru',
|
||||
'Maluku Barat Daya',
|
||||
'Buru Selatan',
|
||||
'Kota Ambon',
|
||||
'Kota Tual',
|
||||
'Halmahera Barat',
|
||||
'Halmahera Tengah',
|
||||
'Halmahera Utara',
|
||||
'Halmahera Selatan',
|
||||
'Kepulauan Sula',
|
||||
'Halmahera Timur',
|
||||
'Pulau Morotai',
|
||||
'Pulau Taliabu',
|
||||
'Kota Ternate',
|
||||
'Kota Tidore Kepulauan',
|
||||
'Jayapura',
|
||||
'Kepulauan Yapen',
|
||||
'Biak Numfor',
|
||||
'Sarmi',
|
||||
'Keerom',
|
||||
'Waropen',
|
||||
'Supiori',
|
||||
'Mamberamo Raya',
|
||||
'Kota Jayapura',
|
||||
'Manokwari',
|
||||
'Fak Fak',
|
||||
'Teluk Bintuni',
|
||||
'Teluk Wondama',
|
||||
'Kaimana',
|
||||
'Manokwari Selatan',
|
||||
'Pegunungan Arfak',
|
||||
'Merauke',
|
||||
'Boven Digoel',
|
||||
'Mappi',
|
||||
'Asmat',
|
||||
'Nabire',
|
||||
'Puncak Jaya',
|
||||
'Paniai',
|
||||
'Mimika',
|
||||
'Puncak',
|
||||
'Dogiyai',
|
||||
'Intan Jaya',
|
||||
'Deiyai',
|
||||
'Jayawijaya',
|
||||
'Pegunungan Bintang',
|
||||
'Yahukimo',
|
||||
'Tolikara',
|
||||
'Mamberamo Tengah',
|
||||
'Yalimo',
|
||||
'Lanny Jaya',
|
||||
'Nduga',
|
||||
'Sorong',
|
||||
'Sorong Selatan',
|
||||
'Raja Ampat',
|
||||
'Tambrauw',
|
||||
'Maybrat',
|
||||
'Kota Sorong',
|
||||
];
|
||||
|
||||
export default INDONESIAN_CITIES;
|
||||
@@ -0,0 +1,76 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,169 @@
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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>
|
||||
);
|
||||
@@ -0,0 +1,137 @@
|
||||
import { SessionUser } from '@imphnen-frontend-service/utils';
|
||||
import { supabase } 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 session from Supabase (authoritative source)
|
||||
const { data: { session: supabaseSession }, error: sessionError } = await supabase.auth.getSession();
|
||||
|
||||
// Handle session errors
|
||||
if (sessionError) {
|
||||
console.error('[Middleware] Session error:', sessionError);
|
||||
// Don't redirect on session errors, let the app handle it
|
||||
}
|
||||
|
||||
// Allow to access the hackathon pages without authentication
|
||||
if (mappingPublicPrefixRoutes.some((prefix) => pathname.startsWith(prefix))) {
|
||||
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 (supabaseSession) return redirect('/dashboard');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Require authentication for all other routes - ONLY check Supabase session
|
||||
if (!supabaseSession) {
|
||||
return redirect('/auth/login');
|
||||
}
|
||||
|
||||
// Check if user has completed onboarding by querying database (not localStorage!)
|
||||
// Skip onboarding check for onboarding routes themselves
|
||||
if (!mappingOnboardingRoutes.includes(pathname)) {
|
||||
try {
|
||||
const userId = supabaseSession.user.id;
|
||||
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 {
|
||||
const { data: userData, error: userError } = await supabase
|
||||
.from('users')
|
||||
.select('location')
|
||||
.eq('id', userId)
|
||||
.single();
|
||||
|
||||
if (userError) {
|
||||
console.error('[Middleware] Failed to fetch user data:', userError);
|
||||
// If we can't fetch user data, allow access (don't break the app)
|
||||
return null;
|
||||
}
|
||||
|
||||
hasLocation = !!userData?.location;
|
||||
|
||||
// Update cache
|
||||
onboardingCache.set(userId, { hasLocation, timestamp: now });
|
||||
}
|
||||
|
||||
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 fresh user data from Zustand (for UI metadata)
|
||||
const session = SessionUser.get();
|
||||
const userPermissions =
|
||||
session?.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;
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/// <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,
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1 @@
|
||||
Tue, Nov 25, 2025 4:21:27 PM
|
||||
@@ -0,0 +1,2 @@
|
||||
# Deployment trigger
|
||||
# Updated to deploy circular dependency fixes and auth hooks
|
||||
Vendored
+1
@@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./../../dist/apps/landing/.next/types/routes.d.ts";
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -3,7 +3,7 @@ const path = require('path');
|
||||
|
||||
/** @type {import('@nx/next/plugins/with-nx').WithNxOptions} */
|
||||
const nextConfig = {
|
||||
nx: { svgr: false },
|
||||
nx: {},
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 85 KiB |
@@ -22,4 +22,3 @@ export async function fetchPostSignin({
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export function CommunitySection() {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
ease: [0.25, 0.46, 0.45, 0.94] as any,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -30,7 +30,7 @@ export function TestimonialSection() {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
duration: 0.4,
|
||||
ease: [0.25, 0.46, 0.45, 0.94],
|
||||
ease: [0.25, 0.46, 0.45, 0.94] as any,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { CommunitySection } from '../_components/community-section';
|
||||
import { CTASection } from '../_components/cta-section';
|
||||
import { HeroSection } from '../_components/hero-section';
|
||||
import { TestimonialSection } from '../_components/testimonial-section';
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<HeroSection />
|
||||
<CommunitySection />
|
||||
<TestimonialSection />
|
||||
<CTASection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { CommunitySection } from './_components/community-section';
|
||||
import { CTASection } from './_components/cta-section';
|
||||
import { HeroSection } from './_components/hero-section';
|
||||
import { TestimonialSection } from './_components/testimonial-section';
|
||||
export default function Page() {
|
||||
return (
|
||||
<>
|
||||
<HeroSection />
|
||||
<CommunitySection />
|
||||
<TestimonialSection />
|
||||
<CTASection />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -24,32 +24,37 @@ export default function Footer() {
|
||||
</p>
|
||||
<div className="flex space-x-4">
|
||||
<Link
|
||||
href="#"
|
||||
href="https://fb.com/groups/programmerhandal"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
target="_blank"
|
||||
>
|
||||
<FaFacebook className="size-6" />
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
href="https://discord.gg/imphnen"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
target="_blank"
|
||||
>
|
||||
<FaDiscord className="size-6" />
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
href="https://www.instagram.com/imphnen.dev"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
target="_blank"
|
||||
>
|
||||
<FaInstagram className="size-6" />
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
href="https://www.tiktok.com/@imphnen"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
target="_blank"
|
||||
>
|
||||
<FaTiktok className="size-6" />
|
||||
</Link>
|
||||
<Link
|
||||
href="#"
|
||||
href="https://www.linkedin.com/company/imphnen"
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
target="_blank"
|
||||
>
|
||||
<FaLinkedinIn className="size-6" />
|
||||
</Link>
|
||||
@@ -78,6 +83,7 @@ export default function Footer() {
|
||||
<Link
|
||||
href={link}
|
||||
className="text-sm text-muted-foreground hover:text-foreground"
|
||||
target="_blank"
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { LogoSimple } from '@/app/_components/logo';
|
||||
import NAVIGATIONS from '@/data/navigations.json';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { Button } from '@components';
|
||||
import { cn } from '@utils';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
@@ -14,7 +13,6 @@ import { LuMenu, LuX } from 'react-icons/lu';
|
||||
export function Header() {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { isAuthenticated } = useAuth();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -32,13 +30,6 @@ export function Header() {
|
||||
setMobileMenuOpen(false);
|
||||
}, [pathname]);
|
||||
|
||||
const handleLogout = () => {
|
||||
document.cookie = '__imphnen_access_token__=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
document.cookie = '__imphnen_refresh_token__=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';
|
||||
router.push('/');
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 w-full z-50 bg-background/70">
|
||||
<div className="container flex h-20 items-center justify-between">
|
||||
@@ -71,31 +62,19 @@ export function Header() {
|
||||
</nav>
|
||||
|
||||
<div className="hidden md:flex items-center gap-x-3">
|
||||
{isAuthenticated ? (
|
||||
<Button
|
||||
onClick={handleLogout}
|
||||
variant="bordered"
|
||||
className="px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
Keluar
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => router.push('/signin')}
|
||||
className="px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
Masuk
|
||||
</Button>
|
||||
<Button
|
||||
variant="bordered"
|
||||
onClick={() => router.push('/signup')}
|
||||
className="px-5 py-2 text-sm font-medium shadow-lg shadow-primary/20 hover:shadow-primary/30"
|
||||
>
|
||||
Daftar
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => router.push('/signin')}
|
||||
className="px-5 py-2 text-sm font-medium"
|
||||
>
|
||||
Masuk
|
||||
</Button>
|
||||
<Button
|
||||
variant="bordered"
|
||||
onClick={() => router.push('/signup')}
|
||||
className="px-5 py-2 text-sm font-medium shadow-lg shadow-primary/20 hover:shadow-primary/30"
|
||||
>
|
||||
Daftar
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -165,40 +144,25 @@ export function Header() {
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
{isAuthenticated ? (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
handleLogout();
|
||||
}}
|
||||
variant="bordered"
|
||||
className="w-full py-4 text-base"
|
||||
>
|
||||
Keluar
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
router.push('/signin');
|
||||
}}
|
||||
className="w-full py-4 text-base"
|
||||
>
|
||||
Masuk
|
||||
</Button>
|
||||
<Button
|
||||
variant="bordered"
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
router.push('/signup');
|
||||
}}
|
||||
className="w-full py-4 text-base shadow-lg shadow-primary/20"
|
||||
>
|
||||
Daftar
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
router.push('/signin');
|
||||
}}
|
||||
className="w-full py-4 text-base"
|
||||
>
|
||||
Masuk
|
||||
</Button>
|
||||
<Button
|
||||
variant="bordered"
|
||||
onClick={() => {
|
||||
setMobileMenuOpen(false);
|
||||
router.push('/signup');
|
||||
}}
|
||||
className="w-full py-4 text-base shadow-lg shadow-primary/20"
|
||||
>
|
||||
Daftar
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import React from 'react';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
|
||||
interface HackathonContentProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function HackathonContent({ content }: HackathonContentProps) {
|
||||
return (
|
||||
<div className="prose w-full max-w-none">
|
||||
<MDXRemote source={content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { HackathonContent } from '@/content/hackathons/types';
|
||||
|
||||
interface HackathonHeaderProps {
|
||||
metadata: HackathonContent['metadata'];
|
||||
}
|
||||
|
||||
export function HackathonHeader({ metadata }: HackathonHeaderProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="max-w-2xl space-y-4 mb-8"
|
||||
>
|
||||
<div className='space-y-2'>
|
||||
<h1 className="text-2xl md:text-3xl font-bold">
|
||||
{metadata.name}
|
||||
</h1>
|
||||
{metadata.theme && (
|
||||
<p>
|
||||
{metadata.theme}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{metadata.description && (
|
||||
<p>
|
||||
{metadata.description}
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface HackathonHeroProps {
|
||||
coverImage: string;
|
||||
hackathonName: string;
|
||||
}
|
||||
|
||||
export function HackathonHero({ coverImage, hackathonName }: HackathonHeroProps) {
|
||||
return (
|
||||
<motion.div
|
||||
className="relative h-64 overflow-hidden"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6 }}
|
||||
>
|
||||
<Image
|
||||
unoptimized
|
||||
fill
|
||||
src={coverImage}
|
||||
alt={hackathonName}
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import React from 'react';
|
||||
import { HackathonContent } from '@/content/hackathons/types';
|
||||
import { BiBuilding } from 'react-icons/bi';
|
||||
|
||||
interface HackathonPartnersProps {
|
||||
partners: HackathonContent['metadata']['partners'];
|
||||
}
|
||||
|
||||
export function HackathonPartners({ partners }: HackathonPartnersProps) {
|
||||
if (!partners || partners.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="mx-auto w-24 h-24 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||
<BiBuilding className="w-12 h-12 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Sponsors Yet</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Sponsor information will appear here once partnerships are announced.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Event Sponsors</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{partners.length} sponsor{partners.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
<p>
|
||||
Kami berterima kasih kepada sponsor-sponsor berikut yang telah mendukung hackathon ini, tanpa dukungan mereka, acara ini tidak akan mungkin terlaksana.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{partners.map((partner, idx) => (
|
||||
<a
|
||||
key={idx}
|
||||
href={partner.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-4 p-4 bg-card rounded-lg border hover:shadow-md transition-all hover:border-primary/50"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={partner.logo}
|
||||
alt={partner.name}
|
||||
className="w-12 h-12 object-contain flex-shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-medium text-sm block truncate">{partner.name}</span>
|
||||
<span className="text-xs text-muted-foreground">Sponsor</span>
|
||||
</div>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
import React from 'react';
|
||||
import { HackathonContent } from '@/content/hackathons/types';
|
||||
|
||||
interface HackathonQuickInfoProps {
|
||||
metadata: HackathonContent['metadata'];
|
||||
}
|
||||
|
||||
export function HackathonQuickInfo({ metadata }: HackathonQuickInfoProps) {
|
||||
return (
|
||||
<div className="bg-card rounded-lg p-6 border">
|
||||
<h3 className="text-lg font-semibold mb-4">Quick Info</h3>
|
||||
<div className="space-y-3 text-sm">
|
||||
{metadata.prize && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Prize Pool:</span>
|
||||
<span className="font-medium">{metadata.prize}</span>
|
||||
</div>
|
||||
)}
|
||||
{metadata.minTeamSize && metadata.maxTeamSize && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Team Size:</span>
|
||||
<span className="font-medium">
|
||||
{metadata.minTeamSize === metadata.maxTeamSize
|
||||
? `${metadata.minTeamSize} person${metadata.minTeamSize > 1 ? 's' : ''}`
|
||||
: `${metadata.minTeamSize}-${metadata.maxTeamSize} people`
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{metadata.submissionsCount && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Submissions:</span>
|
||||
<span className="font-medium">{metadata.submissionsCount}</span>
|
||||
</div>
|
||||
)}
|
||||
{metadata.partnersCount && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Partners:</span>
|
||||
<span className="font-medium">{metadata.partnersCount}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import React from 'react';
|
||||
import { HackathonContent } from '@/content/hackathons/types';
|
||||
|
||||
interface HackathonRequirementsProps {
|
||||
requirements: HackathonContent['metadata']['requirements'];
|
||||
}
|
||||
|
||||
export function HackathonRequirements({ requirements }: HackathonRequirementsProps) {
|
||||
if (!requirements || requirements.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-card rounded-lg p-6 border">
|
||||
<h3 className="text-lg font-semibold mb-4">Requirements</h3>
|
||||
<div className="space-y-3">
|
||||
{requirements.map((req) => (
|
||||
<div key={req.id} className="flex items-start gap-3">
|
||||
<div className={`w-2 h-2 rounded-full mt-2 ${
|
||||
req.mandatory ? 'bg-red-500' : 'bg-blue-500'
|
||||
}`} />
|
||||
<div>
|
||||
<div className="font-medium text-sm">{req.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{req.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { HackathonContent } from '@/content/hackathons/types';
|
||||
import { HackathonHeader } from './HackathonHeader';
|
||||
import { HackathonQuickInfo } from './HackathonQuickInfo';
|
||||
import { HackathonRequirements } from './HackathonRequirements';
|
||||
|
||||
interface HackathonSidebarProps {
|
||||
metadata: HackathonContent['metadata'];
|
||||
}
|
||||
|
||||
export function HackathonSidebar({ metadata }: HackathonSidebarProps) {
|
||||
return (
|
||||
<div className="lg:col-span-1">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.2 }}
|
||||
>
|
||||
<HackathonHeader metadata={metadata} />
|
||||
</motion.div>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.3 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
<HackathonQuickInfo metadata={metadata} />
|
||||
<HackathonRequirements requirements={metadata.requirements} />
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
import React from 'react';
|
||||
import { HackathonContent } from '@/content/hackathons/types';
|
||||
import { formatDateRange } from '@/content/hackathons/utils';
|
||||
|
||||
interface HackathonStatusBarProps {
|
||||
metadata: HackathonContent['metadata'];
|
||||
progressPercent: number | null;
|
||||
daysLeftText: string | null;
|
||||
}
|
||||
|
||||
export function HackathonStatusBar({ metadata, progressPercent, daysLeftText }: HackathonStatusBarProps) {
|
||||
// Only show if we have progress and days left, and it's not too far in the past
|
||||
if (!(progressPercent !== null && daysLeftText && parseInt(daysLeftText) > -5)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="bg-muted/50 py-4">
|
||||
<div className="container mx-auto px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${
|
||||
metadata.status === 'active' ? 'bg-green-100 text-green-800' :
|
||||
metadata.status === 'upcoming' ? 'bg-blue-100 text-blue-800' :
|
||||
metadata.status === 'ended' ? 'bg-gray-100 text-gray-800' :
|
||||
'bg-yellow-100 text-yellow-800'
|
||||
}`}>
|
||||
{metadata.status?.charAt(0).toUpperCase() + metadata.status?.slice(1)}
|
||||
</span>
|
||||
{metadata.submissionWindow && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{formatDateRange(metadata.submissionWindow)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{daysLeftText && (
|
||||
<span className="text-sm font-medium">
|
||||
{daysLeftText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{progressPercent !== null && (
|
||||
<div className="mt-3">
|
||||
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { BiLinkExternal, BiLogoGithub, BiImage, BiGroup } from 'react-icons/bi';
|
||||
|
||||
interface Submission {
|
||||
team_name: string;
|
||||
project_title: string;
|
||||
description: string;
|
||||
repo_link: string;
|
||||
screenshot?: string;
|
||||
file_name?: string;
|
||||
}
|
||||
|
||||
interface HackathonSubmissionsProps {
|
||||
submissions?: Submission[];
|
||||
submissionsCount?: number;
|
||||
}
|
||||
|
||||
export function HackathonSubmissions({ submissions, submissionsCount }: HackathonSubmissionsProps) {
|
||||
if (!submissions || submissions.length === 0) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<div className="mx-auto w-24 h-24 bg-muted rounded-full flex items-center justify-center mb-4">
|
||||
<BiGroup className="w-12 h-12 text-muted-foreground" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">No Submissions Yet</h3>
|
||||
<p className="text-muted-foreground">
|
||||
Submissions will appear here once participants start submitting their projects.
|
||||
</p>
|
||||
{submissionsCount && (
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Expected submissions: {submissionsCount}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">Project Submissions</h3>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{submissions.length} project{submissions.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
|
||||
{submissions.map((submission, index) => (
|
||||
<div key={index} className="bg-card rounded-lg border hover:shadow-md transition-shadow">
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="w-full h-40 bg-muted rounded-lg rounded-b-none overflow-hidden flex items-center justify-center">
|
||||
{submission.screenshot ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={submission.screenshot}
|
||||
alt={submission.project_title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-muted-foreground">
|
||||
No Screenshot Available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 flex flex-col h-full">
|
||||
<div>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h4 className="font-semibold text-base">{submission.project_title}</h4>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<BiGroup className="w-4 h-4" />
|
||||
<span>{submission.team_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-muted-foreground leading-relaxed grow line-clamp-6">
|
||||
{submission.description}
|
||||
</p>
|
||||
|
||||
{/* Links */}
|
||||
<div className="flex flex-wrap gap-2 pt-2">
|
||||
{submission.repo_link && (
|
||||
<a
|
||||
href={submission.repo_link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-1 bg-primary/10 text-primary rounded-full text-xs hover:bg-primary/20 transition-colors"
|
||||
>
|
||||
<BiLogoGithub className="w-3 h-3" />
|
||||
Repository
|
||||
<BiLinkExternal className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { HackathonContent } from '@/content/hackathons/types';
|
||||
import { HackathonPartners } from './HackathonPartners';
|
||||
import { HackathonSubmissions } from './HackathonSubmissions';
|
||||
import { BiGroup, BiFile, BiInfoCircle } from 'react-icons/bi';
|
||||
|
||||
interface Submission {
|
||||
team_name: string;
|
||||
project_title: string;
|
||||
description: string;
|
||||
repo_link: string;
|
||||
screenshot?: string;
|
||||
file_name?: string;
|
||||
}
|
||||
|
||||
interface HackathonTabsProps {
|
||||
metadata: HackathonContent['metadata'];
|
||||
submissions?: Submission[];
|
||||
contentElement: React.ReactNode;
|
||||
}
|
||||
|
||||
export function HackathonTabs({ metadata, submissions, contentElement }: HackathonTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState('content');
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
id: 'content',
|
||||
label: 'Details',
|
||||
icon: BiInfoCircle,
|
||||
count: 0 // No count for content tab
|
||||
},
|
||||
{
|
||||
id: 'sponsors',
|
||||
label: 'Sponsors',
|
||||
icon: BiGroup,
|
||||
count: metadata.partners?.length || 0
|
||||
},
|
||||
{
|
||||
id: 'submissions',
|
||||
label: 'Submissions',
|
||||
icon: BiFile,
|
||||
count: submissions?.length || metadata.submissionsCount || 0
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{/* Tab Navigation */}
|
||||
<div className="border-b border-border">
|
||||
<nav className="flex space-x-8" aria-label="Tabs">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`relative py-4 px-1 cursor-pointer font-medium text-sm flex items-center gap-2 transition-colors`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{tab.label}</span>
|
||||
{tab.count > 0 && (
|
||||
<span className="ml-2 bg-muted text-muted-foreground px-2 py-1 rounded-full text-xs">
|
||||
{tab.count}
|
||||
</span>
|
||||
)}
|
||||
{activeTab === tab.id && (
|
||||
<motion.div
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"
|
||||
layoutId="activeTab"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="mt-6">
|
||||
<motion.div
|
||||
key={activeTab}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
{activeTab === 'content' && contentElement}
|
||||
{activeTab === 'sponsors' && (
|
||||
<HackathonPartners partners={metadata.partners} />
|
||||
)}
|
||||
{activeTab === 'submissions' && (
|
||||
<HackathonSubmissions
|
||||
submissions={submissions}
|
||||
submissionsCount={metadata.submissionsCount}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import React from 'react';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
|
||||
interface ServerHackathonContentProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export async function ServerHackathonContent({ content }: ServerHackathonContentProps) {
|
||||
return (
|
||||
<div className="prose w-full max-w-none">
|
||||
<MDXRemote source={content} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { Metadata } from 'next';
|
||||
import { getHackathonBySlug, getAllHackathonSlugs } from '@/content/hackathons/content';
|
||||
import { getDaysLeftText, getProgressPercent } from '@/content/hackathons/utils';
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@components';
|
||||
import { BsArrowLeft } from 'react-icons/bs';
|
||||
import { HackathonHero } from './_components/HackathonHero';
|
||||
import { HackathonStatusBar } from './_components/HackathonStatusBar';
|
||||
import { HackathonSidebar } from './_components/HackathonSidebar';
|
||||
import { HackathonTabs } from './_components/HackathonTabs';
|
||||
import { MDXRemote } from 'next-mdx-remote/rsc';
|
||||
|
||||
interface Props {
|
||||
params: {
|
||||
slug: string;
|
||||
};
|
||||
}
|
||||
|
||||
// Generate static params for all hackathons
|
||||
export async function generateStaticParams() {
|
||||
const slugs = await getAllHackathonSlugs();
|
||||
return slugs.map((slug) => ({
|
||||
slug,
|
||||
}));
|
||||
}
|
||||
|
||||
// Generate metadata for each hackathon
|
||||
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
||||
const resolvedParams = await params;
|
||||
const hackathon = await getHackathonBySlug(resolvedParams.slug);
|
||||
|
||||
if (!hackathon) {
|
||||
return {
|
||||
title: 'Hackathon Not Found',
|
||||
description: 'The requested hackathon could not be found.',
|
||||
};
|
||||
}
|
||||
|
||||
const { metadata } = hackathon;
|
||||
|
||||
return {
|
||||
title: metadata.seoTitle || `${metadata.name} - IMPHNEN`,
|
||||
description: metadata.seoDescription || metadata.description,
|
||||
keywords: metadata.tags?.join(', '),
|
||||
openGraph: {
|
||||
title: metadata.name,
|
||||
description: metadata.description,
|
||||
images: metadata.socialImage ? [metadata.socialImage] : [metadata.cover],
|
||||
type: 'website',
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: metadata.name,
|
||||
description: metadata.description,
|
||||
images: metadata.socialImage ? [metadata.socialImage] : [metadata.cover],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function HackathonPage({ params }: Props) {
|
||||
const resolvedParams = await params;
|
||||
const hackathon = await getHackathonBySlug(resolvedParams.slug);
|
||||
|
||||
if (!hackathon) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const { metadata, content } = hackathon;
|
||||
|
||||
// Return 404 if no content is found
|
||||
if (!content || content.trim() === '') {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const progressPercent = getProgressPercent(metadata);
|
||||
const daysLeftText = getDaysLeftText(metadata);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen container mx-auto px-4 py-8 bg-background">
|
||||
<Link href="/hackathon">
|
||||
<Button variant={'bordered'} className='mb-6 font-normal gap-2'>
|
||||
<BsArrowLeft /> <span>Back to Hackathons</span>
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
<HackathonHero
|
||||
coverImage={metadata.cover}
|
||||
hackathonName={metadata.name}
|
||||
/>
|
||||
|
||||
<HackathonStatusBar
|
||||
metadata={metadata}
|
||||
progressPercent={progressPercent}
|
||||
daysLeftText={daysLeftText}
|
||||
/>
|
||||
|
||||
<div className="container mx-auto px-4 py-12 flex gap-8">
|
||||
{/* Sidebar only */}
|
||||
<div className="max-w-xs w-full shrink-0 mx-auto lg:mx-0 lg:col-span-2">
|
||||
<HackathonSidebar metadata={metadata} />
|
||||
</div>
|
||||
|
||||
{/* Tabs section */}
|
||||
<div className="mb-12 col-span-8 lg:col-span-8">
|
||||
<HackathonTabs
|
||||
metadata={metadata}
|
||||
submissions={metadata.submissions}
|
||||
contentElement={
|
||||
<div className="prose w-full max-w-none">
|
||||
<MDXRemote source={content} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,173 +1,79 @@
|
||||
/* eslint-disable @next/next/no-img-element */
|
||||
"use client";
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import hackathons from '@/data/hackathons.json';
|
||||
import { buttonVariants } from '@components';
|
||||
import { cn } from '@utils';
|
||||
import Image from 'next/image';
|
||||
import { HiOutlineCode } from 'react-icons/hi';
|
||||
import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import { HackathonSummary } from '@/content/hackathons/types';
|
||||
import { hackathonSummaries } from '@/content/hackathons/index';
|
||||
import {
|
||||
getDaysLeftText,
|
||||
getProgressPercent,
|
||||
filterHackathons,
|
||||
sortHackathons
|
||||
} from '@/content/hackathons/utils';
|
||||
|
||||
const HackathonTags: React.FC<{ tags?: string[] }> = ({ tags }) => {
|
||||
if (!tags || tags.length === 0) return null;
|
||||
export default function HackathonsPage() {
|
||||
const sortedHackathons = [...hackathons];
|
||||
|
||||
if (sortedHackathons.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mb-3 text-xs">
|
||||
{tags.slice(0, 3).map((t) => (
|
||||
<div
|
||||
key={t}
|
||||
className="px-3 py-[2px] rounded-md border border-primary-500/50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<div className="w-2 h-2 bg-primary-500 rounded-full" />
|
||||
{t}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<section className="min-h-screen bg-background container py-10 flex items-center justify-center">
|
||||
<p className="text-muted-foreground text-lg">No hackathon projects available yet.</p>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const RegistrationProgress: React.FC<{ hackathon: HackathonSummary }> = ({ hackathon }) => {
|
||||
const status = hackathon.status?.toLowerCase().trim();
|
||||
// Hide the progress bar if the status is explicitly "ended"
|
||||
if (status === 'ended') return null;
|
||||
|
||||
const percent = getProgressPercent(hackathon);
|
||||
const label = getDaysLeftText(hackathon);
|
||||
if (percent === null && !label) return null;
|
||||
|
||||
return (
|
||||
<div className="px-6 mt-2 mb-3 flex gap-2 items-center justify-center">
|
||||
<div
|
||||
className="w-full h-[0.6rem] rounded-full border border-primary-500/50 overflow-hidden"
|
||||
aria-label="Registration progress"
|
||||
>
|
||||
<div className="h-full bg-primary rounded-full" style={{ width: `${percent ?? 0}%` }} />
|
||||
</div>
|
||||
<div className="text-xs text-primary-700 shrink-0">{label}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const HackathonCard: React.FC<{ hackathon: HackathonSummary; idx: number }> = ({ hackathon, idx }) => {
|
||||
return (
|
||||
<motion.div
|
||||
className="rounded-lg overflow-hidden shadow-sm hover:shadow-lg transition-all duration-300 bg-card group"
|
||||
return (
|
||||
<section className="min-h-screen bg-background container py-10">
|
||||
<motion.div
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{
|
||||
visible: { transition: { staggerChildren: 0.12 } },
|
||||
}}
|
||||
>
|
||||
{sortedHackathons.map((hackathon, idx) => (
|
||||
<motion.div
|
||||
key={hackathon.project_title}
|
||||
className="rounded-xl shadow-sm hover:shadow-lg transition-all duration-300 bg-card group"
|
||||
initial={{ opacity: 0, y: 40 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, delay: idx * 0.08, type: 'spring', stiffness: 60 }}
|
||||
whileHover={{ scale: 1.03, boxShadow: '0 8px 32px rgba(0,0,0,0.10)' }}
|
||||
>
|
||||
<Link href={`/hackathon/${hackathon.slug}`} className="block focus:outline-none relative">
|
||||
<div className="h-48 bg-muted overflow-hidden">
|
||||
<div className='w-full h-48 overflow-hidden object-cover'>
|
||||
{/* using img tag since it simpler to control */}
|
||||
<img
|
||||
src={hackathon.cover}
|
||||
alt={hackathon.name}
|
||||
className="object-cover object-center w-full h-full group-hover:scale-105 transition-transform duration-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='relative -mt-4 bg-card rounded-lg border-2 border-white hover:border-muted transition-all duration-300'>
|
||||
<div className="p-6">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h3 className="text-lg font-medium mb-2 text-foreground line-clamp-2">
|
||||
{hackathon.name}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm mb-2 line-clamp-3">
|
||||
{hackathon.description}
|
||||
</p>
|
||||
<HackathonTags tags={hackathon.tags} />
|
||||
</div>
|
||||
<RegistrationProgress hackathon={hackathon} />
|
||||
{hackathon.prize && (
|
||||
<div className="px-6 py-3 border-t font-medium text-lg">
|
||||
<h4 className='text-muted-foreground'>Hadiah</h4>
|
||||
<p>{hackathon.prize ?? '—'}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
export default function HackathonsPage() {
|
||||
const [filteredItems, setFilteredItems] = React.useState<HackathonSummary[]>(hackathonSummaries);
|
||||
const [searchTerm, setSearchTerm] = React.useState('');
|
||||
const [statusFilter, setStatusFilter] = React.useState<string>('all');
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
let filtered = [...hackathonSummaries];
|
||||
if (searchTerm) {
|
||||
filtered = filterHackathons(filtered, { search: searchTerm });
|
||||
}
|
||||
|
||||
if (statusFilter !== 'all') {
|
||||
filtered = filterHackathons(filtered, { status: [statusFilter] });
|
||||
}
|
||||
|
||||
filtered = sortHackathons(filtered, 'registrationStart', 'desc');
|
||||
setFilteredItems(filtered);
|
||||
}, [searchTerm, statusFilter]);
|
||||
|
||||
return (
|
||||
<section className="min-h-screen bg-background container py-10">
|
||||
{/* Search and Filter Controls */}
|
||||
{/*
|
||||
<div className="mb-8 space-y-4">
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search hackathons..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="flex-1 px-4 py-2 border border-gray-400 rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
/>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="px-4 py-2 border border-muted rounded-md focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="upcoming">Upcoming</option>
|
||||
<option value="ended">Ended</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
</div> */}
|
||||
|
||||
<motion.div
|
||||
className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-8"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{
|
||||
visible: { transition: { staggerChildren: 0.12 } },
|
||||
}}
|
||||
>
|
||||
{filteredItems.map((hackathon, idx) => (
|
||||
<HackathonCard key={hackathon.slug || hackathon.name} hackathon={hackathon} idx={idx} />
|
||||
))}
|
||||
>
|
||||
<motion.div className="h-48 bg-muted relative overflow-hidden rounded-t-xl">
|
||||
<Image
|
||||
src={`https://cdn.asepharyana.tech/imphnen/hackatons/${hackathon.file_name}`}
|
||||
alt={hackathon.project_title}
|
||||
fill
|
||||
className="object-cover object-top group-hover:scale-105 transition-transform duration-500"
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
unoptimized
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* No results message */}
|
||||
{filteredItems.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<h3 className="text-lg font-medium text-muted-foreground mb-2">
|
||||
No hackathons found
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Try adjusting your search or filter criteria
|
||||
</p>
|
||||
<div className="p-6">
|
||||
<h3 className="text-lg font-semibold mb-3 text-foreground">
|
||||
{hackathon.project_title}
|
||||
</h3>
|
||||
<div className="space-y-2 mb-4 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-2">
|
||||
<HiOutlineCode className="w-4 h-4" />
|
||||
<span>{hackathon.team_name}</span>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm mb-4 line-clamp-3">
|
||||
{hackathon.description}
|
||||
</p>
|
||||
<a
|
||||
href={hackathon.repo_link}
|
||||
target="_blank"
|
||||
className={cn(
|
||||
buttonVariants({ variant: 'bordered' }),
|
||||
'w-full text-sm'
|
||||
)}
|
||||
>
|
||||
Lihat Proyek
|
||||
</a>
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
</motion.div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,13 +2,31 @@ import { poppinsFont } from '@/lib/fonts';
|
||||
import '@/styles/globals.css';
|
||||
import { cn } from '@utils';
|
||||
import { type Metadata } from 'next';
|
||||
import NextTopLoader from 'nextjs-toploader';
|
||||
import { Providers } from './_components/providers';
|
||||
import { Toaster } from './_components/toaster';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'IMPHNEN - Ingin Menjadi Programmer Handal?',
|
||||
title: 'IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan Ngoding',
|
||||
description: 'Komunitas belajar programming untuk semua level',
|
||||
openGraph: {
|
||||
title: 'IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan Ngoding',
|
||||
description: 'Komunitas belajar programming untuk semua level',
|
||||
url: 'https://imphnen.dev',
|
||||
images: [
|
||||
{
|
||||
url: 'https://imphnen.dev/imphnen-group-cover.webp',
|
||||
alt: 'IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan Ngoding',
|
||||
width: 2550,
|
||||
height: 945,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title: 'IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan Ngoding',
|
||||
description: 'Komunitas belajar programming untuk semua level',
|
||||
images: ['https://imphnen.dev/imphnen-group-cover.webp'],
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -19,17 +37,6 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="id" suppressHydrationWarning>
|
||||
<body className={cn(poppinsFont.className, 'antialiased')}>
|
||||
<NextTopLoader
|
||||
color="#6366f1"
|
||||
initialPosition={0.08}
|
||||
crawlSpeed={200}
|
||||
height={3}
|
||||
crawl={true}
|
||||
showSpinner={true}
|
||||
easing="ease"
|
||||
speed={200}
|
||||
shadow="0 0 10px #6366f1,0 0 5px #6366f1"
|
||||
/>
|
||||
<Providers
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
title: "AI Agent Hackathon 2025"
|
||||
description: "Hackathon AI Agent untuk Kemerdekaan Indonesia"
|
||||
---
|
||||
|
||||
## Tentang Acara
|
||||
|
||||
Halo semuanya 👋, IMPHNEN dengan bangga mempersembahkan **Hackathon perdana** bertajuk **AI Agent Hackathon 2025**.
|
||||
Event ini dirancang untuk menjadi ruang eksplorasi dan kolaborasi bagi para developer, mahasiswa, maupun kreator teknologi yang ingin membangun **AI Agent inovatif** dengan semangat **Kemerdekaan Indonesia** 🇮🇩.
|
||||
|
||||
Selama satu minggu penuh, para peserta akan bekerja dalam tim untuk menciptakan solusi berbasis AI yang memanfaatkan platform teknologi terbaru.
|
||||
|
||||
## 🌟 Tema Hackathon
|
||||
|
||||
**"AI Agent for Kemerdekaan Indonesia"**
|
||||
Peserta diajak merancang agen AI yang mampu memberikan dampak positif dalam memperingati dan mengaktualisasi nilai kemerdekaan Indonesia di era digital.
|
||||
|
||||
## 📜 Ketentuan Peserta
|
||||
|
||||
1. Hackathon ini bersifat **tim-based**, dengan minimal **1 orang** dan maksimal **3 orang** per tim.
|
||||
2. Peserta **wajib menggunakan ketiga platform berikut** sebagai komponen utama proyek:
|
||||
- [lunos.tech](https://lunos.tech)
|
||||
- [mailry.co](https://mailry.co)
|
||||
- [unli.dev](https://unli.dev)
|
||||
3. Semua ide dan kode yang disubmit **harus orisinal** serta dikembangkan selama periode hackathon.
|
||||
4. Penggunaan API atau library pihak ketiga diperbolehkan, selama tidak melanggar hak cipta atau lisensi.
|
||||
5. Plagiarisme dalam bentuk apa pun akan menyebabkan diskualifikasi.
|
||||
6. Penyelenggara berhak melakukan perubahan jadwal maupun aturan dan akan mengumumkannya kepada peserta.
|
||||
7. Informasi detail dapat dilihat pada **formulir pendaftaran**.
|
||||
|
||||
## 🏆 Hadiah
|
||||
|
||||
Hadiah akan diberikan kepada **3 tim terbaik**, dengan detail lebih lanjut diumumkan pada saat acara.
|
||||
*(Catatan: pajak hadiah ditanggung oleh pemenang).*
|
||||
|
||||
## 📅 Timeline
|
||||
|
||||
* **Pendaftaran dibuka:** segera setelah pengumuman
|
||||
* **Masa pengerjaan:** 18–24 Agustus 2025
|
||||
* **Deadline submission:** Kamis, 21 Agustus 2025
|
||||
* **Pengumuman pemenang:** setelah tahap penjurian selesai
|
||||
|
||||
## Cara Ikut (Arsip)
|
||||
|
||||
1. Daftar melalui tautan yang tersedia (QR code atau kolom komentar).
|
||||
2. Bentuk timmu.
|
||||
3. Mulai ngoding dan kembangkan ide terbaikmu.
|
||||
4. Submit proyek sesuai jadwal.
|
||||
|
||||
## Catatan
|
||||
|
||||
* Event ini terbuka bagi siapa saja yang berkomitmen untuk membangun solusi kreatif.
|
||||
* Jangan sia-siakan kesempatan ini untuk berkolaborasi, belajar, dan menantang dirimu.
|
||||
* **Status:** Hackathon telah berakhir. Terima kasih untuk semua partisipan!
|
||||
@@ -1,246 +0,0 @@
|
||||
{
|
||||
"slug": "ai-agent-hackathon-2025",
|
||||
"name": "AI Agent Hackathon",
|
||||
"cover": "https://cdn.andka.my.id/imphnen/534798944_122241336080178516_4647521272813012181_n.jpg",
|
||||
"description": "Hackathon dalam rangka AI Agent Kemerdekaan Indonesia. Selama satu minggu, para peserta akan diminta untuk membuat proyek mereka sendiri secara online dengan bantuan lunos.tech, mailry.co, dan unli.dev.",
|
||||
"theme": "AI Agent untuk Kemerdekaan Indonesia",
|
||||
"status": "ended",
|
||||
"prize": "Rp. 5.000.000",
|
||||
"prizes": [
|
||||
{
|
||||
"position": "Juara 1",
|
||||
"amount": "Rp. 5.000.000",
|
||||
"description": "Hadiah utama untuk tim terbaik"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"AI Agent"
|
||||
],
|
||||
"difficulty": "intermediate",
|
||||
"submissionWindow": {
|
||||
"start": "2025-08-15T00:00:00.000Z",
|
||||
"end": "2025-08-22T23:59:59.999Z"
|
||||
},
|
||||
"minTeamSize": 1,
|
||||
"maxTeamSize": 3,
|
||||
"partnersCount": 2,
|
||||
"submissionsCount": 22,
|
||||
"partners": [
|
||||
{
|
||||
"name": "Lunos.tech",
|
||||
"logo": "https://lunos.tech/favicon.ico",
|
||||
"link": "https://lunos.tech"
|
||||
},
|
||||
{
|
||||
"name": "Mailry.co",
|
||||
"logo": "https://mailry.co/favicon.ico",
|
||||
"link": "https://mailry.co"
|
||||
},
|
||||
{
|
||||
"name": "Unli.dev",
|
||||
"logo": "https://unli.dev/favicon.ico",
|
||||
"link": "https://unli.dev"
|
||||
}
|
||||
],
|
||||
"requirements": [
|
||||
{
|
||||
"id": "lunos-tech",
|
||||
"name": "lunos.tech",
|
||||
"description": "Wajib menggunakan platform lunos.tech dalam proyek",
|
||||
"mandatory": true
|
||||
},
|
||||
{
|
||||
"id": "mailry-co",
|
||||
"name": "mailry.co",
|
||||
"description": "Wajib menggunakan platform mailry.co dalam proyek",
|
||||
"mandatory": true
|
||||
},
|
||||
{
|
||||
"id": "unli-dev",
|
||||
"name": "unli.dev",
|
||||
"description": "Wajib menggunakan platform unli.dev dalam proyek",
|
||||
"mandatory": true
|
||||
}
|
||||
],
|
||||
"seoTitle": "AI Agent Hackathon 2025 - IMPHNEN",
|
||||
"seoDescription": "Hackathon AI Agent untuk Kemerdekaan Indonesia. Bergabunglah dengan pengembang dari seluruh Indonesia untuk membangun solusi AI yang inovatif.",
|
||||
"socialImage": "https://cdn.andka.my.id/imphnen/534798944_122241336080178516_4647521272813012181_n.jpg",
|
||||
"submissions": [
|
||||
{
|
||||
"team_name": "Lineproject",
|
||||
"project_title": "LaporMerdeka",
|
||||
"description": "Platform pelaporan infrastruktur publik Indonesia yang memungkinkan warga melaporkan masalah dengan cepat dan mudah untuk Indonesia yang lebih baik.",
|
||||
"repo_link": "https://github.com/MANFIT7/lapormerdeka",
|
||||
"screenshot": "https://drive.google.com/open?id=1tbOJKacQGsfldr5TsWtzNKL65iCpADz2",
|
||||
"file_name": "Screenshot 2025-08-22 062036 - Fafnir.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Aliansi switch",
|
||||
"project_title": "News-ai",
|
||||
"description": "ai agent untuk memilah beritah hoax dengan asli",
|
||||
"repo_link": "https://github.com/7FIl/News-AI",
|
||||
"screenshot": "https://drive.google.com/open?id=1IYoOB1zqL70tpxaeopdS6VtuL8hoqpPn",
|
||||
"file_name": "Screenshot 2025-08-22 223626 - 7Fil.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Sodev Sedap",
|
||||
"project_title": "Sejarah Alternatif ID",
|
||||
"description": "Website AI Agent yang dapat memberikan user pov bagaimana jika user ada di situasi tersebut menggunakan reka adegan dengan pendekatan teks dengan gaya novel",
|
||||
"repo_link": "https://github.com/rizalkr/sejarah-alternatif-id/tree/main",
|
||||
"screenshot": "https://drive.google.com/open?id=1TMUgayvtI45gU79I-0SokQnPJP6LHQaC",
|
||||
"file_name": "Screenshot 2025-08-23 115137 - Rizal Kurnia.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Muhammad Harafsan Alhad",
|
||||
"project_title": "Elysia AI Kemerdekaan Indonesia",
|
||||
"description": "“Sebuah chatbot AI interaktif yang menampilkan Elysia (dari Honkai Impact) yang menjawab pertanyaan tentang Kemerdekaan Indonesia dengan gaya khas Elysia, lengkap dengan fitur kuis interaktif.",
|
||||
"repo_link": "https://github.com/rafsanalhad/elysia-ai-kemerdekaan",
|
||||
"screenshot": "https://drive.google.com/open?id=1_QE59lgHNbJfuVikJ5KT0_85tbJc6pMo",
|
||||
"file_name": "Screenshot 2025-08-23 131756 - Ralhad Alhad.png"
|
||||
},
|
||||
{
|
||||
"team_name": "RaflanGT",
|
||||
"project_title": "Ecobot",
|
||||
"description": "EcoBot adalah AI Agent yang hadir untuk menjawab tantangan pengelolaan sampah dan keterbatasan digitalisasi di masyarakat. Melalui WhatsApp yang akrab bagi warga, EcoBot memandu pemilahan sampah dengan analisis gambar berbasis AI sekaligus menumbuhkan kesadaran lingkungan. Kemerdekaan bukan hanya bebas dari penjajahan, tetapi juga kesadaran kolektif untuk mengelola hal-hal sederhana yang berdampak besar. Dengan langkah kecil seperti ini, desa dan masyarakat dapat mandiri secara digital, menjaga lingkungan, dan bersama-sama membawa Indonesia terus maju.",
|
||||
"repo_link": "https://github.com/mycoderisyad/raflangt-ecobot",
|
||||
"screenshot": "https://drive.google.com/open?id=1lj5DMJfxSrCwyNSLIlq-GQohJHCUDU1-",
|
||||
"file_name": "Screenshot 2025-08-23 223413 - MRisyad Raflan.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Tchh Tidak Akan",
|
||||
"project_title": "Merdeka Quiziz",
|
||||
"description": "Merdeka Quiziz merupakan web kuis yang menggunakan tema Kemerdekaan Indonesia dengan fitur gamifikasi yang membuat kuis menjadi menyenangkan, dimana setiap kuis dibuat oleh Mera (AI) dan dipersonalisasi untuk pengguna. Selain itu di Merdeka Quiziz pengguna juga dapat membahas sejarah Indonesia bersama Mera (AI).",
|
||||
"repo_link": "https://gitlab.com/personal-projects9094234/merdeka-quiziz",
|
||||
"screenshot": "https://drive.google.com/open?id=1U_UMaYABLjbO38GA9DopXebDWznFKQcI",
|
||||
"file_name": "Screenshot 2025-08-24 at 09.15.06 - Khen Cahyo.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Pengen Ikut tapi Bingung Mau Buat Apa",
|
||||
"project_title": "IMMPHNEN (Ingin Menjadi Mesin Pencari Handal Namun Enggan Ngecrawl)",
|
||||
"description": "Mesin pencari yang didesain untuk memerdekakan para pencari informasi dari tracker-tracker yang berlebihan (lelah bukan habis mencari A, nongol iklan A dimana-mana?). Memiliki fitur ringkasan pencarian, serta filter negatif penelusuran (judi & pornografi). Dibuat dengan LangSearch dan Lunos(ChatGPT 5.0).",
|
||||
"repo_link": "https://gitlab.com/myracledev/py-search-engine",
|
||||
"screenshot": "https://drive.google.com/open?id=1xNFtvGSLfWwdA4Mx46S7bx2nmwpt5CXA",
|
||||
"file_name": "{CBBB6849-BC8E-4435-9C6A-8C88C83287DF} - Mohamad Yusuf Rizaldi.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Ayam Geprek",
|
||||
"project_title": "SURA AI (Suara Rakyat)",
|
||||
"description": "SIngkatnya ini itu AI yang jadi mewakili hati rakyat Indonesia (bukan dpr). Dia bukan sekadar asisten digital, kenapa? ya karena dia kritis, cerdas, dan punya selera sinis yang bikin narasi kekuasaan gampang dibongkar. Gayanya penuh satir, dan sering pakai perumpamaan yang sangat panas. Sura AI hadir untuk menantang pemikiran, membakar semangat, dan memberikan perspektif yang ngga takut ngomong jujur tentang realita sosial dan politik.",
|
||||
"repo_link": "https://github.com/Roti18/sura-ai",
|
||||
"screenshot": "https://drive.google.com/open?id=1uUWGu08NimQ1qV9E-xu0h39HyoAYrclS",
|
||||
"file_name": "Screenshot 2025-08-24 204538 - Roti 1.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Fae",
|
||||
"project_title": "Daily Commit",
|
||||
"description": "Daily Commit adalah semacam alarm commit yang bakal ngingetin kamu kalau seharian nggak ada commit di GitHub. Tapi kalau rajin, dia juga bisa jadi cheerleader digital yang muji-muji kamu.",
|
||||
"repo_link": "https://github.com/far-id/send-mail-mailry.git",
|
||||
"screenshot": "https://drive.google.com/open?id=1GAvN4RxW_gAvOfew7LbbHANEx2F3lC5y",
|
||||
"file_name": "GITHUB~1.PNG"
|
||||
},
|
||||
{
|
||||
"team_name": "garudaStack",
|
||||
"project_title": "Tani AI",
|
||||
"description": "Tani AI adalah AI agent andalan anda untuk membantu dalam perkembangan, produktifitas serta analisis untuk komoditas pertanian anda.",
|
||||
"repo_link": "FE : https://github.com/Jazaniest/garuda-ai-frontend.git BE : https://github.com/Rifaldy1292/be-hackaton.git",
|
||||
"screenshot": "https://drive.google.com/open?id=173dS3v9sAJXMOxs7uY_SG-TUW9wIo_WM",
|
||||
"file_name": "Tani AI - M Abdillah Aljazani.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Roki Miftah Kamaludin",
|
||||
"project_title": "Mengenang Pahlawan",
|
||||
"description": "Mengenang Pahlawan adalah platform digital untuk mengenang dan mempelajari kisah pahlawan nasional Indonesia. Aplikasi ini menyajikan biografi, foto, serta informasi resmi terkait penetapan gelar pahlawan.\n\nSelain sebagai ensiklopedia digital, platform ini juga dilengkapi fitur interaktif seperti kuis edukatif, pencarian, dan poin penghargaan.",
|
||||
"repo_link": "https://github.com/rokimiftah/mengenang-pahlawan",
|
||||
"screenshot": "https://drive.google.com/open?id=140c-FyndYCAOCtKChbRaENc9fgWvQwU2",
|
||||
"file_name": "mengenang-pahlawan - Roki Miftah Kamaludin.png"
|
||||
},
|
||||
{
|
||||
"team_name": "LokerHunter",
|
||||
"project_title": "LokerKerja",
|
||||
"description": "Sebuah platform job matching yang memanfaatkan analisis CV atau portofolio untuk mengidentifikasi keahlian utama pengguna dan melakukan inferensi otomatis terhadap posisi pekerjaan yang paling sesuai.\n\nHasil analisis ini digunakan untuk memberikan rekomendasi daftar lowongan yang relevan dengan profil keterampilan pengguna. Selain itu, pengguna dapat berlangganan newsletter agar selalu mendapatkan informasi lowongan terbaru yang sesuai dengan hasil analisis CV mereka, yang kemudian akan dikirimkan langsung melalui email.\n\nMapping ke Sponsor\nUNLI = Digunakan untuk vision & reasoning engine dalam analisis CV/portofolio (misalnya parsing teks dari PDF/gambar, lalu inferensi posisi kerja yang cocok).\nLunos = Digunakan untuk parsing terstruktur (PDF ke JSON), normalisasi data, dan orkestrasi pipeline analisis.\nMailry = Digunakan untuk layanan email newsletter, agar pengguna bisa berlangganan update lowongan yang sesuai dengan profil keterampilannya.",
|
||||
"repo_link": "https://github.com/iegl3/LokerKerja",
|
||||
"screenshot": "https://drive.google.com/open?id=1qludNU5DnNPFtzowSnB_mYkPB00Ll4Rz",
|
||||
"file_name": "demo - Eagle.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Kami Gila Roblox",
|
||||
"project_title": "Pitara: Pintu Sejarah Nusantara",
|
||||
"description": "Pitara adalah platform yang bertujuan untuk meningkatkan literasi sejarah dan melawan hoaks di Indonesia. Platform ini menyediakan fitur chat AI untuk belajar sejarah, AI fact-checker untuk memverifikasi berita, forum diskusi, dan fitur pembuatan artikel otomatis. Pitara juga menjaga retensi pengguna melalui newsletter mingguan.",
|
||||
"repo_link": "https://github.com/JackBerck/pitara",
|
||||
"screenshot": "https://drive.google.com/open?id=1GIXZkRrRn21hpNMcip-QdGayLr8m2qCG",
|
||||
"file_name": "screencapture-127-0-0-1-8000-2025-08-24-22_56_54 - Zaki Dzulfikar.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Hidup Jokowi",
|
||||
"project_title": "Historia",
|
||||
"description": "Historia, sebuah platform revolusioner yang menjembatani masa lalu dengan masa kini. kami memanfaatkan kekuatan kecerdasan buatan (AI) untuk menganalisis dan memberikan narasi pada foto-foto dan dokumen bersejarah Indonesia. cukup unggah sebuah gambar, dan biarkan teknologi kami mengungkap cerita, tokoh, serta konteks di balik momen beku tersebut. mari jelajahi kembali perjuangan bangsa dengan cara yang belum pernah ada sebelumnya.",
|
||||
"repo_link": "https://github.com/mybday123/historia",
|
||||
"screenshot": "https://drive.google.com/open?id=1RaMiswa7Fy5m3V1xsoODvKabEwTspu2y",
|
||||
"file_name": "Historia_-_Preview - Julian Mifta Yama Fauzan.png"
|
||||
},
|
||||
{
|
||||
"team_name": "CORTEZA FAMILY",
|
||||
"project_title": "Garuda Shield - Criminal Website Detector",
|
||||
"description": "Garuda Shield - Criminal Website Detector: Adalah Web analysis berbasis Crawling yang memanfaatkan AI Untuk mendeteksi anomali pada suatu web menggunakan: LunosTech, Mailry, Unli.Dev serta Crawler Tools",
|
||||
"repo_link": "https://github.com/c0rt3z4/hackathon-imphnen",
|
||||
"screenshot": "https://drive.google.com/open?id=1AKJ7pN7zUEAoJEcZFAxGVtSE582r2Hw5",
|
||||
"file_name": "Capture - Calm.PNG"
|
||||
},
|
||||
{
|
||||
"team_name": "Oziral",
|
||||
"project_title": "Kerja Merdeka - AI Agent Pendamping Pelamar Kerja",
|
||||
"description": "Kerja Merdeka – AI Agent Pendamping Pelamar Kerja adalah platform berbasis kecerdasan buatan yang membantu pencari kerja menyusun CV dan Cover Letter yang relevan, berlatih interview secara interaktif, hingga mengirimkan lamaran dalam satu alur terpadu.",
|
||||
"repo_link": "frontend : https://github.com/lakhatekno/imphnen-frontend, backend: https://github.com/Contsol-dev/kerja-merdeka-be",
|
||||
"screenshot": "https://drive.google.com/open?id=1l7IOCTj1tRJTtFgq0Wq8CJ8NaDYZ-DS1",
|
||||
"file_name": "Screenshot 2025-08-24 230728 - Muhammad Iqbal Ghozy.png"
|
||||
},
|
||||
{
|
||||
"team_name": "ak mw heketon",
|
||||
"project_title": "MerdekAI",
|
||||
"description": "Kita sedang mengembangkan sebuah chatbot AI versi low budget yang tetap powerful dan fungsional. Meskipun budget pembuatan murah bahkan gratis dibanding ChatGPT, fitur-fiturnya gak kalah lengkap. Chatbot ini mendukung:\n\nChat Completion (percakapan interaktif seperti ChatGPT)\n\nText-to-Voice (mengubah teks menjadi suara)\n\nImage Generation (membuat gambar dari prompt)\n\nImage Recognition (mengidentifikasi dan mendeskripsikan gambar)\n\nJadi, meskipun gak ada dana keluar, project ini dirancang supaya tetap memberikan pengalaman mirip ChatGPT dengan fitur-fitur AI kekinian ygy.",
|
||||
"repo_link": "https://github.com/kevinalvarel/merdekai",
|
||||
"screenshot": "https://drive.google.com/open?id=1U24L8_4c2nM088olI7V8LaqUGcOfg1YH",
|
||||
"file_name": "merdekai.my.id_ - Muhammad Kevin Alvarel.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Er Project",
|
||||
"project_title": "Agentic Merdeka",
|
||||
"description": "Multi-modal AI Chat interface, dengan kombinasi beberapa capability. Diantaranya:\n\nConversation, Image Analisis, Generate Embeddings Vector, Generate voice, Dan yang terakhir Generate Gambar, bisa build character ai sendiri, select persona dll\n\nFramework:\nNextjs 15+ (app router)\n\nDatabseses:\nFirebase untuk penyimpanan chat history dan login\n\nDilengkapi proteksi CSRF, Next Middleware dan Authentikasi menggunakan mailry\n\nSEMUA ITU DAPAT DI AKSES melalui satu web interface. Ini sudah malas, JANGAN ANGGAP PROYEK INI RAJIN🗿",
|
||||
"repo_link": "https://github.com/ErRickow/ai-agent-hackathon",
|
||||
"screenshot": "https://drive.google.com/open?id=1EkVWezUIXc_F_9IeM3LeX47TFDl2j2Va",
|
||||
"file_name": "download - Er Rickow.png"
|
||||
},
|
||||
{
|
||||
"team_name": "NamamuCore",
|
||||
"project_title": "Namamu - Startup Name Generator",
|
||||
"description": "Namamu.web.id merupakan situs generator nama sederhana yang memudahkan brainstorming ide platform, dengan tambahan fitur pengiriman hasil ke email.",
|
||||
"repo_link": "https://github.com/nooradn/namamu-name-gen",
|
||||
"screenshot": "https://drive.google.com/open?id=1EW6wBuujpHkhT1tW-_j6TklSgqvZt7wa",
|
||||
"file_name": "preview - Noor Adn.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Tim GakTau.Dev",
|
||||
"project_title": "Quiz Kemerdekaan",
|
||||
"description": "Sebuah aplikasi kuis interaktif berbasis AI untuk membantu pelajar dan penggemar sejarah Indonesia memahami peristiwa kemerdekaan dengan cara yang menyenangkan",
|
||||
"repo_link": "https://github.com/RAYDENFLY/Quiz-Merdeka/tree/main",
|
||||
"screenshot": "https://drive.google.com/open?id=1c0A6ijx_pNCmh87g_EvlyUAAV7zAT8Li",
|
||||
"file_name": "Gambar WhatsApp 2025-08-24 pukul 21.36.26_53cb7a14 - RAYDENFLY.jpg"
|
||||
},
|
||||
{
|
||||
"team_name": "Icikiwir semilir",
|
||||
"project_title": "Chef AI",
|
||||
"description": "chat bot untuk mendapatkan resep dari AI",
|
||||
"repo_link": "https://github.com/ranggacey/chef",
|
||||
"screenshot": "https://drive.google.com/open?id=1JCz7pEfM_nF--ZsAZvQSlUonJCjTZkh6",
|
||||
"file_name": "Screenshot 2025-08-24 235059 - Diablo volfir.png"
|
||||
},
|
||||
{
|
||||
"team_name": "Greatvitech Team",
|
||||
"project_title": "Patriotisme Quiz",
|
||||
"description": "Sebuah aplikasi quiz bertema patriotisme, pengguna bisa menjawab soal - soal yang berkaitan dengan patriotisme, serta soal digenerate langsung oleh ai",
|
||||
"repo_link": "frontend: https://github.com/farhanangwa12/patriot-frontend backend: https://github.com/farhanangwa12/patriot-backend",
|
||||
"screenshot": "https://drive.google.com/open?id=1IAKk_ShPo51_CmqaClgv1ulXKrlf2fHA",
|
||||
"file_name": "Screenshot 2025-08-24 221129 - farhan hokado.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,268 +0,0 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { HackathonMetadata, HackathonContent, HackathonSummary } from './types';
|
||||
import { readMDXFile } from '../shared/mdx';
|
||||
import { validateHackathon } from './validation';
|
||||
import { toHackathonSummary, calculateHackathonStatus, sortHackathons, filterHackathons } from './utils';
|
||||
|
||||
const HACKATHONS_DIR = path.join(process.cwd(), 'src/content/hackathons');
|
||||
|
||||
/**
|
||||
* Load metadata from a hackathon's metadata.json file
|
||||
*/
|
||||
async function loadHackathonMetadata(hackathonDir: string): Promise<HackathonMetadata | null> {
|
||||
const metadataPath = path.join(hackathonDir, 'metadata.json');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(metadataPath)) {
|
||||
console.warn(`No metadata.json found in ${hackathonDir}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Read and parse JSON file
|
||||
const metadataContent = fs.readFileSync(metadataPath, 'utf-8');
|
||||
const metadata = JSON.parse(metadataContent) as HackathonMetadata;
|
||||
|
||||
// Validate the metadata
|
||||
const validation = validateHackathon(metadata);
|
||||
if (!validation.isValid) {
|
||||
console.error(`Invalid metadata in ${hackathonDir}:`, validation.errors);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate status if not explicitly set
|
||||
const finalMetadata: HackathonMetadata = {
|
||||
...metadata,
|
||||
status: metadata.status || calculateHackathonStatus(metadata),
|
||||
contentPath: path.relative(HACKATHONS_DIR, hackathonDir),
|
||||
lastModified: fs.statSync(metadataPath).mtime.toISOString()
|
||||
};
|
||||
|
||||
return finalMetadata;
|
||||
} catch (error) {
|
||||
console.error(`Error loading metadata from ${metadataPath}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load content from a hackathon's content.mdx file
|
||||
*/
|
||||
async function loadHackathonContent(hackathonDir: string): Promise<string | null> {
|
||||
const contentPath = path.join(hackathonDir, 'content.mdx');
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(contentPath)) {
|
||||
console.warn(`No content.mdx found in ${hackathonDir}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const mdxContent = await readMDXFile(contentPath);
|
||||
return mdxContent?.content || null;
|
||||
} catch (error) {
|
||||
console.error(`Error loading content from ${contentPath}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all hackathon directories
|
||||
*/
|
||||
function getHackathonDirectories(): string[] {
|
||||
if (!fs.existsSync(HACKATHONS_DIR)) {
|
||||
console.warn(`Hackathons directory not found: ${HACKATHONS_DIR}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs.readdirSync(HACKATHONS_DIR, { withFileTypes: true })
|
||||
.filter(dirent => dirent.isDirectory())
|
||||
.map(dirent => path.join(HACKATHONS_DIR, dirent.name))
|
||||
.filter(dir => {
|
||||
// Only include directories that have either metadata.json or content.mdx
|
||||
const hasMetadata = fs.existsSync(path.join(dir, 'metadata.json'));
|
||||
const hasContent = fs.existsSync(path.join(dir, 'content.mdx'));
|
||||
return hasMetadata || hasContent;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all hackathons with full content (build-time)
|
||||
*/
|
||||
export async function getAllHackathons(options: {
|
||||
includeDrafts?: boolean;
|
||||
sortBy?: 'name' | 'registrationStart' | 'status';
|
||||
sortDirection?: 'asc' | 'desc';
|
||||
} = {}): Promise<HackathonContent[]> {
|
||||
const hackathonDirs = getHackathonDirectories();
|
||||
const hackathons: HackathonContent[] = [];
|
||||
|
||||
for (const dir of hackathonDirs) {
|
||||
const metadata = await loadHackathonMetadata(dir);
|
||||
if (!metadata) continue;
|
||||
|
||||
// Skip drafts unless explicitly included
|
||||
if (!options.includeDrafts && metadata.status === 'draft') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = await loadHackathonContent(dir);
|
||||
|
||||
hackathons.push({
|
||||
metadata,
|
||||
content: content || ''
|
||||
});
|
||||
}
|
||||
|
||||
// Sort if requested
|
||||
if (options.sortBy) {
|
||||
const summaries = hackathons.map(h => toHackathonSummary(h.metadata));
|
||||
const sortedSummaries = sortHackathons(summaries, options.sortBy, options.sortDirection);
|
||||
|
||||
// Reorder hackathons based on sorted summaries
|
||||
return sortedSummaries.map(summary => {
|
||||
const hackathon = hackathons.find(h => h.metadata.slug === summary.slug);
|
||||
return hackathon;
|
||||
}).filter((hackathon): hackathon is HackathonContent => hackathon !== undefined);
|
||||
}
|
||||
|
||||
return hackathons;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get hackathon summaries for listing pages (build-time)
|
||||
*/
|
||||
export async function getHackathonSummaries(options: {
|
||||
includeDrafts?: boolean;
|
||||
sortBy?: 'name' | 'registrationStart' | 'status';
|
||||
sortDirection?: 'asc' | 'desc';
|
||||
filters?: {
|
||||
status?: string[];
|
||||
tags?: string[];
|
||||
search?: string;
|
||||
};
|
||||
} = {}): Promise<HackathonSummary[]> {
|
||||
const hackathons = await getAllHackathons({
|
||||
includeDrafts: options.includeDrafts,
|
||||
sortBy: options.sortBy,
|
||||
sortDirection: options.sortDirection
|
||||
});
|
||||
|
||||
let summaries = hackathons.map(h => toHackathonSummary(h.metadata));
|
||||
|
||||
// Apply filters if provided
|
||||
if (options.filters) {
|
||||
summaries = filterHackathons(summaries, options.filters);
|
||||
}
|
||||
|
||||
return summaries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single hackathon by slug (build-time)
|
||||
*/
|
||||
export async function getHackathonBySlug(slug: string): Promise<HackathonContent | null> {
|
||||
console.log('Fetching hackathon by slug:', slug);
|
||||
const hackathonDir = path.join(HACKATHONS_DIR, slug);
|
||||
console.log('Resolved hackathon directory:', hackathonDir);
|
||||
|
||||
if (!fs.existsSync(hackathonDir)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const metadata = await loadHackathonMetadata(hackathonDir);
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = await loadHackathonContent(hackathonDir);
|
||||
|
||||
return {
|
||||
metadata,
|
||||
content: content || ''
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all hackathon slugs (for static generation)
|
||||
*/
|
||||
export async function getAllHackathonSlugs(): Promise<string[]> {
|
||||
const hackathons = await getAllHackathons({ includeDrafts: false });
|
||||
return hackathons.map(h => h.metadata.slug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate static index file for runtime use
|
||||
*/
|
||||
export async function generateHackathonIndex(): Promise<void> {
|
||||
const summaries = await getHackathonSummaries({
|
||||
includeDrafts: false,
|
||||
sortBy: 'registrationStart',
|
||||
sortDirection: 'desc'
|
||||
});
|
||||
|
||||
const indexContent = `// Auto-generated file - do not edit manually
|
||||
// Generated on: ${new Date().toISOString()}
|
||||
|
||||
import { HackathonSummary } from './types';
|
||||
|
||||
export const hackathonSummaries: HackathonSummary[] = ${JSON.stringify(summaries, null, 2)};
|
||||
|
||||
export const hackathonSlugs = ${JSON.stringify(summaries.map(s => s.slug), null, 2)};
|
||||
|
||||
export function getHackathonSummaryBySlug(slug: string): HackathonSummary | undefined {
|
||||
return hackathonSummaries.find(h => h.slug === slug);
|
||||
}
|
||||
|
||||
export function getActiveHackathons(): HackathonSummary[] {
|
||||
return hackathonSummaries.filter(h => h.status === 'active');
|
||||
}
|
||||
|
||||
export function getUpcomingHackathons(): HackathonSummary[] {
|
||||
return hackathonSummaries.filter(h => h.status === 'upcoming');
|
||||
}
|
||||
|
||||
export function getFeaturedHackathons(): HackathonSummary[] {
|
||||
return hackathonSummaries.filter(h => (h as any).featured === true);
|
||||
}
|
||||
`;
|
||||
|
||||
const indexPath = path.join(HACKATHONS_DIR, 'index.ts');
|
||||
fs.writeFileSync(indexPath, indexContent, 'utf-8');
|
||||
|
||||
console.log(`Generated hackathon index with ${summaries.length} hackathons`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Development helper - watch for changes and regenerate index
|
||||
*/
|
||||
export function watchHackathonChanges(): void {
|
||||
if (process.env.NODE_ENV !== 'development') {
|
||||
return;
|
||||
}
|
||||
|
||||
fs.watch(HACKATHONS_DIR, { recursive: true }, (eventType, filename) => {
|
||||
if (filename && (filename.includes('metadata.json') || filename.includes('content.mdx'))) {
|
||||
console.log(`Hackathon content changed: ${filename}`);
|
||||
generateHackathonIndex().catch(console.error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build-time optimization: precompile all hackathon content
|
||||
*/
|
||||
export async function precompileHackathons(): Promise<void> {
|
||||
console.log('Precompiling hackathon content...');
|
||||
|
||||
const hackathons = await getAllHackathons({ includeDrafts: false });
|
||||
|
||||
// Generate the main index
|
||||
await generateHackathonIndex();
|
||||
|
||||
// Could add more optimizations here like:
|
||||
// - Image optimization
|
||||
// - Content minification
|
||||
// - Search index generation
|
||||
|
||||
console.log(`Precompiled ${hackathons.length} hackathons`);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
// Auto-generated file - do not edit manually
|
||||
// This file will be regenerated by the build process
|
||||
// Generated on: 2025-09-17T00:00:00.000Z
|
||||
|
||||
import { HackathonSummary } from './types';
|
||||
|
||||
export const hackathonSummaries: HackathonSummary[] = [
|
||||
{
|
||||
slug: 'ai-agent-hackathon-2025',
|
||||
name: 'AI Agent Hackathon',
|
||||
cover: 'https://cdn.andka.my.id/imphnen/534798944_122241336080178516_4647521272813012181_n.jpg',
|
||||
description: 'Hackathon dalam rangka AI Agent Kemerdekaan Indonesia. Selama satu minggu, para peserta akan diminta untuk membuat proyek mereka sendiri secara online dengan bantuan lunos.tech, mailry.co, dan unli.dev.',
|
||||
prize: 'Rp. 5.000.000',
|
||||
tags: ['AI Agent'],
|
||||
theme: 'AI Agent untuk Kemerdekaan Indonesia',
|
||||
status: 'ended',
|
||||
partnersCount: 2,
|
||||
submissionsCount: 22,
|
||||
submissionWindow: {
|
||||
start: '2025-08-15T00:00:00.000Z',
|
||||
end: '2025-09-20T23:59:59.999Z'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
export const hackathonSlugs = ['ai-agent-hackathon-2025'];
|
||||
|
||||
export function getHackathonSummaryBySlug(slug: string): HackathonSummary | undefined {
|
||||
return hackathonSummaries.find(h => h.slug === slug);
|
||||
}
|
||||
|
||||
export function getActiveHackathons(): HackathonSummary[] {
|
||||
return hackathonSummaries.filter(h => h.status === 'active');
|
||||
}
|
||||
|
||||
export function getUpcomingHackathons(): HackathonSummary[] {
|
||||
return hackathonSummaries.filter(h => h.status === 'upcoming');
|
||||
}
|
||||
|
||||
export function getFeaturedHackathons(): HackathonSummary[] {
|
||||
return hackathonSummaries.filter(h => (h as Record<string, unknown>).featured === true);
|
||||
}
|
||||
|
||||
export function getEndedHackathons(): HackathonSummary[] {
|
||||
return hackathonSummaries.filter(h => h.status === 'ended');
|
||||
}
|
||||
@@ -1,138 +0,0 @@
|
||||
export interface HackathonSubmission {
|
||||
team_name: string;
|
||||
project_title: string;
|
||||
description: string;
|
||||
repo_link: string;
|
||||
screenshot: string;
|
||||
file_name: string;
|
||||
}
|
||||
|
||||
export interface HackathonPartner {
|
||||
name: string;
|
||||
logo: string;
|
||||
link: string;
|
||||
}
|
||||
|
||||
export interface HackathonPrize {
|
||||
position: string;
|
||||
amount: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface HackathonTimeWindow {
|
||||
start: string; // ISO date string
|
||||
end: string; // ISO date string
|
||||
}
|
||||
|
||||
export interface HackathonJudge {
|
||||
name: string;
|
||||
title: string;
|
||||
company?: string;
|
||||
avatar?: string;
|
||||
bio?: string;
|
||||
}
|
||||
|
||||
export interface HackathonSponsor {
|
||||
name: string;
|
||||
logo: string;
|
||||
link: string;
|
||||
tier: 'title' | 'platinum' | 'gold' | 'silver' | 'bronze';
|
||||
}
|
||||
|
||||
export interface HackathonRequirement {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
mandatory: boolean;
|
||||
}
|
||||
|
||||
export interface HackathonMetadata {
|
||||
slug: string;
|
||||
name: string;
|
||||
cover: string;
|
||||
description?: string;
|
||||
theme?: string;
|
||||
status: 'draft' | 'upcoming' | 'active' | 'ended';
|
||||
|
||||
// Prizes and competition details
|
||||
prize?: string; // Main prize display text
|
||||
prizes?: HackathonPrize[];
|
||||
|
||||
// Tags and categorization
|
||||
tags?: string[];
|
||||
difficulty?: 'beginner' | 'intermediate' | 'advanced';
|
||||
|
||||
// Time windows
|
||||
registrationStart?: string;
|
||||
registrationEnd?: string;
|
||||
submissionWindow?: HackathonTimeWindow;
|
||||
judgingWindow?: HackathonTimeWindow;
|
||||
|
||||
// Participation
|
||||
partnersCount?: number;
|
||||
submissionsCount?: number;
|
||||
maxTeamSize?: number;
|
||||
minTeamSize?: number;
|
||||
|
||||
// Relations
|
||||
partners?: HackathonPartner[];
|
||||
submissions?: HackathonSubmission[];
|
||||
judges?: HackathonJudge[];
|
||||
sponsors?: HackathonSponsor[];
|
||||
requirements?: HackathonRequirement[];
|
||||
|
||||
// Content metadata
|
||||
contentPath?: string;
|
||||
lastModified?: string;
|
||||
featured?: boolean;
|
||||
|
||||
// SEO and social
|
||||
seoTitle?: string;
|
||||
seoDescription?: string;
|
||||
socialImage?: string;
|
||||
}
|
||||
|
||||
export interface HackathonContent {
|
||||
metadata: HackathonMetadata;
|
||||
content: string; // MDX content as string
|
||||
compiledContent?: React.ComponentType; // Compiled MDX component
|
||||
}
|
||||
|
||||
export interface HackathonSummary {
|
||||
slug: string;
|
||||
name: string;
|
||||
cover: string;
|
||||
description?: string;
|
||||
prize?: string;
|
||||
tags?: string[];
|
||||
theme?: string;
|
||||
status?: string;
|
||||
partnersCount?: number;
|
||||
submissionsCount?: number;
|
||||
registrationStart?: string;
|
||||
registrationEnd?: string;
|
||||
submissionWindow?: HackathonTimeWindow;
|
||||
}
|
||||
|
||||
export interface HackathonApiResponse {
|
||||
data: HackathonSummary[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface HackathonFilterOptions {
|
||||
status?: string[];
|
||||
tags?: string[];
|
||||
difficulty?: string[];
|
||||
featured?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
|
||||
export interface HackathonSortOptions {
|
||||
field: 'name' | 'registrationStart' | 'registrationEnd' | 'status' | 'featured';
|
||||
direction: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export type HackathonStatus = 'draft' | 'upcoming' | 'active' | 'ended';
|
||||
@@ -1,243 +0,0 @@
|
||||
import { HackathonMetadata, HackathonSummary, HackathonTimeWindow, HackathonStatus } from './types';
|
||||
|
||||
/**
|
||||
* Get the registration window dates for a hackathon
|
||||
*/
|
||||
export const getRegistrationWindow = (hackathon: HackathonMetadata | HackathonSummary) => {
|
||||
const start = hackathon.submissionWindow?.start || hackathon.registrationStart || undefined;
|
||||
const end = hackathon.submissionWindow?.end || hackathon.registrationEnd || undefined;
|
||||
return {
|
||||
start: start ? new Date(start) : undefined,
|
||||
end: end ? new Date(end) : undefined,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate days left for registration
|
||||
*/
|
||||
export const getDaysLeftText = (hackathon: HackathonMetadata | HackathonSummary): string | null => {
|
||||
const { end } = getRegistrationWindow(hackathon);
|
||||
if (!end) return null;
|
||||
|
||||
const now = new Date();
|
||||
const ms = end.getTime() - now.getTime();
|
||||
const days = Math.ceil(ms / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days < 0) return 'Registration ended';
|
||||
if (days === 0) return 'Ends today';
|
||||
return `${days} days left`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Calculate registration progress percentage
|
||||
*/
|
||||
export const getProgressPercent = (hackathon: HackathonMetadata | HackathonSummary): number | null => {
|
||||
const { start, end } = getRegistrationWindow(hackathon);
|
||||
if (!start || !end) return null;
|
||||
|
||||
const now = Date.now();
|
||||
const s = start.getTime();
|
||||
const e = end.getTime();
|
||||
|
||||
if (now <= s) return 0;
|
||||
if (now >= e) return 100;
|
||||
|
||||
return Math.min(100, Math.max(0, ((now - s) / (e - s)) * 100));
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine hackathon status based on dates
|
||||
*/
|
||||
export const calculateHackathonStatus = (hackathon: HackathonMetadata): HackathonStatus => {
|
||||
// If status is explicitly set to draft, keep it
|
||||
if (hackathon.status === 'draft') return 'draft';
|
||||
|
||||
const now = new Date();
|
||||
const { start, end } = getRegistrationWindow(hackathon);
|
||||
|
||||
if (!start || !end) {
|
||||
return hackathon.status || 'upcoming';
|
||||
}
|
||||
|
||||
if (now < start) return 'upcoming';
|
||||
if (now >= start && now <= end) return 'active';
|
||||
return 'ended';
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if hackathon is currently accepting registrations
|
||||
*/
|
||||
export const isRegistrationOpen = (hackathon: HackathonMetadata | HackathonSummary): boolean => {
|
||||
const status = typeof hackathon.status === 'string'
|
||||
? hackathon.status.toLowerCase().trim()
|
||||
: '';
|
||||
|
||||
if (status === 'ended' || status === 'draft') return false;
|
||||
|
||||
const { start, end } = getRegistrationWindow(hackathon);
|
||||
if (!start || !end) return false;
|
||||
|
||||
const now = new Date();
|
||||
return now >= start && now <= end;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format date to readable string
|
||||
*/
|
||||
export const formatHackathonDate = (dateString: string): string => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('id-ID', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Format date range
|
||||
*/
|
||||
export const formatDateRange = (window: HackathonTimeWindow): string => {
|
||||
const start = formatHackathonDate(window.start);
|
||||
const end = formatHackathonDate(window.end);
|
||||
return `${start} - ${end}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate slug from hackathon name
|
||||
*/
|
||||
export const generateSlug = (name: string): string => {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.trim();
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate hackathon metadata
|
||||
*/
|
||||
export const validateHackathonMetadata = (metadata: Partial<HackathonMetadata>): string[] => {
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!metadata.slug) errors.push('Slug is required');
|
||||
if (!metadata.name) errors.push('Name is required');
|
||||
if (!metadata.cover) errors.push('Cover image is required');
|
||||
|
||||
if (metadata.submissionWindow) {
|
||||
const start = new Date(metadata.submissionWindow.start);
|
||||
const end = new Date(metadata.submissionWindow.end);
|
||||
|
||||
if (start >= end) {
|
||||
errors.push('Submission end date must be after start date');
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.maxTeamSize && metadata.minTeamSize) {
|
||||
if (metadata.maxTeamSize < metadata.minTeamSize) {
|
||||
errors.push('Max team size must be greater than or equal to min team size');
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert HackathonMetadata to HackathonSummary
|
||||
*/
|
||||
export const toHackathonSummary = (metadata: HackathonMetadata): HackathonSummary => {
|
||||
return {
|
||||
slug: metadata.slug,
|
||||
name: metadata.name,
|
||||
cover: metadata.cover,
|
||||
description: metadata.description,
|
||||
prize: metadata.prize,
|
||||
tags: metadata.tags,
|
||||
theme: metadata.theme,
|
||||
status: metadata.status,
|
||||
partnersCount: metadata.partnersCount,
|
||||
submissionsCount: metadata.submissionsCount,
|
||||
registrationStart: metadata.registrationStart,
|
||||
registrationEnd: metadata.registrationEnd,
|
||||
submissionWindow: metadata.submissionWindow,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter hackathons based on criteria
|
||||
*/
|
||||
export const filterHackathons = (
|
||||
hackathons: HackathonSummary[],
|
||||
filters: {
|
||||
status?: string[];
|
||||
tags?: string[];
|
||||
search?: string;
|
||||
featured?: boolean;
|
||||
}
|
||||
): HackathonSummary[] => {
|
||||
return hackathons.filter(hackathon => {
|
||||
// Status filter
|
||||
if (filters.status && filters.status.length > 0) {
|
||||
const currentStatus = calculateHackathonStatus(hackathon as HackathonMetadata);
|
||||
if (!filters.status.includes(currentStatus)) return false;
|
||||
}
|
||||
|
||||
// Tags filter
|
||||
if (filters.tags && filters.tags.length > 0) {
|
||||
if (!hackathon.tags || !filters.tags.some(tag => hackathon.tags?.includes(tag))) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (filters.search) {
|
||||
const searchTerm = filters.search.toLowerCase();
|
||||
const searchableText = [
|
||||
hackathon.name,
|
||||
hackathon.description,
|
||||
hackathon.theme,
|
||||
...(hackathon.tags || [])
|
||||
].join(' ').toLowerCase();
|
||||
|
||||
if (!searchableText.includes(searchTerm)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Sort hackathons
|
||||
*/
|
||||
export const sortHackathons = (
|
||||
hackathons: HackathonSummary[],
|
||||
sortBy: 'name' | 'registrationStart' | 'status' | 'featured' = 'registrationStart',
|
||||
direction: 'asc' | 'desc' = 'desc'
|
||||
): HackathonSummary[] => {
|
||||
return [...hackathons].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
comparison = a.name.localeCompare(b.name);
|
||||
break;
|
||||
case 'registrationStart': {
|
||||
const aDate = getRegistrationWindow(a).start?.getTime() || 0;
|
||||
const bDate = getRegistrationWindow(b).start?.getTime() || 0;
|
||||
comparison = aDate - bDate;
|
||||
break;
|
||||
}
|
||||
case 'status': {
|
||||
const statusOrder = { 'active': 0, 'upcoming': 1, 'ended': 2, 'draft': 3 };
|
||||
const aStatus = calculateHackathonStatus(a as HackathonMetadata);
|
||||
const bStatus = calculateHackathonStatus(b as HackathonMetadata);
|
||||
comparison = (statusOrder[aStatus] || 99) - (statusOrder[bStatus] || 99);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
comparison = 0;
|
||||
}
|
||||
|
||||
return direction === 'desc' ? -comparison : comparison;
|
||||
});
|
||||
};
|
||||
@@ -1,312 +0,0 @@
|
||||
import { HackathonMetadata } from './types';
|
||||
|
||||
export interface ValidationResult {
|
||||
isValid: boolean;
|
||||
errors: string[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
export interface ValidationRule<T> {
|
||||
name: string;
|
||||
validate: (value: T) => ValidationResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate required fields
|
||||
*/
|
||||
export const validateRequiredFields = (metadata: Partial<HackathonMetadata>): ValidationResult => {
|
||||
const errors: string[] = [];
|
||||
const requiredFields = [
|
||||
{ key: 'slug', name: 'Slug' },
|
||||
{ key: 'name', name: 'Name' },
|
||||
{ key: 'cover', name: 'Cover image' },
|
||||
{ key: 'status', name: 'Status' }
|
||||
];
|
||||
|
||||
requiredFields.forEach(({ key, name }) => {
|
||||
if (!metadata[key as keyof HackathonMetadata]) {
|
||||
errors.push(`${name} is required`);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings: []
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate slug format
|
||||
*/
|
||||
export const validateSlug = (slug: string): ValidationResult => {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!slug) {
|
||||
errors.push('Slug cannot be empty');
|
||||
} else {
|
||||
// Check slug format
|
||||
const slugRegex = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
if (!slugRegex.test(slug)) {
|
||||
errors.push('Slug must contain only lowercase letters, numbers, and hyphens');
|
||||
}
|
||||
|
||||
// Check length
|
||||
if (slug.length < 3) {
|
||||
errors.push('Slug must be at least 3 characters long');
|
||||
}
|
||||
|
||||
if (slug.length > 100) {
|
||||
errors.push('Slug must be less than 100 characters');
|
||||
}
|
||||
|
||||
// Check for consecutive hyphens
|
||||
if (slug.includes('--')) {
|
||||
errors.push('Slug cannot contain consecutive hyphens');
|
||||
}
|
||||
|
||||
// Check start/end
|
||||
if (slug.startsWith('-') || slug.endsWith('-')) {
|
||||
errors.push('Slug cannot start or end with a hyphen');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate dates
|
||||
*/
|
||||
export const validateDates = (metadata: Partial<HackathonMetadata>): ValidationResult => {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Validate submission window
|
||||
if (metadata.submissionWindow) {
|
||||
const start = new Date(metadata.submissionWindow.start);
|
||||
const end = new Date(metadata.submissionWindow.end);
|
||||
|
||||
if (isNaN(start.getTime())) {
|
||||
errors.push('Invalid submission start date');
|
||||
}
|
||||
|
||||
if (isNaN(end.getTime())) {
|
||||
errors.push('Invalid submission end date');
|
||||
}
|
||||
|
||||
if (start.getTime() >= end.getTime()) {
|
||||
errors.push('Submission end date must be after start date');
|
||||
}
|
||||
|
||||
// Check if dates are in the past
|
||||
const now = new Date();
|
||||
if (end.getTime() < now.getTime()) {
|
||||
warnings.push('Submission end date is in the past');
|
||||
}
|
||||
|
||||
// Check reasonable duration
|
||||
const duration = end.getTime() - start.getTime();
|
||||
const durationDays = duration / (1000 * 60 * 60 * 24);
|
||||
|
||||
if (durationDays < 1) {
|
||||
warnings.push('Submission window is less than 1 day');
|
||||
}
|
||||
|
||||
if (durationDays > 365) {
|
||||
warnings.push('Submission window is longer than 1 year');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate judging window
|
||||
if (metadata.judgingWindow) {
|
||||
const start = new Date(metadata.judgingWindow.start);
|
||||
const end = new Date(metadata.judgingWindow.end);
|
||||
|
||||
if (isNaN(start.getTime())) {
|
||||
errors.push('Invalid judging start date');
|
||||
}
|
||||
|
||||
if (isNaN(end.getTime())) {
|
||||
errors.push('Invalid judging end date');
|
||||
}
|
||||
|
||||
if (start.getTime() >= end.getTime()) {
|
||||
errors.push('Judging end date must be after start date');
|
||||
}
|
||||
|
||||
// Check judging starts after submission ends
|
||||
if (metadata.submissionWindow) {
|
||||
const submissionEnd = new Date(metadata.submissionWindow.end);
|
||||
if (start.getTime() < submissionEnd.getTime()) {
|
||||
warnings.push('Judging should start after submission window ends');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate team size constraints
|
||||
*/
|
||||
export const validateTeamSize = (metadata: Partial<HackathonMetadata>): ValidationResult => {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (metadata.minTeamSize !== undefined && metadata.maxTeamSize !== undefined) {
|
||||
if (metadata.minTeamSize < 1) {
|
||||
errors.push('Minimum team size must be at least 1');
|
||||
}
|
||||
|
||||
if (metadata.maxTeamSize < 1) {
|
||||
errors.push('Maximum team size must be at least 1');
|
||||
}
|
||||
|
||||
if (metadata.maxTeamSize < metadata.minTeamSize) {
|
||||
errors.push('Maximum team size must be greater than or equal to minimum team size');
|
||||
}
|
||||
|
||||
if (metadata.maxTeamSize > 20) {
|
||||
warnings.push('Maximum team size is unusually large (>20)');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate URLs
|
||||
*/
|
||||
export const validateUrls = (metadata: Partial<HackathonMetadata>): ValidationResult => {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// Validate cover image URL
|
||||
if (metadata.cover) {
|
||||
try {
|
||||
new URL(metadata.cover);
|
||||
} catch {
|
||||
errors.push('Cover image must be a valid URL');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate social image URL
|
||||
if (metadata.socialImage) {
|
||||
try {
|
||||
new URL(metadata.socialImage);
|
||||
} catch {
|
||||
errors.push('Social image must be a valid URL');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate partner URLs
|
||||
if (metadata.partners) {
|
||||
metadata.partners.forEach((partner, index) => {
|
||||
try {
|
||||
new URL(partner.link);
|
||||
} catch {
|
||||
errors.push(`Partner ${index + 1} link must be a valid URL`);
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(partner.logo);
|
||||
} catch {
|
||||
errors.push(`Partner ${index + 1} logo must be a valid URL`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Validate sponsor URLs
|
||||
if (metadata.sponsors) {
|
||||
metadata.sponsors.forEach((sponsor, index) => {
|
||||
try {
|
||||
new URL(sponsor.link);
|
||||
} catch {
|
||||
errors.push(`Sponsor ${index + 1} link must be a valid URL`);
|
||||
}
|
||||
|
||||
try {
|
||||
new URL(sponsor.logo);
|
||||
} catch {
|
||||
errors.push(`Sponsor ${index + 1} logo must be a valid URL`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate status
|
||||
*/
|
||||
export const validateStatus = (status: string): ValidationResult => {
|
||||
const validStatuses = ['draft', 'upcoming', 'active', 'ended'];
|
||||
const errors: string[] = [];
|
||||
|
||||
if (!validStatuses.includes(status)) {
|
||||
errors.push(`Status must be one of: ${validStatuses.join(', ')}`);
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings: []
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Comprehensive validation
|
||||
*/
|
||||
export const validateHackathon = (metadata: Partial<HackathonMetadata>): ValidationResult => {
|
||||
const validations = [
|
||||
validateRequiredFields(metadata),
|
||||
metadata.slug ? validateSlug(metadata.slug) : { isValid: true, errors: [], warnings: [] },
|
||||
validateDates(metadata),
|
||||
validateTeamSize(metadata),
|
||||
validateUrls(metadata),
|
||||
metadata.status ? validateStatus(metadata.status) : { isValid: true, errors: [], warnings: [] }
|
||||
];
|
||||
|
||||
const allErrors = validations.flatMap(v => v.errors);
|
||||
const allWarnings = validations.flatMap(v => v.warnings);
|
||||
|
||||
return {
|
||||
isValid: allErrors.length === 0,
|
||||
errors: allErrors,
|
||||
warnings: allWarnings
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate hackathon content file structure
|
||||
*/
|
||||
export const validateHackathonStructure = (directoryPath: string): ValidationResult => {
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
// This would be implemented to check file system structure
|
||||
// For now, just return a placeholder
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings
|
||||
};
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user