Compare commits

..
66 changed files with 1561 additions and 4408 deletions
+1 -6
View File
@@ -7,10 +7,6 @@ on:
paths: paths:
- 'apps/backoffice/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY - 'apps/backoffice/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -25,7 +21,6 @@ jobs:
username: ${{ secrets.SSH_USER }} username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }} key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }} port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: | script: |
set -e set -e
if [ ! -d ~/imphnen-frontend-service-backoffice ]; then if [ ! -d ~/imphnen-frontend-service-backoffice ]; then
@@ -39,6 +34,6 @@ jobs:
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }} VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF 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 down || true
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml build --progress=plain docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml build
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml up -d docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml up -d
docker image prune -af docker image prune -af
+1 -6
View File
@@ -7,10 +7,6 @@ on:
paths: paths:
- 'apps/dimentorin/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY - 'apps/dimentorin/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -25,7 +21,6 @@ jobs:
username: ${{ secrets.SSH_USER }} username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }} key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }} port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: | script: |
set -e set -e
if [ ! -d ~/imphnen-frontend-service-dimentorin ]; then if [ ! -d ~/imphnen-frontend-service-dimentorin ]; then
@@ -39,6 +34,6 @@ jobs:
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }} VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF 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 down || true
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml build --progress=plain docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml build
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml up -d docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml up -d
docker image prune -af docker image prune -af
+1 -6
View File
@@ -7,10 +7,6 @@ on:
paths: paths:
- 'apps/gacha/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY - 'apps/gacha/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -25,7 +21,6 @@ jobs:
username: ${{ secrets.SSH_USER }} username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }} key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }} port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: | script: |
set -e set -e
if [ ! -d ~/imphnen-frontend-service-gacha ]; then if [ ! -d ~/imphnen-frontend-service-gacha ]; then
@@ -39,6 +34,6 @@ jobs:
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }} VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF 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 down || true
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml build --progress=plain docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml build
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml up -d docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml up -d
docker image prune -af docker image prune -af
+2 -8
View File
@@ -7,10 +7,6 @@ on:
paths: paths:
- 'apps/hackathon/**' # Auto deploy when changes pushed to apps/hackathon - 'apps/hackathon/**' # Auto deploy when changes pushed to apps/hackathon
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -25,7 +21,6 @@ jobs:
username: ${{ secrets.SSH_USER }} username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }} key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }} port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: | script: |
set -e set -e
if [ ! -d ~/imphnen-frontend-service-hackathon ]; then if [ ! -d ~/imphnen-frontend-service-hackathon ]; then
@@ -33,13 +28,12 @@ jobs:
else else
cd ~/imphnen-frontend-service-hackathon && git fetch origin && git reset --hard origin/develop && git pull cd ~/imphnen-frontend-service-hackathon && git fetch origin && git reset --hard origin/develop && git pull
fi fi
cat > ~/imphnen-frontend-service-hackathon/.env.local << EOF cat > ~/imphnen-frontend-service-hackathon/apps/hackathon/.env << EOF
VITE_API_URL=${{ secrets.VITE_API_URL }} VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }} VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }} VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF EOF
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml down || true docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml down || true
docker builder prune -af || true docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml build
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 compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml up -d
docker image prune -af docker image prune -af
+1 -6
View File
@@ -7,10 +7,6 @@ on:
paths: paths:
- 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY - 'apps/landing/**' # INI BASED ON PATH CHANGES JADI NTAR KALAU ADA PUSH DIISNI OTOMATIS DEPLOY
concurrency:
group: deploy-vps
cancel-in-progress: false
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -25,7 +21,6 @@ jobs:
username: ${{ secrets.SSH_USER }} username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }} key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }} port: ${{ secrets.SSH_PORT }}
command_timeout: 30m
script: | script: |
set -e set -e
if [ ! -d ~/imphnen-frontend-service ]; then if [ ! -d ~/imphnen-frontend-service ]; then
@@ -39,6 +34,6 @@ jobs:
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }} NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
EOF EOF
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
DOCKER_BUILDKIT=1 docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build --progress=plain docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
docker image prune -af docker image prune -af
+1 -1
View File
@@ -1 +1 @@
Tue, Nov 25, 2025 4:21:27 PM Tue, Nov 25, 2025 11:09:55 AM
+1 -1
View File
@@ -1 +1 @@
Tue, Nov 25, 2025 4:21:27 PM Tue, Nov 25, 2025 11:09:55 AM
+1 -1
View File
@@ -1 +1 @@
Tue, Nov 25, 2025 4:21:27 PM Tue, Nov 25, 2025 11:09:55 AM
+1 -1
View File
@@ -1 +1 @@
Tue, Nov 25, 2025 16:42:27 PM Tue, Nov 25, 2025 11:09:55 AM
+2
View File
@@ -0,0 +1,2 @@
# Deployment trigger
# Updated to deploy circular dependency fixes and auth hooks
+3 -27
View File
@@ -2,36 +2,12 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<title>IMPHNEN x Kolosal.ai Hackathon 2025</title> <title>Hackathon</title>
<base href="/" /> <base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta <link rel="icon" type="image/x-icon" href="/logos/simple.svg" />
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" /> <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> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 511 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 351 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 7.2 KiB

+3 -3
View File
@@ -2,13 +2,13 @@ import { Link } from 'react-router-dom';
export default function NotFoundPage() { export default function NotFoundPage() {
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950"> <div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center"> <div className="text-center">
<h1 className="text-9xl font-bold text-gray-200 mb-4">404</h1> <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"> <h2 className="text-3xl font-semibold text-gray-900 mb-4">
Page Not Found Page Not Found
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400 mb-8"> <p className="text-gray-600 mb-8">
The page you are looking for doesn't exist or has been moved. The page you are looking for doesn't exist or has been moved.
</p> </p>
<Link <Link
+29 -41
View File
@@ -13,72 +13,62 @@ const CallbackPage: FC = (): ReactElement => {
useEffect(() => { useEffect(() => {
const handleCallback = async () => { const handleCallback = async () => {
if (hasRunRef.current) { if (hasRunRef.current) {
// console.log('[Callback] Already processed, skipping...'); console.log('[Callback] Already processed, skipping...');
return; return;
} }
hasRunRef.current = true; hasRunRef.current = true;
try { try {
// console.log('[Callback] Processing OAuth callback...'); console.log('[Callback] Processing OAuth callback...');
// console.log('[Callback] Current URL:', globalThis.location.href); console.log('[Callback] Current URL:', globalThis.location.href);
// Supabase client is configured with detectSessionInUrl: true // Supabase client is configured with detectSessionInUrl: true
// This means Supabase automatically detects and processes OAuth tokens from the URL hash // 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 // 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...'); console.log('[Callback] Waiting for Supabase to process OAuth callback...');
await new Promise((resolve) => setTimeout(resolve, 1000)); await new Promise(resolve => setTimeout(resolve, 1000));
// Get the session that Supabase automatically created from the URL hash // Get the session that Supabase automatically created from the URL hash
const { const { data: { session: sessionData }, error: sessionError } = await supabase.auth.getSession();
data: { session: sessionData },
error: sessionError,
} = await supabase.auth.getSession();
if (sessionError) { if (sessionError) {
// console.error('[Callback] Session error:', sessionError); console.error('[Callback] Session error:', sessionError);
throw new Error(sessionError.message || 'Failed to get session'); throw new Error(sessionError.message || 'Failed to get session');
} }
if (!sessionData || !sessionData.user) { if (!sessionData || !sessionData.user) {
throw new Error( throw new Error('No session found after OAuth callback. Please try logging in again.');
'No session found after OAuth callback. Please try logging in again.'
);
} }
// console.log('[Callback] Supabase session established:', { console.log('[Callback] Supabase session established:', {
// userId: sessionData.user.id, userId: sessionData.user.id,
// email: sessionData.user.email, email: sessionData.user.email,
// }); });
// Create/update user in the users table (for foreign key constraints) // Create/update user in the users table (for foreign key constraints)
// console.log('[Callback] Creating/updating user record...'); console.log('[Callback] Creating/updating user record...');
const { data: userData, error: upsertError } = await supabase const { data: userData, error: upsertError } = await supabase
.from('users') .from('users')
.upsert( .upsert({
{
id: sessionData.user.id, id: sessionData.user.id,
email: sessionData.user.email || '', email: sessionData.user.email || '',
fullname: fullname: sessionData.user.user_metadata?.full_name ||
sessionData.user.user_metadata?.full_name ||
sessionData.user.user_metadata?.name || sessionData.user.user_metadata?.name ||
sessionData.user.email?.split('@')[0] || sessionData.user.email?.split('@')[0] || '',
'',
avatar: sessionData.user.user_metadata?.avatar_url || '', avatar: sessionData.user.user_metadata?.avatar_url || '',
is_active: true, is_active: true,
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}, }, {
{
onConflict: 'id', onConflict: 'id',
} })
)
.select() .select()
.single(); .single();
if (upsertError) { if (upsertError) {
// console.warn('[Callback] Failed to create user record:', upsertError); console.warn('[Callback] Failed to create user record:', upsertError);
// Don't throw - continue with login even if user record creation fails // Don't throw - continue with login even if user record creation fails
} else { } else {
// console.log('[Callback] User record created/updated successfully'); console.log('[Callback] User record created/updated successfully');
} }
// Store user-friendly data in Zustand for UI purposes // Store user-friendly data in Zustand for UI purposes
@@ -87,11 +77,9 @@ const CallbackPage: FC = (): ReactElement => {
const userRecord = userData || { const userRecord = userData || {
id: sessionData.user.id, id: sessionData.user.id,
email: sessionData.user.email || '', email: sessionData.user.email || '',
fullname: fullname: sessionData.user.user_metadata?.full_name ||
sessionData.user.user_metadata?.full_name ||
sessionData.user.user_metadata?.name || sessionData.user.user_metadata?.name ||
sessionData.user.email?.split('@')[0] || sessionData.user.email?.split('@')[0] || '',
'',
avatar: sessionData.user.user_metadata?.avatar_url || '', avatar: sessionData.user.user_metadata?.avatar_url || '',
phone_number: '', phone_number: '',
birthdate: '', birthdate: '',
@@ -126,21 +114,21 @@ const CallbackPage: FC = (): ReactElement => {
}, },
}); });
// console.log('[Callback] Session stored successfully'); console.log('[Callback] Session stored successfully');
toast.success('Login successful!'); toast.success('Login successful!');
setIsProcessing(false); setIsProcessing(false);
// Check if user has completed onboarding (has location) // Check if user has completed onboarding (has location)
// Use globalThis.location.replace for hard redirect to prevent history issues // Use globalThis.location.replace for hard redirect to prevent history issues
if (userRecord.location) { if (userRecord.location) {
// console.log('[Callback] User has completed onboarding, redirecting to dashboard...'); console.log('[Callback] User has completed onboarding, redirecting to dashboard...');
globalThis.location.replace('/dashboard'); globalThis.location.replace('/dashboard');
} else { } else {
// console.log('[Callback] User needs onboarding, redirecting...'); console.log('[Callback] User needs onboarding, redirecting...');
globalThis.location.replace('/onboarding/user'); globalThis.location.replace('/onboarding/user');
} }
} catch (err) { } catch (err) {
// console.error('[Callback] Error:', err); console.error('[Callback] Error:', err);
setError((err as Error).message); setError((err as Error).message);
setIsProcessing(false); setIsProcessing(false);
toast.error('An error occurred during login'); toast.error('An error occurred during login');
@@ -176,13 +164,13 @@ const CallbackPage: FC = (): ReactElement => {
} }
return ( return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="flex justify-center items-center min-h-screen bg-gray-50">
<div className="text-center"> <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> <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"> <h2 className="text-xl font-semibold text-gray-900 mb-2">
Completing login... Completing login...
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400">Please wait</p> <p className="text-gray-600">Please wait</p>
</div> </div>
</div> </div>
); );
@@ -1,15 +1,12 @@
import { useState } from 'react'; import { useState } from 'react';
import { supabase } from '@imphnen-frontend-service/service'; import { supabase } from '@imphnen-frontend-service/service';
import { Link, useNavigate } from 'react-router'; import { Link } from 'react-router';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Icon } from '@iconify/react';
import ThemeToggle from '../../../components/theme-toggle';
export default function ForgotPasswordPage() { export default function ForgotPasswordPage() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [emailSent, setEmailSent] = useState(false); const [emailSent, setEmailSent] = useState(false);
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -33,7 +30,7 @@ export default function ForgotPasswordPage() {
setEmailSent(true); setEmailSent(true);
toast.success('Password reset email sent! Check your inbox.'); toast.success('Password reset email sent! Check your inbox.');
} catch (err) { } catch (err) {
// console.error('Failed to send reset email:', err); console.error('Failed to send reset email:', err);
toast.error((err as Error).message || 'Failed to send reset email'); toast.error((err as Error).message || 'Failed to send reset email');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
@@ -58,8 +55,7 @@ export default function ForgotPasswordPage() {
<div className="space-y-4"> <div className="space-y-4">
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
Click the link in the email to reset your password. The link will Click the link in the email to reset your password. The link will expire in 1 hour.
expire in 1 hour.
</p> </p>
<Link to="/auth/login"> <Link to="/auth/login">
@@ -81,33 +77,20 @@ export default function ForgotPasswordPage() {
} }
return ( return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4"> <div className="flex justify-center items-center min-h-screen bg-gray-50 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="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
<div className="flex items-center justify-between mb-6">
<button
onClick={() => navigate('/auth/login')}
className="cursor-pointer text-primary-500 hover:text-primary-600 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"> <div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2"> <h2 className="text-3xl font-bold text-gray-900 mb-2">
Forgot Password? Forgot Password?
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400"> <p className="text-gray-600">
No worries, we'll send you reset instructions No worries, we'll send you reset instructions
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label <label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Email Address Email Address
</label> </label>
<input <input
@@ -117,7 +100,7 @@ export default function ForgotPasswordPage() {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com" placeholder="your@email.com"
disabled={isLoading} 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" className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required required
/> />
</div> </div>
@@ -130,6 +113,12 @@ export default function ForgotPasswordPage() {
{isLoading ? 'Sending...' : 'Send Reset Link'} {isLoading ? 'Sending...' : 'Send Reset Link'}
</button> </button>
</form> </form>
<div className="mt-6 text-center">
<Link to="/auth/login" className="text-primary-600 hover:text-primary-700 font-semibold">
Back to Login
</Link>
</div>
</div> </div>
</div> </div>
); );
@@ -0,0 +1,89 @@
import { FC, ReactElement, useEffect, useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate } from 'react-router';
import { useGitHubAuth } from '@imphnen-frontend-service/service';
import { useSession } from '@imphnen-frontend-service/utils';
import { GithubOutlined } from '@ant-design/icons';
const LoginPage: FC = (): ReactElement => {
console.log('[LoginPage] Rendering...');
const navigate = useNavigate();
const { signInWithGitHub } = useGitHubAuth();
const [isGithubLoading, setIsGithubLoading] = useState(false);
const { isAuthenticated } = useSession();
console.log('[LoginPage] isAuthenticated:', isAuthenticated);
useEffect(() => {
console.log('[LoginPage] useEffect - isAuthenticated:', isAuthenticated);
if (isAuthenticated) {
console.log('[LoginPage] Redirecting to dashboard...');
navigate('/dashboard');
}
}, [isAuthenticated, navigate]);
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);
// Supabase should handle the redirect automatically
// If we're still here after 2 seconds, manually redirect
setTimeout(() => {
if (result.url) {
globalThis.location.href = result.url;
}
}, 2000);
} else {
console.error('[Login] No OAuth URL returned');
setIsGithubLoading(false);
}
} catch (error) {
console.error('[Login] GitHub login failed:', error);
setIsGithubLoading(false);
}
};
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 px-4">
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-100">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-2">
Welcome to Hackathon
</h2>
<p className="text-gray-600">
Sign in with your GitHub account to join or create your hackathon team
</p>
</div>
<Button
className="w-full gap-2 py-3"
variant="secondary"
onClick={handleGithubLogin}
disabled={isGithubLoading}
type="button"
>
<GithubOutlined className="text-xl" />
<span className="font-semibold">
{isGithubLoading ? 'Connecting...' : 'Sign in with GitHub'}
</span>
</Button>
<div className="mt-6 text-center">
<p className="text-gray-500 text-sm">
By signing in, you agree to our Terms of Service and Privacy Policy
</p>
</div>
</div>
</div>
);
};
export default LoginPage;
+29 -62
View File
@@ -1,18 +1,11 @@
import { useState } from 'react'; import { useState } from 'react';
import { import { useGitHubAuth, useEmailAuth, supabase, useAuthStore } from '@imphnen-frontend-service/service';
useGitHubAuth,
useEmailAuth,
supabase,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { GithubOutlined } from '@ant-design/icons'; import { GithubOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router'; import { useNavigate, Link } from 'react-router';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Icon } from '@iconify/react';
import { ThemeToggle } from '../../../components/theme-toggle';
export default function LoginPage() { export default function LoginPage() {
// console.log('[LoginPage] Rendering...'); console.log('[LoginPage] Rendering...');
const navigate = useNavigate(); const navigate = useNavigate();
const { setSession } = useAuthStore(); const { setSession } = useAuthStore();
@@ -35,10 +28,10 @@ export default function LoginPage() {
try { try {
setIsEmailLoading(true); setIsEmailLoading(true);
// console.log('[Login] Attempting email login...'); console.log('[Login] Attempting email login...');
const result = await signInWithEmail(email, password); const result = await signInWithEmail(email, password);
// console.log('[Login] Email login successful:', result); console.log('[Login] Email login successful:', result);
// Get user data from database // Get user data from database
const { data: userData } = await supabase const { data: userData } = await supabase
@@ -56,8 +49,7 @@ export default function LoginPage() {
user: { user: {
id: result.user.id, id: result.user.id,
email: result.user.email || '', email: result.user.email || '',
fullname: fullname: userData?.fullname || result.user.user_metadata?.full_name || '',
userData?.fullname || result.user.user_metadata?.full_name || '',
phone_number: userData?.phone_number || '', phone_number: userData?.phone_number || '',
avatar: userData?.avatar || '', avatar: userData?.avatar || '',
birthdate: userData?.birthdate || '', birthdate: userData?.birthdate || '',
@@ -94,61 +86,47 @@ export default function LoginPage() {
const handleGithubLogin = async () => { const handleGithubLogin = async () => {
try { try {
setIsGithubLoading(true); setIsGithubLoading(true);
// console.log('[Login] Initiating GitHub OAuth...'); console.log('[Login] Initiating GitHub OAuth...');
const result = await signInWithGitHub(); const result = await signInWithGitHub();
// console.log('[Login] OAuth result:', result); console.log('[Login] OAuth result:', result);
// Check if we got a redirect URL // Check if we got a redirect URL
if (result?.url) { if (result?.url) {
// console.log('[Login] Redirecting to GitHub OAuth:', result.url); console.log('[Login] Redirecting to GitHub OAuth:', result.url);
// Manually redirect immediately // Manually redirect immediately
globalThis.location.href = result.url; globalThis.location.href = result.url;
} else { } else {
// console.error('[Login] No OAuth URL returned'); console.error('[Login] No OAuth URL returned');
setIsGithubLoading(false); setIsGithubLoading(false);
} }
} catch (error) { } catch (error) {
// console.error('[Login] GitHub login failed'); console.error('[Login] GitHub login failed:', error);
setIsGithubLoading(false); setIsGithubLoading(false);
} }
}; };
return ( return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4"> <div className="flex justify-center items-center min-h-screen bg-gray-50 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="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
<div className="flex items-center justify-between mb-6">
<button
onClick={() => navigate('/')}
className="cursor-pointer text-primary-500 hover:text-primary-600 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"> <div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2"> <h2 className="text-3xl font-bold text-gray-900 mb-2">
Welcome Back Welcome Back
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400 font-sans"> <p className="text-gray-600">
Sign in to join or create your hackathon team Sign in to join or create your hackathon team
</p> </p>
</div> </div>
{error && ( {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"> <div className="mb-6 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p> <p className="text-red-600 text-sm">{error}</p>
</div> </div>
)} )}
<form onSubmit={handleEmailLogin} className="space-y-4"> <form onSubmit={handleEmailLogin} className="space-y-4">
<div> <div>
<label <label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Email Email
</label> </label>
<input <input
@@ -158,23 +136,17 @@ export default function LoginPage() {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com" placeholder="your@email.com"
disabled={isEmailLoading} 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" className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required required
/> />
</div> </div>
<div> <div>
<div className="flex items-center justify-between mb-1"> <div className="flex items-center justify-between mb-1">
<label <label htmlFor="password" className="block text-sm font-medium text-gray-700">
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300"
>
Password Password
</label> </label>
<Link <Link to="/auth/forgot-password" className="text-sm text-primary-600 hover:text-primary-700">
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? Forgot password?
</Link> </Link>
</div> </div>
@@ -185,7 +157,7 @@ export default function LoginPage() {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
disabled={isEmailLoading} 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" className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required required
/> />
</div> </div>
@@ -193,25 +165,23 @@ export default function LoginPage() {
<button <button
type="submit" type="submit"
disabled={isEmailLoading} 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" 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"
> >
{isEmailLoading ? 'Signing in...' : 'Sign in with Email'} {isEmailLoading ? 'Signing in...' : 'Sign in with Email'}
</button> </button>
</form> </form>
<div className="my-6 flex items-center"> <div className="my-6 flex items-center">
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div> <div className="flex-1 border-t border-gray-300"></div>
<span className="px-4 text-sm text-gray-500 dark:text-gray-400"> <span className="px-4 text-sm text-gray-500">OR</span>
OR <div className="flex-1 border-t border-gray-300"></div>
</span>
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div>
</div> </div>
<button <button
onClick={handleGithubLogin} onClick={handleGithubLogin}
disabled={isGithubLoading} disabled={isGithubLoading}
type="button" 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" className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 border border-gray-300 rounded-lg font-semibold hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:bg-gray-100 disabled:cursor-not-allowed transition-colors"
> >
<GithubOutlined className="text-xl" /> <GithubOutlined className="text-xl" />
<span> <span>
@@ -220,19 +190,16 @@ export default function LoginPage() {
</button> </button>
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<p className="text-gray-600 dark:text-gray-400 text-sm"> <p className="text-gray-600 text-sm">
Don't have an account?{' '} Don't have an account?{' '}
<a <a href="/auth/signup" className="text-primary-600 hover:text-primary-700 font-semibold">
href="/auth/signup"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
>
Sign up Sign up
</a> </a>
</p> </p>
</div> </div>
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<p className="text-gray-500 dark:text-gray-500 text-xs"> <p className="text-gray-500 text-xs">
By signing in, you agree to our Terms of Service and Privacy Policy By signing in, you agree to our Terms of Service and Privacy Policy
</p> </p>
</div> </div>
@@ -10,17 +10,17 @@ export default function ResetPasswordPage() {
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isValidToken, setIsValidToken] = useState(false); const [isValidToken, setIsValidToken] = useState(false);
// useEffect(() => { useEffect(() => {
// // Check if we have a valid session (from the reset link) // Check if we have a valid session (from the reset link)
// supabase.auth.getSession().then(({ data: { session } }) => { supabase.auth.getSession().then(({ data: { session } }) => {
// if (session) { if (session) {
// setIsValidToken(true); setIsValidToken(true);
// } else { } else {
// toast.error('Invalid or expired reset link'); toast.error('Invalid or expired reset link');
// setTimeout(() => navigate('/auth/forgot-password'), 2000); setTimeout(() => navigate('/auth/forgot-password'), 2000);
// } }
// }); });
// }, [navigate]); }, [navigate]);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -39,7 +39,7 @@ export default function ResetPasswordPage() {
setIsLoading(true); setIsLoading(true);
const { error } = await supabase.auth.updateUser({ const { error } = await supabase.auth.updateUser({
password: password, password: password
}); });
if (error) { if (error) {
@@ -52,44 +52,39 @@ export default function ResetPasswordPage() {
await supabase.auth.signOut(); await supabase.auth.signOut();
navigate('/auth/login'); navigate('/auth/login');
} catch (err) { } catch (err) {
// console.error('Failed to reset password:', err); console.error('Failed to reset password:', err);
toast.error((err as Error).message || 'Failed to reset password'); toast.error((err as Error).message || 'Failed to reset password');
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
// if (!isValidToken) { if (!isValidToken) {
// return ( return (
// <div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="flex justify-center items-center min-h-screen bg-gray-50">
// <div className="text-center"> <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> <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"> <p className="text-gray-600">Verifying reset link...</p>
// Verifying reset link... </div>
// </p> </div>
// </div> );
// </div> }
// );
// }
return ( return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4"> <div className="flex justify-center items-center min-h-screen bg-gray-50 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="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
<div className="text-center mb-8"> <div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2"> <h2 className="text-3xl font-bold text-gray-900 mb-2">
Set New Password Set New Password
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400"> <p className="text-gray-600">
Enter your new password below Enter your new password below
</p> </p>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label <label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
New Password New Password
</label> </label>
<input <input
@@ -99,17 +94,14 @@ export default function ResetPasswordPage() {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
disabled={isLoading} 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" className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required required
minLength={6} minLength={6}
/> />
</div> </div>
<div> <div>
<label <label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Confirm New Password Confirm New Password
</label> </label>
<input <input
@@ -119,7 +111,7 @@ export default function ResetPasswordPage() {
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
disabled={isLoading} 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" className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required required
minLength={6} minLength={6}
/> />
+38 -76
View File
@@ -1,15 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { import { useGitHubAuth, useEmailAuth, supabase, useAuthStore } from '@imphnen-frontend-service/service';
useGitHubAuth,
useEmailAuth,
supabase,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { GithubOutlined } from '@ant-design/icons'; import { GithubOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Icon } from '@iconify/react';
import { ThemeToggle } from '../../../components/theme-toggle';
export default function SignupPage() { export default function SignupPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -45,31 +38,30 @@ export default function SignupPage() {
try { try {
setIsEmailLoading(true); setIsEmailLoading(true);
// console.log('[Signup] Attempting email signup...'); console.log('[Signup] Attempting email signup...');
const result = await signUpWithEmail(email, password, fullname); const result = await signUpWithEmail(email, password, fullname);
// console.log('[Signup] Email signup successful:', result); console.log('[Signup] Email signup successful:', result);
if (!result.user) { if (!result.user) {
throw new Error('Signup failed - no user returned'); throw new Error('Signup failed - no user returned');
} }
// Create user record in database // Create user record in database
const { error: upsertError } = await supabase.from('users').upsert( const { error: upsertError } = await supabase
{ .from('users')
.upsert({
id: result.user.id, id: result.user.id,
email: result.user.email || '', email: result.user.email || '',
fullname: fullname, fullname: fullname,
is_active: true, is_active: true,
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}, }, {
{
onConflict: 'id', onConflict: 'id',
} });
);
if (upsertError) { if (upsertError) {
console.warn('[Signup] Failed to create user record'); console.warn('[Signup] Failed to create user record:', upsertError);
} }
// If session is available (email confirmation disabled), store it // If session is available (email confirmation disabled), store it
@@ -102,15 +94,13 @@ export default function SignupPage() {
navigate('/onboarding/user'); navigate('/onboarding/user');
} else { } else {
// Email confirmation is enabled // Email confirmation is enabled
toast.success( toast.success('Account created! Please check your email to verify your account.');
'Account created! Please check your email to verify your account.'
);
setTimeout(() => { setTimeout(() => {
navigate('/auth/login'); navigate('/auth/login');
}, 2000); }, 2000);
} }
} catch (err) { } catch (err) {
// console.error('[Signup] Email signup failed:', err); console.error('[Signup] Email signup failed:', err);
setError((err as Error).message || 'Signup failed'); setError((err as Error).message || 'Signup failed');
setIsEmailLoading(false); setIsEmailLoading(false);
} }
@@ -119,59 +109,45 @@ export default function SignupPage() {
const handleGithubLogin = async () => { const handleGithubLogin = async () => {
try { try {
setIsGithubLoading(true); setIsGithubLoading(true);
// console.log('[Signup] Initiating GitHub OAuth...'); console.log('[Signup] Initiating GitHub OAuth...');
const result = await signInWithGitHub(); const result = await signInWithGitHub();
// console.log('[Signup] OAuth result:', result); console.log('[Signup] OAuth result:', result);
if (result?.url) { if (result?.url) {
// console.log('[Signup] Redirecting to GitHub OAuth:', result.url); console.log('[Signup] Redirecting to GitHub OAuth:', result.url);
globalThis.location.href = result.url; globalThis.location.href = result.url;
} else { } else {
// console.error('[Signup] No OAuth URL returned'); console.error('[Signup] No OAuth URL returned');
setIsGithubLoading(false); setIsGithubLoading(false);
} }
} catch (error) { } catch (error) {
// console.error('[Signup] GitHub login failed:', error); console.error('[Signup] GitHub login failed:', error);
setIsGithubLoading(false); setIsGithubLoading(false);
} }
}; };
return ( return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4"> <div className="flex justify-center items-center min-h-screen bg-gray-50 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="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
<div className="flex items-center justify-between mb-6">
<button
onClick={() => navigate('/')}
className="cursor-pointer text-primary-500 hover:text-primary-600 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"> <div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2"> <h2 className="text-3xl font-bold text-gray-900 mb-2">
Create Account Create Account
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400"> <p className="text-gray-600">
Join the hackathon community Join the hackathon community
</p> </p>
</div> </div>
{error && ( {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"> <div className="mb-6 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p> <p className="text-red-600 text-sm">{error}</p>
</div> </div>
)} )}
<form onSubmit={handleEmailSignup} className="space-y-4"> <form onSubmit={handleEmailSignup} className="space-y-4">
<div> <div>
<label <label htmlFor="fullname" className="block text-sm font-medium text-gray-700 mb-1">
htmlFor="fullname"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Full Name Full Name
</label> </label>
<input <input
@@ -181,16 +157,13 @@ export default function SignupPage() {
onChange={(e) => setFullname(e.target.value)} onChange={(e) => setFullname(e.target.value)}
placeholder="John Doe" placeholder="John Doe"
disabled={isEmailLoading} 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" className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required required
/> />
</div> </div>
<div> <div>
<label <label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Email Email
</label> </label>
<input <input
@@ -200,16 +173,13 @@ export default function SignupPage() {
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com" placeholder="your@email.com"
disabled={isEmailLoading} 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" className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required required
/> />
</div> </div>
<div> <div>
<label <label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Password Password
</label> </label>
<input <input
@@ -219,16 +189,13 @@ export default function SignupPage() {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
disabled={isEmailLoading} 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" className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required required
/> />
</div> </div>
<div> <div>
<label <label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Confirm Password Confirm Password
</label> </label>
<input <input
@@ -238,7 +205,7 @@ export default function SignupPage() {
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
disabled={isEmailLoading} 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" className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required required
/> />
</div> </div>
@@ -246,25 +213,23 @@ export default function SignupPage() {
<button <button
type="submit" type="submit"
disabled={isEmailLoading} 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" 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"
> >
{isEmailLoading ? 'Creating account...' : 'Create Account'} {isEmailLoading ? 'Creating account...' : 'Create Account'}
</button> </button>
</form> </form>
<div className="my-6 flex items-center"> <div className="my-6 flex items-center">
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div> <div className="flex-1 border-t border-gray-300"></div>
<span className="px-4 text-sm text-gray-500 dark:text-gray-400"> <span className="px-4 text-sm text-gray-500">OR</span>
OR <div className="flex-1 border-t border-gray-300"></div>
</span>
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div>
</div> </div>
<button <button
onClick={handleGithubLogin} onClick={handleGithubLogin}
disabled={isGithubLoading} disabled={isGithubLoading}
type="button" 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" className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 border border-gray-300 rounded-lg font-semibold hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:bg-gray-100 disabled:cursor-not-allowed transition-colors"
> >
<GithubOutlined className="text-xl" /> <GithubOutlined className="text-xl" />
<span> <span>
@@ -273,19 +238,16 @@ export default function SignupPage() {
</button> </button>
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<p className="text-gray-600 dark:text-gray-400 text-sm"> <p className="text-gray-600 text-sm">
Already have an account?{' '} Already have an account?{' '}
<a <a href="/auth/login" className="text-primary-600 hover:text-primary-700 font-semibold">
href="/auth/login"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
>
Sign in Sign in
</a> </a>
</p> </p>
</div> </div>
<div className="mt-6 text-center"> <div className="mt-6 text-center">
<p className="text-gray-500 dark:text-gray-500 text-xs"> <p className="text-gray-500 text-xs">
By signing up, you agree to our Terms of Service and Privacy Policy By signing up, you agree to our Terms of Service and Privacy Policy
</p> </p>
</div> </div>
@@ -1,46 +0,0 @@
import { FC, ReactElement, useState } from 'react';
import { Outlet } from 'react-router';
import { Sidebar } from '../../components/sidebar';
const DashboardLayout: FC = (): ReactElement => {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex-1 flex flex-col">
{/* Mobile Header with Hamburger */}
<div className="lg:hidden sticky top-0 bg-white dark:bg-gray-900 border-b dark:border-gray-700 px-4 py-3 flex items-center">
<button
onClick={() => setSidebarOpen(true)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<svg
className="w-6 h-6 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<h1 className="ml-3 text-lg font-bold text-gray-900 dark:text-white">
Hackathon
</h1>
</div>
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
</div>
);
};
export default DashboardLayout;
+149 -174
View File
@@ -1,57 +1,19 @@
import { FC, ReactElement, useEffect, useState } from 'react'; import { FC, ReactElement } 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 { Button } from '@imphnen-frontend-service/ui/atoms';
import { Icon } from '@iconify/react'; import { Link, useNavigate } from 'react-router';
import ProfilePage from '../profile/page'; import { useMyTeams, useMyInvitations, useRespondToInvitation, supabase, useAuthStore } from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
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 DashboardPage: FC = (): ReactElement => {
const { session } = useAuthStore(); const navigate = useNavigate();
const [showProfileModal, setShowProfileModal] = useState(false); const { session, clearSession } = useAuthStore();
// 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: teamsData } = useMyTeams();
const { data: invitationsData } = useMyInvitations(); const { data: invitationsData } = useMyInvitations();
const { mutateAsync: respondToInvitation } = useRespondToInvitation(); const { mutateAsync: respondToInvitation } = useRespondToInvitation();
const user = session?.user; const user = session?.user;
const myTeams = teamsData?.data || []; const myTeams = teamsData?.data || [];
const invitations: Invitation[] = (invitationsData?.data || const invitations = invitationsData?.data || [];
[]) as Invitation[];
const handleAcceptInvitation = async (invitationId: string) => { const handleAcceptInvitation = async (invitationId: string) => {
try { try {
@@ -73,39 +35,78 @@ const DashboardPage: FC = (): ReactElement => {
} }
}; };
const handleLogout = async () => {
try {
// Clear Supabase session
await supabase.auth.signOut();
// Clear Zustand store
clearSession();
// Clear localStorage
localStorage.clear();
toast.success('Logged out successfully');
// Redirect to login
navigate('/auth/login');
} catch (error) {
console.error('Logout error:', error);
// Even if there's an error, clear everything and redirect
clearSession();
localStorage.clear();
navigate('/auth/login');
}
};
return ( return (
<> <div className="min-h-screen bg-gray-50">
<div className="p-6 md:p-8 md:max-w-7xl w-full mx-auto"> {/* Header */}
<div className="mb-8"> <div className="bg-white border-b">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
Welcome, {user?.fullname || user?.email?.split('@')[0] || 'User'}! <div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">
Welcome, {user?.fullname || 'User'}!
</h1> </h1>
{user?.location && ( <p className="text-gray-600 mt-1">
<p className="text-gray-600 dark:text-gray-400 mt-1 font-sans flex items-center space-x-1"> {user?.location && `📍 ${user.location}`}
<Icon icon="heroicons:map-pin-16-solid" width="24" height="24" />
<span>{user.location}</span>
</p> </p>
</div>
<div className="flex items-center space-x-3">
{user?.avatar && (
<img
src={user.avatar}
alt="Profile"
className="w-16 h-16 rounded-full object-cover border-2 border-gray-200"
/>
)} )}
<Button
onClick={handleLogout}
variant="secondary"
className="bg-red-500 hover:bg-red-600 text-white"
>
Logout
</Button>
</div>
</div>
</div>
</div> </div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Team Invitations */}
{invitations.length > 0 && ( {invitations.length > 0 && (
<div className="mb-8 bg-primary-50 dark:bg-blue-900/20 border border-primary-200 dark:border-blue-800 rounded-lg p-6"> <div className="mb-8 bg-blue-50 border border-blue-200 rounded-lg p-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4"> <h2 className="text-xl font-bold text-gray-900 mb-4">
Team Invitations ({invitations.length}) Team Invitations ({invitations.length})
</h2> </h2>
<div className="space-y-3 font-sans"> <div className="space-y-3">
{invitations.map((invitation) => ( {invitations.map((invitation) => (
<div <div key={invitation.id} className="bg-white p-4 rounded-lg shadow-sm flex items-center justify-between">
key={invitation.id}
className="bg-white dark:bg-gray-900 p-4 rounded-lg shadow-sm flex items-center justify-between"
>
<div> <div>
<p className="font-medium text-gray-900 dark:text-white"> <p className="font-medium text-gray-900">{invitation.team.name}</p>
{invitation.team?.name ?? 'Unnamed Team'} <p className="text-sm text-gray-600">
</p> Invited by {invitation.inviter.fullname}
<p className="text-sm text-gray-600 dark:text-gray-400">
Invited by{' '}
{invitation.inviter?.fullname ?? 'Unknown User'}
</p> </p>
</div> </div>
<div className="flex space-x-2"> <div className="flex space-x-2">
@@ -129,51 +130,39 @@ const DashboardPage: FC = (): ReactElement => {
</div> </div>
)} )}
{/* My Teams */}
{myTeams.length > 0 ? ( {myTeams.length > 0 ? (
<div className="mb-6 md:mb-8"> <div className="mb-8">
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4"> <h2 className="text-2xl font-bold text-gray-900 mb-4">My Team</h2>
My Team <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
</h2>
<div className="grid grid-cols-1 gap-4 md:gap-6 w-full md:max-w-lg">
{myTeams.map((team) => ( {myTeams.map((team) => (
<Link <Link
key={team.id} key={team.id}
to={'/teams/' + team.id} to={`/teams/${team.id}`}
className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow" className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow"
> >
{team.banner && ( {team.banner && (
<div className="w-full aspect-3/1">
<img <img
src={team.banner} src={team.banner}
alt={team.name} alt={team.name}
className="w-full h-full object-cover" className="w-full h-32 object-cover"
/> />
</div>
)} )}
<div className="p-4"> <div className="p-6">
<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 "> <div className="flex items-center space-x-3 mb-3">
{team.logo && ( {team.logo && (
<img <img
src={team.logo} src={team.logo}
alt={team.name} alt={team.name}
className="w-20 h-20 rounded-full object-cover shrink-0 border-black dark:border-gray-700" className="w-12 h-12 rounded-full object-cover"
/> />
)} )}
<div> <div>
<h3 className="text-xl md:text-lg font-bold text-gray-900 dark:text-white leading-tight"> <h3 className="text-lg font-bold text-gray-900">{team.name}</h3>
{team.name} <p className="text-sm text-gray-600">📍 {team.city}</p>
</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>
</div> </div>
<p className="text-gray-600 dark:text-gray-400 line-clamp-3 text-ellipsis font-sans"> <p className="text-gray-600 text-sm line-clamp-2">
{team.description} {team.description}
</p> </p>
</div> </div>
@@ -182,109 +171,99 @@ const DashboardPage: FC = (): ReactElement => {
</div> </div>
</div> </div>
) : ( ) : (
<div className="mb-6 md:mb-8 bg-white dark:bg-gray-900 rounded-lg shadow-md p-6 md:p-8"> /* No Team - Show CTAs */
<div className="text-center"> <div className="bg-white rounded-lg shadow-md p-8">
<div className="text-3xl md:text-4xl mb-2 md:mb-3">👋</div> <div className="text-center mb-8">
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-1 md:mb-2"> <h2 className="text-2xl font-bold text-gray-900 mb-2">
You are not in a team yet You're not in a team yet
</h2> </h2>
<p className="text-sm md:text-base text-gray-600 dark:text-gray-400 font-sans"> <p className="text-gray-600">
Use the sidebar to browse teams or create your own Join an existing team or create your own to get started
</p> </p>
</div> </div>
<div className="grid gap-6 md:grid-cols-2 max-w-2xl mx-auto">
<Link
to="/teams/browse"
className="bg-blue-600 text-white rounded-lg p-6 hover:bg-blue-700 transition-colors text-center"
>
<div className="text-4xl mb-3">🔍</div>
<h3 className="text-xl font-bold mb-2">Browse Teams</h3>
<p className="text-blue-100">
Find and join existing teams looking for members
</p>
</Link>
<Link
to="/teams/create"
className="bg-green-600 text-white rounded-lg p-6 hover:bg-green-700 transition-colors text-center"
>
<div className="text-4xl mb-3"></div>
<h3 className="text-xl font-bold mb-2">Create Team</h3>
<p className="text-green-100">
Start your own team and invite members
</p>
</Link>
</div>
</div> </div>
)} )}
<div className="mt-8 bg-white dark:bg-gray-900 rounded-xl shadow-lg"> {/* User Profile Card */}
<div className="bg-linear-to-r from-primary-600 to-primary-500 h-24 rounded-t-xl"></div> <div className="mt-8 bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl shadow-lg overflow-hidden">
<div className="px-4 md:px-8 pb-4 md:pb-8 max-w-7xl"> <div className="bg-gradient-to-r from-blue-600 to-indigo-600 h-24"></div>
<div className="flex flex-col md:flex-row md:items-start -mt-12 mb-4 md:mb-6"> <div className="px-8 pb-8">
<div className="flex flex-col md:flex-row items-start"> <div className="flex items-start -mt-12 mb-6">
{user?.avatar ? ( {user?.avatar && (
<img <img
src={user.avatar} src={user.avatar}
alt={user.fullname || 'User'} alt={user.fullname}
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" className="w-24 h-24 rounded-full border-4 border-white 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"> <div className="ml-6 mt-14">
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white"> <h2 className="text-2xl font-bold text-gray-900">{user?.fullname}</h2>
{user?.fullname ||
user?.email?.split('@')[0] ||
'Unnamed User'}
</h2>
{user?.location ? ( {user?.location ? (
<p className="text-gray-600 dark:text-gray-400 flex items-center mt-1 text-sm md:text-base font-sans"> <p className="text-gray-600 flex items-center mt-1">
<Icon <svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
icon="heroicons:map-pin-16-solid" <path fillRule="evenodd" d="M5.05 4.05a7 7 0 119.9 9.9L10 18.9l-4.95-4.95a7 7 0 010-9.9zM10 11a2 2 0 100-4 2 2 0 000 4z" clipRule="evenodd" />
width="16" </svg>
height="16" {user.location}
/>
<span className="ml-1">{user.location}</span>
</p> </p>
) : ( ) : (
<Link <Link to="/onboarding/user" className="text-blue-600 hover:text-blue-700 text-sm mt-1 inline-block">
to="/onboarding/user" Complete your profile
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> </Link>
)} )}
</div> </div>
</div> </div>
{user?.location && (
<button <div className="space-y-6">
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 && ( {user?.bio && (
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm w-full max-w-full overflow-x-hidden"> <div className="bg-white rounded-lg p-4 shadow-sm">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2"> <h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-2">About</h3>
About <p className="text-gray-800 leading-relaxed">{user.bio}</p>
</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>
)} )}
<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"> <div className="bg-white rounded-lg p-4 shadow-sm">
Contact <h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Contact</h3>
</h3> <div className="flex items-center text-gray-700">
<div className="flex items-center text-gray-700 dark:text-gray-300"> <svg className="w-5 h-5 mr-3 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<span className="text-gray-900 dark:text-white"> <path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
{user?.email} <path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
</span> </svg>
<span className="text-gray-900">{user?.email}</span>
</div> </div>
</div> </div>
{user?.skills && user.skills.length > 0 && ( {user?.skills && user.skills.length > 0 && (
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm"> <div className="bg-white rounded-lg p-4 shadow-sm">
<h3 className="font-semibold text-gray-700 dark:text-gray-300 tracking-wide mb-3"> <h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Skills</h3>
Skills <div className="flex flex-wrap gap-3">
</h3>
<div className="flex flex-wrap gap-2">
{user.skills.map((skill: string) => ( {user.skills.map((skill: string) => (
<span <span
key={skill} 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" className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium shadow-sm hover:bg-blue-700 transition-colors"
> >
{skill} {skill}
</span> </span>
@@ -296,11 +275,7 @@ const DashboardPage: FC = (): ReactElement => {
</div> </div>
</div> </div>
</div> </div>
<ProfilePage </div>
open={showProfileModal}
onClose={() => setShowProfileModal(false)}
/>
</>
); );
}; };
+17 -20
View File
@@ -1,9 +1,4 @@
import { import { Outlet, ScrollRestoration, useLocation, useNavigate } from 'react-router-dom';
Outlet,
ScrollRestoration,
useLocation,
useNavigate,
} from 'react-router-dom';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { supabase } from '@imphnen-frontend-service/service'; import { supabase } from '@imphnen-frontend-service/service';
@@ -19,57 +14,57 @@ export default function RootLayout() {
const checkAuth = async () => { const checkAuth = async () => {
const pathname = location.pathname; const pathname = location.pathname;
console.log('[Layout] Checking auth for route:', pathname);
// Allow hackathon pages without checks // Allow hackathon pages without checks
if (pathname.startsWith('/hackathons')) { if (pathname.startsWith('/hackathons')) {
console.log('[Layout] Public route, allowing access');
setIsChecking(false); setIsChecking(false);
return; return;
} }
// Allow auth callback without checks // Allow auth callback without checks
if (pathname === '/auth/callback') { if (pathname === '/auth/callback') {
console.log('[Layout] Auth callback, allowing access');
setIsChecking(false); setIsChecking(false);
return; return;
} }
// Check Supabase session // Check Supabase session
const { const { data: { session }, error } = await supabase.auth.getSession();
data: { session },
error,
} = await supabase.auth.getSession();
if (error) { if (error) {
console.error('[Layout] Session error:', error); console.error('[Layout] Session error:', error);
} }
// Public auth pages (login, signup, forgot-password, reset-password) - allow unauthenticated access // Public auth pages (login, signup, forgot-password, reset-password) - allow unauthenticated access
const isPublicAuthPage = pathname === '/maintenance'; const isPublicAuthPage = pathname === '/auth/login' || pathname === '/auth/signup' || pathname === '/auth/forgot-password' || pathname === '/auth/reset-password';
// pathname === '/auth/login' ||
// pathname === '/auth/signup' ||
// pathname === '/auth/forgot-password' ||
// pathname === '/auth/reset-password';
if (isPublicAuthPage) { if (isPublicAuthPage) {
// If already authenticated and not on password reset pages, redirect to dashboard // If already authenticated and not on password reset pages, redirect to dashboard
if (session && pathname !== '/auth/reset-password') { if (session && pathname !== '/auth/reset-password') {
console.log('[Layout] Already authenticated, redirecting to dashboard');
navigate('/dashboard', { replace: true }); navigate('/dashboard', { replace: true });
setIsChecking(false); setIsChecking(false);
return; return;
} }
// Allow unauthenticated access // Allow unauthenticated access
console.log('[Layout] Public auth page, allowing access');
setIsChecking(false); setIsChecking(false);
return; return;
} }
// Home page - allow everyone to view the landing page // Home page - allow everyone to view the landing page
if (pathname === '/') { if (pathname === '/') {
console.log('[Layout] Landing page, allowing access');
setIsChecking(false); setIsChecking(false);
return; return;
} }
// Require authentication for all other routes // Require authentication for all other routes
if (!session) { if (!session) {
navigate('/maintenance', { replace: true }); console.log('[Layout] No session, redirecting to login');
// navigate('/auth/login', { replace: true }); navigate('/auth/login', { replace: true });
setIsChecking(false); setIsChecking(false);
return; return;
} }
@@ -92,15 +87,17 @@ export default function RootLayout() {
const hasLocation = !!userData?.location; const hasLocation = !!userData?.location;
if (!hasLocation) { if (!hasLocation) {
console.log('[Layout] User needs onboarding, redirecting');
navigate('/onboarding/user', { replace: true }); navigate('/onboarding/user', { replace: true });
setIsChecking(false); setIsChecking(false);
return; return;
} }
} catch (error) { } catch (error) {
// Silently handle error console.error('[Layout] Unexpected error checking onboarding:', error);
} }
} }
console.log('[Layout] Auth check passed');
setIsChecking(false); setIsChecking(false);
}; };
@@ -110,10 +107,10 @@ export default function RootLayout() {
// Show loading state while checking auth // Show loading state while checking auth
if (isChecking) { if (isChecking) {
return ( return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-neutral-950"> <div className="flex justify-center items-center min-h-screen bg-gray-50">
<div className="text-center"> <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> <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> <p className="text-gray-600">Loading...</p>
</div> </div>
</div> </div>
); );
@@ -1,21 +0,0 @@
export default function MaintenancePage() {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 px-4">
<div className="text-center">
<h1 className="text-6xl font-bold text-yellow-600 mb-4">🚧</h1>
<h2 className="text-3xl font-semibold mb-2">We'll be back soon!</h2>
<p className="text-gray-600">
Our site is currently undergoing scheduled maintenance.
<br />
Thank you for your patience.
</p>
<a
href="/"
className="mt-4 inline-block text-primary-600 hover:underline"
>
Back to Homepage
</a>
</div>
</div>
);
}
+49 -46
View File
@@ -12,10 +12,8 @@ import {
useAuthStore, useAuthStore,
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { CitySelect } from '../../../components/city-select'; import INDONESIAN_CITIES from '../../../constants/cities';
import { Icon } from '@iconify/react';
const ROLE_OPTIONS = [ const ROLE_OPTIONS = [
'Frontend Developer', 'Frontend Developer',
@@ -71,46 +69,53 @@ const UserOnboardingPage: FC = (): ReactElement => {
const onSubmit = form.handleSubmit(async (data) => { const onSubmit = form.handleSubmit(async (data) => {
try { try {
console.log('[Onboarding] Starting submission...', data);
let avatarUrl = session?.user?.avatar || null; let avatarUrl = session?.user?.avatar || null;
// Upload avatar if a new file was selected // Upload avatar if a new file was selected
if (avatarFile) { if (avatarFile) {
console.log('[Onboarding] Uploading avatar...');
const uploadResult = await uploadAvatar(avatarFile); const uploadResult = await uploadAvatar(avatarFile);
avatarUrl = uploadResult.data.url; avatarUrl = uploadResult.data.url;
console.log('[Onboarding] Avatar uploaded:', avatarUrl);
} }
// Update user in Supabase // Update user in Supabase
await updateUser({ console.log('[Onboarding] Updating user in Supabase...');
const result = await updateUser({
fullname: data.fullname, fullname: data.fullname,
avatar: avatarUrl, avatar: avatarUrl,
location: data.location, location: data.location,
bio: data.bio, bio: data.bio,
skills: data.skills, skills: data.skills,
}); });
console.log('[Onboarding] User updated successfully:', result);
// Wait a bit for the onSuccess handler to update localStorage // Wait a bit for the onSuccess handler to update localStorage
// The updateUser mutation's onSuccess handler updates the Zustand store and localStorage // The updateUser mutation's onSuccess handler updates the Zustand store and localStorage
await new Promise((resolve) => setTimeout(resolve, 100)); await new Promise((resolve) => setTimeout(resolve, 100));
console.log('[Onboarding] Navigating to dashboard...');
// Use window.location for a full page reload to ensure middleware sees updated localStorage // Use window.location for a full page reload to ensure middleware sees updated localStorage
globalThis.location.href = '/dashboard'; globalThis.location.href = '/dashboard';
} catch (error) { } catch (error) {
toast.error( console.error('[Onboarding] Onboarding failed:', error);
error instanceof Error alert(
? error.message `Onboarding failed: ${
: 'Onboarding failed. Please try again.' error instanceof Error ? error.message : 'Unknown error'
}`
); );
} }
}); });
return ( 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="flex flex-col justify-center items-center min-h-screen bg-gray-50 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="bg-white w-full max-w-2xl p-8 rounded-xl shadow-lg">
<div className="mb-6"> <div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2"> <h1 className="text-3xl font-bold text-gray-900 mb-2">
Complete Your Profile Complete Your Profile
</h1> </h1>
<p className="text-gray-600 font-sans dark:text-gray-400"> <p className="text-gray-600">
Tell us more about yourself to get started Tell us more about yourself to get started
</p> </p>
</div> </div>
@@ -123,22 +128,17 @@ const UserOnboardingPage: FC = (): ReactElement => {
<img <img
src={avatarPreview} src={avatarPreview}
alt="Avatar preview" alt="Avatar preview"
className="w-32 h-32 rounded-full object-cover border-4 border-gray-200 dark:border-gray-700" className="w-32 h-32 rounded-full object-cover border-4 border-gray-200"
/> />
) : ( ) : (
<div className="w-32 h-32 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center"> <div className="w-32 h-32 rounded-full bg-gray-200 flex items-center justify-center">
<Icon <span className="text-gray-400 text-4xl">👤</span>
icon="ic:baseline-person"
width="48"
height="48"
className="text-gray-400 dark:text-gray-500"
/>
</div> </div>
)} )}
</div> </div>
<div className="flex flex-col items-center"> <div>
<label htmlFor="avatar" className="cursor-pointer"> <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"> <span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">
{avatarPreview ? 'Change Photo' : 'Upload Photo'} {avatarPreview ? 'Change Photo' : 'Upload Photo'}
</span> </span>
<input <input
@@ -149,7 +149,7 @@ const UserOnboardingPage: FC = (): ReactElement => {
onChange={handleAvatarChange} onChange={handleAvatarChange}
/> />
</label> </label>
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2 text-center"> <p className="text-xs text-gray-500 mt-2 text-center">
Optional, but highly recommended Optional, but highly recommended
</p> </p>
</div> </div>
@@ -161,33 +161,42 @@ const UserOnboardingPage: FC = (): ReactElement => {
label="Full Name" label="Full Name"
placeholder="Enter your full name" placeholder="Enter your full name"
name="fullname" name="fullname"
className="px-3 py-2 text-label2 rounded-lg max-h-auto text-base"
size="md"
isRequired={true}
/> />
{/* City */} {/* City */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-base font-medium text-gray-700 dark:text-gray-300"> <label className="block text-sm font-medium text-gray-700">
City <span className="text-red-500">*</span> City <span className="text-red-500">*</span>
</label> </label>
<Controller <Controller
control={form.control} control={form.control}
name="location" name="location"
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<CitySelect <div>
value={field.value} <select
onChange={field.onChange} {...field}
error={fieldState.error?.message} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Search your city..." >
/> <option value="">Select your city</option>
{INDONESIAN_CITIES.map((city) => (
<option key={city} value={city}>
{city}
</option>
))}
</select>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">
{fieldState.error.message}
</p>
)}
</div>
)} )}
/> />
</div> </div>
{/* Role/Skills */} {/* Role/Skills */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-base font-medium text-gray-700 dark:text-gray-300"> <label className="block text-sm font-medium text-gray-700">
Role / Skills Role / Skills
</label> </label>
<Controller <Controller
@@ -210,11 +219,9 @@ const UserOnboardingPage: FC = (): ReactElement => {
: (field.value || []).filter((v) => v !== role); : (field.value || []).filter((v) => v !== role);
field.onChange(newValue); 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" className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/> />
<span className="text-sm dark:text-gray-300"> <span className="text-sm">{role}</span>
{role}
</span>
</label> </label>
))} ))}
</div> </div>
@@ -225,23 +232,19 @@ const UserOnboardingPage: FC = (): ReactElement => {
{/* Bio */} {/* Bio */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-base font-medium text-gray-700 dark:text-gray-300"> <label className="block text-sm font-medium text-gray-700">
Bio{' '} Bio <span className="text-gray-400">(Optional)</span>
<span className="text-gray-400 dark:text-gray-500">
(Optional)
</span>
</label> </label>
<Controller <Controller
control={form.control} control={form.control}
name="bio" name="bio"
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<div className="flex"> <div>
<Textarea <Textarea
{...field} {...field}
placeholder="Tell us about yourself..." placeholder="Tell us about yourself..."
rows={4} rows={4}
className="w-full rounded-lg text-sm" className="w-full"
style={{ resize: 'vertical' }}
/> />
{fieldState.error && ( {fieldState.error && (
<p className="text-sm text-red-500 mt-1"> <p className="text-sm text-red-500 mt-1">
File diff suppressed because one or more lines are too long
-389
View File
@@ -1,389 +0,0 @@
import { FC, ReactElement, useState, useEffect } from 'react';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useForm, Controller } from 'react-hook-form';
import {
userEditProfileSchema,
TUserEditProfileForm,
useUpdateUserMe,
useUploadAvatar,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { CitySelect } from '../../components/city-select';
import { Icon } from '@iconify/react';
const ROLE_OPTIONS = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
];
type ProfileModalProps = {
open: boolean;
onClose: () => void;
};
const ProfilePage: FC<ProfileModalProps> = ({
open,
onClose,
}): ReactElement | null => {
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string>('');
const { mutateAsync: updateUser, isPending: isUpdating } = useUpdateUserMe();
const { mutateAsync: uploadAvatar, isPending: isUploading } =
useUploadAvatar();
const { session } = useAuthStore();
const form = useForm<TUserEditProfileForm>({
resolver: zodResolver(userEditProfileSchema),
mode: 'all',
defaultValues: {
fullname: session?.user?.fullname || '',
avatar: session?.user?.avatar || null,
location: session?.user?.location || '',
bio: session?.user?.bio || '',
skills: session?.user?.skills || [],
},
});
// Set initial avatar preview from current user avatar
useEffect(() => {
if (session?.user?.avatar && !avatarPreview) {
setAvatarPreview(session.user.avatar);
}
if (session?.user?.fullname) {
form.setValue('fullname', session.user.fullname);
}
if (session?.user?.location) {
form.setValue('location', session.user.location);
}
if (session?.user?.bio) {
form.setValue('bio', session.user.bio);
}
if (session?.user?.skills) {
form.setValue('skills', session.user.skills);
}
}, [
session?.user?.avatar,
session?.user?.fullname,
session?.user?.location,
session?.user?.bio,
session?.user?.skills,
avatarPreview,
form,
]);
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
// Validate file size (max 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;
@@ -1,15 +1,8 @@
import { FC, ReactElement, useState, useRef, useEffect, useMemo } from 'react'; import { FC, ReactElement, useState, useRef, useEffect } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router'; import { useNavigate, useParams } from 'react-router';
import { import { useTeamById, useTeamMessages, useSendMessage, useDeleteMessage, useAuthStore } from '@imphnen-frontend-service/service';
useTeamById,
useTeamMessages,
useSendMessage,
useDeleteMessage,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Icon } from '@iconify/react';
const TeamChatPage: FC = (): ReactElement => { const TeamChatPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>(); const { teamId } = useParams<{ teamId: string }>();
@@ -20,54 +13,16 @@ const TeamChatPage: FC = (): ReactElement => {
const { data: teamData } = useTeamById(teamId || ''); const { data: teamData } = useTeamById(teamId || '');
const { data: messages, isLoading } = useTeamMessages(teamId || ''); const { data: messages, isLoading } = useTeamMessages(teamId || '');
const { mutateAsync: sendMessage, isPending: isSending } = useSendMessage( const { mutateAsync: sendMessage, isPending: isSending } = useSendMessage(teamId || '');
teamId || ''
);
const { mutateAsync: deleteMessage } = useDeleteMessage(teamId || ''); const { mutateAsync: deleteMessage } = useDeleteMessage(teamId || '');
const team = teamData?.data; const team = teamData?.data;
const currentUserId = session?.user?.id; 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 // Auto-scroll to bottom when new messages arrive
useEffect(() => { useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [displayMessages]); }, [messages]);
const handleSendMessage = async (e: React.FormEvent) => { const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -83,10 +38,11 @@ const TeamChatPage: FC = (): ReactElement => {
}; };
const handleDeleteMessage = async (messageId: string) => { const handleDeleteMessage = async (messageId: string) => {
if (!confirm('Are you sure you want to delete this message?')) return;
try { try {
await deleteMessage(messageId); await deleteMessage(messageId);
toast.success('Message deleted'); toast.success('Message deleted');
setDeleteTargetId(null);
} catch (error) { } catch (error) {
console.error('Failed to delete message:', error); console.error('Failed to delete message:', error);
toast.error('Failed to delete message'); toast.error('Failed to delete message');
@@ -107,23 +63,16 @@ const TeamChatPage: FC = (): ReactElement => {
}; };
return ( return (
<div className="flex flex-col h-screen bg-gray-50 dark:bg-gray-950"> <div className="flex flex-col h-screen bg-gray-50">
{/* Header */} {/* Header */}
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 shadow-sm dark:shadow-gray-950/50"> <div className="bg-white border-b shadow-sm">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> <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 className="flex items-center justify-between">
<div> <div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white"> <h1 className="text-2xl font-bold text-gray-900">Team Chat</h1>
Team Chat <p className="text-gray-600 text-sm mt-0.5">{team?.name}</p>
</h1>
<p className="text-gray-600 dark:text-gray-400 text-sm mt-0.5">
{team?.name}
</p>
</div> </div>
<Button <Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
variant="secondary"
onClick={() => navigate(`/teams/${teamId}`)}
>
Back to Team Back to Team
</Button> </Button>
</div> </div>
@@ -135,29 +84,23 @@ const TeamChatPage: FC = (): ReactElement => {
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{isLoading ? ( {isLoading ? (
<div className="flex items-center justify-center h-64"> <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 className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div> </div>
) : displayMessages && displayMessages.length > 0 ? ( ) : messages && messages.length > 0 ? (
<div className="space-y-4"> <div className="space-y-4">
{displayMessages.map((msg) => { {messages.map((msg) => {
const isOwnMessage = msg.user_id === currentUserId; const isOwnMessage = msg.user_id === currentUserId;
const isLeader = team?.leader_id === currentUserId; const isLeader = team?.leader_id === currentUserId;
const canDelete = isOwnMessage || isLeader; const canDelete = isOwnMessage || isLeader;
return ( return (
<div <div
key={`${msg.id ?? 'message'}-${msg.created_at}`} key={msg.id}
className={`flex ${ className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}
isOwnMessage ? 'justify-end' : 'justify-start'
}`}
>
<div
className={`flex gap-3 max-w-lg ${
isOwnMessage ? 'flex-row-reverse' : 'flex-row'
}`}
> >
<div className={`flex gap-3 max-w-lg ${isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}>
{/* Avatar */} {/* Avatar */}
<div className="shrink-0"> <div className="flex-shrink-0">
{msg.user?.avatar ? ( {msg.user?.avatar ? (
<img <img
src={msg.user.avatar} src={msg.user.avatar}
@@ -172,76 +115,38 @@ const TeamChatPage: FC = (): ReactElement => {
</div> </div>
{/* Message Bubble */} {/* Message Bubble */}
<div className={`flex-1 text-left`}> <div className={`flex-1 ${isOwnMessage ? 'text-right' : 'text-left'}`}>
<div <div className={`inline-block ${isOwnMessage ? 'items-end' : 'items-start'}`}>
className={`inline-block ${
isOwnMessage ? 'items-end' : 'items-start'
}`}
>
<div className="flex items-baseline gap-2 mb-1"> <div className="flex items-baseline gap-2 mb-1">
<span className="font-semibold text-sm text-gray-900 dark:text-white"> <span className="font-semibold text-sm text-gray-900">
{isOwnMessage ? 'You' : msg.user?.fullname} {isOwnMessage ? 'You' : msg.user?.fullname}
</span> </span>
<span className="text-xs text-gray-500 dark:text-gray-500"> <span className="text-xs text-gray-500">
{formatTime(msg.created_at)} {formatTime(msg.created_at)}
</span> </span>
</div> </div>
<div <div
className={`relative group rounded-2xl px-4 py-2.5 ${ className={`relative group rounded-2xl px-4 py-2.5 ${
isOwnMessage isOwnMessage
? 'bg-blue-600 dark:bg-primary-600 text-white' ? 'bg-blue-600 text-white'
: 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white border border-gray-200 dark:border-gray-700' : 'bg-white text-gray-900 border border-gray-200'
}`} }`}
> >
<p className="text-sm whitespace-pre-wrap wrap-break-word font-sans"> <p className="text-sm whitespace-pre-wrap break-words">{msg.message}</p>
{msg.message}
</p>
{/* Delete button */} {/* Delete button */}
{canDelete && ( {canDelete && (
<button <button
onClick={() => onClick={() => handleDeleteMessage(msg.id)}
setDeleteTargetId( className={`absolute top-1 ${isOwnMessage ? 'left-1' : 'right-1'} opacity-0 group-hover:opacity-100 transition-opacity p-1 rounded hover:bg-gray-200 ${isOwnMessage ? 'hover:bg-blue-700' : ''}`}
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" title="Delete message"
> >
{/* Icon trash */} <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<Icon <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
icon="mdi:trash-can-outline" </svg>
className="w-4 h-4"
/>
</button> </button>
)} )}
</div> </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> </div>
@@ -252,14 +157,11 @@ const TeamChatPage: FC = (): ReactElement => {
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center h-64 text-center"> <div className="flex flex-col items-center justify-center h-64 text-center">
<Icon <div className="text-6xl mb-4">💬</div>
icon="mdi:chat-outline" <h3 className="text-xl font-semibold text-gray-900 mb-2">
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 No messages yet
</h3> </h3>
<p className="text-gray-600 dark:text-gray-400 font-sans"> <p className="text-gray-600">
Be the first to start the conversation! Be the first to start the conversation!
</p> </p>
</div> </div>
@@ -268,7 +170,7 @@ const TeamChatPage: FC = (): ReactElement => {
</div> </div>
{/* Message Input */} {/* 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="bg-white border-t shadow-lg">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
<form onSubmit={handleSendMessage} className="flex gap-3"> <form onSubmit={handleSendMessage} className="flex gap-3">
<input <input
@@ -276,7 +178,7 @@ const TeamChatPage: FC = (): ReactElement => {
value={message} value={message}
onChange={(e) => setMessage(e.target.value)} onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..." 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" className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={isSending} disabled={isSending}
/> />
<Button <Button
@@ -291,18 +193,8 @@ const TeamChatPage: FC = (): ReactElement => {
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<svg <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
className="w-5 h-5" <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
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> </svg>
Send Send
</div> </div>
@@ -3,19 +3,9 @@ import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms'; import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router'; import { useNavigate, useParams } from 'react-router';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { import { teamUpdateSchema, TTeamUpdateForm, useUpdateTeam, useTeamById, ETeamVisibility, useUploadFile, useAuthStore } from '@imphnen-frontend-service/service';
teamUpdateSchema,
TTeamUpdateForm,
useUpdateTeam,
useTeamById,
ETeamVisibility,
useUploadFile,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import INDONESIAN_CITIES from '../../../../constants/cities';
import { CitySelect } from '../../../../components/city-select';
import { Icon } from '@iconify/react';
const EditTeamPage: FC = (): ReactElement => { const EditTeamPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>(); const { teamId } = useParams<{ teamId: string }>();
@@ -26,12 +16,8 @@ const EditTeamPage: FC = (): ReactElement => {
const [bannerFile, setBannerFile] = useState<File | null>(null); const [bannerFile, setBannerFile] = useState<File | null>(null);
const [bannerPreview, setBannerPreview] = useState<string>(''); const [bannerPreview, setBannerPreview] = useState<string>('');
const { data: teamData, isLoading: isLoadingTeam } = useTeamById( const { data: teamData, isLoading: isLoadingTeam } = useTeamById(teamId || '');
teamId || '' const { mutateAsync: updateTeam, isPending: isUpdating } = useUpdateTeam(teamId || '');
);
const { mutateAsync: updateTeam, isPending: isUpdating } = useUpdateTeam(
teamId || ''
);
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const team = teamData?.data; const team = teamData?.data;
@@ -60,24 +46,18 @@ const EditTeamPage: FC = (): ReactElement => {
if (isLoadingTeam) { if (isLoadingTeam) {
return ( return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="flex items-center justify-center min-h-screen">
<div className="text-gray-600 dark:text-gray-400">Loading team...</div> <div className="text-gray-600">Loading team...</div>
</div> </div>
); );
} }
if (!isLeader) { if (!isLeader) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4"> <h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
Access Denied <p className="text-gray-600 mb-4">Only the team leader can edit team information</p>
</h2> <Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
<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> </div>
); );
} }
@@ -134,35 +114,28 @@ const EditTeamPage: FC = (): ReactElement => {
}); });
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="min-h-screen bg-gray-50">
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700"> <div className="bg-white border-b">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <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"> <h1 className="text-3xl font-bold text-gray-900">Edit Team Info</h1>
Edit Team Info <p className="text-gray-600 mt-1">Update your team details</p>
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Update your team details
</p>
</div> </div>
</div> </div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-8"> <div className="bg-white rounded-lg shadow-md p-8">
<form onSubmit={onSubmit} className="space-y-6"> <form onSubmit={onSubmit} className="space-y-6">
{/* Banner Upload */} {/* Banner Upload */}
<div> <div>
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Team Banner Team Banner
</label> </label>
{bannerPreview ? ( {bannerPreview ? (
<div <div className="relative">
className="relative w-full"
style={{ aspectRatio: '3 / 1' }}
>
<img <img
src={bannerPreview} src={bannerPreview}
alt="Banner preview" alt="Banner preview"
className="absolute inset-0 w-full h-full object-cover rounded-lg border-2 border-gray-200 dark:border-gray-700" className="w-full h-48 object-cover rounded-lg border-2 border-gray-200"
/> />
<button <button
type="button" type="button"
@@ -171,20 +144,16 @@ const EditTeamPage: FC = (): ReactElement => {
setBannerPreview(''); setBannerPreview('');
form.setValue('banner', null); 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" className="absolute top-2 right-2 bg-red-500 text-white px-3 py-1 rounded-lg text-sm hover:bg-red-600"
> >
Remove Remove
</button> </button>
</div> </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"> <label className="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50">
<div className="text-center"> <div className="text-center">
<p className="text-gray-500 dark:text-gray-400"> <p className="text-gray-500">Click to upload banner</p>
Click to upload banner <p className="text-xs text-gray-400 mt-1">1200x400 recommended</p>
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
1200x400 recommended
</p>
</div> </div>
<input <input
type="file" type="file"
@@ -198,7 +167,7 @@ const EditTeamPage: FC = (): ReactElement => {
{/* Logo Upload */} {/* Logo Upload */}
<div> <div>
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Team Logo Team Logo
</label> </label>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
@@ -206,18 +175,16 @@ const EditTeamPage: FC = (): ReactElement => {
<img <img
src={logoPreview} src={logoPreview}
alt="Logo preview" alt="Logo preview"
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200 dark:border-gray-700" className="w-24 h-24 rounded-full object-cover border-2 border-gray-200"
/> />
) : ( ) : (
<div className="w-24 h-24 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center"> <div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-400 dark:text-gray-500 text-3xl"> <span className="text-gray-400 text-3xl">👥</span>
<Icon icon="mdi:account-group" />
</span>
</div> </div>
)} )}
<div className="flex flex-col md:flex-row items-start justify-start gap-1"> <div>
<label htmlFor="logo" className="cursor-pointer"> <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"> <span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">
{logoPreview ? 'Change Logo' : 'Upload Logo'} {logoPreview ? 'Change Logo' : 'Upload Logo'}
</span> </span>
<input <input
@@ -236,7 +203,7 @@ const EditTeamPage: FC = (): ReactElement => {
setLogoPreview(''); setLogoPreview('');
form.setValue('logo', null); form.setValue('logo', null);
}} }}
className="px-4 py-2 text-sm bg-danger-600 text-white rounded-lg hover:bg-danger-700 cursor-pointer" className="ml-3 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
> >
Remove Remove
</button> </button>
@@ -251,32 +218,40 @@ const EditTeamPage: FC = (): ReactElement => {
label="Team Name" label="Team Name"
placeholder="Enter team name" placeholder="Enter team name"
name="name" name="name"
size="lg"
isRequired={true}
/> />
{/* City */} {/* City */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300"> <label className="block text-sm font-medium text-gray-700">
City City
</label> </label>
<Controller <Controller
control={form.control} control={form.control}
name="city" name="city"
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<CitySelect <div>
value={field.value || ''} <select
onChange={field.onChange} {...field}
error={fieldState.error?.message} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Search your city..." >
/> <option value="">Select city</option>
{INDONESIAN_CITIES.map((city) => (
<option key={city} value={city}>
{city}
</option>
))}
</select>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
)}
</div>
)} )}
/> />
</div> </div>
{/* Description */} {/* Description */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300"> <label className="block text-sm font-medium text-gray-700">
Description Description
</label> </label>
<Controller <Controller
@@ -290,12 +265,9 @@ const EditTeamPage: FC = (): ReactElement => {
placeholder="Tell others about your team..." placeholder="Tell others about your team..."
rows={4} rows={4}
className="w-full" className="w-full"
size="lg"
/> />
{fieldState.error && ( {fieldState.error && (
<p className="text-sm text-red-500 mt-1"> <p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
{fieldState.error.message}
</p>
)} )}
</div> </div>
)} )}
@@ -304,7 +276,7 @@ const EditTeamPage: FC = (): ReactElement => {
{/* Visibility */} {/* Visibility */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300"> <label className="block text-sm font-medium text-gray-700">
Team Visibility Team Visibility
</label> </label>
<Controller <Controller
@@ -312,7 +284,7 @@ const EditTeamPage: FC = (): ReactElement => {
name="visibility" name="visibility"
render={({ field }) => ( render={({ field }) => (
<div className="space-y-3"> <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"> <label className="flex items-start space-x-3 cursor-pointer border rounded-lg p-4 hover:bg-gray-50">
<input <input
type="radio" type="radio"
{...field} {...field}
@@ -321,15 +293,13 @@ const EditTeamPage: FC = (): ReactElement => {
className="mt-1" className="mt-1"
/> />
<div> <div>
<p className="font-medium text-gray-900 dark:text-white"> <p className="font-medium text-gray-900">Public</p>
Public <p className="text-sm text-gray-600">
</p>
<p className="text-sm text-gray-600 dark:text-gray-400 font-sans">
Team will be visible in Browse Teams Team will be visible in Browse Teams
</p> </p>
</div> </div>
</label> </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"> <label className="flex items-start space-x-3 cursor-pointer border rounded-lg p-4 hover:bg-gray-50">
<input <input
type="radio" type="radio"
{...field} {...field}
@@ -338,10 +308,8 @@ const EditTeamPage: FC = (): ReactElement => {
className="mt-1" className="mt-1"
/> />
<div> <div>
<p className="font-medium text-gray-900 dark:text-white"> <p className="font-medium text-gray-900">Private</p>
Private <p className="text-sm text-gray-600">
</p>
<p className="text-sm text-gray-600 dark:text-gray-400 font-sans">
Team hidden, invite-only Team hidden, invite-only
</p> </p>
</div> </div>
@@ -1,46 +1,18 @@
import { FC, ReactElement, useState } from 'react'; import { FC, ReactNode } from 'react';
import { Outlet } from 'react-router'; import { Outlet } from 'react-router';
import { Sidebar } from '../../../components/sidebar'; import { Navigation } from '../../../components/navigation';
const TeamDetailLayout: FC = (): ReactElement => { interface TeamLayoutProps {
const [sidebarOpen, setSidebarOpen] = useState(false); children?: ReactNode;
}
export const TeamLayout: FC<TeamLayoutProps> = () => {
return ( return (
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="min-h-screen bg-gray-50">
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} /> <Navigation />
<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 /> <Outlet />
</main>
</div>
</div> </div>
); );
}; };
export default TeamDetailLayout; export default TeamLayout;
@@ -15,7 +15,6 @@ import {
} from '@imphnen-frontend-service/service'; } from '@imphnen-frontend-service/service';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
const ManageMembersPage: FC = (): ReactElement => { const ManageMembersPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>(); const { teamId } = useParams<{ teamId: string }>();
@@ -23,28 +22,17 @@ const ManageMembersPage: FC = (): ReactElement => {
const { session } = useAuthStore(); const { session } = useAuthStore();
const [showInviteModal, setShowInviteModal] = useState(false); const [showInviteModal, setShowInviteModal] = useState(false);
const { data: teamData, isLoading: isLoadingTeam } = useTeamById( const { data: teamData } = useTeamById(teamId || '');
teamId || '' const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(teamId || '');
);
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(
teamId || ''
);
const { data: joinRequestsData } = useTeamJoinRequests(teamId || ''); const { data: joinRequestsData } = useTeamJoinRequests(teamId || '');
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember( const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(teamId || '');
teamId || '' const { mutateAsync: removeMember, isPending: isRemoving } = useRemoveMember(teamId || '');
); const { mutateAsync: respondToRequest, isPending: isResponding } = useRespondToJoinRequest(teamId || '');
const { mutateAsync: removeMember, isPending: isRemoving } = useRemoveMember(
teamId || ''
);
const { mutateAsync: respondToRequest, isPending: isResponding } =
useRespondToJoinRequest(teamId || '');
const team = teamData?.data; const team = teamData?.data;
const members = membersData?.data || []; const members = membersData?.data || [];
const joinRequests = Array.isArray(joinRequestsData?.data) const joinRequests = Array.isArray(joinRequestsData?.data) ? joinRequestsData.data : [];
? joinRequestsData.data
: [];
const currentUserId = session?.user?.id; const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id; const isLeader = currentUserId === team?.leader_id;
@@ -53,27 +41,12 @@ const ManageMembersPage: FC = (): ReactElement => {
mode: 'all', 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) { if (!isLeader) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4"> <h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
Access Denied <p className="text-gray-600 mb-4">Only the team leader can manage members</p>
</h2> <Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
<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> </div>
); );
} }
@@ -102,44 +75,33 @@ const ManageMembersPage: FC = (): ReactElement => {
const handleApproveRequest = async (requestId: string) => { const handleApproveRequest = async (requestId: string) => {
try { try {
await respondToRequest({ requestId, action: 'approve' }); await respondToRequest({ requestId, action: 'approve' });
toast.success('Member added successfully!');
} catch (error) { } catch (error) {
console.error('Failed to approve request:', error); console.error('Failed to approve request:', error);
toast.error((error as Error).message || 'Failed to approve request');
} }
}; };
const handleRejectRequest = async (requestId: string) => { const handleRejectRequest = async (requestId: string) => {
try { try {
await respondToRequest({ requestId, action: 'reject' }); await respondToRequest({ requestId, action: 'reject' });
toast.success('Request rejected');
} catch (error) { } catch (error) {
console.error('Failed to reject request:', error); console.error('Failed to reject request:', error);
toast.error((error as Error).message || 'Failed to reject request');
} }
}; };
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="min-h-screen bg-gray-50">
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700"> <div className="bg-white border-b">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex flex-col md:flex-row gap-y-4 items-start md:items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white"> <h1 className="text-3xl font-bold text-gray-900">Manage Members</h1>
Manage Members <p className="text-gray-600 mt-1">{team?.name}</p>
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
{team?.name}
</p>
</div> </div>
<div className="flex space-x-3"> <div className="flex space-x-3">
<Button onClick={() => setShowInviteModal(true)}> <Button onClick={() => setShowInviteModal(true)}>
Invite Member Invite Member
</Button> </Button>
<Button <Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
variant="secondary"
onClick={() => navigate(`/teams/${teamId}`)}
>
Back to Team Back to Team
</Button> </Button>
</div> </div>
@@ -150,16 +112,13 @@ const ManageMembersPage: FC = (): ReactElement => {
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6"> <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
{/* Join Requests */} {/* Join Requests */}
{joinRequests.length > 0 && ( {joinRequests.length > 0 && (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-6"> <div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4"> <h2 className="text-xl font-bold text-gray-900 mb-4">
Join Requests ({joinRequests.length}) Join Requests ({joinRequests.length})
</h2> </h2>
<div className="space-y-3"> <div className="space-y-3">
{joinRequests.map((request) => ( {joinRequests.map((request) => (
<div <div key={request.id} className="border rounded-lg p-4">
key={request.id}
className="border dark:border-gray-700 rounded-lg p-4"
>
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-center space-x-3 flex-1"> <div className="flex items-center space-x-3 flex-1">
{request.user.avatar ? ( {request.user.avatar ? (
@@ -169,27 +128,17 @@ const ManageMembersPage: FC = (): ReactElement => {
className="w-12 h-12 rounded-full object-cover" 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"> <div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500 dark:text-gray-400"> <span className="text-gray-500">👤</span>
👤
</span>
</div> </div>
)} )}
<div className="flex-1"> <div className="flex-1">
<p className="font-medium text-gray-900 dark:text-white"> <p className="font-medium text-gray-900">{request.user.fullname}</p>
{request.user.fullname} <p className="text-sm text-gray-600">{request.user.email}</p>
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{request.user.email}
</p>
{request.user.location && ( {request.user.location && (
<p className="text-sm text-gray-500 dark:text-gray-500"> <p className="text-sm text-gray-500">📍 {request.user.location}</p>
📍 {request.user.location}
</p>
)} )}
<p className="text-sm text-gray-700 dark:text-gray-300 mt-2 italic"> <p className="text-sm text-gray-700 mt-2 italic">"{request.message}"</p>
"{request.message}"
</p>
</div> </div>
</div> </div>
<div className="flex space-x-2 ml-4"> <div className="flex space-x-2 ml-4">
@@ -217,21 +166,16 @@ const ManageMembersPage: FC = (): ReactElement => {
)} )}
{/* Current Members */} {/* Current Members */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-6"> <div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4"> <h2 className="text-xl font-bold text-gray-900 mb-4">
Current Members ({members.length}) Current Members ({members.length})
</h2> </h2>
{isLoadingMembers ? ( {isLoadingMembers ? (
<p className="text-gray-600 dark:text-gray-400"> <p className="text-gray-600">Loading members...</p>
Loading members...
</p>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{members.map((member) => ( {members.map((member) => (
<div <div key={member.id} className="border rounded-lg p-4 flex items-center justify-between">
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"> <div className="flex items-center space-x-3">
{member.user.avatar ? ( {member.user.avatar ? (
<img <img
@@ -240,32 +184,24 @@ const ManageMembersPage: FC = (): ReactElement => {
className="w-12 h-12 rounded-full object-cover" 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"> <div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500 dark:text-gray-400"> <span className="text-gray-500">👤</span>
👤
</span>
</div> </div>
)} )}
<div> <div>
<p className="font-medium text-gray-900 dark:text-white"> <p className="font-medium text-gray-900">{member.user.fullname}</p>
{member.user.fullname} <p className="text-sm text-gray-600">{member.user.email}</p>
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{member.user.email}
</p>
{member.user.location && ( {member.user.location && (
<p className="text-sm text-gray-500 dark:text-gray-500"> <p className="text-sm text-gray-500">📍 {member.user.location}</p>
📍 {member.user.location}
</p>
)} )}
<div className="flex items-center space-x-2 mt-1"> <div className="flex items-center space-x-2 mt-1">
{member.role === 'leader' && ( {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"> <span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs font-medium">
Leader Leader
</span> </span>
)} )}
{member.status === ETeamMemberStatus.PENDING && ( {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"> <span className="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-xs font-medium">
Pending Invitation Pending Invitation
</span> </span>
)} )}
@@ -289,41 +225,37 @@ const ManageMembersPage: FC = (): ReactElement => {
</div> </div>
{/* Warning */} {/* Warning */}
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4"> <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p className="text-sm text-yellow-800 dark:text-yellow-200"> <p className="text-sm text-yellow-800">
<strong>Note:</strong> Members cannot leave the team without your <strong>Note:</strong> Members cannot leave the team without your approval. Only you can remove members from the team.
approval. Only you can remove members from the team.
</p> </p>
</div> </div>
</div> </div>
{/* Invite Member Modal */} {/* Invite Member Modal */}
{showInviteModal && ( {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="fixed inset-0 bg-black/20 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"> <div className="bg-white 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"> <h2 className="text-2xl font-bold text-gray-900 mb-4">
Invite Member Invite Member
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400 mb-4 font-sans"> <p className="text-gray-600 mb-4">
Send an invitation to join your team. The invited member will see Send an invitation to join your team. The invited member will see the invitation on their dashboard after logging in.
the invitation on their dashboard after logging in.
</p> </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"> <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-6">
<p className="text-sm text-yellow-800 dark:text-yellow-200 font-sans"> <p className="text-sm text-yellow-800">
<strong>Important:</strong> The email you enter must match the <strong>Important:</strong> The email you enter must match the GitHub email address the member uses to sign in.
GitHub email address the member uses to sign in.
</p> </p>
</div> </div>
<form onSubmit={handleInvite} className="space-y-4"> <form onSubmit={handleInvite} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Email Address Email Address
</label> </label>
<Input <Input
{...form.register('email')} {...form.register('email')}
type="email" type="email"
placeholder="member@example.com" placeholder="member@example.com"
size="lg"
/> />
{form.formState.errors.email && ( {form.formState.errors.email && (
<p className="text-sm text-red-500 mt-1"> <p className="text-sm text-red-500 mt-1">
+117 -376
View File
@@ -1,51 +1,11 @@
import { FC, ReactElement, useState, useEffect } from 'react'; import { FC, ReactElement, useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Link, useParams, useNavigate } from 'react-router'; import { Link, useParams, useNavigate } from 'react-router';
import { import { useTeamById, useTeamMembers, useInviteMember, useTeamJoinRequests, useRespondToJoinRequest, ETeamMemberRole, useAuthStore } from '@imphnen-frontend-service/service';
useTeamById,
useTeamMembers,
useInviteMember,
useTeamJoinRequests,
useRespondToJoinRequest,
ETeamMemberRole,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Icon } from '@iconify/react';
const MAX_TEAM_MEMBERS = 5; 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 TeamDashboardPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>(); const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -53,62 +13,24 @@ const TeamDashboardPage: FC = (): ReactElement => {
const [showInviteModal, setShowInviteModal] = useState(false); const [showInviteModal, setShowInviteModal] = useState(false);
const [showJoinRequestsModal, setShowJoinRequestsModal] = useState(false); const [showJoinRequestsModal, setShowJoinRequestsModal] = useState(false);
const [inviteEmail, setInviteEmail] = useState(''); const [inviteEmail, setInviteEmail] = useState('');
const [imagesLoaded, setImagesLoaded] = useState(false);
const [imageLoadCount, setImageLoadCount] = useState(0);
// Calculate total images to load (banner + logo + member avatars) const { data: teamData, isLoading: isLoadingTeam } = useTeamById(teamId || '');
const totalImagesToLoad = 0; // Simplified - disable image loading overlay const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(teamId || '');
const { data: joinRequestsData } = useTeamJoinRequests(teamId || '', !!teamId);
const handleImageLoad = () => { const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(teamId || '');
setImageLoadCount((prev) => { const { mutateAsync: respondToJoinRequest, isPending: isResponding } = useRespondToJoinRequest(teamId || '');
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 team = teamData?.data;
const members = membersData?.data || []; const members = membersData?.data || [];
const joinRequests = joinRequestsData?.data || []; const joinRequests = joinRequestsData?.data || [];
const pendingJoinRequests = joinRequests.filter( const pendingJoinRequests = joinRequests.filter((req: any) => req.status === 'pending');
(req: any) => req.status === 'pending'
);
const currentUserId = session?.user?.id; const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_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 canInvite = isLeader && members.length < MAX_TEAM_MEMBERS;
console.log('Leader check:', { currentUserId, leaderId: team?.leader_id, isLeader });
const handleInviteMember = async (e: React.FormEvent) => { const handleInviteMember = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!inviteEmail.trim() || isInviting) return; if (!inviteEmail.trim() || isInviting) return;
@@ -124,15 +46,10 @@ const TeamDashboardPage: FC = (): ReactElement => {
} }
}; };
const handleRespondToJoinRequest = async ( const handleRespondToJoinRequest = async (requestId: string, action: 'approve' | 'reject') => {
requestId: string,
action: 'approve' | 'reject'
) => {
try { try {
await respondToJoinRequest({ requestId, action }); await respondToJoinRequest({ requestId, action });
toast.success( toast.success(action === 'approve' ? 'Request approved!' : 'Request rejected');
action === 'approve' ? 'Request approved!' : 'Request rejected'
);
} catch (error) { } catch (error) {
console.error('Failed to respond to join request:', error); console.error('Failed to respond to join request:', error);
toast.error('Failed to process request'); toast.error('Failed to process request');
@@ -141,67 +58,8 @@ const TeamDashboardPage: FC = (): ReactElement => {
if (isLoadingTeam || isLoadingMembers) { if (isLoadingTeam || isLoadingMembers) {
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="flex items-center justify-center min-h-screen">
{/* Skeleton Header */} <div className="text-gray-600">Loading team...</div>
<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> </div>
); );
} }
@@ -209,130 +67,74 @@ const TeamDashboardPage: FC = (): ReactElement => {
if (!team) { if (!team) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen"> <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"> <h2 className="text-2xl font-bold text-gray-900 mb-4">Team not found</h2>
Team not found <Button onClick={() => navigate('/dashboard')}>Back to Dashboard</Button>
</h2>
<Button onClick={() => navigate('/dashboard')}>
Back to Dashboard
</Button>
</div> </div>
); );
} }
return ( return (
<div className="min-h-screen bg-gray-50 relative dark:bg-gray-950"> <div className="min-h-screen bg-gray-50">
{/* 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 */} {/* Header with Banner */}
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700"> <div className="bg-white border-b">
{team.banner && ( {team.banner && (
<div className="relative w-full aspect-3/1 overflow-hidden"> <div className="w-full h-48 overflow-hidden">
<div
className={`absolute inset-0 bg-gray-200 animate-pulse ${
imagesLoaded ? 'hidden' : ''
}`}
/>
<img <img
src={team.banner} src={team.banner}
alt={team.name} alt={team.name}
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${ className="w-full h-full object-cover"
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>
)} )}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-6"> <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-start justify-between">
<div className="flex items-center space-x-3 md:space-x-4"> <div className="flex items-center space-x-4">
{team.logo && ( {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 <img
src={team.logo} src={team.logo}
alt={team.name} 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 ${ className="w-20 h-20 rounded-full object-cover border-4 border-white shadow-lg -mt-10"
imagesLoaded ? 'opacity-100' : 'opacity-0'
}`}
onLoad={handleImageLoad}
onError={handleImageError}
/> />
</div>
)} )}
<div> <div>
<h1 className="text-xl md:text-3xl font-bold text-gray-900 dark:text-white line-clamp-2"> <h1 className="text-3xl font-bold text-gray-900">{team.name}</h1>
{team.name} <p className="text-gray-600 mt-1">📍 {team.city}</p>
</h1> <div className="flex items-center space-x-4 mt-2">
<p className="text-sm md:text-base text-gray-600 dark:text-gray-400 mt-1 line-clamp-1 font-sans flex items-center"> <span className="text-sm text-gray-500">
<Icon icon="mdi:map-marker" className="inline-block mr-1" /> {members.length} {members.length === 1 ? 'Member' : 'Members'}
<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>
<span className="text-sm text-gray-500 dark:text-gray-400 flex items-center"> <span className="text-sm text-gray-500">
<Icon {team.visibility === 'public' ? '🌐 Public' : '🔒 Private'}
icon={
team.visibility === 'public' ? 'mdi:web' : 'mdi:lock'
}
className="inline-block mr-1"
/>
{team.visibility === 'public' ? 'Public' : 'Private'}
</span> </span>
{isLeader && ( {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"> <span className="px-3 py-1 bg-blue-600 text-white rounded-md text-sm font-semibold shadow-sm">
<Icon icon="mdi:crown" className="inline-block mr-1" /> 👑 Team Leader
<span>Team Leader</span>
</span> </span>
)} )}
</div> </div>
</div> </div>
</div> </div>
<Link to="/dashboard">
<Button variant="secondary">Back to Dashboard</Button>
</Link>
</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="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid gap-4 md:gap-6 xl:grid-cols-3"> <div className="grid gap-6 lg:grid-cols-3">
{/* Main Content */} {/* Main Content */}
<div className="lg:col-span-2 space-y-4 md:space-y-6"> <div className="lg:col-span-2 space-y-6">
{/* Team Description */} {/* Team Description */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6"> <div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4"> <h2 className="text-xl font-bold text-gray-900 mb-4">About Team</h2>
About Team <p className="text-gray-700 whitespace-pre-wrap">{team.description}</p>
</h2>
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-all font-sans">
{team.description}
</p>
</div> </div>
{/* Team Actions - Only for Leader */} {/* Team Actions - Only for Leader */}
{isLeader && ( {isLeader && (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6"> <div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4"> <h2 className="text-xl font-bold text-gray-900 mb-4">Team Management</h2>
Team Management
</h2>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<Button <Button
className="w-full" className="w-full"
@@ -340,17 +142,14 @@ const TeamDashboardPage: FC = (): ReactElement => {
onClick={() => setShowInviteModal(true)} onClick={() => setShowInviteModal(true)}
disabled={!canInvite} disabled={!canInvite}
> >
<Icon icon="mdi:plus" className="inline-block mr-2" /> Invite Member {!canInvite && `(${members.length}/${MAX_TEAM_MEMBERS})`}
Invite Member{' '}
{!canInvite && `(${members.length}/${MAX_TEAM_MEMBERS})`}
</Button> </Button>
<Button <Button
className="w-full relative" className="w-full relative"
variant="secondary" variant="secondary"
onClick={() => setShowJoinRequestsModal(true)} onClick={() => setShowJoinRequestsModal(true)}
> >
<Icon icon="mdi:email" className="inline-block mr-2" /> Join 📩 Join Requests
Requests
{pendingJoinRequests.length > 0 && ( {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"> <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} {pendingJoinRequests.length}
@@ -359,46 +158,37 @@ const TeamDashboardPage: FC = (): ReactElement => {
</Button> </Button>
<Link to={`/teams/${teamId}/edit`}> <Link to={`/teams/${teamId}/edit`}>
<Button className="w-full" variant="secondary"> <Button className="w-full" variant="secondary">
<Icon icon="mdi:pencil" className="inline-block mr-2" /> Edit Team Info
Edit Team Info
</Button> </Button>
</Link> </Link>
<Link to={`/teams/${teamId}/members`}> <Link to={`/teams/${teamId}/members`}>
<Button className="w-full" variant="secondary"> <Button className="w-full" variant="secondary">
<Icon 👥 Manage Members
icon="mdi:account-group"
className="inline-block mr-2"
/>
Manage Members
</Button> </Button>
</Link> </Link>
<Link to={`/teams/${teamId}/chat`}> <Link to={`/teams/${teamId}/chat`}>
<Button className="w-full" variant="secondary"> <Button className="w-full" variant="secondary">
<Icon icon="mdi:chat" className="inline-block mr-2" /> 💬 Team Chat
Team Chat
</Button> </Button>
</Link> </Link>
<Link to={`/teams/${teamId}/submit`}> <Link to={`/teams/${teamId}/submit`}>
<Button className="w-full"> <Button className="w-full">
<Icon icon="mdi:rocket" className="inline-block mr-2" /> 🚀 Submit Project
Submit Project
</Button> </Button>
</Link> </Link>
</div> </div>
{!canInvite && members.length >= MAX_TEAM_MEMBERS && ( {!canInvite && members.length >= MAX_TEAM_MEMBERS && (
<p className="text-sm text-gray-600 dark:text-gray-400 mt-3 text-center"> <p className="text-sm text-gray-600 mt-3 text-center">
Maximum team size reached ({MAX_TEAM_MEMBERS} members) Maximum team size reached ({MAX_TEAM_MEMBERS} members)
</p> </p>
)} )}
</div> </div>
)} )}
{/* Quick Actions for Members (non-leaders) */} {/* Quick Actions for Members */}
{!isLeader && isMember && ( {!isLeader && (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-4 md:p-6"> <div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4"> <h2 className="text-xl font-bold text-gray-900 mb-4">Quick Actions</h2>
Quick Actions
</h2>
<div className="grid gap-3 sm:grid-cols-2"> <div className="grid gap-3 sm:grid-cols-2">
<Link to={`/teams/${teamId}/chat`}> <Link to={`/teams/${teamId}/chat`}>
<Button className="w-full" variant="secondary"> <Button className="w-full" variant="secondary">
@@ -416,16 +206,14 @@ const TeamDashboardPage: FC = (): ReactElement => {
</div> </div>
)} )}
{/* Submission Status - Only show to members */} {/* Submission Status */}
{team.has_submission && isMember && ( {team.has_submission && (
<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="bg-green-50 border border-green-200 rounded-lg p-6">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<span className="text-3xl"></span> <span className="text-3xl"></span>
<div> <div>
<h3 className="font-bold text-green-900 dark:text-green-400"> <h3 className="font-bold text-green-900">Project Submitted</h3>
Project Submitted <p className="text-green-700 text-sm">
</h3>
<p className="text-green-700 dark:text-green-300 text-sm">
Your team has successfully submitted a project Your team has successfully submitted a project
</p> </p>
</div> </div>
@@ -440,17 +228,12 @@ const TeamDashboardPage: FC = (): ReactElement => {
</div> </div>
{/* Sidebar */} {/* Sidebar */}
<div className="space-y-4 md:space-y-6"> <div className="space-y-6">
{/* Team Leader */} {/* Team Leader */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6"> <div className="bg-white rounded-lg shadow-md p-6">
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4"> <h3 className="font-bold text-gray-900 mb-4">Team Leader</h3>
Team Leader
</h3>
{team.leader && ( {team.leader && (
<button <div className="flex items-center space-x-3">
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 ? ( {team.leader.avatar ? (
<img <img
src={team.leader.avatar} src={team.leader.avatar}
@@ -458,62 +241,46 @@ const TeamDashboardPage: FC = (): ReactElement => {
className="w-12 h-12 rounded-full object-cover" 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"> <div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500 dark:text-gray-400"> <span className="text-gray-500">👤</span>
👤
</span>
</div> </div>
)} )}
<div> <div>
<p className="font-medium text-gray-900 dark:text-white"> <p className="font-medium text-gray-900">{team.leader.fullname}</p>
{team.leader.fullname} <p className="text-sm text-gray-600">{team.leader.email}</p>
</p> </div>
<p className="text-sm text-gray-600 dark:text-gray-400">
{team.leader.email}
</p>
</div> </div>
</button>
)} )}
</div> </div>
{/* Team Members */} {/* Team Members */}
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6"> <div className="bg-white rounded-lg shadow-md p-6">
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4"> <h3 className="font-bold text-gray-900 mb-4">
Members ({members.length}) Members ({members.length})
</h3> </h3>
<div className="space-y-3"> <div className="space-y-3">
{members.map((member: any) => ( {members.map((member) => (
<button <div key={member.id} className="flex items-center space-x-3">
key={member.id} {member.user.avatar ? (
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 <img
src={member.user.avatar} src={member.user.avatar}
alt={member.user.fullname || 'Member'} alt={member.user.fullname}
className="w-10 h-10 rounded-full object-cover" 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"> <div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500 dark:text-gray-400 text-sm"> <span className="text-gray-500 text-sm">👤</span>
👤
</span>
</div> </div>
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 dark:text-white truncate"> <p className="font-medium text-gray-900 truncate">
{member.user?.fullname || 'Unknown'} {member.user.fullname}
</p> </p>
<p className="text-xs text-gray-500 dark:text-gray-400"> <p className="text-xs text-gray-500">
{member.role === ETeamMemberRole.LEADER {member.role === ETeamMemberRole.LEADER ? 'Leader' : 'Member'}
? 'Leader'
: 'Member'}
</p> </p>
</div> </div>
</button> </div>
))} ))}
</div> </div>
</div> </div>
@@ -523,27 +290,22 @@ const TeamDashboardPage: FC = (): ReactElement => {
{/* Invite Member Modal */} {/* Invite Member Modal */}
{showInviteModal && ( {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="fixed inset-0 bg-black/20 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6"> <div className="bg-white 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"> <h2 className="text-2xl font-bold text-gray-900 mb-4">
Invite Team Member Invite Team Member
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400 mb-4 font-sans"> <p className="text-gray-600 mb-4">
Send an invitation to join your team. The invited member will see Send an invitation to join your team. The invited member will see the invitation on their dashboard after logging in.
the invitation on their dashboard after logging in.
</p> </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"> <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-6">
<p className="text-sm text-yellow-800 dark:text-yellow-300 font-sans"> <p className="text-sm text-yellow-800">
<strong>Important:</strong> The email you enter must match the <strong>Important:</strong> The email you enter must match the GitHub email address the member uses to sign in.
GitHub email address the member uses to sign in.
</p> </p>
</div> </div>
<form onSubmit={handleInviteMember} className="space-y-4"> <form onSubmit={handleInviteMember} className="space-y-4">
<div> <div>
<label <label htmlFor="invite-email" className="block text-sm font-medium text-gray-700 mb-2">
htmlFor="invite-email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
>
Email Address Email Address
</label> </label>
<input <input
@@ -552,10 +314,10 @@ const TeamDashboardPage: FC = (): ReactElement => {
value={inviteEmail} value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)} onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Enter email address..." 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" className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required required
/> />
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> <p className="text-sm text-gray-500 mt-1">
Current members: {members.length}/{MAX_TEAM_MEMBERS} Current members: {members.length}/{MAX_TEAM_MEMBERS}
</p> </p>
</div> </div>
@@ -586,15 +348,15 @@ const TeamDashboardPage: FC = (): ReactElement => {
{/* Join Requests Modal */} {/* Join Requests Modal */}
{showJoinRequestsModal && ( {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="fixed inset-0 bg-black/30 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="bg-white 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"> <div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white"> <h2 className="text-2xl font-bold text-gray-900">
Join Requests ({pendingJoinRequests.length}) Join Requests ({pendingJoinRequests.length})
</h2> </h2>
<button <button
onClick={() => setShowJoinRequestsModal(false)} onClick={() => setShowJoinRequestsModal(false)}
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 text-2xl cursor-pointer" className="text-gray-400 hover:text-gray-600 text-2xl"
> >
</button> </button>
@@ -602,20 +364,15 @@ const TeamDashboardPage: FC = (): ReactElement => {
{pendingJoinRequests.length === 0 ? ( {pendingJoinRequests.length === 0 ? (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-600 dark:text-gray-400 text-lg"> <p className="text-gray-600 text-lg">No pending join requests</p>
No pending join requests <p className="text-gray-500 text-sm mt-2">
</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 When users request to join your team, they'll appear here
</p> </p>
</div> </div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
{pendingJoinRequests.map((request: any) => ( {pendingJoinRequests.map((request: any) => (
<div <div key={request.id} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
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 justify-between">
<div className="flex items-start space-x-3 flex-1"> <div className="flex items-start space-x-3 flex-1">
{request.user?.avatar ? ( {request.user?.avatar ? (
@@ -625,67 +382,51 @@ const TeamDashboardPage: FC = (): ReactElement => {
className="w-12 h-12 rounded-full object-cover" 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"> <div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center shrink-0">
<span className="text-gray-500 dark:text-gray-400 text-xl"> <span className="text-gray-500 text-xl">👤</span>
<Icon
icon="mdi:account-circle"
className="text-3xl"
/>
</span>
</div> </div>
)} )}
<div className="flex-1"> <div className="flex-1">
<p className="font-semibold text-gray-900 dark:text-white"> <p className="font-semibold text-gray-900">
{request.user?.fullname || 'Unknown User'} {request.user?.fullname || 'Unknown User'}
</p> </p>
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-sm text-gray-600">
{request.user?.email} {request.user?.email}
</p> </p>
{request.message && ( {request.message && (
<div className="mt-2 bg-gray-50 dark:bg-gray-800 rounded-lg p-3"> <div className="mt-2 bg-gray-50 rounded-lg p-3">
<p className="text-sm text-gray-700 dark:text-gray-300"> <p className="text-sm text-gray-700">
<strong>Message:</strong> {request.message} <strong>Message:</strong> {request.message}
</p> </p>
</div> </div>
)} )}
<p className="text-sm text-gray-500 dark:text-gray-500 mt-2 font-sans"> <p className="text-xs text-gray-500 mt-2">
Requested{' '} Requested {new Date(request.created_at).toLocaleDateString()}
{new Date(request.created_at).toLocaleDateString()}
</p> </p>
</div> </div>
</div> </div>
</div> <div className="flex space-x-2 ml-4">
<div className="flex space-x-2 mt-4 justify-end">
<Button <Button
onClick={() => onClick={() => handleRespondToJoinRequest(request.id, 'approve')}
handleRespondToJoinRequest(request.id, 'approve') disabled={isResponding || members.length >= MAX_TEAM_MEMBERS}
}
disabled={
isResponding || members.length >= MAX_TEAM_MEMBERS
}
className="px-4 py-2 text-sm" className="px-4 py-2 text-sm"
> >
Accept Accept
</Button> </Button>
<Button <Button
onClick={() => onClick={() => handleRespondToJoinRequest(request.id, 'reject')}
handleRespondToJoinRequest(request.id, 'reject')
}
disabled={isResponding} disabled={isResponding}
variant="secondary" variant="secondary"
className="px-4 py-2 text-sm" className="px-4 py-2 text-sm"
> >
Reject Reject
</Button> </Button>
</div> </div>
</div>
{members.length >= MAX_TEAM_MEMBERS && ( {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"> <div className="mt-3 bg-yellow-50 border border-yellow-200 rounded-lg p-2">
<p className="text-sm text-yellow-800 dark:text-yellow-300 font-sans"> <p className="text-xs text-yellow-800">
Team is full ({MAX_TEAM_MEMBERS}/{MAX_TEAM_MEMBERS}{' '} Team is full ({MAX_TEAM_MEMBERS}/{MAX_TEAM_MEMBERS} members). Remove a member before accepting new requests.
members). Remove a member before accepting new
requests.
</p> </p>
</div> </div>
)} )}
@@ -15,18 +15,18 @@ const SubmissionViewPage: FC = (): ReactElement => {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950"> <div className="flex items-center justify-center min-h-screen">
<div className="text-gray-600 dark:text-neutral-400">Loading submission...</div> <div className="text-gray-600">Loading submission...</div>
</div> </div>
); );
} }
if (!submission) { if (!submission) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-neutral-950"> <div className="flex flex-col items-center justify-center min-h-screen">
<div className="text-6xl mb-4">📄</div> <div className="text-6xl mb-4">📄</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">No Submission Yet</h2> <h2 className="text-2xl font-bold text-gray-900 mb-4">No Submission Yet</h2>
<p className="text-gray-600 dark:text-neutral-400 mb-4">Your team hasn't submitted a project</p> <p className="text-gray-600 mb-4">Your team hasn't submitted a project</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button> <Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
</div> </div>
); );
@@ -43,13 +43,13 @@ const SubmissionViewPage: FC = (): ReactElement => {
: 'Not submitted'; : 'Not submitted';
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950"> <div className="min-h-screen bg-gray-50">
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700"> <div className="bg-white border-b">
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Project Submission</h1> <h1 className="text-3xl font-bold text-gray-900">Project Submission</h1>
<p className="text-gray-600 dark:text-neutral-400 mt-1">{team?.name}</p> <p className="text-gray-600 mt-1">{team?.name}</p>
</div> </div>
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}> <Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team Back to Team
@@ -60,22 +60,22 @@ const SubmissionViewPage: FC = (): ReactElement => {
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Status Banner */} {/* Status Banner */}
<div className="bg-green-50 dark:bg-green-900/20 border border-green-500 rounded-lg p-6 mb-6"> <div className="bg-green-50 border border-green-500 rounded-lg p-6 mb-6">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<span className="text-4xl">✅</span> <span className="text-4xl">✅</span>
<div> <div>
<h3 className="font-bold text-green-900 dark:text-green-100 text-lg">Project Submitted Successfully</h3> <h3 className="font-bold text-green-900 text-lg">Project Submitted Successfully</h3>
<p className="text-green-700 dark:text-green-300 text-sm"> <p className="text-green-700 text-sm">
Submitted on {submittedDate} Submitted on {submittedDate}
</p> </p>
<p className="text-green-600 dark:text-green-400 text-xs mt-1"> <p className="text-green-600 text-xs mt-1">
This submission is now read-only and cannot be edited This submission is now read-only and cannot be edited
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-950/50 overflow-hidden"> <div className="bg-white rounded-lg shadow-md overflow-hidden">
{/* Project Header */} {/* Project Header */}
<div className="bg-gradient-to-r from-blue-600 to-blue-800 text-white p-8"> <div className="bg-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> <h2 className="text-3xl font-bold mb-2">{submission.project_name}</h2>
@@ -86,9 +86,9 @@ const SubmissionViewPage: FC = (): ReactElement => {
<div className="p-8 space-y-6"> <div className="p-8 space-y-6">
{/* Description */} {/* Description */}
<div> <div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Project Description</h3> <h3 className="text-lg font-bold text-gray-900 mb-3">Project Description</h3>
<div className="bg-gray-50 dark:bg-neutral-800 rounded-lg p-4"> <div className="bg-gray-50 rounded-lg p-4">
<p className="text-gray-700 dark:text-neutral-300 whitespace-pre-wrap">{submission.description}</p> <p className="text-gray-700 whitespace-pre-wrap">{submission.description}</p>
</div> </div>
</div> </div>
@@ -96,12 +96,12 @@ const SubmissionViewPage: FC = (): ReactElement => {
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
{/* Repository */} {/* Repository */}
<div> <div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Repository</h3> <h3 className="text-lg font-bold text-gray-900 mb-3">Repository</h3>
<a <a
href={submission.repository_url} href={submission.repository_url}
target="_blank" target="_blank"
rel="noopener noreferrer" 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" className="flex items-center space-x-2 text-blue-600 hover:text-blue-800"
> >
<span>🔗</span> <span>🔗</span>
<span className="break-all">{submission.repository_url}</span> <span className="break-all">{submission.repository_url}</span>
@@ -111,12 +111,12 @@ const SubmissionViewPage: FC = (): ReactElement => {
{/* Demo URL */} {/* Demo URL */}
{submission.demo_url && ( {submission.demo_url && (
<div> <div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Live Demo</h3> <h3 className="text-lg font-bold text-gray-900 mb-3">Live Demo</h3>
<a <a
href={submission.demo_url} href={submission.demo_url}
target="_blank" target="_blank"
rel="noopener noreferrer" 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" className="flex items-center space-x-2 text-blue-600 hover:text-blue-800"
> >
<span>🌐</span> <span>🌐</span>
<span className="break-all">{submission.demo_url}</span> <span className="break-all">{submission.demo_url}</span>
@@ -127,12 +127,12 @@ const SubmissionViewPage: FC = (): ReactElement => {
{/* Presentation URL */} {/* Presentation URL */}
{submission.presentation_url && ( {submission.presentation_url && (
<div> <div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Presentation</h3> <h3 className="text-lg font-bold text-gray-900 mb-3">Presentation</h3>
<a <a
href={submission.presentation_url} href={submission.presentation_url}
target="_blank" target="_blank"
rel="noopener noreferrer" 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" className="flex items-center space-x-2 text-blue-600 hover:text-blue-800"
> >
<span>📊</span> <span>📊</span>
<span className="break-all">{submission.presentation_url}</span> <span className="break-all">{submission.presentation_url}</span>
@@ -144,7 +144,7 @@ const SubmissionViewPage: FC = (): ReactElement => {
{/* Screenshots */} {/* Screenshots */}
{submission.screenshots && submission.screenshots.length > 0 && ( {submission.screenshots && submission.screenshots.length > 0 && (
<div> <div>
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3"> <h3 className="text-lg font-bold text-gray-900 mb-3">
Screenshots ({submission.screenshots.length}) Screenshots ({submission.screenshots.length})
</h3> </h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
@@ -159,7 +159,7 @@ const SubmissionViewPage: FC = (): ReactElement => {
<img <img
src={url} src={url}
alt={`Screenshot ${index + 1}`} alt={`Screenshot ${index + 1}`}
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 dark:border-neutral-700 hover:border-blue-500 dark:hover:border-primary-500 transition-colors cursor-pointer" className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 hover:border-blue-500 transition-colors cursor-pointer"
/> />
</a> </a>
))} ))}
@@ -168,22 +168,22 @@ const SubmissionViewPage: FC = (): ReactElement => {
)} )}
{/* Submission Info */} {/* Submission Info */}
<div className="bg-gray-50 dark:bg-neutral-800 rounded-lg p-4 border-t-4 border-blue-600 dark:border-primary-500"> <div className="bg-gray-50 rounded-lg p-4 border-t-4 border-blue-600">
<h3 className="text-sm font-bold text-gray-900 dark:text-white mb-2">Submission Information</h3> <h3 className="text-sm font-bold text-gray-900 mb-2">Submission Information</h3>
<div className="grid gap-2 text-sm"> <div className="grid gap-2 text-sm">
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600 dark:text-neutral-400">Status:</span> <span className="text-gray-600">Status:</span>
<span className="font-medium text-green-600 dark:text-green-400"> <span className="font-medium text-green-600">
{submission.status === 'submitted' ? ' Submitted' : 'Draft'} {submission.status === 'submitted' ? ' Submitted' : 'Draft'}
</span> </span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600 dark:text-neutral-400">Submitted:</span> <span className="text-gray-600">Submitted:</span>
<span className="font-medium text-gray-900 dark:text-white">{submittedDate}</span> <span className="font-medium text-gray-900">{submittedDate}</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600 dark:text-neutral-400">Submission ID:</span> <span className="text-gray-600">Submission ID:</span>
<span className="font-medium text-gray-900 dark:text-white font-mono text-xs"> <span className="font-medium text-gray-900 font-mono text-xs">
{submission.id} {submission.id}
</span> </span>
</div> </div>
@@ -191,8 +191,8 @@ const SubmissionViewPage: FC = (): ReactElement => {
</div> </div>
{/* Read-only Notice */} {/* 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"> <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p className="text-sm text-yellow-800 dark:text-yellow-200"> <p className="text-sm text-yellow-800">
<strong>Note:</strong> This submission is now locked and cannot be edited or deleted. <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. If you need to make changes, please contact the hackathon organizers.
</p> </p>
@@ -23,8 +23,7 @@ const SubmitProjectPage: FC = (): ReactElement => {
const { data: teamData } = useTeamById(teamId || ''); const { data: teamData } = useTeamById(teamId || '');
const { data: submissionData } = useTeamSubmission(teamId || '', !!teamId); const { data: submissionData } = useTeamSubmission(teamId || '', !!teamId);
const { mutateAsync: submitProject, isPending: isSubmitting } = const { mutateAsync: submitProject, isPending: isSubmitting } = useSubmitProject(teamId || '');
useSubmitProject(teamId || '');
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile(); const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const team = teamData?.data; const team = teamData?.data;
@@ -39,38 +38,25 @@ const SubmitProjectPage: FC = (): ReactElement => {
if (!isLeader) { if (!isLeader) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4"> <h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
Access Denied <p className="text-gray-600 mb-4">Only the team leader can submit projects</p>
</h2> <Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
<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> </div>
); );
} }
if (hasSubmission) { if (hasSubmission) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="flex flex-col items-center justify-center min-h-screen">
<div className="text-6xl mb-4"></div> <div className="text-6xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4"> <h2 className="text-2xl font-bold text-gray-900 mb-4">Project Already Submitted</h2>
Project Already Submitted <p className="text-gray-600 mb-4">Your team has already submitted a project</p>
</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"> <div className="flex space-x-3">
<Button onClick={() => navigate(`/teams/${teamId}/submission`)}> <Button onClick={() => navigate(`/teams/${teamId}/submission`)}>
View Submission View Submission
</Button> </Button>
<Button <Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
variant="secondary"
onClick={() => navigate(`/teams/${teamId}`)}
>
Back to Team Back to Team
</Button> </Button>
</div> </div>
@@ -78,16 +64,14 @@ const SubmitProjectPage: FC = (): ReactElement => {
); );
} }
const handleScreenshotUpload = async ( const handleScreenshotUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
e: React.ChangeEvent<HTMLInputElement>
) => {
const files = e.target.files; const files = e.target.files;
if (!files) return; if (!files) return;
try { try {
const uploadPromises = Array.from(files).map((file) => uploadFile(file)); const uploadPromises = Array.from(files).map(file => uploadFile(file));
const results = await Promise.all(uploadPromises); const results = await Promise.all(uploadPromises);
const urls = results.map((r) => r.data.url); const urls = results.map(r => r.data.url);
setScreenshots([...screenshots, ...urls]); setScreenshots([...screenshots, ...urls]);
} catch (error) { } catch (error) {
console.error('Failed to upload screenshots:', error); console.error('Failed to upload screenshots:', error);
@@ -111,58 +95,47 @@ const SubmitProjectPage: FC = (): ReactElement => {
}); });
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="min-h-screen bg-gray-50">
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700"> <div className="bg-white border-b">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <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"> <h1 className="text-3xl font-bold text-gray-900">Submit Project</h1>
Submit Project <p className="text-gray-600 mt-1">{team?.name}</p>
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">{team?.name}</p>
</div> </div>
</div> </div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Warning Banner */} {/* 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="bg-red-50 border-2 border-red-500 rounded-lg p-6 mb-6">
<div className="flex items-start space-x-3"> <div className="flex items-start space-x-3">
<span className="text-3xl"></span> <span className="text-3xl"></span>
<div> <div>
<h3 className="font-bold text-red-900 dark:text-red-100 text-lg"> <h3 className="font-bold text-red-900 text-lg">IMPORTANT WARNING</h3>
IMPORTANT WARNING <ul className="text-red-800 mt-2 space-y-1 text-sm">
</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> You can only submit your project ONCE</li>
<li> After submission, you CANNOT edit or change anything</li> <li> After submission, you CANNOT edit or change anything</li>
<li> <li> Make sure all information is correct before submitting</li>
Make sure all information is correct before submitting
</li>
<li> Review your project details carefully</li> <li> Review your project details carefully</li>
</ul> </ul>
</div> </div>
</div> </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"> <div className="bg-white rounded-lg shadow-md p-8">
<form <form onSubmit={(e) => {
onSubmit={(e) => {
e.preventDefault(); e.preventDefault();
setShowConfirmModal(true); setShowConfirmModal(true);
}} }} className="space-y-6">
className="space-y-6"
>
{/* Project Name */} {/* Project Name */}
<ControlledInputField <ControlledInputField
control={form.control} control={form.control}
label="Project Name" label="Project Name"
placeholder="Enter your project name" placeholder="Enter your project name"
name="project_name" name="project_name"
size="lg"
isRequired={true}
/> />
{/* Description */} {/* Description */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-[15px] font-medium text-gray-700 dark:text-gray-300"> <label className="block text-sm font-medium text-gray-700">
Project Description <span className="text-red-500">*</span> Project Description <span className="text-red-500">*</span>
</label> </label>
<Controller <Controller
@@ -175,12 +148,9 @@ const SubmitProjectPage: FC = (): ReactElement => {
placeholder="Describe your project, its features, and what problem it solves..." placeholder="Describe your project, its features, and what problem it solves..."
rows={6} rows={6}
className="w-full" className="w-full"
size="lg"
/> />
{fieldState.error && ( {fieldState.error && (
<p className="text-sm text-red-500 mt-1"> <p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
{fieldState.error.message}
</p>
)} )}
</div> </div>
)} )}
@@ -194,8 +164,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
placeholder="https://github.com/username/project" placeholder="https://github.com/username/project"
name="repository_url" name="repository_url"
type="url" type="url"
size="lg"
isRequired={true}
/> />
{/* Demo URL */} {/* Demo URL */}
@@ -205,7 +173,6 @@ const SubmitProjectPage: FC = (): ReactElement => {
placeholder="https://your-project-demo.com" placeholder="https://your-project-demo.com"
name="demo_url" name="demo_url"
type="url" type="url"
size="lg"
/> />
{/* Presentation URL */} {/* Presentation URL */}
@@ -215,13 +182,12 @@ const SubmitProjectPage: FC = (): ReactElement => {
placeholder="https://slides.com/your-presentation or Google Drive link" placeholder="https://slides.com/your-presentation or Google Drive link"
name="presentation_url" name="presentation_url"
type="url" type="url"
size="lg"
/> />
{/* Screenshots */} {/* Screenshots */}
<div> <div className="space-y-2">
<label className="block text-label1 font-medium text-neutral-800 dark:text-gray-300"> <label className="block text-sm font-medium text-gray-700">
Project Screenshots (Optional) Project Screenshots <span className="text-gray-400">(Optional)</span>
</label> </label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-4"> <div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-4">
{screenshots.map((url, index) => ( {screenshots.map((url, index) => (
@@ -229,7 +195,7 @@ const SubmitProjectPage: FC = (): ReactElement => {
<img <img
src={url} src={url}
alt={`Screenshot ${index + 1}`} alt={`Screenshot ${index + 1}`}
className="w-full h-32 object-cover rounded-lg border-2 border-gray-200 dark:border-gray-700" className="w-full h-32 object-cover rounded-lg border-2 border-gray-200"
/> />
<button <button
type="button" type="button"
@@ -241,14 +207,10 @@ const SubmitProjectPage: FC = (): ReactElement => {
</div> </div>
))} ))}
</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"> <label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50">
<div className="text-center"> <div className="text-center">
<p className="text-gray-500 dark:text-gray-400"> <p className="text-gray-500">Click to upload screenshots</p>
Click to upload screenshots <p className="text-xs text-gray-400 mt-1">PNG, JPG up to 5MB each</p>
</p>
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1 font-sans">
PNG, JPG up to 5MB each
</p>
</div> </div>
<input <input
type="file" type="file"
@@ -285,22 +247,22 @@ const SubmitProjectPage: FC = (): ReactElement => {
{/* Confirmation Modal */} {/* Confirmation Modal */}
{showConfirmModal && ( {showConfirmModal && (
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black bg-opacity-50 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="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<div className="text-center mb-6"> <div className="text-center mb-6">
<div className="text-5xl mb-4"></div> <div className="text-5xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2"> <h2 className="text-2xl font-bold text-gray-900 mb-2">
Final Confirmation Final Confirmation
</h2> </h2>
<p className="text-red-600 dark:text-red-400 font-medium"> <p className="text-red-600 font-medium">
This action is IRREVERSIBLE! This action is IRREVERSIBLE!
</p> </p>
</div> </div>
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 mb-6"> <div className="bg-gray-50 rounded-lg p-4 mb-6">
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3"> <p className="text-sm text-gray-700 mb-3">
By clicking "Submit Project", you confirm that: By clicking "Submit Project", you confirm that:
</p> </p>
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-2"> <ul className="text-sm text-gray-600 space-y-2">
<li> All information is correct and complete</li> <li> All information is correct and complete</li>
<li> You understand this can only be done once</li> <li> You understand this can only be done once</li>
<li> You cannot edit after submission</li> <li> You cannot edit after submission</li>
+55 -101
View File
@@ -1,38 +1,23 @@
import { FC, ReactElement, useState, useEffect } from 'react'; import { FC, ReactElement, useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { Link, useNavigate } from 'react-router'; import { Link, useNavigate } from 'react-router';
import { import { useTeams, useJoinTeam, useMyTeams, ETeamVisibility, joinTeamSchema, TJoinTeamForm } from '@imphnen-frontend-service/service';
useTeams,
useJoinTeam,
useMyTeams,
ETeamVisibility,
joinTeamSchema,
TJoinTeamForm,
} from '@imphnen-frontend-service/service';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { CitySelect } from '../../../components/city-select'; import INDONESIAN_CITIES_DATA from '../../../constants/cities';
import { Icon } from '@iconify/react';
const INDONESIAN_CITIES = ['All Cities', ...INDONESIAN_CITIES_DATA];
const BrowseTeamsPage: FC = (): ReactElement => { const BrowseTeamsPage: FC = (): ReactElement => {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchTerm, setSearchTerm] = useState(''); const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState(''); const [selectedCity, setSelectedCity] = useState('All Cities');
const [selectedCity, setSelectedCity] = useState('');
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null); const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const [showJoinModal, setShowJoinModal] = useState(false); const [showJoinModal, setShowJoinModal] = useState(false);
// Debounce search term
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(searchTerm);
}, 300);
return () => clearTimeout(timer);
}, [searchTerm]);
const { data: teamsData, isLoading } = useTeams({ const { data: teamsData, isLoading } = useTeams({
search: debouncedSearch, search,
city: selectedCity || undefined, city: selectedCity === 'All Cities' ? undefined : selectedCity,
visibility: ETeamVisibility.PUBLIC, visibility: ETeamVisibility.PUBLIC,
}); });
@@ -71,20 +56,16 @@ const BrowseTeamsPage: FC = (): ReactElement => {
}); });
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950"> <div className="min-h-screen bg-gray-50">
{/* Header */} {/* Header */}
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-800"> <div className="bg-white border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <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 className="flex items-center justify-between">
<div> <div>
<h1 className="text-3xl font-bold text-gray-900 dark:text-white"> <h1 className="text-3xl font-bold text-gray-900">Browse Teams</h1>
Browse Teams <p className="text-gray-600 mt-1">Find and join teams looking for members</p>
</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">
Find and join teams looking for members
</p>
</div> </div>
<Link to="/dashboard" className="hidden md:block"> <Link to="/dashboard">
<Button variant="secondary">Back to Dashboard</Button> <Button variant="secondary">Back to Dashboard</Button>
</Link> </Link>
</div> </div>
@@ -93,29 +74,34 @@ const BrowseTeamsPage: FC = (): ReactElement => {
{/* Filters */} {/* Filters */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <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="bg-white p-6 rounded-lg shadow-sm mb-6">
<div className="grid gap-4 md:grid-cols-2"> <div className="grid gap-4 md:grid-cols-2">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Search Teams Search Teams
</label> </label>
<input <Input
type="text" type="text"
placeholder="Search by team name..." placeholder="Search by team name..."
value={searchTerm} value={search}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearch(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>
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Filter by City Filter by City
</label> </label>
<CitySelect <select
value={selectedCity} value={selectedCity}
onChange={setSelectedCity} onChange={(e) => setSelectedCity(e.target.value)}
placeholder="All Cities (search to filter...)" className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/> >
{INDONESIAN_CITIES.map((city) => (
<option key={city} value={city}>
{city}
</option>
))}
</select>
</div> </div>
</div> </div>
</div> </div>
@@ -123,30 +109,25 @@ const BrowseTeamsPage: FC = (): ReactElement => {
{/* Teams List */} {/* Teams List */}
{isLoading ? ( {isLoading ? (
<div className="text-center py-12"> <div className="text-center py-12">
<p className="text-gray-600 dark:text-gray-400">Loading teams...</p> <p className="text-gray-600">Loading teams...</p>
</div> </div>
) : teams.length === 0 ? ( ) : teams.length === 0 ? (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-sm p-12 text-center"> <div className="bg-white rounded-lg shadow-sm p-12 text-center">
<p className="text-gray-600 dark:text-gray-400 text-lg"> <p className="text-gray-600 text-lg">No teams found</p>
No teams found <p className="text-gray-500 mt-2">Try adjusting your filters</p>
</p>
<p className="text-gray-500 dark:text-gray-500 mt-2">
Try adjusting your filters
</p>
</div> </div>
) : ( ) : (
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{teams.map((team) => ( {teams.map((team) => (
<div <div key={team.id} className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
key={team.id} {team.banner && (
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 <img
src={team.banner || '/images/banner-imphnen.webp'} src={team.banner}
alt={team.name} alt={team.name}
className="w-full aspect-3/1 object-cover" className="w-full h-32 object-cover"
/> />
<div className="p-6 flex flex-col flex-1"> )}
<div className="p-6">
<div className="flex items-center space-x-3 mb-3"> <div className="flex items-center space-x-3 mb-3">
{team.logo ? ( {team.logo ? (
<img <img
@@ -155,32 +136,18 @@ const BrowseTeamsPage: FC = (): ReactElement => {
className="w-12 h-12 rounded-full object-cover" 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"> <div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500 dark:text-gray-300 text-xl"> <span className="text-gray-500 text-xl">👥</span>
<Icon icon="mdi:account-group" />
</span>
</div> </div>
)} )}
<div className="flex-1 min-w-0"> <div className="flex-1">
<h3 className="text-lg font-bold text-gray-900 dark:text-white line-clamp-2 leading-tight"> <h3 className="text-lg font-bold text-gray-900">{team.name}</h3>
{team.name} <p className="text-sm text-gray-600">📍 {team.city}</p>
</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> </div>
</div> <p className="text-gray-600 text-sm mb-4 line-clamp-3">
<p className="text-gray-600 dark:text-gray-300 text-sm mb-4 line-clamp-3 font-sans">
{team.description} {team.description}
</p> </p>
<div className="space-y-3 mt-auto">
{isMyTeam(team.id) ? ( {isMyTeam(team.id) ? (
<Button <Button
className="w-full" className="w-full"
@@ -190,9 +157,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
Your Team Your Team
</Button> </Button>
) : ( ) : (
<>
{myTeams.length === 0 &&
(team.members?.length || 0) < 5 && (
<Button <Button
className="w-full" className="w-full"
onClick={() => handleJoinRequest(team.id)} onClick={() => handleJoinRequest(team.id)}
@@ -200,16 +164,6 @@ const BrowseTeamsPage: FC = (): ReactElement => {
Request to Join Request to Join
</Button> </Button>
)} )}
<Button
className="w-full"
variant="secondary"
onClick={() => navigate(`/teams/${team.id}`)}
>
View Team
</Button>
</>
)}
</div>
</div> </div>
</div> </div>
))} ))}
@@ -219,27 +173,27 @@ const BrowseTeamsPage: FC = (): ReactElement => {
{/* Join Request Modal */} {/* Join Request Modal */}
{showJoinModal && ( {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="fixed inset-0 bg-black/30 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6"> <div className="bg-white 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"> <h2 className="text-2xl font-bold text-gray-900 mb-4">
Request to Join Team Request to Join Team
</h2> </h2>
<p className="text-gray-600 dark:text-gray-400 mb-6 font-sans"> <p className="text-gray-600 mb-6">
Send a message to the team leader explaining why you want to join Send a message to the team leader explaining why you want to join
</p> </p>
<form onSubmit={onSubmit} className="space-y-4"> <form onSubmit={onSubmit} className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Your Message Your Message
</label> </label>
<textarea <textarea
{...form.register('message')} {...form.register('message')}
rows={4} 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" className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Tell the team leader why you want to join their team..." placeholder="Tell the team leader why you want to join their team..."
/> />
{form.formState.errors.message && ( {form.formState.errors.message && (
<p className="text-sm text-red-500 mt-1 font-sans"> <p className="text-sm text-red-500 mt-1">
{form.formState.errors.message.message} {form.formState.errors.message.message}
</p> </p>
)} )}
+44 -35
View File
@@ -5,8 +5,7 @@ import { useNavigate } from 'react-router';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { teamCreateSchema, TTeamCreateForm, useCreateTeam, ETeamVisibility, useUploadFile } from '@imphnen-frontend-service/service'; import { teamCreateSchema, TTeamCreateForm, useCreateTeam, ETeamVisibility, useUploadFile } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import INDONESIAN_CITIES from '../../../constants/cities';
import { CitySelect } from '../../../components/city-select';
const CreateTeamPage: FC = (): ReactElement => { const CreateTeamPage: FC = (): ReactElement => {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -80,29 +79,29 @@ const CreateTeamPage: FC = (): ReactElement => {
}); });
return ( return (
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950"> <div className="min-h-screen bg-gray-50">
{/* Header */} {/* Header */}
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700"> <div className="bg-white border-b">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6"> <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> <h1 className="text-3xl font-bold text-gray-900">Create Your Team</h1>
<p className="text-gray-600 dark:text-neutral-400 mt-1">Build your hackathon dream team</p> <p className="text-gray-600 mt-1">Build your hackathon dream team</p>
</div> </div>
</div> </div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-900/50 p-8"> <div className="bg-white rounded-lg shadow-md p-8">
<form onSubmit={onSubmit} className="space-y-6"> <form onSubmit={onSubmit} className="space-y-6">
{/* Banner Upload */} {/* Banner Upload */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Team Banner <span className="text-gray-400 dark:text-neutral-500">(Optional)</span> Team Banner <span className="text-gray-400">(Optional)</span>
</label> </label>
{bannerPreview ? ( {bannerPreview ? (
<div className="relative"> <div className="relative">
<img <img
src={bannerPreview} src={bannerPreview}
alt="Banner preview" alt="Banner preview"
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 dark:border-neutral-700" className="w-full h-48 object-cover rounded-lg border-2 border-gray-200"
/> />
<button <button
type="button" type="button"
@@ -116,10 +115,10 @@ const CreateTeamPage: FC = (): ReactElement => {
</button> </button>
</div> </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"> <label className="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50">
<div className="text-center"> <div className="text-center">
<p className="text-gray-500 dark:text-neutral-400">Click to upload banner</p> <p className="text-gray-500">Click to upload banner</p>
<p className="text-xs text-gray-400 dark:text-neutral-500 mt-1">1200x400 recommended</p> <p className="text-xs text-gray-400 mt-1">1200x400 recommended</p>
</div> </div>
<input <input
type="file" type="file"
@@ -133,19 +132,19 @@ const CreateTeamPage: FC = (): ReactElement => {
{/* Logo Upload */} {/* Logo Upload */}
<div> <div>
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
Team Logo <span className="text-gray-400 dark:text-neutral-500">(Optional, but highly recommended)</span> Team Logo <span className="text-gray-400">(Optional, but highly recommended)</span>
</label> </label>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
{logoPreview ? ( {logoPreview ? (
<img <img
src={logoPreview} src={logoPreview}
alt="Logo preview" alt="Logo preview"
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200 dark:border-neutral-700" className="w-24 h-24 rounded-full object-cover border-2 border-gray-200"
/> />
) : ( ) : (
<div className="w-24 h-24 rounded-full bg-gray-200 dark:bg-neutral-700 flex items-center justify-center"> <div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-400 dark:text-neutral-500 text-3xl">👥</span> <span className="text-gray-400 text-3xl">👥</span>
</div> </div>
)} )}
<div> <div>
@@ -187,26 +186,36 @@ const CreateTeamPage: FC = (): ReactElement => {
{/* City */} {/* City */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300"> <label className="block text-sm font-medium text-gray-700">
City <span className="text-red-500">*</span> City <span className="text-red-500">*</span>
</label> </label>
<Controller <Controller
control={form.control} control={form.control}
name="city" name="city"
render={({ field, fieldState }) => ( render={({ field, fieldState }) => (
<CitySelect <div>
value={field.value} <select
onChange={field.onChange} {...field}
error={fieldState.error?.message} className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Search your city..." >
/> <option value="">Select city</option>
{INDONESIAN_CITIES.map((city) => (
<option key={city} value={city}>
{city}
</option>
))}
</select>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
)}
</div>
)} )}
/> />
</div> </div>
{/* Description */} {/* Description */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300"> <label className="block text-sm font-medium text-gray-700">
Description <span className="text-red-500">*</span> Description <span className="text-red-500">*</span>
</label> </label>
<Controller <Controller
@@ -230,7 +239,7 @@ const CreateTeamPage: FC = (): ReactElement => {
{/* Visibility */} {/* Visibility */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300"> <label className="block text-sm font-medium text-gray-700">
Team Visibility <span className="text-red-500">*</span> Team Visibility <span className="text-red-500">*</span>
</label> </label>
<Controller <Controller
@@ -238,7 +247,7 @@ const CreateTeamPage: FC = (): ReactElement => {
name="visibility" name="visibility"
render={({ field }) => ( render={({ field }) => (
<div className="space-y-3"> <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"> <label className="flex items-start space-x-3 cursor-pointer border rounded-lg p-4 hover:bg-gray-50">
<input <input
type="radio" type="radio"
{...field} {...field}
@@ -247,13 +256,13 @@ const CreateTeamPage: FC = (): ReactElement => {
className="mt-1" className="mt-1"
/> />
<div> <div>
<p className="font-medium text-gray-900 dark:text-white">Public</p> <p className="font-medium text-gray-900">Public</p>
<p className="text-sm text-gray-600 dark:text-neutral-400"> <p className="text-sm text-gray-600">
Team will be visible in Browse Teams. Anyone can request to join. Team will be visible in Browse Teams. Anyone can request to join.
</p> </p>
</div> </div>
</label> </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"> <label className="flex items-start space-x-3 cursor-pointer border rounded-lg p-4 hover:bg-gray-50">
<input <input
type="radio" type="radio"
{...field} {...field}
@@ -262,8 +271,8 @@ const CreateTeamPage: FC = (): ReactElement => {
className="mt-1" className="mt-1"
/> />
<div> <div>
<p className="font-medium text-gray-900 dark:text-white">Private</p> <p className="font-medium text-gray-900">Private</p>
<p className="text-sm text-gray-600 dark:text-neutral-400"> <p className="text-sm text-gray-600">
Team is hidden from Browse Teams. Members can only join via invitation. Team is hidden from Browse Teams. Members can only join via invitation.
</p> </p>
</div> </div>
@@ -274,8 +283,8 @@ const CreateTeamPage: FC = (): ReactElement => {
</div> </div>
{/* Warning */} {/* Warning */}
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4"> <div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p className="text-sm text-yellow-800 dark:text-yellow-200"> <p className="text-sm text-yellow-800">
<strong>Note:</strong> As team leader, you cannot leave or join another team after creating this team. <strong>Note:</strong> As team leader, you cannot leave or join another team after creating this team.
</p> </p>
</div> </div>
-48
View File
@@ -1,48 +0,0 @@
import { FC, ReactElement, useState } from 'react';
import { Outlet } from 'react-router';
import { Sidebar } from '../../components/sidebar';
const TeamsLayout: FC = (): ReactElement => {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex-1 flex flex-col">
{/* Mobile Header with Hamburger */}
<div className="lg:hidden sticky top-0 bg-white dark:bg-gray-900 border-b dark:border-gray-700 px-4 py-3 z-10">
<div className="flex items-center">
<button
onClick={() => setSidebarOpen(true)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<svg
className="w-6 h-6 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<h1 className="ml-3 text-lg font-bold text-gray-900 dark:text-white">
Hackathon
</h1>
</div>
</div>
<main className="flex-1">
<Outlet />
</main>
</div>
</div>
);
};
export default TeamsLayout;
@@ -1,46 +0,0 @@
import { FC, ReactElement, useState } from 'react';
import { Outlet } from 'react-router';
import { Sidebar } from '../../../components/sidebar';
const UserDetailLayout: FC = (): ReactElement => {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex-1 flex flex-col overflow-auto">
{/* Mobile Header with Hamburger */}
<div className="lg:hidden bg-white dark:bg-gray-900 border-b dark:border-neutral-700 px-4 py-3 flex items-center">
<button
onClick={() => setSidebarOpen(true)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<svg
className="w-6 h-6 text-gray-600 dark:text-neutral-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<h1 className="ml-3 text-lg font-bold text-gray-900 dark:text-white">
Hackathon
</h1>
</div>
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
</div>
);
};
export default UserDetailLayout;
@@ -1,236 +0,0 @@
import { FC, ReactElement } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useParams, useNavigate, Link } from 'react-router';
import {
useUserDetailsById,
useTeamsByUserId,
} from '@imphnen-frontend-service/service';
import { Icon } from '@iconify/react';
const UserProfilePage: FC = (): ReactElement => {
const { userId } = useParams<{ userId: string }>();
const navigate = useNavigate();
const { data: userData, isLoading, error } = useUserDetailsById(userId || '');
const { data: teamsData } = useTeamsByUserId(userId || '');
const user = userData?.data;
const userTeams = teamsData?.data || [];
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-gray-600 dark:text-gray-400">
Loading user profile...
</div>
</div>
);
}
if (error || !user) {
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
User not found
</h2>
{error && (
<p className="text-red-600 dark:text-red-400 mb-4">{String(error)}</p>
)}
<Button onClick={() => navigate('/dashboard')}>
Back to Dashboard
</Button>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
{/* Header */}
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-6">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3 md:space-x-4">
{user.avatar ? (
<img
src={user.avatar}
alt={user.fullname}
className="w-16 h-16 md:w-20 md:h-20 rounded-full object-cover border-4 border-white dark:border-gray-800 shadow-lg"
/>
) : (
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center border-4 border-white dark:border-gray-800 shadow-lg">
<span className="text-gray-500 dark:text-gray-400 text-2xl md:text-3xl">
<Icon icon="mdi:account-circle" className="w-10 h-10" />
</span>
</div>
)}
<div>
<h1 className="text-xl md:text-3xl font-bold text-gray-900 dark:text-white">
{user.fullname}
</h1>
<p className="text-sm md:text-base text-gray-600 dark:text-gray-400 mt-1">
{user.email}
</p>
{user.location && (
<div className="flex items-center space-x-4 mt-2">
<span className="text-sm text-gray-500 dark:text-gray-500">
<Icon
icon="mdi:map-marker"
className="inline-block w-4 h-4 mr-1"
/>
{user.location}
</span>
</div>
)}
</div>
</div>
<Link to="/dashboard" className="hidden md:block">
<Button variant="secondary">Back to Dashboard</Button>
</Link>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-8">
<div className="grid gap-4 md:gap-6 lg:grid-cols-3">
{/* Main Content */}
<div className="lg:col-span-2 space-y-4 md:space-y-6">
{/* About Section */}
{user.bio && (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6 overflow-hidden">
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
About
</h2>
<p className="text-gray-700 dark:text-gray-300 break-all overflow-wrap-anywhere font-sans">
{user.bio}
</p>
</div>
)}
{/* Skills Section */}
{user.skills && user.skills.length > 0 && (
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
Skills
</h2>
<div className="flex flex-wrap gap-3">
{user.skills.map((skill: string) => (
<span
key={skill}
className="inline-flex items-center px-4 py-2 border border-primary-600 dark:border-gray-500 text-primary-600 dark:text-white rounded-4xl text-xs font-medium dark:bg-gray-600"
>
{skill}
</span>
))}
</div>
</div>
)}
{/* 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;
@@ -1,117 +0,0 @@
import { FC, useState, useRef, useEffect } from 'react';
import INDONESIAN_CITIES from '../constants/cities';
interface CitySelectProps {
value: string;
onChange: (value: string) => void;
error?: string;
placeholder?: string;
}
export const CitySelect: FC<CitySelectProps> = ({
value,
onChange,
error,
placeholder = 'Search your city...',
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const dropdownRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Filter cities based on search query
const filteredCities = INDONESIAN_CITIES.filter((city) =>
city.toLowerCase().includes(searchQuery.toLowerCase())
);
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelectCity = (city: string) => {
onChange(city);
setSearchQuery('');
setIsOpen(false);
};
const handleInputClick = () => {
setIsOpen(true);
setSearchQuery('');
};
const handleClearSelection = () => {
onChange('');
setSearchQuery('');
setIsOpen(true);
inputRef.current?.focus();
};
return (
<div className="relative" ref={dropdownRef}>
<div className="relative">
<input
ref={inputRef}
type="text"
value={isOpen ? searchQuery : value}
onChange={(e) => {
setSearchQuery(e.target.value);
setIsOpen(true);
}}
onClick={handleInputClick}
placeholder={placeholder}
className={`w-full h-[42px] px-3 text-[15px] border rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 ${
error ? 'border-red-500' : 'border-gray-300 dark:border-gray-600'
}`}
/>
{value && !isOpen && (
<button
type="button"
onClick={handleClearSelection}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 dark:text-gray-500 hover:text-gray-600 cursor-pointer dark:hover:text-gray-300"
>
</button>
)}
</div>
{error && <p className="text-sm text-red-500 mt-1">{error}</p>}
{isOpen && (
<div className="absolute z-50 w-full mt-1 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto text-label2">
{filteredCities.length > 0 ? (
<ul className="py-1">
{filteredCities.map((city) => (
<li
key={city}
onClick={() => handleSelectCity(city)}
className={`px-3 py-2 cursor-pointer hover:bg-blue-50 dark:hover:bg-blue-900/30 ${
value === city
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-400'
: 'text-gray-900 dark:text-gray-200'
}`}
>
{city}
</li>
))}
</ul>
) : (
<div className="px-3 py-2 text-gray-500 dark:text-gray-400 text-sm">
No cities found
</div>
)}
</div>
)}
</div>
);
};
-302
View File
@@ -1,302 +0,0 @@
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;
@@ -1,100 +0,0 @@
import { createContext, useContext, useEffect, useState, FC, ReactNode } from 'react';
type Theme = 'light' | 'dark' | 'system';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
resolvedTheme: 'light' | 'dark';
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
const STORAGE_KEY = 'hackathon-theme';
function getSystemTheme(): 'light' | 'dark' {
if (typeof window !== 'undefined') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return 'light';
}
interface ThemeProviderProps {
children: ReactNode;
defaultTheme?: Theme;
}
export const ThemeProvider: FC<ThemeProviderProps> = ({ children, defaultTheme = 'system' }) => {
const [theme, setThemeState] = useState<Theme>(() => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(STORAGE_KEY) as Theme | null;
return stored || defaultTheme;
}
return defaultTheme;
});
const [resolvedTheme, setResolvedTheme] = useState<'light' | 'dark'>(() => {
if (theme === 'system') {
return getSystemTheme();
}
return theme;
});
const setTheme = (newTheme: Theme) => {
setThemeState(newTheme);
localStorage.setItem(STORAGE_KEY, newTheme);
};
useEffect(() => {
const root = document.documentElement;
// Calculate the resolved theme
const resolved = theme === 'system' ? getSystemTheme() : theme;
setResolvedTheme(resolved);
// Apply dark class to root
if (resolved === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
}, [theme]);
// Listen for system theme changes
useEffect(() => {
if (theme !== 'system') return;
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (e: MediaQueryListEvent) => {
const newTheme = e.matches ? 'dark' : 'light';
setResolvedTheme(newTheme);
const root = document.documentElement;
if (newTheme === 'dark') {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
{children}
</ThemeContext.Provider>
);
};
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}
export default ThemeProvider;
@@ -1,93 +0,0 @@
import { FC } from 'react';
import { useTheme } from './theme-provider';
interface ThemeToggleProps {
className?: string;
}
// Sun icon for light mode
const SunIcon = () => (
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"
/>
</svg>
);
// Moon icon for dark mode
const MoonIcon = () => (
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"
/>
</svg>
);
// System icon (monitor)
const SystemIcon = () => (
<svg
className="w-5 h-5"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
);
export const ThemeToggle: FC<ThemeToggleProps> = ({ className = '' }) => {
const { theme, setTheme, resolvedTheme } = useTheme();
const cycleTheme = () => {
if (theme === 'light') setTheme('dark');
else if (theme === 'dark') setTheme('system');
else setTheme('light');
};
const getThemeLabel = () => {
if (theme === 'system') return 'System';
return theme === 'dark' ? 'Dark' : 'Light';
};
const renderIcon = () => {
if (theme === 'system') {
return <SystemIcon />;
}
return resolvedTheme === 'dark' ? <MoonIcon /> : <SunIcon />;
};
return (
<button
type="button"
onClick={cycleTheme}
className={`p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-neutral-800 transition-colors cursor-pointer text-gray-600 dark:text-neutral-200 ${className}`}
title={`Theme: ${getThemeLabel()}`}
aria-label={`Current theme: ${getThemeLabel()}. Click to change.`}
>
{renderIcon()}
</button>
);
};
export default ThemeToggle;
+7 -40
View File
@@ -2,9 +2,6 @@
@import 'tailwindcss'; @import 'tailwindcss';
@source "../../../libs/ui/**/*.{ts,tsx}"; @source "../../../libs/ui/**/*.{ts,tsx}";
/* Enable class-based dark mode (using .dark class on html element) */
@custom-variant dark (&:where(.dark, .dark *));
@theme { @theme {
--color-primary-50: #f0f8ff; --color-primary-50: #f0f8ff;
--color-primary-100: #e1f0fd; --color-primary-100: #e1f0fd;
@@ -23,12 +20,12 @@
--color-neutral-200: #d1d1d1; --color-neutral-200: #d1d1d1;
--color-neutral-300: #b0b0b0; --color-neutral-300: #b0b0b0;
--color-neutral-400: #888888; --color-neutral-400: #888888;
--color-neutral-500: #6e6a86; --color-neutral-500: #6d6d6d;
--color-neutral-600: #56526e; --color-neutral-600: #5d5d5d;
--color-neutral-700: #44415a; --color-neutral-700: #4f4f4f;
--color-neutral-800: #393552; --color-neutral-800: #454545;
--color-neutral-900: #2a273f; --color-neutral-900: #3d3d3d;
--color-neutral-950: #232136; --color-neutral-950: #2b2b2b;
--color-success-100: #e0fbd8; --color-success-100: #e0fbd8;
--color-success-200: #bcf8b0; --color-success-200: #bcf8b0;
@@ -111,14 +108,6 @@
border-color: var(--color-neutral-200, currentColor); 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 */ /* Custom scrollbar */
::-webkit-scrollbar { ::-webkit-scrollbar {
width: 6px; width: 6px;
@@ -129,41 +118,19 @@
background: var(--color-neutral-50); background: var(--color-neutral-50);
} }
.dark ::-webkit-scrollbar-track {
background: var(--color-neutral-900);
}
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #cccccc; background: #cccccc;
border-radius: 3px; border-radius: 3px;
} }
.dark ::-webkit-scrollbar-thumb {
background: var(--color-neutral-600);
}
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: var(--color-neutral-300); background: var(--color-neutral-300);
} }
.dark ::-webkit-scrollbar-thumb:hover {
background: var(--color-neutral-500);
}
html { html {
font-family: 'Bai Jamjuree', sans-serif; font-family: 'Bai Jamjuree', sans-serif;
font-weight: 400; font-weight: 400;
font-size: 16px; font-size: 12px;
line-height: 1.2; 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);
}
} }
+1 -4
View File
@@ -9,7 +9,6 @@ import {
QueryProvider, QueryProvider,
} from '@imphnen-frontend-service/utils'; } from '@imphnen-frontend-service/utils';
import { Toaster } from 'sonner'; import { Toaster } from 'sonner';
import { ThemeProvider } from './components/theme-provider';
import './index.css'; import './index.css';
const files = import.meta.glob('./app/**/*(page|layout).tsx'); const files = import.meta.glob('./app/**/*(page|layout).tsx');
@@ -37,13 +36,11 @@ if (!rootElement) throw new Error('Failed to find the root element');
createRoot(rootElement).render( createRoot(rootElement).render(
<StrictMode> <StrictMode>
<ThemeProvider defaultTheme="system">
<QueryProvider> <QueryProvider>
<ModalLoginProvider> <ModalLoginProvider>
<Toaster position="top-right" richColors /> <Toaster position="top-right" />
<RouterProvider router={router} /> <RouterProvider router={router} />
</ModalLoginProvider> </ModalLoginProvider>
</QueryProvider> </QueryProvider>
</ThemeProvider>
</StrictMode> </StrictMode>
); );
+4
View File
@@ -37,6 +37,8 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url); const url = new URL(request.url);
const pathname = url.pathname; const pathname = url.pathname;
console.log('[Middleware] Checking route:', pathname);
// Get session from Supabase (authoritative source) // Get session from Supabase (authoritative source)
const { data: { session: supabaseSession }, error: sessionError } = await supabase.auth.getSession(); const { data: { session: supabaseSession }, error: sessionError } = await supabase.auth.getSession();
@@ -84,8 +86,10 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
let hasLocation = false; let hasLocation = false;
if (cached && (now - cached.timestamp) < CACHE_DURATION) { if (cached && (now - cached.timestamp) < CACHE_DURATION) {
console.log('[Middleware] Using cached onboarding status');
hasLocation = cached.hasLocation; hasLocation = cached.hasLocation;
} else { } else {
console.log('[Middleware] Fetching fresh onboarding status');
const { data: userData, error: userError } = await supabase const { data: userData, error: userError } = await supabase
.from('users') .from('users')
.select('location') .select('location')
+1 -1
View File
@@ -1 +1 @@
Tue, Nov 25, 2025 4:21:27 PM Tue, Nov 25, 2025 11:09:55 AM
-1
View File
@@ -7,7 +7,6 @@ FROM node:22-alpine AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
ENV NX_DAEMON=false
RUN npm run backoffice:build RUN npm run backoffice:build
FROM nginx:alpine AS runner FROM nginx:alpine AS runner

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