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:
co-authored by
Claude Opus 4.6
parent
86d9e759a6
commit
4240e8eb51
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
// This file is auto-generated by TanStack Router
|
||||
|
||||
export const routeTree = {} as any
|
||||
@@ -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;
|
||||
+8
-6
@@ -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;
|
||||
+6
-4
@@ -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;
|
||||
+11
-10
@@ -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;
|
||||
+7
-6
@@ -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;
|
||||
+12
-14
@@ -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;
|
||||
+9
-9
@@ -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;
|
||||
+9
-8
@@ -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;
|
||||
+15
-16
@@ -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;
|
||||
+18
-7
@@ -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;
|
||||
+9
-6
@@ -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;
|
||||
+7
-6
@@ -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;
|
||||
@@ -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">⚠️</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">✓</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>
|
||||
)
|
||||
}
|
||||
+51
-39
@@ -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>
|
||||
)
|
||||
}
|
||||
+18
-26
@@ -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;
|
||||
+26
-40
@@ -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;
|
||||
+9
-8
@@ -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">🚧</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>
|
||||
);
|
||||
)
|
||||
}
|
||||
+5
-2
@@ -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
|
||||
© 2025 IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan
|
||||
Ngoding. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
);
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user