feat: migrate all 5 Vite apps from react-router to TanStack Router

Migrated backoffice, hackathon, dimentorin, gacha, qrcampaign, and
infra from custom react-router file-based routing to TanStack Router
file-based routing following the tanstack-frontend-best-practice
convention.

Key changes per app:
- New routes/ directory with __root.tsx, _public.tsx, _authenticated.tsx
- Auth guards via beforeLoad (replaces old middleware.ts)
- createFileRoute pattern for all page components
- TanStackRouterVite plugin in vite.config for auto route generation
- _components/_hooks folders colocated with routes (ignored by router)
- routeTree.gen.ts auto-generated on dev/build

Convention:
- _public/* routes redirect to dashboard if authenticated
- _authenticated/* routes redirect to /auth/login if not authenticated
- $param for dynamic segments (was [param] in old convention)
- _layout suffix for pathless layout routes

Removed:
- Old src/app/ directories from all apps
- Old src/middleware.ts files
- Custom convertPagesToRoute utility (no longer needed)
- react-router dependency usage (kept in package.json for shared libs)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
maulanasdqn
2026-04-11 17:04:14 +07:00
co-authored by Claude Opus 4.6
parent 86d9e759a6
commit 4240e8eb51
291 changed files with 3889 additions and 4585 deletions
-23
View File
@@ -1,23 +0,0 @@
import { Link } from 'react-router-dom';
export default function NotFoundPage() {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<h1 className="text-9xl font-bold text-gray-200 mb-4">404</h1>
<h2 className="text-3xl font-semibold text-gray-900 dark:text-gray-300 mb-4">
Page Not Found
</h2>
<p className="text-gray-600 dark:text-gray-400 mb-8">
The page you are looking for doesn't exist or has been moved.
</p>
<Link
to="/"
className="inline-block px-6 py-3 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
Go Back Home
</Link>
</div>
</div>
);
}
@@ -1,160 +0,0 @@
import { FC, ReactElement, useEffect, useState, useRef } from 'react';
import { useNavigate } from 'react-router';
import { useGitHubCallback } from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
const CallbackPage: FC = (): ReactElement => {
const navigate = useNavigate();
const { mutateAsync: exchangeGitHubCode } = useGitHubCallback();
const [isProcessing, setIsProcessing] = useState(true);
const [error, setError] = useState<string | null>(null);
const hasRunRef = useRef(false);
useEffect(() => {
const handleCallback = async () => {
if (hasRunRef.current) {
return;
}
hasRunRef.current = true;
try {
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
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');
console.log('[Callback] Params:', { type, accessToken: !!accessToken, hash: globalThis.location.hash, search: globalThis.location.search });
if (accessToken) {
setIsProcessing(false);
if (type === 'recovery' || type === 'magiclink') {
toast.success('Email verified! Please set your new password.');
navigate('/auth/reset-password?access_token=' + accessToken);
return;
}
if (type === 'signup' || type === 'email_confirmation') {
toast.success('Email verified successfully! Please log in to continue.');
navigate('/auth/login');
return;
}
toast.success('Email verified! Please set your new password.');
navigate('/auth/reset-password?access_token=' + accessToken);
return;
}
const code = urlParams.get('code');
if (!code) {
throw new Error('No authorization code received');
}
const result = await exchangeGitHubCode({ code });
toast.success('Login successful!');
setIsProcessing(false);
if (result.user.location) {
globalThis.location.replace('/dashboard');
} else {
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
}, []);
if (error) {
const isPrivateEmailError =
error.toLowerCase().includes('failed to create user') ||
error.toLowerCase().includes('email') ||
error.toLowerCase().includes('user record');
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 px-4">
<div className="bg-white dark:bg-gray-900 w-full max-w-2xl p-8 rounded-2xl shadow-lg border border-red-200 dark:border-red-800">
<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 dark:text-white mb-2">
GitHub Login Failed
</h2>
<p className="text-red-600 dark:text-red-400 mb-4 whitespace-pre-line">{error}</p>
</div>
{isPrivateEmailError && (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-amber-800 dark:text-amber-300 mb-2">
Is your GitHub email set to private?
</h3>
<p className="text-amber-700 dark:text-amber-400 text-sm mb-3">
GitHub login requires a public email address. Please follow these steps:
</p>
<ol className="text-amber-700 dark:text-amber-400 text-sm list-decimal list-inside space-y-1 mb-3">
<li>
Go to{' '}
<a
href="https://github.com/settings/emails"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-amber-900 dark:hover:text-amber-200"
>
GitHub Email Settings
</a>
</li>
<li>Uncheck "Keep my email addresses private"</li>
<li>
Or go to{' '}
<a
href="https://github.com/settings/profile"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-amber-900 dark:hover:text-amber-200"
>
Profile Settings
</a>{' '}
and set a public email
</li>
<li>Try signing in with GitHub again</li>
</ol>
<p className="text-amber-600 dark:text-amber-500 text-xs">
Alternatively, you can sign up using email and password instead.
</p>
</div>
)}
<p className="text-gray-600 dark:text-gray-400 text-sm mt-6 text-center">
Redirecting to login page in 3 seconds...
</p>
</div>
</div>
);
}
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
Completing login...
</h2>
<p className="text-gray-600 dark:text-gray-400">Please wait</p>
</div>
</div>
);
};
export default CallbackPage;
@@ -1,125 +0,0 @@
import { useState } from 'react';
import { useForgotPassword } from '@imphnen-frontend-service/service';
import { Link, useNavigate } from 'react-router';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
import ThemeToggle from '../../../components/theme-toggle';
export default function ForgotPasswordPage() {
const [email, setEmail] = useState('');
const [emailSent, setEmailSent] = useState(false);
const navigate = useNavigate();
const forgotPasswordMutation = useForgotPassword();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email) {
toast.error('Please enter your email');
return;
}
try {
await forgotPasswordMutation.mutateAsync({ email });
setEmailSent(true);
toast.success('Password reset email sent! Check your inbox.');
} catch (err) {
toast.error((err as Error).message || 'Failed to send reset email');
}
};
if (emailSent) {
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">
<span className="text-3xl"></span>
</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 a password reset link to <strong>{email}</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 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 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors"
>
Send another email
</button>
</div>
</div>
</div>
);
}
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-6">
<button
onClick={() => navigate('/auth/login')}
className="cursor-pointer text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300 text-base font-sans flex items-center"
>
<Icon icon="ic:baseline-chevron-left" width="24" height="24" />
Back to Login
</button>
<ThemeToggle />
</div>
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Forgot Password?
</h2>
<p className="text-gray-600 dark:text-gray-400">
No worries, we'll send you reset instructions
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Email Address
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
disabled={forgotPasswordMutation.isPending}
className="bg-white dark:bg-gray-800 text-gray-900 dark:text-white w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed"
required
/>
</div>
<button
type="submit"
disabled={forgotPasswordMutation.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 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{forgotPasswordMutation.isPending ? 'Sending...' : 'Send Reset Link'}
</button>
</form>
</div>
</div>
);
}
@@ -1,158 +0,0 @@
import { useState, useEffect } from 'react';
import { useResetPassword, useAuthStore } from '@imphnen-frontend-service/service';
import { useNavigate } from 'react-router';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
export default function ResetPasswordPage() {
const navigate = useNavigate();
const { clearSession } = useAuthStore();
const resetPasswordMutation = useResetPassword();
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [accessToken, setAccessToken] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
useEffect(() => {
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
const queryParams = new URLSearchParams(globalThis.location.search);
const token = hashParams.get('access_token') || queryParams.get('access_token');
if (token) {
setAccessToken(token);
} 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;
}
if (!accessToken) {
toast.error('Invalid reset token');
return;
}
try {
await resetPasswordMutation.mutateAsync({
access_token: accessToken,
new_password: password,
});
toast.success('Password updated successfully!');
clearSession();
navigate('/auth/login');
} catch (err) {
toast.error((err as Error).message || 'Failed to reset password');
}
};
if (!accessToken) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
<p className="text-gray-600 dark:text-gray-400">
Verifying reset link...
</p>
</div>
</div>
);
}
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Set New Password
</h2>
<p className="text-gray-600 dark:text-gray-400">
Enter your new password below
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
New Password
</label>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
disabled={resetPasswordMutation.isPending}
className="w-full px-4 py-2.5 pr-12 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
required
minLength={6}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
<Icon icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
</button>
</div>
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Confirm New Password
</label>
<div className="relative">
<input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
disabled={resetPasswordMutation.isPending}
className="w-full px-4 py-2.5 pr-12 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
required
minLength={6}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
<Icon icon={showConfirmPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
</button>
</div>
</div>
<button
type="submit"
disabled={resetPasswordMutation.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 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors"
>
{resetPasswordMutation.isPending ? 'Updating...' : 'Update Password'}
</button>
</form>
</div>
</div>
);
}
-402
View File
@@ -1,402 +0,0 @@
import { useState } from 'react';
import { useGitHubAuth, useSignup } from '@imphnen-frontend-service/service';
import { GithubOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
import { ThemeToggle } from '../../../components/theme-toggle';
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>;
const REGISTRATION_DEADLINE = new Date('2025-11-30T16:29:00Z');
export default function SignupPage() {
const navigate = useNavigate();
const isRegistrationClosed = new Date() >= REGISTRATION_DEADLINE;
const { signInWithGitHub } = useGitHubAuth();
const signupMutation = useSignup();
const [isGithubLoading, setIsGithubLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [registrationSuccess, setRegistrationSuccess] = useState(false);
const [registeredEmail, setRegisteredEmail] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isValid },
} = useForm<SignupFormData>({
resolver: zodResolver(signupSchema),
mode: 'onChange',
});
const onSubmit = async (data: SignupFormData) => {
setError(null);
try {
const result = await signupMutation.mutateAsync({
email: data.email,
password: data.password,
fullname: data.fullname,
});
toast.success(result.message);
setRegisteredEmail(data.email);
setRegistrationSuccess(true);
} catch (err) {
console.error('[Signup] Email signup failed:', err);
setError((err as Error).message || 'Signup failed');
}
};
if (isRegistrationClosed) {
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-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-4">
<Icon
icon="mdi:clock-alert"
className="text-3xl text-red-600 dark:text-red-400"
/>
</div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Registration Closed
</h2>
<p className="text-gray-600 dark:text-gray-400">
The registration period for this hackathon has ended.
</p>
</div>
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">
Thank you for your interest! Registration closed on November 30, 2025 at 23:29 WIB.
</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 cursor-pointer">
Go to Login
</button>
</Link>
<button
onClick={() => navigate('/')}
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
>
Back to Homepage
</button>
</div>
</div>
</div>
);
}
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 () => {
try {
setIsGithubLoading(true);
const result = await signInWithGitHub();
if (result?.url) {
globalThis.location.href = result.url;
} else {
setIsGithubLoading(false);
setError('Failed to get GitHub OAuth URL');
}
} catch (err) {
console.error('[Signup] GitHub login failed:', err);
setError((err as Error).message || 'GitHub login failed');
setIsGithubLoading(false);
}
};
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 (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-6">
<button
onClick={() => navigate('/')}
className="cursor-pointer text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300 text-base font-sans flex items-center"
>
<Icon icon="ic:baseline-chevron-left" width="24" height="24" />
Back to Homepage
</button>
<ThemeToggle />
</div>
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Create Account
</h2>
<p className="text-gray-600 dark:text-gray-400">
Join the hackathon community
</p>
</div>
{error && (
<div className="mb-6 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p>
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label
htmlFor="fullname"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Full Name
</label>
<input
id="fullname"
type="text"
{...register('fullname')}
placeholder="John Doe"
disabled={signupMutation.isPending}
className={`${inputBaseClass} ${
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>
<label
htmlFor="email"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Email
</label>
<input
id="email"
type="email"
{...register('email')}
placeholder="your@email.com"
disabled={signupMutation.isPending}
className={`${inputBaseClass} ${
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>
<label
htmlFor="password"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Password
</label>
<div className="relative">
<input
id="password"
type={showPassword ? 'text' : 'password'}
{...register('password')}
placeholder="••••••••"
disabled={signupMutation.isPending}
className={`${inputBaseClass} pr-12 ${
errors.password ? inputErrorClass : inputNormalClass
}`}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
<Icon icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
</button>
</div>
{errors.password && (
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
{errors.password.message}
</p>
)}
</div>
<div>
<label
htmlFor="confirmPassword"
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"
>
Confirm Password
</label>
<div className="relative">
<input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
{...register('confirmPassword')}
placeholder="••••••••"
disabled={signupMutation.isPending}
className={`${inputBaseClass} pr-12 ${
errors.confirmPassword ? inputErrorClass : inputNormalClass
}`}
/>
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
>
<Icon icon={showConfirmPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
</button>
</div>
{errors.confirmPassword && (
<p className="mt-1 text-sm text-red-500 dark:text-red-400">
{errors.confirmPassword.message}
</p>
)}
</div>
<button
type="submit"
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"
>
{signupMutation.isPending
? 'Creating account...'
: 'Create Account'}
</button>
</form>
<div className="my-6 flex items-center">
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div>
<span className="px-4 text-sm text-gray-500 dark:text-gray-400">
OR
</span>
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div>
</div>
<button
onClick={handleGithubLogin}
disabled={isGithubLoading}
type="button"
className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg font-semibold text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:bg-gray-100 dark:disabled:bg-gray-800 disabled:cursor-not-allowed transition-colors cursor-pointer"
>
<GithubOutlined className="text-xl" />
<span>
{isGithubLoading ? 'Connecting...' : 'Sign up with GitHub'}
</span>
</button>
<p className="mt-3 text-xs text-center text-gray-500 dark:text-gray-500 font-sans">
Make sure your GitHub email is{' '}
<a
href="https://github.com/settings/emails"
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 dark:text-primary-400 hover:underline"
>
set to public
</a>{' '}
for GitHub sign up to work.
</p>
<div className="mt-6 text-center">
<p className="text-gray-600 dark:text-gray-400 text-sm">
Already have an account?{' '}
<Link
to="/auth/login"
className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold"
>
Sign in
</Link>
</p>
</div>
<div className="mt-6 text-center">
<p className="text-gray-500 dark:text-gray-500 text-xs">
By signing up, you agree to our Terms of Service and Privacy Policy
</p>
</div>
</div>
</div>
);
}
@@ -1,46 +0,0 @@
import { FC, ReactElement, useState } from 'react';
import { Outlet } from 'react-router';
import { Sidebar } from '../../components/sidebar';
const DashboardLayout: FC = (): ReactElement => {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex-1 flex flex-col">
<div className="lg:hidden sticky top-0 bg-white dark:bg-gray-900 border-b dark:border-gray-700 px-4 py-3 flex items-center">
<button
onClick={() => setSidebarOpen(true)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<svg
className="w-6 h-6 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<h1 className="ml-3 text-lg font-bold text-gray-900 dark:text-white">
Hackathon
</h1>
</div>
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
</div>
);
};
export default DashboardLayout;
-29
View File
@@ -1,29 +0,0 @@
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>
);
}
-96
View File
@@ -1,96 +0,0 @@
import {
Outlet,
ScrollRestoration,
useLocation,
useNavigate,
} from 'react-router-dom';
import { useEffect, useState } from 'react';
import { useAuthStore, useUserMe } from '@imphnen-frontend-service/service';
const ONBOARDING_ROUTES = new Set(['/onboarding/user']);
export default function RootLayout() {
const location = useLocation();
const navigate = useNavigate();
const { session } = useAuthStore();
const { data: userData, isLoading: isUserLoading } = useUserMe();
const [isChecking, setIsChecking] = useState(true);
useEffect(() => {
const checkAuth = async () => {
const pathname = location.pathname;
if (pathname.startsWith('/hackathons')) {
setIsChecking(false);
return;
}
if (pathname === '/auth/callback') {
setIsChecking(false);
return;
}
if (pathname.startsWith('/auth')) {
if (session && pathname !== '/auth/reset-password') {
navigate('/dashboard', { replace: true });
setIsChecking(false);
return;
}
setIsChecking(false);
return;
}
if (pathname === '/') {
setIsChecking(false);
return;
}
if (pathname.startsWith('/certificate/')) {
setIsChecking(false);
return;
}
if (!session) {
navigate('/auth/login', { replace: true });
setIsChecking(false);
return;
}
if (isUserLoading) {
return;
}
if (!ONBOARDING_ROUTES.has(pathname)) {
const hasLocation = !!userData?.data?.location || !!session?.user?.location;
if (!hasLocation) {
navigate('/onboarding/user', { replace: true });
setIsChecking(false);
return;
}
}
setIsChecking(false);
};
checkAuth();
}, [location.pathname, navigate, session, userData, isUserLoading]);
if (isChecking) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-neutral-950">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
<p className="text-gray-600 dark:text-neutral-400">Loading...</p>
</div>
</div>
);
}
return (
<>
<Outlet />
<ScrollRestoration />
</>
);
}
-377
View File
@@ -1,377 +0,0 @@
import { FC, ReactElement, useState, useEffect } from 'react';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useForm, Controller } from 'react-hook-form';
import {
userEditProfileSchema,
TUserEditProfileForm,
useUpdateUserMe,
useUploadAvatar,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { CitySelect } from '../../components/city-select';
import { Icon } from '@iconify/react';
const ROLE_OPTIONS = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
];
type ProfileModalProps = {
open: boolean;
onClose: () => void;
};
const ProfilePage: FC<ProfileModalProps> = ({
open,
onClose,
}): ReactElement | null => {
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string>('');
const { mutateAsync: updateUser, isPending: isUpdating } = useUpdateUserMe();
const { mutateAsync: uploadAvatar, isPending: isUploading } =
useUploadAvatar();
const { session } = useAuthStore();
const form = useForm<TUserEditProfileForm>({
resolver: zodResolver(userEditProfileSchema),
mode: 'all',
defaultValues: {
fullname: session?.user?.fullname || '',
avatar: session?.user?.avatar || null,
location: session?.user?.location || '',
bio: session?.user?.bio || '',
skills: session?.user?.skills || [],
},
});
useEffect(() => {
if (session?.user?.avatar && !avatarPreview) {
setAvatarPreview(session.user.avatar);
}
if (session?.user?.fullname) {
form.setValue('fullname', session.user.fullname);
}
if (session?.user?.location) {
form.setValue('location', session.user.location);
}
if (session?.user?.bio) {
form.setValue('bio', session.user.bio);
}
if (session?.user?.skills) {
form.setValue('skills', session.user.skills);
}
}, [
session?.user?.avatar,
session?.user?.fullname,
session?.user?.location,
session?.user?.bio,
session?.user?.skills,
avatarPreview,
form,
]);
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 2 * 1024 * 1024) {
toast.error('The file is too large. Maximum size is 2MB.');
e.target.value = '';
return;
}
if (!file.type.startsWith('image/')) {
toast.error('The file must be an image');
e.target.value = '';
return;
}
setAvatarFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const onSubmit = form.handleSubmit(async (data) => {
try {
let avatarUrl = session?.user?.avatar || null;
if (avatarFile) {
const uploadResult = await uploadAvatar(avatarFile);
avatarUrl = uploadResult.data.url;
}
await updateUser({
fullname: data.fullname,
avatar: avatarUrl,
location: data.location,
bio: data.bio,
skills: data.skills,
});
toast.success('Profile updated successfully!');
await new Promise((resolve) => setTimeout(resolve, 100));
onClose();
} catch (error) {
console.error('Profile update failed:', error);
toast.error(
`Failed to update profile: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
});
const isLoading = isUpdating || isUploading;
return open ? (
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-md z-50 overflow-y-auto">
<div className="min-h-full flex items-center justify-center">
<div className="bg-white dark:bg-gray-900 w-full max-w-md mx-4 my-6 sm:my-8 p-8 rounded-xl border dark:boder-gray-800">
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Edit Profile
</h1>
<button
type="button"
onClick={onClose}
className="text-gray-500 dark:text-neutral-400 hover:text-gray-700 dark:hover:text-neutral-200 cursor-pointer"
aria-label="Close profile modal"
>
<svg
className="w-6 h-6"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M6 18L18 6M6 6l12 12"
/>
</svg>
</button>
</div>
<p className="text-gray-600 font-sans dark:text-neutral-400">
Update your photo and name
</p>
</div>
<form onSubmit={onSubmit} className="space-y-6">
<div className="flex flex-col items-center space-y-4">
<div className="relative group">
{avatarPreview ? (
<img
src={avatarPreview}
alt="Avatar preview"
className="w-24 h-24 rounded-full object-cover border-4 border-gray-200 dark:border-neutral-700 group-hover:border-blue-400 dark:group-hover:border-blue-500 transition-colors"
/>
) : (
<div className="w-24 h-24 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center group-hover:bg-gray-300 dark:group-hover:bg-gray-600 transition-colors">
<Icon
icon="ic:baseline-person"
width="48"
height="48"
className="text-gray-400 dark:text-neutral-500"
/>
</div>
)}
<label
htmlFor="avatar"
className="absolute bottom-0 right-0 bg-primary-500 text-white p-2 rounded-full cursor-pointer hover:bg-primary-600 transition-colors shadow-lg"
>
<svg
className="w-5 h-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<input
id="avatar"
type="file"
accept="image/*"
className="hidden"
onChange={handleAvatarChange}
disabled={isLoading}
/>
</label>
</div>
<p className="text-sm text-gray-500 dark:text-neutral-400 text-center font-sans">
Click the camera icon to change your photo
<br />
Format: JPG, PNG. Max 2MB
</p>
</div>
<ControlledInputField
control={form.control}
label="Full Name"
placeholder="Enter your full name"
name="fullname"
size="lg"
/>
<div className="space-y-2">
<label className="block text-label1 font-medium text-neutral-800 dark:text-neutral-300">
City
</label>
<Controller
control={form.control}
name="location"
render={({ field, fieldState }) => (
<CitySelect
value={field.value ?? ''}
onChange={field.onChange}
error={fieldState.error?.message}
placeholder="Search your city..."
/>
)}
/>
</div>
<div className="space-y-2">
<label className="block text-label1 font-medium text-gray-700 dark:text-neutral-300">
Role / Skills
</label>
<Controller
control={form.control}
name="skills"
render={({ field }) => (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((role) => (
<label
key={role}
className="flex items-center space-x-2 cursor-pointer font-sans"
>
<input
type="checkbox"
checked={field.value?.includes(role)}
onChange={(e) => {
const newValue = e.target.checked
? [...(field.value || []), role]
: (field.value || []).filter((v) => v !== role);
field.onChange(newValue);
}}
className="rounded border-gray-300 dark:border-neutral-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-800"
/>
<span className="text-sm dark:text-neutral-300">
{role}
</span>
</label>
))}
</div>
</div>
)}
/>
</div>
<div className="space-y-2">
<label className="block text-label1 font-medium text-gray-700 dark:text-neutral-300">
Bio{' '}
<span className="text-gray-400 dark:text-neutral-500">
(Optional)
</span>
</label>
<Controller
control={form.control}
name="bio"
render={({ field, fieldState }) => (
<div>
<Textarea
{...field}
placeholder="Tell us about yourself..."
rows={4}
className="w-full font-sans"
size="lg"
/>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">
{fieldState.error.message}
</p>
)}
</div>
)}
/>
</div>
<div className="flex space-x-3 pt-4">
<Button
type="button"
variant="secondary"
className="flex-1"
onClick={onClose}
disabled={isLoading}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1"
disabled={isLoading || !form.formState.isValid}
>
{isLoading ? (
<span className="flex items-center justify-center">
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4 text-white"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
Saving...
</span>
) : (
'Save'
)}
</Button>
</div>
</form>
</div>
</div>
</div>
) : null;
};
export default ProfilePage;
@@ -1,46 +0,0 @@
import { FC, ReactElement, useState } from 'react';
import { Outlet } from 'react-router';
import { Sidebar } from '../../../components/sidebar';
const TeamDetailLayout: FC = (): ReactElement => {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex-1 flex flex-col">
<div className="lg:hidden sticky top-0 z-30 bg-white dark:bg-gray-900 border-b dark:border-gray-700 px-4 py-3 flex items-center">
<button
onClick={() => setSidebarOpen(true)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<svg
className="w-6 h-6 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<h1 className="ml-3 text-lg font-bold text-gray-900 dark:text-white">
Hackathon
</h1>
</div>
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
</div>
);
};
export default TeamDetailLayout;
-48
View File
@@ -1,48 +0,0 @@
import { FC, ReactElement, useState } from 'react';
import { Outlet } from 'react-router';
import { Sidebar } from '../../components/sidebar';
const TeamsLayout: FC = (): ReactElement => {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex-1 flex flex-col">
<div className="lg:hidden sticky top-0 bg-white dark:bg-gray-900 border-b dark:border-gray-700 px-4 py-3 z-10">
<div className="flex items-center">
<button
onClick={() => setSidebarOpen(true)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<svg
className="w-6 h-6 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<h1 className="ml-3 text-lg font-bold text-gray-900 dark:text-white">
Hackathon
</h1>
</div>
</div>
<main className="flex-1">
<Outlet />
</main>
</div>
</div>
);
};
export default TeamsLayout;
@@ -1,46 +0,0 @@
import { FC, ReactElement, useState } from 'react';
import { Outlet } from 'react-router';
import { Sidebar } from '../../../components/sidebar';
const UserDetailLayout: FC = (): ReactElement => {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex-1 flex flex-col overflow-auto">
<div className="lg:hidden bg-white dark:bg-gray-900 border-b dark:border-neutral-700 px-4 py-3 flex items-center">
<button
onClick={() => setSidebarOpen(true)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<svg
className="w-6 h-6 text-gray-600 dark:text-neutral-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<h1 className="ml-3 text-lg font-bold text-gray-900 dark:text-white">
Hackathon
</h1>
</div>
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
</div>
);
};
export default UserDetailLayout;
+17 -26
View File
@@ -1,35 +1,26 @@
import { createRoot } from 'react-dom/client';
import { StrictMode } from 'react';
import { createBrowserRouter, RouteObject, RouterProvider } from 'react-router';
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { RouterProvider, createRouter } from '@tanstack/react-router'
import {
add404PageToRoutesChildren,
addErrorElementToRoutes,
convertPagesToRoute,
ModalLoginProvider,
QueryProvider,
} from '@imphnen-frontend-service/utils';
import { Toaster } from 'sonner';
import { ThemeProvider } from './components/theme-provider';
import './index.css';
} from '@imphnen-frontend-service/utils'
import { Toaster } from 'sonner'
import { ThemeProvider } from './components/theme-provider'
import { routeTree } from './routeTree.gen'
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 router = createRouter({ routeTree })
const routes = convertPagesToRoute(files, loadingFiles) as RouteObject;
addErrorElementToRoutes(errorFiles, routes);
add404PageToRoutesChildren(notFoundFiles, routes);
declare module '@tanstack/react-router' {
interface Register {
router: typeof router
}
}
const router = createBrowserRouter([
{
...routes,
},
]);
const rootElement = document.getElementById('root')
const rootElement = document.getElementById('root');
if (!rootElement) throw new Error('Failed to find the root element');
if (!rootElement) throw new Error('Failed to find the root element')
createRoot(rootElement).render(
<StrictMode>
@@ -42,4 +33,4 @@ createRoot(rootElement).render(
</QueryProvider>
</ThemeProvider>
</StrictMode>
);
)
-120
View File
@@ -1,120 +0,0 @@
import { SessionUser } from '@imphnen-frontend-service/service';
import { hackathonApi, SessionToken } 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',
];
const onboardingCache = new Map<string, { hasLocation: boolean; timestamp: number }>();
const CACHE_DURATION = 5000;
export const middleware = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const pathname = url.pathname;
const tokenData = SessionToken.get();
const user = SessionUser.get();
const isAuthenticated = !!tokenData?.token?.access_token;
if (mappingPublicPrefixRoutes.some((prefix) => pathname.startsWith(prefix))) {
return null;
}
if (mappingPublicRoutes.includes(pathname)) {
return null;
}
if (pathname === '/auth/callback') {
return null;
}
if (pathname.startsWith('/auth')) {
if (isAuthenticated) return redirect('/dashboard');
return null;
}
if (!isAuthenticated) {
return redirect('/auth/login');
}
if (!mappingOnboardingRoutes.includes(pathname)) {
try {
const userId = user?.id;
if (!userId) {
return redirect('/auth/login');
}
const now = Date.now();
const cached = onboardingCache.get(userId);
let hasLocation = false;
if (cached && (now - cached.timestamp) < CACHE_DURATION) {
hasLocation = cached.hasLocation;
} else {
if (user?.location) {
hasLocation = true;
} else {
try {
const response = await hackathonApi.get('/users/me');
hasLocation = !!response.data?.data?.location;
} catch {
hasLocation = !!user?.location;
}
}
onboardingCache.set(userId, { hasLocation, timestamp: now });
}
if (!hasLocation) {
return redirect('/onboarding/user');
}
} catch (error) {
console.error('[Middleware] Unexpected error checking onboarding:', error);
return null;
}
}
const userPermissions =
user?.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;
};
+3
View File
@@ -0,0 +1,3 @@
// This file is auto-generated by TanStack Router
export const routeTree = {} as any
+5
View File
@@ -0,0 +1,5 @@
import { createRootRoute, Outlet } from '@tanstack/react-router'
export const Route = createRootRoute({
component: () => <Outlet />,
})
@@ -0,0 +1,92 @@
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
import { SessionToken, SessionUser, hackathonApi } from '@imphnen-frontend-service/service'
import { useState } from 'react'
import { Sidebar } from '../components/sidebar'
const onboardingCache = new Map<string, { hasLocation: boolean; timestamp: number }>()
const CACHE_DURATION = 5000
export const Route = createFileRoute('/_authenticated')({
beforeLoad: async ({ location }) => {
const session = SessionToken.get()
if (!session?.token?.access_token) {
throw redirect({ to: '/auth/login' })
}
const user = SessionUser.get()
const pathname = location.pathname
if (!pathname.startsWith('/onboarding')) {
const userId = user?.id
if (!userId) {
throw redirect({ to: '/auth/login' })
}
const now = Date.now()
const cached = onboardingCache.get(userId)
let hasLocation = false
if (cached && now - cached.timestamp < CACHE_DURATION) {
hasLocation = cached.hasLocation
} else {
if (user?.location) {
hasLocation = true
} else {
try {
const response = await hackathonApi.get('/users/me')
hasLocation = !!response.data?.data?.location
} catch {
hasLocation = !!user?.location
}
}
onboardingCache.set(userId, { hasLocation, timestamp: now })
}
if (!hasLocation) {
throw redirect({ to: '/onboarding/user' })
}
}
},
component: AuthenticatedLayout,
})
function AuthenticatedLayout() {
const [sidebarOpen, setSidebarOpen] = useState(false)
return (
<div className="flex min-h-screen bg-gray-50 dark:bg-gray-950">
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex-1 flex flex-col">
<div className="lg:hidden sticky top-0 bg-white dark:bg-gray-900 border-b dark:border-gray-700 px-4 py-3 flex items-center z-30">
<button
onClick={() => setSidebarOpen(true)}
className="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
>
<svg
className="w-6 h-6 text-gray-600 dark:text-gray-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M4 6h16M4 12h16M4 18h16"
/>
</svg>
</button>
<h1 className="ml-3 text-lg font-bold text-gray-900 dark:text-white">
Hackathon
</h1>
</div>
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
</div>
)
}
@@ -0,0 +1,243 @@
import { FC, ReactElement, useState, useEffect } from 'react';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useForm, Controller } from 'react-hook-form';
import {
userEditProfileSchema,
TUserEditProfileForm,
useUpdateUserMe,
useUploadAvatar,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { CitySelect } from '../../../components/city-select';
import { Icon } from '@iconify/react';
const ROLE_OPTIONS = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
];
type ProfileModalProps = {
open: boolean;
onClose: () => void;
};
const ProfilePage: FC<ProfileModalProps> = ({
open,
onClose,
}): ReactElement | null => {
const [avatarFile, setAvatarFile] = useState<File | null>(null);
const [avatarPreview, setAvatarPreview] = useState<string>('');
const { mutateAsync: updateUser, isPending: isUpdating } = useUpdateUserMe();
const { mutateAsync: uploadAvatar, isPending: isUploading } =
useUploadAvatar();
const { session } = useAuthStore();
const form = useForm<TUserEditProfileForm>({
resolver: zodResolver(userEditProfileSchema),
mode: 'all',
defaultValues: {
fullname: session?.user?.fullname || '',
avatar: session?.user?.avatar || null,
location: session?.user?.location || '',
bio: session?.user?.bio || '',
skills: session?.user?.skills || [],
},
});
useEffect(() => {
if (session?.user?.avatar && !avatarPreview) {
setAvatarPreview(session.user.avatar);
}
if (session?.user?.fullname) {
form.setValue('fullname', session.user.fullname);
}
if (session?.user?.location) {
form.setValue('location', session.user.location);
}
if (session?.user?.bio) {
form.setValue('bio', session.user.bio);
}
if (session?.user?.skills) {
form.setValue('skills', session.user.skills);
}
}, [
session?.user?.avatar,
session?.user?.fullname,
session?.user?.location,
session?.user?.bio,
session?.user?.skills,
avatarPreview,
form,
]);
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.size > 2 * 1024 * 1024) {
toast.error('The file is too large. Maximum size is 2MB.');
e.target.value = '';
return;
}
if (!file.type.startsWith('image/')) {
toast.error('The file must be an image');
e.target.value = '';
return;
}
setAvatarFile(file);
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
}
};
const onSubmit = form.handleSubmit(async (data) => {
try {
let avatarUrl = session?.user?.avatar || null;
if (avatarFile) {
const uploadResult = await uploadAvatar(avatarFile);
avatarUrl = uploadResult.data.url;
}
await updateUser({
fullname: data.fullname,
avatar: avatarUrl,
location: data.location,
bio: data.bio,
skills: data.skills,
});
toast.success('Profile updated successfully!');
await new Promise((resolve) => setTimeout(resolve, 100));
onClose();
} catch (error) {
console.error('Profile update failed:', error);
toast.error(
`Failed to update profile: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
}
});
const isLoading = isUpdating || isUploading;
return open ? (
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-md z-50 overflow-y-auto">
<div className="min-h-full flex items-center justify-center">
<div className="bg-white dark:bg-gray-900 w-full max-w-md mx-4 my-6 sm:my-8 p-8 rounded-xl border dark:boder-gray-800">
<div className="mb-6">
<div className="flex items-center justify-between mb-2">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Edit Profile
</h1>
<button
type="button"
onClick={onClose}
className="text-gray-500 dark:text-neutral-400 hover:text-gray-700 dark:hover:text-neutral-200 cursor-pointer"
aria-label="Close profile modal"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<p className="text-gray-600 font-sans dark:text-neutral-400">Update your photo and name</p>
</div>
<form onSubmit={onSubmit} className="space-y-6">
<div className="flex flex-col items-center space-y-4">
<div className="relative group">
{avatarPreview ? (
<img src={avatarPreview} alt="Avatar preview" className="w-24 h-24 rounded-full object-cover border-4 border-gray-200 dark:border-neutral-700 group-hover:border-blue-400 dark:group-hover:border-blue-500 transition-colors" />
) : (
<div className="w-24 h-24 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center group-hover:bg-gray-300 dark:group-hover:bg-gray-600 transition-colors">
<Icon icon="ic:baseline-person" width="48" height="48" className="text-gray-400 dark:text-neutral-500" />
</div>
)}
<label htmlFor="avatar" className="absolute bottom-0 right-0 bg-primary-500 text-white p-2 rounded-full cursor-pointer hover:bg-primary-600 transition-colors shadow-lg">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<input id="avatar" type="file" accept="image/*" className="hidden" onChange={handleAvatarChange} disabled={isLoading} />
</label>
</div>
<p className="text-sm text-gray-500 dark:text-neutral-400 text-center font-sans">
Click the camera icon to change your photo<br />Format: JPG, PNG. Max 2MB
</p>
</div>
<ControlledInputField control={form.control} label="Full Name" placeholder="Enter your full name" name="fullname" size="lg" />
<div className="space-y-2">
<label className="block text-label1 font-medium text-neutral-800 dark:text-neutral-300">City</label>
<Controller control={form.control} name="location" render={({ field, fieldState }) => (
<CitySelect value={field.value ?? ''} onChange={field.onChange} error={fieldState.error?.message} placeholder="Search your city..." />
)} />
</div>
<div className="space-y-2">
<label className="block text-label1 font-medium text-gray-700 dark:text-neutral-300">Role / Skills</label>
<Controller control={form.control} name="skills" render={({ field }) => (
<div className="space-y-2">
<div className="grid grid-cols-2 gap-2">
{ROLE_OPTIONS.map((role) => (
<label key={role} className="flex items-center space-x-2 cursor-pointer font-sans">
<input type="checkbox" checked={field.value?.includes(role)} onChange={(e) => { const newValue = e.target.checked ? [...(field.value || []), role] : (field.value || []).filter((v) => v !== role); field.onChange(newValue); }} className="rounded border-gray-300 dark:border-neutral-600 text-blue-600 focus:ring-blue-500 dark:bg-gray-800" />
<span className="text-sm dark:text-neutral-300">{role}</span>
</label>
))}
</div>
</div>
)} />
</div>
<div className="space-y-2">
<label className="block text-label1 font-medium text-gray-700 dark:text-neutral-300">Bio <span className="text-gray-400 dark:text-neutral-500">(Optional)</span></label>
<Controller control={form.control} name="bio" render={({ field, fieldState }) => (
<div>
<Textarea {...field} placeholder="Tell us about yourself..." rows={4} className="w-full font-sans" size="lg" />
{fieldState.error && <p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>}
</div>
)} />
</div>
<div className="flex space-x-3 pt-4">
<Button type="button" variant="secondary" className="flex-1" onClick={onClose} disabled={isLoading}>Cancel</Button>
<Button type="submit" className="flex-1" disabled={isLoading || !form.formState.isValid}>
{isLoading ? (
<span className="flex items-center justify-center">
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Saving...
</span>
) : 'Save'}
</Button>
</div>
</form>
</div>
</div>
</div>
) : null;
};
export default ProfilePage;
@@ -1,18 +1,21 @@
import { FC, ReactElement, useEffect, useMemo, useState } from 'react';
import { Link, useNavigate } from 'react-router';
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
import {
useMyTeams,
useMyInvitations,
useRespondToInvitation,
useAuthStore,
useWinners,
} from '@imphnen-frontend-service/service';
useWinners } from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Icon } from '@iconify/react';
import ProfilePage from '../profile/page';
import ProfilePage from './_components/profile-modal';
import { encodeWinnerCertificateId } from '../../utils/certificate';
export const Route = createFileRoute('/_authenticated/dashboard')({
component: DashboardPage,
})
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
const SUBMISSION_DEADLINE = new Date('2025-12-07T16:59:00Z');
@@ -159,7 +162,7 @@ const DashboardPage: FC = (): ReactElement => {
const team = myTeams[0] as { id?: string } | null | undefined;
if (!team?.id) return;
const certId = await encodeWinnerCertificateId(team.id);
navigate(`/certificate/winner/${encodeURIComponent(certId)}`);
navigate({ to: `/certificate/winner/${encodeURIComponent(certId)}` });
}}
className="shrink-0 px-6 py-2 bg-amber-600 hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-700 text-white font-medium rounded-lg transition-colors cursor-pointer"
>
@@ -475,4 +478,3 @@ const DashboardPage: FC = (): ReactElement => {
);
};
export default DashboardPage;
@@ -1,7 +1,7 @@
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 { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useForm, Controller } from 'react-hook-form';
import {
userOnboardingSchema,
@@ -9,14 +9,17 @@ import {
useUpdateUserMe,
useUploadAvatar,
useUserMe,
useAuthStore,
} from '@imphnen-frontend-service/service';
useAuthStore } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { CitySelect } from '../../../components/city-select';
import { Icon } from '@iconify/react';
export const Route = createFileRoute('/_authenticated/onboarding/user')({
component: UserOnboardingPage,
})
const ROLE_OPTIONS = [
'Frontend Developer',
'Backend Developer',
@@ -267,4 +270,3 @@ const UserOnboardingPage: FC = (): ReactElement => {
);
};
export default UserOnboardingPage;
@@ -1,6 +1,6 @@
import { FC, ReactElement, useState, useEffect } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Link, useParams, useNavigate } from 'react-router';
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
import {
useTeamById,
useTeamMembers,
@@ -10,11 +10,13 @@ import {
useLeaveTeam,
useDeleteTeam,
ETeamMemberRole,
useAuthStore,
} from '@imphnen-frontend-service/service';
useAuthStore } from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
export const Route = createFileRoute('/_authenticated/teams/$teamId')({
component: TeamDashboardPage })
const MAX_TEAM_MEMBERS = 5;
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
@@ -50,7 +52,7 @@ const ImageWithLoader: FC<{
};
const TeamDashboardPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const { teamId } = Route.useParams();
const navigate = useNavigate();
const { session } = useAuthStore();
@@ -153,7 +155,7 @@ const TeamDashboardPage: FC = (): ReactElement => {
try {
await leaveTeam(teamId);
toast.success('You have left the team');
navigate('/dashboard');
navigate({ to: '/dashboard' });
} catch (error) {
console.error('Failed to leave team:', error);
toast.error('Failed to leave team');
@@ -166,7 +168,7 @@ const TeamDashboardPage: FC = (): ReactElement => {
try {
await deleteTeam(teamId);
toast.success('Team deleted successfully');
navigate('/dashboard');
navigate({ to: '/dashboard' });
} catch (error: any) {
console.error('Failed to delete team:', error);
toast.error(error?.message || 'Failed to delete team');
@@ -242,7 +244,7 @@ const TeamDashboardPage: FC = (): ReactElement => {
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
Team not found
</h2>
<Button onClick={() => navigate('/dashboard')}>
<Button onClick={() => navigate({ to: '/dashboard' })}>
Back to Dashboard
</Button>
</div>
@@ -525,7 +527,7 @@ const TeamDashboardPage: FC = (): ReactElement => {
</h3>
{team.leader && (
<button
onClick={() => navigate(`/users/${team.leader.id}`)}
onClick={() => navigate({ to: `/users/${team.leader.id}` })}
className="w-full flex items-center space-x-3 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg p-2 transition-colors text-left cursor-pointer"
>
{team.leader.avatar ? (
@@ -561,7 +563,7 @@ const TeamDashboardPage: FC = (): ReactElement => {
{members.map((member: any) => (
<button
key={member.id}
onClick={() => navigate(`/users/${member.user.id}`)}
onClick={() => navigate({ to: `/users/${member.user.id}` })}
className="w-full flex items-center space-x-3 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg p-2 transition-colors text-left cursor-pointer"
>
{member.user?.avatar ? (
@@ -876,4 +878,3 @@ const TeamDashboardPage: FC = (): ReactElement => {
);
};
export default TeamDashboardPage;
@@ -1,18 +1,20 @@
import { FC, ReactElement, useState, useRef, useEffect, useMemo } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import {
useTeamById,
useTeamMessages,
useSendMessage,
useDeleteMessage,
useAuthStore,
} from '@imphnen-frontend-service/service';
useAuthStore } from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
export const Route = createFileRoute('/_authenticated/teams/$teamId/chat')({
component: TeamChatPage })
const TeamChatPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const { teamId } = Route.useParams();
const navigate = useNavigate();
const { session } = useAuthStore();
const [message, setMessage] = useState('');
@@ -117,7 +119,7 @@ const TeamChatPage: FC = (): ReactElement => {
</div>
<Button
variant="secondary"
onClick={() => navigate(`/teams/${teamId}`)}
onClick={() => navigate({ to: `/teams/${teamId}` })}
>
Back to Team
</Button>
@@ -304,4 +306,3 @@ const TeamChatPage: FC = (): ReactElement => {
);
};
export default TeamChatPage;
@@ -1,7 +1,7 @@
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 { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useForm, Controller } from 'react-hook-form';
import {
teamUpdateSchema,
@@ -10,18 +10,20 @@ import {
useTeamById,
ETeamVisibility,
useUploadFile,
useAuthStore,
} from '@imphnen-frontend-service/service';
useAuthStore } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { CitySelect } from '../../../../components/city-select';
import { Icon } from '@iconify/react';
import { toast } from 'sonner';
export const Route = createFileRoute('/_authenticated/teams/$teamId/edit')({
component: EditTeamPage })
const MAX_FILE_SIZE = 2 * 1024 * 1024;
const EditTeamPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const { teamId } = Route.useParams();
const navigate = useNavigate();
const { session } = useAuthStore();
const [logoFile, setLogoFile] = useState<File | null>(null);
@@ -43,8 +45,7 @@ const EditTeamPage: FC = (): ReactElement => {
const form = useForm<TTeamUpdateForm>({
resolver: zodResolver(teamUpdateSchema),
mode: 'all',
});
mode: 'all' });
useEffect(() => {
if (team) {
@@ -54,8 +55,7 @@ const EditTeamPage: FC = (): ReactElement => {
city: team.city,
visibility: team.visibility,
logo: team.logo,
banner: team.banner,
});
banner: team.banner });
if (team.logo) setLogoPreview(team.logo);
if (team.banner) setBannerPreview(team.banner);
}
@@ -78,7 +78,7 @@ const EditTeamPage: FC = (): ReactElement => {
<p className="text-gray-600 dark:text-gray-400 mb-4">
Only the team leader can edit team information
</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>
<Button onClick={() => navigate({ to: `/teams/${teamId}` })}>
Back to Team
</Button>
</div>
@@ -137,10 +137,9 @@ const EditTeamPage: FC = (): ReactElement => {
await updateTeam({
...data,
logo: logoUrl,
banner: bannerUrl,
});
banner: bannerUrl });
navigate(`/teams/${teamId}`);
navigate({ to: `/teams/${teamId}` });
} catch (error) {
console.error('Failed to update team:', error);
}
@@ -366,7 +365,7 @@ const EditTeamPage: FC = (): ReactElement => {
type="button"
variant="secondary"
className="flex-1"
onClick={() => navigate(`/teams/${teamId}`)}
onClick={() => navigate({ to: `/teams/${teamId}` })}
>
Cancel
</Button>
@@ -385,4 +384,3 @@ const EditTeamPage: FC = (): ReactElement => {
);
};
export default EditTeamPage;
@@ -1,6 +1,6 @@
import { FC, ReactElement, useState } from 'react';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import {
useTeamById,
useTeamMembers,
@@ -11,14 +11,16 @@ import {
ETeamMemberStatus,
inviteMemberSchema,
TInviteMemberForm,
useAuthStore,
} from '@imphnen-frontend-service/service';
useAuthStore } from '@imphnen-frontend-service/service';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
export const Route = createFileRoute('/_authenticated/teams/$teamId/members')({
component: ManageMembersPage })
const ManageMembersPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const { teamId } = Route.useParams();
const navigate = useNavigate();
const { session } = useAuthStore();
const [showInviteModal, setShowInviteModal] = useState(false);
@@ -51,8 +53,7 @@ const ManageMembersPage: FC = (): ReactElement => {
const form = useForm<TInviteMemberForm>({
resolver: zodResolver(inviteMemberSchema),
mode: 'all',
});
mode: 'all' });
if (isLoadingTeam) {
return (
@@ -71,7 +72,7 @@ const ManageMembersPage: FC = (): ReactElement => {
<p className="text-gray-600 dark:text-gray-400 mb-4">
Only the team leader can manage members
</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>
<Button onClick={() => navigate({ to: `/teams/${teamId}` })}>
Back to Team
</Button>
</div>
@@ -142,7 +143,7 @@ const ManageMembersPage: FC = (): ReactElement => {
</Button>
<Button
variant="secondary"
onClick={() => navigate(`/teams/${teamId}`)}
onClick={() => navigate({ to: `/teams/${teamId}` })}
>
Back to Team
</Button>
@@ -378,4 +379,3 @@ const ManageMembersPage: FC = (): ReactElement => {
);
};
export default ManageMembersPage;
@@ -1,11 +1,14 @@
import { FC, ReactElement } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, useParams } from 'react-router';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useTeamById, useTeamSubmission, useAuthStore } from '@imphnen-frontend-service/service';
import { encodeCertificateId } from '../../../../utils/certificate';
export const Route = createFileRoute('/_authenticated/teams/$teamId/submission')({
component: SubmissionViewPage })
const SubmissionViewPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const { teamId } = Route.useParams();
const navigate = useNavigate();
const { session } = useAuthStore();
@@ -29,7 +32,7 @@ const SubmissionViewPage: FC = (): ReactElement => {
<div className="text-6xl mb-4">📄</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">No Submission Yet</h2>
<p className="text-gray-600 dark:text-gray-400 mb-4">Your team hasn't submitted a project</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>Back to Team</Button>
<Button onClick={() => navigate({ to: `/teams/${teamId}` })}>Back to Team</Button>
</div>
);
}
@@ -40,8 +43,7 @@ const SubmissionViewPage: FC = (): ReactElement => {
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
minute: '2-digit' })
: 'Not submitted';
return (
@@ -53,7 +55,7 @@ const SubmissionViewPage: FC = (): ReactElement => {
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Project Submission</h1>
<p className="text-gray-600 dark:text-gray-400 mt-1">{team?.name}</p>
</div>
<Button variant="secondary" onClick={() => navigate(`/teams/${teamId}`)}>
<Button variant="secondary" onClick={() => navigate({ to: `/teams/${teamId}` })}>
Back to Team
</Button>
</div>
@@ -124,7 +126,7 @@ const SubmissionViewPage: FC = (): ReactElement => {
submission.id,
session?.user?.id || ''
);
navigate(`/certificate/${encodeURIComponent(certId)}`);
navigate({ to: `/certificate/${encodeURIComponent(certId)}` });
}}
className="shrink-0 px-6 py-2 bg-amber-600 hover:bg-amber-700 dark:bg-amber-600 dark:hover:bg-amber-700 text-white font-medium rounded-lg transition-colors"
>
@@ -252,4 +254,3 @@ const SubmissionViewPage: FC = (): ReactElement => {
);
};
export default SubmissionViewPage;
@@ -1,7 +1,7 @@
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 { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useForm, Controller } from 'react-hook-form';
import {
projectSubmissionSchema,
@@ -10,19 +10,21 @@ import {
useTeamById,
useTeamSubmission,
useUploadSubmission,
useAuthStore,
} from '@imphnen-frontend-service/service';
useAuthStore } from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
export const Route = createFileRoute('/_authenticated/teams/$teamId/submit')({
component: SubmitProjectPage })
const MIN_TEAM_MEMBERS = 2;
const MAX_FILE_SIZE = 2 * 1024 * 1024;
const SUBMISSION_DEADLINE = new Date('2025-12-07T16:59:00Z');
const SubmitProjectPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
const { teamId } = Route.useParams();
const navigate = useNavigate();
const { session } = useAuthStore();
const [showConfirmModal, setShowConfirmModal] = useState(false);
@@ -78,8 +80,7 @@ const SubmitProjectPage: FC = (): ReactElement => {
const form = useForm<TProjectSubmissionForm>({
resolver: zodResolver(projectSubmissionSchema),
mode: 'all',
});
mode: 'all' });
if (!isLeader) {
return (
@@ -90,7 +91,7 @@ const SubmitProjectPage: FC = (): ReactElement => {
<p className="text-gray-600 dark:text-gray-400 mb-4">
Only the team leader can submit projects
</p>
<Button onClick={() => navigate(`/teams/${teamId}`)}>
<Button onClick={() => navigate({ to: `/teams/${teamId}` })}>
Back to Team
</Button>
</div>
@@ -108,12 +109,12 @@ const SubmitProjectPage: FC = (): ReactElement => {
Your team has already submitted a project
</p>
<div className="flex space-x-3">
<Button onClick={() => navigate(`/teams/${teamId}/submission`)}>
<Button onClick={() => navigate({ to: `/teams/${teamId}/submission` })}>
View Submission
</Button>
<Button
variant="secondary"
onClick={() => navigate(`/teams/${teamId}`)}
onClick={() => navigate({ to: `/teams/${teamId}` })}
>
Back to Team
</Button>
@@ -147,14 +148,14 @@ const SubmitProjectPage: FC = (): ReactElement => {
</p>
<button
onClick={() => navigate(`/teams/${teamId}`)}
onClick={() => navigate({ to: `/teams/${teamId}` })}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors cursor-pointer"
>
Back to Team
</button>
<button
onClick={() => navigate('/dashboard')}
onClick={() => navigate({ to: '/dashboard' })}
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
>
Back to Dashboard
@@ -197,9 +198,8 @@ const SubmitProjectPage: FC = (): ReactElement => {
try {
await submitProject({
...data,
screenshots,
});
navigate(`/teams/${teamId}/submission`);
screenshots });
navigate({ to: `/teams/${teamId}/submission` });
} catch (error) {
console.error('Failed to submit project:', error);
}
@@ -416,7 +416,7 @@ const SubmitProjectPage: FC = (): ReactElement => {
type="button"
variant="secondary"
className="flex-1"
onClick={() => navigate(`/teams/${teamId}`)}
onClick={() => navigate({ to: `/teams/${teamId}` })}
>
Cancel
</Button>
@@ -500,4 +500,3 @@ const SubmitProjectPage: FC = (): ReactElement => {
);
};
export default SubmitProjectPage;
@@ -1,19 +1,22 @@
import { FC, ReactElement, useState, useEffect, useCallback } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Link, useNavigate, useSearchParams } from 'react-router';
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
import {
useTeams,
useJoinTeam,
useMyTeams,
ETeamVisibility,
joinTeamSchema,
TJoinTeamForm,
} from '@imphnen-frontend-service/service';
TJoinTeamForm } from '@imphnen-frontend-service/service';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { CitySelect } from '../../../components/city-select';
import { Icon } from '@iconify/react';
export const Route = createFileRoute('/_authenticated/teams/browse')({
component: BrowseTeamsPage,
})
const DEFAULT_PER_PAGE = 12;
const PER_PAGE_OPTIONS = [6, 12, 24, 48];
@@ -61,7 +64,16 @@ const TeamCardSkeleton: FC = () => (
const BrowseTeamsPage: FC = (): ReactElement => {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const searchParams = new URLSearchParams(globalThis.location.search);
const setSearchParams = (newParams: URLSearchParams, opts?: { replace?: boolean }) => {
const url = new URL(globalThis.location.href);
url.search = newParams.toString();
if (opts?.replace) {
globalThis.history.replaceState(null, '', url.toString());
} else {
globalThis.history.pushState(null, '', url.toString());
}
};
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
@@ -435,7 +447,7 @@ const BrowseTeamsPage: FC = (): ReactElement => {
<Button
className="w-full"
variant="secondary"
onClick={() => navigate(`/teams/${team.id}`)}
onClick={() => navigate({ to: `/teams/${team.id}` })}
>
Your Team
</Button>
@@ -455,7 +467,7 @@ const BrowseTeamsPage: FC = (): ReactElement => {
<Button
className="w-full"
variant="secondary"
onClick={() => navigate(`/teams/${team.id}`)}
onClick={() => navigate({ to: `/teams/${team.id}` })}
>
View Team
</Button>
@@ -616,4 +628,3 @@ const BrowseTeamsPage: FC = (): ReactElement => {
);
};
export default BrowseTeamsPage;
@@ -1,7 +1,7 @@
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 { createFileRoute, useNavigate } from '@tanstack/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';
@@ -10,6 +10,10 @@ import { Icon } from '@iconify/react';
import { CitySelect } from '../../../components/city-select';
export const Route = createFileRoute('/_authenticated/teams/create')({
component: CreateTeamPage,
})
const MAX_FILE_SIZE = 2 * 1024 * 1024;
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
@@ -93,7 +97,7 @@ const CreateTeamPage: FC = (): ReactElement => {
});
toast.success('Team created successfully!');
navigate(`/teams/${result.data.id}`);
navigate({ to: `/teams/${result.data.id}` });
} catch (error: any) {
console.error('Failed to create team:', error);
@@ -133,14 +137,14 @@ const CreateTeamPage: FC = (): ReactElement => {
</p>
<button
onClick={() => navigate('/dashboard')}
onClick={() => navigate({ to: '/dashboard' })}
className="w-full py-3 bg-primary-600 text-white rounded-lg font-semibold hover:bg-primary-700 transition-colors cursor-pointer"
>
Back to Dashboard
</button>
<button
onClick={() => navigate('/teams/browse')}
onClick={() => navigate({ to: '/teams/browse' })}
className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer"
>
Browse Teams
@@ -352,7 +356,7 @@ const CreateTeamPage: FC = (): ReactElement => {
type="button"
variant="secondary"
className="flex-1"
onClick={() => navigate('/dashboard')}
onClick={() => navigate({ to: '/dashboard' })}
>
Cancel
</Button>
@@ -371,4 +375,3 @@ const CreateTeamPage: FC = (): ReactElement => {
);
};
export default CreateTeamPage;
@@ -1,14 +1,16 @@
import { FC, ReactElement } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useParams, useNavigate, Link } from 'react-router';
import { createFileRoute, useNavigate, Link } from '@tanstack/react-router';
import {
useUserDetailsById,
useTeamsByUserId,
} from '@imphnen-frontend-service/service';
useTeamsByUserId } from '@imphnen-frontend-service/service';
import { Icon } from '@iconify/react';
export const Route = createFileRoute('/_authenticated/users/$userId')({
component: UserProfilePage })
const UserProfilePage: FC = (): ReactElement => {
const { userId } = useParams<{ userId: string }>();
const { userId } = Route.useParams();
const navigate = useNavigate();
const { data: userData, isLoading, error } = useUserDetailsById(userId || '');
const { data: teamsData } = useTeamsByUserId(userId || '');
@@ -35,7 +37,7 @@ const UserProfilePage: FC = (): ReactElement => {
{error && (
<p className="text-red-600 dark:text-red-400 mb-4">{String(error)}</p>
)}
<Button onClick={() => navigate('/dashboard')}>
<Button onClick={() => navigate({ to: '/dashboard' })}>
Back to Dashboard
</Button>
</div>
@@ -234,4 +236,3 @@ const UserProfilePage: FC = (): ReactElement => {
);
};
export default UserProfilePage;
+9
View File
@@ -0,0 +1,9 @@
import { createFileRoute, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/_public')({
component: PublicLayout,
})
function PublicLayout() {
return <Outlet />
}
@@ -0,0 +1,123 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { FC, ReactElement, useEffect, useState, useRef } from 'react'
import { useGitHubCallback } from '@imphnen-frontend-service/service'
import { toast } from 'sonner'
export const Route = createFileRoute('/_public/auth/callback')({
component: CallbackPage,
})
function CallbackPage(): ReactElement {
const navigate = useNavigate()
const { mutateAsync: exchangeGitHubCode } = useGitHubCallback()
const [isProcessing, setIsProcessing] = useState(true)
const [error, setError] = useState<string | null>(null)
const hasRunRef = useRef(false)
useEffect(() => {
const handleCallback = async () => {
if (hasRunRef.current) return
hasRunRef.current = true
try {
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1))
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')
if (accessToken) {
setIsProcessing(false)
if (type === 'recovery' || type === 'magiclink') {
toast.success('Email verified! Please set your new password.')
navigate({ to: '/auth/reset-password', search: { access_token: accessToken } })
return
}
if (type === 'signup' || type === 'email_confirmation') {
toast.success('Email verified successfully! Please log in to continue.')
navigate({ to: '/auth/login' })
return
}
toast.success('Email verified! Please set your new password.')
navigate({ to: '/auth/reset-password', search: { access_token: accessToken } })
return
}
const code = urlParams.get('code')
if (!code) {
throw new Error('No authorization code received')
}
const result = await exchangeGitHubCode({ code })
toast.success('Login successful!')
setIsProcessing(false)
if (result.user.location) {
globalThis.location.replace('/dashboard')
} else {
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({ to: '/auth/login' })
}, 3000)
}
}
handleCallback()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
if (error) {
const isPrivateEmailError =
error.toLowerCase().includes('failed to create user') ||
error.toLowerCase().includes('email') ||
error.toLowerCase().includes('user record')
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 px-4">
<div className="bg-white dark:bg-gray-900 w-full max-w-2xl p-8 rounded-2xl shadow-lg border border-red-200 dark:border-red-800">
<div className="text-center mb-6">
<div className="text-red-500 text-5xl mb-4">&#x26A0;&#xFE0F;</div>
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">GitHub Login Failed</h2>
<p className="text-red-600 dark:text-red-400 mb-4 whitespace-pre-line">{error}</p>
</div>
{isPrivateEmailError && (
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-6">
<h3 className="font-semibold text-amber-800 dark:text-amber-300 mb-2">Is your GitHub email set to private?</h3>
<p className="text-amber-700 dark:text-amber-400 text-sm mb-3">GitHub login requires a public email address. Please follow these steps:</p>
<ol className="text-amber-700 dark:text-amber-400 text-sm list-decimal list-inside space-y-1 mb-3">
<li>Go to <a href="https://github.com/settings/emails" target="_blank" rel="noopener noreferrer" className="underline hover:text-amber-900 dark:hover:text-amber-200">GitHub Email Settings</a></li>
<li>Uncheck "Keep my email addresses private"</li>
<li>Or go to <a href="https://github.com/settings/profile" target="_blank" rel="noopener noreferrer" className="underline hover:text-amber-900 dark:hover:text-amber-200">Profile Settings</a> and set a public email</li>
<li>Try signing in with GitHub again</li>
</ol>
<p className="text-amber-600 dark:text-amber-500 text-xs">Alternatively, you can sign up using email and password instead.</p>
</div>
)}
<p className="text-gray-600 dark:text-gray-400 text-sm mt-6 text-center">Redirecting to login page in 3 seconds...</p>
</div>
</div>
)
}
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">Completing login...</h2>
<p className="text-gray-600 dark:text-gray-400">Please wait</p>
</div>
</div>
)
}
@@ -0,0 +1,74 @@
import { createFileRoute, useNavigate, Link } from '@tanstack/react-router'
import { useState } from 'react'
import { useForgotPassword } from '@imphnen-frontend-service/service'
import { toast } from 'sonner'
import { Icon } from '@iconify/react'
import ThemeToggle from '../../../components/theme-toggle'
export const Route = createFileRoute('/_public/auth/forgot-password')({
component: ForgotPasswordPage,
})
function ForgotPasswordPage() {
const [email, setEmail] = useState('')
const [emailSent, setEmailSent] = useState(false)
const navigate = useNavigate()
const forgotPasswordMutation = useForgotPassword()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!email) { toast.error('Please enter your email'); return }
try {
await forgotPasswordMutation.mutateAsync({ email })
setEmailSent(true)
toast.success('Password reset email sent! Check your inbox.')
} catch (err) {
toast.error((err as Error).message || 'Failed to send reset email')
}
}
if (emailSent) {
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"><span className="text-3xl">&#x2713;</span></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 a password reset link to <strong>{email}</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 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 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors">Send another email</button>
</div>
</div>
</div>
)
}
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-6">
<button onClick={() => navigate({ to: '/auth/login' })} className="cursor-pointer text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300 text-base font-sans flex items-center">
<Icon icon="ic:baseline-chevron-left" width="24" height="24" />Back to Login
</button>
<ThemeToggle />
</div>
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">Forgot Password?</h2>
<p className="text-gray-600 dark:text-gray-400">No worries, we'll send you reset instructions</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email Address</label>
<input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="your@email.com" disabled={forgotPasswordMutation.isPending} className="bg-white dark:bg-gray-800 text-gray-900 dark:text-white w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 dark:disabled:bg-gray-700 disabled:cursor-not-allowed" required />
</div>
<button type="submit" disabled={forgotPasswordMutation.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 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors">
{forgotPasswordMutation.isPending ? 'Sending...' : 'Send Reset Link'}
</button>
</form>
</div>
</div>
)
}
@@ -1,71 +1,83 @@
import { useState, useEffect } from 'react';
import { createFileRoute, redirect } from '@tanstack/react-router'
import { useState, useEffect } from 'react'
import {
useGitHubAuth,
useLogin,
authLoginSchema,
TLoginRequest,
} from '@imphnen-frontend-service/service';
import { GithubOutlined } from '@ant-design/icons';
import { useNavigate, Link } from 'react-router';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { Icon } from '@iconify/react';
import { ThemeToggle } from '../../../components/theme-toggle';
SessionToken,
} from '@imphnen-frontend-service/service'
import { GithubOutlined } from '@ant-design/icons'
import { useNavigate, Link } from '@tanstack/react-router'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from 'sonner'
import { Icon } from '@iconify/react'
import { ThemeToggle } from '../../../components/theme-toggle'
export default function LoginPage() {
const navigate = useNavigate();
const { signInWithGitHub } = useGitHubAuth();
const loginMutation = useLogin();
const [isGithubLoading, setIsGithubLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
export const Route = createFileRoute('/_public/auth/login')({
beforeLoad: () => {
const session = SessionToken.get()
if (session?.token?.access_token) {
throw redirect({ to: '/dashboard' })
}
},
component: LoginPage,
})
function LoginPage() {
const navigate = useNavigate()
const { signInWithGitHub } = useGitHubAuth()
const loginMutation = useLogin()
const [isGithubLoading, setIsGithubLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showPassword, setShowPassword] = useState(false)
const { register, handleSubmit, formState: { errors, isValid } } = useForm<TLoginRequest>({
resolver: zodResolver(authLoginSchema),
mode: 'onChange',
defaultValues: { email: '', password: '' },
});
})
useEffect(() => {
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1));
const urlParams = new URLSearchParams(globalThis.location.search);
const accessToken = hashParams.get('access_token') || urlParams.get('access_token');
const type = hashParams.get('type') || urlParams.get('type');
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1))
const urlParams = new URLSearchParams(globalThis.location.search)
const accessToken = hashParams.get('access_token') || urlParams.get('access_token')
const type = hashParams.get('type') || urlParams.get('type')
if (accessToken && (type === 'recovery' || type === 'magiclink' || !type)) {
toast.info('Redirecting to password reset...');
navigate('/auth/reset-password?access_token=' + accessToken);
toast.info('Redirecting to password reset...')
navigate({ to: '/auth/reset-password', search: { access_token: accessToken } })
}
}, [navigate]);
}, [navigate])
const onSubmit = handleSubmit(async (data) => {
setError(null);
setError(null)
try {
const result = await loginMutation.mutateAsync(data);
toast.success('Login successful!');
navigate(result.user.location ? '/dashboard' : '/onboarding/user');
const result = await loginMutation.mutateAsync(data)
toast.success('Login successful!')
navigate({ to: result.user.location ? '/dashboard' : '/onboarding/user' })
} catch (err) {
setError((err as Error).message || 'Login failed');
setError((err as Error).message || 'Login failed')
}
});
})
const handleGithubLogin = async () => {
try {
setIsGithubLoading(true);
const result = await signInWithGitHub();
if (result?.url) globalThis.location.href = result.url;
else { setIsGithubLoading(false); setError('Failed to get GitHub OAuth URL'); }
setIsGithubLoading(true)
const result = await signInWithGitHub()
if (result?.url) globalThis.location.href = result.url
else { setIsGithubLoading(false); setError('Failed to get GitHub OAuth URL') }
} catch (err) {
setError((err as Error).message || 'GitHub login failed');
setIsGithubLoading(false);
setError((err as Error).message || 'GitHub login failed')
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="flex items-center justify-between mb-6">
<button onClick={() => navigate('/')} className="cursor-pointer text-primary-500 hover:text-primary-600 text-base font-sans flex items-center">
<button onClick={() => navigate({ to: '/' })} className="cursor-pointer text-primary-500 hover:text-primary-600 text-base font-sans flex items-center">
<Icon icon="ic:baseline-chevron-left" width="24" height="24" />
Back to Homepage
</button>
@@ -136,5 +148,5 @@ export default function LoginPage() {
</div>
</div>
</div>
);
)
}
@@ -0,0 +1,87 @@
import { createFileRoute, useNavigate } from '@tanstack/react-router'
import { useState, useEffect } from 'react'
import { useResetPassword, useAuthStore } from '@imphnen-frontend-service/service'
import { toast } from 'sonner'
import { Icon } from '@iconify/react'
export const Route = createFileRoute('/_public/auth/reset-password')({
component: ResetPasswordPage,
})
function ResetPasswordPage() {
const navigate = useNavigate()
const { clearSession } = useAuthStore()
const resetPasswordMutation = useResetPassword()
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [accessToken, setAccessToken] = useState<string | null>(null)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
useEffect(() => {
const hashParams = new URLSearchParams(globalThis.location.hash.substring(1))
const queryParams = new URLSearchParams(globalThis.location.search)
const token = hashParams.get('access_token') || queryParams.get('access_token')
if (token) { setAccessToken(token) } else { toast.error('Invalid or expired reset link'); setTimeout(() => navigate({ to: '/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 }
if (!accessToken) { toast.error('Invalid reset token'); return }
try {
await resetPasswordMutation.mutateAsync({ access_token: accessToken, new_password: password })
toast.success('Password updated successfully!')
clearSession()
navigate({ to: '/auth/login' })
} catch (err) {
toast.error((err as Error).message || 'Failed to reset password')
}
}
if (!accessToken) {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600 mb-4"></div>
<p className="text-gray-600 dark:text-gray-400">Verifying reset link...</p>
</div>
</div>
)
}
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700">
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">Set New Password</h2>
<p className="text-gray-600 dark:text-gray-400">Enter your new password below</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">New Password</label>
<div className="relative">
<input id="password" type={showPassword ? 'text' : 'password'} value={password} onChange={(e) => setPassword(e.target.value)} placeholder="••••••••" disabled={resetPasswordMutation.isPending} className="w-full px-4 py-2.5 pr-12 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed bg-white dark:bg-gray-800 text-gray-900 dark:text-white" required minLength={6} />
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300">
<Icon icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
</button>
</div>
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm New Password</label>
<div className="relative">
<input id="confirmPassword" type={showConfirmPassword ? 'text' : 'password'} value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} placeholder="••••••••" disabled={resetPasswordMutation.isPending} className="w-full px-4 py-2.5 pr-12 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed bg-white dark:bg-gray-800 text-gray-900 dark:text-white" required minLength={6} />
<button type="button" onClick={() => setShowConfirmPassword(!showConfirmPassword)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300">
<Icon icon={showConfirmPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
</button>
</div>
</div>
<button type="submit" disabled={resetPasswordMutation.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 disabled:bg-gray-400 disabled:cursor-not-allowed transition-colors">
{resetPasswordMutation.isPending ? 'Updating...' : 'Update Password'}
</button>
</form>
</div>
</div>
)
}
@@ -0,0 +1,227 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { useState } from 'react'
import { useGitHubAuth, useSignup, SessionToken } from '@imphnen-frontend-service/service'
import { GithubOutlined } from '@ant-design/icons'
import { useNavigate, Link } from '@tanstack/react-router'
import { toast } from 'sonner'
import { Icon } from '@iconify/react'
import { ThemeToggle } from '../../../components/theme-toggle'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
export const Route = createFileRoute('/_public/auth/signup')({
beforeLoad: () => {
const session = SessionToken.get()
if (session?.token?.access_token) {
throw redirect({ to: '/dashboard' })
}
},
component: SignupPage,
})
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>
const REGISTRATION_DEADLINE = new Date('2025-11-30T16:29:00Z')
function SignupPage() {
const navigate = useNavigate()
const isRegistrationClosed = new Date() >= REGISTRATION_DEADLINE
const { signInWithGitHub } = useGitHubAuth()
const signupMutation = useSignup()
const [isGithubLoading, setIsGithubLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [registrationSuccess, setRegistrationSuccess] = useState(false)
const [registeredEmail, setRegisteredEmail] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const {
register,
handleSubmit,
formState: { errors, isValid },
} = useForm<SignupFormData>({
resolver: zodResolver(signupSchema),
mode: 'onChange',
})
const onSubmit = async (data: SignupFormData) => {
setError(null)
try {
const result = await signupMutation.mutateAsync({
email: data.email,
password: data.password,
fullname: data.fullname,
})
toast.success(result.message)
setRegisteredEmail(data.email)
setRegistrationSuccess(true)
} catch (err) {
console.error('[Signup] Email signup failed:', err)
setError((err as Error).message || 'Signup failed')
}
}
if (isRegistrationClosed) {
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-red-100 dark:bg-red-900/30 rounded-full flex items-center justify-center mb-4">
<Icon icon="mdi:clock-alert" className="text-3xl text-red-600 dark:text-red-400" />
</div>
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">Registration Closed</h2>
<p className="text-gray-600 dark:text-gray-400">The registration period for this hackathon has ended.</p>
</div>
<div className="space-y-4">
<p className="text-sm text-gray-600 dark:text-gray-400">Thank you for your interest! Registration closed on November 30, 2025 at 23:29 WIB.</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 cursor-pointer">Go to Login</button>
</Link>
<button onClick={() => navigate({ to: '/' })} className="w-full py-3 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white transition-colors cursor-pointer">Back to Homepage</button>
</div>
</div>
</div>
)
}
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 () => {
try {
setIsGithubLoading(true)
const result = await signInWithGitHub()
if (result?.url) { globalThis.location.href = result.url } else { setIsGithubLoading(false); setError('Failed to get GitHub OAuth URL') }
} catch (err) {
console.error('[Signup] GitHub login failed:', err)
setError((err as Error).message || 'GitHub login failed')
setIsGithubLoading(false)
}
}
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 (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 p-4">
<div className="bg-white dark:bg-gray-900 w-full max-w-md p-8 rounded-2xl shadow-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between mb-6">
<button onClick={() => navigate({ to: '/' })} className="cursor-pointer text-primary-500 hover:text-primary-600 dark:text-primary-400 dark:hover:text-primary-300 text-base font-sans flex items-center">
<Icon icon="ic:baseline-chevron-left" width="24" height="24" />
Back to Homepage
</button>
<ThemeToggle />
</div>
<div className="text-center mb-8">
<h2 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">Create Account</h2>
<p className="text-gray-600 dark:text-gray-400">Join the hackathon community</p>
</div>
{error && (
<div className="mb-6 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p className="text-red-600 dark:text-red-400 text-sm">{error}</p>
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<label htmlFor="fullname" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Full Name</label>
<input id="fullname" type="text" {...register('fullname')} placeholder="John Doe" disabled={signupMutation.isPending} className={`${inputBaseClass} ${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>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Email</label>
<input id="email" type="email" {...register('email')} placeholder="your@email.com" disabled={signupMutation.isPending} className={`${inputBaseClass} ${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>
<label htmlFor="password" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Password</label>
<div className="relative">
<input id="password" type={showPassword ? 'text' : 'password'} {...register('password')} placeholder="••••••••" disabled={signupMutation.isPending} className={`${inputBaseClass} pr-12 ${errors.password ? inputErrorClass : inputNormalClass}`} />
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300">
<Icon icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
</button>
</div>
{errors.password && <p className="mt-1 text-sm text-red-500 dark:text-red-400">{errors.password.message}</p>}
</div>
<div>
<label htmlFor="confirmPassword" className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Confirm Password</label>
<div className="relative">
<input id="confirmPassword" type={showConfirmPassword ? 'text' : 'password'} {...register('confirmPassword')} placeholder="••••••••" disabled={signupMutation.isPending} className={`${inputBaseClass} pr-12 ${errors.confirmPassword ? inputErrorClass : inputNormalClass}`} />
<button type="button" onClick={() => setShowConfirmPassword(!showConfirmPassword)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300">
<Icon icon={showConfirmPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
</button>
</div>
{errors.confirmPassword && <p className="mt-1 text-sm text-red-500 dark:text-red-400">{errors.confirmPassword.message}</p>}
</div>
<button type="submit" 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">
{signupMutation.isPending ? 'Creating account...' : 'Create Account'}
</button>
</form>
<div className="my-6 flex items-center">
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div>
<span className="px-4 text-sm text-gray-500 dark:text-gray-400">OR</span>
<div className="flex-1 border-t border-gray-300 dark:border-gray-600"></div>
</div>
<button onClick={handleGithubLogin} disabled={isGithubLoading} type="button" className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg font-semibold text-gray-900 dark:text-white hover:bg-gray-200 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 dark:focus:ring-offset-gray-900 disabled:bg-gray-100 dark:disabled:bg-gray-800 disabled:cursor-not-allowed transition-colors cursor-pointer">
<GithubOutlined className="text-xl" />
<span>{isGithubLoading ? 'Connecting...' : 'Sign up with GitHub'}</span>
</button>
<p className="mt-3 text-xs text-center text-gray-500 dark:text-gray-500 font-sans">
Make sure your GitHub email is <a href="https://github.com/settings/emails" target="_blank" rel="noopener noreferrer" className="text-primary-600 dark:text-primary-400 hover:underline">set to public</a> for GitHub sign up to work.
</p>
<div className="mt-6 text-center">
<p className="text-gray-600 dark:text-gray-400 text-sm">Already have an account? <Link to="/auth/login" className="text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 font-semibold">Sign in</Link></p>
</div>
<div className="mt-6 text-center">
<p className="text-gray-500 dark:text-gray-500 text-xs">By signing up, you agree to our Terms of Service and Privacy Policy</p>
</div>
</div>
</div>
)
}
@@ -1,14 +1,16 @@
import { FC, ReactElement, useState, useEffect, useRef } from 'react';
import { useParams, useNavigate } from 'react-router';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { decodeCertificateId } from '../../../utils/certificate';
import {
useCertificatePublicData,
useAuthStore,
} from '@imphnen-frontend-service/service';
useAuthStore } from '@imphnen-frontend-service/service';
import QRCode from 'qrcode';
import html2canvas from 'html2canvas';
export const Route = createFileRoute('/_public/certificate/$certId')({
component: CertificatePage })
interface DecodedCert {
teamId: string;
submissionId: string;
@@ -16,7 +18,7 @@ interface DecodedCert {
}
const CertificatePage: FC = (): ReactElement => {
const { certId } = useParams<{ certId: string }>();
const { certId } = Route.useParams();
const navigate = useNavigate();
const { session } = useAuthStore();
const [decodedInfo, setDecodedInfo] = useState<DecodedCert | null>(null);
@@ -53,9 +55,7 @@ const CertificatePage: FC = (): ReactElement => {
margin: 1,
color: {
dark: '#000000',
light: '#ffffff',
},
})
light: '#ffffff' } })
.then(setQrCodeUrl)
.catch((err) => console.error('QR Code generation failed:', err));
}
@@ -117,8 +117,7 @@ const CertificatePage: FC = (): ReactElement => {
height: (1000 * 2480) / 3508,
allowTaint: true,
imageTimeout: 0,
removeContainer: true,
});
removeContainer: true });
const imageUrl = canvas.toDataURL('image/png', 1.0);
setCertificateImage(imageUrl);
@@ -183,7 +182,7 @@ const CertificatePage: FC = (): ReactElement => {
<p className="text-gray-600 dark:text-gray-400 mb-6">
{error || 'The certificate ID is invalid or malformed.'}
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
<Button onClick={() => navigate({ to: '/' })}>Back to Home</Button>
</div>
);
}
@@ -211,7 +210,7 @@ const CertificatePage: FC = (): ReactElement => {
<p className="text-gray-600 dark:text-gray-400 mb-6">
The user associated with this certificate could not be found.
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
<Button onClick={() => navigate({ to: '/' })}>Back to Home</Button>
</div>
);
}
@@ -277,7 +276,7 @@ const CertificatePage: FC = (): ReactElement => {
{team && isTeamMember && (
<Button
variant="secondary"
onClick={() => navigate(`/teams/${team.id}/submission`)}
onClick={() => navigate({ to: `/teams/${team.id}/submission` })}
>
Back to Submission
</Button>
@@ -303,16 +302,14 @@ const CertificatePage: FC = (): ReactElement => {
backgroundSize: 'cover',
backgroundPosition: 'center',
width: '1000px',
height: `${(1000 * 2480) / 3508}px`,
}}
height: `${(1000 * 2480) / 3508}px` }}
>
<div
style={{
position: 'absolute',
top: '41%',
left: '3.5%',
width: '55%',
}}
width: '55%' }}
>
<h3
ref={teamNameRef}
@@ -324,8 +321,7 @@ const CertificatePage: FC = (): ReactElement => {
fontSize: '32px',
lineHeight: '1.2',
wordBreak: 'break-word',
margin: 0,
}}
margin: 0 }}
>
{team?.name}
</h3>
@@ -336,8 +332,7 @@ const CertificatePage: FC = (): ReactElement => {
position: 'absolute',
top: '45%',
left: '3.5%',
width: '55%',
}}
width: '55%' }}
>
<h3
ref={userNameRef}
@@ -349,8 +344,7 @@ const CertificatePage: FC = (): ReactElement => {
fontSize: '40px',
lineHeight: '1.2',
wordBreak: 'break-word',
margin: 0,
}}
margin: 0 }}
>
{certificateName || 'N/A'}
</h3>
@@ -365,8 +359,7 @@ const CertificatePage: FC = (): ReactElement => {
height: '190px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
justifyContent: 'center' }}
>
{qrCodeUrl && (
<img
@@ -420,7 +413,7 @@ const CertificatePage: FC = (): ReactElement => {
</Button>
{team && (
<Button
onClick={() => navigate(`/teams/${team.id}/submission`)}
onClick={() => navigate({ to: `/teams/${team.id}/submission` })}
variant="secondary"
className="col-span-2 flex items-center gap-2 xl:col-span-1"
>
@@ -446,4 +439,3 @@ const CertificatePage: FC = (): ReactElement => {
);
};
export default CertificatePage;
@@ -1,5 +1,5 @@
import { FC, ReactElement, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams } from 'react-router';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Icon } from '@iconify/react';
import { decodeWinnerCertificateId } from '../../../../utils/certificate';
@@ -8,11 +8,13 @@ import {
useMyTeams,
useTeamById,
useTeamSubmission,
useWinners,
} from '@imphnen-frontend-service/service';
useWinners } from '@imphnen-frontend-service/service';
import QRCode from 'qrcode';
import html2canvas from 'html2canvas';
export const Route = createFileRoute('/_public/certificate/winner/$certId')({
component: CertificateWinnerPage })
type WinnerEntry = {
team_id: string;
rank: number;
@@ -48,15 +50,14 @@ const LAYOUT_SCALE = CERT_WIDTH / LAYOUT_BASE_WIDTH;
const s = (px: number) => Math.round(px * LAYOUT_SCALE);
const CertificateWinnerPage: FC = (): ReactElement => {
const { certId } = useParams<{ certId: string }>();
const { certId } = Route.useParams();
const navigate = useNavigate();
const { session } = useAuthStore();
const { data: myTeamsData } = useMyTeams();
const {
data: winnersResponse,
isLoading: isLoadingWinners,
isError: isWinnersError,
} = useWinners();
isError: isWinnersError } = useWinners();
const [decodedTeamId, setDecodedTeamId] = useState<string>('');
const [error, setError] = useState<string | null>(null);
@@ -127,9 +128,7 @@ const CertificateWinnerPage: FC = (): ReactElement => {
margin: 1,
color: {
dark: '#000000',
light: '#ffffff',
},
})
light: '#ffffff' } })
.then(setQrCodeUrl)
.catch((err) => console.error('QR Code generation failed:', err));
}, [certId]);
@@ -156,8 +155,7 @@ const CertificateWinnerPage: FC = (): ReactElement => {
height: CERT_HEIGHT,
allowTaint: true,
imageTimeout: 0,
removeContainer: true,
});
removeContainer: true });
const imageUrl = canvas.toDataURL('image/png', 1.0);
setCertificateImage(imageUrl);
@@ -228,7 +226,7 @@ const CertificateWinnerPage: FC = (): ReactElement => {
<p className="text-gray-600 dark:text-gray-400 mb-6">
{error || 'The certificate ID is invalid or malformed.'}
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
<Button onClick={() => navigate({ to: '/' })}>Back to Home</Button>
</div>
);
}
@@ -268,7 +266,7 @@ const CertificateWinnerPage: FC = (): ReactElement => {
<p className="text-gray-600 dark:text-gray-400 mb-6">
Please try again later.
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
<Button onClick={() => navigate({ to: '/' })}>Back to Home</Button>
</div>
);
}
@@ -282,7 +280,7 @@ const CertificateWinnerPage: FC = (): ReactElement => {
<p className="text-gray-600 dark:text-gray-400 mb-6">
This team is not listed as a hackathon winner.
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
<Button onClick={() => navigate({ to: '/' })}>Back to Home</Button>
</div>
);
}
@@ -296,7 +294,7 @@ const CertificateWinnerPage: FC = (): ReactElement => {
<p className="text-gray-600 dark:text-gray-400 mb-6">
The team associated with this certificate could not be loaded.
</p>
<Button onClick={() => navigate('/')}>Back to Home</Button>
<Button onClick={() => navigate({ to: '/' })}>Back to Home</Button>
</div>
);
}
@@ -317,7 +315,7 @@ const CertificateWinnerPage: FC = (): ReactElement => {
{isTeamMember && (
<Button
variant="secondary"
onClick={() => navigate('/dashboard')}
onClick={() => navigate({ to: '/dashboard' })}
>
Back to Dashboard
</Button>
@@ -344,8 +342,7 @@ const CertificateWinnerPage: FC = (): ReactElement => {
backgroundRepeat: 'no-repeat',
backgroundPosition: 'center',
width: `${CERT_WIDTH}px`,
height: `${CERT_HEIGHT}px`,
}}
height: `${CERT_HEIGHT}px` }}
>
<style>{`
#winner-members li::marker {
@@ -358,8 +355,7 @@ const CertificateWinnerPage: FC = (): ReactElement => {
position: 'absolute',
top: '35%',
left: '3.5%',
width: '55%',
}}
width: '55%' }}
>
<h3
style={{
@@ -370,8 +366,7 @@ const CertificateWinnerPage: FC = (): ReactElement => {
fontSize: `${s(28)}px`,
lineHeight: '1.2',
wordBreak: 'break-word',
margin: 0,
}}
margin: 0 }}
>
{team.name}
</h3>
@@ -382,8 +377,7 @@ const CertificateWinnerPage: FC = (): ReactElement => {
position: 'absolute',
top: '40.5%',
left: '3.5%',
width: '55%',
}}
width: '55%' }}
>
<ul
id="winner-members"
@@ -392,8 +386,7 @@ const CertificateWinnerPage: FC = (): ReactElement => {
fontFamily: 'Poppins, sans-serif',
fontSize: `${s(18)}px`,
lineHeight: '1.35',
color: '#59bef5',
}}
color: '#59bef5' }}
>
{(memberNames.length
? memberNames
@@ -409,8 +402,7 @@ const CertificateWinnerPage: FC = (): ReactElement => {
position: 'absolute',
top: '60%',
left: '3.5%',
width: '60%',
}}
width: '60%' }}
>
<p
style={{
@@ -418,8 +410,7 @@ const CertificateWinnerPage: FC = (): ReactElement => {
fontFamily: 'Poppins, sans-serif',
fontSize: `${s(18)}px`,
lineHeight: '1.35',
color: '#6B6B6B',
}}
color: '#6B6B6B' }}
>
Diberikan sebagai penghargaan atas pencapaian meraih
<br />
@@ -440,8 +431,7 @@ const CertificateWinnerPage: FC = (): ReactElement => {
right: '8.5%',
width: `${s(190)}px`,
display: 'flex',
justifyContent: 'center',
}}
justifyContent: 'center' }}
>
<div
style={{
@@ -454,8 +444,7 @@ const CertificateWinnerPage: FC = (): ReactElement => {
fontWeight: 700,
color: '#78350F',
fontSize: `${s(24)}px`,
lineHeight: '1',
}}
lineHeight: '1' }}
>
{rankLabel}
</div>
@@ -471,8 +460,7 @@ const CertificateWinnerPage: FC = (): ReactElement => {
height: `${s(190)}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
justifyContent: 'center' }}
>
{qrCodeUrl && (
<img
@@ -481,8 +469,7 @@ const CertificateWinnerPage: FC = (): ReactElement => {
style={{
width: `${s(190)}px`,
height: `${s(190)}px`,
display: 'block',
}}
display: 'block' }}
/>
)}
</div>
@@ -561,7 +548,7 @@ const CertificateWinnerPage: FC = (): ReactElement => {
)}
</Button>
<Button
onClick={() => navigate('/dashboard')}
onClick={() => navigate({ to: '/dashboard' })}
variant="secondary"
className="col-span-2 flex items-center gap-2 xl:col-span-1"
>
@@ -584,4 +571,3 @@ const CertificateWinnerPage: FC = (): ReactElement => {
);
};
export default CertificateWinnerPage;
@@ -1,23 +1,24 @@
import { Link } from 'react-router';
import { createFileRoute, Link } from '@tanstack/react-router'
export default function MaintenancePage() {
export const Route = createFileRoute('/_public/maintenance')({
component: MaintenancePage,
})
function MaintenancePage() {
return (
<div className="flex justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 px-4">
<div className="text-center">
<h1 className="text-6xl font-bold text-yellow-600 mb-4">🚧</h1>
<h1 className="text-6xl font-bold text-yellow-600 mb-4">&#x1F6A7;</h1>
<h2 className="text-3xl font-semibold mb-2">We'll be back soon!</h2>
<p className="text-gray-600">
Our site is currently undergoing scheduled maintenance.
<br />
Thank you for your patience.
</p>
<Link
to="/"
className="mt-4 inline-block text-primary-600 hover:underline"
>
<Link to="/" className="mt-4 inline-block text-primary-600 hover:underline">
Back to Homepage
</Link>
</div>
</div>
);
)
}
@@ -1,7 +1,11 @@
import { FC, ReactElement } from 'react';
import { useNavigate } from 'react-router';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { useWinners } from '@imphnen-frontend-service/service';
export const Route = createFileRoute('/_public/winners')({
component: WinnerPage,
})
const WinnerPage: FC = (): ReactElement => {
const navigate = useNavigate();
const { data, isLoading, error } = useWinners();
@@ -303,4 +307,3 @@ const WinnerPage: FC = (): ReactElement => {
);
};
export default WinnerPage;
@@ -1,16 +1,21 @@
import { useNavigate, Link } from 'react-router';
import { useState } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Icon } from '@iconify/react';
import { ThemeToggle } from '../components/theme-toggle';
import { useAuthStore } from '@imphnen-frontend-service/service';
import { createFileRoute } from '@tanstack/react-router'
import { useNavigate } from '@tanstack/react-router'
import { useState } from 'react'
import { Button } from '@imphnen-frontend-service/ui/atoms'
import { Icon } from '@iconify/react'
import { ThemeToggle } from '../components/theme-toggle'
import { useAuthStore } from '@imphnen-frontend-service/service'
export default function HomePage() {
const navigate = useNavigate();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [openFaq, setOpenFaq] = useState<number | null>(0);
const { session } = useAuthStore();
const isAuthenticated = !!session?.token;
export const Route = createFileRoute('/')({
component: HomePage,
})
function HomePage() {
const navigate = useNavigate()
const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
const [openFaq, setOpenFaq] = useState<number | null>(0)
const { session } = useAuthStore()
const isAuthenticated = !!session?.token
const faqs = [
{
@@ -63,7 +68,7 @@ export default function HomePage() {
answer:
'Jika menemukan masalah teknis, silakan hubungi kami melalui grup WA Hackathon.',
},
];
]
return (
<main className="min-h-screen bg-white dark:bg-gray-950">
@@ -105,7 +110,7 @@ export default function HomePage() {
<ThemeToggle />
{isAuthenticated ? (
<Button
onClick={() => navigate('/dashboard')}
onClick={() => navigate({ to: '/dashboard' })}
size="sm"
className="rounded-lg text-base"
>
@@ -114,7 +119,7 @@ export default function HomePage() {
) : (
<>
<Button
onClick={() => navigate('/auth/login')}
onClick={() => navigate({ to: '/auth/login' })}
size="sm"
variant="bordered"
className="rounded-lg text-base dark:bg-gray-800"
@@ -122,7 +127,7 @@ export default function HomePage() {
Masuk
</Button>
<Button
onClick={() => navigate('/auth/signup')}
onClick={() => navigate({ to: '/auth/signup' })}
size="sm"
className="rounded-lg text-base"
>
@@ -179,7 +184,7 @@ export default function HomePage() {
</a>
{isAuthenticated ? (
<button
onClick={() => navigate('/dashboard')}
onClick={() => navigate({ to: '/dashboard' })}
className="px-4 py-2 bg-primary-500 text-white text-base rounded-lg hover:bg-primary-600 transition-colors text-center cursor-pointer"
>
Dashboard
@@ -187,13 +192,13 @@ export default function HomePage() {
) : (
<>
<button
onClick={() => navigate('/auth/login')}
onClick={() => navigate({ to: '/auth/login' })}
className="ms-3 text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white cursor-pointer"
>
Masuk
</button>
<button
onClick={() => navigate('/auth/login')}
onClick={() => navigate({ to: '/auth/login' })}
className="px-4 py-2 bg-primary-500 text-white text-base rounded-lg hover:bg-primary-600 transition-colors text-center cursor-pointer"
>
Daftar Sekarang
@@ -227,7 +232,7 @@ export default function HomePage() {
/>
</div>
<span className="text-3xl md:text-5xl font-bold text-gray-400 dark:text-gray-500">
×
x
</span>
<div className="flex items-center">
<img
@@ -264,7 +269,7 @@ export default function HomePage() {
<div className="flex flex-col md:flex-row items-center gap-4 px-4">
<Button
onClick={() => navigate('/auth/signup')}
onClick={() => navigate({ to: '/auth/signup' })}
className="rounded-lg text-base max-h-auto"
>
Daftar Sekarang
@@ -334,82 +339,47 @@ export default function HomePage() {
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-primary-500 dark:hover:border-primary-500 transition-colors">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-primary-100 dark:bg-primary-900/30 rounded-full flex items-center justify-center">
<Icon
icon="ic:round-star"
className="h-8 w-8 text-primary-500"
/>
<Icon icon="ic:round-star" className="h-8 w-8 text-primary-500" />
</div>
</div>
<h3 className="text-xl font-bold text-center mb-2 dark:text-white">
Juara 1
</h3>
<p className="text-2xl font-bold text-primary-500 text-center font-sans">
Rp6.000.000
</p>
<h3 className="text-xl font-bold text-center mb-2 dark:text-white">Juara 1</h3>
<p className="text-2xl font-bold text-primary-500 text-center font-sans">Rp6.000.000</p>
</div>
<div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-gray-500 dark:hover:border-gray-500 transition-colors">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-700 rounded-full flex items-center justify-center">
<Icon
icon="ic:round-star"
className="h-8 w-8 text-gray-600 dark:text-gray-200"
/>
<Icon icon="ic:round-star" className="h-8 w-8 text-gray-600 dark:text-gray-200" />
</div>
</div>
<h3 className="text-xl font-bold text-center mb-2 dark:text-white">
Juara 2
</h3>
<p className="text-2xl font-bold text-primary-500 text-center font-sans">
Rp4.000.000
</p>
<h3 className="text-xl font-bold text-center mb-2 dark:text-white">Juara 2</h3>
<p className="text-2xl font-bold text-primary-500 text-center font-sans">Rp4.000.000</p>
</div>
<div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-orange-300 dark:hover:border-orange-500 transition-colors">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-orange-100 dark:bg-orange-900/30 rounded-full flex items-center justify-center">
<Icon
icon="ic:round-star"
className="h-8 w-8 text-orange-600 dark:text-orange-500"
/>
<Icon icon="ic:round-star" className="h-8 w-8 text-orange-600 dark:text-orange-500" />
</div>
</div>
<h3 className="text-xl font-bold text-center mb-2 dark:text-white">
Juara 3
</h3>
<p className="text-2xl font-bold text-orange-600 dark:text-orange-500 text-center font-sans">
Rp2.500.000
</p>
<h3 className="text-xl font-bold text-center mb-2 dark:text-white">Juara 3</h3>
<p className="text-2xl font-bold text-orange-600 dark:text-orange-500 text-center font-sans">Rp2.500.000</p>
</div>
<div className="bg-white dark:bg-gray-900 rounded-xl px-4 py-8 shadow-lg border-2 border-gray-300 dark:border-gray-700 hover:border-purple-300 dark:hover:border-purple-500 transition-colors">
<div className="flex justify-center mb-4">
<div className="w-16 h-16 bg-purple-100 dark:bg-purple-900/30 rounded-full flex items-center justify-center">
<Icon
icon="ic:round-star"
className="h-8 w-8 text-purple-600 dark:text-purple-500"
/>
<Icon icon="ic:round-star" className="h-8 w-8 text-purple-600 dark:text-purple-500" />
</div>
</div>
<h3 className="text-xl font-bold text-center mb-2 dark:text-white">
Juara Kategori Lainnya
</h3>
<p className="text-2xl font-bold text-purple-600 dark:text-purple-500 text-center font-sans">
Rp2.000.000
</p>
<h3 className="text-xl font-bold text-center mb-2 dark:text-white">Juara Kategori Lainnya</h3>
<p className="text-2xl font-bold text-purple-600 dark:text-purple-500 text-center font-sans">Rp2.000.000</p>
</div>
</div>
</div>
</section>
<section
id="timeline"
className="py-16 md:py-24 px-4 md:px-8 bg-white dark:bg-gray-950"
>
<section id="timeline" className="py-16 md:py-24 px-4 md:px-8 bg-white dark:bg-gray-950">
<div className="max-w-4xl mx-auto font-sans">
<div className="text-center mb-12 font-bai-jamjuree">
<h2 className="text-3xl md:text-5xl font-bold mb-4 dark:text-white">
@@ -421,94 +391,60 @@ export default function HomePage() {
</div>
<div>
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
<div className="w-0.5 h-full bg-gray-300 dark:bg-gray-700"></div>
</div>
<div className="flex-1 pb-8">
<p className="text-primary-500 font-semibold mb-2">
30 November 2025
</p>
<h3 className="text-xl font-bold mb-2 dark:text-white">
Penutupan Registrasi
</h3>
<p className="text-gray-600 dark:text-gray-200">
Batas akhir pendaftaran peserta hackathon.
</p>
<p className="text-primary-500 font-semibold mb-2">30 November 2025</p>
<h3 className="text-xl font-bold mb-2 dark:text-white">Penutupan Registrasi</h3>
<p className="text-gray-600 dark:text-gray-200">Batas akhir pendaftaran peserta hackathon.</p>
</div>
</div>
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
<div className="w-0.5 h-full bg-gray-300 dark:bg-gray-700"></div>
</div>
<div className="flex-1 pb-8">
<p className="text-primary-500 font-semibold mb-2">
30 November 2025
</p>
<h3 className="text-xl font-bold mb-2 dark:text-white">
Technical Meeting
</h3>
<p className="text-primary-500 font-semibold mb-2">30 November 2025</p>
<h3 className="text-xl font-bold mb-2 dark:text-white">Technical Meeting</h3>
<p className="text-gray-600 dark:text-gray-200">
Akan diadakan technical meeting terkait lomba melalui Google
Meet. Stay tune di grup WA Hackathon.
Akan diadakan technical meeting terkait lomba melalui Google Meet. Stay tune di grup WA Hackathon.
</p>
</div>
</div>
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
<div className="w-0.5 h-full bg-gray-300 dark:bg-gray-700"></div>
</div>
<div className="flex-1 pb-8">
<p className="text-primary-500 font-semibold mb-2">
1 - 7 Desember 2025
</p>
<h3 className="text-xl font-bold mb-2 dark:text-white">
Tahap Penyisihan
</h3>
<p className="text-gray-600 dark:text-gray-200">
Peserta mengerjakan tantangan yang diberikan.
</p>
<p className="text-primary-500 font-semibold mb-2">1 - 7 Desember 2025</p>
<h3 className="text-xl font-bold mb-2 dark:text-white">Tahap Penyisihan</h3>
<p className="text-gray-600 dark:text-gray-200">Peserta mengerjakan tantangan yang diberikan.</p>
</div>
</div>
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
<div className="w-0.5 h-full bg-gray-300 dark:bg-gray-700"></div>
</div>
<div className="flex-1 pb-8">
<p className="text-primary-500 font-semibold mb-2">
8 - 14 Desember 2025
</p>
<h3 className="text-xl font-bold mb-2 dark:text-white">
Penilaian & Webinar
</h3>
<p className="text-gray-600 dark:text-gray-200">
Proses penilaian oleh juri dan sesi webinar.
</p>
<p className="text-primary-500 font-semibold mb-2">8 - 14 Desember 2025</p>
<h3 className="text-xl font-bold mb-2 dark:text-white">Penilaian & Webinar</h3>
<p className="text-gray-600 dark:text-gray-200">Proses penilaian oleh juri dan sesi webinar.</p>
</div>
</div>
<div className="flex gap-6">
<div className="flex flex-col items-center">
<div className="w-4 h-4 bg-primary-500 rounded-full"></div>
</div>
<div className="flex-1">
<p className="text-primary-500 font-semibold mb-2">
15 Desember 2025
</p>
<h3 className="text-xl font-bold mb-2 dark:text-white">
Pengumuman & Final
</h3>
<p className="text-gray-600 dark:text-gray-200">
Presentasi final dan pengumuman pemenang.
</p>
<p className="text-primary-500 font-semibold mb-2">15 Desember 2025</p>
<h3 className="text-xl font-bold mb-2 dark:text-white">Pengumuman & Final</h3>
<p className="text-gray-600 dark:text-gray-200">Presentasi final dan pengumuman pemenang.</p>
</div>
</div>
</div>
@@ -527,31 +463,22 @@ export default function HomePage() {
</div>
<div className="w-full max-w-md md:max-w-4xl mx-auto grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="flex flex-col justify-between bg-white dark:bg-gray-800 rounded-xl px-4 py-8 shadow-lg text-center">
<h3 className="text-p3 font-bold mb-1 dark:text-white">
Alifais Farrel Ramdhani
</h3>
<h3 className="text-p3 font-bold mb-1 dark:text-white">Alifais Farrel Ramdhani</h3>
<div>
<p className="text-primary-500 font-semibold mb-1">CTO</p>
<p className="text-gray-600 dark:text-gray-200">Kolosal.ai</p>
</div>
</div>
<div className="flex flex-col justify-between bg-white dark:bg-gray-800 rounded-xl px-4 py-8 shadow-lg text-center">
<h3 className="text-p3 font-bold mb-1 dark:text-white">
Muhammad Alif Ramadhan
</h3>
<h3 className="text-p3 font-bold mb-1 dark:text-white">Muhammad Alif Ramadhan</h3>
<div>
<p className="text-primary-500 font-semibold mb-1">Admin</p>
<p className="text-gray-600 dark:text-gray-200">IMPHNEN</p>
</div>
</div>
<div className="flex flex-col justify-between bg-white dark:bg-gray-800 rounded-xl px-4 py-8 shadow-lg text-center">
<h3 className="text-p3 font-bold mb-1 dark:text-white">
Hafid Nur
</h3>
<h3 className="text-p3 font-bold mb-1 dark:text-white">Hafid Nur</h3>
<div>
<p className="text-primary-500 font-semibold mb-1">Moderator</p>
<p className="text-gray-600 dark:text-gray-200">IMPHNEN</p>
@@ -658,7 +585,7 @@ export default function HomePage() {
<div className="flex flex-col md:flex-row gap-4 justify-center">
<button
onClick={() => navigate('/auth/signup')}
onClick={() => navigate({ to: '/auth/signup' })}
className="px-8 py-3 bg-primary-500 text-white text-lg rounded-lg hover:bg-primary-700 transition-colors font-semibold cursor-pointer"
>
Daftar Sekarang
@@ -685,13 +612,10 @@ export default function HomePage() {
<footer className="bg-gray-950 text-white py-12 px-4 md:px-8 font-sans">
<div className="max-w-6xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-8">
<div>
<div className="flex items-center gap-2 mb-4 font-bai-jamjuree">
<span className="text-2xl font-bold">IMPHNEN</span>
<span className="text-2xl font-bold text-blue-500">
Hackathon
</span>
<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
@@ -699,137 +623,48 @@ export default function HomePage() {
masa depan.
</p>
<div className="flex gap-4 mt-4">
<div className="flex gap-4">
<a
href="https://fb.com/groups/programmerhandal"
className="text-gray-400 hover:text-accent transition-colors"
target="_blank"
rel="noopener noreferrer"
aria-label="Facebook"
>
<Icon icon="ic:baseline-facebook" className="w-6 h-6" />
</a>
<a
href="https://www.instagram.com/imphnen.dev"
className="text-gray-400 hover:text-accent transition-colors"
target="_blank"
rel="noopener noreferrer"
aria-label="Instagram"
>
<Icon icon="mdi:instagram" className="w-6 h-6" />
</a>
<a
href="https://www.linkedin.com/company/imphnen"
className="text-gray-400 hover:text-accent transition-colors"
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn"
>
<Icon icon="mdi:linkedin" className="w-6 h-6" />
</a>
<a
href="https://www.tiktok.com/@imphnen"
className="text-gray-400 hover:text-accent transition-colors"
target="_blank"
rel="noopener noreferrer"
aria-label="TikTok"
>
<Icon icon="ic:baseline-tiktok" className="w-6 h-6" />
</a>
<a
href="https://github.com/IMPHNEN"
className="text-gray-400 hover:text-accent transition-colors"
target="_blank"
rel="noopener noreferrer"
aria-label="GitHub"
>
<Icon icon="mdi:github" className="w-6 h-6" />
</a>
</div>
<a href="https://fb.com/groups/programmerhandal" className="text-gray-400 hover:text-accent transition-colors" target="_blank" rel="noopener noreferrer" aria-label="Facebook">
<Icon icon="ic:baseline-facebook" className="w-6 h-6" />
</a>
<a href="https://www.instagram.com/imphnen.dev" className="text-gray-400 hover:text-accent transition-colors" target="_blank" rel="noopener noreferrer" aria-label="Instagram">
<Icon icon="mdi:instagram" className="w-6 h-6" />
</a>
<a href="https://www.linkedin.com/company/imphnen" className="text-gray-400 hover:text-accent transition-colors" target="_blank" rel="noopener noreferrer" aria-label="LinkedIn">
<Icon icon="mdi:linkedin" className="w-6 h-6" />
</a>
<a href="https://www.tiktok.com/@imphnen" className="text-gray-400 hover:text-accent transition-colors" target="_blank" rel="noopener noreferrer" aria-label="TikTok">
<Icon icon="ic:baseline-tiktok" className="w-6 h-6" />
</a>
<a href="https://github.com/IMPHNEN" className="text-gray-400 hover:text-accent transition-colors" target="_blank" rel="noopener noreferrer" aria-label="GitHub">
<Icon icon="mdi:github" className="w-6 h-6" />
</a>
</div>
</div>
<div>
<h3 className="font-bold text-lg mb-4 font-bai-jamjuree">
Quick Links
</h3>
<h3 className="font-bold text-lg mb-4 font-bai-jamjuree">Quick Links</h3>
<ul className="space-y-2 text-gray-400">
<li>
<a
href="#timeline"
className="hover:text-white transition-colors"
>
Timeline
</a>
</li>
<li>
<a
href="#hadiah"
className="hover:text-white transition-colors"
>
Hadiah
</a>
</li>
<li>
<a href="#faq" className="hover:text-white transition-colors">
FAQ
</a>
</li>
<li>
<Link
to="/auth/signup"
className="hover:text-white transition-colors"
>
Daftar
</Link>
</li>
<li><a href="#timeline" className="hover:text-white transition-colors">Timeline</a></li>
<li><a href="#hadiah" className="hover:text-white transition-colors">Hadiah</a></li>
<li><a href="#faq" className="hover:text-white transition-colors">FAQ</a></li>
<li><a href="/auth/signup" className="hover:text-white transition-colors">Daftar</a></li>
</ul>
</div>
<div>
<h3 className="font-bold text-lg mb-4 font-bai-jamjuree">
Contact
</h3>
<h3 className="font-bold text-lg mb-4 font-bai-jamjuree">Contact</h3>
<ul className="space-y-2 text-gray-400 text-sm">
<li className="flex items-start gap-2">
<Icon
icon="material-symbols:mail-outline-rounded"
className="min-w-5 min-h-5 mt-0.5"
/>
<a
href="mailto:imphnen@gmail.com"
className="hover:text-white transition-colors"
>
imphnen@gmail.com
</a>
<Icon icon="material-symbols:mail-outline-rounded" className="min-w-5 min-h-5 mt-0.5" />
<a href="mailto:imphnen@gmail.com" className="hover:text-white transition-colors">imphnen@gmail.com</a>
</li>
<li className="flex items-start gap-2">
<Icon
icon="solar:phone-linear"
className="min-w-5 min-h-5 mt-0.5"
/>
<a
href="https://chat.whatsapp.com/BlxrYh9uSC37d7VPhJslGL"
className="hover:text-white transition-colors"
target="_blank"
rel="noopener noreferrer"
>
WA Group Hackathon
</a>
<Icon icon="solar:phone-linear" className="min-w-5 min-h-5 mt-0.5" />
<a href="https://chat.whatsapp.com/BlxrYh9uSC37d7VPhJslGL" className="hover:text-white transition-colors" target="_blank" rel="noopener noreferrer">WA Group Hackathon</a>
</li>
<li className="flex items-start gap-2">
<Icon
icon="streamline-plump:web"
className="min-w-5 min-h-5 mt-0.5"
/>
<a
href="https://imphnen.dev"
className="hover:text-white transition-colors"
target="_blank"
rel="noopener noreferrer"
>
IMPHNEN.dev
</a>
<Icon icon="streamline-plump:web" className="min-w-5 min-h-5 mt-0.5" />
<a href="https://imphnen.dev" className="hover:text-white transition-colors" target="_blank" rel="noopener noreferrer">IMPHNEN.dev</a>
</li>
</ul>
</div>
@@ -837,12 +672,12 @@ export default function HomePage() {
<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
&copy; 2025 IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan
Ngoding. All rights reserved.
</p>
</div>
</div>
</footer>
</main>
);
)
}
+9 -1
View File
@@ -3,6 +3,7 @@ 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';
import { TanStackRouterVite } from '@tanstack/router-plugin/vite';
export default defineConfig(() => ({
root: __dirname,
@@ -15,7 +16,14 @@ export default defineConfig(() => ({
port: 3005,
host: 'localhost',
},
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
plugins: [
TanStackRouterVite({
routeFileIgnorePattern: '_components|_hooks|_hook|_data',
}),
react(),
nxViteTsPaths(),
nxCopyAssetsPlugin(['*.md']),
],
build: {
outDir: '../../dist/apps/hackathon',
emptyOutDir: true,