feat(hackathon): improve auth flow and UX
- Add email activation requirement for signup (no auto-login) - Add form validation with React Hook Form and Zod on signup page - Update callback page to handle email confirmation and password reset redirects - Fix token format in API interceptor (use access_token) - Fix middleware to use SessionToken for auth check - Add infinite scroll with IntersectionObserver on browse teams page - Replace all internal <a href> with <Link> components - Add useInfiniteTeams hook for paginated team browsing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
f8d592d811
commit
fa30849d03
@@ -18,15 +18,38 @@ const CallbackPage: FC = (): ReactElement => {
|
|||||||
hasRunRef.current = true;
|
hasRunRef.current = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get the code from URL query params
|
// Check URL hash for Supabase email confirmation callback
|
||||||
|
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
|
||||||
const urlParams = new URLSearchParams(globalThis.location.search);
|
const urlParams = new URLSearchParams(globalThis.location.search);
|
||||||
|
|
||||||
|
const type = hashParams.get('type') || urlParams.get('type');
|
||||||
|
const accessToken = hashParams.get('access_token') || urlParams.get('access_token');
|
||||||
|
|
||||||
|
// Handle email confirmation callback from Supabase
|
||||||
|
if (type === 'signup' || type === 'email_confirmation' || type === 'recovery') {
|
||||||
|
// Don't auto sign in - redirect to login with success message
|
||||||
|
setIsProcessing(false);
|
||||||
|
|
||||||
|
if (type === 'recovery') {
|
||||||
|
// Password reset - redirect to reset password page
|
||||||
|
toast.success('Email verified! Please set your new password.');
|
||||||
|
navigate('/auth/reset-password' + (accessToken ? `?access_token=${accessToken}` : ''));
|
||||||
|
} else {
|
||||||
|
// Email confirmation for signup
|
||||||
|
toast.success('Email verified successfully! Please log in to continue.');
|
||||||
|
navigate('/auth/login');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the code from URL query params (GitHub OAuth)
|
||||||
const code = urlParams.get('code');
|
const code = urlParams.get('code');
|
||||||
|
|
||||||
if (!code) {
|
if (!code) {
|
||||||
throw new Error('No authorization code received from GitHub');
|
throw new Error('No authorization code received');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exchange the code for tokens using backend API
|
// Exchange the code for tokens using backend API (GitHub OAuth)
|
||||||
const result = await exchangeGitHubCode({ code });
|
const result = await exchangeGitHubCode({ code });
|
||||||
|
|
||||||
toast.success('Login successful!');
|
toast.success('Login successful!');
|
||||||
|
|||||||
@@ -172,12 +172,12 @@ export default function LoginPage() {
|
|||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
<a
|
<Link
|
||||||
href="/auth/signup"
|
to="/auth/signup"
|
||||||
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
|
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
|
||||||
>
|
>
|
||||||
Sign up
|
Sign up
|
||||||
</a>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,55 +1,125 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import { useGitHubAuth, useSignup } from '@imphnen-frontend-service/service';
|
||||||
useGitHubAuth,
|
|
||||||
useSignup,
|
|
||||||
} from '@imphnen-frontend-service/service';
|
|
||||||
import { GithubOutlined } from '@ant-design/icons';
|
import { GithubOutlined } from '@ant-design/icons';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate, Link, Links } from 'react-router';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
import { ThemeToggle } from '../../../components/theme-toggle';
|
import { ThemeToggle } from '../../../components/theme-toggle';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
const signupSchema = z
|
||||||
|
.object({
|
||||||
|
fullname: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Full name is required')
|
||||||
|
.min(2, 'Full name must be at least 2 characters'),
|
||||||
|
email: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Email is required')
|
||||||
|
.email('Please enter a valid email address'),
|
||||||
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Password is required')
|
||||||
|
.min(6, 'Password must be at least 6 characters'),
|
||||||
|
confirmPassword: z.string().min(1, 'Please confirm your password'),
|
||||||
|
})
|
||||||
|
.refine((data) => data.password === data.confirmPassword, {
|
||||||
|
message: 'Passwords do not match',
|
||||||
|
path: ['confirmPassword'],
|
||||||
|
});
|
||||||
|
|
||||||
|
type SignupFormData = z.infer<typeof signupSchema>;
|
||||||
|
|
||||||
export default function SignupPage() {
|
export default function SignupPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { signInWithGitHub } = useGitHubAuth();
|
const { signInWithGitHub } = useGitHubAuth();
|
||||||
const signupMutation = useSignup();
|
const signupMutation = useSignup();
|
||||||
const [isGithubLoading, setIsGithubLoading] = useState(false);
|
const [isGithubLoading, setIsGithubLoading] = 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 [error, setError] = useState<string | null>(null);
|
||||||
|
const [registrationSuccess, setRegistrationSuccess] = useState(false);
|
||||||
|
const [registeredEmail, setRegisteredEmail] = useState('');
|
||||||
|
|
||||||
const handleEmailSignup = async (e: React.FormEvent) => {
|
const {
|
||||||
e.preventDefault();
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors, isValid },
|
||||||
|
} = useForm<SignupFormData>({
|
||||||
|
resolver: zodResolver(signupSchema),
|
||||||
|
mode: 'onChange',
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSubmit = async (data: SignupFormData) => {
|
||||||
setError(null);
|
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 {
|
try {
|
||||||
await signupMutation.mutateAsync({ email, password, fullname });
|
const result = await signupMutation.mutateAsync({
|
||||||
|
email: data.email,
|
||||||
toast.success('Account created successfully!');
|
password: data.password,
|
||||||
navigate('/onboarding/user');
|
fullname: data.fullname,
|
||||||
|
});
|
||||||
|
toast.success(result.message);
|
||||||
|
setRegisteredEmail(data.email);
|
||||||
|
setRegistrationSuccess(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Signup] Email signup failed:', err);
|
console.error('[Signup] Email signup failed:', err);
|
||||||
setError((err as Error).message || 'Signup failed');
|
setError((err as Error).message || 'Signup failed');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Show success screen after registration
|
||||||
|
if (registrationSuccess) {
|
||||||
|
return (
|
||||||
|
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||||
|
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700 text-center">
|
||||||
|
<div className="mb-6">
|
||||||
|
<div className="mx-auto w-16 h-16 bg-green-100 dark:bg-green-900/30 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<Icon
|
||||||
|
icon="mdi:email-check"
|
||||||
|
className="text-3xl text-green-600 dark:text-green-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||||
|
Check Your Email
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-600 dark:text-gray-400">
|
||||||
|
We've sent an activation link to{' '}
|
||||||
|
<strong>{registeredEmail}</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Click the link in the email to activate your account. The link
|
||||||
|
will expire in 24 hours.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-amber-700 dark:text-amber-300">
|
||||||
|
Don't forget to check your spam folder if you don't see the
|
||||||
|
email.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link to="/auth/login">
|
||||||
|
<button className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors cursor-pointer">
|
||||||
|
Go to Login
|
||||||
|
</button>
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setRegistrationSuccess(false)}
|
||||||
|
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Register with different email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const handleGithubLogin = async () => {
|
const handleGithubLogin = async () => {
|
||||||
try {
|
try {
|
||||||
setIsGithubLoading(true);
|
setIsGithubLoading(true);
|
||||||
@@ -69,6 +139,11 @@ export default function SignupPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const inputBaseClass =
|
||||||
|
'w-full px-4 py-2.5 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed';
|
||||||
|
const inputErrorClass = 'border-red-500 dark:border-red-500';
|
||||||
|
const inputNormalClass = 'border-gray-300 dark:border-gray-600';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
|
||||||
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700">
|
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700">
|
||||||
@@ -98,7 +173,7 @@ export default function SignupPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleEmailSignup} className="space-y-4">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label
|
<label
|
||||||
htmlFor="fullname"
|
htmlFor="fullname"
|
||||||
@@ -109,13 +184,18 @@ export default function SignupPage() {
|
|||||||
<input
|
<input
|
||||||
id="fullname"
|
id="fullname"
|
||||||
type="text"
|
type="text"
|
||||||
value={fullname}
|
{...register('fullname')}
|
||||||
onChange={(e) => setFullname(e.target.value)}
|
|
||||||
placeholder="John Doe"
|
placeholder="John Doe"
|
||||||
disabled={signupMutation.isPending}
|
disabled={signupMutation.isPending}
|
||||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
className={`${inputBaseClass} ${
|
||||||
required
|
errors.fullname ? inputErrorClass : inputNormalClass
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
|
{errors.fullname && (
|
||||||
|
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
|
||||||
|
{errors.fullname.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -128,13 +208,18 @@ export default function SignupPage() {
|
|||||||
<input
|
<input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
{...register('email')}
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
placeholder="your@email.com"
|
placeholder="your@email.com"
|
||||||
disabled={signupMutation.isPending}
|
disabled={signupMutation.isPending}
|
||||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
className={`${inputBaseClass} ${
|
||||||
required
|
errors.email ? inputErrorClass : inputNormalClass
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
|
{errors.email && (
|
||||||
|
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
|
||||||
|
{errors.email.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -147,13 +232,18 @@ export default function SignupPage() {
|
|||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
{...register('password')}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
disabled={signupMutation.isPending}
|
disabled={signupMutation.isPending}
|
||||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
className={`${inputBaseClass} ${
|
||||||
required
|
errors.password ? inputErrorClass : inputNormalClass
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
|
{errors.password && (
|
||||||
|
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
|
||||||
|
{errors.password.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -166,21 +256,28 @@ export default function SignupPage() {
|
|||||||
<input
|
<input
|
||||||
id="confirmPassword"
|
id="confirmPassword"
|
||||||
type="password"
|
type="password"
|
||||||
value={confirmPassword}
|
{...register('confirmPassword')}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
||||||
placeholder="••••••••"
|
placeholder="••••••••"
|
||||||
disabled={signupMutation.isPending}
|
disabled={signupMutation.isPending}
|
||||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
|
className={`${inputBaseClass} ${
|
||||||
required
|
errors.confirmPassword ? inputErrorClass : inputNormalClass
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
|
{errors.confirmPassword && (
|
||||||
|
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
|
||||||
|
{errors.confirmPassword.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={signupMutation.isPending}
|
disabled={!isValid || signupMutation.isPending}
|
||||||
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:bg-gray-400 dark:disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:bg-gray-400 dark:disabled:bg-gray-600 disabled:cursor-not-allowed transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
{signupMutation.isPending ? 'Creating account...' : 'Create Account'}
|
{signupMutation.isPending
|
||||||
|
? 'Creating account...'
|
||||||
|
: 'Create Account'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -207,12 +304,12 @@ export default function SignupPage() {
|
|||||||
<div className="mt-6 text-center">
|
<div className="mt-6 text-center">
|
||||||
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
<p className="text-gray-600 dark:text-gray-400 text-sm">
|
||||||
Already have an account?{' '}
|
Already have an account?{' '}
|
||||||
<a
|
<Link
|
||||||
href="/auth/login"
|
to="/auth/login"
|
||||||
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
|
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
|
||||||
>
|
>
|
||||||
Sign in
|
Sign in
|
||||||
</a>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
export default function MaintenancePage() {
|
export default function MaintenancePage() {
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 px-4">
|
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 px-4">
|
||||||
@@ -9,12 +11,12 @@ export default function MaintenancePage() {
|
|||||||
<br />
|
<br />
|
||||||
Thank you for your patience.
|
Thank you for your patience.
|
||||||
</p>
|
</p>
|
||||||
<a
|
<Link
|
||||||
href="/"
|
to="/"
|
||||||
className="mt-4 inline-block text-primary-600 hover:underline"
|
className="mt-4 inline-block text-primary-600 hover:underline"
|
||||||
>
|
>
|
||||||
Back to Homepage
|
Back to Homepage
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useNavigate } from 'react-router';
|
import { useNavigate, Link } from 'react-router';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { Icon } from '@iconify/react';
|
import { Icon } from '@iconify/react';
|
||||||
@@ -796,12 +796,12 @@ export default function HomePage() {
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<a
|
<Link
|
||||||
href="/auth/signup"
|
to="/auth/signup"
|
||||||
className="hover:text-white transition-colors"
|
className="hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
Daftar
|
Daftar
|
||||||
</a>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { FC, ReactElement, useState, useEffect } from 'react';
|
import { FC, ReactElement, useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { Link, useNavigate } from 'react-router';
|
import { Link, useNavigate } from 'react-router';
|
||||||
import {
|
import {
|
||||||
useTeams,
|
useInfiniteTeams,
|
||||||
useJoinTeam,
|
useJoinTeam,
|
||||||
useMyTeams,
|
useMyTeams,
|
||||||
ETeamVisibility,
|
ETeamVisibility,
|
||||||
@@ -22,6 +22,9 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
|||||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||||
const [showJoinModal, setShowJoinModal] = useState(false);
|
const [showJoinModal, setShowJoinModal] = useState(false);
|
||||||
|
|
||||||
|
// Ref for intersection observer
|
||||||
|
const loadMoreRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Debounce search term
|
// Debounce search term
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
@@ -30,7 +33,13 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}, [searchTerm]);
|
}, [searchTerm]);
|
||||||
|
|
||||||
const { data: teamsData, isLoading } = useTeams({
|
const {
|
||||||
|
data: teamsData,
|
||||||
|
isLoading,
|
||||||
|
isFetchingNextPage,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
} = useInfiniteTeams({
|
||||||
search: debouncedSearch,
|
search: debouncedSearch,
|
||||||
city: selectedCity || undefined,
|
city: selectedCity || undefined,
|
||||||
visibility: ETeamVisibility.PUBLIC,
|
visibility: ETeamVisibility.PUBLIC,
|
||||||
@@ -44,9 +53,36 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
|||||||
mode: 'all',
|
mode: 'all',
|
||||||
});
|
});
|
||||||
|
|
||||||
const teams = teamsData?.data || [];
|
// Flatten pages into single array
|
||||||
|
const teams = teamsData?.pages.flatMap((page) => page.data) || [];
|
||||||
const myTeams = myTeamsData?.data || [];
|
const myTeams = myTeamsData?.data || [];
|
||||||
|
|
||||||
|
// Intersection Observer callback
|
||||||
|
const handleObserver = useCallback(
|
||||||
|
(entries: IntersectionObserverEntry[]) => {
|
||||||
|
const [target] = entries;
|
||||||
|
if (target.isIntersecting && hasNextPage && !isFetchingNextPage) {
|
||||||
|
fetchNextPage();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[hasNextPage, isFetchingNextPage, fetchNextPage]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set up intersection observer
|
||||||
|
useEffect(() => {
|
||||||
|
const element = loadMoreRef.current;
|
||||||
|
if (!element) return;
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(handleObserver, {
|
||||||
|
root: null,
|
||||||
|
rootMargin: '100px',
|
||||||
|
threshold: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(element);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [handleObserver]);
|
||||||
|
|
||||||
// Helper function to check if user is a member of a team
|
// Helper function to check if user is a member of a team
|
||||||
const isMyTeam = (teamId: string) => {
|
const isMyTeam = (teamId: string) => {
|
||||||
return myTeams.some((team: any) => team.id === teamId);
|
return myTeams.some((team: any) => team.id === teamId);
|
||||||
@@ -135,85 +171,102 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
<>
|
||||||
{teams.map((team) => (
|
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||||
<div
|
{teams.map((team) => (
|
||||||
key={team.id}
|
<div
|
||||||
className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow flex flex-col border dark:border-gray-800"
|
key={team.id}
|
||||||
>
|
className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow flex flex-col border dark:border-gray-800"
|
||||||
<img
|
>
|
||||||
src={team.banner || '/images/banner-imphnen.webp'}
|
<img
|
||||||
alt={team.name}
|
src={team.banner || '/images/banner-imphnen.webp'}
|
||||||
className="w-full aspect-3/1 object-cover"
|
alt={team.name}
|
||||||
/>
|
className="w-full aspect-3/1 object-cover"
|
||||||
<div className="p-6 flex flex-col flex-1">
|
/>
|
||||||
<div className="flex items-center space-x-3 mb-3">
|
<div className="p-6 flex flex-col flex-1">
|
||||||
{team.logo ? (
|
<div className="flex items-center space-x-3 mb-3">
|
||||||
<img
|
{team.logo ? (
|
||||||
src={team.logo}
|
<img
|
||||||
alt={team.name}
|
src={team.logo}
|
||||||
className="w-12 h-12 rounded-full object-cover"
|
alt={team.name}
|
||||||
/>
|
className="w-12 h-12 rounded-full object-cover"
|
||||||
) : (
|
/>
|
||||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
) : (
|
||||||
<span className="text-gray-500 dark:text-gray-300 text-xl">
|
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||||
<Icon icon="mdi:account-group" />
|
<span className="text-gray-500 dark:text-gray-300 text-xl">
|
||||||
</span>
|
<Icon icon="mdi:account-group" />
|
||||||
</div>
|
</span>
|
||||||
)}
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
)}
|
||||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white line-clamp-2 leading-tight">
|
<div className="flex-1 min-w-0">
|
||||||
{team.name}
|
<h3 className="text-lg font-bold text-gray-900 dark:text-white line-clamp-2 leading-tight">
|
||||||
</h3>
|
{team.name}
|
||||||
<div className="text-sm font-sans text-gray-600 dark:text-gray-400 flex gap-2">
|
</h3>
|
||||||
<p className="truncate flex-1 min-w-0 flex items-center gap-x-1">
|
<div className="text-sm font-sans text-gray-600 dark:text-gray-400 flex gap-2">
|
||||||
<Icon icon="mdi:map-marker" />{' '}
|
<p className="truncate flex-1 min-w-0 flex items-center gap-x-1">
|
||||||
<span>{team.city}</span>
|
<Icon icon="mdi:map-marker" />{' '}
|
||||||
</p>
|
<span>{team.city}</span>
|
||||||
<p className="whitespace-nowrap shrink-0 flex items-center gap-x-1">
|
</p>
|
||||||
<Icon icon="mdi:account-group" />{' '}
|
<p className="whitespace-nowrap shrink-0 flex items-center gap-x-1">
|
||||||
{team.members?.length || 0} members
|
<Icon icon="mdi:account-group" />{' '}
|
||||||
</p>
|
{team.members?.length || 0} members
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p className="text-gray-600 dark:text-gray-300 text-sm mb-4 line-clamp-3 font-sans">
|
||||||
<p className="text-gray-600 dark:text-gray-300 text-sm mb-4 line-clamp-3 font-sans">
|
{team.description}
|
||||||
{team.description}
|
</p>
|
||||||
</p>
|
<div className="space-y-3 mt-auto">
|
||||||
<div className="space-y-3 mt-auto">
|
{isMyTeam(team.id) ? (
|
||||||
{isMyTeam(team.id) ? (
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => navigate(`/teams/${team.id}`)}
|
|
||||||
>
|
|
||||||
Your Team
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{myTeams.length === 0 &&
|
|
||||||
(team.members?.length || 0) < 5 && (
|
|
||||||
<Button
|
|
||||||
className="w-full"
|
|
||||||
onClick={() => handleJoinRequest(team.id)}
|
|
||||||
>
|
|
||||||
Request to Join
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => navigate(`/teams/${team.id}`)}
|
onClick={() => navigate(`/teams/${team.id}`)}
|
||||||
>
|
>
|
||||||
View Team
|
Your Team
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
) : (
|
||||||
)}
|
<>
|
||||||
|
{myTeams.length === 0 &&
|
||||||
|
(team.members?.length || 0) < 5 && (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => handleJoinRequest(team.id)}
|
||||||
|
>
|
||||||
|
Request to Join
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => navigate(`/teams/${team.id}`)}
|
||||||
|
>
|
||||||
|
View Team
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
</div>
|
||||||
</div>
|
|
||||||
|
{/* Intersection Observer Sentinel */}
|
||||||
|
<div ref={loadMoreRef} className="py-8 flex justify-center">
|
||||||
|
{isFetchingNextPage && (
|
||||||
|
<div className="flex items-center gap-2 text-gray-600 dark:text-gray-400">
|
||||||
|
<Icon icon="mdi:loading" className="animate-spin text-xl" />
|
||||||
|
<span>Loading more teams...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!hasNextPage && teams.length > 0 && (
|
||||||
|
<p className="text-gray-500 dark:text-gray-500 text-sm">
|
||||||
|
No more teams to load
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { SessionUser } from '@imphnen-frontend-service/utils';
|
import { SessionUser } from '@imphnen-frontend-service/utils';
|
||||||
import { hackathonApi } from '@imphnen-frontend-service/service';
|
import { hackathonApi, SessionToken } from '@imphnen-frontend-service/service';
|
||||||
import { LoaderFunctionArgs, redirect } from 'react-router';
|
import { LoaderFunctionArgs, redirect } from 'react-router';
|
||||||
|
|
||||||
const mappingPublicRoutes = [
|
const mappingPublicRoutes = [
|
||||||
@@ -37,9 +37,10 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const pathname = url.pathname;
|
const pathname = url.pathname;
|
||||||
|
|
||||||
// Get session from local storage (via SessionUser)
|
// Get token from cookies and user from local storage
|
||||||
const session = SessionUser.get();
|
const tokenData = SessionToken.get();
|
||||||
const isAuthenticated = !!session?.token?.access_token;
|
const user = SessionUser.get();
|
||||||
|
const isAuthenticated = !!tokenData?.token?.access_token;
|
||||||
|
|
||||||
// Allow to access the hackathon pages without authentication
|
// Allow to access the hackathon pages without authentication
|
||||||
if (mappingPublicPrefixRoutes.some((prefix) => pathname.startsWith(prefix))) {
|
if (mappingPublicPrefixRoutes.some((prefix) => pathname.startsWith(prefix))) {
|
||||||
@@ -71,7 +72,7 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
// Skip onboarding check for onboarding routes themselves
|
// Skip onboarding check for onboarding routes themselves
|
||||||
if (!mappingOnboardingRoutes.includes(pathname)) {
|
if (!mappingOnboardingRoutes.includes(pathname)) {
|
||||||
try {
|
try {
|
||||||
const userId = session?.user?.id;
|
const userId = user?.id;
|
||||||
if (!userId) {
|
if (!userId) {
|
||||||
return redirect('/auth/login');
|
return redirect('/auth/login');
|
||||||
}
|
}
|
||||||
@@ -85,8 +86,8 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
|
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
|
||||||
hasLocation = cached.hasLocation;
|
hasLocation = cached.hasLocation;
|
||||||
} else {
|
} else {
|
||||||
// First check session data (faster)
|
// First check user data (faster)
|
||||||
if (session?.user?.location) {
|
if (user?.location) {
|
||||||
hasLocation = true;
|
hasLocation = true;
|
||||||
} else {
|
} else {
|
||||||
// Fetch from backend API
|
// Fetch from backend API
|
||||||
@@ -94,8 +95,8 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
const response = await hackathonApi.get('/users/me');
|
const response = await hackathonApi.get('/users/me');
|
||||||
hasLocation = !!response.data?.data?.location;
|
hasLocation = !!response.data?.data?.location;
|
||||||
} catch {
|
} catch {
|
||||||
// If API fails, check session data as fallback
|
// If API fails, check user data as fallback
|
||||||
hasLocation = !!session?.user?.location;
|
hasLocation = !!user?.location;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,9 +114,9 @@ export const middleware = async ({ request }: LoaderFunctionArgs) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check route permissions using user data from session
|
// Check route permissions using user data
|
||||||
const userPermissions =
|
const userPermissions =
|
||||||
session?.user?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
|
user?.role?.permissions?.map?.((perm) => perm?.name) ?? [];
|
||||||
|
|
||||||
const matchedRoute = mappingRoutePermissions.find(
|
const matchedRoute = mappingRoutePermissions.find(
|
||||||
(route) => route.path === pathname
|
(route) => route.path === pathname
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ export const hackathonApi = axios.create({
|
|||||||
hackathonApi.interceptors.request.use(
|
hackathonApi.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const { session } = useAuthStore.getState();
|
const { session } = useAuthStore.getState();
|
||||||
if (session?.token) {
|
if (session?.token?.access_token) {
|
||||||
config.headers.Authorization = `Bearer ${session.token}`;
|
config.headers.Authorization = `Bearer ${session.token.access_token}`;
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -111,43 +111,16 @@ export const useLogin = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Email/Password Signup
|
// Email/Password Signup - returns message only (user needs to activate via email)
|
||||||
export const useSignup = () => {
|
export const useSignup = () => {
|
||||||
const { setSession } = useAuthStore();
|
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (data: SignupRequest) => {
|
mutationFn: async (data: SignupRequest) => {
|
||||||
const response = await hackathonApi.post<HackathonApiResponse<AuthResponse>>(
|
const response = await hackathonApi.post<HackathonApiResponse<MessageResponse>>(
|
||||||
'/auth/signup',
|
'/auth/signup',
|
||||||
data
|
data
|
||||||
);
|
);
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
},
|
},
|
||||||
onSuccess: (data) => {
|
|
||||||
setSession({
|
|
||||||
token: data.token,
|
|
||||||
user: {
|
|
||||||
id: data.user.id,
|
|
||||||
email: data.user.email,
|
|
||||||
fullname: data.user.fullname,
|
|
||||||
phone_number: data.user.phone_number || '',
|
|
||||||
avatar: data.user.avatar || '',
|
|
||||||
birthdate: data.user.birthdate || '',
|
|
||||||
gender: data.user.gender || '',
|
|
||||||
is_active: data.user.is_active,
|
|
||||||
location: data.user.location,
|
|
||||||
bio: data.user.bio,
|
|
||||||
skills: data.user.skills,
|
|
||||||
role: {
|
|
||||||
id: '',
|
|
||||||
name: 'user',
|
|
||||||
permissions: [],
|
|
||||||
created_at: '',
|
|
||||||
updated_at: '',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient, useInfiniteQuery } from '@tanstack/react-query';
|
||||||
import { hackathonApi, HackathonApiResponse } from '../../api/hackathon';
|
import { hackathonApi, HackathonApiResponse } from '../../api/hackathon';
|
||||||
import { useAuthStore } from '../auth';
|
import { useAuthStore } from '../auth';
|
||||||
import type {
|
import type {
|
||||||
@@ -131,6 +131,39 @@ export const useTeams = (params?: {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Infinite scroll teams hook
|
||||||
|
const TEAMS_PAGE_SIZE = 12;
|
||||||
|
|
||||||
|
export const useInfiniteTeams = (params?: {
|
||||||
|
city?: string;
|
||||||
|
visibility?: string;
|
||||||
|
search?: string;
|
||||||
|
}) => {
|
||||||
|
return useInfiniteQuery({
|
||||||
|
queryKey: [...teamKeys.lists(), 'infinite', params],
|
||||||
|
queryFn: async ({ pageParam = 1 }) => {
|
||||||
|
const queryParams = new URLSearchParams();
|
||||||
|
queryParams.append('page', String(pageParam));
|
||||||
|
queryParams.append('limit', String(TEAMS_PAGE_SIZE));
|
||||||
|
if (params?.search) queryParams.append('search', params.search);
|
||||||
|
if (params?.city) queryParams.append('city', params.city);
|
||||||
|
if (params?.visibility) queryParams.append('visibility', params.visibility);
|
||||||
|
|
||||||
|
const response = await hackathonApi.get<HackathonApiResponse<Team[]>>(
|
||||||
|
`/teams/browse?${queryParams.toString()}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const teams = response.data.data || [];
|
||||||
|
return {
|
||||||
|
data: teams,
|
||||||
|
nextPage: teams.length === TEAMS_PAGE_SIZE ? pageParam + 1 : undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
initialPageParam: 1,
|
||||||
|
getNextPageParam: (lastPage) => lastPage.nextPage,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const useTeamById = (teamId: string, enabled = true) => {
|
export const useTeamById = (teamId: string, enabled = true) => {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: teamKeys.detail(teamId),
|
queryKey: teamKeys.detail(teamId),
|
||||||
|
|||||||
Reference in New Issue
Block a user