Compare commits

...
20 Commits
Author SHA1 Message Date
Hafid Nur 509a7dd36f chore: use npm, Node 22 LTS, revert package.json 2025-11-25 11:29:51 +07:00
Hafid Nur fb98841fb8 Merge remote-tracking branch 'origin/develop' into fix/hackathon 2025-11-25 11:20:29 +07:00
Maulana SodiqinandClaude 734e8540f2 chore: trigger deployment for all apps
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 11:10:07 +07:00
Hafid Nur 029c852290 feat: add Indonesian cities constants 2025-11-25 11:08:04 +07:00
Maulana SodiqinandClaude 1289555749 feat: add Supabase environment variables to deployment workflows
- Updated all deployment workflows to create .env files with Supabase credentials
- Added VITE_SUPABASE_URL and VITE_SUPABASE_ANON_KEY for Vite apps
- Added NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY for Next.js landing app
- Changed from single-line echo to multi-line cat heredoc for better .env management

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 11:06:57 +07:00
Maulana SodiqinandClaude 971fe7160f fix: handle local changes in deployment workflows
Add git reset --hard before pull to discard any local changes on server:
- Fetch latest changes from origin
- Reset to origin/develop to discard local modifications
- Then pull to update

This fixes deployment failures caused by uncommitted local changes on the server.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 11:04:45 +07:00
Maulana Sodiqin 722caeec9d fix: landing hackathon redirect issue 2025-11-25 11:02:14 +07:00
Maulana SodiqinandClaude 7404afe466 fix: allow landing page to be viewed without authentication
- Remove automatic redirect from "/" to login/dashboard
- Allow everyone to view the hackathon landing page
- Users can still click "Daftar Sekarang" to go to login

This fixes the issue where the landing page immediately redirected to login.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 11:01:41 +07:00
Maulana SodiqinandClaude 400daea60f fix: resolve TypeScript build errors in service library
- Fix supabase.ts with proper placeholder types (was error messages from failed CLI command)
- Remove duplicate TPermissionItem export from local-storage.ts
- Import TPermissionItem from types/permissions instead

These changes fix TypeScript compilation errors during Docker builds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 10:41:45 +07:00
Maulana SodiqinandClaude 40cd47c6be chore: update package-lock.json to sync with package.json
Synchronized package-lock.json with package.json to fix Docker build failures with npm ci.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 10:32:09 +07:00
Maulana SodiqinandClaude 529d065a75 perf: optimize Docker builds with npm ci
Replace npm install with npm ci and optimization flags to fix deployment timeouts:
- Use npm ci for faster, more reliable installs
- Add --prefer-offline to use cached packages
- Add --no-audit and --no-fund to skip unnecessary checks
- Optimize landing.Dockerfile to copy package files before installing

This resolves the Docker build EOF errors during deployment.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 10:27:49 +07:00
Maulana SodiqinandClaude 69d6d86342 chore: trigger deployment for all apps
Triggering deployment for all apps to deploy the following changes:
- Fixed circular dependency between utils and service libraries
- Added missing auth hooks (usePostVerifyEmail, useGoogleCallback, etc.)
- Moved useAuthStore from utils to service
- All apps now build successfully

Apps to deploy:
- hackathon
- gacha
- dimentorin
- backoffice
- landing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 10:00:10 +07:00
Maulana SodiqinandClaude 31736cc403 fix: add missing auth hooks for gacha and dimentorin builds
Added missing React Query hooks that were being imported but not exported:
- usePostLogin
- usePostRegister
- usePostVerifyEmail
- usePostSendOtp
- useGoogleCallback

These hooks wrap the existing auth API functions with React Query's useMutation.

Fixes build errors:
- gacha:build - "usePostVerifyEmail" is not exported
- dimentorin:build - "useGoogleCallback" is not exported

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 09:49:51 +07:00
Maulana SodiqinandClaude 1cb2443b58 fix: resolve circular dependency between utils and service libraries
Moved useAuthStore from utils to service to break circular dependency:
- utils was importing from service (supabase client, types)
- service was importing from utils (useAuthStore)
- Solution: moved useAuthStore and related storage utilities to service

Changes:
- Created libs/service/src/storage/ with cookies.ts and local-storage.ts
- Moved use-auth-store.ts from utils/hooks to service/hooks/auth
- Updated all 20+ files to import useAuthStore from service instead of utils
- Removed useAuthStore export from utils
- Added storage exports to service index

This fixes the build error: "Could not execute command because the task graph has a circular dependency"

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 02:34:52 +07:00
Maulana Sodiqin 9e9bfc2793 feat: hackathon 2025-11-25 01:47:56 +07:00
Maulana Sodiqin 5a99f216cb chore: deploy hackathon 2025-11-24 13:09:12 +07:00
Maulana Sodiqin efdbc65be6 chore: remove vetrsion 2025-11-24 12:51:41 +07:00
Maulana Sodiqin f5905c0acc chore: remove storybook 2025-11-24 11:21:29 +07:00
Maulana Sodiqin 6348a4998b feat: upgrade nx 2025-11-24 10:48:09 +07:00
Hafid Nur e067450fcb fix(landing): Fix community links & meta tag for SEO (#57) 2025-10-06 08:57:13 +07:00
114 changed files with 38099 additions and 13077 deletions
+18
View File
@@ -0,0 +1,18 @@
{
"permissions": {
"allow": [
"Bash(npx nx run-many:*)",
"Bash(npm install:*)",
"Bash(npm view:*)",
"Bash(npx nx build landing)",
"Bash(npm uninstall:*)",
"Bash(dir:*)",
"Bash(ren page.tsx page-original.tsx)",
"Bash(ren:*)",
"Bash(npx supabase:*)",
"Bash(libs/service/src/types/supabase.ts)"
],
"deny": [],
"ask": []
}
}
+55
View File
@@ -0,0 +1,55 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Build outputs
dist
.next
out
build
coverage
# IDE
.vscode
.idea
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Git
.git
.gitignore
# Environment
.env
.env.local
.env.*.local
# Testing
coverage
.nyc_output
# Logs
logs
*.log
# Nx
.nx
# Docker
Dockerfile
docker-compose*.yml
.dockerignore
# Misc
*.md
!README.md
.editorconfig
.prettierrc
.eslintrc
+3
View File
@@ -0,0 +1,3 @@
VITE_SUPABASE_URL=https://your-project-id.supabase.co
VITE_SUPABASE_ANON_KEY=your-anon-key-here
+6 -2
View File
@@ -26,9 +26,13 @@ jobs:
if [ ! -d ~/imphnen-frontend-service-backoffice ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-backoffice
else
cd ~/imphnen-frontend-service-backoffice && git pull
cd ~/imphnen-frontend-service-backoffice && git fetch origin && git reset --hard origin/develop && git pull
fi
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-backoffice/apps/backoffice/.env
cat > ~/imphnen-frontend-service-backoffice/apps/backoffice/.env << EOF
VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml down || true
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml build
docker compose -f ~/imphnen-frontend-service-backoffice/docker-compose-backoffice.yml up -d
+6 -2
View File
@@ -26,9 +26,13 @@ jobs:
if [ ! -d ~/imphnen-frontend-service-dimentorin ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-dimentorin
else
cd ~/imphnen-frontend-service-dimentorin && git pull
cd ~/imphnen-frontend-service-dimentorin && git fetch origin && git reset --hard origin/develop && git pull
fi
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-dimentorin/apps/dimentorin/.env
cat > ~/imphnen-frontend-service-dimentorin/apps/dimentorin/.env << EOF
VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml down || true
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml build
docker compose -f ~/imphnen-frontend-service-dimentorin/docker-compose-dimentorin.yml up -d
+6 -2
View File
@@ -26,9 +26,13 @@ jobs:
if [ ! -d ~/imphnen-frontend-service-gacha ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-gacha
else
cd ~/imphnen-frontend-service-gacha && git pull
cd ~/imphnen-frontend-service-gacha && git fetch origin && git reset --hard origin/develop && git pull
fi
echo "VITE_API_URL=${{ secrets.VITE_API_URL }}" > ~/imphnen-frontend-service-gacha/apps/gacha/.env
cat > ~/imphnen-frontend-service-gacha/apps/gacha/.env << EOF
VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml down || true
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml build
docker compose -f ~/imphnen-frontend-service-gacha/docker-compose-gacha.yml up -d
+39
View File
@@ -0,0 +1,39 @@
name: Deploy Hackathon
on:
push:
branches:
- develop
paths:
- 'apps/hackathon/**' # Auto deploy when changes pushed to apps/hackathon
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Deploy via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_KEY }}
port: ${{ secrets.SSH_PORT }}
script: |
set -e
if [ ! -d ~/imphnen-frontend-service-hackathon ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service-hackathon
else
cd ~/imphnen-frontend-service-hackathon && git fetch origin && git reset --hard origin/develop && git pull
fi
cat > ~/imphnen-frontend-service-hackathon/apps/hackathon/.env << EOF
VITE_API_URL=${{ secrets.VITE_API_URL }}
VITE_SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}
VITE_SUPABASE_ANON_KEY=${{ secrets.VITE_SUPABASE_ANON_KEY }}
EOF
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml down || true
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml build
docker compose -f ~/imphnen-frontend-service-hackathon/docker-compose-hackathon.yml up -d
docker image prune -af
+6 -2
View File
@@ -26,9 +26,13 @@ jobs:
if [ ! -d ~/imphnen-frontend-service ]; then
git clone https://github.com/IMPHNEN/imphnen-frontend-service.git ~/imphnen-frontend-service
else
cd ~/imphnen-frontend-service && git pull
cd ~/imphnen-frontend-service && git fetch origin && git reset --hard origin/develop && git pull
fi
echo "NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}" > ~/imphnen-frontend-service/apps/landing/.env
cat > ~/imphnen-frontend-service/apps/landing/.env << EOF
NEXT_PUBLIC_API_URL=${{ secrets.NEXT_PUBLIC_API_URL }}
NEXT_PUBLIC_SUPABASE_URL=${{ secrets.NEXT_PUBLIC_SUPABASE_URL }}
NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ secrets.NEXT_PUBLIC_SUPABASE_ANON_KEY }}
EOF
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml down || true
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml build
docker compose -f ~/imphnen-frontend-service/docker-compose-landing.yml up -d
+4
View File
@@ -62,3 +62,7 @@ storybook-static
# Next.js
.next
out
.cursor/rules/nx-rules.mdc
.github/instructions/nx.instructions.md
.env.local
+1
View File
@@ -0,0 +1 @@
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
+1
View File
@@ -0,0 +1 @@
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,9 +3,8 @@
import React, { createContext, useContext, useMemo, useCallback } from 'react';
import { useParams } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { useAuthStore } from '@imphnen-frontend-service/utils';
import {
useAuthStore,
useUserMe,
useUserById,
useUpdateUserMe,
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { toast } from 'sonner';
import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '@imphnen-frontend-service/utils';
import { useAuthStore } from '@imphnen-frontend-service/service';
export interface GoogleLoginResponse {
access_token?: string;
@@ -1,7 +1,6 @@
import { FC, ReactElement, useEffect } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { useAuthStore } from '@imphnen-frontend-service/utils';
import { useGoogleCallback } from '@imphnen-frontend-service/service';
import { useGoogleCallback, useAuthStore } from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
export const GoogleCallbackPage: FC = (): ReactElement => {
+1
View File
@@ -0,0 +1 @@
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
+1 -1
View File
@@ -3,9 +3,9 @@ import {
authLoginSchema,
TLoginRequest,
usePostLogin,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { useAuthStore } from '@imphnen-frontend-service/utils';
import { toast } from 'sonner';
import { useNavigate } from 'react-router';
import { useVerifyEmail } from './use-verify-email';
+1
View File
@@ -0,0 +1 @@
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
+14
View File
@@ -0,0 +1,14 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...baseConfig,
...nx.configs['flat/react'],
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
// Override or add rules here
rules: {
'jsx-a11y/accessible-emoji': 'off',
},
},
];
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Hackathon</title>
<base href="/" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" type="image/x-icon" href="/logos/simple.svg" />
<link rel="stylesheet" href="/src/index.css" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+9
View File
@@ -0,0 +1,9 @@
import { join } from 'path';
export default {
plugins: {
'@tailwindcss/postcss': {
base: join(import.meta.dirname, '../../'),
},
},
};
+1
View File
@@ -0,0 +1 @@
{}
+23
View File
@@ -0,0 +1,23 @@
import { Link } from 'react-router-dom';
export default function NotFoundPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-9xl font-bold text-gray-200 mb-4">404</h1>
<h2 className="text-3xl font-semibold text-gray-900 mb-4">
Page Not Found
</h2>
<p className="text-gray-600 mb-8">
The page you are looking for doesn't exist or has been moved.
</p>
<Link
to="/"
className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
Go Back Home
</Link>
</div>
</div>
);
}
@@ -0,0 +1,179 @@
import { FC, ReactElement, useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router';
import { useAuthStore, supabase } from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
const CallbackPage: FC = (): ReactElement => {
const navigate = useNavigate();
const { setSession } = useAuthStore();
const [isProcessing, setIsProcessing] = useState(true);
const [error, setError] = useState<string | null>(null);
const hasRunRef = useRef(false);
useEffect(() => {
const handleCallback = async () => {
if (hasRunRef.current) {
console.log('[Callback] Already processed, skipping...');
return;
}
hasRunRef.current = true;
try {
console.log('[Callback] Processing OAuth callback...');
console.log('[Callback] Current URL:', globalThis.location.href);
// Supabase client is configured with detectSessionInUrl: true
// This means Supabase automatically detects and processes OAuth tokens from the URL hash
// We just need to wait a moment for it to complete, then check for the session
console.log('[Callback] Waiting for Supabase to process OAuth callback...');
await new Promise(resolve => setTimeout(resolve, 1000));
// Get the session that Supabase automatically created from the URL hash
const { data: { session: sessionData }, error: sessionError } = await supabase.auth.getSession();
if (sessionError) {
console.error('[Callback] Session error:', sessionError);
throw new Error(sessionError.message || 'Failed to get session');
}
if (!sessionData || !sessionData.user) {
throw new Error('No session found after OAuth callback. Please try logging in again.');
}
console.log('[Callback] Supabase session established:', {
userId: sessionData.user.id,
email: sessionData.user.email,
});
// Create/update user in the users table (for foreign key constraints)
console.log('[Callback] Creating/updating user record...');
const { data: userData, error: upsertError } = await supabase
.from('users')
.upsert({
id: sessionData.user.id,
email: sessionData.user.email || '',
fullname: sessionData.user.user_metadata?.full_name ||
sessionData.user.user_metadata?.name ||
sessionData.user.email?.split('@')[0] || '',
avatar: sessionData.user.user_metadata?.avatar_url || '',
is_active: true,
updated_at: new Date().toISOString(),
}, {
onConflict: 'id',
})
.select()
.single();
if (upsertError) {
console.warn('[Callback] Failed to create user record:', upsertError);
// Don't throw - continue with login even if user record creation fails
} else {
console.log('[Callback] User record created/updated successfully');
}
// Store user-friendly data in Zustand for UI purposes
// Supabase now manages the actual auth session
// Use data from database if available, otherwise use OAuth metadata
const userRecord = userData || {
id: sessionData.user.id,
email: sessionData.user.email || '',
fullname: sessionData.user.user_metadata?.full_name ||
sessionData.user.user_metadata?.name ||
sessionData.user.email?.split('@')[0] || '',
avatar: sessionData.user.user_metadata?.avatar_url || '',
phone_number: '',
birthdate: '',
gender: '',
is_active: true,
};
setSession({
token: {
access_token: sessionData.access_token,
refresh_token: sessionData.refresh_token || '',
},
user: {
id: userRecord.id,
email: userRecord.email,
fullname: userRecord.fullname,
phone_number: userRecord.phone_number || '',
avatar: userRecord.avatar || '',
birthdate: userRecord.birthdate || '',
gender: userRecord.gender || '',
is_active: userRecord.is_active,
location: userRecord.location,
bio: userRecord.bio,
skills: userRecord.skills,
role: {
id: '',
name: 'user',
permissions: [],
created_at: '',
updated_at: '',
},
},
});
console.log('[Callback] Session stored successfully');
toast.success('Login successful!');
setIsProcessing(false);
// Check if user has completed onboarding (has location)
// Use globalThis.location.replace for hard redirect to prevent history issues
if (userRecord.location) {
console.log('[Callback] User has completed onboarding, redirecting to dashboard...');
globalThis.location.replace('/dashboard');
} else {
console.log('[Callback] User needs onboarding, redirecting...');
globalThis.location.replace('/onboarding/user');
}
} catch (err) {
console.error('[Callback] Error:', err);
setError((err as Error).message);
setIsProcessing(false);
toast.error('An error occurred during login');
setTimeout(() => {
navigate('/auth/login');
}, 3000);
}
};
handleCallback();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Run only once on mount
if (error) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 px-4">
<div className="bg-white w-full max-w-2xl p-8 rounded-2xl shadow-lg border border-red-200">
<div className="text-center mb-6">
<div className="text-red-500 text-5xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
GitHub Login Failed
</h2>
<p className="text-red-600 mb-4 whitespace-pre-line">{error}</p>
</div>
<p className="text-gray-600 text-sm mt-6 text-center">
Redirecting to login page in 3 seconds...
</p>
</div>
</div>
);
}
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">
Completing login...
</h2>
<p className="text-gray-600">Please wait</p>
</div>
</div>
);
};
export default CallbackPage;
@@ -0,0 +1,125 @@
import { useState } from 'react';
import { supabase } from '@imphnen-frontend-service/service';
import { Link } from 'react-router';
import { toast } from 'sonner';
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [emailSent, setEmailSent] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) {
toast.error('Please enter your email');
return;
}
try {
setIsLoading(true);
const { error } = await supabase.auth.resetPasswordForEmail(email, {
redirectTo: `${window.location.origin}/auth/reset-password`,
});
if (error) {
throw error;
}
setEmailSent(true);
toast.success('Password reset email sent! Check your inbox.');
} catch (err) {
console.error('Failed to send reset email:', err);
toast.error((err as Error).message || 'Failed to send reset email');
} finally {
setIsLoading(false);
}
};
if (emailSent) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 text-center">
<div className="mb-6">
<div className="mx-auto w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mb-4">
<span className="text-3xl"></span>
</div>
<h2 className="text-3xl font-bold text-gray-900 mb-2">
Check Your Email
</h2>
<p className="text-gray-600">
We've sent a password reset link to <strong>{email}</strong>
</p>
</div>
<div className="space-y-4">
<p className="text-sm text-gray-600">
Click the link in the email to reset your password. The link will expire in 1 hour.
</p>
<Link to="/auth/login">
<button className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors">
Back to Login
</button>
</Link>
<button
onClick={() => setEmailSent(false)}
className="w-full py-3 text-gray-600 hover:text-gray-900 transition-colors"
>
Send another email
</button>
</div>
</div>
</div>
);
}
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-2">
Forgot Password?
</h2>
<p className="text-gray-600">
No worries, we'll send you reset instructions
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email Address
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
disabled={isLoading}
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
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? 'Sending...' : 'Send Reset Link'}
</button>
</form>
<div 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>
);
}
@@ -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;
+209
View File
@@ -0,0 +1,209 @@
import { useState } from 'react';
import { useGitHubAuth, useEmailAuth, supabase, useAuthStore } from '@imphnen-frontend-service/service';
import { GithubOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router';
import { toast } from 'sonner';
export default function LoginPage() {
console.log('[LoginPage] Rendering...');
const navigate = useNavigate();
const { setSession } = useAuthStore();
const { signInWithGitHub } = useGitHubAuth();
const { signInWithEmail } = useEmailAuth();
const [isGithubLoading, setIsGithubLoading] = useState(false);
const [isEmailLoading, setIsEmailLoading] = useState(false);
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const handleEmailLogin = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!email || !password) {
setError('Please enter both email and password');
return;
}
try {
setIsEmailLoading(true);
console.log('[Login] Attempting email login...');
const result = await signInWithEmail(email, password);
console.log('[Login] Email login successful:', result);
// Get user data from database
const { data: userData } = await supabase
.from('users')
.select('*')
.eq('id', result.user.id)
.single();
// Store session in Zustand
setSession({
token: {
access_token: result.session.access_token,
refresh_token: result.session.refresh_token || '',
},
user: {
id: result.user.id,
email: result.user.email || '',
fullname: userData?.fullname || result.user.user_metadata?.full_name || '',
phone_number: userData?.phone_number || '',
avatar: userData?.avatar || '',
birthdate: userData?.birthdate || '',
gender: userData?.gender || '',
is_active: userData?.is_active || true,
location: userData?.location,
bio: userData?.bio,
skills: userData?.skills,
role: {
id: '',
name: 'user',
permissions: [],
created_at: '',
updated_at: '',
},
},
});
toast.success('Login successful!');
// Redirect based on onboarding status
if (userData?.location) {
navigate('/dashboard');
} else {
navigate('/onboarding/user');
}
} catch (err) {
console.error('[Login] Email login failed:', err);
setError((err as Error).message || 'Login failed');
setIsEmailLoading(false);
}
};
const handleGithubLogin = async () => {
try {
setIsGithubLoading(true);
console.log('[Login] Initiating GitHub OAuth...');
const result = await signInWithGitHub();
console.log('[Login] OAuth result:', result);
// Check if we got a redirect URL
if (result?.url) {
console.log('[Login] Redirecting to GitHub OAuth:', result.url);
// Manually redirect immediately
globalThis.location.href = result.url;
} else {
console.error('[Login] No OAuth URL returned');
setIsGithubLoading(false);
}
} catch (error) {
console.error('[Login] GitHub login failed:', error);
setIsGithubLoading(false);
}
};
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-2">
Welcome Back
</h2>
<p className="text-gray-600">
Sign in to join or create your hackathon team
</p>
</div>
{error && (
<div className="mb-6 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-600 text-sm">{error}</p>
</div>
)}
<form onSubmit={handleEmailLogin} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
disabled={isEmailLoading}
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
/>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
Password
</label>
<Link to="/auth/forgot-password" className="text-sm text-primary-600 hover:text-primary-700">
Forgot password?
</Link>
</div>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
disabled={isEmailLoading}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required
/>
</div>
<button
type="submit"
disabled={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 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{isEmailLoading ? 'Signing in...' : 'Sign in with Email'}
</button>
</form>
<div className="my-6 flex items-center">
<div className="flex-1 border-t border-gray-300"></div>
<span className="px-4 text-sm text-gray-500">OR</span>
<div className="flex-1 border-t border-gray-300"></div>
</div>
<button
onClick={handleGithubLogin}
disabled={isGithubLoading}
type="button"
className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 border border-gray-300 rounded-lg font-semibold 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" />
<span>
{isGithubLoading ? 'Connecting...' : 'Sign in with GitHub'}
</span>
</button>
<div className="mt-6 text-center">
<p className="text-gray-600 text-sm">
Don't have an account?{' '}
<a href="/auth/signup" className="text-primary-600 hover:text-primary-700 font-semibold">
Sign up
</a>
</p>
</div>
<div className="mt-6 text-center">
<p className="text-gray-500 text-xs">
By signing in, you agree to our Terms of Service and Privacy Policy
</p>
</div>
</div>
</div>
);
}
@@ -0,0 +1,131 @@
import { useState, useEffect } from 'react';
import { supabase } from '@imphnen-frontend-service/service';
import { useNavigate } from 'react-router';
import { toast } from 'sonner';
export default function ResetPasswordPage() {
const navigate = useNavigate();
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isValidToken, setIsValidToken] = useState(false);
useEffect(() => {
// Check if we have a valid session (from the reset link)
supabase.auth.getSession().then(({ data: { session } }) => {
if (session) {
setIsValidToken(true);
} else {
toast.error('Invalid or expired reset link');
setTimeout(() => navigate('/auth/forgot-password'), 2000);
}
});
}, [navigate]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (password !== confirmPassword) {
toast.error('Passwords do not match');
return;
}
if (password.length < 6) {
toast.error('Password must be at least 6 characters');
return;
}
try {
setIsLoading(true);
const { error } = await supabase.auth.updateUser({
password: password
});
if (error) {
throw error;
}
toast.success('Password updated successfully!');
// Sign out and redirect to login
await supabase.auth.signOut();
navigate('/auth/login');
} catch (err) {
console.error('Failed to reset password:', err);
toast.error((err as Error).message || 'Failed to reset password');
} finally {
setIsLoading(false);
}
};
if (!isValidToken) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
<p className="text-gray-600">Verifying reset link...</p>
</div>
</div>
);
}
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-2">
Set New Password
</h2>
<p className="text-gray-600">
Enter your new password below
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
New Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
disabled={isLoading}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required
minLength={6}
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
Confirm New Password
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
disabled={isLoading}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required
minLength={6}
/>
</div>
<button
type="submit"
disabled={isLoading}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{isLoading ? 'Updating...' : 'Update Password'}
</button>
</form>
</div>
</div>
);
}
+257
View File
@@ -0,0 +1,257 @@
import { useState } from 'react';
import { useGitHubAuth, useEmailAuth, supabase, useAuthStore } from '@imphnen-frontend-service/service';
import { GithubOutlined } from '@ant-design/icons';
import { useNavigate } from 'react-router';
import { toast } from 'sonner';
export default function SignupPage() {
const navigate = useNavigate();
const { setSession } = useAuthStore();
const { signInWithGitHub } = useGitHubAuth();
const { signUpWithEmail } = useEmailAuth();
const [isGithubLoading, setIsGithubLoading] = useState(false);
const [isEmailLoading, setIsEmailLoading] = useState(false);
const [fullname, setFullname] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState<string | null>(null);
const handleEmailSignup = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
if (!fullname || !email || !password || !confirmPassword) {
setError('Please fill in all fields');
return;
}
if (password !== confirmPassword) {
setError('Passwords do not match');
return;
}
if (password.length < 6) {
setError('Password must be at least 6 characters long');
return;
}
try {
setIsEmailLoading(true);
console.log('[Signup] Attempting email signup...');
const result = await signUpWithEmail(email, password, fullname);
console.log('[Signup] Email signup successful:', result);
if (!result.user) {
throw new Error('Signup failed - no user returned');
}
// Create user record in database
const { error: upsertError } = await supabase
.from('users')
.upsert({
id: result.user.id,
email: result.user.email || '',
fullname: fullname,
is_active: true,
updated_at: new Date().toISOString(),
}, {
onConflict: 'id',
});
if (upsertError) {
console.warn('[Signup] Failed to create user record:', upsertError);
}
// If session is available (email confirmation disabled), store it
if (result.session) {
setSession({
token: {
access_token: result.session.access_token,
refresh_token: result.session.refresh_token || '',
},
user: {
id: result.user.id,
email: result.user.email || '',
fullname: fullname,
phone_number: '',
avatar: '',
birthdate: '',
gender: '',
is_active: true,
role: {
id: '',
name: 'user',
permissions: [],
created_at: '',
updated_at: '',
},
},
});
toast.success('Account created successfully!');
navigate('/onboarding/user');
} else {
// Email confirmation is enabled
toast.success('Account created! Please check your email to verify your account.');
setTimeout(() => {
navigate('/auth/login');
}, 2000);
}
} catch (err) {
console.error('[Signup] Email signup failed:', err);
setError((err as Error).message || 'Signup failed');
setIsEmailLoading(false);
}
};
const handleGithubLogin = async () => {
try {
setIsGithubLoading(true);
console.log('[Signup] Initiating GitHub OAuth...');
const result = await signInWithGitHub();
console.log('[Signup] OAuth result:', result);
if (result?.url) {
console.log('[Signup] Redirecting to GitHub OAuth:', result.url);
globalThis.location.href = result.url;
} else {
console.error('[Signup] No OAuth URL returned');
setIsGithubLoading(false);
}
} catch (error) {
console.error('[Signup] GitHub login failed:', error);
setIsGithubLoading(false);
}
};
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 p-4">
<div className="bg-white w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 mb-2">
Create Account
</h2>
<p className="text-gray-600">
Join the hackathon community
</p>
</div>
{error && (
<div className="mb-6 p-3 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-600 text-sm">{error}</p>
</div>
)}
<form onSubmit={handleEmailSignup} className="space-y-4">
<div>
<label htmlFor="fullname" className="block text-sm font-medium text-gray-700 mb-1">
Full Name
</label>
<input
id="fullname"
type="text"
value={fullname}
onChange={(e) => setFullname(e.target.value)}
placeholder="John Doe"
disabled={isEmailLoading}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
disabled={isEmailLoading}
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
/>
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
Password
</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
disabled={isEmailLoading}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required
/>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 mb-1">
Confirm Password
</label>
<input
id="confirmPassword"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
disabled={isEmailLoading}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed"
required
/>
</div>
<button
type="submit"
disabled={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 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{isEmailLoading ? 'Creating account...' : 'Create Account'}
</button>
</form>
<div className="my-6 flex items-center">
<div className="flex-1 border-t border-gray-300"></div>
<span className="px-4 text-sm text-gray-500">OR</span>
<div className="flex-1 border-t border-gray-300"></div>
</div>
<button
onClick={handleGithubLogin}
disabled={isGithubLoading}
type="button"
className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 border border-gray-300 rounded-lg font-semibold 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" />
<span>
{isGithubLoading ? 'Connecting...' : 'Sign up with GitHub'}
</span>
</button>
<div className="mt-6 text-center">
<p className="text-gray-600 text-sm">
Already have an account?{' '}
<a href="/auth/login" className="text-primary-600 hover:text-primary-700 font-semibold">
Sign in
</a>
</p>
</div>
<div className="mt-6 text-center">
<p className="text-gray-500 text-xs">
By signing up, you agree to our Terms of Service and Privacy Policy
</p>
</div>
</div>
</div>
);
}
+282
View File
@@ -0,0 +1,282 @@
import { FC, ReactElement } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Link, useNavigate } from 'react-router';
import { useMyTeams, useMyInvitations, useRespondToInvitation, supabase, useAuthStore } from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
const DashboardPage: FC = (): ReactElement => {
const navigate = useNavigate();
const { session, clearSession } = useAuthStore();
const { data: teamsData } = useMyTeams();
const { data: invitationsData } = useMyInvitations();
const { mutateAsync: respondToInvitation } = useRespondToInvitation();
const user = session?.user;
const myTeams = teamsData?.data || [];
const invitations = invitationsData?.data || [];
const handleAcceptInvitation = async (invitationId: string) => {
try {
await respondToInvitation({ invitationId, action: 'accept' });
toast.success('Invitation accepted! You are now a team member.');
} catch (error) {
console.error('Failed to accept invitation:', error);
toast.error('Failed to accept invitation');
}
};
const handleRejectInvitation = async (invitationId: string) => {
try {
await respondToInvitation({ invitationId, action: 'reject' });
toast.success('Invitation declined');
} catch (error) {
console.error('Failed to reject invitation:', error);
toast.error('Failed to decline invitation');
}
};
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 (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<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="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">
Welcome, {user?.fullname || 'User'}!
</h1>
<p className="text-gray-600 mt-1">
{user?.location && `📍 ${user.location}`}
</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 className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Team Invitations */}
{invitations.length > 0 && (
<div className="mb-8 bg-blue-50 border border-blue-200 rounded-lg p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">
Team Invitations ({invitations.length})
</h2>
<div className="space-y-3">
{invitations.map((invitation) => (
<div key={invitation.id} className="bg-white p-4 rounded-lg shadow-sm flex items-center justify-between">
<div>
<p className="font-medium text-gray-900">{invitation.team.name}</p>
<p className="text-sm text-gray-600">
Invited by {invitation.inviter.fullname}
</p>
</div>
<div className="flex space-x-2">
<Button
size="sm"
onClick={() => handleAcceptInvitation(invitation.id)}
>
Accept
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => handleRejectInvitation(invitation.id)}
>
Decline
</Button>
</div>
</div>
))}
</div>
</div>
)}
{/* My Teams */}
{myTeams.length > 0 ? (
<div className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">My Team</h2>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{myTeams.map((team) => (
<Link
key={team.id}
to={`/teams/${team.id}`}
className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow"
>
{team.banner && (
<img
src={team.banner}
alt={team.name}
className="w-full h-32 object-cover"
/>
)}
<div className="p-6">
<div className="flex items-center space-x-3 mb-3">
{team.logo && (
<img
src={team.logo}
alt={team.name}
className="w-12 h-12 rounded-full object-cover"
/>
)}
<div>
<h3 className="text-lg font-bold text-gray-900">{team.name}</h3>
<p className="text-sm text-gray-600">📍 {team.city}</p>
</div>
</div>
<p className="text-gray-600 text-sm line-clamp-2">
{team.description}
</p>
</div>
</Link>
))}
</div>
</div>
) : (
/* No Team - Show CTAs */
<div className="bg-white rounded-lg shadow-md p-8">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-2">
You're not in a team yet
</h2>
<p className="text-gray-600">
Join an existing team or create your own to get started
</p>
</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>
)}
{/* User Profile Card */}
<div className="mt-8 bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl shadow-lg overflow-hidden">
<div className="bg-gradient-to-r from-blue-600 to-indigo-600 h-24"></div>
<div className="px-8 pb-8">
<div className="flex items-start -mt-12 mb-6">
{user?.avatar && (
<img
src={user.avatar}
alt={user.fullname}
className="w-24 h-24 rounded-full border-4 border-white shadow-lg object-cover"
/>
)}
<div className="ml-6 mt-14">
<h2 className="text-2xl font-bold text-gray-900">{user?.fullname}</h2>
{user?.location ? (
<p className="text-gray-600 flex items-center mt-1">
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
<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" />
</svg>
{user.location}
</p>
) : (
<Link to="/onboarding/user" className="text-blue-600 hover:text-blue-700 text-sm mt-1 inline-block">
Complete your profile
</Link>
)}
</div>
</div>
<div className="space-y-6">
{user?.bio && (
<div className="bg-white rounded-lg p-4 shadow-sm">
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-2">About</h3>
<p className="text-gray-800 leading-relaxed">{user.bio}</p>
</div>
)}
<div className="bg-white rounded-lg p-4 shadow-sm">
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Contact</h3>
<div className="flex items-center text-gray-700">
<svg className="w-5 h-5 mr-3 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path d="M2.003 5.884L10 9.882l7.997-3.998A2 2 0 0016 4H4a2 2 0 00-1.997 1.884z" />
<path d="M18 8.118l-8 4-8-4V14a2 2 0 002 2h12a2 2 0 002-2V8.118z" />
</svg>
<span className="text-gray-900">{user?.email}</span>
</div>
</div>
{user?.skills && user.skills.length > 0 && (
<div className="bg-white rounded-lg p-4 shadow-sm">
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-3">Skills</h3>
<div className="flex flex-wrap gap-3">
{user.skills.map((skill: string) => (
<span
key={skill}
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}
</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
</div>
);
};
export default DashboardPage;
+29
View File
@@ -0,0 +1,29 @@
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
export default function ErrorPage() {
const error = useRouteError();
let errorMessage: string;
if (isRouteErrorResponse(error)) {
errorMessage = error.statusText;
} else if (error instanceof Error) {
errorMessage = error.message;
} else if (typeof error === 'string') {
errorMessage = error;
} else {
console.error(error);
errorMessage = 'Unknown error';
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<h1 className="text-6xl font-bold text-red-600 mb-4">Oops!</h1>
<p className="text-xl text-gray-700 mb-2">
Sorry, an unexpected error has occurred.
</p>
<p className="text-gray-500 italic">{errorMessage}</p>
</div>
</div>
);
}
+125
View File
@@ -0,0 +1,125 @@
import { Outlet, ScrollRestoration, useLocation, useNavigate } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { supabase } from '@imphnen-frontend-service/service';
// Define onboarding routes
const ONBOARDING_ROUTES = new Set(['/onboarding/user']);
export default function RootLayout() {
const location = useLocation();
const navigate = useNavigate();
const [isChecking, setIsChecking] = useState(true);
useEffect(() => {
const checkAuth = async () => {
const pathname = location.pathname;
console.log('[Layout] Checking auth for route:', pathname);
// Allow hackathon pages without checks
if (pathname.startsWith('/hackathons')) {
console.log('[Layout] Public route, allowing access');
setIsChecking(false);
return;
}
// Allow auth callback without checks
if (pathname === '/auth/callback') {
console.log('[Layout] Auth callback, allowing access');
setIsChecking(false);
return;
}
// Check Supabase session
const { data: { session }, error } = await supabase.auth.getSession();
if (error) {
console.error('[Layout] Session error:', error);
}
// Public auth pages (login, signup, forgot-password, reset-password) - allow unauthenticated access
const isPublicAuthPage = pathname === '/auth/login' || pathname === '/auth/signup' || pathname === '/auth/forgot-password' || pathname === '/auth/reset-password';
if (isPublicAuthPage) {
// If already authenticated and not on password reset pages, redirect to dashboard
if (session && pathname !== '/auth/reset-password') {
console.log('[Layout] Already authenticated, redirecting to dashboard');
navigate('/dashboard', { replace: true });
setIsChecking(false);
return;
}
// Allow unauthenticated access
console.log('[Layout] Public auth page, allowing access');
setIsChecking(false);
return;
}
// Home page - allow everyone to view the landing page
if (pathname === '/') {
console.log('[Layout] Landing page, allowing access');
setIsChecking(false);
return;
}
// Require authentication for all other routes
if (!session) {
console.log('[Layout] No session, redirecting to login');
navigate('/auth/login', { replace: true });
setIsChecking(false);
return;
}
// Check if user has completed onboarding (skip for onboarding routes)
if (!ONBOARDING_ROUTES.has(pathname)) {
try {
const { data: userData, error: userError } = await supabase
.from('users')
.select('location')
.eq('id', session.user.id)
.single();
if (userError) {
console.error('[Layout] Failed to fetch user data:', userError);
setIsChecking(false);
return;
}
const hasLocation = !!userData?.location;
if (!hasLocation) {
console.log('[Layout] User needs onboarding, redirecting');
navigate('/onboarding/user', { replace: true });
setIsChecking(false);
return;
}
} catch (error) {
console.error('[Layout] Unexpected error checking onboarding:', error);
}
}
console.log('[Layout] Auth check passed');
setIsChecking(false);
};
checkAuth();
}, [location.pathname, navigate]);
// Show loading state while checking auth
if (isChecking) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
<p className="text-gray-600">Loading...</p>
</div>
</div>
);
}
return (
<>
<Outlet />
<ScrollRestoration />
</>
);
}
@@ -0,0 +1,272 @@
import { FC, ReactElement, useState, useEffect } from 'react';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate } from 'react-router';
import { useForm, Controller } from 'react-hook-form';
import {
userOnboardingSchema,
TUserOnboardingForm,
useUpdateUserMe,
useUploadAvatar,
useUserMe,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import INDONESIAN_CITIES from '../../../constants/cities';
const ROLE_OPTIONS = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
];
const UserOnboardingPage: FC = (): ReactElement => {
const navigate = useNavigate();
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string>('');
const { data: userData } = useUserMe();
const { mutateAsync: updateUser, isPending: isUpdating } = useUpdateUserMe();
const { mutateAsync: uploadAvatar, isPending: isUploading } =
useUploadAvatar();
const { session } = useAuthStore();
const form = useForm<TUserOnboardingForm>({
resolver: zodResolver(userOnboardingSchema),
mode: 'all',
defaultValues: {
fullname: session?.user?.fullname || userData?.data?.fullname || '',
location: session?.user?.location || '',
bio: session?.user?.bio || '',
skills: session?.user?.skills || [],
},
});
// Set initial avatar preview from GitHub avatar if available
useEffect(() => {
if (session?.user?.avatar && !avatarPreview) {
setAvatarPreview(session.user.avatar);
}
}, [session?.user?.avatar, avatarPreview]);
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setAvatarFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const onSubmit = form.handleSubmit(async (data) => {
try {
console.log('[Onboarding] Starting submission...', data);
let avatarUrl = session?.user?.avatar || null;
// Upload avatar if a new file was selected
if (avatarFile) {
console.log('[Onboarding] Uploading avatar...');
const uploadResult = await uploadAvatar(avatarFile);
avatarUrl = uploadResult.data.url;
console.log('[Onboarding] Avatar uploaded:', avatarUrl);
}
// Update user in Supabase
console.log('[Onboarding] Updating user in Supabase...');
const result = await updateUser({
fullname: data.fullname,
avatar: avatarUrl,
location: data.location,
bio: data.bio,
skills: data.skills,
});
console.log('[Onboarding] User updated successfully:', result);
// Wait a bit for the onSuccess handler to update localStorage
// The updateUser mutation's onSuccess handler updates the Zustand store and localStorage
await new Promise((resolve) => setTimeout(resolve, 100));
console.log('[Onboarding] Navigating to dashboard...');
// Use window.location for a full page reload to ensure middleware sees updated localStorage
globalThis.location.href = '/dashboard';
} catch (error) {
console.error('[Onboarding] Onboarding failed:', error);
alert(
`Onboarding failed: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
});
return (
<div className="flex flex-col justify-center items-center min-h-screen bg-gray-50 px-4 py-8">
<div className="bg-white w-full max-w-2xl p-8 rounded-xl shadow-lg">
<div className="mb-6">
<h1 className="text-3xl font-bold text-gray-900 mb-2">
Complete Your Profile
</h1>
<p className="text-gray-600">
Tell us more about yourself to get started
</p>
</div>
<form onSubmit={onSubmit} className="space-y-6">
{/* Avatar Upload */}
<div className="flex flex-col items-center space-y-4">
<div className="relative">
{avatarPreview ? (
<img
src={avatarPreview}
alt="Avatar preview"
className="w-32 h-32 rounded-full object-cover border-4 border-gray-200"
/>
) : (
<div className="w-32 h-32 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-400 text-4xl">👤</span>
</div>
)}
</div>
<div>
<label htmlFor="avatar" className="cursor-pointer">
<span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">
{avatarPreview ? 'Change Photo' : 'Upload Photo'}
</span>
<input
id="avatar"
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarChange}
/>
</label>
<p className="text-xs text-gray-500 mt-2 text-center">
Optional, but highly recommended
</p>
</div>
</div>
{/* Full Name */}
<ControlledInputField
control={form.control}
label="Full Name"
placeholder="Enter your full name"
name="fullname"
/>
{/* City */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
City <span className="text-red-500">*</span>
</label>
<Controller
control={form.control}
name="location"
render={({ field, fieldState }) => (
<div>
<select
{...field}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<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>
{/* Role/Skills */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Role / Skills
</label>
<Controller
control={form.control}
name="skills"
render={({ field }) => (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((role) => (
<label
key={role}
className="flex items-center space-x-2 cursor-pointer"
>
<input
type="checkbox"
checked={field.value?.includes(role)}
onChange={(e) => {
const newValue = e.target.checked
? [...(field.value || []), role]
: (field.value || []).filter((v) => v !== role);
field.onChange(newValue);
}}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm">{role}</span>
</label>
))}
</div>
</div>
)}
/>
</div>
{/* Bio */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Bio <span className="text-gray-400">(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"
/>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">
{fieldState.error.message}
</p>
)}
</div>
)}
/>
</div>
<Button
className="w-full"
type="submit"
disabled={!form.formState.isValid || isUpdating || isUploading}
>
{isUpdating || isUploading ? 'Saving...' : 'Complete Setup'}
</Button>
</form>
</div>
</div>
);
};
export default UserOnboardingPage;
+759
View File
@@ -0,0 +1,759 @@
import { useNavigate } from 'react-router';
import { useState } from 'react';
export default function HomePage() {
const navigate = useNavigate();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [openFaq, setOpenFaq] = useState<number | null>(0);
const faqs = [
{
question: 'Siapa yang bisa mengikuti hackathon ini?',
answer:
'Hackathon ini terbuka untuk mahasiswa, fresh graduate, dan profesional muda yang memiliki passion di bidang teknologi. Peserta dapat mendaftar secara tim.',
},
{
question: 'Apakah ada biaya pendaftaran?',
answer:
'Tidak, hackathon ini gratis dan terbuka untuk semua peserta yang memenuhi kriteria.',
},
{
question: 'Apa tema hackathon kali ini?',
answer:
'Tema hackathon kali ini adalah "Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif".',
},
{
question: 'Bagaimana format pelaksanaannya?',
answer:
'Hackathon dilaksanakan secara online dengan berbagai tahap mulai dari pendaftaran, technical meeting, tahap penyisihan, hingga final.',
},
{
question: 'Apa hadiah lombanya?',
answer:
'Total hadiah senilai Rp14.500.000 dengan juara 1 mendapat Rp6.000.000, juara 2 Rp4.000.000, juara 3 Rp2.500.000, dan juara kategori lainnya Rp2.000.000.',
},
{
question: 'Apakah harus membentuk tim? Boleh solo?',
answer:
'Ya, peserta harus membentuk tim dengan maksimal 3 orang per tim.',
},
{
question: 'Apakah boleh menggunakan AI (vibe coding)?',
answer:
'Ya, peserta diperbolehkan menggunakan AI tools untuk membantu development.',
},
{
question: 'Apa project wajib di-deploy?',
answer:
'Ya, project harus di-deploy dan dapat diakses secara online untuk penilaian.',
},
{
question: 'Apakah peserta mendapatkan sertifikat?',
answer:
'Ya, semua peserta yang menyelesaikan hackathon akan mendapatkan sertifikat.',
},
{
question: 'Website error dan terjadi masalah?',
answer:
'Silakan hubungi kami melalui grup WA Hackathon atau email imphnen@gmail.com.',
},
];
return (
<div className="min-h-screen bg-white">
{/* Navigation */}
<nav className="flex items-center justify-between px-4 md:px-8 py-4 border-b border-gray-200 bg-white sticky top-0 z-50">
<div className="flex items-center gap-2">
<span className="text-lg md:text-xl font-bold">IMPHNEN</span>
<span className="text-lg md:text-xl font-bold text-blue-600">
Hackathon
</span>
</div>
{/* Desktop Menu */}
<div className="hidden md:flex items-center gap-6 lg:gap-8">
<a
href="#timeline"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
Timeline
</a>
<a
href="#hadiah"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
Hadiah
</a>
<a
href="#faq"
className="text-gray-600 hover:text-gray-900 transition-colors"
>
FAQ
</a>
<button
onClick={() => navigate('/auth/login')}
className="px-4 lg:px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm lg:text-base"
>
Daftar Sekarang
</button>
</div>
{/* Mobile Menu Button */}
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="md:hidden p-2"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
</nav>
{/* Mobile Menu */}
{mobileMenuOpen && (
<div className="md:hidden border-b border-gray-200 bg-white">
<div className="flex flex-col gap-4 px-4 py-4">
<a href="#timeline" className="text-gray-600 hover:text-gray-900">
Timeline
</a>
<a href="#hadiah" className="text-gray-600 hover:text-gray-900">
Hadiah
</a>
<a href="#faq" className="text-gray-600 hover:text-gray-900">
FAQ
</a>
<a href="#masuk" className="text-gray-600 hover:text-gray-900">
Masuk
</a>
<button
onClick={() => navigate('/auth/login')}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-center"
>
Daftar Sekarang
</button>
</div>
</div>
)}
{/* Hero Section */}
<section className="flex flex-col items-center justify-center px-4 md:px-8 py-10 md:py-20 bg-gradient-to-b from-white to-gray-50">
{/* Logos */}
<div className="flex items-center gap-4 md:gap-8 lg:gap-12 mb-8 md:mb-12 lg:mb-16 flex-wrap justify-center">
<div className="flex items-center">
<span className="text-3xl md:text-5xl lg:text-6xl font-bold text-blue-600">
IMPHNEN
</span>
</div>
<span className="text-3xl md:text-5xl lg:text-6xl font-bold text-gray-400">
×
</span>
<div className="flex items-center">
<span className="text-3xl md:text-5xl lg:text-6xl font-bold">
Kolosal
</span>
</div>
</div>
{/* Title */}
<h1 className="text-5xl md:text-7xl lg:text-9xl font-bold text-gray-900 mb-6 md:mb-10 lg:mb-12 text-center">
Hackathon
</h1>
{/* Subtitle */}
<p className="text-xl md:text-3xl lg:text-4xl text-blue-600 font-semibold mb-6 md:mb-8 text-center px-4">
"Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif"
</p>
{/* Description */}
<p className="text-base md:text-xl lg:text-2xl text-gray-600 max-w-xs md:max-w-2xl lg:max-w-3xl text-center mb-8 md:mb-12 px-4">
Kompetisi pengembangan teknologi untuk menciptakan
<br className="hidden md:block" /> solusi inovatif yang menghadirkan
dampak nyata
</p>
{/* Status */}
<div className="flex flex-col md:flex-row items-center gap-4 md:gap-8 mb-10 md:mb-16 text-sm md:text-lg lg:text-xl text-gray-600">
<div className="flex items-center gap-2 md:gap-3">
<svg
className="w-5 h-5 md:w-6 md:h-6"
fill="currentColor"
viewBox="0 0 20 20"
>
<circle cx="10" cy="10" r="8" fill="#10b981" />
</svg>
<span>Online</span>
</div>
<div className="flex items-center gap-2 md:gap-3 text-center">
<svg
className="w-5 h-5 md:w-6 md:h-6"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1 1 0 00-1-1zm0 5a1 1 0 000 2h8a1 1 0 100-2H6z" />
</svg>
<span>Pendaftaran hingga 30 November 2025</span>
</div>
</div>
{/* CTA Buttons */}
<div className="flex flex-col md:flex-row gap-4 w-full md:w-auto px-4 md:px-0">
<button
onClick={() => navigate('/auth/login')}
className="w-full md:w-auto px-6 md:px-8 py-3 bg-blue-600 text-white text-base md:text-lg rounded-lg hover:bg-blue-700 transition-colors text-center font-semibold"
>
Daftar Sekarang
</button>
<button className="w-full md:w-auto px-6 md:px-8 py-3 bg-white text-gray-900 text-base md:text-lg rounded-lg border-2 border-gray-300 hover:border-gray-400 transition-colors text-center font-semibold">
Gabung Grup WA Hackathon
</button>
</div>
{/* Scroll indicator */}
<div className="mt-12 md:mt-20">
<svg
className="w-6 h-6 text-gray-400 animate-bounce"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 14l-7 7m0 0l-7-7m7 7V3"
/>
</svg>
</div>
</section>
{/* About Section */}
<section className="py-16 md:py-24 px-4 md:px-8 bg-white">
<div className="max-w-4xl mx-auto text-center">
<h2 className="text-3xl md:text-5xl font-bold mb-6">
Tentang <span className="text-blue-600">Hackathon</span>
</h2>
<p className="text-lg md:text-xl text-gray-600 mb-6">
<span className="font-semibold text-gray-900">Hackathon</span>{' '}
adalah kompetisi pengembangan teknologi yang mengajak talenta muda
untuk berkolaborasi dalam menciptakan solusi inovatif.
</p>
<p className="text-lg md:text-xl text-gray-600">
IMPHNEN bersama Kolosal.ai mengadakan Hackathon dengan tema lomba{' '}
<span className="font-semibold text-blue-600">
"Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif"
</span>
.
</p>
</div>
</section>
{/* Prizes Section */}
<section id="hadiah" className="py-16 md:py-24 px-4 md:px-8 bg-gray-50">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-5xl font-bold mb-4">
Hadiah <span className="text-blue-600">Menarik</span>
</h2>
<p className="text-xl md:text-2xl text-blue-600 font-semibold">
Total Prize Pool Rp14.500.000
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Prize 1 */}
<div className="bg-white rounded-xl p-8 shadow-lg border-2 border-blue-500 transform hover:scale-105 transition-transform">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-blue-100 rounded-full flex items-center justify-center">
<svg
className="w-8 h-8 text-blue-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</div>
</div>
<h3 className="text-2xl font-bold text-center mb-2">Juara 1</h3>
<p className="text-3xl font-bold text-blue-600 text-center">
Rp6.000.000
</p>
</div>
{/* Prize 2 */}
<div className="bg-white rounded-xl p-8 shadow-lg border-2 border-gray-300 transform hover:scale-105 transition-transform">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center">
<svg
className="w-8 h-8 text-gray-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</div>
</div>
<h3 className="text-2xl font-bold text-center mb-2">Juara 2</h3>
<p className="text-3xl font-bold text-blue-600 text-center">
Rp4.000.000
</p>
</div>
{/* Prize 3 */}
<div className="bg-white rounded-xl p-8 shadow-lg border-2 border-orange-300 transform hover:scale-105 transition-transform">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-orange-100 rounded-full flex items-center justify-center">
<svg
className="w-8 h-8 text-orange-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</div>
</div>
<h3 className="text-2xl font-bold text-center mb-2">Juara 3</h3>
<p className="text-3xl font-bold text-orange-600 text-center">
Rp2.500.000
</p>
</div>
{/* Special Prize */}
<div className="bg-white rounded-xl p-8 shadow-lg border-2 border-purple-300 transform hover:scale-105 transition-transform">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-purple-100 rounded-full flex items-center justify-center">
<svg
className="w-8 h-8 text-purple-600"
fill="currentColor"
viewBox="0 0 20 20"
>
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
</svg>
</div>
</div>
<h3 className="text-2xl font-bold text-center mb-2">
Juara Kategori Lainnya
</h3>
<p className="text-3xl font-bold text-purple-600 text-center">
Rp2.000.000
</p>
</div>
</div>
</div>
</section>
{/* Timeline Section */}
<section id="timeline" className="py-16 md:py-24 px-4 md:px-8 bg-white">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-5xl font-bold mb-4">
Timeline <span className="text-blue-600">Acara</span>
</h2>
<p className="text-lg text-gray-600">
Jadwal lengkap pelaksanaan hackathon
</p>
</div>
<div className="space-y-8">
{/* Timeline Item 1 */}
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-blue-600 rounded-full"></div>
<div className="w-0.5 h-full bg-gray-300"></div>
</div>
<div className="flex-1 pb-8">
<p className="text-blue-600 font-semibold mb-2">
30 November 2025
</p>
<h3 className="text-xl font-bold mb-2">Penutupan Registrasi</h3>
<p className="text-gray-600">
Batas akhir pendaftaran peserta hackathon.
</p>
</div>
</div>
{/* Timeline Item 2 */}
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-blue-600 rounded-full"></div>
<div className="w-0.5 h-full bg-gray-300"></div>
</div>
<div className="flex-1 pb-8">
<p className="text-blue-600 font-semibold mb-2">
30 November 2025
</p>
<h3 className="text-xl font-bold mb-2">Technical Meeting</h3>
<p className="text-gray-600">
Akan diadakan technical meeting terkait lomba melalui Google
Meet. Stay tune di grup WA Hackathon.
</p>
</div>
</div>
{/* Timeline Item 3 */}
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-blue-600 rounded-full"></div>
<div className="w-0.5 h-full bg-gray-300"></div>
</div>
<div className="flex-1 pb-8">
<p className="text-blue-600 font-semibold mb-2">
1 - 7 Desember 2025
</p>
<h3 className="text-xl font-bold mb-2">Tahap Penyisihan</h3>
<p className="text-gray-600">
Peserta mengerjakan tantangan yang diberikan.
</p>
</div>
</div>
{/* Timeline Item 4 */}
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-blue-600 rounded-full"></div>
<div className="w-0.5 h-full bg-gray-300"></div>
</div>
<div className="flex-1 pb-8">
<p className="text-blue-600 font-semibold mb-2">
8 - 14 Desember 2025
</p>
<h3 className="text-xl font-bold mb-2">Penilaian & Webinar</h3>
<p className="text-gray-600">
Proses penilaian oleh juri dan sesi webinar.
</p>
</div>
</div>
{/* Timeline Item 5 */}
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-blue-600 rounded-full"></div>
</div>
<div className="flex-1">
<p className="text-blue-600 font-semibold mb-2">
15 Desember 2025
</p>
<h3 className="text-xl font-bold mb-2">Pengumuman & Final</h3>
<p className="text-gray-600">
Presentasi final dan pengumuman pemenang.
</p>
</div>
</div>
</div>
</div>
</section>
{/* Judges Section */}
<section className="py-16 md:py-24 px-4 md:px-8 bg-gray-50">
<div className="max-w-6xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-5xl font-bold mb-4">
Dewan <span className="text-blue-600">Juri</span>
</h2>
<p className="text-lg text-gray-600">
Perwakilan dari IMPHNEN dan Kolosal.ai yang akan menilai karya
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{/* Judge 1 */}
<div className="bg-white rounded-xl p-8 shadow-lg text-center">
<div className="w-24 h-24 bg-gray-200 rounded-full mx-auto mb-4"></div>
<h3 className="text-xl font-bold mb-1">
Alifais Farrel Ramdhani
</h3>
<p className="text-blue-600 font-semibold mb-1">CTO</p>
<p className="text-gray-600">Kolosal.ai</p>
</div>
{/* Judge 2 */}
<div className="bg-white rounded-xl p-8 shadow-lg text-center">
<div className="w-24 h-24 bg-gray-200 rounded-full mx-auto mb-4"></div>
<h3 className="text-xl font-bold mb-1">Anka Tama</h3>
<p className="text-blue-600 font-semibold mb-1">Admin</p>
<p className="text-gray-600">IMPHNEN</p>
</div>
{/* Judge 3 */}
<div className="bg-white rounded-xl p-8 shadow-lg text-center">
<div className="w-24 h-24 bg-gray-200 rounded-full mx-auto mb-4"></div>
<h3 className="text-xl font-bold mb-1">Hafid Nur</h3>
<p className="text-blue-600 font-semibold mb-1">Moderator</p>
<p className="text-gray-600">IMPHNEN</p>
</div>
</div>
</div>
</section>
{/* FAQ Section */}
<section id="faq" className="py-16 md:py-24 px-4 md:px-8 bg-white">
<div className="max-w-4xl mx-auto">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-5xl font-bold mb-4 text-blue-600">
FAQ
</h2>
<p className="text-lg text-gray-600">
Pertanyaan yang Sering Diajukan
</p>
</div>
<div className="space-y-4">
{faqs.map((faq, index) => (
<div key={index} className="border border-gray-200 rounded-lg">
<button
onClick={() => setOpenFaq(openFaq === index ? null : index)}
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 transition-colors"
>
<span className="font-semibold text-gray-900">
{faq.question}
</span>
<svg
className={`w-5 h-5 text-blue-600 transform transition-transform ${
openFaq === index ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{openFaq === index && (
<div className="px-6 pb-4 text-gray-600">{faq.answer}</div>
)}
</div>
))}
</div>
</div>
</section>
{/* Sponsors Section */}
<section className="py-16 md:py-24 px-4 md:px-8 bg-gray-50">
<div className="max-w-6xl mx-auto text-center">
<h2 className="text-3xl md:text-5xl font-bold mb-4">
Sponsor & <span className="text-blue-600">Partner</span>
</h2>
<p className="text-lg text-gray-600 mb-12">
Acara ini sepenuhnya disponsori oleh
</p>
<div className="flex justify-center">
<div className="bg-white rounded-xl p-8 shadow-lg inline-block">
<span className="text-3xl font-bold">Kolosal</span>
</div>
</div>
</div>
</section>
{/* CTA Section */}
<section
id="masuk"
className="py-16 md:py-24 px-4 md:px-8 bg-gradient-to-b from-white to-blue-50"
>
<div className="max-w-4xl mx-auto text-center">
<h2 className="text-3xl md:text-5xl font-bold mb-6">
Segera Daftarkan <span className="text-blue-600">Timmu!</span>
</h2>
<p className="text-lg md:text-xl text-gray-600 mb-8">
Jangan lewatkan kesempatan emas untuk bersaing dengan developer
terbaik,
<br className="hidden md:block" />
belajar dari para ahli, dan memenangkan hadiah jutaan rupiah!
</p>
<div className="flex flex-col md:flex-row gap-4 justify-center">
<button
onClick={() => navigate('/auth/login')}
className="px-8 py-3 bg-blue-600 text-white text-lg rounded-lg hover:bg-blue-700 transition-colors font-semibold"
>
Daftar Sekarang
</button>
<button className="px-8 py-3 bg-white text-gray-900 text-lg rounded-lg border-2 border-gray-300 hover:border-gray-400 transition-colors font-semibold">
Gabung Grup WA Hackathon
</button>
</div>
<p className="text-sm text-gray-500 mt-6">
Pendaftaran ditutup pada{' '}
<span className="text-blue-600 font-semibold">
30 November 2025
</span>
</p>
</div>
</section>
{/* Footer */}
<footer className="bg-gray-900 text-white py-12 px-4 md:px-8">
<div className="max-w-6xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-8">
{/* Brand */}
<div>
<div className="flex items-center gap-2 mb-4">
<span className="text-2xl font-bold">IMPHNEN</span>
<span className="text-2xl font-bold text-blue-500">
Hackathon
</span>
</div>
<p className="text-gray-400 text-sm">
Wujudkan ide brilian mu menjadi solusi nyata. Bergabunglah dalam
IMPHNEN Hackathon dan jadilah bagian dari perubahan teknologi
masa depan.
</p>
<div className="flex gap-4 mt-4">
<a
href="#"
className="text-gray-400 hover:text-white transition-colors"
>
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M24 12.073c0-6.627-5.373-12-12-12s-12 5.373-12 12c0 5.99 4.388 10.954 10.125 11.854v-8.385H7.078v-3.47h3.047V9.43c0-3.007 1.792-4.669 4.533-4.669 1.312 0 2.686.235 2.686.235v2.953H15.83c-1.491 0-1.956.925-1.956 1.874v2.25h3.328l-.532 3.47h-2.796v8.385C19.612 23.027 24 18.062 24 12.073z" />
</svg>
</a>
<a
href="#"
className="text-gray-400 hover:text-white transition-colors"
>
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zm0-2.163c-3.259 0-3.667.014-4.947.072-4.358.2-6.78 2.618-6.98 6.98-.059 1.281-.073 1.689-.073 4.948 0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98 1.281.058 1.689.072 4.948.072 3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98-1.281-.059-1.69-.073-4.949-.073z" />
</svg>
</a>
<a
href="#"
className="text-gray-400 hover:text-white transition-colors"
>
<svg
className="w-6 h-6"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.840 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
</svg>
</a>
</div>
</div>
{/* Quick Links */}
<div>
<h3 className="font-bold text-lg mb-4">Quick Links</h3>
<ul className="space-y-2 text-gray-400">
<li>
<a
href="#timeline"
className="hover:text-white transition-colors"
>
Timeline
</a>
</li>
<li>
<a
href="#hadiah"
className="hover:text-white transition-colors"
>
Hadiah
</a>
</li>
<li>
<a href="#faq" className="hover:text-white transition-colors">
FAQ
</a>
</li>
<li>
<a
href="#masuk"
className="hover:text-white transition-colors"
>
Daftar
</a>
</li>
</ul>
</div>
{/* Contact */}
<div>
<h3 className="font-bold text-lg mb-4">Contact</h3>
<ul className="space-y-2 text-gray-400">
<li className="flex items-center gap-2">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
/>
</svg>
imphnen@gmail.com
</li>
<li className="flex items-center gap-2">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17 8h2a2 2 0 012 2v6a2 2 0 01-2 2h-2v4l-4-4H9a1.994 1.994 0 01-1.414-.586m0 0L11 14h4a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2v4l.586-.586z"
/>
</svg>
WA Group Hackathon
</li>
<li className="flex items-center gap-2">
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
/>
</svg>
IMPHNEN.dev
</li>
</ul>
</div>
</div>
<div className="border-t border-gray-800 pt-8 text-center text-gray-400 text-sm">
<p>
© 2025 IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan
Ngoding. All rights reserved.
</p>
</div>
</div>
</footer>
</div>
);
}
@@ -0,0 +1,210 @@
import { FC, ReactElement, useState, useRef, useEffect } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router';
import { useTeamById, useTeamMessages, useSendMessage, useDeleteMessage, useAuthStore } from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
const TeamChatPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const [message, setMessage] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const { data: teamData } = useTeamById(teamId || '');
const { data: messages, isLoading } = useTeamMessages(teamId || '');
const { mutateAsync: sendMessage, isPending: isSending } = useSendMessage(teamId || '');
const { mutateAsync: deleteMessage } = useDeleteMessage(teamId || '');
const team = teamData?.data;
const currentUserId = session?.user?.id;
// Auto-scroll to bottom when new messages arrive
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleSendMessage = async (e: React.FormEvent) => {
e.preventDefault();
if (!message.trim() || isSending) return;
try {
await sendMessage(message.trim());
setMessage('');
} catch (error) {
console.error('Failed to send message:', error);
toast.error('Failed to send message');
}
};
const handleDeleteMessage = async (messageId: string) => {
if (!confirm('Are you sure you want to delete this message?')) return;
try {
await deleteMessage(messageId);
toast.success('Message deleted');
} catch (error) {
console.error('Failed to delete message:', error);
toast.error('Failed to delete message');
}
};
const formatTime = (timestamp: string) => {
const date = new Date(timestamp);
const now = new Date();
const diffInMs = now.getTime() - date.getTime();
const diffInMins = Math.floor(diffInMs / 60000);
if (diffInMins < 1) return 'Just now';
if (diffInMins < 60) return `${diffInMins}m ago`;
if (diffInMins < 1440) return `${Math.floor(diffInMins / 60)}h ago`;
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
};
return (
<div className="flex flex-col h-screen bg-gray-50">
{/* Header */}
<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="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">Team Chat</h1>
<p className="text-gray-600 text-sm mt-0.5">{team?.name}</p>
</div>
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team
</Button>
</div>
</div>
</div>
{/* Messages Container */}
<div className="flex-1 overflow-y-auto">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
{isLoading ? (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
) : messages && messages.length > 0 ? (
<div className="space-y-4">
{messages.map((msg) => {
const isOwnMessage = msg.user_id === currentUserId;
const isLeader = team?.leader_id === currentUserId;
const canDelete = isOwnMessage || isLeader;
return (
<div
key={msg.id}
className={`flex ${isOwnMessage ? 'justify-end' : 'justify-start'}`}
>
<div className={`flex gap-3 max-w-lg ${isOwnMessage ? 'flex-row-reverse' : 'flex-row'}`}>
{/* Avatar */}
<div className="flex-shrink-0">
{msg.user?.avatar ? (
<img
src={msg.user.avatar}
alt={msg.user.fullname}
className="w-10 h-10 rounded-full object-cover"
/>
) : (
<div className="w-10 h-10 rounded-full bg-blue-600 flex items-center justify-center text-white font-semibold">
{msg.user?.fullname?.charAt(0) || '?'}
</div>
)}
</div>
{/* Message Bubble */}
<div className={`flex-1 ${isOwnMessage ? 'text-right' : 'text-left'}`}>
<div className={`inline-block ${isOwnMessage ? 'items-end' : 'items-start'}`}>
<div className="flex items-baseline gap-2 mb-1">
<span className="font-semibold text-sm text-gray-900">
{isOwnMessage ? 'You' : msg.user?.fullname}
</span>
<span className="text-xs text-gray-500">
{formatTime(msg.created_at)}
</span>
</div>
<div
className={`relative group rounded-2xl px-4 py-2.5 ${
isOwnMessage
? 'bg-blue-600 text-white'
: 'bg-white text-gray-900 border border-gray-200'
}`}
>
<p className="text-sm whitespace-pre-wrap break-words">{msg.message}</p>
{/* Delete button */}
{canDelete && (
<button
onClick={() => handleDeleteMessage(msg.id)}
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' : ''}`}
title="Delete message"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" />
</svg>
</button>
)}
</div>
</div>
</div>
</div>
</div>
);
})}
<div ref={messagesEndRef} />
</div>
) : (
<div className="flex flex-col items-center justify-center h-64 text-center">
<div className="text-6xl mb-4">💬</div>
<h3 className="text-xl font-semibold text-gray-900 mb-2">
No messages yet
</h3>
<p className="text-gray-600">
Be the first to start the conversation!
</p>
</div>
)}
</div>
</div>
{/* Message Input */}
<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">
<form onSubmit={handleSendMessage} className="flex gap-3">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
placeholder="Type your message..."
className="flex-1 px-4 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
disabled={isSending}
/>
<Button
type="submit"
disabled={!message.trim() || isSending}
className="px-6 py-3"
>
{isSending ? (
<div className="flex items-center gap-2">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
Sending...
</div>
) : (
<div className="flex items-center gap-2">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
</svg>
Send
</div>
)}
</Button>
</form>
</div>
</div>
</div>
);
};
export default TeamChatPage;
@@ -0,0 +1,347 @@
import { FC, ReactElement, useState, useEffect } from 'react';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router';
import { useForm, Controller } from 'react-hook-form';
import { teamUpdateSchema, TTeamUpdateForm, useUpdateTeam, useTeamById, ETeamVisibility, useUploadFile, useAuthStore } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import INDONESIAN_CITIES from '../../../../constants/cities';
const EditTeamPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string>('');
const [bannerFile, setBannerFile] = useState<File | null>(null);
const [bannerPreview, setBannerPreview] = useState<string>('');
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(teamId || '');
const { mutateAsync: updateTeam, isPending: isUpdating } = useUpdateTeam(teamId || '');
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const team = teamData?.data;
const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id;
const form = useForm<TTeamUpdateForm>({
resolver: zodResolver(teamUpdateSchema),
mode: 'all',
});
useEffect(() => {
if (team) {
form.reset({
name: team.name,
description: team.description,
city: team.city,
visibility: team.visibility,
logo: team.logo,
banner: team.banner,
});
if (team.logo) setLogoPreview(team.logo);
if (team.banner) setBannerPreview(team.banner);
}
}, [team, form]);
if (isLoadingTeam) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-gray-600">Loading team...</div>
</div>
);
}
if (!isLeader) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
<p className="text-gray-600 mb-4">Only the team leader can edit team information</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
</div>
);
}
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setLogoFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setLogoPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setBannerFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setBannerPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const onSubmit = form.handleSubmit(async (data) => {
try {
let logoUrl = data.logo;
let bannerUrl = data.banner;
if (logoFile) {
const logoResult = await uploadFile(logoFile);
logoUrl = logoResult.data.url;
}
if (bannerFile) {
const bannerResult = await uploadFile(bannerFile);
bannerUrl = bannerResult.data.url;
}
await updateTeam({
...data,
logo: logoUrl,
banner: bannerUrl,
});
navigate(`/teams/${teamId}`);
} catch (error) {
console.error('Failed to update team:', error);
}
});
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-white border-b">
<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">Edit Team Info</h1>
<p className="text-gray-600 mt-1">Update your team details</p>
</div>
</div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white rounded-lg shadow-md p-8">
<form onSubmit={onSubmit} className="space-y-6">
{/* Banner Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Team Banner
</label>
{bannerPreview ? (
<div className="relative">
<img
src={bannerPreview}
alt="Banner preview"
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200"
/>
<button
type="button"
onClick={() => {
setBannerFile(null);
setBannerPreview('');
form.setValue('banner', null);
}}
className="absolute top-2 right-2 bg-red-500 text-white px-3 py-1 rounded-lg text-sm hover:bg-red-600"
>
Remove
</button>
</div>
) : (
<label className="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50">
<div className="text-center">
<p className="text-gray-500">Click to upload banner</p>
<p className="text-xs text-gray-400 mt-1">1200x400 recommended</p>
</div>
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleBannerChange}
/>
</label>
)}
</div>
{/* Logo Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Team Logo
</label>
<div className="flex items-center space-x-4">
{logoPreview ? (
<img
src={logoPreview}
alt="Logo preview"
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200"
/>
) : (
<div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-400 text-3xl">👥</span>
</div>
)}
<div>
<label htmlFor="logo" className="cursor-pointer">
<span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">
{logoPreview ? 'Change Logo' : 'Upload Logo'}
</span>
<input
id="logo"
type="file"
accept="image/*"
className="hidden"
onChange={handleLogoChange}
/>
</label>
{logoPreview && (
<button
type="button"
onClick={() => {
setLogoFile(null);
setLogoPreview('');
form.setValue('logo', null);
}}
className="ml-3 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
>
Remove
</button>
)}
</div>
</div>
</div>
{/* Team Name */}
<ControlledInputField
control={form.control}
label="Team Name"
placeholder="Enter team name"
name="name"
/>
{/* City */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
City
</label>
<Controller
control={form.control}
name="city"
render={({ field, fieldState }) => (
<div>
<select
{...field}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<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>
{/* Description */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Description
</label>
<Controller
control={form.control}
name="description"
render={({ field, fieldState }) => (
<div>
<Textarea
{...field}
value={field.value || ''}
placeholder="Tell others about your team..."
rows={4}
className="w-full"
/>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
)}
</div>
)}
/>
</div>
{/* Visibility */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Team Visibility
</label>
<Controller
control={form.control}
name="visibility"
render={({ field }) => (
<div className="space-y-3">
<label className="flex items-start space-x-3 cursor-pointer border rounded-lg p-4 hover:bg-gray-50">
<input
type="radio"
{...field}
value={ETeamVisibility.PUBLIC}
checked={field.value === ETeamVisibility.PUBLIC}
className="mt-1"
/>
<div>
<p className="font-medium text-gray-900">Public</p>
<p className="text-sm text-gray-600">
Team will be visible in Browse Teams
</p>
</div>
</label>
<label className="flex items-start space-x-3 cursor-pointer border rounded-lg p-4 hover:bg-gray-50">
<input
type="radio"
{...field}
value={ETeamVisibility.PRIVATE}
checked={field.value === ETeamVisibility.PRIVATE}
className="mt-1"
/>
<div>
<p className="font-medium text-gray-900">Private</p>
<p className="text-sm text-gray-600">
Team hidden, invite-only
</p>
</div>
</label>
</div>
)}
/>
</div>
{/* Submit Button */}
<div className="flex space-x-3 pt-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => navigate(`/teams/${teamId}`)}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={isUpdating || isUploading}
>
{isUpdating || isUploading ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</form>
</div>
</div>
</div>
);
};
export default EditTeamPage;
@@ -0,0 +1,18 @@
import { FC, ReactNode } from 'react';
import { Outlet } from 'react-router';
import { Navigation } from '../../../components/navigation';
interface TeamLayoutProps {
children?: ReactNode;
}
export const TeamLayout: FC<TeamLayoutProps> = () => {
return (
<div className="min-h-screen bg-gray-50">
<Navigation />
<Outlet />
</div>
);
};
export default TeamLayout;
@@ -0,0 +1,294 @@
import { FC, ReactElement, useState } from 'react';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router';
import {
useTeamById,
useTeamMembers,
useTeamJoinRequests,
useInviteMember,
useRemoveMember,
useRespondToJoinRequest,
ETeamMemberStatus,
inviteMemberSchema,
TInviteMemberForm,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
const ManageMembersPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const [showInviteModal, setShowInviteModal] = useState(false);
const { data: teamData } = useTeamById(teamId || '');
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(teamId || '');
const { data: joinRequestsData } = useTeamJoinRequests(teamId || '');
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(teamId || '');
const { mutateAsync: removeMember, isPending: isRemoving } = useRemoveMember(teamId || '');
const { mutateAsync: respondToRequest, isPending: isResponding } = useRespondToJoinRequest(teamId || '');
const team = teamData?.data;
const members = membersData?.data || [];
const joinRequests = Array.isArray(joinRequestsData?.data) ? joinRequestsData.data : [];
const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id;
const form = useForm<TInviteMemberForm>({
resolver: zodResolver(inviteMemberSchema),
mode: 'all',
});
if (!isLeader) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
<p className="text-gray-600 mb-4">Only the team leader can manage members</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
</div>
);
}
const handleInvite = form.handleSubmit(async (data) => {
try {
await inviteMember(data);
setShowInviteModal(false);
form.reset();
} catch (error) {
console.error('Failed to invite member:', error);
}
});
const handleRemove = async (userId: string) => {
// eslint-disable-next-line no-restricted-globals
if (confirm('Are you sure you want to remove this member?')) {
try {
await removeMember(userId);
} catch (error) {
console.error('Failed to remove member:', error);
}
}
};
const handleApproveRequest = async (requestId: string) => {
try {
await respondToRequest({ requestId, action: 'approve' });
} catch (error) {
console.error('Failed to approve request:', error);
}
};
const handleRejectRequest = async (requestId: string) => {
try {
await respondToRequest({ requestId, action: 'reject' });
} catch (error) {
console.error('Failed to reject request:', error);
}
};
return (
<div className="min-h-screen bg-gray-50">
<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="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Manage Members</h1>
<p className="text-gray-600 mt-1">{team?.name}</p>
</div>
<div className="flex space-x-3">
<Button onClick={() => setShowInviteModal(true)}>
Invite Member
</Button>
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team
</Button>
</div>
</div>
</div>
</div>
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
{/* Join Requests */}
{joinRequests.length > 0 && (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">
Join Requests ({joinRequests.length})
</h2>
<div className="space-y-3">
{joinRequests.map((request) => (
<div key={request.id} className="border rounded-lg p-4">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3 flex-1">
{request.user.avatar ? (
<img
src={request.user.avatar}
alt={request.user.fullname}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500">👤</span>
</div>
)}
<div className="flex-1">
<p className="font-medium text-gray-900">{request.user.fullname}</p>
<p className="text-sm text-gray-600">{request.user.email}</p>
{request.user.location && (
<p className="text-sm text-gray-500">📍 {request.user.location}</p>
)}
<p className="text-sm text-gray-700 mt-2 italic">"{request.message}"</p>
</div>
</div>
<div className="flex space-x-2 ml-4">
<Button
size="sm"
onClick={() => handleApproveRequest(request.id)}
disabled={isResponding}
>
Approve
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => handleRejectRequest(request.id)}
disabled={isResponding}
>
Reject
</Button>
</div>
</div>
</div>
))}
</div>
</div>
)}
{/* Current Members */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">
Current Members ({members.length})
</h2>
{isLoadingMembers ? (
<p className="text-gray-600">Loading members...</p>
) : (
<div className="space-y-3">
{members.map((member) => (
<div key={member.id} className="border rounded-lg p-4 flex items-center justify-between">
<div className="flex items-center space-x-3">
{member.user.avatar ? (
<img
src={member.user.avatar}
alt={member.user.fullname}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500">👤</span>
</div>
)}
<div>
<p className="font-medium text-gray-900">{member.user.fullname}</p>
<p className="text-sm text-gray-600">{member.user.email}</p>
{member.user.location && (
<p className="text-sm text-gray-500">📍 {member.user.location}</p>
)}
<div className="flex items-center space-x-2 mt-1">
{member.role === 'leader' && (
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs font-medium">
Leader
</span>
)}
{member.status === ETeamMemberStatus.PENDING && (
<span className="px-2 py-1 bg-yellow-100 text-yellow-800 rounded text-xs font-medium">
Pending Invitation
</span>
)}
</div>
</div>
</div>
{member.role !== 'leader' && (
<Button
size="sm"
variant="secondary"
onClick={() => handleRemove(member.user_id)}
disabled={isRemoving}
>
Remove
</Button>
)}
</div>
))}
</div>
)}
</div>
{/* Warning */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p className="text-sm text-yellow-800">
<strong>Note:</strong> Members cannot leave the team without your approval. Only you can remove members from the team.
</p>
</div>
</div>
{/* Invite Member Modal */}
{showInviteModal && (
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Invite Member
</h2>
<p className="text-gray-600 mb-4">
Send an invitation to join your team. The invited member will see the invitation on their dashboard after logging in.
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-6">
<p className="text-sm text-yellow-800">
<strong>Important:</strong> The email you enter must match the GitHub email address the member uses to sign in.
</p>
</div>
<form onSubmit={handleInvite} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Email Address
</label>
<Input
{...form.register('email')}
type="email"
placeholder="member@example.com"
/>
{form.formState.errors.email && (
<p className="text-sm text-red-500 mt-1">
{form.formState.errors.email.message}
</p>
)}
</div>
<div className="flex space-x-3">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => {
setShowInviteModal(false);
form.reset();
}}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={!form.formState.isValid || isInviting}
>
{isInviting ? 'Sending...' : 'Send Invitation'}
</Button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default ManageMembersPage;
@@ -0,0 +1,444 @@
import { FC, ReactElement, useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Link, useParams, useNavigate } from 'react-router';
import { useTeamById, useTeamMembers, useInviteMember, useTeamJoinRequests, useRespondToJoinRequest, ETeamMemberRole, useAuthStore } from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
const MAX_TEAM_MEMBERS = 5;
const TeamDashboardPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const [showInviteModal, setShowInviteModal] = useState(false);
const [showJoinRequestsModal, setShowJoinRequestsModal] = useState(false);
const [inviteEmail, setInviteEmail] = useState('');
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(teamId || '');
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(teamId || '');
const { data: joinRequestsData } = useTeamJoinRequests(teamId || '', !!teamId);
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(teamId || '');
const { mutateAsync: respondToJoinRequest, isPending: isResponding } = useRespondToJoinRequest(teamId || '');
const team = teamData?.data;
const members = membersData?.data || [];
const joinRequests = joinRequestsData?.data || [];
const pendingJoinRequests = joinRequests.filter((req: any) => req.status === 'pending');
const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id;
const canInvite = isLeader && members.length < MAX_TEAM_MEMBERS;
console.log('Leader check:', { currentUserId, leaderId: team?.leader_id, isLeader });
const handleInviteMember = async (e: React.FormEvent) => {
e.preventDefault();
if (!inviteEmail.trim() || isInviting) return;
try {
await inviteMember({ email: inviteEmail.trim() });
toast.success('Invitation sent successfully!');
setInviteEmail('');
setShowInviteModal(false);
} catch (error) {
console.error('Failed to invite member:', error);
toast.error('Failed to send invitation');
}
};
const handleRespondToJoinRequest = async (requestId: string, action: 'approve' | 'reject') => {
try {
await respondToJoinRequest({ requestId, action });
toast.success(action === 'approve' ? 'Request approved!' : 'Request rejected');
} catch (error) {
console.error('Failed to respond to join request:', error);
toast.error('Failed to process request');
}
};
if (isLoadingTeam || isLoadingMembers) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-gray-600">Loading team...</div>
</div>
);
}
if (!team) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Team not found</h2>
<Button onClick={() => navigate('/dashboard')}>Back to Dashboard</Button>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* Header with Banner */}
<div className="bg-white border-b">
{team.banner && (
<div className="w-full h-48 overflow-hidden">
<img
src={team.banner}
alt={team.name}
className="w-full h-full object-cover"
/>
</div>
)}
<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">
{team.logo && (
<img
src={team.logo}
alt={team.name}
className="w-20 h-20 rounded-full object-cover border-4 border-white shadow-lg -mt-10"
/>
)}
<div>
<h1 className="text-3xl font-bold text-gray-900">{team.name}</h1>
<p className="text-gray-600 mt-1">📍 {team.city}</p>
<div className="flex items-center space-x-4 mt-2">
<span className="text-sm text-gray-500">
{members.length} {members.length === 1 ? 'Member' : 'Members'}
</span>
<span className="text-sm text-gray-500">
{team.visibility === 'public' ? '🌐 Public' : '🔒 Private'}
</span>
{isLeader && (
<span className="px-3 py-1 bg-blue-600 text-white rounded-md text-sm font-semibold shadow-sm">
👑 Team Leader
</span>
)}
</div>
</div>
</div>
<Link to="/dashboard">
<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-8">
<div className="grid gap-6 lg:grid-cols-3">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Team Description */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">About Team</h2>
<p className="text-gray-700 whitespace-pre-wrap">{team.description}</p>
</div>
{/* Team Actions - Only for Leader */}
{isLeader && (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">Team Management</h2>
<div className="grid gap-3 sm:grid-cols-2">
<Button
className="w-full"
variant="secondary"
onClick={() => setShowInviteModal(true)}
disabled={!canInvite}
>
Invite Member {!canInvite && `(${members.length}/${MAX_TEAM_MEMBERS})`}
</Button>
<Button
className="w-full relative"
variant="secondary"
onClick={() => setShowJoinRequestsModal(true)}
>
📩 Join Requests
{pendingJoinRequests.length > 0 && (
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs font-bold rounded-full w-6 h-6 flex items-center justify-center">
{pendingJoinRequests.length}
</span>
)}
</Button>
<Link to={`/teams/${teamId}/edit`}>
<Button className="w-full" variant="secondary">
Edit Team Info
</Button>
</Link>
<Link to={`/teams/${teamId}/members`}>
<Button className="w-full" variant="secondary">
👥 Manage Members
</Button>
</Link>
<Link to={`/teams/${teamId}/chat`}>
<Button className="w-full" variant="secondary">
💬 Team Chat
</Button>
</Link>
<Link to={`/teams/${teamId}/submit`}>
<Button className="w-full">
🚀 Submit Project
</Button>
</Link>
</div>
{!canInvite && members.length >= MAX_TEAM_MEMBERS && (
<p className="text-sm text-gray-600 mt-3 text-center">
Maximum team size reached ({MAX_TEAM_MEMBERS} members)
</p>
)}
</div>
)}
{/* Quick Actions for Members */}
{!isLeader && (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">Quick Actions</h2>
<div className="grid gap-3 sm:grid-cols-2">
<Link to={`/teams/${teamId}/chat`}>
<Button className="w-full" variant="secondary">
💬 Team Chat
</Button>
</Link>
{team.has_submission && (
<Link to={`/teams/${teamId}/submission`}>
<Button className="w-full" variant="secondary">
📄 View Submission
</Button>
</Link>
)}
</div>
</div>
)}
{/* Submission Status */}
{team.has_submission && (
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
<div className="flex items-center space-x-3">
<span className="text-3xl"></span>
<div>
<h3 className="font-bold text-green-900">Project Submitted</h3>
<p className="text-green-700 text-sm">
Your team has successfully submitted a project
</p>
</div>
</div>
<Link to={`/teams/${teamId}/submission`}>
<Button className="mt-4 w-full" variant="secondary">
View Submission Details
</Button>
</Link>
</div>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Team Leader */}
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="font-bold text-gray-900 mb-4">Team Leader</h3>
{team.leader && (
<div className="flex items-center space-x-3">
{team.leader.avatar ? (
<img
src={team.leader.avatar}
alt={team.leader.fullname}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500">👤</span>
</div>
)}
<div>
<p className="font-medium text-gray-900">{team.leader.fullname}</p>
<p className="text-sm text-gray-600">{team.leader.email}</p>
</div>
</div>
)}
</div>
{/* Team Members */}
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="font-bold text-gray-900 mb-4">
Members ({members.length})
</h3>
<div className="space-y-3">
{members.map((member) => (
<div key={member.id} className="flex items-center space-x-3">
{member.user.avatar ? (
<img
src={member.user.avatar}
alt={member.user.fullname}
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">
<span className="text-gray-500 text-sm">👤</span>
</div>
)}
<div className="flex-1 min-w-0">
<p className="font-medium text-gray-900 truncate">
{member.user.fullname}
</p>
<p className="text-xs text-gray-500">
{member.role === ETeamMemberRole.LEADER ? 'Leader' : 'Member'}
</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
{/* Invite Member Modal */}
{showInviteModal && (
<div className="fixed inset-0 bg-black/20 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Invite Team Member
</h2>
<p className="text-gray-600 mb-4">
Send an invitation to join your team. The invited member will see the invitation on their dashboard after logging in.
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-6">
<p className="text-sm text-yellow-800">
<strong>Important:</strong> The email you enter must match the GitHub email address the member uses to sign in.
</p>
</div>
<form onSubmit={handleInviteMember} className="space-y-4">
<div>
<label htmlFor="invite-email" className="block text-sm font-medium text-gray-700 mb-2">
Email Address
</label>
<input
id="invite-email"
type="email"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
placeholder="Enter email address..."
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
required
/>
<p className="text-sm text-gray-500 mt-1">
Current members: {members.length}/{MAX_TEAM_MEMBERS}
</p>
</div>
<div className="flex space-x-3">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => {
setShowInviteModal(false);
setInviteEmail('');
}}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={!inviteEmail.trim() || isInviting}
>
{isInviting ? 'Sending...' : 'Send Invitation'}
</Button>
</div>
</form>
</div>
</div>
)}
{/* Join Requests Modal */}
{showJoinRequestsModal && (
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<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">
<h2 className="text-2xl font-bold text-gray-900">
Join Requests ({pendingJoinRequests.length})
</h2>
<button
onClick={() => setShowJoinRequestsModal(false)}
className="text-gray-400 hover:text-gray-600 text-2xl"
>
</button>
</div>
{pendingJoinRequests.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-600 text-lg">No pending join requests</p>
<p className="text-gray-500 text-sm mt-2">
When users request to join your team, they'll appear here
</p>
</div>
) : (
<div className="space-y-4">
{pendingJoinRequests.map((request: any) => (
<div key={request.id} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between">
<div className="flex items-start space-x-3 flex-1">
{request.user?.avatar ? (
<img
src={request.user.avatar}
alt={request.user.fullname}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center shrink-0">
<span className="text-gray-500 text-xl">👤</span>
</div>
)}
<div className="flex-1">
<p className="font-semibold text-gray-900">
{request.user?.fullname || 'Unknown User'}
</p>
<p className="text-sm text-gray-600">
{request.user?.email}
</p>
{request.message && (
<div className="mt-2 bg-gray-50 rounded-lg p-3">
<p className="text-sm text-gray-700">
<strong>Message:</strong> {request.message}
</p>
</div>
)}
<p className="text-xs text-gray-500 mt-2">
Requested {new Date(request.created_at).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex space-x-2 ml-4">
<Button
onClick={() => handleRespondToJoinRequest(request.id, 'approve')}
disabled={isResponding || members.length >= MAX_TEAM_MEMBERS}
className="px-4 py-2 text-sm"
>
✓ Accept
</Button>
<Button
onClick={() => handleRespondToJoinRequest(request.id, 'reject')}
disabled={isResponding}
variant="secondary"
className="px-4 py-2 text-sm"
>
Reject
</Button>
</div>
</div>
{members.length >= MAX_TEAM_MEMBERS && (
<div className="mt-3 bg-yellow-50 border border-yellow-200 rounded-lg p-2">
<p className="text-xs text-yellow-800">
Team is full ({MAX_TEAM_MEMBERS}/{MAX_TEAM_MEMBERS} members). Remove a member before accepting new requests.
</p>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
)}
</div>
);
};
export default TeamDashboardPage;
@@ -0,0 +1,207 @@
import { FC, ReactElement } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router';
import { useTeamById, useTeamSubmission } from '@imphnen-frontend-service/service';
const SubmissionViewPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
const { data: teamData } = useTeamById(teamId || '');
const { data: submissionData, isLoading } = useTeamSubmission(teamId || '', !!teamId);
const team = teamData?.data;
const submission = submissionData?.data;
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-gray-600">Loading submission...</div>
</div>
);
}
if (!submission) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<div className="text-6xl mb-4">📄</div>
<h2 className="text-2xl font-bold text-gray-900 mb-4">No Submission Yet</h2>
<p className="text-gray-600 mb-4">Your team hasn't submitted a project</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
</div>
);
}
const submittedDate = submission.submitted_at
? new Date(submission.submitted_at).toLocaleString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: 'Not submitted';
return (
<div className="min-h-screen bg-gray-50">
<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="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Project Submission</h1>
<p className="text-gray-600 mt-1">{team?.name}</p>
</div>
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team
</Button>
</div>
</div>
</div>
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Status Banner */}
<div className="bg-green-50 border border-green-500 rounded-lg p-6 mb-6">
<div className="flex items-center space-x-3">
<span className="text-4xl">✅</span>
<div>
<h3 className="font-bold text-green-900 text-lg">Project Submitted Successfully</h3>
<p className="text-green-700 text-sm">
Submitted on {submittedDate}
</p>
<p className="text-green-600 text-xs mt-1">
This submission is now read-only and cannot be edited
</p>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-md overflow-hidden">
{/* Project Header */}
<div className="bg-gradient-to-r from-blue-600 to-blue-800 text-white p-8">
<h2 className="text-3xl font-bold mb-2">{submission.project_name}</h2>
<p className="text-blue-100">Team: {team?.name}</p>
</div>
{/* Project Details */}
<div className="p-8 space-y-6">
{/* Description */}
<div>
<h3 className="text-lg font-bold text-gray-900 mb-3">Project Description</h3>
<div className="bg-gray-50 rounded-lg p-4">
<p className="text-gray-700 whitespace-pre-wrap">{submission.description}</p>
</div>
</div>
{/* Links */}
<div className="grid gap-6 md:grid-cols-2">
{/* Repository */}
<div>
<h3 className="text-lg font-bold text-gray-900 mb-3">Repository</h3>
<a
href={submission.repository_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-2 text-blue-600 hover:text-blue-800"
>
<span>🔗</span>
<span className="break-all">{submission.repository_url}</span>
</a>
</div>
{/* Demo URL */}
{submission.demo_url && (
<div>
<h3 className="text-lg font-bold text-gray-900 mb-3">Live Demo</h3>
<a
href={submission.demo_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-2 text-blue-600 hover:text-blue-800"
>
<span>🌐</span>
<span className="break-all">{submission.demo_url}</span>
</a>
</div>
)}
{/* Presentation URL */}
{submission.presentation_url && (
<div>
<h3 className="text-lg font-bold text-gray-900 mb-3">Presentation</h3>
<a
href={submission.presentation_url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-2 text-blue-600 hover:text-blue-800"
>
<span>📊</span>
<span className="break-all">{submission.presentation_url}</span>
</a>
</div>
)}
</div>
{/* Screenshots */}
{submission.screenshots && submission.screenshots.length > 0 && (
<div>
<h3 className="text-lg font-bold text-gray-900 mb-3">
Screenshots ({submission.screenshots.length})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{submission.screenshots.map((url, index) => (
<a
key={index}
href={url}
target="_blank"
rel="noopener noreferrer"
className="block"
>
<img
src={url}
alt={`Screenshot ${index + 1}`}
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 hover:border-blue-500 transition-colors cursor-pointer"
/>
</a>
))}
</div>
</div>
)}
{/* Submission Info */}
<div className="bg-gray-50 rounded-lg p-4 border-t-4 border-blue-600">
<h3 className="text-sm font-bold text-gray-900 mb-2">Submission Information</h3>
<div className="grid gap-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Status:</span>
<span className="font-medium text-green-600">
{submission.status === 'submitted' ? ' Submitted' : 'Draft'}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Submitted:</span>
<span className="font-medium text-gray-900">{submittedDate}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Submission ID:</span>
<span className="font-medium text-gray-900 font-mono text-xs">
{submission.id}
</span>
</div>
</div>
</div>
{/* Read-only Notice */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p className="text-sm text-yellow-800">
<strong>Note:</strong> This submission is now locked and cannot be edited or deleted.
If you need to make changes, please contact the hackathon organizers.
</p>
</div>
</div>
</div>
</div>
</div>
);
};
export default SubmissionViewPage;
@@ -0,0 +1,296 @@
import { FC, ReactElement, useState } from 'react';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router';
import { useForm, Controller } from 'react-hook-form';
import {
projectSubmissionSchema,
TProjectSubmissionForm,
useSubmitProject,
useTeamById,
useTeamSubmission,
useUploadFile,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
const SubmitProjectPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const navigate = useNavigate();
const { session } = useAuthStore();
const [showConfirmModal, setShowConfirmModal] = useState(false);
const [screenshots, setScreenshots] = useState<string[]>([]);
const { data: teamData } = useTeamById(teamId || '');
const { data: submissionData } = useTeamSubmission(teamId || '', !!teamId);
const { mutateAsync: submitProject, isPending: isSubmitting } = useSubmitProject(teamId || '');
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const team = teamData?.data;
const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id;
const hasSubmission = !!submissionData?.data;
const form = useForm<TProjectSubmissionForm>({
resolver: zodResolver(projectSubmissionSchema),
mode: 'all',
});
if (!isLeader) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Access Denied</h2>
<p className="text-gray-600 mb-4">Only the team leader can submit projects</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
</div>
);
}
if (hasSubmission) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<div className="text-6xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 mb-4">Project Already Submitted</h2>
<p className="text-gray-600 mb-4">Your team has already submitted a project</p>
<div className="flex space-x-3">
<Button onClick={() => navigate(`/teams/${teamId}/submission`)}>
View Submission
</Button>
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
Back to Team
</Button>
</div>
</div>
);
}
const handleScreenshotUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) return;
try {
const uploadPromises = Array.from(files).map(file => uploadFile(file));
const results = await Promise.all(uploadPromises);
const urls = results.map(r => r.data.url);
setScreenshots([...screenshots, ...urls]);
} catch (error) {
console.error('Failed to upload screenshots:', error);
}
};
const removeScreenshot = (index: number) => {
setScreenshots(screenshots.filter((_, i) => i !== index));
};
const onSubmit = form.handleSubmit(async (data) => {
try {
await submitProject({
...data,
screenshots,
});
navigate(`/teams/${teamId}/submission`);
} catch (error) {
console.error('Failed to submit project:', error);
}
});
return (
<div className="min-h-screen bg-gray-50">
<div className="bg-white border-b">
<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">Submit Project</h1>
<p className="text-gray-600 mt-1">{team?.name}</p>
</div>
</div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Warning Banner */}
<div className="bg-red-50 border-2 border-red-500 rounded-lg p-6 mb-6">
<div className="flex items-start space-x-3">
<span className="text-3xl"></span>
<div>
<h3 className="font-bold text-red-900 text-lg">IMPORTANT WARNING</h3>
<ul className="text-red-800 mt-2 space-y-1 text-sm">
<li> You can only submit your project ONCE</li>
<li> After submission, you CANNOT edit or change anything</li>
<li> Make sure all information is correct before submitting</li>
<li> Review your project details carefully</li>
</ul>
</div>
</div>
</div>
<div className="bg-white rounded-lg shadow-md p-8">
<form onSubmit={(e) => {
e.preventDefault();
setShowConfirmModal(true);
}} className="space-y-6">
{/* Project Name */}
<ControlledInputField
control={form.control}
label="Project Name"
placeholder="Enter your project name"
name="project_name"
/>
{/* Description */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Project Description <span className="text-red-500">*</span>
</label>
<Controller
control={form.control}
name="description"
render={({ field, fieldState }) => (
<div>
<Textarea
{...field}
placeholder="Describe your project, its features, and what problem it solves..."
rows={6}
className="w-full"
/>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
)}
</div>
)}
/>
</div>
{/* Repository URL */}
<ControlledInputField
control={form.control}
label="Repository URL (GitHub, GitLab, etc.)"
placeholder="https://github.com/username/project"
name="repository_url"
type="url"
/>
{/* Demo URL */}
<ControlledInputField
control={form.control}
label="Demo URL (Optional)"
placeholder="https://your-project-demo.com"
name="demo_url"
type="url"
/>
{/* Presentation URL */}
<ControlledInputField
control={form.control}
label="Presentation URL (Optional)"
placeholder="https://slides.com/your-presentation or Google Drive link"
name="presentation_url"
type="url"
/>
{/* Screenshots */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Project Screenshots <span className="text-gray-400">(Optional)</span>
</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-4">
{screenshots.map((url, index) => (
<div key={index} className="relative">
<img
src={url}
alt={`Screenshot ${index + 1}`}
className="w-full h-32 object-cover rounded-lg border-2 border-gray-200"
/>
<button
type="button"
onClick={() => removeScreenshot(index)}
className="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm hover:bg-red-600"
>
×
</button>
</div>
))}
</div>
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50">
<div className="text-center">
<p className="text-gray-500">Click to upload screenshots</p>
<p className="text-xs text-gray-400 mt-1">PNG, JPG up to 5MB each</p>
</div>
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={handleScreenshotUpload}
disabled={isUploading}
/>
</label>
</div>
{/* Submit Button */}
<div className="flex space-x-3 pt-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => navigate(`/teams/${teamId}`)}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={!form.formState.isValid || isUploading}
>
Review & Submit
</Button>
</div>
</form>
</div>
</div>
{/* Confirmation Modal */}
{showConfirmModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<div className="text-center mb-6">
<div className="text-5xl mb-4"></div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">
Final Confirmation
</h2>
<p className="text-red-600 font-medium">
This action is IRREVERSIBLE!
</p>
</div>
<div className="bg-gray-50 rounded-lg p-4 mb-6">
<p className="text-sm text-gray-700 mb-3">
By clicking "Submit Project", you confirm that:
</p>
<ul className="text-sm text-gray-600 space-y-2">
<li> All information is correct and complete</li>
<li> You understand this can only be done once</li>
<li> You cannot edit after submission</li>
<li> Your team agrees with this submission</li>
</ul>
</div>
<div className="flex space-x-3">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => setShowConfirmModal(false)}
>
Go Back
</Button>
<Button
onClick={onSubmit}
className="flex-1 bg-red-600 hover:bg-red-700"
disabled={isSubmitting}
>
{isSubmitting ? 'Submitting...' : 'Submit Project'}
</Button>
</div>
</div>
</div>
)}
</div>
);
};
export default SubmitProjectPage;
@@ -0,0 +1,230 @@
import { FC, ReactElement, useState } from 'react';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { Link, useNavigate } from 'react-router';
import { useTeams, useJoinTeam, useMyTeams, ETeamVisibility, joinTeamSchema, TJoinTeamForm } from '@imphnen-frontend-service/service';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import INDONESIAN_CITIES_DATA from '../../../constants/cities';
const INDONESIAN_CITIES = ['All Cities', ...INDONESIAN_CITIES_DATA];
const BrowseTeamsPage: FC = (): ReactElement => {
const navigate = useNavigate();
const [search, setSearch] = useState('');
const [selectedCity, setSelectedCity] = useState('All Cities');
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
const [showJoinModal, setShowJoinModal] = useState(false);
const { data: teamsData, isLoading } = useTeams({
search,
city: selectedCity === 'All Cities' ? undefined : selectedCity,
visibility: ETeamVisibility.PUBLIC,
});
const { data: myTeamsData } = useMyTeams();
const { mutateAsync: joinTeam, isPending: isJoining } = useJoinTeam();
const form = useForm<TJoinTeamForm>({
resolver: zodResolver(joinTeamSchema),
mode: 'all',
});
const teams = teamsData?.data || [];
const myTeams = myTeamsData?.data || [];
// Helper function to check if user is a member of a team
const isMyTeam = (teamId: string) => {
return myTeams.some((team: any) => team.id === teamId);
};
const handleJoinRequest = (teamId: string) => {
setSelectedTeamId(teamId);
setShowJoinModal(true);
};
const onSubmit = form.handleSubmit(async (data) => {
if (!selectedTeamId) return;
try {
await joinTeam({ teamId: selectedTeamId, data });
setShowJoinModal(false);
form.reset();
setSelectedTeamId(null);
} catch (error) {
console.error('Failed to send join request:', error);
}
});
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<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="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900">Browse Teams</h1>
<p className="text-gray-600 mt-1">Find and join teams looking for members</p>
</div>
<Link to="/dashboard">
<Button variant="secondary">Back to Dashboard</Button>
</Link>
</div>
</div>
</div>
{/* Filters */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="bg-white p-6 rounded-lg shadow-sm mb-6">
<div className="grid gap-4 md:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Search Teams
</label>
<Input
type="text"
placeholder="Search by team name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Filter by City
</label>
<select
value={selectedCity}
onChange={(e) => setSelectedCity(e.target.value)}
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>
{/* Teams List */}
{isLoading ? (
<div className="text-center py-12">
<p className="text-gray-600">Loading teams...</p>
</div>
) : teams.length === 0 ? (
<div className="bg-white rounded-lg shadow-sm p-12 text-center">
<p className="text-gray-600 text-lg">No teams found</p>
<p className="text-gray-500 mt-2">Try adjusting your filters</p>
</div>
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{teams.map((team) => (
<div key={team.id} className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
{team.banner && (
<img
src={team.banner}
alt={team.name}
className="w-full h-32 object-cover"
/>
)}
<div className="p-6">
<div className="flex items-center space-x-3 mb-3">
{team.logo ? (
<img
src={team.logo}
alt={team.name}
className="w-12 h-12 rounded-full object-cover"
/>
) : (
<div className="w-12 h-12 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500 text-xl">👥</span>
</div>
)}
<div className="flex-1">
<h3 className="text-lg font-bold text-gray-900">{team.name}</h3>
<p className="text-sm text-gray-600">📍 {team.city}</p>
</div>
</div>
<p className="text-gray-600 text-sm mb-4 line-clamp-3">
{team.description}
</p>
{isMyTeam(team.id) ? (
<Button
className="w-full"
variant="secondary"
onClick={() => navigate(`/teams/${team.id}`)}
>
Your Team
</Button>
) : (
<Button
className="w-full"
onClick={() => handleJoinRequest(team.id)}
>
Request to Join
</Button>
)}
</div>
</div>
))}
</div>
)}
</div>
{/* Join Request Modal */}
{showJoinModal && (
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg shadow-xl max-w-md w-full p-6">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Request to Join Team
</h2>
<p className="text-gray-600 mb-6">
Send a message to the team leader explaining why you want to join
</p>
<form onSubmit={onSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Your Message
</label>
<textarea
{...form.register('message')}
rows={4}
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..."
/>
{form.formState.errors.message && (
<p className="text-sm text-red-500 mt-1">
{form.formState.errors.message.message}
</p>
)}
</div>
<div className="flex space-x-3">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => {
setShowJoinModal(false);
form.reset();
setSelectedTeamId(null);
}}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={!form.formState.isValid || isJoining}
>
{isJoining ? 'Sending...' : 'Send Request'}
</Button>
</div>
</form>
</div>
</div>
)}
</div>
);
};
export default BrowseTeamsPage;
@@ -0,0 +1,317 @@
import { FC, ReactElement, useState } from 'react';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate } from 'react-router';
import { useForm, Controller } from 'react-hook-form';
import { teamCreateSchema, TTeamCreateForm, useCreateTeam, ETeamVisibility, useUploadFile } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import INDONESIAN_CITIES from '../../../constants/cities';
const CreateTeamPage: FC = (): ReactElement => {
const navigate = useNavigate();
const [logoFile, setLogoFile] = useState<File | null>(null);
const [logoPreview, setLogoPreview] = useState<string>('');
const [bannerFile, setBannerFile] = useState<File | null>(null);
const [bannerPreview, setBannerPreview] = useState<string>('');
const form = useForm<TTeamCreateForm>({
resolver: zodResolver(teamCreateSchema),
mode: 'all',
defaultValues: {
visibility: ETeamVisibility.PUBLIC,
logo: null,
banner: null,
},
});
const { mutateAsync: createTeam, isPending: isCreating } = useCreateTeam();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setLogoFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setLogoPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
setBannerFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setBannerPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const onSubmit = form.handleSubmit(async (data) => {
try {
let logoUrl = null;
let bannerUrl = null;
if (logoFile) {
const logoResult = await uploadFile(logoFile);
logoUrl = logoResult.data.url;
}
if (bannerFile) {
const bannerResult = await uploadFile(bannerFile);
bannerUrl = bannerResult.data.url;
}
const result = await createTeam({
...data,
logo: logoUrl,
banner: bannerUrl,
});
navigate(`/teams/${result.data.id}`);
} catch (error) {
console.error('Failed to create team:', error);
}
});
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white border-b">
<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">Create Your Team</h1>
<p className="text-gray-600 mt-1">Build your hackathon dream team</p>
</div>
</div>
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white rounded-lg shadow-md p-8">
<form onSubmit={onSubmit} className="space-y-6">
{/* Banner Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Team Banner <span className="text-gray-400">(Optional)</span>
</label>
{bannerPreview ? (
<div className="relative">
<img
src={bannerPreview}
alt="Banner preview"
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200"
/>
<button
type="button"
onClick={() => {
setBannerFile(null);
setBannerPreview('');
}}
className="absolute top-2 right-2 bg-red-500 text-white px-3 py-1 rounded-lg text-sm hover:bg-red-600"
>
Remove
</button>
</div>
) : (
<label className="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-300 rounded-lg cursor-pointer hover:bg-gray-50">
<div className="text-center">
<p className="text-gray-500">Click to upload banner</p>
<p className="text-xs text-gray-400 mt-1">1200x400 recommended</p>
</div>
<input
type="file"
accept="image/*"
className="hidden"
onChange={handleBannerChange}
/>
</label>
)}
</div>
{/* Logo Upload */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Team Logo <span className="text-gray-400">(Optional, but highly recommended)</span>
</label>
<div className="flex items-center space-x-4">
{logoPreview ? (
<img
src={logoPreview}
alt="Logo preview"
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200"
/>
) : (
<div className="w-24 h-24 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-400 text-3xl">👥</span>
</div>
)}
<div>
<label htmlFor="logo" className="cursor-pointer">
<span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">
{logoPreview ? 'Change Logo' : 'Upload Logo'}
</span>
<input
id="logo"
type="file"
accept="image/*"
className="hidden"
onChange={handleLogoChange}
/>
</label>
{logoPreview && (
<button
type="button"
onClick={() => {
setLogoFile(null);
setLogoPreview('');
}}
className="ml-3 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
>
Remove
</button>
)}
</div>
</div>
</div>
{/* Team Name */}
<ControlledInputField
control={form.control}
label="Team Name"
placeholder="Enter your team name"
name="name"
/>
{/* City */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
City <span className="text-red-500">*</span>
</label>
<Controller
control={form.control}
name="city"
render={({ field, fieldState }) => (
<div>
<select
{...field}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
>
<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>
{/* Description */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Description <span className="text-red-500">*</span>
</label>
<Controller
control={form.control}
name="description"
render={({ field, fieldState }) => (
<div>
<Textarea
{...field}
placeholder="Tell others about your team, what you're looking for, your goals..."
rows={4}
className="w-full"
/>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
)}
</div>
)}
/>
</div>
{/* Visibility */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Team Visibility <span className="text-red-500">*</span>
</label>
<Controller
control={form.control}
name="visibility"
render={({ field }) => (
<div className="space-y-3">
<label className="flex items-start space-x-3 cursor-pointer border rounded-lg p-4 hover:bg-gray-50">
<input
type="radio"
{...field}
value={ETeamVisibility.PUBLIC}
checked={field.value === ETeamVisibility.PUBLIC}
className="mt-1"
/>
<div>
<p className="font-medium text-gray-900">Public</p>
<p className="text-sm text-gray-600">
Team will be visible in Browse Teams. Anyone can request to join.
</p>
</div>
</label>
<label className="flex items-start space-x-3 cursor-pointer border rounded-lg p-4 hover:bg-gray-50">
<input
type="radio"
{...field}
value={ETeamVisibility.PRIVATE}
checked={field.value === ETeamVisibility.PRIVATE}
className="mt-1"
/>
<div>
<p className="font-medium text-gray-900">Private</p>
<p className="text-sm text-gray-600">
Team is hidden from Browse Teams. Members can only join via invitation.
</p>
</div>
</label>
</div>
)}
/>
</div>
{/* Warning */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<p className="text-sm text-yellow-800">
<strong>Note:</strong> As team leader, you cannot leave or join another team after creating this team.
</p>
</div>
{/* Submit Button */}
<div className="flex space-x-3 pt-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={() => navigate('/dashboard')}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={!form.formState.isValid || isCreating || isUploading}
>
{isCreating || isUploading ? 'Creating Team...' : 'Create Team'}
</Button>
</div>
</form>
</div>
</div>
</div>
);
};
export default CreateTeamPage;
@@ -0,0 +1,144 @@
import { FC, useState } from 'react';
import { Link, useNavigate } from 'react-router';
import { useUserMe, useMyTeams } from '@imphnen-frontend-service/service';
import { useSession } from '@imphnen-frontend-service/utils';
export const Navigation: FC = () => {
const navigate = useNavigate();
const [showUserMenu, setShowUserMenu] = useState(false);
const { data: userData } = useUserMe();
const { data: teamsData } = useMyTeams();
const { signOut } = useSession();
const user = userData?.data;
const myTeams = teamsData?.data || [];
const hasTeam = myTeams.length > 0;
const handleLogout = async () => {
await signOut();
navigate('/auth/login');
};
return (
<nav className="bg-white border-b shadow-sm">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
{/* Logo */}
<Link to="/dashboard" className="flex items-center space-x-2">
<span className="text-2xl">🏆</span>
<span className="text-xl font-bold text-gray-900">Hackathon</span>
</Link>
{/* Navigation Links */}
<div className="hidden md:flex items-center space-x-6">
<Link
to="/dashboard"
className="text-gray-700 hover:text-blue-600 font-medium transition-colors"
>
Dashboard
</Link>
<Link
to="/teams/browse"
className="text-gray-700 hover:text-blue-600 font-medium transition-colors"
>
Browse Teams
</Link>
{hasTeam && (
<Link
to={`/teams/${myTeams[0].id}`}
className="text-gray-700 hover:text-blue-600 font-medium transition-colors"
>
My Team
</Link>
)}
</div>
{/* User Menu */}
<div className="relative">
<button
onClick={() => setShowUserMenu(!showUserMenu)}
className="flex items-center space-x-3 focus:outline-none"
>
{user?.avatar ? (
<img
src={user.avatar}
alt={user.fullname}
className="w-10 h-10 rounded-full object-cover border-2 border-gray-200"
/>
) : (
<div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center">
<span className="text-gray-500">👤</span>
</div>
)}
<span className="hidden md:block text-gray-700 font-medium">
{user?.fullname}
</span>
<svg
className="w-4 h-4 text-gray-500"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M19 9l-7 7-7-7"
/>
</svg>
</button>
{/* Dropdown Menu */}
{showUserMenu && (
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-gray-200 py-2 z-50">
<div className="px-4 py-2 border-b border-gray-200">
<p className="text-sm font-medium text-gray-900">{user?.fullname}</p>
<p className="text-xs text-gray-600 truncate">{user?.email}</p>
</div>
<Link
to="/dashboard"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setShowUserMenu(false)}
>
Dashboard
</Link>
<Link
to="/teams/browse"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setShowUserMenu(false)}
>
Browse Teams
</Link>
{hasTeam && (
<Link
to={`/teams/${myTeams[0].id}`}
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
onClick={() => setShowUserMenu(false)}
>
My Team
</Link>
)}
<div className="border-t border-gray-200 mt-2 pt-2">
<button
onClick={handleLogout}
className="block w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50"
>
Logout
</button>
</div>
</div>
)}
</div>
</div>
</div>
{/* Close dropdown when clicking outside */}
{showUserMenu && (
<div
className="fixed inset-0 z-40"
onClick={() => setShowUserMenu(false)}
/>
)}
</nav>
);
};
+518
View File
@@ -0,0 +1,518 @@
const INDONESIAN_CITIES: string[] = [
'Aceh Selatan',
'Aceh Tenggara',
'Aceh Timur',
'Aceh Tengah',
'Aceh Barat',
'Aceh Besar',
'Pidie',
'Aceh Utara',
'Simeulue',
'Aceh Singkil',
'Bireuen',
'Aceh Barat Daya',
'Gayo Lues',
'Aceh Jaya',
'Nagan Raya',
'Aceh Tamiang',
'Bener Meriah',
'Pidie Jaya',
'Kota Banda Aceh',
'Kota Sabang',
'Kota Lhokseumawe',
'Kota Langsa',
'Kota Subulussalam',
'Tapanuli Tengah',
'Tapanuli Utara',
'Tapanuli Selatan',
'Nias',
'Langkat',
'Karo',
'Deli Serdang',
'Simalungun',
'Asahan',
'Labuhanbatu',
'Dairi',
'Toba',
'Mandailing Natal',
'Nias Selatan',
'Pakpak Bharat',
'Humbang Hasundutan',
'Samosir',
'Serdang Bedagai',
'Batu Bara',
'Padang Lawas Utara',
'Padang Lawas',
'Labuhanbatu Selatan',
'Labuhanbatu Utara',
'Nias Utara',
'Nias Barat',
'Kota Medan',
'Kota Pematangsiantar',
'Kota Sibolga',
'Kota Tanjung Balai',
'Kota Binjai',
'Kota Tebing Tinggi',
'Kota Padangsidimpuan',
'Kota Gunungsitoli',
'Pesisir Selatan',
'Solok',
'Sijunjung',
'Tanah Datar',
'Padang Pariaman',
'Agam',
'Lima Puluh Kota',
'Pasaman',
'Kepulauan Mentawai',
'Dharmasraya',
'Solok Selatan',
'Pasaman Barat',
'Kota Padang',
'Kota Solok',
'Kota Sawahlunto',
'Kota Padang Panjang',
'Kota Bukittinggi',
'Kota Payakumbuh',
'Kota Pariaman',
'Kampar',
'Indragiri Hulu',
'Bengkalis',
'Indragiri Hilir',
'Pelalawan',
'Rokan Hulu',
'Rokan Hilir',
'Siak',
'Kuantan Singingi',
'Kepulauan Meranti',
'Kota Pekanbaru',
'Kota Dumai',
'Kerinci',
'Merangin',
'Sarolangun',
'Batanghari',
'Muaro Jambi',
'Tanjung Jabung Barat',
'Tanjung Jabung Timur',
'Bungo',
'Tebo',
'Kota Jambi',
'Kota Sungai Penuh',
'Ogan Komering Ulu',
'Ogan Komering Ilir',
'Muara Enim',
'Lahat',
'Musi Rawas',
'Musi Banyuasin',
'Banyuasin',
'Ogan Komering Ulu Timur',
'Ogan Komering Ulu Selatan',
'Ogan Ilir',
'Empat Lawang',
'Penukal Abab Lematang Ilir',
'Musi Rawas Utara',
'Kota Palembang',
'Kota Pagar Alam',
'Kota Lubuk Linggau',
'Kota Prabumulih',
'Bengkulu Selatan',
'Rejang Lebong',
'Bengkulu Utara',
'Kaur',
'Seluma',
'Muko Muko',
'Lebong',
'Kepahiang',
'Bengkulu Tengah',
'Kota Bengkulu',
'Lampung Selatan',
'Lampung Tengah',
'Lampung Utara',
'Lampung Barat',
'Tulang Bawang',
'Tanggamus',
'Lampung Timur',
'Way Kanan',
'Pesawaran',
'Pringsewu',
'Mesuji',
'Tulang Bawang Barat',
'Pesisir Barat',
'Kota Bandar Lampung',
'Kota Metro',
'Bangka',
'Belitung',
'Bangka Selatan',
'Bangka Tengah',
'Bangka Barat',
'Belitung Timur',
'Kota Pangkal Pinang',
'Bintan',
'Karimun',
'Natuna',
'Lingga',
'Kepulauan Anambas',
'Kota Batam',
'Kota Tanjung Pinang',
'Kepulauan Seribu',
'Kota Jakarta Pusat',
'Kota Jakarta Utara',
'Kota Jakarta Barat',
'Kota Jakarta Selatan',
'Kota Jakarta Timur',
'Bogor',
'Sukabumi',
'Cianjur',
'Bandung',
'Garut',
'Tasikmalaya',
'Ciamis',
'Kuningan',
'Cirebon',
'Majalengka',
'Sumedang',
'Indramayu',
'Subang',
'Purwakarta',
'Karawang',
'Bekasi',
'Bandung Barat',
'Pangandaran',
'Kota Bogor',
'Kota Sukabumi',
'Kota Bandung',
'Kota Cirebon',
'Kota Bekasi',
'Kota Depok',
'Kota Cimahi',
'Kota Tasikmalaya',
'Kota Banjar',
'Cilacap',
'Banyumas',
'Purbalingga',
'Banjarnegara',
'Kebumen',
'Purworejo',
'Wonosobo',
'Magelang',
'Boyolali',
'Klaten',
'Sukoharjo',
'Wonogiri',
'Karanganyar',
'Sragen',
'Grobogan',
'Blora',
'Rembang',
'Pati',
'Kudus',
'Jepara',
'Demak',
'Semarang',
'Temanggung',
'Kendal',
'Batang',
'Pekalongan',
'Pemalang',
'Tegal',
'Brebes',
'Kota Magelang',
'Kota Surakarta',
'Kota Salatiga',
'Kota Semarang',
'Kota Pekalongan',
'Kota Tegal',
'Kulon Progo',
'Bantul',
'Gunungkidul',
'Sleman',
'Kota Yogyakarta',
'Pacitan',
'Ponorogo',
'Trenggalek',
'Tulungagung',
'Blitar',
'Kediri',
'Malang',
'Lumajang',
'Jember',
'Banyuwangi',
'Bondowoso',
'Situbondo',
'Probolinggo',
'Pasuruan',
'Sidoarjo',
'Mojokerto',
'Jombang',
'Nganjuk',
'Madiun',
'Magetan',
'Ngawi',
'Bojonegoro',
'Tuban',
'Lamongan',
'Gresik',
'Bangkalan',
'Sampang',
'Pamekasan',
'Sumenep',
'Kota Kediri',
'Kota Blitar',
'Kota Malang',
'Kota Probolinggo',
'Kota Pasuruan',
'Kota Mojokerto',
'Kota Madiun',
'Kota Surabaya',
'Kota Batu',
'Pandeglang',
'Lebak',
'Tangerang',
'Serang',
'Kota Tangerang',
'Kota Cilegon',
'Kota Serang',
'Kota Tangerang Selatan',
'Jembrana',
'Tabanan',
'Badung',
'Gianyar',
'Klungkung',
'Bangli',
'Karangasem',
'Buleleng',
'Kota Denpasar',
'Lombok Barat',
'Lombok Tengah',
'Lombok Timur',
'Sumbawa',
'Dompu',
'Bima',
'Sumbawa Barat',
'Lombok Utara',
'Kota Mataram',
'Kota Bima',
'Kupang',
'Timor Tengah Selatan',
'Timor Tengah Utara',
'Belu',
'Alor',
'Flores Timur',
'Sikka',
'Ende',
'Ngada',
'Manggarai',
'Sumba Timur',
'Sumba Barat',
'Lembata',
'Rote Ndao',
'Manggarai Barat',
'Nagekeo',
'Sumba Tengah',
'Sumba Barat Daya',
'Manggarai Timur',
'Sabu Raijua',
'Malaka',
'Kota Kupang',
'Sambas',
'Mempawah',
'Sanggau',
'Ketapang',
'Sintang',
'Kapuas Hulu',
'Bengkayang',
'Landak',
'Sekadau',
'Melawi',
'Kayong Utara',
'Kubu Raya',
'Kota Pontianak',
'Kota Singkawang',
'Kotawaringin Barat',
'Kotawaringin Timur',
'Kapuas',
'Barito Selatan',
'Barito Utara',
'Katingan',
'Seruyan',
'Sukamara',
'Lamandau',
'Gunung Mas',
'Pulang Pisau',
'Murung Raya',
'Barito Timur',
'Kota Palangkaraya',
'Tanah Laut',
'Kotabaru',
'Banjar',
'Barito Kuala',
'Tapin',
'Hulu Sungai Selatan',
'Hulu Sungai Tengah',
'Hulu Sungai Utara',
'Tabalong',
'Tanah Bumbu',
'Balangan',
'Kota Banjarmasin',
'Kota Banjarbaru',
'Paser',
'Kutai Kartanegara',
'Berau',
'Kutai Barat',
'Kutai Timur',
'Penajam Paser Utara',
'Mahakam Ulu',
'Kota Balikpapan',
'Kota Samarinda',
'Kota Bontang',
'Bulungan',
'Malinau',
'Nunukan',
'Tana Tidung',
'Kota Tarakan',
'Bolaang Mongondow',
'Minahasa',
'Kepulauan Sangihe',
'Kepulauan Talaud',
'Minahasa Selatan',
'Minahasa Utara',
'Minahasa Tenggara',
'Bolaang Mongondow Utara',
'Kepulauan Siau Tagulandang Biaro (Sitaro)',
'Bolaang Mongondow Timur',
'Bolaang Mongondow Selatan',
'Kota Manado',
'Kota Bitung',
'Kota Tomohon',
'Kota Kotamobagu',
'Banggai',
'Poso',
'Donggala',
'Toli Toli',
'Buol',
'Morowali',
'Banggai Kepulauan',
'Parigi Moutong',
'Tojo Una Una',
'Sigi',
'Banggai Laut',
'Morowali Utara',
'Kota Palu',
'Kepulauan Selayar',
'Bulukumba',
'Bantaeng',
'Jeneponto',
'Takalar',
'Gowa',
'Sinjai',
'Bone',
'Maros',
'Pangkajene Kepulauan',
'Barru',
'Soppeng',
'Wajo',
'Sidenreng Rappang',
'Pinrang',
'Enrekang',
'Luwu',
'Tana Toraja',
'Luwu Utara',
'Luwu Timur',
'Toraja Utara',
'Kota Makassar',
'Kota Pare Pare',
'Kota Palopo',
'Kolaka',
'Konawe',
'Muna',
'Buton',
'Konawe Selatan',
'Bombana',
'Wakatobi',
'Kolaka Utara',
'Konawe Utara',
'Buton Utara',
'Kolaka Timur',
'Konawe Kepulauan',
'Muna Barat',
'Buton Tengah',
'Buton Selatan',
'Kota Kendari',
'Kota Bau Bau',
'Gorontalo',
'Boalemo',
'Bone Bolango',
'Pahuwato',
'Gorontalo Utara',
'Kota Gorontalo',
'Pasangkayu (Mamuju Utara)',
'Mamuju',
'Mamasa',
'Polewali Mandar',
'Majene',
'Mamuju Tengah',
'Maluku Tengah',
'Maluku Tenggara',
'Kepulauan Tanimbar (Maluku Tenggara Barat)',
'Buru',
'Seram Bagian Timur',
'Seram Bagian Barat',
'Kepulauan Aru',
'Maluku Barat Daya',
'Buru Selatan',
'Kota Ambon',
'Kota Tual',
'Halmahera Barat',
'Halmahera Tengah',
'Halmahera Utara',
'Halmahera Selatan',
'Kepulauan Sula',
'Halmahera Timur',
'Pulau Morotai',
'Pulau Taliabu',
'Kota Ternate',
'Kota Tidore Kepulauan',
'Jayapura',
'Kepulauan Yapen',
'Biak Numfor',
'Sarmi',
'Keerom',
'Waropen',
'Supiori',
'Mamberamo Raya',
'Kota Jayapura',
'Manokwari',
'Fak Fak',
'Teluk Bintuni',
'Teluk Wondama',
'Kaimana',
'Manokwari Selatan',
'Pegunungan Arfak',
'Merauke',
'Boven Digoel',
'Mappi',
'Asmat',
'Nabire',
'Puncak Jaya',
'Paniai',
'Mimika',
'Puncak',
'Dogiyai',
'Intan Jaya',
'Deiyai',
'Jayawijaya',
'Pegunungan Bintang',
'Yahukimo',
'Tolikara',
'Mamberamo Tengah',
'Yalimo',
'Lanny Jaya',
'Nduga',
'Sorong',
'Sorong Selatan',
'Raja Ampat',
'Tambrauw',
'Maybrat',
'Kota Sorong',
];
export default INDONESIAN_CITIES;
@@ -0,0 +1,76 @@
import { useTeamById, useTeamMembers, useUserMe, useTeamSubmission } from '@imphnen-frontend-service/service';
/**
* Hook to check if current user is a member of the team
*/
export const useTeamMembership = (teamId: string) => {
const { data: userData } = useUserMe();
const { data: membersData } = useTeamMembers(teamId);
const currentUser = userData?.data;
const members = membersData?.data || [];
const isMember = members.some(m => m.user_id === currentUser?.id);
const currentMember = members.find(m => m.user_id === currentUser?.id);
return {
isMember,
currentMember,
memberRole: currentMember?.role,
memberStatus: currentMember?.status,
};
};
/**
* Hook to check if current user is the team leader
*/
export const useIsTeamLeader = (teamId: string) => {
const { data: teamData } = useTeamById(teamId);
const { data: userData } = useUserMe();
const team = teamData?.data;
const currentUser = userData?.data;
const isLeader = currentUser?.id === team?.leader_id;
return {
isLeader,
leaderId: team?.leader_id,
};
};
/**
* Hook to get team submission status
*/
export const useTeamSubmissionStatus = (teamId: string) => {
const { data: submissionData, isLoading } = useTeamSubmission(teamId, !!teamId);
const hasSubmission = !!submissionData?.data;
const submission = submissionData?.data;
const isSubmitted = submission?.status === 'submitted';
return {
hasSubmission,
isSubmitted,
submission,
isLoading,
};
};
/**
* Hook to check if current user can perform team actions
*/
export const useTeamPermissions = (teamId: string) => {
const { isLeader } = useIsTeamLeader(teamId);
const { isMember } = useTeamMembership(teamId);
const { hasSubmission } = useTeamSubmissionStatus(teamId);
return {
canEditTeam: isLeader,
canManageMembers: isLeader,
canSubmitProject: isLeader && !hasSubmission,
canViewTeam: isMember,
canViewSubmission: isMember,
canAccessChat: isMember,
};
};
+136
View File
@@ -0,0 +1,136 @@
@import url('https://fonts.googleapis.com/css2?family=Bai+Jamjuree:ital,wght@0,200;0,300;0,400;0,500;0,600;0,700;1,200;1,300;1,400;1,500;1,600;1,700&display=swap');
@import 'tailwindcss';
@source "../../../libs/ui/**/*.{ts,tsx}";
@theme {
--color-primary-50: #f0f8ff;
--color-primary-100: #e1f0fd;
--color-primary-200: #bce1fb;
--color-primary-300: #81cbf8;
--color-primary-400: #3eb0f2;
--color-primary-500: #23a1eb;
--color-primary-600: #0877c1;
--color-primary-700: #085f9c;
--color-primary-800: #0b5181;
--color-primary-900: #0f446b;
--color-primary-950: #0a2b47;
--color-neutral-50: #f6f6f6;
--color-neutral-100: #e7e7e7;
--color-neutral-200: #d1d1d1;
--color-neutral-300: #b0b0b0;
--color-neutral-400: #888888;
--color-neutral-500: #6d6d6d;
--color-neutral-600: #5d5d5d;
--color-neutral-700: #4f4f4f;
--color-neutral-800: #454545;
--color-neutral-900: #3d3d3d;
--color-neutral-950: #2b2b2b;
--color-success-100: #e0fbd8;
--color-success-200: #bcf8b0;
--color-success-300: #8eea85;
--color-success-400: #63d564;
--color-success-500: #35ba43;
--color-success-600: #269f3e;
--color-success-700: #1a8439;
--color-success-800: #106b32;
--color-success-900: #0b592f;
--color-info-100: #ccfcfe;
--color-info-200: #9bf3fd;
--color-info-300: #67e3fb;
--color-info-400: #42cdf8;
--color-info-500: #04acf3;
--color-info-600: #0185d0;
--color-info-700: #0264af;
--color-info-800: #01478d;
--color-info-900: #003375;
--color-warning-100: #fffcd3;
--color-warning-200: #fffaa9;
--color-warning-300: #fff67d;
--color-warning-400: #fff25d;
--color-warning-500: #ffed27;
--color-warning-600: #dbc91d;
--color-warning-700: #b7a714;
--color-warning-800: #93850b;
--color-warning-900: #7a6d07;
--color-danger-100: #ffe8da;
--color-danger-200: #ffcbb3;
--color-danger-300: #ffaa8d;
--color-danger-400: #ff8870;
--color-danger-500: #ff5242;
--color-danger-600: #da3030;
--color-danger-700: #b7212d;
--color-danger-800: #93152a;
--color-danger-900: #7a0c27;
--radius-none: 0px;
--radius-sm: 2px;
--radius-md: 4px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-3xl: 24px;
--radius-full: 50%;
--font-bai-jamjuree: 'Bai Jamjuree', sans-serif;
--text-h1: 3.833rem;
/* ~46px */
--text-h2: 3.083rem;
/* ~37px */
--text-h3: 2.417rem;
/* ~29px */
--text-p1: 1.917rem;
/* ~23px */
--text-p2: 1.583rem;
/* ~19px */
--text-p3: 1.25rem;
/* 15px */
--text-label1: 1rem;
/* 12px */
--text-label2: 0.833rem;
/* ~10px */
--text-label3: 0.677rem;
/* ~8px */
}
@layer base {
*,
::after,
::before,
::backdrop,
::file-selector-button {
border-color: var(--color-neutral-200, currentColor);
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: var(--color-neutral-50);
}
::-webkit-scrollbar-thumb {
background: #cccccc;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-neutral-300);
}
html {
font-family: 'Bai Jamjuree', sans-serif;
font-weight: 400;
font-size: 12px;
line-height: 1.2;
}
}
+46
View File
@@ -0,0 +1,46 @@
import { createRoot } from 'react-dom/client';
import { StrictMode } from 'react';
import { createBrowserRouter, RouteObject, RouterProvider } from 'react-router';
import {
add404PageToRoutesChildren,
addErrorElementToRoutes,
convertPagesToRoute,
ModalLoginProvider,
QueryProvider,
} from '@imphnen-frontend-service/utils';
import { Toaster } from 'sonner';
import './index.css';
const files = import.meta.glob('./app/**/*(page|layout).tsx');
const errorFiles = import.meta.glob('./app/**/*error.tsx');
const notFoundFiles = import.meta.glob('./app/**/*404.tsx');
const loadingFiles = import.meta.glob('./app/**/*loading.tsx');
const routes = convertPagesToRoute(files, loadingFiles) as RouteObject;
addErrorElementToRoutes(errorFiles, routes);
add404PageToRoutesChildren(notFoundFiles, routes);
const router = createBrowserRouter([
{
...routes,
// MIDDLEWARE TEMPORARILY DISABLED - causing infinite loops with React Router v7
// TODO: Implement auth checks at component level or use different pattern
// loader: middleware,
// shouldRevalidate: () => false,
},
]);
const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('Failed to find the root element');
createRoot(rootElement).render(
<StrictMode>
<QueryProvider>
<ModalLoginProvider>
<Toaster position="top-right" />
<RouterProvider router={router} />
</ModalLoginProvider>
</QueryProvider>
</StrictMode>
);
+141
View File
@@ -0,0 +1,141 @@
import { SessionUser } from '@imphnen-frontend-service/utils';
import { supabase } from '@imphnen-frontend-service/service';
import { LoaderFunctionArgs, redirect } from 'react-router';
const mappingPublicRoutes = [
'/',
];
const mappingOnboardingRoutes = [
'/onboarding/user',
];
const mappingRoutePermissions = [
{
path: '/dashboard',
permissions: [],
},
{
path: '/teams/browse',
permissions: [],
},
{
path: '/teams/create',
permissions: [],
},
];
const mappingPublicPrefixRoutes = [
'/hackathons',
];
// Cache to prevent redundant checks (cache for 5 seconds)
const onboardingCache = new Map<string, { hasLocation: boolean; timestamp: number }>();
const CACHE_DURATION = 5000; // 5 seconds
export const middleware = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const pathname = url.pathname;
console.log('[Middleware] Checking route:', pathname);
// Get session from Supabase (authoritative source)
const { data: { session: supabaseSession }, error: sessionError } = await supabase.auth.getSession();
// Handle session errors
if (sessionError) {
console.error('[Middleware] Session error:', sessionError);
// Don't redirect on session errors, let the app handle it
}
// Allow to access the hackathon pages without authentication
if (mappingPublicPrefixRoutes.some((prefix) => pathname.startsWith(prefix))) {
return null;
}
// Public routes - allow everyone to view the landing page
if (mappingPublicRoutes.includes(pathname)) {
return null;
}
// Auth callback - allow without authentication check (for OAuth callback)
if (pathname === '/auth/callback') {
return null;
}
// Auth routes (all /auth/* paths) - redirect to dashboard if already authenticated
if (pathname.startsWith('/auth')) {
if (supabaseSession) return redirect('/dashboard');
return null;
}
// Require authentication for all other routes - ONLY check Supabase session
if (!supabaseSession) {
return redirect('/auth/login');
}
// Check if user has completed onboarding by querying database (not localStorage!)
// Skip onboarding check for onboarding routes themselves
if (!mappingOnboardingRoutes.includes(pathname)) {
try {
const userId = supabaseSession.user.id;
const now = Date.now();
// Check cache first
const cached = onboardingCache.get(userId);
let hasLocation = false;
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
console.log('[Middleware] Using cached onboarding status');
hasLocation = cached.hasLocation;
} else {
console.log('[Middleware] Fetching fresh onboarding status');
const { data: userData, error: userError } = await supabase
.from('users')
.select('location')
.eq('id', userId)
.single();
if (userError) {
console.error('[Middleware] Failed to fetch user data:', userError);
// If we can't fetch user data, allow access (don't break the app)
return null;
}
hasLocation = !!userData?.location;
// Update cache
onboardingCache.set(userId, { hasLocation, timestamp: now });
}
if (!hasLocation) {
return redirect('/onboarding/user');
}
} catch (error) {
console.error('[Middleware] Unexpected error checking onboarding:', error);
// On error, allow access (fail open)
return null;
}
}
// Check route permissions using fresh user data from Zustand (for UI metadata)
const session = SessionUser.get();
const userPermissions =
session?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
const matchedRoute = mappingRoutePermissions.find(
(route) => route.path === pathname
);
if (matchedRoute) {
const hasPermission =
!matchedRoute.permissions ||
matchedRoute.permissions.some((perm) => userPermissions.includes(perm));
if (!hasPermission) {
return redirect('/dashboard');
}
}
return null;
};
+27
View File
@@ -0,0 +1,27 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"node",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts",
"vite/client"
]
},
"exclude": [
"src/**/*.spec.ts",
"src/**/*.test.ts",
"src/**/*.spec.tsx",
"src/**/*.test.tsx",
"src/**/*.spec.js",
"src/**/*.test.js",
"src/**/*.spec.jsx",
"src/**/*.test.jsx",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts"
],
"include": ["src/**/*.js", "src/**/*.jsx", "src/**/*.ts", "src/**/*.tsx"]
}
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"allowJs": false,
"esModuleInterop": false,
"allowSyntheticDefaultImports": true,
"strict": true,
"types": ["vite/client", "vitest"]
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.spec.json"
}
],
"extends": "../../tsconfig.base.json"
}
+30
View File
@@ -0,0 +1,30 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest",
"@nx/react/typings/cssmodule.d.ts",
"@nx/react/typings/image.d.ts"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
]
}
+42
View File
@@ -0,0 +1,42 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../node_modules/.vite/apps/hackathon',
server: {
port: 3004,
host: 'localhost',
},
preview: {
port: 3005,
host: 'localhost',
},
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: [ nxViteTsPaths() ],
// },
build: {
outDir: '../../dist/apps/hackathon',
emptyOutDir: true,
reportCompressedSize: true,
commonjsOptions: {
transformMixedEsModules: true,
},
},
test: {
watch: false,
globals: true,
environment: 'jsdom',
include: ['src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../coverage/apps/hackathon',
provider: 'v8' as const,
},
},
}));
+1
View File
@@ -0,0 +1 @@
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
+1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./../../dist/apps/landing/.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+1 -1
View File
@@ -3,7 +3,7 @@ const path = require('path');
/** @type {import('@nx/next/plugins/with-nx').WithNxOptions} */
const nextConfig = {
nx: { svgr: false },
nx: {},
output: 'standalone',
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

@@ -34,7 +34,7 @@ export function CommunitySection() {
opacity: 1,
transition: {
duration: 0.4,
ease: [0.25, 0.46, 0.45, 0.94],
ease: [0.25, 0.46, 0.45, 0.94] as any,
},
},
};
@@ -30,7 +30,7 @@ export function TestimonialSection() {
opacity: 1,
transition: {
duration: 0.4,
ease: [0.25, 0.46, 0.45, 0.94],
ease: [0.25, 0.46, 0.45, 0.94] as any,
},
},
};
@@ -24,32 +24,37 @@ export default function Footer() {
</p>
<div className="flex space-x-4">
<Link
href="#"
href="https://fb.com/groups/programmerhandal"
className="text-muted-foreground hover:text-foreground"
target="_blank"
>
<FaFacebook className="size-6" />
</Link>
<Link
href="#"
href="https://discord.gg/imphnen"
className="text-muted-foreground hover:text-foreground"
target="_blank"
>
<FaDiscord className="size-6" />
</Link>
<Link
href="#"
href="https://www.instagram.com/imphnen.dev"
className="text-muted-foreground hover:text-foreground"
target="_blank"
>
<FaInstagram className="size-6" />
</Link>
<Link
href="#"
href="https://www.tiktok.com/@imphnen"
className="text-muted-foreground hover:text-foreground"
target="_blank"
>
<FaTiktok className="size-6" />
</Link>
<Link
href="#"
href="https://www.linkedin.com/company/imphnen"
className="text-muted-foreground hover:text-foreground"
target="_blank"
>
<FaLinkedinIn className="size-6" />
</Link>
@@ -78,6 +83,7 @@ export default function Footer() {
<Link
href={link}
className="text-sm text-muted-foreground hover:text-foreground"
target="_blank"
>
{name}
</Link>
+20 -1
View File
@@ -6,8 +6,27 @@ import { Providers } from './_components/providers';
import { Toaster } from './_components/toaster';
export const metadata: Metadata = {
title: 'IMPHNEN - Ingin Menjadi Programmer Handal?',
title: 'IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan Ngoding',
description: 'Komunitas belajar programming untuk semua level',
openGraph: {
title: 'IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan Ngoding',
description: 'Komunitas belajar programming untuk semua level',
url: 'https://imphnen.dev',
images: [
{
url: 'https://imphnen.dev/imphnen-group-cover.webp',
alt: 'IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan Ngoding',
width: 2550,
height: 945,
},
],
},
twitter: {
card: 'summary_large_image',
title: 'IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan Ngoding',
description: 'Komunitas belajar programming untuk semua level',
images: ['https://imphnen.dev/imphnen-group-cover.webp'],
},
};
export default function RootLayout({
+5 -5
View File
@@ -4,34 +4,34 @@
"icon": "FaFacebook",
"color": "#1877F2",
"description": "Join discussions and share knowledge",
"link": "#"
"link": "https://fb.com/groups/programmerhandal"
},
{
"name": "Discord",
"icon": "FaDiscord",
"color": "#5865F2",
"description": "Real-time collaboration chat",
"link": "#"
"link": "https://discord.gg/imphnen"
},
{
"name": "Instagram",
"icon": "FaInstagram",
"color": "#E4405F",
"description": "Daily coding tips & showcases",
"link": "#"
"link": "https://www.instagram.com/imphnen.dev"
},
{
"name": "TikTok",
"icon": "FaTiktok",
"color": "#000000",
"description": "Short coding tutorials",
"link": "#"
"link": "https://www.tiktok.com/@imphnen"
},
{
"name": "LinkedIn",
"icon": "FaLinkedin",
"color": "#0A66C2",
"description": "Professional networking",
"link": "#"
"link": "https://www.linkedin.com/company/imphnen"
}
]
+1 -2
View File
@@ -1,10 +1,9 @@
version: '3.8'
services:
backoffice:
build:
context: .
dockerfile: docker/backoffice.Dockerfile
ports:
- "3001:80"
- '127.0.0.1:3001:80'
restart: unless-stopped
container_name: backoffice
+1 -2
View File
@@ -1,10 +1,9 @@
version: '3.8'
services:
dimentorin:
build:
context: .
dockerfile: docker/dimentorin.Dockerfile
ports:
- "3002:80"
- '127.0.0.1:3002:80'
restart: unless-stopped
container_name: dimentorin
+1 -2
View File
@@ -1,10 +1,9 @@
version: '3.8'
services:
gacha:
build:
context: .
dockerfile: docker/gacha.Dockerfile
ports:
- "3003:80"
- '127.0.0.1:3003:80'
restart: unless-stopped
container_name: gacha
+17
View File
@@ -0,0 +1,17 @@
services:
hackathon:
build:
context: .
dockerfile: docker/hackathon.Dockerfile
args:
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
ports:
- '127.0.0.1:3004:80'
restart: unless-stopped
container_name: hackathon
env_file:
- .env.local
environment:
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
+1 -2
View File
@@ -1,10 +1,9 @@
version: '3.8'
services:
landing:
build:
context: .
dockerfile: docker/landing.Dockerfile
ports:
- "3000:3000"
- '127.0.0.1:3005:3000'
restart: unless-stopped
container_name: landing
+1 -1
View File
@@ -1,7 +1,7 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
RUN npm ci --prefer-offline --no-audit --no-fund
FROM node:22-alpine AS builder
WORKDIR /app
+1 -1
View File
@@ -1,7 +1,7 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
RUN npm ci --prefer-offline --no-audit --no-fund
FROM node:22-alpine AS builder
WORKDIR /app
+1 -1
View File
@@ -1,7 +1,7 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm install
RUN npm ci --prefer-offline --no-audit --no-fund
FROM node:22-alpine AS builder
WORKDIR /app
+25
View File
@@ -0,0 +1,25 @@
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --prefer-offline --no-audit --no-fund
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Accept build arguments
ARG VITE_SUPABASE_URL
ARG VITE_SUPABASE_ANON_KEY
# Set environment variables for build
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
RUN npm run hackathon:build
FROM nginx:alpine AS runner
RUN rm -rf /usr/share/nginx/html/*
COPY --from=builder /app/dist/apps/hackathon /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+2 -1
View File
@@ -1,8 +1,9 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --prefer-offline --no-audit --no-fund
COPY . .
RUN npm install
RUN npm run landing:build
FROM node:22-alpine AS runner
+516
View File
@@ -0,0 +1,516 @@
email,fullname,id
"rrrijal24@gmail.com","Rijal","0081ce77-2dd2-469c-8759-8d1259b6206c"
"zulhamrianto@gmail.com","","0176361f-e403-4aeb-a683-8aa58d1e51a4"
"akbarkurniawan790@gmail.com","Akbar Kurniawan","017917c0-7393-4620-9d17-bc8467f9c521"
"auliarestyazizah@gmail.com","","0293026a-dfbe-4380-89fa-e2d5729a5cea"
"alhadad.xyz@gmail.com","alhadad","0308f526-0dfe-4509-bee8-4aa217ec0a12"
"radithamzah12@gmail.com","dorrit","032c630c-8a20-40ec-b191-ba8e009ab538"
"pamungkas.ajiseno@gmail.com","Senoaji Pamungkas","0331921a-4009-4204-86af-5eb90d6773cf"
"gamingarisky@gmail.com","","05004e6b-9d5c-4a14-af97-496bcd55878b"
"bagoesrex@gmail.com","Bagus","058da640-ac05-498b-8618-f2c5a4455b90"
"rizkyseptian401@gmail.com","Moc Rizky Septian","05d98092-8528-4edf-8b23-9134d67f628b"
"excellchriatian12@gmail.com","Excell Christian","06f7158e-2218-4b61-8129-899bdaeb1eab"
"sofyanekafebriyanto@gmail.com","","07138a28-5e15-4c08-9325-0b17061d913f"
"baraskyke7@gmail.com","Barasky","07203a89-c58b-4df2-aeba-54b4977c8fc6"
"cukd3h.hynix@gmail.com","","073af6af-33aa-4067-820c-2213df0843b0"
"tsqf.h29@gmail.com","Muh Tsaqif Hafidz","073bb0a3-69b5-4b4d-8d47-e30b38690ef5"
"azranmu@gmail.com","M ASRAN","078e1f86-6ca1-4fc2-ab9d-a600a6c5adf0"
"gdsudiartika@gmail.com","Diar Sudiar","08b7eb15-dd74-4704-b8ec-d4e7dbc37c6e"
"alfiansa@proton.me","Aditia Akbar Putra A.","095a9662-4d57-4c76-ae28-8f3685a01d34"
"ichaaulia720@gmail.com","","09ab46a0-1394-4e41-9938-8f3df7f8bf99"
"naziq02@gmail.com","","09cacf8e-467d-4885-a792-11287b68d626"
"wahyuskull22@gmail.com","Wahyu SA","09dd11d7-4dae-4273-ba66-e313d82c7d52"
"sulthanzain28@gmail.com","Sulthan .Y","0a28758d-fbc3-4979-93ae-33011b3a9691"
"ivan04.android@gmail.com","","0a48d044-6e36-46b6-bd4e-bbf7d3f9519e"
"muklasputra222@gmail.com","Muklas Adi Putra","0ab605f9-d525-4e1b-b1ca-7b21da065aff"
"rojafadilah0203@gmail.com","Roja Fadilah","0b380e5f-26dc-41f0-914d-731d7ca00603"
"akmalkeisin@gmail.com","","0b55c37c-48ce-4721-a7bb-52d9913dfcee"
"bimasaktiramdani01@gmail.com","","0c1ddd78-5f93-4272-953b-4640cc769ab9"
"gajipgaming@gmail.com","Galih Aji Pangestu","0cb2e0f9-6afe-458b-afc9-3170bcc18fa4"
"pascalsanjaya34@gmail.com","Pascal","0d4f3960-509d-4ba4-8d03-5fc1c38a4071"
"hanifdwyputrasembiring@gmail.com","Hanif Dwy Putra S","0e87a0f3-5a96-4b87-8e04-e180ed2731ca"
"afreezap20@gmail.com","","0e9fcc3c-4a3f-43c9-bf4f-e4e4c342f7a0"
"profyonz@gmail.com","","0eb6f8b2-f10f-4fa7-bf66-a43ef14a8f63"
"kesavadananjaya@gmail.com","","0ec815c3-a256-4583-909d-b02655d7d09a"
"mdwikurniawan976@gmail.com","","0f0e2cb8-2c21-417a-a577-f5d0662a2efa"
"rickyrahmad2001@gmail.com","","0fa03bff-4be2-4c13-b687-688191c02955"
"dimasmaulanaichsan@gmail.com","Dimas Maulana Ichsan","0fc6770d-fac7-4118-8796-6cae1b18f880"
"rikiulir@gmail.com","Riki Ulir Niam","0fc99cb4-4495-405e-8b2f-3728404d7615"
"atmasusanto08@gmail.com","","100b63a9-7487-4553-b45d-afb2201e4492"
"alvarelkevin12@gmail.com","Muhammad Kevin Alvarel","114bebc7-2422-4c0a-9898-521f94739e7f"
"rayid1805@gmail.com","","118b7324-bfc1-45f7-9282-d0d7fee3510f"
"sjr16173@gmail.com","M. Salman Alfarisi","11dc3951-1a07-4937-bd75-2c29d2770c13"
"mhmmdarifrs@gmail.com","Muhammad Arif Rahmad","134cd84e-4d79-474a-8ccc-09d728ad3f61"
"2310512054@mahasiswa.upnvj.ac.id","Rafki Aulia Hazli ","15154e34-3c02-426a-b02e-003278334a29"
"fathamarizkinur@gmail.com","","151fa86a-5cec-4758-9824-a9d2ecd5385c"
"risyadraflan14@gmail.com","","1552a593-f8e3-4c80-833b-4c5017dea18f"
"fauziferdiansyahindo@gmail.com","","16a7eda3-4296-4754-b88e-dcf4cf61a0eb"
"amuhajir3126@gmail.com","MamadNewbie","16b7a5ea-e664-4de5-86f1-578b5f3cd28c"
"fdvkey@gmail.com","Bowo Pratama","1730c82d-369d-486d-b7cf-887be5383f8e"
"wahyusiakrom238@gmail.com","ademaswahyu","173e56ab-92cc-48dd-9b03-dc8fdca13fde"
"abiyuaflah135@gmail.com","Abiyu Aflah","18405a8a-9016-4db7-97fa-26ab32735df2"
"feryaryahidayat@gmail.com","Fery","19355ab7-f1cb-47c5-9d2d-5eeb9ee4dbff"
"rokimiftah@gmail.com","Roki Miftah Kamaludin","1973ee9d-2e18-46bc-b8df-272d61980a50"
"glen.tbjs@gmail.com","Jeki","197d5875-dc6f-4068-844d-657db670babc"
"khulafamanagement@gmail.com","","198b0b9f-a34d-410f-87cd-d19cfe02ff0c"
"12350110343@students.uin-suska.ac.id","","1a79c0ce-9119-4067-9f00-c91dd01db95e"
"rizalalfadlil@gmail.com","Hafidz Rizal Al-Fadlil","1aabe9b4-297a-4422-b01f-1b02369dc401"
"razinsyakib@gmail.com","Muhammad Razin Syakib","1b473465-ea92-4b48-9c2a-43c67cf45e01"
"dodysamarinda@gmail.com","","1b4cc185-9532-49b2-b0ac-9f03c1dcc045"
"superaseph@gmail.com","","1b97626f-bb01-457b-9638-7576e5af1683"
"ku.anime115@gmail.com","Muhamad Wisnu","1c88251f-951a-4470-a2eb-58118a75ee75"
"faizaalfa250@gmail.com","","1c903d4e-a741-42f9-b27c-37c5d4e9a704"
"wandgenius6@gmail.com","maulana adam","1d209d21-ef69-4bab-8796-cfa4cbf1fa20"
"aristiov@gmail.com","Aristio","1e0fd9f8-e7cc-4381-939d-0f1aac4a0510"
"adamabdillah20@gmail.com","Adam Abdillah","1e38dac1-a94c-4ac1-9ee8-8a53629e9be4"
"simarmatarafael11@gmail.com","","1ea5a152-5463-44b0-975a-3ed019e804b8"
"kurniadikiki508@gmail.com","","21054038-79d8-49a8-8daf-ca4e780cc09a"
"alif.ayana48@gmail.com","Alif Muhammad","21fe034a-a9f6-417d-a6ed-e8c26f3005b8"
"minanabdillah4@gmail.com","","22c19165-8c32-45f6-9e85-683a4aeaa2ec"
"gammertag31@gmail.com","","236035ab-a274-4f6f-869d-6aaaf761e659"
"abangiqi@gmail.com","","240ff65c-897c-40e7-aafd-d2dd1114ea66"
"meftah.mafazy@gmail.com","","27a09ee3-2c61-4b38-a9f9-1f2810488131"
"islahulkafaa@gmail.com","","289c5f8c-5e0f-4bca-8f66-7641d14a7bb0"
"zaidanabdulaziz03@gmail.com","Zaidan Abdul Aziz","29049370-3d6f-4ae3-b44a-fe9d4f6edb89"
"adigintings@gmail.com","Adi Primanda Ginting","2935f1f9-c070-422f-b9e0-b378b16a32c8"
"fadhil06akbar@gmail.com","","29ae041b-b96c-45d0-a0e2-256ccd8b2168"
"ilhamfiqri37@gmail.com","","29f3fcaa-9104-494e-844e-79d827f74a06"
"riski01.pku@gmail.com","","2a57227d-2920-4839-8849-93ef38a41b9e"
"bisnis.huruhara@gmail.com","","2ae31e65-4272-431b-bbc1-59158cc7cf11"
"luthlirik@gmail.com","luthfi","2b47f090-1c0f-4982-8893-c0b1af1409e6"
"zackyhokya045@gmail.com","","2bb1bf7f-cffb-4b1c-9171-a7e653fb6541"
"bayanalamahul3@gmail.com","","2c228e47-a6ad-47c4-984f-99b6e5e8a13d"
"nizaram4dhan@gmail.com","Nizar Alif Ramadhan","2cd4994b-ba50-43c1-9d78-f4311ef31108"
"fadhilakbar0612@gmail.com","","2cf73de4-90a4-4d1b-a8f0-748ab0a621e7"
"aryhidayatfebriana@gmail.com","Rizky Reza","2d0a00a8-2660-4b60-8de6-f85a6ce98b1d"
"hadiidandri2000@gmail.com","","2ea30f2a-fbeb-4af6-b727-bebb0b687939"
"ahmadirafif7@gmail.com","Rapip","2f08b18b-bd87-4d20-9923-26f4759a4b6e"
"rve27github@gmail.com","Radika","2f788a66-e34e-466a-bbfd-3de920711223"
"fikricf147@gmail.com","","311d81bf-5b5e-4519-a33b-cdd0ee0ecc82"
"ausathdzil@gmail.com","Ausath Ikram","31a21f59-ecea-4bea-9a7b-56dc92c00a12"
"jono210678@gmail.com","","34298723-449f-4b95-b6cb-1d7d5ab71540"
"rheatkhs@gmail.com","Febiadi Wisnu Akbar","34bad6e8-3372-4634-ac93-b10f8882bbb2"
"sntdashi@gmail.com","rawrzn","35159d0a-33cd-4c64-a36d-d37c97e595db"
"muhammadhidayat0278@gmail.com","","351fe76c-5a5c-4d5c-a546-f0dd7ccfec0f"
"111202213986@mhs.dinus.ac.id","","35930877-5702-4eab-9d47-87ccf4035583"
"khairulumam950@gmail.com","khairul umam","3631e3e5-3eac-4297-af3c-6d8648510471"
"muhammad.jibrilmuqoddas@gmail.com","Murphy Lawden","369f03f6-dbd3-49a0-af32-ebfb527624b3"
"rafibudiansyah@gmail.com","","37844c0a-f42b-4bdb-9731-ab9722de70dc"
"tongkorongankostpaksutha@gmail.com","","37d7c1ea-d063-4ceb-93ad-9c9784744516"
"gilangsukmw@gmail.com","","38a5bd5f-c462-424b-986b-db04df906813"
"muhammadhaikalaziz28@gmail.com","Kaal","38a738a0-3329-4001-bf88-05d4de6b11d3"
"cimicimicilung4868@gmail.com","Eka Revandi","38cce9e2-df1d-43a5-bf45-595e25744e16"
"rizqybelajar8@gmail.com","","397f8039-f1c9-42ee-88de-b9597caae518"
"syaefulloharnas16@gmail.com","Syaefulloh Arnas","398ae3d5-349e-44fd-9428-f9335f89c511"
"oppokunew19@gmail.com","","39d68abb-2502-4330-b475-51416bf5269c"
"hrfahrizzal@gmail.com","","3ade0045-eb6d-43c8-8781-ea8d281a464e"
"rizkyfadillah0890@gmail.com","Rizky Fadillah","3b04b506-ff39-4c2f-9f8f-182c62e4b6f4"
"syahidizanagi@gmail.com","","3b2fb3a3-9b55-4338-bf09-8952db4371c0"
"ryanfebrian729@gmail.com","Ryan Feb","3b467fcb-af79-4031-85f8-3073e9343058"
"hajopan12@gmail.com","","3b4c66b3-4ec3-465b-998c-32c9ab7bf2b4"
"imaim3024@gmail.com","AimZ","3b5a0b3a-5930-4cbc-ac78-b1d9d20ac460"
"rafligamercratf@gmail.com","rafly","3b901d8e-7c96-483d-b299-98c8e54f5b57"
"jojohyperbackend@gmail.com","ZeeHyper02","3b9e0c60-5a32-4d1c-b011-46390971b67f"
"ihenry@gmail.com","","3c96f60e-d8ab-4e2d-86f6-6a7f4b40c179"
"dimsartz021@gmail.com","Dimas Tri M","3cdffe97-dad4-47e3-825d-d9c13ae815b5"
"fathurrahman081296@gmail.com","Fathur Rahman","3da30612-1885-423f-a836-3cc7ca33add9"
"hafidnurazis@gmail.com","Hafid Nur","3e20b9ee-f566-4b0f-9e4f-cbb92d31031c"
"helmiputra265@gmail.com","","3f60fb92-3440-4f61-82fe-192c6bcdb301"
"farhansyamsuddin3@gmail.com","","3fc8adf4-a75a-4272-a431-a9b614e8dbd3"
"fauzanabiyan33@gmail.com","Hoshino Abydos ᴾᴿᴼ▄︻デk̷n̷e̷a̷f̷══━一","3ff2ae42-a561-4338-ba61-f444409322c3"
"bagusindrayanaindo@gmail.com","Bagus Indrayana","40194320-d827-4a53-bb8f-36303a6713a2"
"daunn871@gmail.com","Daun","41412e38-3b07-464f-93a4-457ed1cf6176"
"muhammadirfanbaihaqi538@gmail.com","","4155a53f-252e-4b44-a951-851ea5cce606"
"mujahidislami030@gmail.com","Mujahid Islami Primaldi Abdullah","41899d5b-9f1f-4ca9-8be8-9837ea83f493"
"muhammadkevinalvarel@gmail.com","","418f738b-2187-4432-957d-15eb258a65bb"
"galangryandana@gmail.com","","433c45f0-1d8e-442e-95c8-bd9c7b97108e"
"nasruladitri2@gmail.com","Nasrul Aditri","43ea0f85-1b69-4235-ae25-523ad1984d46"
"muhammadariefcp@gmail.com","Muhammad Arief C P","43f5ef95-ac4b-4d5e-bf57-b6bf0c30936d"
"adityamaullana234@gmail.com","Aditya Maulana zidqy ","44045f03-964c-4935-9918-cddc36676278"
"g4mless@proton.me","g4mless","44192093-fadb-4be9-8662-d02e9c47a1b3"
"justgilang99@gmail.com","","46769f5b-f33f-4777-aff1-9c25b9c488e8"
"jezudu@denipl.net","","46a4954b-1b91-4647-a5ba-376bbc299402"
"alfisahri33@gmail.com","alfi sahri","473b0a56-7583-43e6-8511-2e73889e9b37"
"mraihanaf0@gmail.com","Muhammad Raihan Al Farizy","47866d52-c02c-44d9-a89d-76ee1103eed9"
"obenpasaribu@gmail.com","","47e2dd3f-339f-4ce5-aa42-4d89a1fd4aea"
"2206073@itg.ac.id","ZaenalSyamsyulArief","48a70338-aa6b-4f22-b5b1-458da13d85c6"
"ronyronny12@gmail.com","","4b5b6fd7-4f79-412b-967e-0437d3c4c6af"
"malfathiratir@gmail.com","","4bda0567-2a5a-4fda-8d8f-a5998e9d8800"
"lakazamlak02@gmail.com","","4c4abd7a-772d-49fe-977a-57a917adbe6a"
"nazedev@gmail.com","","4cdb4fd8-565e-47c7-9559-215a31f4bd1c"
"revelcahyadi2004@gmail.com","Revel Cahyadi","4d2efba3-a0dd-448e-9a40-a7395a15f863"
"pulungwicaksono2006@gmail.com","Pulung Wicaksono","4d9a29b1-080e-47d0-836b-f33dfbf84c6c"
"ferospedrosa@gmail.com","","501c795f-fd27-4f86-900e-fbbbd02474a7"
"bilgrandov9902003@gmail.com","","503377c4-26af-4890-82f1-a3619953b7b7"
"gbrnmhd89@gmail.com","Gilbran Mahda","509825be-9d84-451e-9833-f2bbd5507ade"
"jevonit252@gmail.com","","512805dd-507c-4193-adad-a1cf5f76a777"
"iwank31052003@gmail.com","Iwan Kurniawan","516deb0d-9ec1-4776-835f-6daba29e7c1b"
"rifanajieagung48@gmail.com","","5182dafb-4a12-4bcc-b967-6ea50aefe7d0"
"anata@yopmail.com","","51832f14-1e08-4543-b1a0-d8107573efbc"
"fajargustiana2@gmail.com","Fajar Gustiana","51896ad3-8184-4e4a-a9c8-4eaa39ac8451"
"raflyazizabdillah30@gmail.com","","51bb6cfe-bd23-4ef9-9992-96b4e1c3a7ad"
"patrickmarcel65@gmail.com","Patrick Marcel","51d0ed9b-5d7c-400e-a75e-b3777dae5be8"
"auranawamadani@gmail.com","Aura N,A","529c4fa4-4894-4c88-ba63-857532f7fef3"
"ayamkucing129@gmail.com","Elang","52b81c76-fa09-4e96-8dfd-f405c5c09808"
"ayyasi52@gmail.com","","543eced6-00b4-4227-82b0-13e626fb1c88"
"itsandka@gmail.com","","548a310d-05a7-4b64-9d5b-032d848446fc"
"ryanda.valents3649@student.unri.ac.id","Ryanda Valents Anakri","54db9209-843c-465c-82c2-d8c4ea72dc7e"
"noaa277@gmail.com","Karakaira","54e0cfb9-55b7-4c00-812f-f04296c11a70"
"larasrahm22@gmail.com","","550fc044-10bd-49a1-9c09-50bd4d5325cc"
"zrbagus5@gmail.com","Bagus","5568477e-e7a0-4a93-b1de-0e775414111a"
"jonfrymarbun@gmail.com","Jonfry Agung Marbun","56f8b4f6-f31e-4faf-87f6-acc8d83bd72d"
"andika@umpo.ac.id","","5768d665-77a3-4ae3-b2ee-37e408c0e98e"
"snarsaggaf@gmail.com","pii","576d1650-71c8-4a5c-81c9-dda3284c13c4"
"azhararizkyrafi@gmail.com","Rizky Rafi Azhara","58871899-6d54-4cd5-bbd0-3d9e2cbe5f9f"
"safuzi333@gmail.com","","58ced2da-dd5c-42da-acd5-0082b5e0e835"
"putrasyaddad@gmail.com","Syaddad Raihan Putra","59d3324c-a264-4f60-91ca-96d9d5def00a"
"bryanfernandoks11@gmail.com","Bryan Fernando Kurniawan Suhartono","5a48dc88-25c8-49e8-8a74-36b493584aa8"
"saehansa@gmail.com","","5a8cf51a-fa8a-44ce-ad1a-898c8487513f"
"kontakpribadiatsushi@gmail.com","","5ab3bd8c-27a2-4552-8f0c-98370dff4e3b"
"mohkaisanalkalbi@gmail.com","","5c15338b-c4a0-4071-ab67-c8f29ffaadaa"
"bayuardana213@gmail.com","","5dc2beb9-46c1-4920-9d07-9708de896778"
"danufebriansyah09@gmail.com","Danu Febriansyah","5e0d9e2e-5ced-4f94-9797-b8083778fe0e"
"pjuanda12@gmail.com","","5e91489c-cacf-4050-b46b-4327255ec74a"
"muzammilulmuttaqin11@gmail.com","","5ee5672c-dba1-42b7-b8f9-e76ba4efd21a"
"raffida2013@gmail.com","Raffi Deas Alvaro","5f46ecdb-f025-4fc6-a981-dd4c4c93a52a"
"markusdamar2958@gmail.com","Damar","5f475128-017f-4021-961c-4d58eef63d99"
"felixcyro008@gmail.com","","5fb48143-4d2c-4d71-bf9a-bd9f44647de6"
"hazelbigbosshazel@gmail.com","","5fecab2f-2382-404e-8f8f-ed031332b886"
"mhd.rijal203@gmail.com","Muhamad Rijal","60fbc970-2079-42c8-8dcb-637c466a7f1c"
"imovic004@gmail.com","movic","61c5d3a0-553f-474d-9950-483cb86057df"
"samuelmarubamanik@gmail.com","","625aa73b-69db-473f-baad-6791ff865e40"
"muhammad.fahri137296@smp.belajar.id","","63a19768-b9c4-480d-95c5-2770393935fd"
"me@anym.dev","","63a87b68-4dc0-41c8-a99a-1335c77dd4e0"
"kithly19@gmail.com","","64428bf7-052f-4b7b-95ca-802f76e93dcc"
"raissatristan14@gmail.com","","645e0a22-a055-4cba-a007-37bd9791f882"
"fairuzfuadi123@gmail.com","","653f2a62-66c5-4eae-a7c5-822a85cd2d9b"
"ahdan.abde@gmail.com","Ahdan Abde Rifai","65d6021e-a03c-4661-a3eb-5452cc89b588"
"ivanrahmat.p77@gmail.com","Ivan Rahmat Prakasa","65d79307-2b78-48af-a9d8-750e66126401"
"hamzahbaik9@gmail.com","","65efcb5a-13e3-4552-9336-f7b68705e521"
"andrianxyz82@gmail.com","","6677b5ae-3171-428d-aa61-22464b1ba80f"
"billysyahputra630@gmail.com","Billy","66dbdf3d-3cf0-4d81-810e-185e61b952b7"
"yusri.irfani@gmail.com","","6773b841-2037-4aee-ace1-a8bea7341e25"
"reiyhan.aditya@gmail.com","Rey Panda","68876807-64ed-49f9-91d4-d7f68eb0e6c9"
"hexorzplay@gmail.com","","689872ad-856d-40f6-9d87-86d580394fb4"
"codewithwan@gmail.com","","699646d9-192a-451f-99d1-46d4a42e4eec"
"mtaufiksaadi28@gmail.com","","6a9264f2-2731-4e4a-8e29-9fc1a505e6dc"
"alamsantoso.12@gmail.com","Emowmow","6ab9934d-8f77-4e32-b4db-d09222fa4e44"
"kunengrestu@gmail.com","","6ae98213-5e3a-41c7-b356-03ae6760993d"
"andikacahargen2@gmail.com","Anka Tama","6b62be2c-912d-4809-af64-08ff52aef4b8"
"rmz4mc7wg6@privaterelay.appleid.com","","6bab0fab-6c05-4ed5-9d4d-bbabaf62b513"
"handikacoco@gmail.com","","6bd01f53-7884-40a2-aeb4-34b000120088"
"riziqliliulilalbab@gmail.com","RIZIQ LILI ULIL ALBAB","6d10db2d-3ac2-4008-90dd-141311219c53"
"syisahi4696@gmail.com","","6dd8c78b-9884-4944-b2d4-de2bb8711da6"
"akhmadwibu05@gmail.com","akhmadkhoirudin","6e32e4d3-25e7-47d5-97b5-60955969e22f"
"andika.namikaze02@gmail.com","Andika Fitra Darmawan","6eae5e60-544e-4bf2-81af-7b1138ae967a"
"luhalisarafansyah@gmail.com","","6eb155d0-aea1-41ca-bcdf-7effa9912366"
"novianybl@gmail.com","Novian Project","6ebfa0ad-fde4-42f9-9d0f-8e51e465d534"
"boostlivious@gmail.com","Raynaldi Siahaan","6f089bd4-8829-4a62-8c06-104319116e93"
"rasyadbimasatya04@gmail.com","Rasyad Bimasatya","6f237adc-f724-424c-baf1-4d13f03887e1"
"rezyaindisaputra@gmail.com","","6f43758c-224e-4d9d-9273-f3a3df51a11c"
"rahardisalim23@gmail.com","","6f8a13c1-3836-4715-9181-dc21314cd8af"
"alfin.rozzaq.270206@gmail.com","Alfin Rozzaq Nirwana","6f974b20-6309-4b44-9c7c-165d4cb458c9"
"muhammad.toffazal00@gmail.com","","7055961a-7b40-49d9-ace2-6ce7ad48f14d"
"raaflynr@gmail.com","","706b67c2-e410-4143-a7fa-213d35178db4"
"kujouarisu@gmail.com","","7216ca14-7cff-4d0b-b8c9-70427af21b4a"
"rohimsaga7@gmail.com","","722989b5-f685-4fc3-9bc7-76f04c479d49"
"rayhanlubis01@gmail.com","Rayhan Lubis","72bf1d04-8b95-4f90-a88f-f78c0571d841"
"raflyirham1005@gmail.com","Rafly Irham Safatulloh","73404aea-a007-4629-b9dc-fb26cb307e57"
"sopiadi2006@gmail.com","","7364d4fa-f6f7-429f-a95d-f6775eca6d9f"
"haydar130305@gmail.com","","7462e4b2-b446-4562-9bc8-e08c07d157ef"
"arilindra21@gmail.com","Moch. Aril Indra Permana","748de15d-374a-4756-89e2-75754e90542f"
"gundowijoyo7@gmail.com","bahaywuj","74bd7b16-a75a-4ffd-908d-0b00f6ced98b"
"yayohajid@gmail.com","","74f39d03-eb00-4a6c-81fe-f2d3d2f60cf3"
"codesphere01010@gmail.com","Dzarel Developer ","757980ed-43fe-4092-9d2b-387e86c5b596"
"stefanuslay01@gmail.com","Stefanus Lay","763244a7-0bc5-4668-811f-4e20ae8825bb"
"fawfawfaw994@gmail.com","","76c148f1-fef9-4489-8421-1cb875d7747f"
"resa.rahman@gmail.com","","76da63db-4937-44f4-bd7b-2673db6b7c40"
"bryann.carls@gmail.com","","76e6cd37-478a-45f7-ac57-81ff1b646761"
"m.saidibjm4@gmail.com","Muhammad Saidi","786820eb-eccf-4c57-b05a-9cc4249672c4"
"securtydit@gmail.com","","78d09810-d778-46e4-a69a-c055ebc29998"
"helmygarcia67@gmail.com","Helmy ","793b5a30-f0c7-4dfe-afc8-0e85e7ccd49c"
"akbarriansyah339@gmail.com","Akbar Riansyah","793ce294-f69a-4aba-8259-f20ee2b4c73f"
"revanza.firdaus@gmail.com","Muhammad Revanza Firdaus","79c01c0b-4bff-43af-9ec9-d1fa29f3987b"
"rezokushop@gmail.com","","7a27e0da-9593-40a7-8462-9daf6e96e3ae"
"aril.permana@paper.id","","7a960082-4df8-45fd-99fa-a204d6688caa"
"atmasusanto38.imphenhack@gmail.com","","7b247e32-ed65-4865-a1ce-0e9cc4cb8297"
"ihsanwar77@gmail.com","","7b4b6a6d-1cef-415a-aca3-f98648f0f340"
"fsparatos@gmail.com","","7c30facc-eb6f-412e-88c4-0095f9bfdd1b"
"shalahuddin.4332311020@students.polibatam.ac.id","Shalahuddin Al-Ayyubi","7ca6c4b1-be0e-4993-83d8-0eff8945ea50"
"arissetia.m@gmail.com","Aris Setiawan","7cbcf002-8e92-4b28-bcee-1776ddf5ec86"
"andikadwipwf@gmail.com","","7d5ac7ef-704e-4275-b1b6-59b497548365"
"msayyid.rafeed@gmail.com","Muhammad Sayyid Rafee' Djamil","7da76028-0ed7-434e-b51e-d163cb3d3585"
"muhammadriskiakbar2009@gmail.com","","7ecd33ed-40b6-4236-981f-5319fdd390d3"
"bobypratama.dev@gmail.com","Muhammad Boby Pratama","7f0592c4-b0a6-476d-a436-75a8fe753f32"
"akbarrozaq691@gmail.com","","7f1689fe-9d54-4cec-a1f0-db5ffae98d3b"
"220605110044@student.uin-malang.ac.id","Ridho Aulia Rahman","801d6313-6a1c-45d3-9a64-1db88269e250"
"muhammadrestu1108@gmail.com","Muhammad Restu","805d5fc1-6f9d-4e37-b1bc-514ed4e17bea"
"iyandabes1@gmail.com","Astha","80e0229e-e268-4d22-a0c5-4610bd2b4bdd"
"ridhofahmij225@gmail.com","Ridha Fahmi J","80f24b96-36a6-44eb-a361-26bd04554609"
"wisnuajipamungkas354@gmail.com","","80f40577-19f8-45cb-86d1-dcc60472b9e8"
"mgsmfr@gmail.com","","8177c99d-28f5-4a88-8415-9abea3fb934b"
"eguinjonathan08@gmail.com","Guinn","8268ab9d-acc1-4379-b9e9-331392668db2"
"fardanyudhistira16@gmail.com","Fardan Yudhistira ","83635084-c207-40ee-ac11-742b213fe843"
"quyun532@gmail.com","","83c6b62f-5c95-4e74-b16f-a9f48a9346cc"
"zakamaragames@gmail.com","Muhammad Ilyasa","83cc1c2b-01ba-438c-8c62-7c11a75c531c"
"krisnaguntur71@gmail.com","","840aa041-314a-44cc-8b52-e529b7aab3c3"
"mhmdazkanfl@gmail.com","Muhammad Azka Naufal","8454c75e-b0e4-4c04-a580-c13cddd63f3c"
"fajarrahman216@gmail.com","Computer","848435a2-d024-497a-b020-f320155ef675"
"aswinarung2@gmail.com","AswinA","84fe21f4-c03c-4042-b535-d61d92d33288"
"davidk3476@gmail.com","","85887410-b9e3-4861-8ebb-e35474bfd003"
"glenrioariesto@gmail.com","GlennJackson","865d44a1-be9b-4dbc-8110-02e91a0ad557"
"ferta996@gmail.com","Ferta Junindi","86be6a41-8d6d-4f91-b5d7-782d9a2de14e"
"julianisa.ar@gmail.com","Fatah","8709fc42-9f88-496c-8610-6cae5253cce9"
"indraaziz15@gmail.com","","8786bfb3-7f3e-40ed-9f46-f3ab5b1a938a"
"hshino@student.telkomuniversity.ac.id","Muhammad Hashfi Hadyan","87ce6b8d-af52-449b-a11a-854042633edf"
"farhansyahbanna07@gmail.com","Farhan ","8a1671be-64ea-48ec-9b24-f2589fc33281"
"nawvalovsky@gmail.com","","8b023aff-3d10-44dd-ad30-2251604bd319"
"mininsna@gmail.com","Minin","8bcee28c-19fa-45b8-bc0a-35e97af0110c"
"rzkalih330@gmail.com","","8bf4de04-3335-43cd-9f09-9a0bcddd3e65"
"agusse9986@gmail.com","Agus Setiawan","8c49cb67-5a7b-4bff-bd78-141166b1ce39"
"ixanramdhani@gmail.com","","8ca8015e-07c1-4d57-86f0-3223165b10af"
"bagas101021@gmail.com","Bagas Pamungkas","8d3cdd6e-cf34-4462-a2b3-91874bda4839"
"maulidanfaka391@gmail.com","Faksz","8e4ccb11-d491-4aaf-bf78-15fa333ea71d"
"mugteadotco@gmail.com","MugTea","8e907eae-5137-4499-a13b-11fd89106ae4"
"denandragagono@gmail.com","","90b98bc8-3d98-428f-8549-07457cf4bda0"
"trian.radis448@gmail.com","Trian Radis Pengestu","91064af2-a72c-4c48-9cd5-acdc2b6527f9"
"mnazriel177@gmail.com","","9130249c-c366-4c47-9d4e-337c35abce6a"
"onesta928@gmail.com","","91582716-23d1-42f9-afc8-6eb857bcc289"
"dimasmaulana.idn@gmail.com","Dimas Maulana","917aa59a-bf05-47af-a26b-f78f9faeaac0"
"naufalhaidar946@gmail.com","","91c5e840-3758-4c7f-9dcc-b621b6ba4d10"
"sandysyarifhartanto@gmail.com","Sandy Syarif","92e971bf-863c-4d66-a522-81a1a864c56c"
"77leviann@gmail.com","","93cc57f9-48c5-4b71-83bd-63bac1f5717a"
"roxyuciha@gmail.com","Robbanie","93d67e12-dbf6-4df7-882f-38ff471c9cf5"
"thisiswahibza@gmail.com","","93ffb004-f16d-4dd8-bd4e-55384c056138"
"raazan.muhammad.ikhsan@gmail.com","Razan Muhammad Ikhsan","953d0ce6-49ee-4606-9616-23b204e61710"
"farilpratamap@gmail.com","","955d4a9e-7e58-4210-86af-d38f8794a565"
"candraggml@gmail.com","","9665ef56-2b46-4883-bd1d-29d20c5a6f98"
"aulianaufaln@gmail.com","","9815d40b-d962-4a75-8383-8993407f89a7"
"rubenrextonb@gmail.com","","98236b68-a066-4538-9ae5-d5b063961244"
"bettyindrawati4@gmail.com","Betty Indrawati","98352ac8-241d-4433-b61b-60d9904b7999"
"alfandy115@gmail.com","Alfandy Afriandani","98a8d32e-1408-4f29-a374-fba3470bbe7c"
"zahranvidian@gmail.com","","9939f383-0ee4-4b8e-be03-08977324edd0"
"alifr5353@gmail.com","Alif Ramadhan ","9a5f346e-dcfe-47d2-9e43-80dd353d46e3"
"gn.mailwork@gmail.com","Grafis Nuresa","9acbdac5-f7aa-4834-90c4-4d19d67410f4"
"zexceed12300@gmail.com","Ihsan Nul Amri","9b067871-2da9-48e7-b15f-1e604d422d33"
"haikalrafifas@gmail.com","Haikal Rafif","9b212b4f-a019-48cd-9e6a-0f9d3c0ada4d"
"novealdioardhan@gmail.com","ardhan novealdio","9b9b68e3-f8fa-47a2-a47b-13af2d5cb22f"
"nabilpasha230606@gmail.com","Nabil Pasha","9c63cf90-68af-4566-aa74-562a9a10d73a"
"nash.collage.life@gmail.com","Muhammad Nashrulloh","9c963bbf-6eb0-46c8-86ce-ebf1d0d09b38"
"hhhh25737@gmail.com","Muhammad Ridwan","9cb022e2-1f94-414d-b9cb-c1781d676a72"
"samuelsiallagan49@gmail.com","","9cf09ee4-6ce0-4afe-a36e-9f472035cb34"
"sirrauf412@gmail.com","Sir Ananda Rauf","9d4ccd3e-c2c1-4ef9-9fda-007403908ecc"
"bentambunan20@gmail.com","","9d539268-8beb-4e57-80fd-6d2293776528"
"falihafiq1928@gmail.com","Muhammad Falih Afiq","9d8a38c3-e631-4b31-8f86-e3f6408f7330"
"muhammadalfathir@smkwikrama.sch.id","","9e26cc6f-048d-41a1-b657-4f06f5198aaa"
"hazizhaziz549@gmail.com","","9e63c2d8-d2c2-4ac7-9c2e-226965c4f402"
"hekate071@gmail.com","","9eb70e94-b963-4f4c-9852-7f6e95e7987f"
"mramdani1230@gmail.com","MRamdani","9f472bbd-aedb-49be-b8bf-0473d55f2622"
"dewarahmat1233@gmail.com","Matt","9fece7da-1c1d-4e07-843c-9cd26aee60b5"
"derriverse@gmail.com","","a0148ac9-002a-420e-bc23-ac7b3ee9a33c"
"jalagohu@denipl.net","","a050ea6f-8d2c-4b4b-9f64-306517b63613"
"muhammadrobisasaki@gmail.com","sasakirooo","a2770339-e7e6-4b20-9cfe-99c8d2471930"
"triwibowoilham2@gmail.com","Muhammad Ilham Triwibowo","a2c1d665-f360-4cd8-a313-5fee29b91d6e"
"yuratamma@gmail.com","Zidna Fadla","a2e938b8-7403-4e25-bc34-63b9838983a6"
"sudo.rusydi@gmail.com","","a32c0e81-1fbd-4bec-8e91-4cd6d10f553b"
"nazihelfikar68@gmail.com","","a42e221f-c198-463f-ac11-84060fd9b494"
"adzthariq@gmail.com","Muhammd Thariq Adzikra","a4c434da-2491-40b2-aa57-7b4aa53a69c1"
"saintparid@gmail.com","Saint Jameson Parid","a53db442-4a6b-4431-afe1-b9d148b977fb"
"asja77502@gmail.com","lioXploitcs ","a57807e3-bafb-4c4c-b23e-ba57c43c2cc2"
"realdrenzzz@gmail.com","Drenzzz.","a5ea628a-24d9-4754-b461-3eda53da53bf"
"maldenai76@gmail.com","","a5ee4302-50ff-4463-8f47-9cf60b414960"
"yogaagustiansyah01@gmail.com","Yoga Agustiansyah","a60c10f3-d73a-4953-8d2c-1e413c881470"
"masuwha@gmail.com","","a6acc3eb-6b59-4d31-881a-c4a1aecf8cfc"
"jazaniezt07@gmail.com","M. Abdillah Aljazani","a81baf2a-b875-4c86-84ab-618f403abc60"
"rickogurning333@gmail.com","","a8e87809-36a3-467e-8a49-8a730bb70724"
"zakyfarhan1326@gmail.com","Zaky Muhammad Fauzi","a90a9400-0576-4708-8912-c630807b9391"
"sandysyarifhhartanto@gmail.com","","a91c8500-69fd-4602-9795-779d006a305e"
"andra.pratama@riswan.com","","a927c690-8ebf-44eb-bf97-1d780729a2c4"
"naufalyuprata@gmail.com","","a93fd9dd-3e36-4ba1-b668-988dc2d186c6"
"mubarrokwildan1@gmail.com","Akazano_abe","a9807283-d4de-4990-8985-b22ded98e4cd"
"fackyouu128@gmail.com","Reza A Maulana ","a997e674-f927-474b-b817-9543f722aa9b"
"riantrito@gmail.com","","a9dfd864-faa1-452a-b074-8498e6e08af5"
"alhikam200@gmail.com","ΑlhikamΔirga⌘","a9f7ded3-ed87-486b-bd11-0d4b3cb5d9a7"
"afiffahriafrizall@gmail.com","","aa088d9f-7251-4673-ad6b-88261666710b"
"miawspecialist@gmail.com","iyep","aa42b892-74dc-4a00-85b5-c7f78127c4e9"
"muhammaddean27@gmail.com","Muhammad Dean Fahreza","aa4ba09e-4ed0-449c-ba98-5441844f022b"
"harissabil04@gmail.com","Muhammad Haris Sabil Al Karim","aa9d683d-0787-4279-8085-541459b1d8fa"
"quyunisnawan34@gmail.com","","ab36232b-b46c-4d30-8bb3-9b11e3e9ac7b"
"mnabilfs22@gmail.com","Muhammad Nabil Farras Sulthan","ab89f5c9-95fe-4808-81be-9bdc9c3b21ec"
"mochamadrifkyrifaldy@gmail.com","rifkyrifaldy","aba1da85-d87b-4d52-bea9-35639c4f1d64"
"muhammadyustanzah@gmail.com","","ada3b4d9-745b-41c2-95b5-1a8275e29882"
"sulfikaralijun@gmail.com","Sulfikar Alijun","adbd7ccc-252f-40c4-b5db-8940392da7e2"
"mitsuhaadly@gmail.com","Adeley","aef3f5b3-23b0-44e7-aa73-edab6ab20c6e"
"mobilezero24@gmail.com","Muhammad keisya fadilah","af8d0e16-0358-4d96-b822-a4023b06752d"
"arvardy@student.ub.ac.id","","b022268c-2ac3-43a7-a5cf-c0bae568477a"
"zulfikarm022@gmail.com","","b04d2b39-25c2-4e65-a28f-8b35edeb6137"
"hasrinata@gmail.com","Hasrinata Arya Afendi","b1171213-ed39-4333-b58f-2424d3d2b9fe"
"wahyuikbal777@gmail.com","wahyu ikbal maulana","b1e1ba24-35fe-4dd7-b37f-77d42314b3d9"
"rafiadly259@gmail.com","","b21cde91-57f4-4f4e-b082-9130dcaff5a6"
"rakhaakbar522@gmail.com","Muhammad Rakha Akbar","b2208e46-26b9-497c-9105-7bfff4fd89f3"
"diwapraset02@gmail.com","Diwa","b22a09b9-7079-49c9-9734-0f69f73cdd67"
"anakriryanda@gmail.com","","b26a5fba-f01d-49eb-a0ab-b0fa67d846a8"
"sxntient.labs@gmail.com","","b2fb784d-cf93-4d45-a8bf-0fbbefdb8761"
"vlakasofficial@gmail.com","Fauzan","b35057c8-d5c3-4e20-b027-025f3d959398"
"sandifadel739@gmail.com","","b43dfb80-8037-4478-a050-e28b0b0e243c"
"alfawwaz.naufal13@gmail.com","Naufal Al F","b44432b9-c6ce-472c-b53b-fe6f838cc283"
"ilhampradani8@gmail.com","","b49fddfb-0be3-4d4f-a6d3-927c14ac9b81"
"divoveenda@gmail.com","Veenda Putri Divo","b4c53c87-6310-4143-bccf-94ee0440c17d"
"fazaradityarma@gmail.com","Faza","b50caf6d-e433-43d8-bef2-1ff073b13027"
"muhyusufsyamsyam@gmail.com","Yusuf Syam","b5113861-be40-4651-8ab2-33a8051656a7"
"koding12sam@gmail.com","Muel","b569b9c4-b173-45a4-b022-5bd0607e84be"
"zenaufa@hotmail.com","","b5a1b1e9-3bb5-4076-88a1-d36ce4dde6fc"
"naufalfadhilah689@gmail.com","Muhammad Naufal Fadhilah","b6a3051f-1890-440e-9956-c380e2b5c139"
"putrosrv@gmail.com","","b6ad4101-23d4-427c-9376-964cf995fc18"
"fajarskyy833@gmail.com","","b732bc7c-b3c7-4a79-8529-4622445343f3"
"fuwafuwa@tiffincrane.com","","b76b604d-9621-4aaf-bdf1-bc2da2f8248f"
"aminaprzaa@gmail.com","","b7e71f93-6fce-4ef0-8eae-1a15634bcb9e"
"abdurrahman.hafizh02@gmail.com","","b7f0d89b-6bdf-40dd-b5ca-442130b12c00"
"apriliyantoecha1@gmail.com","Echa Apriliyanto","b8350a6f-621e-4c2f-9d2b-cf79c2a2f091"
"rizkysrg62@gmail.com","","b864e321-9c43-422a-b980-c91809d3039a"
"kuncibatara@gmail.com","","b9066990-a721-40f9-8478-5f806c80c751"
"lotteaquamarine@comfythings.com","","b96bc4aa-4a97-40cc-be49-2560df93751a"
"zlutfhi89@gmail.com","","b9bb3ff3-5539-4b48-8101-90517a06cc42"
"theownerkill432@gmail.com","","b9ef4db0-6b7b-4dba-aef7-9434ce104e07"
"aryandasanggadiennata26@gmail.com","R3X","bb1696b1-7f82-46fa-805b-6ec79e673e40"
"rickyricko302@gmail.com","Ricky Verdiyanto","bbbf243b-f08e-4911-b4bd-838200c50cb5"
"nalindasepti@gmail.com","","bbc5e3e7-cd72-4739-81c6-199876b02084"
"gamerzcraft37@gmail.com","Gazach","bbfac7a8-2a13-48a2-9f29-499f33d68b8c"
"rewyzzi@gmail.com","","bc02bb7b-8193-4ddc-b8ca-d858b212de2e"
"alfanhuda2004@gmail.com","Alfanhuda","bc2bec53-ee17-40f7-9640-9556745a171a"
"tengkusripratiwi1608@gmail.com","","bd35df8a-9a78-4b14-aa8b-8366e9de58bd"
"bataraa@proton.me","Batara Krisna Danduru","bdb30d1b-9754-4333-b7e0-bce7e7f9ebda"
"hayyannashrulloh25@gmail.com","","be5f3f84-3435-4800-b019-3b6564113004"
"050393537@ecampus.ut.ac.id","Fahrur Rozi","be83a7e1-abf4-43ca-a23c-d335449b844f"
"arivopwenz@gmail.com","Ari Prabowo","be9d3160-e00e-4547-bbdd-c6546fe0af71"
"faaldy16@gmail.com","Aldi Fauzi Ilmamuslim","beb5d548-c976-4110-87f2-75d4f8465e2b"
"arbasfardan@gmail.com","Fardan Arbaz","bef2164a-6f12-4d69-847e-5d6ac19f554f"
"bwfzbw@gmail.com","jarss (zharfan)","bef8c2ba-c959-4896-a0d3-463e2f77570b"
"nabielilyasa133@gmail.com","","bf0d6b84-0508-4917-8ff5-e23e1d3ce23b"
"xecloud4@gmail.com","A. Abqory","bfacc996-8020-41fe-b323-c5c3dac7f425"
"christiannathanielp@gmail.com","Christian Nathaniel","c00a24fd-44b7-477a-9048-05007fdfd76e"
"ardyrkm23@gmail.com","","c048d109-8d91-49f2-b04f-fc86866c1487"
"risfarishk@gmail.com","","c084728e-f1c0-4775-b0b5-10ab0441bab6"
"awannshafwan@gmail.com","Awan","c0f6b85a-4723-484d-8fdf-713f7e5aa9f7"
"willygurning14@gmail.com","","c19ad425-8df8-4b37-8f35-797767f48672"
"0110224081@student.nurulfikri.ac.id","Raffa Yuda Pratama","c288a2df-a1e6-4beb-a6ea-aab0b7f912df"
"240605110160@student.uin-malang.ac.id","","c478b5f7-fc09-4b97-8354-e8889f852450"
"muhammadsultansyah14@gmail.com","Muhammad Sultansyah","c4aa882a-bfc2-4a1c-a9e7-89f9a37b9fba"
"grecyllagrecy@gmail.com","","c5611544-3041-4a31-866a-84ad0b781aca"
"ilhamthaariq@gmail.com","Muhammad Ilham Rijal Thaariq","c5ecbd2b-8d7b-4440-a75e-2bb58b1ed8cb"
"basukiridhoalghifary@gmail.com","basukiridho","c6323853-13e3-49dc-972c-a7cb789f0b32"
"kacunghabibiganteng@gmail.com","KacungHabibi™","c6dab1e3-8177-4784-936b-364a9e58a076"
"nhardiansa@gmail.com","Nabil Hardiansa","c79d4b2b-e63f-4187-a8cd-520fde333ab3"
"bhosya27@gmail.com","Bowoksae","c8f35350-384f-4933-be59-d785dbe06abc"
"m.sadiqin46@gmail.com","MuhammadSadiqin","c92b26ed-ddc1-45e7-9a17-f2d2ea7b9d2f"
"gayuyunma1123@gmail.com","","c978891b-71d3-423a-8c97-5648304f5a47"
"akbarriffani19@gmail.com","LebahRajin","c9b2c04a-5027-4ccb-bd0b-2c26c7e9f619"
"ajangrahmat@gmail.com","Ajang Rahmat","ca299304-dfa7-49ba-aa1d-0ed259ffe0fc"
"auliyanihlah@gmail.com","","caa569eb-822e-418c-93aa-df23563a178c"
"razandirgham@gmail.com","","caaaf8b5-ac4b-40b5-9549-f0f8ec9d6cd1"
"profadlibae@gmail.com","Acmad Fadli Aditama","cabaade4-b256-4798-844a-78f36cb16b0b"
"atnandimas@gmail.com","Adnan Rohmat K","cb2161a5-7c09-4360-908b-f2e629be1cda"
"sitizahraazizah23@gmail.com","Siti Zahra Azizah","cc7e4a74-7c3d-44cd-af91-a1b9326cb13e"
"nurdin99.ek@gmail.com","Nurdin","cd64c94d-ca81-4169-bb4d-e2af2dba4a55"
"fauzanhabib56@gmail.com","","cd7ebe7e-a914-457f-be95-2e6256342552"
"reyhananf7@gmail.com","","cdab11c6-ef41-4e2c-ad9d-f9636681e9f0"
"loidthegost@gmail.com","","ce458e26-313e-47cd-937d-e5e95f77a136"
"omnidevv@gmail.com","","cf1eadaa-a313-4760-808e-37dc70446ccf"
"m.ilhamjaya1808@gmail.com","Muhammad Ilham Jaya ","cfc296db-5cd7-4485-b8d0-ba330f5c148c"
"risbulgt@gmail.com","","d00b9542-2bc4-4a1c-acca-e2e58f7363e5"
"lembahku@gmail.com","","d15d8329-a248-4339-8090-69c40ca2a050"
"fcrizkywijaya@gmail.com","Farid Rizky Wijaya","d1c950e6-fb9e-4d53-9c2c-e77477a9a1eb"
"bimaadam.dev@gmail.com","","d263ee7e-e4fb-4b55-a5d9-0207520e0fa8"
"felixarajiph@gmail.com","Rajiph iqbal","d350da40-7543-4073-b6a5-95c79d705dd3"
"ahmadfawwaz103@gmail.com","","d396661f-c733-4991-a212-76b9e4ecb02c"
"hafizpramudya17@gmail.com","Nflz","d44ab980-6982-46b3-8da5-fafef0dedd9f"
"johnobama24keren@gmail.com","Hiuaaaaa","d4953c02-1a07-476c-b5ca-7dfd1e91d28c"
"aliakbar06110@gmail.com","","d4d2c768-3296-40fa-8c87-3bf0b9a54fac"
"sanjaya28051@gmail.com","","d5ff8a92-2b82-42b9-a143-2cd8c376c1ef"
"nawvalovsky@proton.me","","d731aa31-8893-4496-ad36-db71729e3419"
"agung.astanto14@gmail.com","","d744ffbb-7011-43b6-a4f8-7ef2f105ba12"
"yumpp24@gmail.com","","d7587b8e-51fb-404b-ae31-7f5a86c85119"
"novandiramadhan80@gmail.com","","d8f27598-3fed-47da-be14-86c59f70387a"
"sfatimaple@gmail.com","Sfatimah Azzahra","d937a04d-da2e-46e0-9e38-b36d244f8daa"
"andriscreen@gmail.com","Andri Irawan","d938e47a-5a8d-41c2-9952-c42c7d83f291"
"suryami62@outlook.com","Surya Ramadhan","d9a4b3e0-eed5-4c63-984f-096a8fb0a03d"
"abdalhaqqm@gmail.com","Abdalhaqq Muhammad Saih","d9b2f557-027c-4c2e-85e0-f157f2df4917"
"alijun.dev@gmail.com","Sulfikar Alijun","da349dec-eee1-4ed4-bd97-d8322f1ab36e"
"warsenosetyono@gmail.com","Warseno","daaf8fd0-2660-4462-990b-9eab46df6f47"
"kemasmuhammadalqodri@gmail.com","","db3ca0d6-67c6-48c0-9da1-e4b45ca3f921"
"fadlankurahan2@gmail.com","","dd022635-06ed-4ce2-85c1-7026c954e5c7"
"iqbalharimurti1@gmail.com","krenz","dd38bb9b-c45d-4ce2-b4b3-f99b8c8d7e82"
"obarya24@gmail.com","Afdaan","dde9ea50-6a95-4a73-a7cf-0c1e9e457c56"
"jexawe1022@okcdeals.com","","de072603-83a0-4d19-b601-d7bd25534d3d"
"mdjauharil29@gmail.com","Areal","de4190a8-2a66-4e96-aaf6-c3fb0ca838a7"
"diasdigital03@gmail.com","Dias","dea38d11-ef5f-492c-b107-17b509326138"
"apirahman55@gmail.com","Api Dev","df939ac3-de24-473c-8006-cf512fec4e10"
"rvlionxz@gmail.com","Reval Maulidan","dfad62ce-3285-4d1d-8ad6-8cb3a618a3e8"
"tsqf.hx@gmail.com","","e09e8094-49ae-4cb0-9e25-8cb548f6b37e"
"bloomsky133@gmail.com","","e18f82ff-7809-4274-b81c-b3dae3bac731"
"asepharyanalm001@gmail.com","Asep Haryana Saputra","e198581d-62bc-4098-8dc8-94302face1d9"
"kussokurae990@gmail.com","","e1dc4e80-c85e-440f-977e-32ac86ac4a8e"
"kaoricicak000@gmail.com","","e210703d-e0f2-493a-b976-ad02b714fd4d"
"felixvalentino58@gmail.com","","e21dbd95-0379-4087-84d4-735a7e280990"
"ferdialf.dev@gmail.com","FerdiAlfian_","e2248b8b-b6cb-4a9c-9746-eba0bc8d03ce"
"rafiqgans32@gmail.com","Ainur Rafiq","e26b3480-1f75-4038-9c73-c5db5c478e08"
"thed0704@gmail.com","","e27f8e2b-2ba4-41c8-8ef1-1a1c8299ec38"
"voidstayprivate@proton.me","Corteza Familia","e4747f46-e751-4e42-8190-5c8be219d50e"
"hoshikochan93@gmail.com","Rechan Dinata","e5770488-35c2-4298-a97e-b79f863c0c2a"
"ytbchanelsukiran@gmail.com","","e582bdf9-209e-4aa0-b052-043a664a5a27"
"putraaldhyansyah@gmail.com","","e59ed718-2a1b-4f86-a4e1-6063384a5fc1"
"gilarwaluyo@gmail.com","Abimanyu Gilar","e5d0bd59-1f07-49b7-8242-4ddb620e2a8b"
"noisyboyss887@gmail.com","Kangmas Darr","e65bf229-f745-41b4-a54e-875a765aa745"
"farelfebryan06@gmail.com","","e68e50fd-fc75-42a0-8b84-70bfaf508d01"
"wisdom.wahyuaji@gmail.com","Wisdom Wahyu Aji","e6a9a555-7af1-4abb-8ad7-1162e3be258a"
"b19marcel@gmail.com","","e6c844ce-a58a-414e-b0e3-8daf404a79f5"
"devakhri.farhan@gmail.com","Devakhri Farhan ","e725132c-af86-4da9-a220-57288a436ab2"
"haikaljitra35@gmail.com","","e7bec056-7f63-4642-a763-afa39c3c340e"
"evanluis198@gmail.com","Evan Luis Tanjung","e8d69460-4eb9-48c7-a921-4738ad7c8dc9"
"ikhwannurrizkif@gmail.com","","e913c217-2dde-4498-909c-4b2d5afd89d1"
"ryandraa27@gmail.com","","e98fbabe-4120-4d16-b11f-e447467c8ef9"
"mtaufikska@gmail.com","","e99519f0-630b-4293-8487-a17d667fa708"
"tudebaliirl@gmail.com","TudeStillLearning!","e99abe65-5ce7-49c5-930b-b7e456d55661"
"adam.daniamm@gmail.com","","e9b65cdd-9c7c-471d-97ac-b02f0434e87f"
"boboi0704@gmail.com","","e9bc3d6c-67af-4687-8b3b-a122a93710ef"
"nathantanusubroto15@gmail.com","","e9ef0f76-533a-4016-ab14-420934da3fe3"
"mikmself@gmail.com","Muhamad Irga Khoirul Mahfis","eab56176-5f01-4ff2-8dca-51bebf7771fc"
"yuenvengozatheowonata@gmail.com","Yuenvengoza Theowonata","eac6a2e3-017f-4c92-ae73-38bc43520640"
"dexangga06@gmail.com","","eb0176ef-9cd5-474d-9bc2-115afb6eb831"
"reeimu28@gmail.com","Moruten","eb2c7101-f5d4-453c-80e8-1f3f77d350dc"
"henryfaaa@gmail.com","","eb350f16-56c0-4e05-a483-7300ab363c6b"
"kelvinlihandy2005@gmail.com","Kelvin Lihandy","ec3d9492-cc18-497e-b63a-a1c06281bcaa"
"alyssashane2607@gmail.com","","ece0ce22-4193-46f0-af02-d0153e896ee4"
"yandrajafarsidiq8@gmail.com","Yandra Jafar Sidiq","ee2aff01-ddc0-40fd-afda-db8e0c0e9556"
"rikogans754@gmail.com","","eef4fdd0-0198-4749-b099-0d558ab2ce22"
"arkandi.2020@gmail.com","Muhammad Arkandi Syahputra Santosa","ef13c12f-2cca-4ddf-9dad-3d387a89bbf8"
"alfaris.mgk@gmail.com","","f01ba8b4-7ee2-4561-bc22-73bbfb712ea0"
"bilorpanas@gmail.com","","f05211c3-aa26-473b-a6a4-225424349e81"
"limakaki881@gmail.com","yth.mas.kanjeng.gus.mas.lui.s.pd.kom.h.i.alm.h.lc.hc.pc.amd.intel.ndx.aka.","f0cace35-b865-4475-9ddb-5fce24f655f2"
"arvanardana1@gmail.com","Arvan Ardana","f1df8edd-2549-4c98-be46-01584aa134eb"
"farreldiego29@gmail.com","Farrel Diego","f26c75f5-6ae7-446b-aa47-695ba13e7c7f"
"wizzkingchannel@gmail.com","HabiebAnugrah","f34efb80-ad1d-434f-a319-e1d54973ff12"
"adityakurniasaputra903@gmail.com","Kurnias","f377d789-ecf1-4a12-b72b-a8ffe30cc123"
"dav1nalifianda@gmail.com","Davin Alifianda Adytia","f3a25278-ec1d-496a-a89c-030c5c521111"
"rifqinanda60@gmail.com","","f3fef42a-9d51-40d6-b6e7-59f25bf068c8"
"notmadz132@gmail.com","Mu'adz Abdulloh","f40b14d5-0cc4-4605-9893-8f27dd34de71"
"qodria589@gmail.com","","f44ce041-e64c-4cbd-a104-83964aa37ccb"
"fahrezaxgamer@gmail.com","Fahreza Pratama Hidayat","f47cacb7-e099-4816-96c9-77f858414235"
"dyas@live.com","Drestanto Muhammad Dyasputro","f57ac77c-7ea9-4b92-aeb5-a7131a5052dc"
"ahmadgozali4040@gmail.com","Ahmad Ghozali","f6938963-132b-4471-90c1-2a252923b89f"
"jony.huang.me@gmail.com","Jony Wijaya","f786208b-28cc-4484-935f-b88f2f75d6d0"
"afrizaahmad18@gmail.com","normies","f7b6ebf3-3bce-46df-b18f-22763e552e6e"
"satyabr147@gmail.com","","f833772b-a037-48ae-90eb-c3b02a06a3b9"
"gmrikza05@gmail.com","","f83bbabf-f6e8-4023-9626-2a97a0c4c994"
"andikadibya92@gmail.com","Andika Dibya Azzumardi","f8cc3af4-3a3a-4a3d-8314-972725366ffb"
"rizkynursanto48@gmail.com","Rizky Adi N.","fa57208f-b1a7-4766-9067-d4531833f4b9"
"muh.indaarr@gmail.com","Muhammad Indar","fa7f0fc6-7449-42ac-a80f-560960345ac4"
"laluarya633@gmail.com","","fae5c5a9-6a26-48d6-b04d-68ebf4d81e7b"
"muhammadfadlanrais@gmail.com","","fbc4c505-01ef-40b2-a257-c2430fe1bdc5"
"rifkybujanabisri@gmail.com","Rifky Bujana Bisri","fc00d1bf-65da-431b-9994-52a3cfc88273"
"alazmimuhammadhabib@gmail.com","Izumi","fc05d2d3-0a55-4407-b20e-99d7bf24a3ec"
"scamgarapan@gmail.com","","fc22e953-b532-48b2-be6a-0fbc3b3774fb"
"fajaralfrzi@gmail.com","","fc701b59-0510-44f3-a85e-0cd22e9f777f"
"imanwealth00@gmail.com","WebDevShoul","fcad381c-ab34-4f62-b6f7-4569c24625f3"
"muhammadalifaditya92@gmail.com","Alif Aditya","fea8d6e4-a831-43a9-b98c-6a29c20dab53"
"udinhilif@gmail.com","Rio Fernanda","fef16ed6-438c-44c0-9070-66d3f602beae"
1 email fullname id
2 rrrijal24@gmail.com Rijal 0081ce77-2dd2-469c-8759-8d1259b6206c
3 zulhamrianto@gmail.com 0176361f-e403-4aeb-a683-8aa58d1e51a4
4 akbarkurniawan790@gmail.com Akbar Kurniawan 017917c0-7393-4620-9d17-bc8467f9c521
5 auliarestyazizah@gmail.com 0293026a-dfbe-4380-89fa-e2d5729a5cea
6 alhadad.xyz@gmail.com alhadad 0308f526-0dfe-4509-bee8-4aa217ec0a12
7 radithamzah12@gmail.com dorrit 032c630c-8a20-40ec-b191-ba8e009ab538
8 pamungkas.ajiseno@gmail.com Senoaji Pamungkas 0331921a-4009-4204-86af-5eb90d6773cf
9 gamingarisky@gmail.com 05004e6b-9d5c-4a14-af97-496bcd55878b
10 bagoesrex@gmail.com Bagus 058da640-ac05-498b-8618-f2c5a4455b90
11 rizkyseptian401@gmail.com Moc Rizky Septian 05d98092-8528-4edf-8b23-9134d67f628b
12 excellchriatian12@gmail.com Excell Christian 06f7158e-2218-4b61-8129-899bdaeb1eab
13 sofyanekafebriyanto@gmail.com 07138a28-5e15-4c08-9325-0b17061d913f
14 baraskyke7@gmail.com Barasky 07203a89-c58b-4df2-aeba-54b4977c8fc6
15 cukd3h.hynix@gmail.com 073af6af-33aa-4067-820c-2213df0843b0
16 tsqf.h29@gmail.com Muh Tsaqif Hafidz 073bb0a3-69b5-4b4d-8d47-e30b38690ef5
17 azranmu@gmail.com M ASRAN 078e1f86-6ca1-4fc2-ab9d-a600a6c5adf0
18 gdsudiartika@gmail.com Diar Sudiar 08b7eb15-dd74-4704-b8ec-d4e7dbc37c6e
19 alfiansa@proton.me Aditia Akbar Putra A. 095a9662-4d57-4c76-ae28-8f3685a01d34
20 ichaaulia720@gmail.com 09ab46a0-1394-4e41-9938-8f3df7f8bf99
21 naziq02@gmail.com 09cacf8e-467d-4885-a792-11287b68d626
22 wahyuskull22@gmail.com Wahyu SA 09dd11d7-4dae-4273-ba66-e313d82c7d52
23 sulthanzain28@gmail.com Sulthan .Y 0a28758d-fbc3-4979-93ae-33011b3a9691
24 ivan04.android@gmail.com 0a48d044-6e36-46b6-bd4e-bbf7d3f9519e
25 muklasputra222@gmail.com Muklas Adi Putra 0ab605f9-d525-4e1b-b1ca-7b21da065aff
26 rojafadilah0203@gmail.com Roja Fadilah 0b380e5f-26dc-41f0-914d-731d7ca00603
27 akmalkeisin@gmail.com 0b55c37c-48ce-4721-a7bb-52d9913dfcee
28 bimasaktiramdani01@gmail.com 0c1ddd78-5f93-4272-953b-4640cc769ab9
29 gajipgaming@gmail.com Galih Aji Pangestu 0cb2e0f9-6afe-458b-afc9-3170bcc18fa4
30 pascalsanjaya34@gmail.com Pascal 0d4f3960-509d-4ba4-8d03-5fc1c38a4071
31 hanifdwyputrasembiring@gmail.com Hanif Dwy Putra S 0e87a0f3-5a96-4b87-8e04-e180ed2731ca
32 afreezap20@gmail.com 0e9fcc3c-4a3f-43c9-bf4f-e4e4c342f7a0
33 profyonz@gmail.com 0eb6f8b2-f10f-4fa7-bf66-a43ef14a8f63
34 kesavadananjaya@gmail.com 0ec815c3-a256-4583-909d-b02655d7d09a
35 mdwikurniawan976@gmail.com 0f0e2cb8-2c21-417a-a577-f5d0662a2efa
36 rickyrahmad2001@gmail.com 0fa03bff-4be2-4c13-b687-688191c02955
37 dimasmaulanaichsan@gmail.com Dimas Maulana Ichsan 0fc6770d-fac7-4118-8796-6cae1b18f880
38 rikiulir@gmail.com Riki Ulir Niam 0fc99cb4-4495-405e-8b2f-3728404d7615
39 atmasusanto08@gmail.com 100b63a9-7487-4553-b45d-afb2201e4492
40 alvarelkevin12@gmail.com Muhammad Kevin Alvarel 114bebc7-2422-4c0a-9898-521f94739e7f
41 rayid1805@gmail.com 118b7324-bfc1-45f7-9282-d0d7fee3510f
42 sjr16173@gmail.com M. Salman Alfarisi 11dc3951-1a07-4937-bd75-2c29d2770c13
43 mhmmdarifrs@gmail.com Muhammad Arif Rahmad 134cd84e-4d79-474a-8ccc-09d728ad3f61
44 2310512054@mahasiswa.upnvj.ac.id Rafki Aulia Hazli 15154e34-3c02-426a-b02e-003278334a29
45 fathamarizkinur@gmail.com 151fa86a-5cec-4758-9824-a9d2ecd5385c
46 risyadraflan14@gmail.com 1552a593-f8e3-4c80-833b-4c5017dea18f
47 fauziferdiansyahindo@gmail.com 16a7eda3-4296-4754-b88e-dcf4cf61a0eb
48 amuhajir3126@gmail.com MamadNewbie 16b7a5ea-e664-4de5-86f1-578b5f3cd28c
49 fdvkey@gmail.com Bowo Pratama 1730c82d-369d-486d-b7cf-887be5383f8e
50 wahyusiakrom238@gmail.com ademaswahyu 173e56ab-92cc-48dd-9b03-dc8fdca13fde
51 abiyuaflah135@gmail.com Abiyu Aflah 18405a8a-9016-4db7-97fa-26ab32735df2
52 feryaryahidayat@gmail.com Fery 19355ab7-f1cb-47c5-9d2d-5eeb9ee4dbff
53 rokimiftah@gmail.com Roki Miftah Kamaludin 1973ee9d-2e18-46bc-b8df-272d61980a50
54 glen.tbjs@gmail.com Jeki 197d5875-dc6f-4068-844d-657db670babc
55 khulafamanagement@gmail.com 198b0b9f-a34d-410f-87cd-d19cfe02ff0c
56 12350110343@students.uin-suska.ac.id 1a79c0ce-9119-4067-9f00-c91dd01db95e
57 rizalalfadlil@gmail.com Hafidz Rizal Al-Fadlil 1aabe9b4-297a-4422-b01f-1b02369dc401
58 razinsyakib@gmail.com Muhammad Razin Syakib 1b473465-ea92-4b48-9c2a-43c67cf45e01
59 dodysamarinda@gmail.com 1b4cc185-9532-49b2-b0ac-9f03c1dcc045
60 superaseph@gmail.com 1b97626f-bb01-457b-9638-7576e5af1683
61 ku.anime115@gmail.com Muhamad Wisnu 1c88251f-951a-4470-a2eb-58118a75ee75
62 faizaalfa250@gmail.com 1c903d4e-a741-42f9-b27c-37c5d4e9a704
63 wandgenius6@gmail.com maulana adam 1d209d21-ef69-4bab-8796-cfa4cbf1fa20
64 aristiov@gmail.com Aristio 1e0fd9f8-e7cc-4381-939d-0f1aac4a0510
65 adamabdillah20@gmail.com Adam Abdillah 1e38dac1-a94c-4ac1-9ee8-8a53629e9be4
66 simarmatarafael11@gmail.com 1ea5a152-5463-44b0-975a-3ed019e804b8
67 kurniadikiki508@gmail.com 21054038-79d8-49a8-8daf-ca4e780cc09a
68 alif.ayana48@gmail.com Alif Muhammad 21fe034a-a9f6-417d-a6ed-e8c26f3005b8
69 minanabdillah4@gmail.com 22c19165-8c32-45f6-9e85-683a4aeaa2ec
70 gammertag31@gmail.com 236035ab-a274-4f6f-869d-6aaaf761e659
71 abangiqi@gmail.com 240ff65c-897c-40e7-aafd-d2dd1114ea66
72 meftah.mafazy@gmail.com 27a09ee3-2c61-4b38-a9f9-1f2810488131
73 islahulkafaa@gmail.com 289c5f8c-5e0f-4bca-8f66-7641d14a7bb0
74 zaidanabdulaziz03@gmail.com Zaidan Abdul Aziz 29049370-3d6f-4ae3-b44a-fe9d4f6edb89
75 adigintings@gmail.com Adi Primanda Ginting 2935f1f9-c070-422f-b9e0-b378b16a32c8
76 fadhil06akbar@gmail.com 29ae041b-b96c-45d0-a0e2-256ccd8b2168
77 ilhamfiqri37@gmail.com 29f3fcaa-9104-494e-844e-79d827f74a06
78 riski01.pku@gmail.com 2a57227d-2920-4839-8849-93ef38a41b9e
79 bisnis.huruhara@gmail.com 2ae31e65-4272-431b-bbc1-59158cc7cf11
80 luthlirik@gmail.com luthfi 2b47f090-1c0f-4982-8893-c0b1af1409e6
81 zackyhokya045@gmail.com 2bb1bf7f-cffb-4b1c-9171-a7e653fb6541
82 bayanalamahul3@gmail.com 2c228e47-a6ad-47c4-984f-99b6e5e8a13d
83 nizaram4dhan@gmail.com Nizar Alif Ramadhan 2cd4994b-ba50-43c1-9d78-f4311ef31108
84 fadhilakbar0612@gmail.com 2cf73de4-90a4-4d1b-a8f0-748ab0a621e7
85 aryhidayatfebriana@gmail.com Rizky Reza 2d0a00a8-2660-4b60-8de6-f85a6ce98b1d
86 hadiidandri2000@gmail.com 2ea30f2a-fbeb-4af6-b727-bebb0b687939
87 ahmadirafif7@gmail.com Rapip 2f08b18b-bd87-4d20-9923-26f4759a4b6e
88 rve27github@gmail.com Radika 2f788a66-e34e-466a-bbfd-3de920711223
89 fikricf147@gmail.com 311d81bf-5b5e-4519-a33b-cdd0ee0ecc82
90 ausathdzil@gmail.com Ausath Ikram 31a21f59-ecea-4bea-9a7b-56dc92c00a12
91 jono210678@gmail.com 34298723-449f-4b95-b6cb-1d7d5ab71540
92 rheatkhs@gmail.com Febiadi Wisnu Akbar 34bad6e8-3372-4634-ac93-b10f8882bbb2
93 sntdashi@gmail.com rawrzn 35159d0a-33cd-4c64-a36d-d37c97e595db
94 muhammadhidayat0278@gmail.com 351fe76c-5a5c-4d5c-a546-f0dd7ccfec0f
95 111202213986@mhs.dinus.ac.id 35930877-5702-4eab-9d47-87ccf4035583
96 khairulumam950@gmail.com khairul umam 3631e3e5-3eac-4297-af3c-6d8648510471
97 muhammad.jibrilmuqoddas@gmail.com Murphy Lawden 369f03f6-dbd3-49a0-af32-ebfb527624b3
98 rafibudiansyah@gmail.com 37844c0a-f42b-4bdb-9731-ab9722de70dc
99 tongkorongankostpaksutha@gmail.com 37d7c1ea-d063-4ceb-93ad-9c9784744516
100 gilangsukmw@gmail.com 38a5bd5f-c462-424b-986b-db04df906813
101 muhammadhaikalaziz28@gmail.com Kaal 38a738a0-3329-4001-bf88-05d4de6b11d3
102 cimicimicilung4868@gmail.com Eka Revandi 38cce9e2-df1d-43a5-bf45-595e25744e16
103 rizqybelajar8@gmail.com 397f8039-f1c9-42ee-88de-b9597caae518
104 syaefulloharnas16@gmail.com Syaefulloh Arnas 398ae3d5-349e-44fd-9428-f9335f89c511
105 oppokunew19@gmail.com 39d68abb-2502-4330-b475-51416bf5269c
106 hrfahrizzal@gmail.com 3ade0045-eb6d-43c8-8781-ea8d281a464e
107 rizkyfadillah0890@gmail.com Rizky Fadillah 3b04b506-ff39-4c2f-9f8f-182c62e4b6f4
108 syahidizanagi@gmail.com 3b2fb3a3-9b55-4338-bf09-8952db4371c0
109 ryanfebrian729@gmail.com Ryan Feb 3b467fcb-af79-4031-85f8-3073e9343058
110 hajopan12@gmail.com 3b4c66b3-4ec3-465b-998c-32c9ab7bf2b4
111 imaim3024@gmail.com AimZ 3b5a0b3a-5930-4cbc-ac78-b1d9d20ac460
112 rafligamercratf@gmail.com rafly 3b901d8e-7c96-483d-b299-98c8e54f5b57
113 jojohyperbackend@gmail.com ZeeHyper02 3b9e0c60-5a32-4d1c-b011-46390971b67f
114 ihenry@gmail.com 3c96f60e-d8ab-4e2d-86f6-6a7f4b40c179
115 dimsartz021@gmail.com Dimas Tri M 3cdffe97-dad4-47e3-825d-d9c13ae815b5
116 fathurrahman081296@gmail.com Fathur Rahman 3da30612-1885-423f-a836-3cc7ca33add9
117 hafidnurazis@gmail.com Hafid Nur 3e20b9ee-f566-4b0f-9e4f-cbb92d31031c
118 helmiputra265@gmail.com 3f60fb92-3440-4f61-82fe-192c6bcdb301
119 farhansyamsuddin3@gmail.com 3fc8adf4-a75a-4272-a431-a9b614e8dbd3
120 fauzanabiyan33@gmail.com Hoshino Abydos ᴾᴿᴼ▄︻デk̷n̷e̷a̷f̷══━一 3ff2ae42-a561-4338-ba61-f444409322c3
121 bagusindrayanaindo@gmail.com Bagus Indrayana 40194320-d827-4a53-bb8f-36303a6713a2
122 daunn871@gmail.com Daun 41412e38-3b07-464f-93a4-457ed1cf6176
123 muhammadirfanbaihaqi538@gmail.com 4155a53f-252e-4b44-a951-851ea5cce606
124 mujahidislami030@gmail.com Mujahid Islami Primaldi Abdullah 41899d5b-9f1f-4ca9-8be8-9837ea83f493
125 muhammadkevinalvarel@gmail.com 418f738b-2187-4432-957d-15eb258a65bb
126 galangryandana@gmail.com 433c45f0-1d8e-442e-95c8-bd9c7b97108e
127 nasruladitri2@gmail.com Nasrul Aditri 43ea0f85-1b69-4235-ae25-523ad1984d46
128 muhammadariefcp@gmail.com Muhammad Arief C P 43f5ef95-ac4b-4d5e-bf57-b6bf0c30936d
129 adityamaullana234@gmail.com Aditya Maulana zidqy 44045f03-964c-4935-9918-cddc36676278
130 g4mless@proton.me g4mless 44192093-fadb-4be9-8662-d02e9c47a1b3
131 justgilang99@gmail.com 46769f5b-f33f-4777-aff1-9c25b9c488e8
132 jezudu@denipl.net 46a4954b-1b91-4647-a5ba-376bbc299402
133 alfisahri33@gmail.com alfi sahri 473b0a56-7583-43e6-8511-2e73889e9b37
134 mraihanaf0@gmail.com Muhammad Raihan Al Farizy 47866d52-c02c-44d9-a89d-76ee1103eed9
135 obenpasaribu@gmail.com 47e2dd3f-339f-4ce5-aa42-4d89a1fd4aea
136 2206073@itg.ac.id ZaenalSyamsyulArief 48a70338-aa6b-4f22-b5b1-458da13d85c6
137 ronyronny12@gmail.com 4b5b6fd7-4f79-412b-967e-0437d3c4c6af
138 malfathiratir@gmail.com 4bda0567-2a5a-4fda-8d8f-a5998e9d8800
139 lakazamlak02@gmail.com 4c4abd7a-772d-49fe-977a-57a917adbe6a
140 nazedev@gmail.com 4cdb4fd8-565e-47c7-9559-215a31f4bd1c
141 revelcahyadi2004@gmail.com Revel Cahyadi 4d2efba3-a0dd-448e-9a40-a7395a15f863
142 pulungwicaksono2006@gmail.com Pulung Wicaksono 4d9a29b1-080e-47d0-836b-f33dfbf84c6c
143 ferospedrosa@gmail.com 501c795f-fd27-4f86-900e-fbbbd02474a7
144 bilgrandov9902003@gmail.com 503377c4-26af-4890-82f1-a3619953b7b7
145 gbrnmhd89@gmail.com Gilbran Mahda 509825be-9d84-451e-9833-f2bbd5507ade
146 jevonit252@gmail.com 512805dd-507c-4193-adad-a1cf5f76a777
147 iwank31052003@gmail.com Iwan Kurniawan 516deb0d-9ec1-4776-835f-6daba29e7c1b
148 rifanajieagung48@gmail.com 5182dafb-4a12-4bcc-b967-6ea50aefe7d0
149 anata@yopmail.com 51832f14-1e08-4543-b1a0-d8107573efbc
150 fajargustiana2@gmail.com Fajar Gustiana 51896ad3-8184-4e4a-a9c8-4eaa39ac8451
151 raflyazizabdillah30@gmail.com 51bb6cfe-bd23-4ef9-9992-96b4e1c3a7ad
152 patrickmarcel65@gmail.com Patrick Marcel 51d0ed9b-5d7c-400e-a75e-b3777dae5be8
153 auranawamadani@gmail.com Aura N,A 529c4fa4-4894-4c88-ba63-857532f7fef3
154 ayamkucing129@gmail.com Elang 52b81c76-fa09-4e96-8dfd-f405c5c09808
155 ayyasi52@gmail.com 543eced6-00b4-4227-82b0-13e626fb1c88
156 itsandka@gmail.com 548a310d-05a7-4b64-9d5b-032d848446fc
157 ryanda.valents3649@student.unri.ac.id Ryanda Valents Anakri 54db9209-843c-465c-82c2-d8c4ea72dc7e
158 noaa277@gmail.com Karakaira 54e0cfb9-55b7-4c00-812f-f04296c11a70
159 larasrahm22@gmail.com 550fc044-10bd-49a1-9c09-50bd4d5325cc
160 zrbagus5@gmail.com Bagus 5568477e-e7a0-4a93-b1de-0e775414111a
161 jonfrymarbun@gmail.com Jonfry Agung Marbun 56f8b4f6-f31e-4faf-87f6-acc8d83bd72d
162 andika@umpo.ac.id 5768d665-77a3-4ae3-b2ee-37e408c0e98e
163 snarsaggaf@gmail.com pii 576d1650-71c8-4a5c-81c9-dda3284c13c4
164 azhararizkyrafi@gmail.com Rizky Rafi Azhara 58871899-6d54-4cd5-bbd0-3d9e2cbe5f9f
165 safuzi333@gmail.com 58ced2da-dd5c-42da-acd5-0082b5e0e835
166 putrasyaddad@gmail.com Syaddad Raihan Putra 59d3324c-a264-4f60-91ca-96d9d5def00a
167 bryanfernandoks11@gmail.com Bryan Fernando Kurniawan Suhartono 5a48dc88-25c8-49e8-8a74-36b493584aa8
168 saehansa@gmail.com 5a8cf51a-fa8a-44ce-ad1a-898c8487513f
169 kontakpribadiatsushi@gmail.com 5ab3bd8c-27a2-4552-8f0c-98370dff4e3b
170 mohkaisanalkalbi@gmail.com 5c15338b-c4a0-4071-ab67-c8f29ffaadaa
171 bayuardana213@gmail.com 5dc2beb9-46c1-4920-9d07-9708de896778
172 danufebriansyah09@gmail.com Danu Febriansyah 5e0d9e2e-5ced-4f94-9797-b8083778fe0e
173 pjuanda12@gmail.com 5e91489c-cacf-4050-b46b-4327255ec74a
174 muzammilulmuttaqin11@gmail.com 5ee5672c-dba1-42b7-b8f9-e76ba4efd21a
175 raffida2013@gmail.com Raffi Deas Alvaro 5f46ecdb-f025-4fc6-a981-dd4c4c93a52a
176 markusdamar2958@gmail.com Damar 5f475128-017f-4021-961c-4d58eef63d99
177 felixcyro008@gmail.com 5fb48143-4d2c-4d71-bf9a-bd9f44647de6
178 hazelbigbosshazel@gmail.com 5fecab2f-2382-404e-8f8f-ed031332b886
179 mhd.rijal203@gmail.com Muhamad Rijal 60fbc970-2079-42c8-8dcb-637c466a7f1c
180 imovic004@gmail.com movic 61c5d3a0-553f-474d-9950-483cb86057df
181 samuelmarubamanik@gmail.com 625aa73b-69db-473f-baad-6791ff865e40
182 muhammad.fahri137296@smp.belajar.id 63a19768-b9c4-480d-95c5-2770393935fd
183 me@anym.dev 63a87b68-4dc0-41c8-a99a-1335c77dd4e0
184 kithly19@gmail.com 64428bf7-052f-4b7b-95ca-802f76e93dcc
185 raissatristan14@gmail.com 645e0a22-a055-4cba-a007-37bd9791f882
186 fairuzfuadi123@gmail.com 653f2a62-66c5-4eae-a7c5-822a85cd2d9b
187 ahdan.abde@gmail.com Ahdan Abde Rifai 65d6021e-a03c-4661-a3eb-5452cc89b588
188 ivanrahmat.p77@gmail.com Ivan Rahmat Prakasa 65d79307-2b78-48af-a9d8-750e66126401
189 hamzahbaik9@gmail.com 65efcb5a-13e3-4552-9336-f7b68705e521
190 andrianxyz82@gmail.com 6677b5ae-3171-428d-aa61-22464b1ba80f
191 billysyahputra630@gmail.com Billy 66dbdf3d-3cf0-4d81-810e-185e61b952b7
192 yusri.irfani@gmail.com 6773b841-2037-4aee-ace1-a8bea7341e25
193 reiyhan.aditya@gmail.com Rey Panda 68876807-64ed-49f9-91d4-d7f68eb0e6c9
194 hexorzplay@gmail.com 689872ad-856d-40f6-9d87-86d580394fb4
195 codewithwan@gmail.com 699646d9-192a-451f-99d1-46d4a42e4eec
196 mtaufiksaadi28@gmail.com 6a9264f2-2731-4e4a-8e29-9fc1a505e6dc
197 alamsantoso.12@gmail.com Emowmow 6ab9934d-8f77-4e32-b4db-d09222fa4e44
198 kunengrestu@gmail.com 6ae98213-5e3a-41c7-b356-03ae6760993d
199 andikacahargen2@gmail.com Anka Tama 6b62be2c-912d-4809-af64-08ff52aef4b8
200 rmz4mc7wg6@privaterelay.appleid.com 6bab0fab-6c05-4ed5-9d4d-bbabaf62b513
201 handikacoco@gmail.com 6bd01f53-7884-40a2-aeb4-34b000120088
202 riziqliliulilalbab@gmail.com RIZIQ LILI ULIL ALBAB 6d10db2d-3ac2-4008-90dd-141311219c53
203 syisahi4696@gmail.com 6dd8c78b-9884-4944-b2d4-de2bb8711da6
204 akhmadwibu05@gmail.com akhmadkhoirudin 6e32e4d3-25e7-47d5-97b5-60955969e22f
205 andika.namikaze02@gmail.com Andika Fitra Darmawan 6eae5e60-544e-4bf2-81af-7b1138ae967a
206 luhalisarafansyah@gmail.com 6eb155d0-aea1-41ca-bcdf-7effa9912366
207 novianybl@gmail.com Novian Project 6ebfa0ad-fde4-42f9-9d0f-8e51e465d534
208 boostlivious@gmail.com Raynaldi Siahaan 6f089bd4-8829-4a62-8c06-104319116e93
209 rasyadbimasatya04@gmail.com Rasyad Bimasatya 6f237adc-f724-424c-baf1-4d13f03887e1
210 rezyaindisaputra@gmail.com 6f43758c-224e-4d9d-9273-f3a3df51a11c
211 rahardisalim23@gmail.com 6f8a13c1-3836-4715-9181-dc21314cd8af
212 alfin.rozzaq.270206@gmail.com Alfin Rozzaq Nirwana 6f974b20-6309-4b44-9c7c-165d4cb458c9
213 muhammad.toffazal00@gmail.com 7055961a-7b40-49d9-ace2-6ce7ad48f14d
214 raaflynr@gmail.com 706b67c2-e410-4143-a7fa-213d35178db4
215 kujouarisu@gmail.com 7216ca14-7cff-4d0b-b8c9-70427af21b4a
216 rohimsaga7@gmail.com 722989b5-f685-4fc3-9bc7-76f04c479d49
217 rayhanlubis01@gmail.com Rayhan Lubis 72bf1d04-8b95-4f90-a88f-f78c0571d841
218 raflyirham1005@gmail.com Rafly Irham Safatulloh 73404aea-a007-4629-b9dc-fb26cb307e57
219 sopiadi2006@gmail.com 7364d4fa-f6f7-429f-a95d-f6775eca6d9f
220 haydar130305@gmail.com 7462e4b2-b446-4562-9bc8-e08c07d157ef
221 arilindra21@gmail.com Moch. Aril Indra Permana 748de15d-374a-4756-89e2-75754e90542f
222 gundowijoyo7@gmail.com bahaywuj 74bd7b16-a75a-4ffd-908d-0b00f6ced98b
223 yayohajid@gmail.com 74f39d03-eb00-4a6c-81fe-f2d3d2f60cf3
224 codesphere01010@gmail.com Dzarel Developer 757980ed-43fe-4092-9d2b-387e86c5b596
225 stefanuslay01@gmail.com Stefanus Lay 763244a7-0bc5-4668-811f-4e20ae8825bb
226 fawfawfaw994@gmail.com 76c148f1-fef9-4489-8421-1cb875d7747f
227 resa.rahman@gmail.com 76da63db-4937-44f4-bd7b-2673db6b7c40
228 bryann.carls@gmail.com 76e6cd37-478a-45f7-ac57-81ff1b646761
229 m.saidibjm4@gmail.com Muhammad Saidi 786820eb-eccf-4c57-b05a-9cc4249672c4
230 securtydit@gmail.com 78d09810-d778-46e4-a69a-c055ebc29998
231 helmygarcia67@gmail.com Helmy 793b5a30-f0c7-4dfe-afc8-0e85e7ccd49c
232 akbarriansyah339@gmail.com Akbar Riansyah 793ce294-f69a-4aba-8259-f20ee2b4c73f
233 revanza.firdaus@gmail.com Muhammad Revanza Firdaus 79c01c0b-4bff-43af-9ec9-d1fa29f3987b
234 rezokushop@gmail.com 7a27e0da-9593-40a7-8462-9daf6e96e3ae
235 aril.permana@paper.id 7a960082-4df8-45fd-99fa-a204d6688caa
236 atmasusanto38.imphenhack@gmail.com 7b247e32-ed65-4865-a1ce-0e9cc4cb8297
237 ihsanwar77@gmail.com 7b4b6a6d-1cef-415a-aca3-f98648f0f340
238 fsparatos@gmail.com 7c30facc-eb6f-412e-88c4-0095f9bfdd1b
239 shalahuddin.4332311020@students.polibatam.ac.id Shalahuddin Al-Ayyubi 7ca6c4b1-be0e-4993-83d8-0eff8945ea50
240 arissetia.m@gmail.com Aris Setiawan 7cbcf002-8e92-4b28-bcee-1776ddf5ec86
241 andikadwipwf@gmail.com 7d5ac7ef-704e-4275-b1b6-59b497548365
242 msayyid.rafeed@gmail.com Muhammad Sayyid Rafee' Djamil 7da76028-0ed7-434e-b51e-d163cb3d3585
243 muhammadriskiakbar2009@gmail.com 7ecd33ed-40b6-4236-981f-5319fdd390d3
244 bobypratama.dev@gmail.com Muhammad Boby Pratama 7f0592c4-b0a6-476d-a436-75a8fe753f32
245 akbarrozaq691@gmail.com 7f1689fe-9d54-4cec-a1f0-db5ffae98d3b
246 220605110044@student.uin-malang.ac.id Ridho Aulia Rahman 801d6313-6a1c-45d3-9a64-1db88269e250
247 muhammadrestu1108@gmail.com Muhammad Restu 805d5fc1-6f9d-4e37-b1bc-514ed4e17bea
248 iyandabes1@gmail.com Astha 80e0229e-e268-4d22-a0c5-4610bd2b4bdd
249 ridhofahmij225@gmail.com Ridha Fahmi J 80f24b96-36a6-44eb-a361-26bd04554609
250 wisnuajipamungkas354@gmail.com 80f40577-19f8-45cb-86d1-dcc60472b9e8
251 mgsmfr@gmail.com 8177c99d-28f5-4a88-8415-9abea3fb934b
252 eguinjonathan08@gmail.com Guinn 8268ab9d-acc1-4379-b9e9-331392668db2
253 fardanyudhistira16@gmail.com Fardan Yudhistira 83635084-c207-40ee-ac11-742b213fe843
254 quyun532@gmail.com 83c6b62f-5c95-4e74-b16f-a9f48a9346cc
255 zakamaragames@gmail.com Muhammad Ilyasa 83cc1c2b-01ba-438c-8c62-7c11a75c531c
256 krisnaguntur71@gmail.com 840aa041-314a-44cc-8b52-e529b7aab3c3
257 mhmdazkanfl@gmail.com Muhammad Azka Naufal 8454c75e-b0e4-4c04-a580-c13cddd63f3c
258 fajarrahman216@gmail.com Computer 848435a2-d024-497a-b020-f320155ef675
259 aswinarung2@gmail.com AswinA 84fe21f4-c03c-4042-b535-d61d92d33288
260 davidk3476@gmail.com 85887410-b9e3-4861-8ebb-e35474bfd003
261 glenrioariesto@gmail.com GlennJackson 865d44a1-be9b-4dbc-8110-02e91a0ad557
262 ferta996@gmail.com Ferta Junindi 86be6a41-8d6d-4f91-b5d7-782d9a2de14e
263 julianisa.ar@gmail.com Fatah 8709fc42-9f88-496c-8610-6cae5253cce9
264 indraaziz15@gmail.com 8786bfb3-7f3e-40ed-9f46-f3ab5b1a938a
265 hshino@student.telkomuniversity.ac.id Muhammad Hashfi Hadyan 87ce6b8d-af52-449b-a11a-854042633edf
266 farhansyahbanna07@gmail.com Farhan 8a1671be-64ea-48ec-9b24-f2589fc33281
267 nawvalovsky@gmail.com 8b023aff-3d10-44dd-ad30-2251604bd319
268 mininsna@gmail.com Minin 8bcee28c-19fa-45b8-bc0a-35e97af0110c
269 rzkalih330@gmail.com 8bf4de04-3335-43cd-9f09-9a0bcddd3e65
270 agusse9986@gmail.com Agus Setiawan 8c49cb67-5a7b-4bff-bd78-141166b1ce39
271 ixanramdhani@gmail.com 8ca8015e-07c1-4d57-86f0-3223165b10af
272 bagas101021@gmail.com Bagas Pamungkas 8d3cdd6e-cf34-4462-a2b3-91874bda4839
273 maulidanfaka391@gmail.com Faksz 8e4ccb11-d491-4aaf-bf78-15fa333ea71d
274 mugteadotco@gmail.com MugTea 8e907eae-5137-4499-a13b-11fd89106ae4
275 denandragagono@gmail.com 90b98bc8-3d98-428f-8549-07457cf4bda0
276 trian.radis448@gmail.com Trian Radis Pengestu 91064af2-a72c-4c48-9cd5-acdc2b6527f9
277 mnazriel177@gmail.com 9130249c-c366-4c47-9d4e-337c35abce6a
278 onesta928@gmail.com 91582716-23d1-42f9-afc8-6eb857bcc289
279 dimasmaulana.idn@gmail.com Dimas Maulana 917aa59a-bf05-47af-a26b-f78f9faeaac0
280 naufalhaidar946@gmail.com 91c5e840-3758-4c7f-9dcc-b621b6ba4d10
281 sandysyarifhartanto@gmail.com Sandy Syarif 92e971bf-863c-4d66-a522-81a1a864c56c
282 77leviann@gmail.com 93cc57f9-48c5-4b71-83bd-63bac1f5717a
283 roxyuciha@gmail.com Robbanie 93d67e12-dbf6-4df7-882f-38ff471c9cf5
284 thisiswahibza@gmail.com 93ffb004-f16d-4dd8-bd4e-55384c056138
285 raazan.muhammad.ikhsan@gmail.com Razan Muhammad Ikhsan 953d0ce6-49ee-4606-9616-23b204e61710
286 farilpratamap@gmail.com 955d4a9e-7e58-4210-86af-d38f8794a565
287 candraggml@gmail.com 9665ef56-2b46-4883-bd1d-29d20c5a6f98
288 aulianaufaln@gmail.com 9815d40b-d962-4a75-8383-8993407f89a7
289 rubenrextonb@gmail.com 98236b68-a066-4538-9ae5-d5b063961244
290 bettyindrawati4@gmail.com Betty Indrawati 98352ac8-241d-4433-b61b-60d9904b7999
291 alfandy115@gmail.com Alfandy Afriandani 98a8d32e-1408-4f29-a374-fba3470bbe7c
292 zahranvidian@gmail.com 9939f383-0ee4-4b8e-be03-08977324edd0
293 alifr5353@gmail.com Alif Ramadhan 9a5f346e-dcfe-47d2-9e43-80dd353d46e3
294 gn.mailwork@gmail.com Grafis Nuresa 9acbdac5-f7aa-4834-90c4-4d19d67410f4
295 zexceed12300@gmail.com Ihsan Nul Amri 9b067871-2da9-48e7-b15f-1e604d422d33
296 haikalrafifas@gmail.com Haikal Rafif 9b212b4f-a019-48cd-9e6a-0f9d3c0ada4d
297 novealdioardhan@gmail.com ardhan novealdio 9b9b68e3-f8fa-47a2-a47b-13af2d5cb22f
298 nabilpasha230606@gmail.com Nabil Pasha 9c63cf90-68af-4566-aa74-562a9a10d73a
299 nash.collage.life@gmail.com Muhammad Nashrulloh 9c963bbf-6eb0-46c8-86ce-ebf1d0d09b38
300 hhhh25737@gmail.com Muhammad Ridwan 9cb022e2-1f94-414d-b9cb-c1781d676a72
301 samuelsiallagan49@gmail.com 9cf09ee4-6ce0-4afe-a36e-9f472035cb34
302 sirrauf412@gmail.com Sir Ananda Rauf 9d4ccd3e-c2c1-4ef9-9fda-007403908ecc
303 bentambunan20@gmail.com 9d539268-8beb-4e57-80fd-6d2293776528
304 falihafiq1928@gmail.com Muhammad Falih Afiq 9d8a38c3-e631-4b31-8f86-e3f6408f7330
305 muhammadalfathir@smkwikrama.sch.id 9e26cc6f-048d-41a1-b657-4f06f5198aaa
306 hazizhaziz549@gmail.com 9e63c2d8-d2c2-4ac7-9c2e-226965c4f402
307 hekate071@gmail.com 9eb70e94-b963-4f4c-9852-7f6e95e7987f
308 mramdani1230@gmail.com MRamdani 9f472bbd-aedb-49be-b8bf-0473d55f2622
309 dewarahmat1233@gmail.com Matt 9fece7da-1c1d-4e07-843c-9cd26aee60b5
310 derriverse@gmail.com a0148ac9-002a-420e-bc23-ac7b3ee9a33c
311 jalagohu@denipl.net a050ea6f-8d2c-4b4b-9f64-306517b63613
312 muhammadrobisasaki@gmail.com sasakirooo a2770339-e7e6-4b20-9cfe-99c8d2471930
313 triwibowoilham2@gmail.com Muhammad Ilham Triwibowo a2c1d665-f360-4cd8-a313-5fee29b91d6e
314 yuratamma@gmail.com Zidna Fadla a2e938b8-7403-4e25-bc34-63b9838983a6
315 sudo.rusydi@gmail.com a32c0e81-1fbd-4bec-8e91-4cd6d10f553b
316 nazihelfikar68@gmail.com a42e221f-c198-463f-ac11-84060fd9b494
317 adzthariq@gmail.com Muhammd Thariq Adzikra a4c434da-2491-40b2-aa57-7b4aa53a69c1
318 saintparid@gmail.com Saint Jameson Parid a53db442-4a6b-4431-afe1-b9d148b977fb
319 asja77502@gmail.com lioXploitcs a57807e3-bafb-4c4c-b23e-ba57c43c2cc2
320 realdrenzzz@gmail.com Drenzzz. a5ea628a-24d9-4754-b461-3eda53da53bf
321 maldenai76@gmail.com a5ee4302-50ff-4463-8f47-9cf60b414960
322 yogaagustiansyah01@gmail.com Yoga Agustiansyah a60c10f3-d73a-4953-8d2c-1e413c881470
323 masuwha@gmail.com a6acc3eb-6b59-4d31-881a-c4a1aecf8cfc
324 jazaniezt07@gmail.com M. Abdillah Aljazani a81baf2a-b875-4c86-84ab-618f403abc60
325 rickogurning333@gmail.com a8e87809-36a3-467e-8a49-8a730bb70724
326 zakyfarhan1326@gmail.com Zaky Muhammad Fauzi a90a9400-0576-4708-8912-c630807b9391
327 sandysyarifhhartanto@gmail.com a91c8500-69fd-4602-9795-779d006a305e
328 andra.pratama@riswan.com a927c690-8ebf-44eb-bf97-1d780729a2c4
329 naufalyuprata@gmail.com a93fd9dd-3e36-4ba1-b668-988dc2d186c6
330 mubarrokwildan1@gmail.com Akazano_abe a9807283-d4de-4990-8985-b22ded98e4cd
331 fackyouu128@gmail.com Reza A Maulana a997e674-f927-474b-b817-9543f722aa9b
332 riantrito@gmail.com a9dfd864-faa1-452a-b074-8498e6e08af5
333 alhikam200@gmail.com ΑlhikamΔirga⌘ a9f7ded3-ed87-486b-bd11-0d4b3cb5d9a7
334 afiffahriafrizall@gmail.com aa088d9f-7251-4673-ad6b-88261666710b
335 miawspecialist@gmail.com iyep aa42b892-74dc-4a00-85b5-c7f78127c4e9
336 muhammaddean27@gmail.com Muhammad Dean Fahreza aa4ba09e-4ed0-449c-ba98-5441844f022b
337 harissabil04@gmail.com Muhammad Haris Sabil Al Karim aa9d683d-0787-4279-8085-541459b1d8fa
338 quyunisnawan34@gmail.com ab36232b-b46c-4d30-8bb3-9b11e3e9ac7b
339 mnabilfs22@gmail.com Muhammad Nabil Farras Sulthan ab89f5c9-95fe-4808-81be-9bdc9c3b21ec
340 mochamadrifkyrifaldy@gmail.com rifkyrifaldy aba1da85-d87b-4d52-bea9-35639c4f1d64
341 muhammadyustanzah@gmail.com ada3b4d9-745b-41c2-95b5-1a8275e29882
342 sulfikaralijun@gmail.com Sulfikar Alijun adbd7ccc-252f-40c4-b5db-8940392da7e2
343 mitsuhaadly@gmail.com Adeley aef3f5b3-23b0-44e7-aa73-edab6ab20c6e
344 mobilezero24@gmail.com Muhammad keisya fadilah af8d0e16-0358-4d96-b822-a4023b06752d
345 arvardy@student.ub.ac.id b022268c-2ac3-43a7-a5cf-c0bae568477a
346 zulfikarm022@gmail.com b04d2b39-25c2-4e65-a28f-8b35edeb6137
347 hasrinata@gmail.com Hasrinata Arya Afendi b1171213-ed39-4333-b58f-2424d3d2b9fe
348 wahyuikbal777@gmail.com wahyu ikbal maulana b1e1ba24-35fe-4dd7-b37f-77d42314b3d9
349 rafiadly259@gmail.com b21cde91-57f4-4f4e-b082-9130dcaff5a6
350 rakhaakbar522@gmail.com Muhammad Rakha Akbar b2208e46-26b9-497c-9105-7bfff4fd89f3
351 diwapraset02@gmail.com Diwa b22a09b9-7079-49c9-9734-0f69f73cdd67
352 anakriryanda@gmail.com b26a5fba-f01d-49eb-a0ab-b0fa67d846a8
353 sxntient.labs@gmail.com b2fb784d-cf93-4d45-a8bf-0fbbefdb8761
354 vlakasofficial@gmail.com Fauzan b35057c8-d5c3-4e20-b027-025f3d959398
355 sandifadel739@gmail.com b43dfb80-8037-4478-a050-e28b0b0e243c
356 alfawwaz.naufal13@gmail.com Naufal Al F b44432b9-c6ce-472c-b53b-fe6f838cc283
357 ilhampradani8@gmail.com b49fddfb-0be3-4d4f-a6d3-927c14ac9b81
358 divoveenda@gmail.com Veenda Putri Divo b4c53c87-6310-4143-bccf-94ee0440c17d
359 fazaradityarma@gmail.com Faza b50caf6d-e433-43d8-bef2-1ff073b13027
360 muhyusufsyamsyam@gmail.com Yusuf Syam b5113861-be40-4651-8ab2-33a8051656a7
361 koding12sam@gmail.com Muel b569b9c4-b173-45a4-b022-5bd0607e84be
362 zenaufa@hotmail.com b5a1b1e9-3bb5-4076-88a1-d36ce4dde6fc
363 naufalfadhilah689@gmail.com Muhammad Naufal Fadhilah b6a3051f-1890-440e-9956-c380e2b5c139
364 putrosrv@gmail.com b6ad4101-23d4-427c-9376-964cf995fc18
365 fajarskyy833@gmail.com b732bc7c-b3c7-4a79-8529-4622445343f3
366 fuwafuwa@tiffincrane.com b76b604d-9621-4aaf-bdf1-bc2da2f8248f
367 aminaprzaa@gmail.com b7e71f93-6fce-4ef0-8eae-1a15634bcb9e
368 abdurrahman.hafizh02@gmail.com b7f0d89b-6bdf-40dd-b5ca-442130b12c00
369 apriliyantoecha1@gmail.com Echa Apriliyanto b8350a6f-621e-4c2f-9d2b-cf79c2a2f091
370 rizkysrg62@gmail.com b864e321-9c43-422a-b980-c91809d3039a
371 kuncibatara@gmail.com b9066990-a721-40f9-8478-5f806c80c751
372 lotteaquamarine@comfythings.com b96bc4aa-4a97-40cc-be49-2560df93751a
373 zlutfhi89@gmail.com b9bb3ff3-5539-4b48-8101-90517a06cc42
374 theownerkill432@gmail.com b9ef4db0-6b7b-4dba-aef7-9434ce104e07
375 aryandasanggadiennata26@gmail.com R3X bb1696b1-7f82-46fa-805b-6ec79e673e40
376 rickyricko302@gmail.com Ricky Verdiyanto bbbf243b-f08e-4911-b4bd-838200c50cb5
377 nalindasepti@gmail.com bbc5e3e7-cd72-4739-81c6-199876b02084
378 gamerzcraft37@gmail.com Gazach bbfac7a8-2a13-48a2-9f29-499f33d68b8c
379 rewyzzi@gmail.com bc02bb7b-8193-4ddc-b8ca-d858b212de2e
380 alfanhuda2004@gmail.com Alfanhuda bc2bec53-ee17-40f7-9640-9556745a171a
381 tengkusripratiwi1608@gmail.com bd35df8a-9a78-4b14-aa8b-8366e9de58bd
382 bataraa@proton.me Batara Krisna Danduru bdb30d1b-9754-4333-b7e0-bce7e7f9ebda
383 hayyannashrulloh25@gmail.com be5f3f84-3435-4800-b019-3b6564113004
384 050393537@ecampus.ut.ac.id Fahrur Rozi be83a7e1-abf4-43ca-a23c-d335449b844f
385 arivopwenz@gmail.com Ari Prabowo be9d3160-e00e-4547-bbdd-c6546fe0af71
386 faaldy16@gmail.com Aldi Fauzi Ilmamuslim beb5d548-c976-4110-87f2-75d4f8465e2b
387 arbasfardan@gmail.com Fardan Arbaz bef2164a-6f12-4d69-847e-5d6ac19f554f
388 bwfzbw@gmail.com jarss (zharfan) bef8c2ba-c959-4896-a0d3-463e2f77570b
389 nabielilyasa133@gmail.com bf0d6b84-0508-4917-8ff5-e23e1d3ce23b
390 xecloud4@gmail.com A. Abqory bfacc996-8020-41fe-b323-c5c3dac7f425
391 christiannathanielp@gmail.com Christian Nathaniel c00a24fd-44b7-477a-9048-05007fdfd76e
392 ardyrkm23@gmail.com c048d109-8d91-49f2-b04f-fc86866c1487
393 risfarishk@gmail.com c084728e-f1c0-4775-b0b5-10ab0441bab6
394 awannshafwan@gmail.com Awan c0f6b85a-4723-484d-8fdf-713f7e5aa9f7
395 willygurning14@gmail.com c19ad425-8df8-4b37-8f35-797767f48672
396 0110224081@student.nurulfikri.ac.id Raffa Yuda Pratama c288a2df-a1e6-4beb-a6ea-aab0b7f912df
397 240605110160@student.uin-malang.ac.id c478b5f7-fc09-4b97-8354-e8889f852450
398 muhammadsultansyah14@gmail.com Muhammad Sultansyah c4aa882a-bfc2-4a1c-a9e7-89f9a37b9fba
399 grecyllagrecy@gmail.com c5611544-3041-4a31-866a-84ad0b781aca
400 ilhamthaariq@gmail.com Muhammad Ilham Rijal Thaariq c5ecbd2b-8d7b-4440-a75e-2bb58b1ed8cb
401 basukiridhoalghifary@gmail.com basukiridho c6323853-13e3-49dc-972c-a7cb789f0b32
402 kacunghabibiganteng@gmail.com KacungHabibi™ c6dab1e3-8177-4784-936b-364a9e58a076
403 nhardiansa@gmail.com Nabil Hardiansa c79d4b2b-e63f-4187-a8cd-520fde333ab3
404 bhosya27@gmail.com Bowoksae c8f35350-384f-4933-be59-d785dbe06abc
405 m.sadiqin46@gmail.com MuhammadSadiqin c92b26ed-ddc1-45e7-9a17-f2d2ea7b9d2f
406 gayuyunma1123@gmail.com c978891b-71d3-423a-8c97-5648304f5a47
407 akbarriffani19@gmail.com LebahRajin c9b2c04a-5027-4ccb-bd0b-2c26c7e9f619
408 ajangrahmat@gmail.com Ajang Rahmat ca299304-dfa7-49ba-aa1d-0ed259ffe0fc
409 auliyanihlah@gmail.com caa569eb-822e-418c-93aa-df23563a178c
410 razandirgham@gmail.com caaaf8b5-ac4b-40b5-9549-f0f8ec9d6cd1
411 profadlibae@gmail.com Acmad Fadli Aditama cabaade4-b256-4798-844a-78f36cb16b0b
412 atnandimas@gmail.com Adnan Rohmat K cb2161a5-7c09-4360-908b-f2e629be1cda
413 sitizahraazizah23@gmail.com Siti Zahra Azizah cc7e4a74-7c3d-44cd-af91-a1b9326cb13e
414 nurdin99.ek@gmail.com Nurdin cd64c94d-ca81-4169-bb4d-e2af2dba4a55
415 fauzanhabib56@gmail.com cd7ebe7e-a914-457f-be95-2e6256342552
416 reyhananf7@gmail.com cdab11c6-ef41-4e2c-ad9d-f9636681e9f0
417 loidthegost@gmail.com ce458e26-313e-47cd-937d-e5e95f77a136
418 omnidevv@gmail.com cf1eadaa-a313-4760-808e-37dc70446ccf
419 m.ilhamjaya1808@gmail.com Muhammad Ilham Jaya cfc296db-5cd7-4485-b8d0-ba330f5c148c
420 risbulgt@gmail.com d00b9542-2bc4-4a1c-acca-e2e58f7363e5
421 lembahku@gmail.com d15d8329-a248-4339-8090-69c40ca2a050
422 fcrizkywijaya@gmail.com Farid Rizky Wijaya d1c950e6-fb9e-4d53-9c2c-e77477a9a1eb
423 bimaadam.dev@gmail.com d263ee7e-e4fb-4b55-a5d9-0207520e0fa8
424 felixarajiph@gmail.com Rajiph iqbal d350da40-7543-4073-b6a5-95c79d705dd3
425 ahmadfawwaz103@gmail.com d396661f-c733-4991-a212-76b9e4ecb02c
426 hafizpramudya17@gmail.com Nflz d44ab980-6982-46b3-8da5-fafef0dedd9f
427 johnobama24keren@gmail.com Hiuaaaaa d4953c02-1a07-476c-b5ca-7dfd1e91d28c
428 aliakbar06110@gmail.com d4d2c768-3296-40fa-8c87-3bf0b9a54fac
429 sanjaya28051@gmail.com d5ff8a92-2b82-42b9-a143-2cd8c376c1ef
430 nawvalovsky@proton.me d731aa31-8893-4496-ad36-db71729e3419
431 agung.astanto14@gmail.com d744ffbb-7011-43b6-a4f8-7ef2f105ba12
432 yumpp24@gmail.com d7587b8e-51fb-404b-ae31-7f5a86c85119
433 novandiramadhan80@gmail.com d8f27598-3fed-47da-be14-86c59f70387a
434 sfatimaple@gmail.com Sfatimah Azzahra d937a04d-da2e-46e0-9e38-b36d244f8daa
435 andriscreen@gmail.com Andri Irawan d938e47a-5a8d-41c2-9952-c42c7d83f291
436 suryami62@outlook.com Surya Ramadhan d9a4b3e0-eed5-4c63-984f-096a8fb0a03d
437 abdalhaqqm@gmail.com Abdalhaqq Muhammad Saih d9b2f557-027c-4c2e-85e0-f157f2df4917
438 alijun.dev@gmail.com Sulfikar Alijun da349dec-eee1-4ed4-bd97-d8322f1ab36e
439 warsenosetyono@gmail.com Warseno daaf8fd0-2660-4462-990b-9eab46df6f47
440 kemasmuhammadalqodri@gmail.com db3ca0d6-67c6-48c0-9da1-e4b45ca3f921
441 fadlankurahan2@gmail.com dd022635-06ed-4ce2-85c1-7026c954e5c7
442 iqbalharimurti1@gmail.com krenz dd38bb9b-c45d-4ce2-b4b3-f99b8c8d7e82
443 obarya24@gmail.com Afdaan dde9ea50-6a95-4a73-a7cf-0c1e9e457c56
444 jexawe1022@okcdeals.com de072603-83a0-4d19-b601-d7bd25534d3d
445 mdjauharil29@gmail.com Areal de4190a8-2a66-4e96-aaf6-c3fb0ca838a7
446 diasdigital03@gmail.com Dias dea38d11-ef5f-492c-b107-17b509326138
447 apirahman55@gmail.com Api Dev df939ac3-de24-473c-8006-cf512fec4e10
448 rvlionxz@gmail.com Reval Maulidan dfad62ce-3285-4d1d-8ad6-8cb3a618a3e8
449 tsqf.hx@gmail.com e09e8094-49ae-4cb0-9e25-8cb548f6b37e
450 bloomsky133@gmail.com e18f82ff-7809-4274-b81c-b3dae3bac731
451 asepharyanalm001@gmail.com Asep Haryana Saputra e198581d-62bc-4098-8dc8-94302face1d9
452 kussokurae990@gmail.com e1dc4e80-c85e-440f-977e-32ac86ac4a8e
453 kaoricicak000@gmail.com e210703d-e0f2-493a-b976-ad02b714fd4d
454 felixvalentino58@gmail.com e21dbd95-0379-4087-84d4-735a7e280990
455 ferdialf.dev@gmail.com FerdiAlfian_ e2248b8b-b6cb-4a9c-9746-eba0bc8d03ce
456 rafiqgans32@gmail.com Ainur Rafiq e26b3480-1f75-4038-9c73-c5db5c478e08
457 thed0704@gmail.com e27f8e2b-2ba4-41c8-8ef1-1a1c8299ec38
458 voidstayprivate@proton.me Corteza Familia e4747f46-e751-4e42-8190-5c8be219d50e
459 hoshikochan93@gmail.com Rechan Dinata e5770488-35c2-4298-a97e-b79f863c0c2a
460 ytbchanelsukiran@gmail.com e582bdf9-209e-4aa0-b052-043a664a5a27
461 putraaldhyansyah@gmail.com e59ed718-2a1b-4f86-a4e1-6063384a5fc1
462 gilarwaluyo@gmail.com Abimanyu Gilar e5d0bd59-1f07-49b7-8242-4ddb620e2a8b
463 noisyboyss887@gmail.com Kangmas Darr e65bf229-f745-41b4-a54e-875a765aa745
464 farelfebryan06@gmail.com e68e50fd-fc75-42a0-8b84-70bfaf508d01
465 wisdom.wahyuaji@gmail.com Wisdom Wahyu Aji e6a9a555-7af1-4abb-8ad7-1162e3be258a
466 b19marcel@gmail.com e6c844ce-a58a-414e-b0e3-8daf404a79f5
467 devakhri.farhan@gmail.com Devakhri Farhan e725132c-af86-4da9-a220-57288a436ab2
468 haikaljitra35@gmail.com e7bec056-7f63-4642-a763-afa39c3c340e
469 evanluis198@gmail.com Evan Luis Tanjung e8d69460-4eb9-48c7-a921-4738ad7c8dc9
470 ikhwannurrizkif@gmail.com e913c217-2dde-4498-909c-4b2d5afd89d1
471 ryandraa27@gmail.com e98fbabe-4120-4d16-b11f-e447467c8ef9
472 mtaufikska@gmail.com e99519f0-630b-4293-8487-a17d667fa708
473 tudebaliirl@gmail.com TudeStillLearning! e99abe65-5ce7-49c5-930b-b7e456d55661
474 adam.daniamm@gmail.com e9b65cdd-9c7c-471d-97ac-b02f0434e87f
475 boboi0704@gmail.com e9bc3d6c-67af-4687-8b3b-a122a93710ef
476 nathantanusubroto15@gmail.com e9ef0f76-533a-4016-ab14-420934da3fe3
477 mikmself@gmail.com Muhamad Irga Khoirul Mahfis eab56176-5f01-4ff2-8dca-51bebf7771fc
478 yuenvengozatheowonata@gmail.com Yuenvengoza Theowonata eac6a2e3-017f-4c92-ae73-38bc43520640
479 dexangga06@gmail.com eb0176ef-9cd5-474d-9bc2-115afb6eb831
480 reeimu28@gmail.com Moruten eb2c7101-f5d4-453c-80e8-1f3f77d350dc
481 henryfaaa@gmail.com eb350f16-56c0-4e05-a483-7300ab363c6b
482 kelvinlihandy2005@gmail.com Kelvin Lihandy ec3d9492-cc18-497e-b63a-a1c06281bcaa
483 alyssashane2607@gmail.com ece0ce22-4193-46f0-af02-d0153e896ee4
484 yandrajafarsidiq8@gmail.com Yandra Jafar Sidiq ee2aff01-ddc0-40fd-afda-db8e0c0e9556
485 rikogans754@gmail.com eef4fdd0-0198-4749-b099-0d558ab2ce22
486 arkandi.2020@gmail.com Muhammad Arkandi Syahputra Santosa ef13c12f-2cca-4ddf-9dad-3d387a89bbf8
487 alfaris.mgk@gmail.com f01ba8b4-7ee2-4561-bc22-73bbfb712ea0
488 bilorpanas@gmail.com f05211c3-aa26-473b-a6a4-225424349e81
489 limakaki881@gmail.com yth.mas.kanjeng.gus.mas.lui.s.pd.kom.h.i.alm.h.lc.hc.pc.amd.intel.ndx.aka. f0cace35-b865-4475-9ddb-5fce24f655f2
490 arvanardana1@gmail.com Arvan Ardana f1df8edd-2549-4c98-be46-01584aa134eb
491 farreldiego29@gmail.com Farrel Diego f26c75f5-6ae7-446b-aa47-695ba13e7c7f
492 wizzkingchannel@gmail.com HabiebAnugrah f34efb80-ad1d-434f-a319-e1d54973ff12
493 adityakurniasaputra903@gmail.com Kurnias f377d789-ecf1-4a12-b72b-a8ffe30cc123
494 dav1nalifianda@gmail.com Davin Alifianda Adytia f3a25278-ec1d-496a-a89c-030c5c521111
495 rifqinanda60@gmail.com f3fef42a-9d51-40d6-b6e7-59f25bf068c8
496 notmadz132@gmail.com Mu'adz Abdulloh f40b14d5-0cc4-4605-9893-8f27dd34de71
497 qodria589@gmail.com f44ce041-e64c-4cbd-a104-83964aa37ccb
498 fahrezaxgamer@gmail.com Fahreza Pratama Hidayat f47cacb7-e099-4816-96c9-77f858414235
499 dyas@live.com Drestanto Muhammad Dyasputro f57ac77c-7ea9-4b92-aeb5-a7131a5052dc
500 ahmadgozali4040@gmail.com Ahmad Ghozali f6938963-132b-4471-90c1-2a252923b89f
501 jony.huang.me@gmail.com Jony Wijaya f786208b-28cc-4484-935f-b88f2f75d6d0
502 afrizaahmad18@gmail.com normies f7b6ebf3-3bce-46df-b18f-22763e552e6e
503 satyabr147@gmail.com f833772b-a037-48ae-90eb-c3b02a06a3b9
504 gmrikza05@gmail.com f83bbabf-f6e8-4023-9626-2a97a0c4c994
505 andikadibya92@gmail.com Andika Dibya Azzumardi f8cc3af4-3a3a-4a3d-8314-972725366ffb
506 rizkynursanto48@gmail.com Rizky Adi N. fa57208f-b1a7-4766-9067-d4531833f4b9
507 muh.indaarr@gmail.com Muhammad Indar fa7f0fc6-7449-42ac-a80f-560960345ac4
508 laluarya633@gmail.com fae5c5a9-6a26-48d6-b04d-68ebf4d81e7b
509 muhammadfadlanrais@gmail.com fbc4c505-01ef-40b2-a257-c2430fe1bdc5
510 rifkybujanabisri@gmail.com Rifky Bujana Bisri fc00d1bf-65da-431b-9994-52a3cfc88273
511 alazmimuhammadhabib@gmail.com Izumi fc05d2d3-0a55-4407-b20e-99d7bf24a3ec
512 scamgarapan@gmail.com fc22e953-b532-48b2-be6a-0fbc3b3774fb
513 fajaralfrzi@gmail.com fc701b59-0510-44f3-a85e-0cd22e9f777f
514 imanwealth00@gmail.com WebDevShoul fcad381c-ab34-4f62-b6f7-4569c24625f3
515 muhammadalifaditya92@gmail.com Alif Aditya fea8d6e4-a831-43a9-b98c-6a29c20dab53
516 udinhilif@gmail.com Rio Fernanda fef16ed6-438c-44c0-9070-66d3f602beae
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+4 -5
View File
@@ -13,11 +13,10 @@
},
"release": {
"version": {
"generatorOptions": {
"packageRoot": "dist/{projectRoot}",
"currentVersionResolver": "git-tag",
"fallbackCurrentVersionResolver": "disk"
}
"currentVersionResolver": "git-tag",
"fallbackCurrentVersionResolver": "disk",
"preserveLocalDependencyProtocols": false,
"manifestRootsToUpdate": ["dist/{projectRoot}"]
}
}
}
+13
View File
@@ -65,3 +65,16 @@ export const postGoogleCallback = async (code: string, state: string): Promise<T
});
return data;
};
export const getGitHubAuthUrl = async (): Promise<string> => {
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080';
return `${baseUrl}/auth/github/login`;
};
export const postGitHubCallback = async (code: string, state: string): Promise<TGoogleCallbackResponse> => {
const { data } = await api({
method: 'GET',
url: `/auth/github/callback?code=${code}&state=${state}`,
});
return data;
};
+114
View File
@@ -0,0 +1,114 @@
import { api } from '../index';
import type {
TCreateTeamRequest,
TUpdateTeamRequest,
TInviteMemberRequest,
TJoinTeamRequest,
TManageMemberRequest,
TSubmitProjectRequest,
TTeamListResponse,
TTeamDetailResponse,
TTeamMembersResponse,
TTeamInvitationsResponse,
TTeamJoinRequestsResponse,
TProjectSubmissionResponse,
} from '../../types/teams';
const TEAMS_BASE_URL = '/teams';
// Team CRUD
export const getTeams = async (params?: {
page?: number;
limit?: number;
city?: string;
visibility?: string;
search?: string;
}) => {
const response = await api.get<TTeamListResponse>(TEAMS_BASE_URL, { params });
return response.data;
};
export const getTeamById = async (teamId: string) => {
const response = await api.get<TTeamDetailResponse>(`${TEAMS_BASE_URL}/${teamId}`);
return response.data;
};
export const createTeam = async (data: TCreateTeamRequest) => {
const response = await api.post<TTeamDetailResponse>(TEAMS_BASE_URL, data);
return response.data;
};
export const updateTeam = async (teamId: string, data: TUpdateTeamRequest) => {
const response = await api.put<TTeamDetailResponse>(`${TEAMS_BASE_URL}/${teamId}`, data);
return response.data;
};
// Team Members
export const getTeamMembers = async (teamId: string) => {
const response = await api.get<TTeamMembersResponse>(`${TEAMS_BASE_URL}/${teamId}/members`);
return response.data;
};
export const inviteMember = async (teamId: string, data: TInviteMemberRequest) => {
const response = await api.post(`${TEAMS_BASE_URL}/${teamId}/invite`, data);
return response.data;
};
export const manageMember = async (teamId: string, userId: string, data: TManageMemberRequest) => {
const response = await api.put(`${TEAMS_BASE_URL}/${teamId}/members/${userId}`, data);
return response.data;
};
export const removeMember = async (teamId: string, userId: string) => {
const response = await api.delete(`${TEAMS_BASE_URL}/${teamId}/members/${userId}`);
return response.data;
};
// Join Requests
export const joinTeam = async (teamId: string, data: TJoinTeamRequest) => {
const response = await api.post(`${TEAMS_BASE_URL}/${teamId}/join-request`, data);
return response.data;
};
export const getTeamJoinRequests = async (teamId: string) => {
const response = await api.get<TTeamJoinRequestsResponse>(`${TEAMS_BASE_URL}/${teamId}/join-requests`);
return response.data;
};
export const respondToJoinRequest = async (teamId: string, requestId: string, action: 'approve' | 'reject') => {
const response = await api.put(`${TEAMS_BASE_URL}/${teamId}/join-requests/${requestId}`, { action });
return response.data;
};
// Invitations
export const getMyInvitations = async () => {
const response = await api.get<TTeamInvitationsResponse>(`${TEAMS_BASE_URL}/invitations/me`);
return response.data;
};
export const respondToInvitation = async (invitationId: string, action: 'accept' | 'reject') => {
const response = await api.put(`${TEAMS_BASE_URL}/invitations/${invitationId}`, { action });
return response.data;
};
// User's Teams
export const getMyTeams = async () => {
const response = await api.get<TTeamListResponse>(`${TEAMS_BASE_URL}/me`);
return response.data;
};
// Project Submission
export const submitProject = async (teamId: string, data: TSubmitProjectRequest) => {
const response = await api.post<TProjectSubmissionResponse>(`${TEAMS_BASE_URL}/${teamId}/submission`, data);
return response.data;
};
export const getTeamSubmission = async (teamId: string) => {
const response = await api.get<TProjectSubmissionResponse>(`${TEAMS_BASE_URL}/${teamId}/submission`);
return response.data;
};
export const leaveTeam = async (teamId: string) => {
const response = await api.post(`${TEAMS_BASE_URL}/${teamId}/leave`);
return response.data;
};
+83 -58
View File
@@ -1,83 +1,108 @@
import { useMutation, UseMutationResult } from '@tanstack/react-query';
import { postLogin, postRegister, postSendOtp, postVerifyEmail, getGoogleAuthUrl, postGoogleCallback } from '../../api/auth';
import {
TLoginRequest,
TLoginResponse,
TRegisterRequest,
TSendOTPRequest,
TVerifyEmailRequest,
TGoogleCallbackResponse,
} from '../../types/auth';
import { supabase } from '../../supabase';
import { useMutation } from '@tanstack/react-query';
import * as authApi from '../../api/auth';
import { TResponseError, TResponseMessage } from '../../types/common';
export * from './use-auth-store';
export const usePostLogin = (): UseMutationResult<
TLoginResponse,
TResponseError,
TLoginRequest,
unknown
> => {
// React Query hooks for auth API
export const usePostLogin = () => {
return useMutation({
mutationKey: ['post-login'],
mutationFn: async (payload) => await postLogin(payload),
mutationFn: authApi.postLogin,
});
};
export const usePostRegister = (): UseMutationResult<
TResponseMessage,
TResponseError,
TRegisterRequest,
unknown
> => {
export const usePostRegister = () => {
return useMutation({
mutationKey: ['post-register'],
mutationFn: async (payload) => await postRegister(payload),
mutationFn: authApi.postRegister,
});
};
export const usePostVerifyEmail = (): UseMutationResult<
TResponseMessage,
TResponseError,
TVerifyEmailRequest,
unknown
> => {
export const usePostVerifyEmail = () => {
return useMutation({
mutationKey: ['post-verify-email'],
mutationFn: async (payload) => await postVerifyEmail(payload),
mutationFn: authApi.postVerifyEmail,
});
};
export const usePostSendOTP = (): UseMutationResult<
TResponseMessage,
TResponseError,
TSendOTPRequest,
unknown
> => {
export const usePostSendOtp = () => {
return useMutation({
mutationKey: ['post-send-otp'],
mutationFn: async (payload) => await postSendOtp(payload),
mutationFn: authApi.postSendOtp,
});
};
export const useGoogleAuth = () => {
const redirectToGoogle = async (): Promise<string> => {
return await getGoogleAuthUrl();
export const useGoogleCallback = () => {
return useMutation({
mutationFn: ({ code, state }: { code: string; state: string }) =>
authApi.postGoogleCallback(code, state),
});
};
// Supabase GitHub OAuth hook
export const useGitHubAuth = () => {
const signInWithGitHub = async () => {
const { data, error } = await supabase.auth.signInWithOAuth({
provider: 'github',
options: {
redirectTo: `${globalThis.location.origin}/auth/callback`,
},
});
if (error) {
throw error;
}
// Return the OAuth URL for debugging
return data;
};
return {
redirectToGoogle,
signInWithGitHub,
};
};
export const useGoogleCallback = (): UseMutationResult<
TGoogleCallbackResponse,
TResponseError,
{ code: string; state: string },
unknown
> => {
return useMutation({
mutationKey: ['google-callback'],
mutationFn: async ({ code, state }) => await postGoogleCallback(code, state),
});
};
// Supabase Email/Password authentication hook
export const useEmailAuth = () => {
const signInWithEmail = async (email: string, password: string) => {
const { data, error } = await supabase.auth.signInWithPassword({
email,
password,
});
if (error) {
throw error;
}
return data;
};
const signUpWithEmail = async (email: string, password: string, fullname: string) => {
const { data, error } = await supabase.auth.signUp({
email,
password,
options: {
data: {
full_name: fullname,
},
},
});
if (error) {
throw error;
}
return data;
};
const signOut = async () => {
const { error } = await supabase.auth.signOut();
if (error) {
throw error;
}
};
return {
signInWithEmail,
signUpWithEmail,
signOut,
};
};
@@ -0,0 +1,46 @@
import { create } from 'zustand';
import { TLoginItem } from '../../types';
import { SessionToken, SessionUser } from '../../storage';
export enum ESessionStatus {
Authenticated = 'authenticated',
Authenticating = 'authenticating',
Unauthenticated = 'unauthenticated',
}
type SessionState = {
isLoading: boolean;
session?: TLoginItem;
status?: ESessionStatus;
setLoading: (val: boolean) => void;
setSession: (payload: TLoginItem) => void;
clearSession: () => void;
};
export const useAuthStore = create<SessionState>((set) => {
const session = SessionToken.get();
const user = SessionUser.get();
const isAuthenticated = !!session;
return {
isLoading: false,
session: isAuthenticated ? { token: session.token, user } : undefined,
status: isAuthenticated
? ESessionStatus.Authenticated
: ESessionStatus.Unauthenticated,
setLoading: (val) => set({ isLoading: val }),
setSession: (data) => {
SessionToken.set({ token: data.token });
SessionUser.set(data.user);
set({
session: data,
status: ESessionStatus.Authenticated,
});
},
clearSession: () => {
SessionToken.remove();
SessionUser.remove();
set({ session: undefined, status: ESessionStatus.Unauthenticated });
},
};
});
+2
View File
@@ -3,3 +3,5 @@ export * from './gacha';
export * from './users';
export * from './mentors';
export * from './upload';
export * from './teams';
export * from './messages';
+181
View File
@@ -0,0 +1,181 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { supabase } from '../../supabase';
import { useAuthStore } from '../auth';
import { useEffect } from 'react';
export type Message = {
id: string;
team_id: string;
user_id: string;
message: string;
created_at: string;
updated_at: string;
user?: {
id: string;
fullname: string;
avatar: string;
email: string;
};
};
// Query keys
export const messageKeys = {
all: ['messages'] as const,
team: (teamId: string) => [...messageKeys.all, 'team', teamId] as const,
};
// Fetch messages for a team
export const useTeamMessages = (teamId: string) => {
const queryClient = useQueryClient();
const query = useQuery({
queryKey: messageKeys.team(teamId),
queryFn: async () => {
const { data: messages, error } = await supabase
.from('team_messages')
.select(`
id,
team_id,
user_id,
message,
created_at,
updated_at,
user:users(id, fullname, avatar, email)
`)
.eq('team_id', teamId)
.order('created_at', { ascending: true });
if (error) {
console.error('Failed to fetch messages:', error);
throw new Error(error.message || 'Failed to fetch messages');
}
return messages as Message[];
},
enabled: !!teamId,
});
// Subscribe to realtime updates
useEffect(() => {
if (!teamId) return;
const channel = supabase
.channel(`team_messages:${teamId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'team_messages',
filter: `team_id=eq.${teamId}`,
},
async (payload) => {
console.log('[Realtime] New message:', payload);
// Fetch the full message with user data
const { data: newMessage } = await supabase
.from('team_messages')
.select(`
id,
team_id,
user_id,
message,
created_at,
updated_at,
user:users(id, fullname, avatar, email)
`)
.eq('id', payload.new.id)
.single();
if (newMessage) {
queryClient.setQueryData<Message[]>(
messageKeys.team(teamId),
(old) => [...(old || []), newMessage as Message]
);
}
}
)
.on(
'postgres_changes',
{
event: 'DELETE',
schema: 'public',
table: 'team_messages',
filter: `team_id=eq.${teamId}`,
},
(payload) => {
console.log('[Realtime] Message deleted:', payload);
queryClient.setQueryData<Message[]>(
messageKeys.team(teamId),
(old) => old?.filter((msg) => msg.id !== payload.old.id) || []
);
}
)
.subscribe();
return () => {
supabase.removeChannel(channel);
};
}, [teamId, queryClient]);
return query;
};
// Send a message
export const useSendMessage = (teamId: string) => {
const queryClient = useQueryClient();
const { session } = useAuthStore();
return useMutation({
mutationFn: async (message: string) => {
if (!session?.user?.id) {
throw new Error('You must be logged in to send messages');
}
const { data, error } = await supabase
.from('team_messages')
.insert({
team_id: teamId,
user_id: session.user.id,
message,
})
.select()
.single();
if (error) {
console.error('Failed to send message:', error);
throw new Error(error.message || 'Failed to send message');
}
return data;
},
onSuccess: () => {
// Realtime will handle adding the message to the list
// But we can invalidate to ensure consistency
queryClient.invalidateQueries({ queryKey: messageKeys.team(teamId) });
},
});
};
// Delete a message
export const useDeleteMessage = (teamId: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (messageId: string) => {
const { error } = await supabase
.from('team_messages')
.delete()
.eq('id', messageId);
if (error) {
console.error('Failed to delete message:', error);
throw new Error(error.message || 'Failed to delete message');
}
},
onSuccess: () => {
// Realtime will handle removing the message from the list
queryClient.invalidateQueries({ queryKey: messageKeys.team(teamId) });
},
});
};
+692
View File
@@ -0,0 +1,692 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import * as teamsApi from '../../api/teams';
import { supabase, getAuthenticatedClient } from '../../supabase';
import { useAuthStore } from '../auth';
import type {
TCreateTeamRequest,
TUpdateTeamRequest,
TInviteMemberRequest,
TJoinTeamRequest,
TSubmitProjectRequest,
} from '../../types/teams';
// Query keys
export const teamKeys = {
all: ['teams'] as const,
lists: () => [...teamKeys.all, 'list'] as const,
list: (filters?: Record<string, unknown>) => [...teamKeys.lists(), filters] as const,
details: () => [...teamKeys.all, 'detail'] as const,
detail: (id: string) => [...teamKeys.details(), id] as const,
members: (id: string) => [...teamKeys.detail(id), 'members'] as const,
joinRequests: (id: string) => [...teamKeys.detail(id), 'join-requests'] as const,
submission: (id: string) => [...teamKeys.detail(id), 'submission'] as const,
myTeams: () => [...teamKeys.all, 'my-teams'] as const,
myInvitations: () => [...teamKeys.all, 'my-invitations'] as const,
};
// Team CRUD Hooks
export const useTeams = (params?: {
page?: number;
limit?: number;
city?: string;
visibility?: string;
search?: string;
}) => {
return useQuery({
queryKey: teamKeys.list(params),
queryFn: async () => {
try {
// Supabase client now has auth context from setSession()
let query = supabase.from('teams').select('*');
// Filter by visibility
if (params?.visibility) {
query = query.eq('visibility', params.visibility);
}
// Filter by city
if (params?.city) {
query = query.eq('city', params.city);
}
// Search by name
if (params?.search) {
query = query.ilike('name', `%${params.search}%`);
}
// Pagination
if (params?.page && params?.limit) {
const from = (params.page - 1) * params.limit;
const to = from + params.limit - 1;
query = query.range(from, to);
}
const { data, error } = await query;
if (error) {
// If error is 401/403, it means RLS policies need to be set up
// Return empty array for now
console.warn('Teams query error (RLS policies may need to be configured):', error);
return { data: [] };
}
return { data: data || [] };
} catch (err) {
console.error('Failed to fetch teams:', err);
return { data: [] };
}
},
});
};
export const useTeamById = (teamId: string, enabled = true) => {
return useQuery({
queryKey: teamKeys.detail(teamId),
queryFn: async () => {
// Supabase client now has auth context from setSession()
const { data: team, error } = await supabase
.from('teams')
.select(`
*,
leader:users!leader_id(id, email, fullname, avatar),
members:team_members(
id,
role,
status,
joined_at,
user:users(id, email, fullname, avatar)
)
`)
.eq('id', teamId)
.single();
if (error) {
console.error('Failed to fetch team:', error);
throw new Error(error.message || 'Failed to fetch team');
}
// Count active members
const activeMemberCount = team?.members?.filter((m: any) => m.status === 'active').length || 0;
// Check if team has a submission
const { data: submission } = await supabase
.from('project_submissions')
.select('id')
.eq('team_id', teamId)
.maybeSingle();
return {
data: {
...team,
member_count: activeMemberCount,
has_submission: !!submission,
},
};
},
enabled: enabled && !!teamId,
});
};
export const useCreateTeam = () => {
const queryClient = useQueryClient();
const { session } = useAuthStore();
return useMutation({
mutationFn: async (data: TCreateTeamRequest) => {
if (!session?.user?.id) {
throw new Error('You must be logged in to create a team');
}
// Supabase client now has auth context from setSession()
const { data: team, error: teamError } = await supabase
.from('teams')
.insert({
name: data.name,
logo: data.logo,
banner: data.banner,
description: data.description,
city: data.city,
visibility: data.visibility,
leader_id: session.user.id,
})
.select()
.single();
if (teamError) {
console.error('Failed to create team:', teamError);
throw new Error(teamError.message || 'Failed to create team');
}
// Insert team creator as leader in team_members table
const { error: memberError } = await supabase
.from('team_members')
.insert({
team_id: team.id,
user_id: session.user.id,
role: 'leader',
status: 'active',
});
if (memberError) {
// If duplicate key error (23505), it means leader is already a member (possibly by trigger)
// This is acceptable, so we can ignore it
if (memberError.code === '23505') {
console.log('Team leader already exists in team_members (likely added by trigger)');
} else {
// For other errors, clean up and throw
console.error('Failed to add team leader as member:', memberError);
await supabase.from('teams').delete().eq('id', team.id);
throw new Error('Failed to set up team membership');
}
}
return { data: team };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
queryClient.invalidateQueries({ queryKey: teamKeys.myTeams() });
},
});
};
export const useUpdateTeam = (teamId: string) => {
const queryClient = useQueryClient();
const { session } = useAuthStore();
return useMutation({
mutationFn: async (data: TUpdateTeamRequest) => {
if (!session?.user?.id) {
throw new Error('You must be logged in to update a team');
}
// Supabase client now has auth context from setSession()
const { data: team, error } = await supabase
.from('teams')
.update({
name: data.name,
logo: data.logo,
banner: data.banner,
description: data.description,
city: data.city,
visibility: data.visibility,
})
.eq('id', teamId)
.select()
.single();
if (error) {
console.error('Failed to update team:', error);
throw new Error(error.message || 'Failed to update team');
}
return { data: team };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.detail(teamId) });
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
},
});
};
// Team Members Hooks
export const useTeamMembers = (teamId: string, enabled = true) => {
return useQuery({
queryKey: teamKeys.members(teamId),
queryFn: async () => {
// Supabase client now has auth context from setSession()
const { data: members, error } = await supabase
.from('team_members')
.select(`
id,
team_id,
user_id,
role,
status,
joined_at,
user:users(id, email, fullname, avatar)
`)
.eq('team_id', teamId)
.order('joined_at', { ascending: true });
if (error) {
console.error('Failed to fetch team members:', error);
throw new Error(error.message || 'Failed to fetch team members');
}
return { data: members || [] };
},
enabled: enabled && !!teamId,
});
};
export const useInviteMember = (teamId: string) => {
const queryClient = useQueryClient();
const { session } = useAuthStore();
return useMutation({
mutationFn: async (data: TInviteMemberRequest) => {
if (!session?.user?.id) {
throw new Error('You must be logged in to invite a member');
}
// Insert invitation into team_invitations table
const { data: invitation, error } = await supabase
.from('team_invitations')
.insert({
team_id: teamId,
inviter_id: session.user.id,
invitee_email: data.email,
status: 'pending',
})
.select()
.single();
if (error) {
console.error('Failed to create invitation:', error);
throw new Error(error.message || 'Failed to send invitation');
}
return { data: invitation };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.members(teamId) });
},
});
};
export const useManageMember = (teamId: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ userId, data }: { userId: string; data: any }) =>
teamsApi.manageMember(teamId, userId, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.members(teamId) });
queryClient.invalidateQueries({ queryKey: teamKeys.detail(teamId) });
},
});
};
export const useRemoveMember = (teamId: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (userId: string) => teamsApi.removeMember(teamId, userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.members(teamId) });
queryClient.invalidateQueries({ queryKey: teamKeys.detail(teamId) });
},
});
};
// Join Requests Hooks
export const useJoinTeam = () => {
const queryClient = useQueryClient();
const { session } = useAuthStore();
return useMutation({
mutationFn: async ({ teamId, data }: { teamId: string; data: TJoinTeamRequest }) => {
if (!session?.user?.id) {
throw new Error('You must be logged in to join a team');
}
// Insert join request into team_join_requests table
const { data: joinRequest, error } = await supabase
.from('team_join_requests')
.insert({
team_id: teamId,
user_id: session.user.id,
message: data.message,
status: 'pending',
})
.select()
.single();
if (error) {
console.error('Failed to create join request:', error);
throw new Error(error.message || 'Failed to send join request');
}
return { data: joinRequest };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
},
});
};
export const useTeamJoinRequests = (teamId: string, enabled = true) => {
return useQuery({
queryKey: teamKeys.joinRequests(teamId),
queryFn: async () => {
// Fetch join requests with user information
const { data: requests, error } = await supabase
.from('team_join_requests')
.select(`
id,
team_id,
user_id,
message,
status,
created_at,
user:users(id, email, fullname, avatar)
`)
.eq('team_id', teamId)
.order('created_at', { ascending: false });
if (error) {
console.error('Failed to fetch join requests:', error);
throw new Error(error.message || 'Failed to fetch join requests');
}
return { data: requests || [] };
},
enabled: enabled && !!teamId,
});
};
export const useRespondToJoinRequest = (teamId: string) => {
const queryClient = useQueryClient();
const { session } = useAuthStore();
return useMutation({
mutationFn: async ({ requestId, action }: { requestId: string; action: 'approve' | 'reject' }) => {
if (!session?.user?.id) {
throw new Error('You must be logged in to respond to join requests');
}
// First, get the join request details
const { data: joinRequest, error: fetchError } = await supabase
.from('team_join_requests')
.select('id, team_id, user_id, status')
.eq('id', requestId)
.single();
if (fetchError || !joinRequest) {
throw new Error('Join request not found');
}
if (joinRequest.status !== 'pending') {
throw new Error('Join request has already been processed');
}
if (action === 'approve') {
// Update join request status
const { error: updateError } = await supabase
.from('team_join_requests')
.update({ status: 'accepted' })
.eq('id', requestId);
if (updateError) {
throw new Error('Failed to update join request: ' + updateError.message);
}
// Add user as team member
const { error: memberError } = await supabase
.from('team_members')
.insert({
team_id: joinRequest.team_id,
user_id: joinRequest.user_id,
role: 'member',
status: 'active',
});
if (memberError) {
// If member creation fails, rollback join request update
await supabase
.from('team_join_requests')
.update({ status: 'pending' })
.eq('id', requestId);
throw new Error('Failed to add member to team: ' + memberError.message);
}
return { success: true, action: 'accepted' };
} else {
// Reject join request
const { error: updateError } = await supabase
.from('team_join_requests')
.update({ status: 'rejected' })
.eq('id', requestId);
if (updateError) {
throw new Error('Failed to update join request: ' + updateError.message);
}
return { success: true, action: 'rejected' };
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.joinRequests(teamId) });
queryClient.invalidateQueries({ queryKey: teamKeys.members(teamId) });
queryClient.invalidateQueries({ queryKey: teamKeys.detail(teamId) });
},
});
};
// Invitations Hooks
export const useMyInvitations = () => {
const { session } = useAuthStore();
return useQuery({
queryKey: teamKeys.myInvitations(),
queryFn: async () => {
if (!session?.user?.email) {
return { data: [] };
}
// Query team_invitations where invitee_email matches current user's email
const { data: invitations, error } = await supabase
.from('team_invitations')
.select(`
id,
team_id,
inviter_id,
invitee_email,
invitee_id,
status,
created_at,
team:teams(
id,
name,
logo,
banner,
description,
city,
visibility,
leader_id
),
inviter:users!team_invitations_inviter_id_fkey(
id,
fullname,
email,
avatar
)
`)
.eq('invitee_email', session.user.email)
.eq('status', 'pending');
if (error) {
console.error('Failed to fetch invitations:', error);
return { data: [] };
}
return { data: invitations || [] };
},
enabled: !!session?.user?.email,
});
};
export const useRespondToInvitation = () => {
const queryClient = useQueryClient();
const { session } = useAuthStore();
return useMutation({
mutationFn: async ({ invitationId, action }: { invitationId: string; action: 'accept' | 'reject' }) => {
if (!session?.user?.id) {
throw new Error('User not authenticated');
}
// First, get the invitation details
const { data: invitation, error: fetchError } = await supabase
.from('team_invitations')
.select('id, team_id, invitee_email, status')
.eq('id', invitationId)
.single();
if (fetchError || !invitation) {
throw new Error('Invitation not found');
}
if (invitation.status !== 'pending') {
throw new Error('Invitation has already been responded to');
}
if (action === 'accept') {
// Update invitation status and set invitee_id
const { error: updateError } = await supabase
.from('team_invitations')
.update({
status: 'accepted',
invitee_id: session.user.id,
})
.eq('id', invitationId);
if (updateError) {
throw new Error('Failed to update invitation: ' + updateError.message);
}
// Create team_members record
const { error: memberError } = await supabase
.from('team_members')
.insert({
team_id: invitation.team_id,
user_id: session.user.id,
role: 'member',
status: 'active',
});
if (memberError) {
// If member creation fails, rollback invitation update
await supabase
.from('team_invitations')
.update({
status: 'pending',
invitee_id: null,
})
.eq('id', invitationId);
throw new Error('Failed to add member to team: ' + memberError.message);
}
return { success: true, action: 'accepted' };
} else {
// Reject invitation
const { error: updateError } = await supabase
.from('team_invitations')
.update({
status: 'rejected',
invitee_id: session.user.id,
})
.eq('id', invitationId);
if (updateError) {
throw new Error('Failed to update invitation: ' + updateError.message);
}
return { success: true, action: 'rejected' };
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.myInvitations() });
queryClient.invalidateQueries({ queryKey: teamKeys.myTeams() });
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
},
});
};
// User's Teams
export const useMyTeams = () => {
const { session } = useAuthStore();
return useQuery({
queryKey: teamKeys.myTeams(),
queryFn: async () => {
if (!session?.user?.id) {
return { data: [] };
}
// Query team_members to find teams where user is a member
const { data: memberships, error: membershipsError } = await supabase
.from('team_members')
.select(`
team_id,
team:teams(
id,
name,
logo,
banner,
description,
city,
visibility,
leader_id,
created_at
)
`)
.eq('user_id', session.user.id)
.eq('status', 'active');
if (membershipsError) {
console.error('Failed to fetch user teams:', membershipsError);
return { data: [] };
}
// Extract teams from memberships
const teams = memberships?.map((m: any) => m.team).filter(Boolean) || [];
return { data: teams };
},
enabled: !!session?.user?.id,
});
};
// Project Submission Hooks
export const useSubmitProject = (teamId: string) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: TSubmitProjectRequest) => {
return await teamsApi.submitProject(teamId, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.submission(teamId) });
queryClient.invalidateQueries({ queryKey: teamKeys.detail(teamId) });
},
});
};
export const useTeamSubmission = (teamId: string, enabled = true) => {
return useQuery({
queryKey: teamKeys.submission(teamId),
queryFn: async () => {
const submission = await teamsApi.getTeamSubmission(teamId);
return { data: submission };
},
enabled: enabled && !!teamId,
});
};
// Leave Team Hook
export const useLeaveTeam = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (teamId: string) => {
return await teamsApi.leaveTeam(teamId);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: teamKeys.myTeams() });
queryClient.invalidateQueries({ queryKey: teamKeys.lists() });
},
});
};
+104 -24
View File
@@ -1,39 +1,119 @@
import { useMutation, UseMutationResult } from '@tanstack/react-query';
import { uploadService, UploadResponse } from '../../api/upload';
import { TResponseError } from '../../types/common';
import { useMutation } from '@tanstack/react-query';
import { supabase, getAuthenticatedClient } from '../../supabase';
import { useAuthStore } from '../auth';
// Supabase Storage-based upload hooks
export const useUploadFile = () => {
const { session } = useAuthStore();
export const useUploadFile = (): UseMutationResult<
UploadResponse,
TResponseError,
File,
unknown
> => {
return useMutation({
mutationKey: ['upload-file'],
mutationFn: (file) => uploadService.uploadFile(file),
mutationFn: async (file: File) => {
if (!session?.user?.id) {
throw new Error('You must be logged in to upload files');
}
// Generate a unique file name
const fileExt = file.name.split('.').pop();
const fileName = `${session.user.id}-${Date.now()}.${fileExt}`;
const filePath = `teams/${fileName}`;
// Supabase client now has auth context from setSession()
const { error } = await supabase.storage
.from('hackathon-uploads')
.upload(filePath, file, {
cacheControl: '3600',
upsert: true,
});
if (error) {
console.error('Failed to upload file:', error);
throw new Error(error.message || 'Failed to upload file');
}
// Get public URL
const { data: publicUrlData } = supabase.storage
.from('hackathon-uploads')
.getPublicUrl(filePath);
return { data: { url: publicUrlData.publicUrl } };
},
});
};
export const useUploadAvatar = (): UseMutationResult<
UploadResponse,
TResponseError,
File,
unknown
> => {
export const useUploadAvatar = () => {
const { session } = useAuthStore();
return useMutation({
mutationKey: ['upload-avatar'],
mutationFn: (file) => uploadService.uploadAvatar(file),
mutationFn: async (file: File) => {
if (!session?.user?.id) {
throw new Error('You must be logged in to upload avatar');
}
// Generate a unique file name
const fileExt = file.name.split('.').pop();
const fileName = `${session.user.id}-${Date.now()}.${fileExt}`;
const filePath = `avatars/${fileName}`;
// Supabase client now has auth context from setSession()
const { error } = await supabase.storage
.from('hackathon-uploads')
.upload(filePath, file, {
cacheControl: '3600',
upsert: true,
});
if (error) {
console.error('Failed to upload avatar:', error);
throw new Error(error.message || 'Failed to upload avatar');
}
// Get public URL
const { data: publicUrlData } = supabase.storage
.from('hackathon-uploads')
.getPublicUrl(filePath);
return { data: { url: publicUrlData.publicUrl } };
},
});
};
export const useUploadCV = (): UseMutationResult<
UploadResponse,
TResponseError,
File,
unknown
> => {
export const useUploadCV = () => {
const { session } = useAuthStore();
return useMutation({
mutationKey: ['upload-cv'],
mutationFn: (file) => uploadService.uploadCV(file),
mutationFn: async (file: File) => {
if (!session?.user?.id) {
throw new Error('You must be logged in to upload CV');
}
// Generate a unique file name
const fileExt = file.name.split('.').pop();
const fileName = `${session.user.id}-${Date.now()}.${fileExt}`;
const filePath = `cvs/${fileName}`;
// Supabase client now has auth context from setSession()
const { error } = await supabase.storage
.from('hackathon-uploads')
.upload(filePath, file, {
cacheControl: '3600',
upsert: true,
});
if (error) {
console.error('Failed to upload CV:', error);
throw new Error(error.message || 'Failed to upload CV');
}
// Get public URL
const { data: publicUrlData } = supabase.storage
.from('hackathon-uploads')
.getPublicUrl(filePath);
return { data: { url: publicUrlData.publicUrl } };
},
});
};
+73 -23
View File
@@ -1,44 +1,94 @@
import { useQuery, useMutation, UseQueryResult, UseMutationResult, UseQueryOptions } from '@tanstack/react-query';
import { userService, UserDetailResponseDto, UserUpdateRequestDto } from '../../api/users';
import { TResponseError } from '../../types/common';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { userService } from '../../api/users';
import { supabase, getAuthenticatedClient } from '../../supabase';
import { useAuthStore } from '../auth';
export const useUserMe = (options?: UseQueryOptions<UserDetailResponseDto, TResponseError>): UseQueryResult<UserDetailResponseDto, TResponseError> => {
// Supabase-based user hooks
export const useUserMe = () => {
return useQuery({
queryKey: ['user-me'],
queryFn: () => userService.getUserMe(),
...options,
queryFn: async () => {
const user = await userService.getUserMe();
return { data: user };
},
});
};
export const useUserById = (id: string, options?: UseQueryOptions<UserDetailResponseDto, TResponseError>): UseQueryResult<UserDetailResponseDto, TResponseError> => {
export const useUserById = (id: string) => {
return useQuery({
queryKey: ['user-by-id', id],
queryFn: () => userService.getUserById(id),
queryFn: async () => {
const user = await userService.getUserById(id);
return { data: user };
},
enabled: !!id,
...options,
});
};
export const useUpdateUserMe = (): UseMutationResult<
UserDetailResponseDto,
TResponseError,
UserUpdateRequestDto,
unknown
> => {
export const useUpdateUserMe = () => {
const queryClient = useQueryClient();
const { session, setSession } = useAuthStore();
return useMutation({
mutationKey: ['update-user-me'],
mutationFn: (data) => userService.updateUserMe(data),
mutationFn: async (data: any) => {
if (!session?.user?.id) {
throw new Error('You must be logged in to update profile');
}
// Supabase client now has auth context from setSession()
const { data: updatedUser, error } = await supabase
.from('users')
.update({
fullname: data.fullname,
bio: data.bio,
location: data.location,
avatar: data.avatar,
skills: data.skills,
updated_at: new Date().toISOString(),
})
.eq('id', session.user.id)
.select()
.single();
if (error) {
console.error('Failed to update user profile:', error);
throw new Error(error.message || 'Failed to update profile');
}
return { data: updatedUser };
},
onSuccess: (result) => {
// Update Zustand session store with new user data
if (session && result.data) {
setSession({
token: session.token,
user: {
...session.user,
fullname: result.data.fullname || session.user.fullname,
bio: result.data.bio || '',
location: result.data.location || '',
avatar: result.data.avatar || session.user.avatar,
skills: result.data.skills || [],
},
});
}
queryClient.invalidateQueries({ queryKey: ['user-me'] });
},
});
};
export const useUpdateUserById = (): UseMutationResult<
UserDetailResponseDto,
TResponseError,
{ id: string; data: UserUpdateRequestDto },
unknown
> => {
export const useUpdateUserById = () => {
const queryClient = useQueryClient();
return useMutation({
mutationKey: ['update-user-by-id'],
mutationFn: ({ id, data }) => userService.updateUserById(id, data),
mutationFn: async ({ id, data }: { id: string; data: any }) => {
const updated = await userService.updateUserById(id, data);
return { data: updated };
},
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: ['user-by-id', variables.id] });
},
});
};
+2
View File
@@ -2,3 +2,5 @@ export * from './api';
export * from './hooks';
export * from './types';
export * from './schemas';
export * from './supabase';
export * from './storage';
+1
View File
@@ -1,2 +1,3 @@
export * from './auth';
export * from './gacha';
export * from './teams';
+81
View File
@@ -0,0 +1,81 @@
import { z } from 'zod';
import { ETeamVisibility } from '../../types/teams';
export const teamCreateSchema = z.object({
name: z
.string()
.min(3, 'Nama tim minimal 3 karakter')
.max(50, 'Nama tim maksimal 50 karakter'),
logo: z.string().url('Logo harus berupa URL yang valid').nullable(),
banner: z.string().url('Banner harus berupa URL yang valid').nullable(),
description: z
.string()
.min(10, 'Deskripsi minimal 10 karakter')
.max(500, 'Deskripsi maksimal 500 karakter'),
city: z.string().min(1, 'Kota harus diisi'),
visibility: z.nativeEnum(ETeamVisibility, {
errorMap: () => ({ message: 'Visibilitas tidak valid' }),
}),
});
export const teamUpdateSchema = z.object({
name: z
.string()
.min(3, 'Nama tim minimal 3 karakter')
.max(50, 'Nama tim maksimal 50 karakter')
.optional(),
logo: z.string().url('Logo harus berupa URL yang valid').nullable().optional(),
banner: z.string().url('Banner harus berupa URL yang valid').nullable().optional(),
description: z
.string()
.min(10, 'Deskripsi minimal 10 karakter')
.max(500, 'Deskripsi maksimal 500 karakter')
.optional(),
city: z.string().min(1, 'Kota harus diisi').optional(),
visibility: z
.nativeEnum(ETeamVisibility, {
errorMap: () => ({ message: 'Visibilitas tidak valid' }),
})
.optional(),
});
export const inviteMemberSchema = z.object({
email: z.string().email('Email tidak valid'),
});
export const joinTeamSchema = z.object({
message: z
.string()
.min(10, 'Pesan minimal 10 karakter')
.max(200, 'Pesan maksimal 200 karakter'),
});
export const projectSubmissionSchema = z.object({
project_name: z
.string()
.min(3, 'Nama project minimal 3 karakter')
.max(100, 'Nama project maksimal 100 karakter'),
description: z
.string()
.min(20, 'Deskripsi minimal 20 karakter')
.max(1000, 'Deskripsi maksimal 1000 karakter'),
repository_url: z.string().url('URL repository tidak valid'),
demo_url: z.string().url('URL demo tidak valid').optional().or(z.literal('')),
presentation_url: z.string().url('URL presentasi tidak valid').optional().or(z.literal('')),
screenshots: z.array(z.string().url('URL screenshot tidak valid')).optional(),
});
export const userOnboardingSchema = z.object({
fullname: z.string().min(3, 'Nama lengkap minimal 3 karakter').optional(),
avatar: z.string().url('Avatar harus berupa URL yang valid').nullable().optional(),
location: z.string().min(1, 'Domisili harus diisi'),
bio: z.string().max(500, 'Bio maksimal 500 karakter').optional(),
skills: z.array(z.string()).optional(),
});
export type TTeamCreateForm = z.infer<typeof teamCreateSchema>;
export type TTeamUpdateForm = z.infer<typeof teamUpdateSchema>;
export type TInviteMemberForm = z.infer<typeof inviteMemberSchema>;
export type TJoinTeamForm = z.infer<typeof joinTeamSchema>;
export type TProjectSubmissionForm = z.infer<typeof projectSubmissionSchema>;
export type TUserOnboardingForm = z.infer<typeof userOnboardingSchema>;
+36
View File
@@ -0,0 +1,36 @@
import Cookies from 'js-cookie';
type TTokenItem = {
access_token: string;
refresh_token: string;
};
const TOKEN_KEY = 'token';
type TStoredToken =
| {
token?: TTokenItem;
}
| undefined;
export const SessionToken = {
set: (val: TStoredToken) => {
Cookies.set(TOKEN_KEY, JSON.stringify(val), {
secure: true,
sameSite: 'Strict',
expires: 7,
});
},
get: (): TStoredToken => {
const token = Cookies.get(TOKEN_KEY);
if (!token) return undefined;
try {
return JSON.parse(token);
} catch {
return undefined;
}
},
remove: () => {
Cookies.remove(TOKEN_KEY);
},
};
+2
View File
@@ -0,0 +1,2 @@
export * from './cookies';
export * from './local-storage';
+33
View File
@@ -0,0 +1,33 @@
import type { TPermissionItem } from '../types/permissions';
type TRoleItem = {
id: string;
name: string;
created_at: string;
updated_at: string;
permissions: TPermissionItem[];
};
type TUserItem = {
id: string;
avatar: string;
birthdate: string;
email: string;
fullname: string;
gender: string;
is_active: boolean;
phone_number: string;
role: TRoleItem;
bio?: string;
location?: string;
skills?: string[];
};
export const SessionUser = {
set: (val?: TUserItem) => localStorage.setItem('users', JSON.stringify(val)),
get: (): TUserItem | undefined => {
const users = localStorage.getItem('users');
return users ? JSON.parse(users) : undefined;
},
remove: () => localStorage.removeItem('users'),
};
+41
View File
@@ -0,0 +1,41 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL;
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY;
if (!supabaseUrl || !supabaseAnonKey) {
throw new Error('Missing Supabase environment variables');
}
// Create Supabase client with proper session management enabled
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
autoRefreshToken: true, // ✅ Auto-refresh expired tokens
persistSession: true, // ✅ Persist session in storage
detectSessionInUrl: true, // ✅ Auto-detect OAuth callback
storage: typeof window !== 'undefined' ? window.localStorage : undefined,
},
global: {
headers: {
'X-Client-Info': 'supabase-js-web',
},
},
});
// Helper to create authenticated client (kept for backward compatibility)
// NOTE: With proper session management, this should no longer be needed
// Once session is set via supabase.auth.setSession(), the base client will have auth context
export const getAuthenticatedClient = (accessToken: string) => {
return createClient(supabaseUrl, supabaseAnonKey, {
auth: {
autoRefreshToken: false,
persistSession: false,
detectSessionInUrl: false,
},
global: {
headers: {
Authorization: `Bearer ${accessToken}`,
},
},
});
};
+1
View File
@@ -0,0 +1 @@
export * from './client';
+1
View File
@@ -4,3 +4,4 @@ export * from './users';
export * from './roles';
export * from './permissions';
export * from './mentors';
export * from './teams';
+114
View File
@@ -0,0 +1,114 @@
export type Json =
| string
| number
| boolean
| null
| { [key: string]: Json | undefined }
| Json[]
export type Database = {
public: {
Tables: Record<string, never>
Views: Record<string, never>
Functions: Record<string, never>
Enums: Record<string, never>
CompositeTypes: Record<string, never>
}
}
type PublicSchema = Database[Extract<keyof Database, "public">]
export type Tables<
PublicTableNameOrOptions extends
| keyof (PublicSchema["Tables"] & PublicSchema["Views"])
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
Database[PublicTableNameOrOptions["schema"]]["Views"])
: never = never
> = PublicTableNameOrOptions extends { schema: keyof Database }
? (Database[PublicTableNameOrOptions["schema"]]["Tables"] &
Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends {
Row: infer R
}
? R
: never
: PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] &
PublicSchema["Views"])
? (PublicSchema["Tables"] &
PublicSchema["Views"])[PublicTableNameOrOptions] extends {
Row: infer R
}
? R
: never
: never
export type TablesInsert<
PublicTableNameOrOptions extends
| keyof PublicSchema["Tables"]
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
: never = never
> = PublicTableNameOrOptions extends { schema: keyof Database }
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Insert: infer I
}
? I
: never
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
Insert: infer I
}
? I
: never
: never
export type TablesUpdate<
PublicTableNameOrOptions extends
| keyof PublicSchema["Tables"]
| { schema: keyof Database },
TableName extends PublicTableNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"]
: never = never
> = PublicTableNameOrOptions extends { schema: keyof Database }
? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends {
Update: infer U
}
? U
: never
: PublicTableNameOrOptions extends keyof PublicSchema["Tables"]
? PublicSchema["Tables"][PublicTableNameOrOptions] extends {
Update: infer U
}
? U
: never
: never
export type Enums<
PublicEnumNameOrOptions extends
| keyof PublicSchema["Enums"]
| { schema: keyof Database },
EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database }
? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"]
: never = never
> = PublicEnumNameOrOptions extends { schema: keyof Database }
? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName]
: PublicEnumNameOrOptions extends keyof PublicSchema["Enums"]
? PublicSchema["Enums"][PublicEnumNameOrOptions]
: never
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof PublicSchema["CompositeTypes"]
| { schema: keyof Database },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof Database
}
? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"]
: never = never
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof PublicSchema["CompositeTypes"]
? PublicSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions]
: never
+157
View File
@@ -0,0 +1,157 @@
import type { TUserItem } from '../users';
export enum ETeamVisibility {
PUBLIC = 'public',
PRIVATE = 'private',
}
export enum ETeamMemberRole {
LEADER = 'leader',
MEMBER = 'member',
}
export enum ETeamMemberStatus {
ACTIVE = 'active',
PENDING = 'pending',
REQUESTED = 'requested',
}
export enum EInvitationStatus {
PENDING = 'pending',
ACCEPTED = 'accepted',
REJECTED = 'rejected',
}
export enum ESubmissionStatus {
DRAFT = 'draft',
SUBMITTED = 'submitted',
}
export type TTeamItem = {
id: string;
name: string;
logo: string | null;
banner: string | null;
description: string;
city: string;
visibility: ETeamVisibility;
leader_id: string;
created_at: string;
updated_at: string;
};
export type TTeamDetailItem = TTeamItem & {
leader: TUserItem;
members: TTeamMemberItem[];
member_count: number;
has_submission: boolean;
};
export type TTeamMemberItem = {
id: string;
team_id: string;
user_id: string;
role: ETeamMemberRole;
status: ETeamMemberStatus;
joined_at: string;
user: TUserItem;
};
export type TTeamInvitationItem = {
id: string;
team_id: string;
inviter_id: string;
invitee_email: string;
invitee_id: string | null;
status: EInvitationStatus;
created_at: string;
team: TTeamItem;
inviter: TUserItem;
};
export type TTeamJoinRequestItem = {
id: string;
team_id: string;
user_id: string;
status: EInvitationStatus;
message: string;
created_at: string;
user: TUserItem;
team: TTeamItem;
};
export type TProjectSubmissionItem = {
id: string;
team_id: string;
project_name: string;
description: string;
repository_url: string;
demo_url: string | null;
presentation_url: string | null;
screenshots: string[];
status: ESubmissionStatus;
submitted_at: string | null;
submitted_by: string;
created_at: string;
updated_at: string;
};
// Request/Response DTOs
export type TCreateTeamRequest = {
name: string;
logo: string | null;
banner: string | null;
description: string;
city: string;
visibility: ETeamVisibility;
};
export type TUpdateTeamRequest = Partial<TCreateTeamRequest>;
export type TInviteMemberRequest = {
email: string;
};
export type TJoinTeamRequest = {
message: string;
};
export type TManageMemberRequest = {
action: 'approve' | 'reject' | 'remove';
};
export type TSubmitProjectRequest = {
project_name: string;
description: string;
repository_url: string;
demo_url?: string;
presentation_url?: string;
screenshots?: string[];
};
export type TTeamListResponse = {
data: TTeamItem[];
total: number;
page: number;
limit: number;
};
export type TTeamDetailResponse = {
data: TTeamDetailItem;
};
export type TTeamMembersResponse = {
data: TTeamMemberItem[];
};
export type TTeamInvitationsResponse = {
data: TTeamInvitationItem[];
};
export type TTeamJoinRequestsResponse = {
data: TTeamJoinRequestItem[];
};
export type TProjectSubmissionResponse = {
data: TProjectSubmissionItem;
};

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