From 9e9bfc279311af399414c9300a88480e9063d1ba Mon Sep 17 00:00:00 2001 From: Maulana Sodiqin Date: Tue, 25 Nov 2025 01:47:56 +0700 Subject: [PATCH] feat: hackathon --- .claude/settings.local.json | 8 +- .env.example | 3 + .gitignore | 2 + apps/hackathon/eslint.config.mjs | 4 +- apps/hackathon/src/app/auth/callback/page.tsx | 180 ++++ .../src/app/auth/login/page-original.tsx | 89 ++ apps/hackathon/src/app/auth/login/page.tsx | 205 ++++ apps/hackathon/src/app/auth/signup/page.tsx | 258 +++++ apps/hackathon/src/app/dashboard/page.tsx | 283 +++++ apps/hackathon/src/app/layout.tsx | 123 ++- .../src/app/onboarding/user/page.tsx | 257 +++++ apps/hackathon/src/app/page.tsx | 760 +++++++++++++- .../src/app/teams/[teamId]/chat/page.tsx | 211 ++++ .../src/app/teams/[teamId]/edit/page.tsx | 353 +++++++ .../src/app/teams/[teamId]/layout.tsx | 18 + .../src/app/teams/[teamId]/members/page.tsx | 294 ++++++ .../hackathon/src/app/teams/[teamId]/page.tsx | 445 ++++++++ .../app/teams/[teamId]/submission/page.tsx | 207 ++++ .../src/app/teams/[teamId]/submit/page.tsx | 296 ++++++ apps/hackathon/src/app/teams/browse/page.tsx | 233 +++++ apps/hackathon/src/app/teams/create/page.tsx | 322 ++++++ apps/hackathon/src/components/navigation.tsx | 144 +++ apps/hackathon/src/hooks/use-team-guards.ts | 76 ++ apps/hackathon/src/index.css | 135 +++ apps/hackathon/src/main.tsx | 7 +- apps/hackathon/src/middleware.ts | 109 +- docker-compose-hackathon.yml | 8 + docker/hackathon.Dockerfile | 9 + libs/service/src/api/auth/index.ts | 13 + libs/service/src/api/teams/index.ts | 114 ++ libs/service/src/hooks/auth/index.ts | 135 ++- libs/service/src/hooks/index.ts | 2 + libs/service/src/hooks/messages/index.ts | 181 ++++ libs/service/src/hooks/teams/index.ts | 692 +++++++++++++ libs/service/src/hooks/upload/index.ts | 128 ++- libs/service/src/hooks/users/index.ts | 96 +- libs/service/src/index.ts | 1 + libs/service/src/schemas/index.ts | 1 + libs/service/src/schemas/teams/index.ts | 81 ++ libs/service/src/supabase/client.ts | 41 + libs/service/src/supabase/index.ts | 1 + libs/service/src/types/index.ts | 1 + libs/service/src/types/supabase.ts | 4 + libs/service/src/types/teams/index.ts | 157 +++ libs/service/src/types/users/index.ts | 3 + libs/ui/src/atoms/input/input.tsx | 21 +- libs/utils/src/hooks/use-otp.ts | 31 +- libs/utils/src/hooks/use-register.ts | 30 +- libs/utils/src/hooks/use-send-otp.ts | 27 +- libs/utils/src/hooks/use-session.ts | 32 +- libs/utils/src/local-storage/index.ts | 3 + package-lock.json | 974 ++++++------------ package.json | 11 +- 53 files changed, 6892 insertions(+), 927 deletions(-) create mode 100644 .env.example create mode 100644 apps/hackathon/src/app/auth/callback/page.tsx create mode 100644 apps/hackathon/src/app/auth/login/page-original.tsx create mode 100644 apps/hackathon/src/app/auth/login/page.tsx create mode 100644 apps/hackathon/src/app/auth/signup/page.tsx create mode 100644 apps/hackathon/src/app/dashboard/page.tsx create mode 100644 apps/hackathon/src/app/onboarding/user/page.tsx create mode 100644 apps/hackathon/src/app/teams/[teamId]/chat/page.tsx create mode 100644 apps/hackathon/src/app/teams/[teamId]/edit/page.tsx create mode 100644 apps/hackathon/src/app/teams/[teamId]/layout.tsx create mode 100644 apps/hackathon/src/app/teams/[teamId]/members/page.tsx create mode 100644 apps/hackathon/src/app/teams/[teamId]/page.tsx create mode 100644 apps/hackathon/src/app/teams/[teamId]/submission/page.tsx create mode 100644 apps/hackathon/src/app/teams/[teamId]/submit/page.tsx create mode 100644 apps/hackathon/src/app/teams/browse/page.tsx create mode 100644 apps/hackathon/src/app/teams/create/page.tsx create mode 100644 apps/hackathon/src/components/navigation.tsx create mode 100644 apps/hackathon/src/hooks/use-team-guards.ts create mode 100644 libs/service/src/api/teams/index.ts create mode 100644 libs/service/src/hooks/messages/index.ts create mode 100644 libs/service/src/hooks/teams/index.ts create mode 100644 libs/service/src/schemas/teams/index.ts create mode 100644 libs/service/src/supabase/client.ts create mode 100644 libs/service/src/supabase/index.ts create mode 100644 libs/service/src/types/supabase.ts create mode 100644 libs/service/src/types/teams/index.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1d4a4b8..6c0c5ff 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,13 @@ "Bash(npx nx run-many:*)", "Bash(npm install:*)", "Bash(npm view:*)", - "Bash(npx nx build landing)" + "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": [] diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fec1d5e --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ + +VITE_SUPABASE_URL=https://your-project-id.supabase.co +VITE_SUPABASE_ANON_KEY=your-anon-key-here diff --git a/.gitignore b/.gitignore index 26e09e7..9e91e32 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,5 @@ storybook-static out .cursor/rules/nx-rules.mdc .github/instructions/nx.instructions.md + +.env.local diff --git a/apps/hackathon/eslint.config.mjs b/apps/hackathon/eslint.config.mjs index e8e4590..00444b9 100644 --- a/apps/hackathon/eslint.config.mjs +++ b/apps/hackathon/eslint.config.mjs @@ -7,6 +7,8 @@ export default [ { files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'], // Override or add rules here - rules: {}, + rules: { + 'jsx-a11y/accessible-emoji': 'off', + }, }, ]; diff --git a/apps/hackathon/src/app/auth/callback/page.tsx b/apps/hackathon/src/app/auth/callback/page.tsx new file mode 100644 index 0000000..28c87ea --- /dev/null +++ b/apps/hackathon/src/app/auth/callback/page.tsx @@ -0,0 +1,180 @@ +import { FC, ReactElement, useEffect, useState, useRef } from 'react'; +import { useNavigate } from 'react-router'; +import { useAuthStore } from '@imphnen-frontend-service/utils'; +import { 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(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 ( +
+
+
+
⚠️
+

+ GitHub Login Failed +

+

{error}

+
+ +

+ Redirecting to login page in 3 seconds... +

+
+
+ ); + } + + return ( +
+
+
+

+ Completing login... +

+

Please wait

+
+
+ ); +}; + +export default CallbackPage; diff --git a/apps/hackathon/src/app/auth/login/page-original.tsx b/apps/hackathon/src/app/auth/login/page-original.tsx new file mode 100644 index 0000000..c615f92 --- /dev/null +++ b/apps/hackathon/src/app/auth/login/page-original.tsx @@ -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 ( +
+
+
+

+ Welcome to Hackathon +

+

+ Sign in with your GitHub account to join or create your hackathon team +

+
+ + + +
+

+ By signing in, you agree to our Terms of Service and Privacy Policy +

+
+
+
+ ); +}; + +export default LoginPage; diff --git a/apps/hackathon/src/app/auth/login/page.tsx b/apps/hackathon/src/app/auth/login/page.tsx new file mode 100644 index 0000000..33aae9a --- /dev/null +++ b/apps/hackathon/src/app/auth/login/page.tsx @@ -0,0 +1,205 @@ +import { useState } from 'react'; +import { useGitHubAuth, useEmailAuth, supabase } from '@imphnen-frontend-service/service'; +import { GithubOutlined } from '@ant-design/icons'; +import { useNavigate } from 'react-router'; +import { useAuthStore } from '@imphnen-frontend-service/utils'; +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(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 ( +
+
+
+

+ Welcome Back +

+

+ Sign in to join or create your hackathon team +

+
+ + {error && ( +
+

{error}

+
+ )} + +
+
+ + 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 + /> +
+ +
+ + 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 + /> +
+ + +
+ +
+
+ OR +
+
+ + + +
+

+ Don't have an account?{' '} + + Sign up + +

+
+ +
+

+ By signing in, you agree to our Terms of Service and Privacy Policy +

+
+
+
+ ); +} diff --git a/apps/hackathon/src/app/auth/signup/page.tsx b/apps/hackathon/src/app/auth/signup/page.tsx new file mode 100644 index 0000000..0a680bf --- /dev/null +++ b/apps/hackathon/src/app/auth/signup/page.tsx @@ -0,0 +1,258 @@ +import { useState } from 'react'; +import { useGitHubAuth, useEmailAuth, supabase } from '@imphnen-frontend-service/service'; +import { GithubOutlined } from '@ant-design/icons'; +import { useNavigate } from 'react-router'; +import { useAuthStore } from '@imphnen-frontend-service/utils'; +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(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 ( +
+
+
+

+ Create Account +

+

+ Join the hackathon community +

+
+ + {error && ( +
+

{error}

+
+ )} + +
+
+ + 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 + /> +
+ +
+ + 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 + /> +
+ +
+ + 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 + /> +
+ +
+ + 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 + /> +
+ + +
+ +
+
+ OR +
+
+ + + +
+

+ Already have an account?{' '} + + Sign in + +

+
+ +
+

+ By signing up, you agree to our Terms of Service and Privacy Policy +

+
+
+
+ ); +} diff --git a/apps/hackathon/src/app/dashboard/page.tsx b/apps/hackathon/src/app/dashboard/page.tsx new file mode 100644 index 0000000..8d08f22 --- /dev/null +++ b/apps/hackathon/src/app/dashboard/page.tsx @@ -0,0 +1,283 @@ +import { FC, ReactElement } from 'react'; +import { Button } from '@imphnen-frontend-service/ui/atoms'; +import { Link, useNavigate } from 'react-router'; +import { useMyTeams, useMyInvitations, useRespondToInvitation, supabase } from '@imphnen-frontend-service/service'; +import { useAuthStore } from '@imphnen-frontend-service/utils'; +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 ( +
+ {/* Header */} +
+
+
+
+

+ Welcome, {user?.fullname || 'User'}! +

+

+ {user?.location && `📍 ${user.location}`} +

+
+
+ {user?.avatar && ( + Profile + )} + +
+
+
+
+ +
+ {/* Team Invitations */} + {invitations.length > 0 && ( +
+

+ Team Invitations ({invitations.length}) +

+
+ {invitations.map((invitation) => ( +
+
+

{invitation.team.name}

+

+ Invited by {invitation.inviter.fullname} +

+
+
+ + +
+
+ ))} +
+
+ )} + + {/* My Teams */} + {myTeams.length > 0 ? ( +
+

My Team

+
+ {myTeams.map((team) => ( + + {team.banner && ( + {team.name} + )} +
+
+ {team.logo && ( + {team.name} + )} +
+

{team.name}

+

📍 {team.city}

+
+
+

+ {team.description} +

+
+ + ))} +
+
+ ) : ( + /* No Team - Show CTAs */ +
+
+

+ You're not in a team yet +

+

+ Join an existing team or create your own to get started +

+
+ +
+ +
🔍
+

Browse Teams

+

+ Find and join existing teams looking for members +

+ + + +
+

Create Team

+

+ Start your own team and invite members +

+ +
+
+ )} + + {/* User Profile Card */} +
+
+
+
+ {user?.avatar && ( + {user.fullname} + )} +
+

{user?.fullname}

+ {user?.location ? ( +

+ + + + {user.location} +

+ ) : ( + + Complete your profile → + + )} +
+
+ +
+ {user?.bio && ( +
+

About

+

{user.bio}

+
+ )} + +
+

Contact

+
+ + + + + {user?.email} +
+
+ + {user?.skills && user.skills.length > 0 && ( +
+

Skills

+
+ {user.skills.map((skill: string) => ( + + {skill} + + ))} +
+
+ )} +
+
+
+
+
+ ); +}; + +export default DashboardPage; diff --git a/apps/hackathon/src/app/layout.tsx b/apps/hackathon/src/app/layout.tsx index d7b3c11..8994705 100644 --- a/apps/hackathon/src/app/layout.tsx +++ b/apps/hackathon/src/app/layout.tsx @@ -1,6 +1,127 @@ -import { Outlet, ScrollRestoration } from 'react-router-dom'; +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) - allow unauthenticated access + const isPublicAuthPage = pathname === '/auth/login' || pathname === '/auth/signup'; + + if (isPublicAuthPage) { + // If already authenticated, redirect to dashboard + if (session) { + 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 - redirect based on auth status + if (pathname === '/') { + if (session) { + console.log('[Layout] Already authenticated, redirecting to dashboard'); + navigate('/dashboard', { replace: true }); + } else { + console.log('[Layout] Not authenticated, redirecting to login'); + navigate('/auth/login', { replace: true }); + } + 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 ( +
+
+
+

Loading...

+
+
+ ); + } + return ( <> diff --git a/apps/hackathon/src/app/onboarding/user/page.tsx b/apps/hackathon/src/app/onboarding/user/page.tsx new file mode 100644 index 0000000..35814b8 --- /dev/null +++ b/apps/hackathon/src/app/onboarding/user/page.tsx @@ -0,0 +1,257 @@ +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 } from '@imphnen-frontend-service/service'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useUserMe } from '@imphnen-frontend-service/service'; +import { useAuthStore } from '@imphnen-frontend-service/utils'; + +const ROLE_OPTIONS = [ + 'Frontend Developer', + 'Backend Developer', + 'Full Stack Developer', + 'DevOps Engineer', + 'UI/UX Designer', + 'Product Manager', + 'Data Scientist', + 'Mobile Developer', +]; + +const INDONESIAN_CITIES = [ + 'Jakarta', 'Surabaya', 'Bandung', 'Medan', 'Semarang', + 'Makassar', 'Palembang', 'Tangerang', 'Depok', 'Bekasi', + 'Yogyakarta', 'Malang', 'Bogor', 'Batam', 'Pekanbaru', +]; + +const UserOnboardingPage: FC = (): ReactElement => { + const navigate = useNavigate(); + const [avatarFile, setAvatarFile] = useState(null); + const [avatarPreview, setAvatarPreview] = useState(''); + + const { data: userData } = useUserMe(); + const { mutateAsync: updateUser, isPending: isUpdating } = useUpdateUserMe(); + const { mutateAsync: uploadAvatar, isPending: isUploading } = useUploadAvatar(); + const { session } = useAuthStore(); + + const form = useForm({ + 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) => { + 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 ( +
+
+
+

+ Complete Your Profile +

+

+ Tell us more about yourself to get started +

+
+ +
+ {/* Avatar Upload */} +
+
+ {avatarPreview ? ( + Avatar preview + ) : ( +
+ 👤 +
+ )} +
+
+ +

Optional, but highly recommended

+
+
+ + {/* Full Name */} + + + {/* City */} +
+ + ( +
+ + {fieldState.error && ( +

{fieldState.error.message}

+ )} +
+ )} + /> +
+ + {/* Role/Skills */} +
+ + ( +
+
+ {ROLE_OPTIONS.map((role) => ( + + ))} +
+
+ )} + /> +
+ + {/* Bio */} +
+ + ( +
+