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
@@ -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;
|
||||
@@ -0,0 +1,480 @@
|
||||
import { FC, ReactElement, useEffect, useMemo, useState } from 'react';
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
|
||||
import {
|
||||
useMyTeams,
|
||||
useMyInvitations,
|
||||
useRespondToInvitation,
|
||||
useAuthStore,
|
||||
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 './_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');
|
||||
|
||||
type Invitation = {
|
||||
id: string;
|
||||
team: {
|
||||
id?: string;
|
||||
name?: string;
|
||||
logo?: string;
|
||||
banner?: string;
|
||||
description?: string;
|
||||
city?: string;
|
||||
visibility?: string;
|
||||
leader_id?: string;
|
||||
};
|
||||
inviter: {
|
||||
id?: string;
|
||||
fullname?: string;
|
||||
email?: string;
|
||||
avatar?: string;
|
||||
};
|
||||
};
|
||||
|
||||
const DashboardPage: FC = (): ReactElement => {
|
||||
const { session } = useAuthStore();
|
||||
const navigate = useNavigate();
|
||||
const [showProfileModal, setShowProfileModal] = useState(false);
|
||||
const [timeLeft, setTimeLeft] = useState<{
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
} | null>(null);
|
||||
|
||||
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
||||
|
||||
const isSubmissionDeadlinePassed = new Date() >= SUBMISSION_DEADLINE;
|
||||
|
||||
useEffect(() => {
|
||||
if (isSubmissionDeadlinePassed) return;
|
||||
|
||||
const calculateTimeLeft = () => {
|
||||
const now = new Date();
|
||||
const difference = SUBMISSION_DEADLINE.getTime() - now.getTime();
|
||||
|
||||
if (difference <= 0) {
|
||||
setTimeLeft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const days = Math.floor(difference / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((difference / (1000 * 60 * 60)) % 24);
|
||||
const minutes = Math.floor((difference / 1000 / 60) % 60);
|
||||
const seconds = Math.floor((difference / 1000) % 60);
|
||||
|
||||
setTimeLeft({ days, hours, minutes, seconds });
|
||||
};
|
||||
|
||||
calculateTimeLeft();
|
||||
const timer = setInterval(calculateTimeLeft, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isSubmissionDeadlinePassed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showProfileModal) {
|
||||
const originalOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = originalOverflow || '';
|
||||
};
|
||||
}
|
||||
}, [showProfileModal]);
|
||||
const { data: teamsData } = useMyTeams();
|
||||
const { data: winnersResponse } = useWinners();
|
||||
const { data: invitationsData } = useMyInvitations();
|
||||
const { mutateAsync: respondToInvitation } = useRespondToInvitation();
|
||||
|
||||
const user = session?.user;
|
||||
const myTeams = teamsData?.data || [];
|
||||
const invitations: Invitation[] = (invitationsData?.data ||
|
||||
[]) as Invitation[];
|
||||
|
||||
const winnerEntry = useMemo(() => {
|
||||
const team = (myTeams[0] as { id?: string } | null | undefined) || null;
|
||||
const winners = winnersResponse?.data || [];
|
||||
if (!team?.id) return null;
|
||||
return winners.find((w) => w.team_id === team.id) || null;
|
||||
}, [myTeams, winnersResponse?.data]);
|
||||
|
||||
const handleAcceptInvitation = async (invitationId: string) => {
|
||||
try {
|
||||
await respondToInvitation({ invitationId, action: 'accept' });
|
||||
toast.success('Invitation accepted! You are now a team member.');
|
||||
} catch (error) {
|
||||
console.error('Failed to accept invitation:', error);
|
||||
toast.error('Failed to accept invitation');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectInvitation = async (invitationId: string) => {
|
||||
try {
|
||||
await respondToInvitation({ invitationId, action: 'reject' });
|
||||
toast.success('Invitation declined');
|
||||
} catch (error) {
|
||||
console.error('Failed to reject invitation:', error);
|
||||
toast.error('Failed to decline invitation');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="p-6 md:p-8 md:max-w-7xl w-full mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Welcome, {user?.fullname || user?.email?.split('@')[0] || 'User'}!
|
||||
</h1>
|
||||
{user?.location && (
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1 font-sans flex items-center space-x-1">
|
||||
<Icon icon="heroicons:map-pin-16-solid" width="24" height="24" />
|
||||
<span>{user.location}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{winnerEntry && myTeams.length > 0 && (
|
||||
<div className="mb-8 bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-lg p-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||
<span className="text-4xl shrink-0">🏆</span>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-bold text-amber-900 dark:text-amber-100 text-lg">
|
||||
Selamat! Tim Anda meraih JUARA {winnerEntry.rank}
|
||||
</h3>
|
||||
<p className="text-amber-700 dark:text-amber-300 text-sm">
|
||||
Anda dapat generate sertifikat penghargaan dan
|
||||
membagikannya.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const team = myTeams[0] as { id?: string } | null | undefined;
|
||||
if (!team?.id) return;
|
||||
const certId = await encodeWinnerCertificateId(team.id);
|
||||
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"
|
||||
>
|
||||
Generate Sertifikat Juara
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{timeLeft && !isSubmissionDeadlinePassed && (
|
||||
<div className="mb-8 bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 rounded-lg p-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<span className="text-3xl">⏰</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-blue-900 dark:text-blue-100 text-lg">
|
||||
Submission Deadline
|
||||
</h3>
|
||||
<p className="text-blue-800 dark:text-blue-200 mt-2 text-sm font-sans">
|
||||
Project submissions close on December 7, 2025 at 23:59 WIB
|
||||
</p>
|
||||
<div className="mt-4 grid grid-cols-4 gap-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{timeLeft.days}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Days
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{timeLeft.hours.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Hours
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{timeLeft.minutes.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Minutes
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{timeLeft.seconds.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Seconds
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invitations.length > 0 && (
|
||||
<div className="mb-8 bg-primary-50 dark:bg-blue-900/20 border border-primary-200 dark:border-blue-800 rounded-lg p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Team Invitations ({invitations.length})
|
||||
</h2>
|
||||
{isTeamFeaturesClosed && (
|
||||
<div className="mb-4 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 flex items-center gap-2">
|
||||
<Icon icon="mdi:clock-alert" className="text-lg shrink-0" />
|
||||
Team features are closed. You can no longer accept
|
||||
invitations.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-3 font-sans">
|
||||
{invitations.map((invitation) => (
|
||||
<div
|
||||
key={invitation.id}
|
||||
className="bg-white dark:bg-gray-900 p-4 rounded-lg shadow-sm flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{invitation.team?.name ?? 'Unnamed Team'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Invited by{' '}
|
||||
{invitation.inviter?.fullname ?? 'Unknown User'}
|
||||
</p>
|
||||
</div>
|
||||
{!isTeamFeaturesClosed && (
|
||||
<div className="flex space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleAcceptInvitation(invitation.id)}
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleRejectInvitation(invitation.id)}
|
||||
>
|
||||
Decline
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{myTeams.length > 0 ? (
|
||||
(() => {
|
||||
const team = myTeams[0] as any;
|
||||
return (
|
||||
<div className="mb-6 md:mb-8">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
My Team
|
||||
</h2>
|
||||
<Link
|
||||
to={'/teams/' + team.id}
|
||||
className="block bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow border dark:border-gray-700"
|
||||
>
|
||||
<img
|
||||
src={team.banner || '/images/banner-imphnen.webp'}
|
||||
alt={team.name}
|
||||
className="w-full aspect-3/1 object-cover"
|
||||
/>
|
||||
<div className="p-4 md:p-6">
|
||||
<div className="flex items-center space-x-4 mb-4">
|
||||
{team.logo ? (
|
||||
<img
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
className="w-16 h-16 rounded-full object-cover shrink-0"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0">
|
||||
<Icon
|
||||
icon="mdi:account-group"
|
||||
className="text-gray-500 dark:text-gray-400 text-2xl"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white leading-tight line-clamp-1">
|
||||
{team.name}
|
||||
</h3>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-3 font-sans mt-2">
|
||||
{team.has_submission && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 shrink-0">
|
||||
<Icon
|
||||
icon="mdi:check-circle"
|
||||
className="text-sm"
|
||||
/>
|
||||
Submitted
|
||||
</span>
|
||||
)}
|
||||
{team.city && (
|
||||
<span className="flex items-center gap-1 truncate">
|
||||
<Icon
|
||||
icon="mdi:map-marker"
|
||||
className="shrink-0"
|
||||
/>
|
||||
<span className="truncate">{team.city}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
<Icon icon="mdi:account-group" />
|
||||
{team.member_count ||
|
||||
team.members?.length ||
|
||||
0}{' '}
|
||||
member
|
||||
{(team.member_count ||
|
||||
team.members?.length ||
|
||||
0) !== 1
|
||||
? 's'
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{team.description && (
|
||||
<p className="text-gray-600 dark:text-gray-400 line-clamp-3 font-sans">
|
||||
{team.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<div className="mb-6 md:mb-8 bg-white dark:bg-gray-900 rounded-lg shadow-md p-6 md:p-8">
|
||||
<div className="text-center">
|
||||
<div className="text-3xl md:text-4xl mb-2 md:mb-3">👋</div>
|
||||
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-1 md:mb-2">
|
||||
You are not in a team yet
|
||||
</h2>
|
||||
<p className="text-sm md:text-base text-gray-600 dark:text-gray-400 font-sans">
|
||||
Use the sidebar to browse teams or create your own
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 bg-white dark:bg-gray-900 rounded-xl shadow-lg">
|
||||
<div className="bg-linear-to-r from-primary-600 to-primary-500 h-24 rounded-t-xl"></div>
|
||||
<div className="px-4 md:px-8 pb-4 md:pb-8 max-w-7xl">
|
||||
<div className="flex flex-col md:flex-row md:items-start -mt-12 mb-4 md:mb-6">
|
||||
<div className="flex flex-col md:flex-row items-start">
|
||||
{user?.avatar ? (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.fullname || 'User'}
|
||||
className="w-20 h-20 md:w-24 md:h-24 rounded-full border-4 border-white dark:border-gray-700 shadow-lg object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-20 h-20 md:w-24 md:h-24 rounded-full border-4 border-white dark:border-gray-700 shadow-lg bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-gray-400 dark:text-gray-500 text-3xl md:text-4xl font-sans">
|
||||
U
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="md:ml-6 mt-4 md:mt-14">
|
||||
<h2 className="text-xl md:text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{user?.fullname ||
|
||||
user?.email?.split('@')[0] ||
|
||||
'Unnamed User'}
|
||||
</h2>
|
||||
|
||||
{user?.location ? (
|
||||
<p className="text-gray-600 dark:text-gray-400 flex items-center mt-1 text-sm md:text-base font-sans">
|
||||
<Icon
|
||||
icon="heroicons:map-pin-16-solid"
|
||||
width="16"
|
||||
height="16"
|
||||
/>
|
||||
<span className="ml-1">{user.location}</span>
|
||||
</p>
|
||||
) : (
|
||||
<Link
|
||||
to="/onboarding/user"
|
||||
className="text-primary-500 dark:text-blue-400 hover:text-primary-600 dark:hover:text-blue-300 text-sm md:text-sm mt-1 flex items-center"
|
||||
>
|
||||
<span className="font-sans">Complete your profile</span>
|
||||
<Icon
|
||||
icon="ic:baseline-chevron-right"
|
||||
width="24"
|
||||
height="24"
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{user?.location && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowProfileModal(true)}
|
||||
className="mt-3 md:mt-14 md:ml-auto inline-flex items-center justify-center px-3 py-1.5 md:px-4 md:py-2 bg-primary-500 text-white rounded-lg text md:text-sm font-medium shadow-sm hover:bg-primary-600 transition-colors w-full md:w-auto cursor-pointer"
|
||||
>
|
||||
Edit Profile
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-4 md:space-y-6 w-full max-w-full">
|
||||
{user?.bio && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm w-full max-w-full overflow-x-hidden">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wide mb-2">
|
||||
About
|
||||
</h3>
|
||||
<p className="text-gray-800 dark:text-gray-200 leading-relaxed line-clamp-3 wrap-break-word break-all overflow-hidden w-full max-w-full">
|
||||
{user.bio}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm">
|
||||
<h3 className="font-semibold text-gray-700 dark:text-gray-300 tracking-wide mb-3">
|
||||
Contact
|
||||
</h3>
|
||||
<div className="flex items-center text-gray-700 dark:text-gray-300">
|
||||
<span className="text-gray-900 dark:text-white">
|
||||
{user?.email}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{user?.skills && user.skills.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-4 shadow-sm">
|
||||
<h3 className="font-semibold text-gray-700 dark:text-gray-300 tracking-wide mb-3">
|
||||
Skills
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{user.skills.map((skill: string) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="inline-flex items-center px-4 py-2 border border-primary-600 dark:border-gray-500 text-primary-600 dark:text-white rounded-4xl text-xs font-medium dark:bg-gray-600"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProfilePage
|
||||
open={showProfileModal}
|
||||
onClose={() => setShowProfileModal(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
import { FC, ReactElement, useState, useEffect } from 'react';
|
||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import {
|
||||
userOnboardingSchema,
|
||||
TUserOnboardingForm,
|
||||
useUpdateUserMe,
|
||||
useUploadAvatar,
|
||||
useUserMe,
|
||||
useAuthStore } from '@imphnen-frontend-service/service';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { 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',
|
||||
'Full Stack Developer',
|
||||
'DevOps Engineer',
|
||||
'UI/UX Designer',
|
||||
'Product Manager',
|
||||
'Data Scientist',
|
||||
'Mobile Developer',
|
||||
];
|
||||
|
||||
const UserOnboardingPage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
const [avatarFile, setAvatarFile] = useState<File | null>(null);
|
||||
const [avatarPreview, setAvatarPreview] = useState<string>('');
|
||||
|
||||
const { data: userData } = useUserMe();
|
||||
const { mutateAsync: updateUser, isPending: isUpdating } = useUpdateUserMe();
|
||||
const { mutateAsync: uploadAvatar, isPending: isUploading } =
|
||||
useUploadAvatar();
|
||||
const { session } = useAuthStore();
|
||||
|
||||
const form = useForm<TUserOnboardingForm>({
|
||||
resolver: zodResolver(userOnboardingSchema),
|
||||
mode: 'all',
|
||||
defaultValues: {
|
||||
fullname: session?.user?.fullname || userData?.data?.fullname || '',
|
||||
location: session?.user?.location || '',
|
||||
bio: session?.user?.bio || '',
|
||||
skills: session?.user?.skills || [],
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (session?.user?.avatar && !avatarPreview) {
|
||||
setAvatarPreview(session.user.avatar);
|
||||
}
|
||||
}, [session?.user?.avatar, avatarPreview]);
|
||||
|
||||
const handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
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,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
globalThis.location.href = '/dashboard';
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: 'Onboarding failed. Please try again.'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center min-h-screen bg-gray-50 dark:bg-gray-950 px-4 py-8">
|
||||
<div className="bg-white dark:bg-gray-900 w-full max-w-2xl p-8 rounded-xl shadow-lg dark:shadow-gray-950/50">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Complete Your Profile
|
||||
</h1>
|
||||
<p className="text-gray-600 font-sans dark:text-gray-400">
|
||||
Tell us more about yourself to get started
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="relative">
|
||||
{avatarPreview ? (
|
||||
<img
|
||||
src={avatarPreview}
|
||||
alt="Avatar preview"
|
||||
className="w-32 h-32 rounded-full object-cover border-4 border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-32 h-32 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<Icon
|
||||
icon="ic:baseline-person"
|
||||
width="48"
|
||||
height="48"
|
||||
className="text-gray-400 dark:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<label htmlFor="avatar" className="cursor-pointer">
|
||||
<span className="px-4 py-2 bg-primary-500 dark:bg-primary-600 text-white rounded-lg hover:bg-primary-600 dark:hover:bg-primary-700 inline-block">
|
||||
{avatarPreview ? 'Change Photo' : 'Upload Photo'}
|
||||
</span>
|
||||
<input
|
||||
id="avatar"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleAvatarChange}
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-500 mt-2 text-center">
|
||||
Optional, but highly recommended. Max 2MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Full Name"
|
||||
placeholder="Enter your full name"
|
||||
name="fullname"
|
||||
className="px-3 py-2 text-label2 rounded-lg max-h-auto text-base"
|
||||
size="md"
|
||||
isRequired={true}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-base font-medium text-gray-700 dark:text-gray-300">
|
||||
City <span className="text-red-500">*</span>
|
||||
</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-base font-medium text-gray-700 dark:text-gray-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"
|
||||
>
|
||||
<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-gray-600 text-blue-600 dark:text-primary-500 focus:ring-blue-500 dark:focus:ring-primary-500 dark:bg-gray-800"
|
||||
/>
|
||||
<span className="text-sm dark:text-gray-300">
|
||||
{role}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-base font-medium text-gray-700 dark:text-gray-300">
|
||||
Bio{' '}
|
||||
<span className="text-gray-400 dark:text-gray-500">
|
||||
(Optional)
|
||||
</span>
|
||||
</label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="bio"
|
||||
render={({ field, fieldState }) => (
|
||||
<div className="flex">
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="Tell us about yourself..."
|
||||
rows={4}
|
||||
className="w-full rounded-lg text-sm"
|
||||
style={{ resize: 'vertical' }}
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<p className="text-sm text-red-500 mt-1">
|
||||
{fieldState.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
type="submit"
|
||||
disabled={!form.formState.isValid || isUpdating || isUploading}
|
||||
>
|
||||
{isUpdating || isUploading ? 'Saving...' : 'Complete Setup'}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,880 @@
|
||||
import { FC, ReactElement, useState, useEffect } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
|
||||
import {
|
||||
useTeamById,
|
||||
useTeamMembers,
|
||||
useInviteMember,
|
||||
useTeamJoinRequests,
|
||||
useRespondToJoinRequest,
|
||||
useLeaveTeam,
|
||||
useDeleteTeam,
|
||||
ETeamMemberRole,
|
||||
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');
|
||||
|
||||
const ImageWithLoader: FC<{
|
||||
src: string;
|
||||
alt: string;
|
||||
className?: string;
|
||||
onLoad?: () => void;
|
||||
}> = ({ src, alt, className, onLoad }) => {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{!isLoaded && (
|
||||
<div
|
||||
className={`absolute inset-0 bg-gray-200 animate-pulse ${className}`}
|
||||
/>
|
||||
)}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`${className} ${
|
||||
isLoaded ? 'opacity-100' : 'opacity-0'
|
||||
} transition-opacity duration-300`}
|
||||
onLoad={() => {
|
||||
setIsLoaded(true);
|
||||
onLoad?.();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TeamDashboardPage: FC = (): ReactElement => {
|
||||
const { teamId } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
const { session } = useAuthStore();
|
||||
|
||||
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
||||
const [showInviteModal, setShowInviteModal] = useState(false);
|
||||
const [showJoinRequestsModal, setShowJoinRequestsModal] = useState(false);
|
||||
const [showLeaveModal, setShowLeaveModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deleteConfirmText, setDeleteConfirmText] = useState('');
|
||||
const [inviteEmail, setInviteEmail] = useState('');
|
||||
const [imagesLoaded, setImagesLoaded] = useState(false);
|
||||
const [imageLoadCount, setImageLoadCount] = useState(0);
|
||||
|
||||
const totalImagesToLoad = 0;
|
||||
|
||||
const handleImageLoad = () => {
|
||||
setImageLoadCount((prev) => {
|
||||
const newCount = prev + 1;
|
||||
if (newCount >= totalImagesToLoad) {
|
||||
setImagesLoaded(true);
|
||||
}
|
||||
return newCount;
|
||||
});
|
||||
};
|
||||
|
||||
const handleImageError = () => {
|
||||
handleImageLoad();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setImagesLoaded(true);
|
||||
}, []);
|
||||
|
||||
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
|
||||
teamId || ''
|
||||
);
|
||||
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(
|
||||
teamId || ''
|
||||
);
|
||||
|
||||
const team = teamData?.data;
|
||||
const members = membersData?.data || [];
|
||||
const currentUserId = session?.user?.id;
|
||||
const isLeader = currentUserId === team?.leader_id;
|
||||
|
||||
const { data: joinRequestsData } = useTeamJoinRequests(
|
||||
teamId || '',
|
||||
!!teamId && isLeader
|
||||
);
|
||||
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(
|
||||
teamId || ''
|
||||
);
|
||||
const { mutateAsync: respondToJoinRequest, isPending: isResponding } =
|
||||
useRespondToJoinRequest(teamId || '');
|
||||
const { mutateAsync: leaveTeam, isPending: isLeaving } = useLeaveTeam();
|
||||
const { mutateAsync: deleteTeam, isPending: isDeleting } = useDeleteTeam();
|
||||
|
||||
const joinRequests = joinRequestsData?.data || [];
|
||||
const pendingJoinRequests = joinRequests.filter(
|
||||
(req: any) => req.status === 'pending'
|
||||
);
|
||||
const isMember = members.some(
|
||||
(member: any) => member.user_id === currentUserId
|
||||
);
|
||||
const canInvite = isLeader && members.length < MAX_TEAM_MEMBERS;
|
||||
|
||||
const handleInviteMember = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!inviteEmail.trim() || isInviting) return;
|
||||
|
||||
try {
|
||||
await inviteMember({ email: inviteEmail.trim() });
|
||||
toast.success('Invitation sent successfully!');
|
||||
setInviteEmail('');
|
||||
setShowInviteModal(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to invite member:', error);
|
||||
toast.error('Failed to send invitation');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRespondToJoinRequest = async (
|
||||
requestId: string,
|
||||
action: 'approve' | 'reject'
|
||||
) => {
|
||||
try {
|
||||
await respondToJoinRequest({ requestId, action });
|
||||
toast.success(
|
||||
action === 'approve' ? 'Request approved!' : 'Request rejected'
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to respond to join request:', error);
|
||||
toast.error('Failed to process request');
|
||||
}
|
||||
};
|
||||
|
||||
const handleLeaveTeam = async () => {
|
||||
if (!teamId) return;
|
||||
|
||||
try {
|
||||
await leaveTeam(teamId);
|
||||
toast.success('You have left the team');
|
||||
navigate({ to: '/dashboard' });
|
||||
} catch (error) {
|
||||
console.error('Failed to leave team:', error);
|
||||
toast.error('Failed to leave team');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTeam = async () => {
|
||||
if (!teamId) return;
|
||||
|
||||
try {
|
||||
await deleteTeam(teamId);
|
||||
toast.success('Team deleted successfully');
|
||||
navigate({ to: '/dashboard' });
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete team:', error);
|
||||
toast.error(error?.message || 'Failed to delete team');
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoadingTeam || isLoadingMembers) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="bg-white dark:bg-gray-950 border-b">
|
||||
<div className="w-full h-48 bg-gray-200 dark:bg-gray-800 animate-pulse" />
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-20 h-20 rounded-full bg-gray-300 dark:bg-gray-700 animate-pulse -mt-10" />
|
||||
<div>
|
||||
<div className="h-8 w-48 bg-gray-300 dark:bg-gray-700 rounded animate-pulse" />
|
||||
<div className="h-4 w-32 bg-gray-200 dark:bg-gray-800 rounded animate-pulse mt-2" />
|
||||
<div className="h-4 w-24 bg-gray-200 dark:bg-gray-800 rounded animate-pulse mt-2" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="grid gap-6 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-6">
|
||||
<div className="h-6 w-32 bg-gray-300 dark:bg-gray-700 rounded animate-pulse mb-4" />
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-full bg-gray-200 dark:bg-gray-800 rounded animate-pulse" />
|
||||
<div className="h-4 w-3/4 bg-gray-200 dark:bg-gray-800 rounded animate-pulse" />
|
||||
<div className="h-4 w-5/6 bg-gray-200 dark:bg-gray-800 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-6">
|
||||
<div className="h-6 w-40 bg-gray-300 dark:bg-gray-700 rounded animate-pulse mb-4" />
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-center space-x-3">
|
||||
<div className="w-12 h-12 rounded-full bg-gray-300 dark:bg-gray-700 animate-pulse" />
|
||||
<div>
|
||||
<div className="h-4 w-24 bg-gray-300 dark:bg-gray-700 rounded animate-pulse" />
|
||||
<div className="h-3 w-16 bg-gray-200 dark:bg-gray-800 rounded animate-pulse mt-1" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fixed inset-0 bg-white/60 dark:bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent"></div>
|
||||
<p className="mt-4 text-gray-600 dark:text-white">
|
||||
Loading team data...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!team) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Team not found
|
||||
</h2>
|
||||
<Button onClick={() => navigate({ to: '/dashboard' })}>
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 relative dark:bg-gray-950">
|
||||
{!imagesLoaded && totalImagesToLoad > 0 && (
|
||||
<div className="fixed inset-0 bg-white/80 flex items-center justify-center z-50">
|
||||
<div className="text-center">
|
||||
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent"></div>
|
||||
<p className="mt-4 text-gray-600 font-medium">Loading images...</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
{imageLoadCount} / {totalImagesToLoad}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||
{team.banner && (
|
||||
<div className="relative w-full aspect-3/1 overflow-hidden">
|
||||
<div
|
||||
className={`absolute inset-0 bg-gray-200 animate-pulse ${
|
||||
imagesLoaded ? 'hidden' : ''
|
||||
}`}
|
||||
/>
|
||||
<img
|
||||
src={team.banner}
|
||||
alt={team.name}
|
||||
className={`absolute inset-0 w-full h-full object-cover transition-opacity duration-300 ${
|
||||
imagesLoaded ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
<div className="pointer-events-none absolute inset-0 bg-linear-to-b from-transparent via-black/20 to-black/60" />
|
||||
</div>
|
||||
)}
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3 md:space-x-4">
|
||||
{team.logo && (
|
||||
<div className="relative shrink-0">
|
||||
{!imagesLoaded && (
|
||||
<div className="absolute inset-0 w-20 h-20 rounded-full bg-gray-300 dark:bg-gray-700 animate-pulse -mt-10" />
|
||||
)}
|
||||
<img
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
className={`w-16 h-16 md:w-20 md:h-20 rounded-full object-cover border-4 border-white shadow-lg -mt-8 md:-mt-10 transition-opacity duration-300 shrink-0 ${
|
||||
imagesLoaded ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-xl md:text-3xl font-bold text-gray-900 dark:text-white line-clamp-2">
|
||||
{team.name}
|
||||
</h1>
|
||||
<p className="text-sm md:text-base text-gray-600 dark:text-gray-400 mt-1 line-clamp-1 font-sans flex items-center">
|
||||
<Icon icon="mdi:map-marker" className="inline-block mr-1" />
|
||||
<span>{team.city}</span>
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center gap-2 md:space-x-4 mt-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 flex items-center">
|
||||
<Icon
|
||||
icon="mdi:account-multiple"
|
||||
className="inline-block mr-1"
|
||||
/>
|
||||
{members.length}{' '}
|
||||
{members.length === 1 ? 'Member' : 'Members'}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 flex items-center">
|
||||
<Icon
|
||||
icon={
|
||||
team.visibility === 'public' ? 'mdi:web' : 'mdi:lock'
|
||||
}
|
||||
className="inline-block mr-1"
|
||||
/>
|
||||
{team.visibility === 'public' ? 'Public' : 'Private'}
|
||||
</span>
|
||||
{isLeader && (
|
||||
<span className="hidden md:inline-flex px-3 py-1 bg-blue-600 text-white rounded-md text-sm font-semibold shadow-sm items-center">
|
||||
<Icon icon="mdi:crown" className="inline-block mr-1" />
|
||||
<span>Team Leader</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-8">
|
||||
<div className="grid gap-4 md:gap-6 xl:grid-cols-3">
|
||||
<div className="lg:col-span-2 space-y-4 md:space-y-6">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
About Team
|
||||
</h2>
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap break-all font-sans">
|
||||
{team.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLeader && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Team Management
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{!team.has_submission && !isTeamFeaturesClosed && (
|
||||
<>
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
disabled={!canInvite}
|
||||
>
|
||||
<Icon icon="mdi:plus" className="inline-block mr-2" />
|
||||
Invite Member{' '}
|
||||
{!canInvite && `(${members.length}/${MAX_TEAM_MEMBERS})`}
|
||||
</Button>
|
||||
<Button
|
||||
className="w-full relative"
|
||||
variant="secondary"
|
||||
onClick={() => setShowJoinRequestsModal(true)}
|
||||
>
|
||||
<Icon icon="mdi:email" className="inline-block mr-2" /> Join
|
||||
Requests
|
||||
{pendingJoinRequests.length > 0 && (
|
||||
<span className="absolute -top-2 -right-2 bg-red-500 text-white text-xs font-bold rounded-full w-6 h-6 flex items-center justify-center">
|
||||
{pendingJoinRequests.length}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!team.has_submission && !isTeamFeaturesClosed && (
|
||||
<Link to={`/teams/${teamId}/edit`}>
|
||||
<Button className="w-full" variant="secondary">
|
||||
<Icon icon="mdi:pencil" className="inline-block mr-2" />
|
||||
Edit Team Info
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
<Link to={`/teams/${teamId}/members`}>
|
||||
<Button className="w-full" variant="secondary">
|
||||
<Icon
|
||||
icon="mdi:account-group"
|
||||
className="inline-block mr-2"
|
||||
/>
|
||||
View Members
|
||||
</Button>
|
||||
</Link>
|
||||
<Link to={`/teams/${teamId}/chat`}>
|
||||
<Button className="w-full" variant="secondary">
|
||||
<Icon icon="mdi:chat" className="inline-block mr-2" />
|
||||
Team Chat
|
||||
</Button>
|
||||
</Link>
|
||||
{team.has_submission ? (
|
||||
<Link to={`/teams/${teamId}/submission`}>
|
||||
<Button className="w-full" variant="secondary">
|
||||
<Icon icon="mdi:file-document" className="inline-block mr-2" />
|
||||
View Submission
|
||||
</Button>
|
||||
</Link>
|
||||
) : (
|
||||
<Link to={`/teams/${teamId}/submit`}>
|
||||
<Button className="w-full" disabled={members.length < 2}>
|
||||
<Icon icon="mdi:rocket" className="inline-block mr-2" />
|
||||
Submit Project
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{!team.has_submission && !canInvite && members.length >= MAX_TEAM_MEMBERS && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-3 text-center">
|
||||
Maximum team size reached ({MAX_TEAM_MEMBERS} members)
|
||||
</p>
|
||||
)}
|
||||
{members.length === 1 && !team.has_submission && !isTeamFeaturesClosed && (
|
||||
<div className="mt-6 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-medium text-red-600 dark:text-red-400 mb-3">
|
||||
Danger Zone
|
||||
</h3>
|
||||
<Button
|
||||
className="w-full bg-red-600 hover:bg-red-700 text-white"
|
||||
onClick={() => setShowDeleteModal(true)}
|
||||
>
|
||||
<Icon icon="mdi:delete" className="inline-block mr-2" />
|
||||
Delete Team
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLeader && isMember && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Quick Actions
|
||||
</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Link to={`/teams/${teamId}/chat`}>
|
||||
<Button className="w-full" variant="secondary">
|
||||
<Icon icon="mdi:chat" className="inline-block mr-2" />
|
||||
Team Chat
|
||||
</Button>
|
||||
</Link>
|
||||
{team.has_submission && (
|
||||
<Link to={`/teams/${teamId}/submission`}>
|
||||
<Button className="w-full" variant="secondary">
|
||||
<Icon icon="mdi:file-document" className="inline-block mr-2" />
|
||||
View Submission
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
{!team.has_submission && !isTeamFeaturesClosed && (
|
||||
<Button
|
||||
className="w-full bg-red-600 hover:bg-red-700 text-white"
|
||||
onClick={() => setShowLeaveModal(true)}
|
||||
>
|
||||
<Icon icon="mdi:exit-run" className="inline-block mr-2" />
|
||||
Leave Team
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{members.length === 1 && !team.has_submission && isMember && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 md:p-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-3xl">⚠️</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-amber-900 dark:text-amber-400">
|
||||
Team Needs More Members
|
||||
</h3>
|
||||
<p className="text-amber-700 dark:text-amber-300 text-sm">
|
||||
Your team needs at least 2 members to submit a project. Invite someone or wait for join requests!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{team.has_submission && isMember && (
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4 md:p-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-3xl">✅</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-green-900 dark:text-green-400">
|
||||
Project Submitted
|
||||
</h3>
|
||||
<p className="text-green-700 dark:text-green-300 text-sm">
|
||||
Your team has successfully submitted a project
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link to={`/teams/${teamId}/submission`}>
|
||||
<Button className="mt-4 w-full" variant="secondary">
|
||||
View Submission Details
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
|
||||
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Team Leader
|
||||
</h3>
|
||||
{team.leader && (
|
||||
<button
|
||||
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 ? (
|
||||
<img
|
||||
src={team.leader.avatar}
|
||||
alt={team.leader.fullname}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
👤
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{team.leader.fullname}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{team.leader.email}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md p-4 md:p-6">
|
||||
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Members ({members.length})
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{members.map((member: any) => (
|
||||
<button
|
||||
key={member.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 ? (
|
||||
<img
|
||||
src={member.user.avatar}
|
||||
alt={member.user.fullname || 'Member'}
|
||||
className="w-10 h-10 rounded-full object-cover"
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
👤
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-gray-900 dark:text-white truncate">
|
||||
{member.user?.fullname || 'Unknown'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{member.role === ETeamMemberRole.LEADER
|
||||
? 'Leader'
|
||||
: 'Member'}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showInviteModal && (
|
||||
<div className="fixed inset-0 bg-black/20 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Invite Team Member
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4 font-sans">
|
||||
Send an invitation to join your team. The invited member will see
|
||||
the invitation on their dashboard after logging in.
|
||||
</p>
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 mb-6">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-300 font-sans">
|
||||
<strong>Important:</strong> The email you enter must match the
|
||||
GitHub email address the member uses to sign in.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleInviteMember} className="space-y-4">
|
||||
<div>
|
||||
<label
|
||||
htmlFor="invite-email"
|
||||
className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2"
|
||||
>
|
||||
Email Address
|
||||
</label>
|
||||
<input
|
||||
id="invite-email"
|
||||
type="email"
|
||||
value={inviteEmail}
|
||||
onChange={(e) => setInviteEmail(e.target.value)}
|
||||
placeholder="Enter email address..."
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-2 focus:ring-blue-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"
|
||||
required
|
||||
/>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
Current members: {members.length}/{MAX_TEAM_MEMBERS}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setShowInviteModal(false);
|
||||
setInviteEmail('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={!inviteEmail.trim() || isInviting}
|
||||
>
|
||||
{isInviting ? 'Sending...' : 'Send Invitation'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showJoinRequestsModal && (
|
||||
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-2xl w-full p-6 max-h-[80vh] overflow-y-auto">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Join Requests ({pendingJoinRequests.length})
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowJoinRequestsModal(false)}
|
||||
className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 text-2xl cursor-pointer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{pendingJoinRequests.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-lg">
|
||||
No pending join requests
|
||||
</p>
|
||||
<p className="text-gray-500 dark:text-gray-500 text-sm mt-2 font-sans">
|
||||
When users request to join your team, they'll appear here
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{pendingJoinRequests.map((request: any) => (
|
||||
<div
|
||||
key={request.id}
|
||||
className="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start space-x-3 flex-1">
|
||||
{request.user?.avatar ? (
|
||||
<img
|
||||
src={request.user.avatar}
|
||||
alt={request.user.fullname}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center shrink-0">
|
||||
<span className="text-gray-500 dark:text-gray-400 text-xl">
|
||||
<Icon
|
||||
icon="mdi:account-circle"
|
||||
className="text-3xl"
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="font-semibold text-gray-900 dark:text-white">
|
||||
{request.user?.fullname || 'Unknown User'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{request.user?.email}
|
||||
</p>
|
||||
{request.message && (
|
||||
<div className="mt-2 bg-gray-50 dark:bg-gray-800 rounded-lg p-3">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300">
|
||||
<strong>Message:</strong> {request.message}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500 mt-2 font-sans">
|
||||
Requested{' '}
|
||||
{new Date(request.created_at).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 mt-4 justify-end">
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleRespondToJoinRequest(request.id, 'approve')
|
||||
}
|
||||
disabled={
|
||||
isResponding || members.length >= MAX_TEAM_MEMBERS
|
||||
}
|
||||
className="px-4 py-2 text-sm"
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() =>
|
||||
handleRespondToJoinRequest(request.id, 'reject')
|
||||
}
|
||||
disabled={isResponding}
|
||||
variant="secondary"
|
||||
className="px-4 py-2 text-sm"
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{members.length >= MAX_TEAM_MEMBERS && (
|
||||
<div className="mt-3 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-300 font-sans">
|
||||
Team is full ({MAX_TEAM_MEMBERS}/{MAX_TEAM_MEMBERS}{' '}
|
||||
members). Remove a member before accepting new
|
||||
requests.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showLeaveModal && (
|
||||
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<div className="text-center 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:exit-run"
|
||||
className="text-3xl text-red-600 dark:text-red-400"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Leave Team?
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Are you sure you want to leave <strong>{team?.name}</strong>?
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-300 font-sans">
|
||||
<strong>Warning:</strong> If you leave, you will need to request to join again or be re-invited by the team leader.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => setShowLeaveModal(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleLeaveTeam}
|
||||
className="flex-1 bg-red-600 hover:bg-red-700"
|
||||
disabled={isLeaving}
|
||||
>
|
||||
{isLeaving ? 'Leaving...' : 'Leave Team'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDeleteModal && (
|
||||
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<div className="text-center 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:delete-alert"
|
||||
className="text-3xl text-red-600 dark:text-red-400"
|
||||
/>
|
||||
</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Delete Team?
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
This action is <strong className="text-red-600 dark:text-red-400">permanent</strong> and cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm text-red-800 dark:text-red-300 font-sans">
|
||||
<strong>Warning:</strong> All team data, chat messages, and submissions will be permanently deleted.
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Type <span className="font-bold text-red-600 dark:text-red-400">{team?.name}</span> to confirm:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={deleteConfirmText}
|
||||
onChange={(e) => setDeleteConfirmText(e.target.value)}
|
||||
placeholder="Type team name here"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-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"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setShowDeleteModal(false);
|
||||
setDeleteConfirmText('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleDeleteTeam}
|
||||
className="flex-1 bg-red-600 hover:bg-red-700"
|
||||
disabled={isDeleting || deleteConfirmText !== team?.name}
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete Team'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { FC, ReactElement, useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import {
|
||||
useTeamById,
|
||||
useTeamMessages,
|
||||
useSendMessage,
|
||||
useDeleteMessage,
|
||||
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 } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
const { session } = useAuthStore();
|
||||
const [message, setMessage] = useState('');
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data: teamData } = useTeamById(teamId || '');
|
||||
const { data: messages, isLoading } = useTeamMessages(teamId || '');
|
||||
const { mutateAsync: sendMessage, isPending: isSending } = useSendMessage(
|
||||
teamId || ''
|
||||
);
|
||||
const { mutateAsync: deleteMessage } = useDeleteMessage(teamId || '');
|
||||
|
||||
const team = teamData?.data;
|
||||
const currentUserId = session?.user?.id;
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
user_id: string;
|
||||
user?: {
|
||||
avatar?: string;
|
||||
fullname?: string;
|
||||
};
|
||||
message: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
|
||||
const displayMessages = useMemo(() => {
|
||||
const raw: ChatMessage[] = Array.isArray(messages)
|
||||
? (messages as ChatMessage[])
|
||||
: [];
|
||||
const seen = new Set<string>();
|
||||
const dedup: ChatMessage[] = [];
|
||||
for (const m of raw) {
|
||||
const key = m.id ?? `${m.user_id}-${m.created_at}`;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
dedup.push(m);
|
||||
}
|
||||
}
|
||||
dedup.sort(
|
||||
(a, b) =>
|
||||
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
|
||||
);
|
||||
return dedup;
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [displayMessages]);
|
||||
|
||||
const handleSendMessage = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!message.trim() || isSending) return;
|
||||
|
||||
try {
|
||||
await sendMessage(message.trim());
|
||||
setMessage('');
|
||||
} catch (error) {
|
||||
console.error('Failed to send message:', error);
|
||||
toast.error('Failed to send message');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMessage = async (messageId: string) => {
|
||||
try {
|
||||
await deleteMessage(messageId);
|
||||
toast.success('Message deleted');
|
||||
setDeleteTargetId(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete message:', error);
|
||||
toast.error('Failed to delete message');
|
||||
}
|
||||
};
|
||||
|
||||
const formatTime = (timestamp: string) => {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const diffInMs = now.getTime() - date.getTime();
|
||||
const diffInMins = Math.floor(diffInMs / 60000);
|
||||
|
||||
if (diffInMins < 1) return 'Just now';
|
||||
if (diffInMins < 60) return `${diffInMins}m ago`;
|
||||
if (diffInMins < 1440) return `${Math.floor(diffInMins / 60)}h ago`;
|
||||
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 shadow-sm dark:shadow-gray-950/50">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
Team Chat
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm mt-0.5">
|
||||
{team?.name}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => navigate({ to: `/teams/${teamId}` })}
|
||||
>
|
||||
Back to Team
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 dark:border-primary-400"></div>
|
||||
</div>
|
||||
) : displayMessages && displayMessages.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{displayMessages.map((msg) => {
|
||||
const isOwnMessage = msg.user_id === currentUserId;
|
||||
const isLeader = team?.leader_id === currentUserId;
|
||||
const canDelete = isOwnMessage || isLeader;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${msg.id ?? 'message'}-${msg.created_at}`}
|
||||
className={`flex ${
|
||||
isOwnMessage ? 'justify-end' : 'justify-start'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex gap-3 max-w-lg ${
|
||||
isOwnMessage ? 'flex-row-reverse' : 'flex-row'
|
||||
}`}
|
||||
>
|
||||
<div className="shrink-0">
|
||||
{msg.user?.avatar ? (
|
||||
<img
|
||||
src={msg.user.avatar}
|
||||
alt={msg.user.fullname}
|
||||
className="w-10 h-10 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-blue-600 flex items-center justify-center text-white font-semibold">
|
||||
{msg.user?.fullname?.charAt(0) || '?'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`flex-1 text-left`}>
|
||||
<div
|
||||
className={`inline-block ${
|
||||
isOwnMessage ? 'items-end' : 'items-start'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-baseline gap-2 mb-1">
|
||||
<span className="font-semibold text-sm text-gray-900 dark:text-white">
|
||||
{isOwnMessage ? 'You' : msg.user?.fullname}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-500">
|
||||
{formatTime(msg.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className={`relative group rounded-2xl px-4 py-2.5 ${
|
||||
isOwnMessage
|
||||
? 'bg-blue-600 dark:bg-primary-600 text-white'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-900 dark:text-white border border-gray-200 dark:border-gray-700'
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm whitespace-pre-wrap wrap-break-word font-sans">
|
||||
{msg.message}
|
||||
</p>
|
||||
|
||||
{canDelete && (
|
||||
<button
|
||||
onClick={() =>
|
||||
setDeleteTargetId(
|
||||
deleteTargetId === msg.id ? null : msg.id
|
||||
)
|
||||
}
|
||||
className={`absolute top-1 ${
|
||||
isOwnMessage ? 'left-1' : 'right-1'
|
||||
} opacity-0 hover:opacity-100 p-1 rounded hover:bg-gray-200 dark:hover:bg-neutral-700 cursor-pointer ${
|
||||
isOwnMessage
|
||||
? 'hover:bg-blue-700 dark:hover:bg-primary-700'
|
||||
: ''
|
||||
}`}
|
||||
title="Delete message"
|
||||
>
|
||||
<Icon
|
||||
icon="mdi:trash-can-outline"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{canDelete && deleteTargetId === msg.id && (
|
||||
<div
|
||||
className={`mt-2 flex gap-2 ${
|
||||
isOwnMessage ? 'justify-end' : 'justify-start'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => handleDeleteMessage(msg.id)}
|
||||
className="px-2 py-1 text-xs rounded bg-red-600 text-white hover:bg-red-700 cursor-pointer"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteTargetId(null)}
|
||||
className="px-2 py-1 text-xs rounded bg-gray-200 text-gray-800 hover:bg-gray-300 dark:bg-neutral-700 dark:text-white dark:hover:bg-neutral-600 cursor-pointer"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-center">
|
||||
<Icon
|
||||
icon="mdi:chat-outline"
|
||||
className="text-6xl mb-4 text-gray-400"
|
||||
/>
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-2">
|
||||
No messages yet
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-gray-400 font-sans">
|
||||
Be the first to start the conversation!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 border-t dark:border-gray-700 shadow-lg dark:shadow-gray-950/50">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||
<form onSubmit={handleSendMessage} className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Type your message..."
|
||||
className="flex-1 px-4 py-3 border border-gray-300 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-primary-500 focus:border-transparent"
|
||||
disabled={isSending}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!message.trim() || isSending}
|
||||
className="px-6 py-3"
|
||||
>
|
||||
{isSending ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white"></div>
|
||||
Sending...
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-5 h-5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"
|
||||
/>
|
||||
</svg>
|
||||
Send
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,386 @@
|
||||
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 { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import {
|
||||
teamUpdateSchema,
|
||||
TTeamUpdateForm,
|
||||
useUpdateTeam,
|
||||
useTeamById,
|
||||
ETeamVisibility,
|
||||
useUploadFile,
|
||||
useAuthStore } from '@imphnen-frontend-service/service';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
|
||||
import { 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 } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
const { session } = useAuthStore();
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string>('');
|
||||
const [bannerFile, setBannerFile] = useState<File | null>(null);
|
||||
const [bannerPreview, setBannerPreview] = useState<string>('');
|
||||
|
||||
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
|
||||
teamId || ''
|
||||
);
|
||||
const { mutateAsync: updateTeam, isPending: isUpdating } = useUpdateTeam(
|
||||
teamId || ''
|
||||
);
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
|
||||
const team = teamData?.data;
|
||||
const currentUserId = session?.user?.id;
|
||||
const isLeader = currentUserId === team?.leader_id;
|
||||
|
||||
const form = useForm<TTeamUpdateForm>({
|
||||
resolver: zodResolver(teamUpdateSchema),
|
||||
mode: 'all' });
|
||||
|
||||
useEffect(() => {
|
||||
if (team) {
|
||||
form.reset({
|
||||
name: team.name,
|
||||
description: team.description,
|
||||
city: team.city,
|
||||
visibility: team.visibility,
|
||||
logo: team.logo,
|
||||
banner: team.banner });
|
||||
if (team.logo) setLogoPreview(team.logo);
|
||||
if (team.banner) setBannerPreview(team.banner);
|
||||
}
|
||||
}, [team, form]);
|
||||
|
||||
if (isLoadingTeam) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-gray-600 dark:text-gray-400">Loading team...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLeader) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Access Denied
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Only the team leader can edit team information
|
||||
</p>
|
||||
<Button onClick={() => navigate({ to: `/teams/${teamId}` })}>
|
||||
Back to Team
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error('Logo image is too large. Maximum size is 2MB.');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
setLogoFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setLogoPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error('Banner image is too large. Maximum size is 2MB.');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
setBannerFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setBannerPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = form.handleSubmit(async (data) => {
|
||||
try {
|
||||
let logoUrl = data.logo;
|
||||
let bannerUrl = data.banner;
|
||||
|
||||
if (logoFile) {
|
||||
const logoResult = await uploadFile(logoFile);
|
||||
logoUrl = logoResult.data.url;
|
||||
}
|
||||
|
||||
if (bannerFile) {
|
||||
const bannerResult = await uploadFile(bannerFile);
|
||||
bannerUrl = bannerResult.data.url;
|
||||
}
|
||||
|
||||
await updateTeam({
|
||||
...data,
|
||||
logo: logoUrl,
|
||||
banner: bannerUrl });
|
||||
|
||||
navigate({ to: `/teams/${teamId}` });
|
||||
} catch (error) {
|
||||
console.error('Failed to update team:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Edit Team Info
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Update your team details
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-8">
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Team Banner
|
||||
</label>
|
||||
{bannerPreview ? (
|
||||
<div
|
||||
className="relative w-full"
|
||||
style={{ aspectRatio: '3 / 1' }}
|
||||
>
|
||||
<img
|
||||
src={bannerPreview}
|
||||
alt="Banner preview"
|
||||
className="absolute inset-0 w-full h-full object-cover rounded-lg border-2 border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setBannerFile(null);
|
||||
setBannerPreview('');
|
||||
form.setValue('banner', null);
|
||||
}}
|
||||
className="absolute top-2 right-2 bg-danger-600 text-white px-3 py-1 rounded-lg text-sm hover:bg-danger-700 cursor-pointer"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Click to upload banner
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
1200x400 recommended. Max 2MB
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleBannerChange}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Team Logo
|
||||
</label>
|
||||
<div className="flex items-center space-x-4">
|
||||
{logoPreview ? (
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt="Logo preview"
|
||||
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-gray-400 dark:text-gray-500 text-3xl">
|
||||
<Icon icon="mdi:account-group" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="flex flex-col md:flex-row items-start justify-start gap-1">
|
||||
<label htmlFor="logo" className="cursor-pointer">
|
||||
<span className="px-4 py-2 text-sm bg-primary-600 text-white rounded-lg hover:bg-primary-700 inline-block">
|
||||
{logoPreview ? 'Change Logo' : 'Upload Logo'}
|
||||
</span>
|
||||
<input
|
||||
id="logo"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleLogoChange}
|
||||
/>
|
||||
</label>
|
||||
{logoPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLogoFile(null);
|
||||
setLogoPreview('');
|
||||
form.setValue('logo', null);
|
||||
}}
|
||||
className="px-4 py-2 text-sm bg-danger-600 text-white rounded-lg hover:bg-danger-700 cursor-pointer"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-2">Max 2MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Team Name"
|
||||
placeholder="Enter team name"
|
||||
name="name"
|
||||
size="lg"
|
||||
isRequired={true}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
City
|
||||
</label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="city"
|
||||
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-gray-300">
|
||||
Description
|
||||
</label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<Textarea
|
||||
{...field}
|
||||
value={field.value || ''}
|
||||
placeholder="Tell others about your team..."
|
||||
rows={4}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<p className="text-sm text-red-500 mt-1">
|
||||
{fieldState.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-label1 font-medium text-gray-700 dark:text-gray-300">
|
||||
Team Visibility
|
||||
</label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="visibility"
|
||||
render={({ field }) => (
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-start space-x-3 cursor-pointer border dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<input
|
||||
type="radio"
|
||||
{...field}
|
||||
value={ETeamVisibility.PUBLIC}
|
||||
checked={field.value === ETeamVisibility.PUBLIC}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
Public
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 font-sans">
|
||||
Team will be visible in Browse Teams
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-start space-x-3 cursor-pointer border dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<input
|
||||
type="radio"
|
||||
{...field}
|
||||
value={ETeamVisibility.PRIVATE}
|
||||
checked={field.value === ETeamVisibility.PRIVATE}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
Private
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 font-sans">
|
||||
Team hidden, invite-only
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => navigate({ to: `/teams/${teamId}` })}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={isUpdating || isUploading}
|
||||
>
|
||||
{isUpdating || isUploading ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,381 @@
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import {
|
||||
useTeamById,
|
||||
useTeamMembers,
|
||||
useTeamJoinRequests,
|
||||
useInviteMember,
|
||||
useRemoveMember,
|
||||
useRespondToJoinRequest,
|
||||
ETeamMemberStatus,
|
||||
inviteMemberSchema,
|
||||
TInviteMemberForm,
|
||||
useAuthStore } from '@imphnen-frontend-service/service';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export const Route = createFileRoute('/_authenticated/teams/$teamId/members')({
|
||||
component: ManageMembersPage })
|
||||
|
||||
const ManageMembersPage: FC = (): ReactElement => {
|
||||
const { teamId } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
const { session } = useAuthStore();
|
||||
const [showInviteModal, setShowInviteModal] = useState(false);
|
||||
|
||||
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
|
||||
teamId || ''
|
||||
);
|
||||
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(
|
||||
teamId || ''
|
||||
);
|
||||
const { data: joinRequestsData } = useTeamJoinRequests(teamId || '');
|
||||
|
||||
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(
|
||||
teamId || ''
|
||||
);
|
||||
const { mutateAsync: removeMember, isPending: isRemoving } = useRemoveMember(
|
||||
teamId || ''
|
||||
);
|
||||
const { mutateAsync: respondToRequest, isPending: isResponding } =
|
||||
useRespondToJoinRequest(teamId || '');
|
||||
|
||||
const team = teamData?.data;
|
||||
const members = membersData?.data || [];
|
||||
const joinRequests = Array.isArray(joinRequestsData?.data)
|
||||
? joinRequestsData.data
|
||||
: [];
|
||||
const currentUserId = session?.user?.id;
|
||||
const isLeader = currentUserId === team?.leader_id;
|
||||
const hasSubmission = team?.has_submission;
|
||||
|
||||
const form = useForm<TInviteMemberForm>({
|
||||
resolver: zodResolver(inviteMemberSchema),
|
||||
mode: 'all' });
|
||||
|
||||
if (isLoadingTeam) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<p className="text-gray-600 dark:text-gray-400">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLeader) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Access Denied
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Only the team leader can manage members
|
||||
</p>
|
||||
<Button onClick={() => navigate({ to: `/teams/${teamId}` })}>
|
||||
Back to Team
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleInvite = form.handleSubmit(async (data) => {
|
||||
try {
|
||||
await inviteMember(data);
|
||||
setShowInviteModal(false);
|
||||
form.reset();
|
||||
} catch (error) {
|
||||
console.error('Failed to invite member:', error);
|
||||
}
|
||||
});
|
||||
|
||||
const handleRemove = async (userId: string) => {
|
||||
// eslint-disable-next-line no-restricted-globals
|
||||
if (confirm('Are you sure you want to remove this member?')) {
|
||||
try {
|
||||
await removeMember(userId);
|
||||
} catch (error) {
|
||||
console.error('Failed to remove member:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleApproveRequest = async (requestId: string) => {
|
||||
try {
|
||||
await respondToRequest({ requestId, action: 'approve' });
|
||||
toast.success('Member added successfully!');
|
||||
} catch (error) {
|
||||
console.error('Failed to approve request:', error);
|
||||
toast.error((error as Error).message || 'Failed to approve request');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectRequest = async (requestId: string) => {
|
||||
try {
|
||||
await respondToRequest({ requestId, action: 'reject' });
|
||||
toast.success('Request rejected');
|
||||
} catch (error) {
|
||||
console.error('Failed to reject request:', error);
|
||||
toast.error((error as Error).message || 'Failed to reject request');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex flex-col md:flex-row gap-y-4 items-start md:items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Manage Members
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{team?.name}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
onClick={() => setShowInviteModal(true)}
|
||||
disabled={hasSubmission}
|
||||
title={hasSubmission ? 'Cannot invite members after project submission' : undefined}
|
||||
>
|
||||
Invite Member
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => navigate({ to: `/teams/${teamId}` })}
|
||||
>
|
||||
Back to Team
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8 space-y-6">
|
||||
{hasSubmission && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-500 rounded-lg p-4">
|
||||
<div className="flex items-start space-x-3">
|
||||
<span className="text-2xl">🔒</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-amber-900 dark:text-amber-100">
|
||||
Team Locked
|
||||
</h3>
|
||||
<p className="text-amber-800 dark:text-amber-200 text-sm font-sans mt-1">
|
||||
Your team has submitted a project. You cannot add or remove members after submission to maintain competition integrity.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{joinRequests.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Join Requests ({joinRequests.length})
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{joinRequests.map((request) => (
|
||||
<div
|
||||
key={request.id}
|
||||
className="border dark:border-gray-700 rounded-lg p-4"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3 flex-1">
|
||||
{request.user.avatar ? (
|
||||
<img
|
||||
src={request.user.avatar}
|
||||
alt={request.user.fullname}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
👤
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{request.user.fullname}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{request.user.email}
|
||||
</p>
|
||||
{request.user.location && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500">
|
||||
📍 {request.user.location}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 mt-2 italic">
|
||||
"{request.message}"
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex space-x-2 ml-4">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleApproveRequest(request.id)}
|
||||
disabled={isResponding || hasSubmission}
|
||||
title={hasSubmission ? 'Cannot accept members after project submission' : undefined}
|
||||
>
|
||||
Approve
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleRejectRequest(request.id)}
|
||||
disabled={isResponding}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-6">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Current Members ({members.length})
|
||||
</h2>
|
||||
{isLoadingMembers ? (
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Loading members...
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{members.map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="border dark:border-gray-700 rounded-lg p-4 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
{member.user.avatar ? (
|
||||
<img
|
||||
src={member.user.avatar}
|
||||
alt={member.user.fullname}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
👤
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{member.user.fullname}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{member.user.email}
|
||||
</p>
|
||||
{member.user.location && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500">
|
||||
📍 {member.user.location}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
{member.role === 'leader' && (
|
||||
<span className="px-2 py-1 bg-blue-100 dark:bg-primary-900/30 text-blue-800 dark:text-primary-300 rounded text-xs font-medium">
|
||||
Leader
|
||||
</span>
|
||||
)}
|
||||
{member.status === ETeamMemberStatus.PENDING && (
|
||||
<span className="px-2 py-1 bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300 rounded text-xs font-medium">
|
||||
Pending Invitation
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{member.role !== 'leader' && !hasSubmission && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleRemove(member.user_id)}
|
||||
disabled={isRemoving}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!hasSubmission && (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<strong>Note:</strong> Members cannot leave the team without your
|
||||
approval. Only you can remove members from the team.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showInviteModal && (
|
||||
<div className="fixed inset-0 bg-black/20 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 max-w-md w-full p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Invite Member
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4 font-sans">
|
||||
Send an invitation to join your team. The invited member will see
|
||||
the invitation on their dashboard after logging in.
|
||||
</p>
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3 mb-6">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200 font-sans">
|
||||
<strong>Important:</strong> The email you enter must match the
|
||||
GitHub email address the member uses to sign in.
|
||||
</p>
|
||||
</div>
|
||||
<form onSubmit={handleInvite} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Email Address
|
||||
</label>
|
||||
<Input
|
||||
{...form.register('email')}
|
||||
type="email"
|
||||
placeholder="member@example.com"
|
||||
size="lg"
|
||||
/>
|
||||
{form.formState.errors.email && (
|
||||
<p className="text-sm text-red-500 mt-1">
|
||||
{form.formState.errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setShowInviteModal(false);
|
||||
form.reset();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={!form.formState.isValid || isInviting}
|
||||
>
|
||||
{isInviting ? 'Sending...' : 'Send Invitation'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
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 } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
const { session } = useAuthStore();
|
||||
|
||||
const { data: teamData } = useTeamById(teamId || '');
|
||||
const { data: submissionData, isLoading } = useTeamSubmission(teamId || '', !!teamId);
|
||||
|
||||
const team = teamData?.data;
|
||||
const submission = submissionData?.data;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-gray-600 dark:text-gray-400">Loading submission...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!submission) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<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({ to: `/teams/${teamId}` })}>Back to Team</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const submittedDate = submission.submitted_at
|
||||
? new Date(submission.submitted_at).toLocaleString('id-ID', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit' })
|
||||
: 'Not submitted';
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 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({ to: `/teams/${teamId}` })}>
|
||||
Back to Team
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
|
||||
{submission.status === 'submitted' ? (
|
||||
<div className="bg-green-50 dark:bg-green-900/20 border border-green-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-4xl">✅</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-green-900 dark:text-green-100 text-lg">Project Submitted Successfully</h3>
|
||||
<p className="text-green-700 dark:text-green-300 text-sm">
|
||||
Submitted on {submittedDate}
|
||||
</p>
|
||||
<p className="text-green-600 dark:text-green-400 text-xs mt-1">
|
||||
This submission is now read-only and cannot be edited
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : submission.status === 'pending_verification' ? (
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-4xl">⏳</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-yellow-900 dark:text-yellow-100 text-lg">Submission Pending Verification</h3>
|
||||
<p className="text-yellow-700 dark:text-yellow-300 text-sm">
|
||||
Your submission is being processed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-orange-50 dark:bg-orange-900/20 border border-orange-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center space-x-3">
|
||||
<span className="text-4xl">📝</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-orange-900 dark:text-orange-100 text-lg">Draft Submission</h3>
|
||||
<p className="text-orange-700 dark:text-orange-300 text-sm">
|
||||
This submission is still in draft and has not been finalized
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{submission.status === 'submitted' && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-400 dark:border-amber-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
||||
<span className="text-4xl shrink-0">🏆</span>
|
||||
<div className="min-w-0">
|
||||
<h3 className="font-bold text-amber-900 dark:text-amber-100 text-lg">
|
||||
View Your Certificate
|
||||
</h3>
|
||||
<p className="text-amber-700 dark:text-amber-300 text-sm">
|
||||
Congratulations! Your personalized certificate is ready to download and share.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={async () => {
|
||||
const certId = await encodeCertificateId(
|
||||
teamId || '',
|
||||
submission.id,
|
||||
session?.user?.id || ''
|
||||
);
|
||||
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"
|
||||
>
|
||||
Get Certificate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 overflow-hidden">
|
||||
|
||||
<div className="bg-linear-to-r from-blue-600 to-blue-800 text-white p-8">
|
||||
<h2 className="text-3xl font-bold mb-2">{submission.project_name}</h2>
|
||||
<p className="text-blue-100">Team: {team?.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-8 space-y-6">
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Project Description</h3>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4">
|
||||
<p className="text-gray-700 dark:text-gray-300 whitespace-pre-wrap">{submission.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Repository</h3>
|
||||
<a
|
||||
href={submission.repository_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center space-x-2 text-blue-600 dark:text-primary-400 hover:text-blue-800 dark:hover:text-primary-300"
|
||||
>
|
||||
<span>🔗</span>
|
||||
<span className="break-all">{submission.repository_url}</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{submission.demo_url && (
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">Live Demo</h3>
|
||||
<a
|
||||
href={submission.demo_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center space-x-2 text-blue-600 dark:text-primary-400 hover:text-blue-800 dark:hover:text-primary-300"
|
||||
>
|
||||
<span>🌐</span>
|
||||
<span className="break-all">{submission.demo_url}</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{submission.screenshots && submission.screenshots.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white mb-3">
|
||||
Screenshots ({submission.screenshots.length})
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{submission.screenshots.map((url, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block"
|
||||
>
|
||||
<img
|
||||
src={url}
|
||||
alt={`Screenshot ${index + 1}`}
|
||||
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 dark:border-gray-700 hover:border-blue-500 dark:hover:border-primary-500 transition-colors cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border-t-4 border-blue-600 dark:border-primary-500">
|
||||
<h3 className="text-sm font-bold text-gray-900 dark:text-white mb-2">Submission Information</h3>
|
||||
<div className="grid gap-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Status:</span>
|
||||
<span className={`font-medium ${
|
||||
submission.status === 'submitted'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: submission.status === 'pending_verification'
|
||||
? 'text-yellow-600 dark:text-yellow-400'
|
||||
: 'text-orange-600 dark:text-orange-400'
|
||||
}`}>
|
||||
{submission.status === 'submitted'
|
||||
? '✓ Submitted'
|
||||
: submission.status === 'pending_verification'
|
||||
? '⏳ Pending Verification'
|
||||
: '📝 Draft'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Submitted:</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{submittedDate}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-600 dark:text-gray-400">Submission ID:</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white font-mono text-xs">
|
||||
{submission.id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<strong>Note:</strong> This submission is now locked and cannot be edited or deleted.
|
||||
If you need to make changes, please contact the hackathon organizers.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
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 { createFileRoute, useNavigate } from '@tanstack/react-router';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import {
|
||||
projectSubmissionSchema,
|
||||
TProjectSubmissionForm,
|
||||
useSubmitProject,
|
||||
useTeamById,
|
||||
useTeamSubmission,
|
||||
useUploadSubmission,
|
||||
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 } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
const { session } = useAuthStore();
|
||||
const [showConfirmModal, setShowConfirmModal] = useState(false);
|
||||
const [confirmText, setConfirmText] = useState('');
|
||||
const [screenshots, setScreenshots] = useState<string[]>([]);
|
||||
const [timeLeft, setTimeLeft] = useState<{
|
||||
days: number;
|
||||
hours: number;
|
||||
minutes: number;
|
||||
seconds: number;
|
||||
} | null>(null);
|
||||
|
||||
const isDeadlinePassed = new Date() >= SUBMISSION_DEADLINE;
|
||||
|
||||
useEffect(() => {
|
||||
if (isDeadlinePassed) return;
|
||||
|
||||
const calculateTimeLeft = () => {
|
||||
const now = new Date();
|
||||
const difference = SUBMISSION_DEADLINE.getTime() - now.getTime();
|
||||
|
||||
if (difference <= 0) {
|
||||
setTimeLeft(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const days = Math.floor(difference / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((difference / (1000 * 60 * 60)) % 24);
|
||||
const minutes = Math.floor((difference / 1000 / 60) % 60);
|
||||
const seconds = Math.floor((difference / 1000) % 60);
|
||||
|
||||
setTimeLeft({ days, hours, minutes, seconds });
|
||||
};
|
||||
|
||||
calculateTimeLeft();
|
||||
const timer = setInterval(calculateTimeLeft, 1000);
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [isDeadlinePassed]);
|
||||
|
||||
const { data: teamData } = useTeamById(teamId || '');
|
||||
const { data: submissionData } = useTeamSubmission(teamId || '', !!teamId);
|
||||
const { mutateAsync: submitProject, isPending: isSubmitting } =
|
||||
useSubmitProject(teamId || '');
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadSubmission();
|
||||
|
||||
const team = teamData?.data;
|
||||
const currentUserId = session?.user?.id;
|
||||
const isLeader = currentUserId === team?.leader_id;
|
||||
const hasSubmission = !!submissionData?.data;
|
||||
const memberCount = team?.members?.length || 0;
|
||||
const hasEnoughMembers = memberCount >= MIN_TEAM_MEMBERS;
|
||||
|
||||
const form = useForm<TProjectSubmissionForm>({
|
||||
resolver: zodResolver(projectSubmissionSchema),
|
||||
mode: 'all' });
|
||||
|
||||
if (!isLeader) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Access Denied
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Only the team leader can submit projects
|
||||
</p>
|
||||
<Button onClick={() => navigate({ to: `/teams/${teamId}` })}>
|
||||
Back to Team
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (hasSubmission) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-6xl mb-4">✅</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Project Already Submitted
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-4">
|
||||
Your team has already submitted a project
|
||||
</p>
|
||||
<div className="flex space-x-3">
|
||||
<Button onClick={() => navigate({ to: `/teams/${teamId}/submission` })}>
|
||||
View Submission
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => navigate({ to: `/teams/${teamId}` })}
|
||||
>
|
||||
Back to Team
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDeadlinePassed) {
|
||||
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">
|
||||
Submission Closed
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Project submissions are no longer accepted.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
The submission deadline was December 7, 2025 at 23:59 WIB.
|
||||
</p>
|
||||
|
||||
<button
|
||||
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({ 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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleScreenshotUpload = async (
|
||||
e: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const files = e.target.files;
|
||||
if (!files) return;
|
||||
|
||||
const oversizedFiles = Array.from(files).filter(file => file.size > MAX_FILE_SIZE);
|
||||
if (oversizedFiles.length > 0) {
|
||||
toast.error(`${oversizedFiles.length} file(s) are too large. Maximum size is 2MB per file.`);
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const uploadPromises = Array.from(files).map((file) => uploadFile(file));
|
||||
const results = await Promise.all(uploadPromises);
|
||||
const urls = results.map((r) => r.data.url);
|
||||
setScreenshots([...screenshots, ...urls]);
|
||||
} catch (error) {
|
||||
console.error('Failed to upload screenshots:', error);
|
||||
toast.error('Failed to upload screenshots. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const removeScreenshot = (index: number) => {
|
||||
setScreenshots(screenshots.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const onSubmit = form.handleSubmit(async (data) => {
|
||||
try {
|
||||
await submitProject({
|
||||
...data,
|
||||
screenshots });
|
||||
navigate({ to: `/teams/${teamId}/submission` });
|
||||
} catch (error) {
|
||||
console.error('Failed to submit project:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Submit Project
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">{team?.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{timeLeft && (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<span className="text-3xl">⏰</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-blue-900 dark:text-blue-100 text-lg">
|
||||
Submission Deadline
|
||||
</h3>
|
||||
<p className="text-blue-800 dark:text-blue-200 mt-2 text-sm font-sans">
|
||||
Submissions close on December 7, 2025 at 23:59 WIB
|
||||
</p>
|
||||
<div className="mt-4 grid grid-cols-4 gap-4">
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{timeLeft.days}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Days
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{timeLeft.hours.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Hours
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{timeLeft.minutes.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Minutes
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{timeLeft.seconds.toString().padStart(2, '0')}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
Seconds
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!hasEnoughMembers && (
|
||||
<div className="bg-amber-50 dark:bg-amber-900/20 border-2 border-amber-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<span className="text-3xl">👥</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-amber-900 dark:text-amber-100 text-lg">
|
||||
Team Members Required
|
||||
</h3>
|
||||
<p className="text-amber-800 dark:text-amber-200 mt-2 text-sm font-sans">
|
||||
Your team needs at least <strong>{MIN_TEAM_MEMBERS} members</strong> to submit a project.
|
||||
Currently you have <strong>{memberCount} member{memberCount !== 1 ? 's' : ''}</strong>.
|
||||
Please invite more members to your team before submitting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-red-50 dark:bg-red-900/20 border-2 border-red-500 rounded-lg p-6 mb-6">
|
||||
<div className="flex items-start space-x-3">
|
||||
<span className="text-3xl">⚠️</span>
|
||||
<div>
|
||||
<h3 className="font-bold text-red-900 dark:text-red-100 text-lg">
|
||||
IMPORTANT WARNING
|
||||
</h3>
|
||||
<ul className="text-red-800 dark:text-red-200 mt-2 space-y-1 text-sm font-sans">
|
||||
<li>• You can only submit your project ONCE</li>
|
||||
<li>• After submission, you CANNOT edit or change anything</li>
|
||||
<li>
|
||||
• Make sure all information is correct before submitting
|
||||
</li>
|
||||
<li>• Review your project details carefully</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-8 border dark:border-gray-800">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
setShowConfirmModal(true);
|
||||
}}
|
||||
className="space-y-6"
|
||||
>
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Project Name"
|
||||
placeholder="Enter your project name"
|
||||
name="project_name"
|
||||
size="lg"
|
||||
isRequired={true}
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-[15px] font-medium text-gray-700 dark:text-gray-300">
|
||||
Project Description <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
Describe your project, its features, and what problem it solves. You can also paste your demo video link here.
|
||||
</p>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="Describe your project, its features, and what problem it solves... You can paste your demo video link (YouTube, Loom, etc.) here as well."
|
||||
rows={6}
|
||||
className="w-full"
|
||||
size="lg"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<p className="text-sm text-red-500 mt-1">
|
||||
{fieldState.error.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Repository URL (GitHub, GitLab, etc.)"
|
||||
placeholder="https://github.com/username/project"
|
||||
name="repository_url"
|
||||
type="url"
|
||||
size="lg"
|
||||
isRequired={true}
|
||||
/>
|
||||
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Demo URL (Optional)"
|
||||
placeholder="https://your-project-demo.com"
|
||||
name="demo_url"
|
||||
type="url"
|
||||
size="lg"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<label className="block text-label1 font-medium text-neutral-800 dark:text-gray-300">
|
||||
Project Screenshots (Optional)
|
||||
</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-4 mb-4">
|
||||
{screenshots.map((url, index) => (
|
||||
<div key={index} className="relative">
|
||||
<img
|
||||
src={url}
|
||||
alt={`Screenshot ${index + 1}`}
|
||||
className="w-full h-32 object-cover rounded-lg border-2 border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeScreenshot(index)}
|
||||
className="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-sm hover:bg-red-600"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
Click to upload screenshots
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mt-1 font-sans">
|
||||
PNG, JPG. Max 2MB each
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleScreenshotUpload}
|
||||
disabled={isUploading}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => navigate({ to: `/teams/${teamId}` })}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={!form.formState.isValid || isUploading || !hasEnoughMembers}
|
||||
>
|
||||
Review & Submit
|
||||
</Button>
|
||||
</div>
|
||||
{!hasEnoughMembers && (
|
||||
<p className="text-center text-sm text-amber-600 dark:text-amber-400 mt-2">
|
||||
You need at least {MIN_TEAM_MEMBERS} team members to submit
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showConfirmModal && (
|
||||
<div className="fixed inset-0 bg-black/50 dark:bg-black/70 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 max-w-md w-full p-6">
|
||||
<div className="text-center mb-6">
|
||||
<div className="text-5xl mb-4">⚠️</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Final Confirmation
|
||||
</h2>
|
||||
<p className="text-red-600 dark:text-red-400 font-medium">
|
||||
This action is IRREVERSIBLE!
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 mb-6">
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
||||
By submitting, you confirm that:
|
||||
</p>
|
||||
<ul className="text-sm text-gray-600 dark:text-gray-400 space-y-2">
|
||||
<li>✓ All information is correct and complete</li>
|
||||
<li>✓ You understand this can only be done once</li>
|
||||
<li>✓ You cannot edit after submission</li>
|
||||
<li>✓ Your team agrees with this submission</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Type <span className="font-bold text-red-600 dark:text-red-400">SUBMIT</span> to confirm:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder="Type SUBMIT here"
|
||||
className="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-red-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"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setShowConfirmModal(false);
|
||||
setConfirmText('');
|
||||
}}
|
||||
>
|
||||
Go Back
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onSubmit}
|
||||
className="flex-1 bg-red-600 hover:bg-red-700"
|
||||
disabled={isSubmitting || confirmText !== 'SUBMIT'}
|
||||
>
|
||||
{isSubmitting ? 'Submitting...' : 'Submit Project'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
import { FC, ReactElement, useState, useEffect, useCallback } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router';
|
||||
import {
|
||||
useTeams,
|
||||
useJoinTeam,
|
||||
useMyTeams,
|
||||
ETeamVisibility,
|
||||
joinTeamSchema,
|
||||
TJoinTeamForm } from '@imphnen-frontend-service/service';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { 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];
|
||||
|
||||
const TEAM_FEATURES_DEADLINE = new Date('2025-11-30T16:59:00Z');
|
||||
|
||||
const MEMBER_FILTER_OPTIONS = [
|
||||
{ label: 'All Teams', value: '', minMembers: undefined, maxMembers: undefined },
|
||||
{ label: 'Looking for Members (1-4)', value: 'looking', minMembers: 1, maxMembers: 4 },
|
||||
{ label: '1 Member', value: '1', minMembers: 1, maxMembers: 1 },
|
||||
{ label: '2 Members', value: '2', minMembers: 2, maxMembers: 2 },
|
||||
{ label: '3 Members', value: '3', minMembers: 3, maxMembers: 3 },
|
||||
{ label: '4 Members', value: '4', minMembers: 4, maxMembers: 4 },
|
||||
{ label: '5 Members (Full)', value: '5', minMembers: 5, maxMembers: 5 },
|
||||
];
|
||||
|
||||
const SUBMISSION_FILTER_OPTIONS = [
|
||||
{ label: 'All Teams', value: '' },
|
||||
{ label: 'Submitted', value: 'true' },
|
||||
{ label: 'Not Submitted', value: 'false' },
|
||||
];
|
||||
|
||||
const TeamCardSkeleton: FC = () => (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden flex flex-col border dark:border-gray-800 animate-pulse">
|
||||
<div className="w-full aspect-3/1 bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="p-6 flex flex-col flex-1">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="flex-1">
|
||||
<div className="h-5 w-32 bg-gray-200 dark:bg-gray-700 rounded mb-2" />
|
||||
<div className="h-4 w-48 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 mb-4 flex-1">
|
||||
<div className="h-4 w-full bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-4 w-5/6 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-4 w-4/6 bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
<div className="space-y-3 mt-auto">
|
||||
<div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
<div className="h-10 w-full bg-gray-200 dark:bg-gray-700 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const BrowseTeamsPage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
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;
|
||||
|
||||
const initialPage = parseInt(searchParams.get('page') || '1', 10);
|
||||
const initialPerPage = parseInt(
|
||||
searchParams.get('per_page') || String(DEFAULT_PER_PAGE),
|
||||
10
|
||||
);
|
||||
const initialSearch = searchParams.get('search') || '';
|
||||
const initialCity = searchParams.get('city') || '';
|
||||
const initialMembers = searchParams.get('members') || '';
|
||||
const initialSubmission = searchParams.get('submission') || '';
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState(initialSearch);
|
||||
const [debouncedSearch, setDebouncedSearch] = useState(initialSearch);
|
||||
const [selectedCity, setSelectedCity] = useState(initialCity);
|
||||
const [selectedMembers, setSelectedMembers] = useState(initialMembers);
|
||||
const [selectedSubmission, setSelectedSubmission] = useState(initialSubmission);
|
||||
const [selectedTeamId, setSelectedTeamId] = useState<string | null>(null);
|
||||
const [showJoinModal, setShowJoinModal] = useState(false);
|
||||
const [currentPage, setCurrentPage] = useState(
|
||||
PER_PAGE_OPTIONS.includes(initialPerPage) ? initialPage : 1
|
||||
);
|
||||
const [perPage, setPerPage] = useState(
|
||||
PER_PAGE_OPTIONS.includes(initialPerPage) ? initialPerPage : DEFAULT_PER_PAGE
|
||||
);
|
||||
|
||||
const updateUrlParams = useCallback(
|
||||
(params: {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
search?: string;
|
||||
city?: string;
|
||||
members?: string;
|
||||
submission?: string;
|
||||
}) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
|
||||
if (params.page !== undefined) {
|
||||
if (params.page === 1) {
|
||||
newParams.delete('page');
|
||||
} else {
|
||||
newParams.set('page', String(params.page));
|
||||
}
|
||||
}
|
||||
|
||||
if (params.per_page !== undefined) {
|
||||
if (params.per_page === DEFAULT_PER_PAGE) {
|
||||
newParams.delete('per_page');
|
||||
} else {
|
||||
newParams.set('per_page', String(params.per_page));
|
||||
}
|
||||
}
|
||||
|
||||
if (params.search !== undefined) {
|
||||
if (params.search === '') {
|
||||
newParams.delete('search');
|
||||
} else {
|
||||
newParams.set('search', params.search);
|
||||
}
|
||||
}
|
||||
|
||||
if (params.city !== undefined) {
|
||||
if (params.city === '') {
|
||||
newParams.delete('city');
|
||||
} else {
|
||||
newParams.set('city', params.city);
|
||||
}
|
||||
}
|
||||
|
||||
if (params.members !== undefined) {
|
||||
if (params.members === '') {
|
||||
newParams.delete('members');
|
||||
} else {
|
||||
newParams.set('members', params.members);
|
||||
}
|
||||
}
|
||||
|
||||
if (params.submission !== undefined) {
|
||||
if (params.submission === '') {
|
||||
newParams.delete('submission');
|
||||
} else {
|
||||
newParams.set('submission', params.submission);
|
||||
}
|
||||
}
|
||||
|
||||
setSearchParams(newParams, { replace: true });
|
||||
},
|
||||
[searchParams, setSearchParams]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedSearch(searchTerm);
|
||||
if (searchTerm !== debouncedSearch) {
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ search: searchTerm, page: 1 });
|
||||
}
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCity !== initialCity) {
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ city: selectedCity, page: 1 });
|
||||
}
|
||||
}, [selectedCity]);
|
||||
|
||||
const memberFilter = MEMBER_FILTER_OPTIONS.find(
|
||||
(opt) => opt.value === selectedMembers
|
||||
);
|
||||
|
||||
const hasSubmissionFilter = selectedSubmission === 'true' ? true : selectedSubmission === 'false' ? false : undefined;
|
||||
|
||||
const {
|
||||
data: teamsData,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useTeams({
|
||||
page: currentPage,
|
||||
limit: perPage,
|
||||
search: debouncedSearch,
|
||||
city: selectedCity || undefined,
|
||||
visibility: ETeamVisibility.PUBLIC,
|
||||
minMembers: memberFilter?.minMembers,
|
||||
maxMembers: memberFilter?.maxMembers,
|
||||
hasSubmission: hasSubmissionFilter,
|
||||
});
|
||||
|
||||
const { data: myTeamsData } = useMyTeams();
|
||||
const { mutateAsync: joinTeam, isPending: isJoining } = useJoinTeam();
|
||||
|
||||
const form = useForm<TJoinTeamForm>({
|
||||
resolver: zodResolver(joinTeamSchema),
|
||||
mode: 'all',
|
||||
});
|
||||
|
||||
const teams = teamsData?.teams || [];
|
||||
const totalPages = teamsData?.totalPages || 1;
|
||||
const total = teamsData?.total || 0;
|
||||
const myTeams = myTeamsData?.data || [];
|
||||
|
||||
const isMyTeam = (teamId: string) => {
|
||||
return myTeams.some((team: any) => team.id === teamId);
|
||||
};
|
||||
|
||||
const handleJoinRequest = (teamId: string) => {
|
||||
setSelectedTeamId(teamId);
|
||||
setShowJoinModal(true);
|
||||
};
|
||||
|
||||
const onSubmit = form.handleSubmit(async (data) => {
|
||||
if (!selectedTeamId) return;
|
||||
|
||||
try {
|
||||
await joinTeam({ teamId: selectedTeamId, data });
|
||||
setShowJoinModal(false);
|
||||
form.reset();
|
||||
setSelectedTeamId(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to send join request:', error);
|
||||
}
|
||||
});
|
||||
|
||||
const getPageNumbers = () => {
|
||||
const pages: (number | string)[] = [];
|
||||
const maxVisible = 5;
|
||||
|
||||
if (totalPages <= maxVisible + 2) {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
} else {
|
||||
pages.push(1);
|
||||
|
||||
if (currentPage > 3) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
const start = Math.max(2, currentPage - 1);
|
||||
const end = Math.min(totalPages - 1, currentPage + 1);
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i);
|
||||
}
|
||||
|
||||
if (currentPage < totalPages - 2) {
|
||||
pages.push('...');
|
||||
}
|
||||
|
||||
pages.push(totalPages);
|
||||
}
|
||||
|
||||
return pages;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-800">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Browse Teams
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Find and join teams looking for members
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/dashboard" className="hidden md:block">
|
||||
<Button variant="secondary">Back to Dashboard</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="bg-white dark:bg-gray-900 p-6 rounded-lg shadow-sm mb-6 border dark:border-gray-800">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Search Teams
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by team name..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full h-[42px] px-3 border border-gray-300 dark:border-gray-700 rounded-md focus:ring-2 focus:ring-blue-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"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Filter by City
|
||||
</label>
|
||||
<CitySelect
|
||||
value={selectedCity}
|
||||
onChange={setSelectedCity}
|
||||
placeholder="All Cities (search to filter...)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Filter by Members
|
||||
</label>
|
||||
<select
|
||||
value={selectedMembers}
|
||||
onChange={(e) => {
|
||||
setSelectedMembers(e.target.value);
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ members: e.target.value, page: 1 });
|
||||
}}
|
||||
className="w-full h-[42px] px-3 border border-gray-300 dark:border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
>
|
||||
{MEMBER_FILTER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Submission Status
|
||||
</label>
|
||||
<select
|
||||
value={selectedSubmission}
|
||||
onChange={(e) => {
|
||||
setSelectedSubmission(e.target.value);
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ submission: e.target.value, page: 1 });
|
||||
}}
|
||||
className="w-full h-[42px] px-3 border border-gray-300 dark:border-gray-700 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
|
||||
>
|
||||
{SUBMISSION_FILTER_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isLoading && total > 0 && (
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing {(currentPage - 1) * perPage + 1}-
|
||||
{Math.min(currentPage * perPage, total)} of {total} teams
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading || isFetching ? (
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: perPage }).map((_, index) => (
|
||||
<TeamCardSkeleton key={index} />
|
||||
))}
|
||||
</div>
|
||||
) : teams.length === 0 ? (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-sm p-12 text-center">
|
||||
<p className="text-gray-600 dark:text-gray-400 text-lg">
|
||||
No teams found
|
||||
</p>
|
||||
<p className="text-gray-500 dark:text-gray-500 mt-2">
|
||||
Try adjusting your filters
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-6 md:grid-cols-2 xl:grid-cols-3">
|
||||
{teams.map((team: any) => (
|
||||
<div
|
||||
key={team.id}
|
||||
className="bg-white dark:bg-gray-900 rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow flex flex-col border dark:border-gray-800"
|
||||
>
|
||||
<img
|
||||
src={team.banner || '/images/banner-imphnen.webp'}
|
||||
alt={team.name}
|
||||
className="w-full aspect-3/1 object-cover"
|
||||
/>
|
||||
<div className="p-6 flex flex-col flex-1">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
{team.logo ? (
|
||||
<img
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<span className="text-gray-500 dark:text-gray-300 text-xl">
|
||||
<Icon icon="mdi:account-group" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="text-lg font-bold text-gray-900 dark:text-white line-clamp-1 leading-tight">
|
||||
{team.name}
|
||||
</h3>
|
||||
<div className="text-sm font-sans text-gray-600 dark:text-gray-400 flex items-center gap-2 mt-1">
|
||||
{team.has_submission && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 shrink-0">
|
||||
<Icon icon="mdi:check-circle" className="text-sm" />
|
||||
Submitted
|
||||
</span>
|
||||
)}
|
||||
<p className="truncate flex-1 min-w-0 flex items-center gap-x-1">
|
||||
<Icon icon="mdi:map-marker" />{' '}
|
||||
<span>{team.city}</span>
|
||||
</p>
|
||||
<p className="whitespace-nowrap shrink-0 flex items-center gap-x-1">
|
||||
<Icon icon="mdi:account-group" />{' '}
|
||||
{team.member_count || 0} members
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm mb-4 line-clamp-3 font-sans flex-1">
|
||||
{team.description}
|
||||
</p>
|
||||
<div className="space-y-3 mt-auto">
|
||||
{(team.member_count || 0) === 1 && !team.has_submission && (
|
||||
<div className="p-2 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400 flex items-center gap-1">
|
||||
<Icon icon="mdi:alert" className="text-sm shrink-0" />
|
||||
<span>This team needs at least 2 members to submit</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{isMyTeam(team.id) ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
onClick={() => navigate({ to: `/teams/${team.id}` })}
|
||||
>
|
||||
Your Team
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{myTeams.length === 0 &&
|
||||
(team.member_count || 0) < 5 &&
|
||||
!team.has_submission &&
|
||||
!isTeamFeaturesClosed && (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => handleJoinRequest(team.id)}
|
||||
>
|
||||
Request to Join
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="secondary"
|
||||
onClick={() => navigate({ to: `/teams/${team.id}` })}
|
||||
>
|
||||
View Team
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{(totalPages > 1 || total > 6) && (
|
||||
<div className="mt-8 flex flex-col sm:flex-row items-center justify-center gap-4">
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const newPage = Math.max(1, currentPage - 1);
|
||||
setCurrentPage(newPage);
|
||||
updateUrlParams({ page: newPage });
|
||||
}}
|
||||
disabled={currentPage === 1 || isFetching}
|
||||
className="px-3"
|
||||
>
|
||||
<Icon icon="mdi:chevron-left" className="text-xl" />
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{getPageNumbers().map((page, index) =>
|
||||
typeof page === 'string' ? (
|
||||
<span
|
||||
key={`ellipsis-${index}`}
|
||||
className="px-2 text-gray-400 dark:text-gray-500"
|
||||
>
|
||||
...
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => {
|
||||
setCurrentPage(page);
|
||||
updateUrlParams({ page });
|
||||
}}
|
||||
disabled={isFetching}
|
||||
className={`min-w-10 h-10 px-3 rounded-md text-sm font-medium transition-colors ${
|
||||
currentPage === page
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 border border-gray-300 dark:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const newPage = Math.min(totalPages, currentPage + 1);
|
||||
setCurrentPage(newPage);
|
||||
updateUrlParams({ page: newPage });
|
||||
}}
|
||||
disabled={currentPage === totalPages || isFetching}
|
||||
className="px-3"
|
||||
>
|
||||
<Icon icon="mdi:chevron-right" className="text-xl" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{totalPages > 1 && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">Show:</span>
|
||||
<select
|
||||
value={perPage}
|
||||
onChange={(e) => {
|
||||
const newPerPage = Number(e.target.value);
|
||||
setPerPage(newPerPage);
|
||||
setCurrentPage(1);
|
||||
updateUrlParams({ per_page: newPerPage, page: 1 });
|
||||
}}
|
||||
className="h-10 px-3 pr-8 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||
>
|
||||
{PER_PAGE_OPTIONS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showJoinModal && (
|
||||
<div className="fixed inset-0 bg-black/30 dark:bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Request to Join Team
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6 font-sans">
|
||||
Send a message to the team leader explaining why you want to join
|
||||
</p>
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Your Message
|
||||
</label>
|
||||
<textarea
|
||||
{...form.register('message')}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md focus:ring-2 focus:ring-blue-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"
|
||||
placeholder="Tell the team leader why you want to join their team..."
|
||||
/>
|
||||
{form.formState.errors.message && (
|
||||
<p className="text-sm text-red-500 mt-1 font-sans">
|
||||
{form.formState.errors.message.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => {
|
||||
setShowJoinModal(false);
|
||||
form.reset();
|
||||
setSelectedTeamId(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={!form.formState.isValid || isJoining}
|
||||
>
|
||||
{isJoining ? 'Sending...' : 'Send Request'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
import { FC, ReactElement, useState } from 'react';
|
||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
||||
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
|
||||
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';
|
||||
import { toast } from 'sonner';
|
||||
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');
|
||||
|
||||
const CreateTeamPage: FC = (): ReactElement => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const isTeamFeaturesClosed = new Date() >= TEAM_FEATURES_DEADLINE;
|
||||
|
||||
const [logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [logoPreview, setLogoPreview] = useState<string>('');
|
||||
const [bannerFile, setBannerFile] = useState<File | null>(null);
|
||||
const [bannerPreview, setBannerPreview] = useState<string>('');
|
||||
|
||||
const form = useForm<TTeamCreateForm>({
|
||||
resolver: zodResolver(teamCreateSchema),
|
||||
mode: 'all',
|
||||
defaultValues: {
|
||||
visibility: ETeamVisibility.PUBLIC,
|
||||
logo: null,
|
||||
banner: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { mutateAsync: createTeam, isPending: isCreating } = useCreateTeam();
|
||||
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
||||
|
||||
const handleLogoChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error('Logo image is too large. Maximum size is 2MB.');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
setLogoFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setLogoPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBannerChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
toast.error('Banner image is too large. Maximum size is 2MB.');
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
setBannerFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setBannerPreview(reader.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmit = form.handleSubmit(async (data) => {
|
||||
try {
|
||||
let logoUrl = null;
|
||||
let bannerUrl = null;
|
||||
|
||||
if (logoFile) {
|
||||
const logoResult = await uploadFile(logoFile);
|
||||
logoUrl = logoResult.data.url;
|
||||
}
|
||||
|
||||
if (bannerFile) {
|
||||
const bannerResult = await uploadFile(bannerFile);
|
||||
bannerUrl = bannerResult.data.url;
|
||||
}
|
||||
|
||||
const result = await createTeam({
|
||||
...data,
|
||||
logo: logoUrl,
|
||||
banner: bannerUrl,
|
||||
});
|
||||
|
||||
toast.success('Team created successfully!');
|
||||
navigate({ to: `/teams/${result.data.id}` });
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create team:', error);
|
||||
|
||||
const message = error?.message || '';
|
||||
if (message.includes('413') || message.includes('length limit') || message.includes('too large')) {
|
||||
toast.error('Image file is too large. Please use smaller images (max 2MB each).');
|
||||
} else if (message.includes('already a member')) {
|
||||
toast.error(message);
|
||||
} else {
|
||||
toast.error(message || 'Failed to create team. Please try again.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (isTeamFeaturesClosed) {
|
||||
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">
|
||||
Team Features Closed
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Team creation is no longer available.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
The deadline for team features was November 30, 2025 at 23:59 WIB.
|
||||
</p>
|
||||
|
||||
<button
|
||||
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({ 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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-neutral-950">
|
||||
<div className="bg-white dark:bg-neutral-900 border-b dark:border-neutral-700">
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">Create Your Team</h1>
|
||||
<p className="text-gray-600 dark:text-neutral-400 mt-1">Build your hackathon dream team</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-lg shadow-md dark:shadow-neutral-900/50 p-8">
|
||||
<form onSubmit={onSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-2">
|
||||
Team Banner <span className="text-gray-400 dark:text-neutral-500">(Optional)</span>
|
||||
</label>
|
||||
{bannerPreview ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={bannerPreview}
|
||||
alt="Banner preview"
|
||||
className="w-full h-48 object-cover rounded-lg border-2 border-gray-200 dark:border-neutral-700"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setBannerFile(null);
|
||||
setBannerPreview('');
|
||||
}}
|
||||
className="absolute top-2 right-2 bg-red-500 text-white px-3 py-1 rounded-lg text-sm hover:bg-red-600"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex flex-col items-center justify-center w-full h-48 border-2 border-dashed border-gray-300 dark:border-neutral-600 rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-neutral-800">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-500 dark:text-neutral-400">Click to upload banner</p>
|
||||
<p className="text-xs text-gray-400 dark:text-neutral-500 mt-1">1200x400 recommended. Max 2MB</p>
|
||||
</div>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleBannerChange}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300 mb-2">
|
||||
Team Logo <span className="text-gray-400 dark:text-neutral-500">(Optional, but highly recommended)</span>
|
||||
</label>
|
||||
<div className="flex items-center space-x-4">
|
||||
{logoPreview ? (
|
||||
<img
|
||||
src={logoPreview}
|
||||
alt="Logo preview"
|
||||
className="w-24 h-24 rounded-full object-cover border-2 border-gray-200 dark:border-neutral-700"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-24 h-24 rounded-full bg-gray-200 dark:bg-neutral-700 flex items-center justify-center">
|
||||
<span className="text-gray-400 dark:text-neutral-500 text-3xl">👥</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div>
|
||||
<label htmlFor="logo" className="cursor-pointer">
|
||||
<span className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 inline-block">
|
||||
{logoPreview ? 'Change Logo' : 'Upload Logo'}
|
||||
</span>
|
||||
<input
|
||||
id="logo"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleLogoChange}
|
||||
/>
|
||||
</label>
|
||||
{logoPreview && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setLogoFile(null);
|
||||
setLogoPreview('');
|
||||
}}
|
||||
className="ml-3 px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-neutral-500 mt-2">Max 2MB</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ControlledInputField
|
||||
control={form.control}
|
||||
label="Team Name"
|
||||
placeholder="Enter your team name"
|
||||
name="name"
|
||||
/>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300">
|
||||
City <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="city"
|
||||
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-sm font-medium text-gray-700 dark:text-neutral-300">
|
||||
Description <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field, fieldState }) => (
|
||||
<div>
|
||||
<Textarea
|
||||
{...field}
|
||||
placeholder="Tell others about your team, what you're looking for, your goals..."
|
||||
rows={4}
|
||||
className="w-full"
|
||||
/>
|
||||
{fieldState.error && (
|
||||
<p className="text-sm text-red-500 mt-1">{fieldState.error.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-neutral-300">
|
||||
Team Visibility <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Controller
|
||||
control={form.control}
|
||||
name="visibility"
|
||||
render={({ field }) => (
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-start space-x-3 cursor-pointer border dark:border-neutral-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-neutral-800">
|
||||
<input
|
||||
type="radio"
|
||||
{...field}
|
||||
value={ETeamVisibility.PUBLIC}
|
||||
checked={field.value === ETeamVisibility.PUBLIC}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">Public</p>
|
||||
<p className="text-sm text-gray-600 dark:text-neutral-400">
|
||||
Team will be visible in Browse Teams. Anyone can request to join.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-start space-x-3 cursor-pointer border dark:border-neutral-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-neutral-800">
|
||||
<input
|
||||
type="radio"
|
||||
{...field}
|
||||
value={ETeamVisibility.PRIVATE}
|
||||
checked={field.value === ETeamVisibility.PRIVATE}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900 dark:text-white">Private</p>
|
||||
<p className="text-sm text-gray-600 dark:text-neutral-400">
|
||||
Team is hidden from Browse Teams. Members can only join via invitation.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
<strong>Note:</strong> As team leader, you cannot leave or join another team after creating this team.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-3 pt-4">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="flex-1"
|
||||
onClick={() => navigate({ to: '/dashboard' })}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={!form.formState.isValid || isCreating || isUploading}
|
||||
>
|
||||
{isCreating || isUploading ? 'Creating Team...' : 'Create Team'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||
import { createFileRoute, useNavigate, Link } from '@tanstack/react-router';
|
||||
import {
|
||||
useUserDetailsById,
|
||||
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 } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
const { data: userData, isLoading, error } = useUserDetailsById(userId || '');
|
||||
const { data: teamsData } = useTeamsByUserId(userId || '');
|
||||
|
||||
const user = userData?.data;
|
||||
const userTeams = teamsData?.data || [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
Loading user profile...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !user) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
User not found
|
||||
</h2>
|
||||
{error && (
|
||||
<p className="text-red-600 dark:text-red-400 mb-4">{String(error)}</p>
|
||||
)}
|
||||
<Button onClick={() => navigate({ to: '/dashboard' })}>
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3 md:space-x-4">
|
||||
{user.avatar ? (
|
||||
<img
|
||||
src={user.avatar}
|
||||
alt={user.fullname}
|
||||
className="w-16 h-16 md:w-20 md:h-20 rounded-full object-cover border-4 border-white dark:border-gray-800 shadow-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center border-4 border-white dark:border-gray-800 shadow-lg">
|
||||
<span className="text-gray-500 dark:text-gray-400 text-2xl md:text-3xl">
|
||||
<Icon icon="mdi:account-circle" className="w-10 h-10" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-xl md:text-3xl font-bold text-gray-900 dark:text-white">
|
||||
{user.fullname}
|
||||
</h1>
|
||||
<p className="text-sm md:text-base text-gray-600 dark:text-gray-400 mt-1">
|
||||
{user.email}
|
||||
</p>
|
||||
{user.location && (
|
||||
<div className="flex items-center space-x-4 mt-2">
|
||||
<span className="text-sm text-gray-500 dark:text-gray-500">
|
||||
<Icon
|
||||
icon="mdi:map-marker"
|
||||
className="inline-block w-4 h-4 mr-1"
|
||||
/>
|
||||
{user.location}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Link to="/dashboard" className="hidden md:block">
|
||||
<Button variant="secondary">Back to Dashboard</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-8">
|
||||
<div className="grid gap-4 md:gap-6 lg:grid-cols-3">
|
||||
|
||||
<div className="lg:col-span-2 space-y-4 md:space-y-6">
|
||||
|
||||
{user.bio && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6 overflow-hidden">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
About
|
||||
</h2>
|
||||
<p className="text-gray-700 dark:text-gray-300 break-all overflow-wrap-anywhere font-sans">
|
||||
{user.bio}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user.skills && user.skills.length > 0 && (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Skills
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{user.skills.map((skill: string) => (
|
||||
<span
|
||||
key={skill}
|
||||
className="inline-flex items-center px-4 py-2 border border-primary-600 dark:border-gray-500 text-primary-600 dark:text-white rounded-4xl text-xs font-medium dark:bg-gray-600"
|
||||
>
|
||||
{skill}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userTeams.length > 0 ? (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Team
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
{userTeams.map((team: any) => (
|
||||
<Link
|
||||
key={team.id}
|
||||
to={'/teams/' + team.id}
|
||||
className="block bg-white dark:bg-gray-800 rounded-lg shadow-md dark:shadow-gray-950/50 overflow-hidden hover:shadow-lg transition-shadow border dark:border-gray-700"
|
||||
>
|
||||
<img
|
||||
src={team.banner || '/images/banner-imphnen.webp'}
|
||||
alt={team.name}
|
||||
className="w-full aspect-3/1 object-cover"
|
||||
/>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center space-x-3 mb-3">
|
||||
{team.logo ? (
|
||||
<img
|
||||
src={team.logo}
|
||||
alt={team.name}
|
||||
className="w-12 h-12 rounded-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-12 h-12 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<Icon icon="mdi:account-group" className="text-gray-500 dark:text-gray-400 text-xl" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-gray-900 dark:text-white line-clamp-1 text-lg">
|
||||
{team.name}
|
||||
</h3>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 flex items-center gap-3 font-sans mt-1">
|
||||
{team.has_submission && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 shrink-0">
|
||||
<Icon icon="mdi:check-circle" className="text-sm" />
|
||||
Submitted
|
||||
</span>
|
||||
)}
|
||||
{team.city && (
|
||||
<span className="flex items-center gap-1 truncate">
|
||||
<Icon icon="mdi:map-marker" className="shrink-0" />
|
||||
<span className="truncate">{team.city}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{team.description && (
|
||||
<p className="text-gray-600 dark:text-gray-400 text-sm line-clamp-2 font-sans">
|
||||
{team.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
|
||||
<h2 className="text-lg md:text-xl font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Team
|
||||
</h2>
|
||||
<div className="text-center py-8">
|
||||
<div className="text-4xl mb-3">
|
||||
<Icon icon="mdi:account-group-outline" className="inline-block text-gray-400" />
|
||||
</div>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Not in any team yet
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 md:space-y-6">
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-md dark:shadow-gray-950/50 p-4 md:p-6">
|
||||
<h3 className="text-base md:text-lg font-bold text-gray-900 dark:text-white mb-3 md:mb-4">
|
||||
Contact Information
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Email
|
||||
</p>
|
||||
<p className="text-gray-900 dark:text-white font-medium">
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
{user.location && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Location
|
||||
</p>
|
||||
<p className="text-gray-900 dark:text-white font-medium">
|
||||
{user.location}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
import { useState, useEffect } from 'react'
|
||||
import {
|
||||
useGitHubAuth,
|
||||
useLogin,
|
||||
authLoginSchema,
|
||||
TLoginRequest,
|
||||
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 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')
|
||||
if (accessToken && (type === 'recovery' || type === 'magiclink' || !type)) {
|
||||
toast.info('Redirecting to password reset...')
|
||||
navigate({ to: '/auth/reset-password', search: { access_token: accessToken } })
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
const onSubmit = handleSubmit(async (data) => {
|
||||
setError(null)
|
||||
try {
|
||||
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')
|
||||
}
|
||||
})
|
||||
|
||||
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) {
|
||||
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({ 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>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-3xl font-bold text-gray-900 mb-2">Welcome Back</h2>
|
||||
<p className="text-gray-600 font-sans">Sign in to join or create your hackathon team</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-3 bg-red-50 border border-red-200 rounded-lg">
|
||||
<p className="text-red-600 text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">Email</label>
|
||||
<input id="email" type="text" {...register('email')} placeholder="your@email.com" disabled={loginMutation.isPending}
|
||||
className={`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 text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed ${errors.email ? 'border-red-400' : 'border-gray-300'}`} />
|
||||
{errors.email && <p className="text-red-500 text-xs mt-1">{errors.email.message}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700">Password</label>
|
||||
<Link to="/auth/forgot-password" className="text-sm text-primary-600 hover:text-primary-700">Forgot password?</Link>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<input id="password" type={showPassword ? 'text' : 'password'} {...register('password')} placeholder="••••••••" disabled={loginMutation.isPending}
|
||||
className={`w-full px-4 py-2.5 pr-12 border rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent bg-white text-gray-900 placeholder:text-gray-400 disabled:bg-gray-100 disabled:cursor-not-allowed ${errors.password ? 'border-red-400' : 'border-gray-300'}`} />
|
||||
<button type="button" onClick={() => setShowPassword(!showPassword)} className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-700">
|
||||
<Icon icon={showPassword ? 'mdi:eye-off' : 'mdi:eye'} className="text-xl" />
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && <p className="text-red-500 text-xs mt-1">{errors.password.message}</p>}
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={!isValid || loginMutation.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-300 disabled:cursor-not-allowed transition-colors cursor-pointer">
|
||||
{loginMutation.isPending ? 'Signing in...' : 'Sign in with Email'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="my-6 flex items-center">
|
||||
<div className="flex-1 border-t border-gray-300"></div>
|
||||
<span className="px-4 text-sm text-gray-500">OR</span>
|
||||
<div className="flex-1 border-t border-gray-300"></div>
|
||||
</div>
|
||||
|
||||
<button onClick={handleGithubLogin} disabled={isGithubLoading} type="button"
|
||||
className="w-full py-3 flex items-center justify-center gap-2 bg-gray-100 border border-gray-300 rounded-lg font-semibold text-gray-900 hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-400 focus:ring-offset-2 disabled:bg-gray-100 disabled:cursor-not-allowed transition-colors cursor-pointer">
|
||||
<GithubOutlined className="text-xl" />
|
||||
<span>{isGithubLoading ? 'Connecting...' : 'Sign in with GitHub'}</span>
|
||||
</button>
|
||||
|
||||
<p className="mt-3 text-xs text-center 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 hover:underline">set to public</a> for GitHub sign in to work.
|
||||
</p>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-gray-600 text-sm">Don't have an account? <Link to="/auth/signup" className="text-primary-600 hover:text-primary-700 font-semibold">Sign up</Link></p>
|
||||
</div>
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-gray-500 text-xs">By signing in, you agree to our Terms of Service and Privacy Policy</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
import { FC, ReactElement, useState, useEffect, useRef } from 'react';
|
||||
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';
|
||||
import QRCode from 'qrcode';
|
||||
import html2canvas from 'html2canvas';
|
||||
|
||||
export const Route = createFileRoute('/_public/certificate/$certId')({
|
||||
component: CertificatePage })
|
||||
|
||||
interface DecodedCert {
|
||||
teamId: string;
|
||||
submissionId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
const CertificatePage: FC = (): ReactElement => {
|
||||
const { certId } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
const { session } = useAuthStore();
|
||||
const [decodedInfo, setDecodedInfo] = useState<DecodedCert | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const teamNameRef = useRef<HTMLHeadingElement>(null);
|
||||
const userNameRef = useRef<HTMLHeadingElement>(null);
|
||||
const [teamNameFontSize, setTeamNameFontSize] = useState('2.25rem');
|
||||
const [userNameFontSize, setUserNameFontSize] = useState('2.25rem');
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
|
||||
const certificateRef = useRef<HTMLDivElement>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [certificateImage, setCertificateImage] = useState<string>('');
|
||||
const [showTemplate, setShowTemplate] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (certId) {
|
||||
decodeCertificateId(certId)
|
||||
.then(setDecodedInfo)
|
||||
.catch(() => {
|
||||
setError('Invalid certificate ID');
|
||||
});
|
||||
}
|
||||
}, [certId]);
|
||||
|
||||
const { data: certificateData, isLoading: isLoadingCertificate } =
|
||||
useCertificatePublicData(decodedInfo?.userId || '', !!decodedInfo?.userId);
|
||||
|
||||
useEffect(() => {
|
||||
if (certId) {
|
||||
const encodedCertId = encodeURIComponent(certId);
|
||||
const certificateUrl = `${window.location.origin}/certificate/${encodedCertId}`;
|
||||
QRCode.toDataURL(certificateUrl, {
|
||||
width: 200,
|
||||
margin: 1,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff' } })
|
||||
.then(setQrCodeUrl)
|
||||
.catch((err) => console.error('QR Code generation failed:', err));
|
||||
}
|
||||
}, [certId]);
|
||||
|
||||
const certificate = certificateData?.data;
|
||||
const team = certificate?.team;
|
||||
const submission = certificate?.submission;
|
||||
const certificateUser = certificate?.user;
|
||||
|
||||
const isLoading = (!decodedInfo && !error) || isLoadingCertificate;
|
||||
|
||||
const certificateName = certificateUser?.fullname;
|
||||
|
||||
const isTeamMember = session?.user?.id === decodedInfo?.userId;
|
||||
|
||||
useEffect(() => {
|
||||
const adjustFontSize = (
|
||||
element: HTMLElement | null,
|
||||
maxHeight: number,
|
||||
startSize: number,
|
||||
setter: (size: string) => void
|
||||
) => {
|
||||
if (!element) return;
|
||||
|
||||
let currentSize = startSize;
|
||||
element.style.fontSize = `${currentSize}px`;
|
||||
|
||||
while (element.offsetHeight > maxHeight && currentSize > 1) {
|
||||
currentSize -= 2;
|
||||
element.style.fontSize = `${currentSize}px`;
|
||||
}
|
||||
|
||||
setter(`${currentSize}px`);
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
adjustFontSize(teamNameRef.current, 80, 20, setTeamNameFontSize);
|
||||
adjustFontSize(userNameRef.current, 80, 36, setUserNameFontSize);
|
||||
}, 0);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [team?.name, certificateName]);
|
||||
|
||||
useEffect(() => {
|
||||
const generateCertificate = async () => {
|
||||
if (!certificateRef.current || !team || !submission || !qrCodeUrl) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
const canvas = await html2canvas(certificateRef.current, {
|
||||
scale: 4,
|
||||
useCORS: true,
|
||||
backgroundColor: '#ffffff',
|
||||
logging: false,
|
||||
width: 1000,
|
||||
height: (1000 * 2480) / 3508,
|
||||
allowTaint: true,
|
||||
imageTimeout: 0,
|
||||
removeContainer: true });
|
||||
|
||||
const imageUrl = canvas.toDataURL('image/png', 1.0);
|
||||
setCertificateImage(imageUrl);
|
||||
setShowTemplate(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to generate certificate:', error);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
generateCertificate();
|
||||
}, [team, submission, qrCodeUrl]);
|
||||
|
||||
const handleDownloadCertificate = () => {
|
||||
if (!certificateImage) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = certificateImage;
|
||||
link.download = `certificate-${team?.name || 'hackathon'}.png`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
const handlePrintCertificate = () => {
|
||||
if (!certificateImage) return;
|
||||
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (printWindow) {
|
||||
printWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Certificate - ${team?.name}</title>
|
||||
<style>
|
||||
body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
@media print {
|
||||
@page { size: A4 landscape; margin: 0; }
|
||||
body { margin: 0; }
|
||||
img { width: 100%; height: auto; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="${certificateImage}" />
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
printWindow.onload = () => {
|
||||
printWindow.print();
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (error || !certId) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-6xl mb-4">❌</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Invalid Certificate
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
{error || 'The certificate ID is invalid or malformed.'}
|
||||
</p>
|
||||
<Button onClick={() => navigate({ to: '/' })}>Back to Home</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
Loading certificate...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!certificateUser) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-6xl mb-4">📄</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Certificate Not Found
|
||||
</h2>
|
||||
<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({ to: '/' })}>Back to Home</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<style>{`
|
||||
@media print {
|
||||
@page {
|
||||
size: A4 landscape;
|
||||
margin: 0;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
body * {
|
||||
visibility: hidden;
|
||||
}
|
||||
#certificate-wrapper {
|
||||
visibility: visible;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: white;
|
||||
}
|
||||
#certificate, #certificate * {
|
||||
visibility: visible;
|
||||
}
|
||||
#certificate {
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
page-break-after: avoid;
|
||||
}
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#certificate-container {
|
||||
transform-origin: top center;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 no-print">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Certificate
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{team?.name}
|
||||
</p>
|
||||
</div>
|
||||
{team && isTeamMember && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => navigate({ to: `/teams/${team.id}/submission` })}
|
||||
>
|
||||
Back to Submission
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12"
|
||||
id="certificate-wrapper"
|
||||
>
|
||||
<div
|
||||
className={showTemplate ? 'block' : 'hidden'}
|
||||
style={{ position: 'absolute', left: '-9999px' }}
|
||||
>
|
||||
<div
|
||||
ref={certificateRef}
|
||||
id="certificate-template"
|
||||
style={{
|
||||
position: 'relative',
|
||||
backgroundImage: 'url(/images/blank_cert.svg)',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
width: '1000px',
|
||||
height: `${(1000 * 2480) / 3508}px` }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '41%',
|
||||
left: '3.5%',
|
||||
width: '55%' }}
|
||||
>
|
||||
<h3
|
||||
ref={teamNameRef}
|
||||
style={{
|
||||
fontFamily: 'Poppins, sans-serif',
|
||||
fontWeight: 'bold',
|
||||
color: '#59bef5',
|
||||
textAlign: 'left',
|
||||
fontSize: '32px',
|
||||
lineHeight: '1.2',
|
||||
wordBreak: 'break-word',
|
||||
margin: 0 }}
|
||||
>
|
||||
{team?.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '45%',
|
||||
left: '3.5%',
|
||||
width: '55%' }}
|
||||
>
|
||||
<h3
|
||||
ref={userNameRef}
|
||||
style={{
|
||||
fontFamily: 'Poppins, sans-serif',
|
||||
fontWeight: 'bold',
|
||||
color: '#59bef5',
|
||||
textAlign: 'left',
|
||||
fontSize: '40px',
|
||||
lineHeight: '1.2',
|
||||
wordBreak: 'break-word',
|
||||
margin: 0 }}
|
||||
>
|
||||
{certificateName || 'N/A'}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '33%',
|
||||
right: '9.3%',
|
||||
width: '190px',
|
||||
height: '190px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center' }}
|
||||
>
|
||||
{qrCodeUrl && (
|
||||
<img
|
||||
src={qrCodeUrl}
|
||||
alt="Certificate QR Code"
|
||||
style={{ width: '190px', height: '190px', display: 'block' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 overflow-hidden p-2">
|
||||
{isGenerating && (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
Generating certificate...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{certificateImage && !isGenerating && (
|
||||
<img
|
||||
src={certificateImage}
|
||||
alt="Certificate"
|
||||
className="w-full h-auto"
|
||||
style={{ maxWidth: '100%', height: 'auto' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isTeamMember && (
|
||||
<div className="bg-gray-50 dark:bg-gray-900 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleDownloadCertificate}
|
||||
className="flex items-center gap-2"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{isGenerating ? '⏳ Generating...' : '📥 Download'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handlePrintCertificate}
|
||||
className="flex items-center gap-2"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{isGenerating ? '⏳ Generating...' : '🖨️ Print'}
|
||||
</Button>
|
||||
{team && (
|
||||
<Button
|
||||
onClick={() => navigate({ to: `/teams/${team.id}/submission` })}
|
||||
variant="secondary"
|
||||
className="col-span-2 flex items-center gap-2 xl:col-span-1"
|
||||
>
|
||||
View Submission
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6 no-print">
|
||||
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
|
||||
Certificate Information
|
||||
</h3>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
This certificate is a digital record of your hackathon participation
|
||||
and project submission. You can print or save this page as a PDF for
|
||||
your records.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
import { FC, ReactElement, useEffect, useMemo, useRef, useState } from 'react';
|
||||
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';
|
||||
import {
|
||||
useAuthStore,
|
||||
useMyTeams,
|
||||
useTeamById,
|
||||
useTeamSubmission,
|
||||
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;
|
||||
team?: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
const formatOrdinalRank = (rank: number): string => {
|
||||
const mod100 = rank % 100;
|
||||
if (mod100 >= 11 && mod100 <= 13) return `${rank}th`;
|
||||
|
||||
switch (rank % 10) {
|
||||
case 1:
|
||||
return `${rank}st`;
|
||||
case 2:
|
||||
return `${rank}nd`;
|
||||
case 3:
|
||||
return `${rank}rd`;
|
||||
default:
|
||||
return `${rank}th`;
|
||||
}
|
||||
};
|
||||
|
||||
const CERT_WIDTH = 842 * 2;
|
||||
const CERT_HEIGHT = 595 * 2;
|
||||
|
||||
const EXPORT_SCALE = 2;
|
||||
|
||||
const LAYOUT_BASE_WIDTH = 1000;
|
||||
const LAYOUT_SCALE = CERT_WIDTH / LAYOUT_BASE_WIDTH;
|
||||
const s = (px: number) => Math.round(px * LAYOUT_SCALE);
|
||||
|
||||
const CertificateWinnerPage: FC = (): ReactElement => {
|
||||
const { certId } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
const { session } = useAuthStore();
|
||||
const { data: myTeamsData } = useMyTeams();
|
||||
const {
|
||||
data: winnersResponse,
|
||||
isLoading: isLoadingWinners,
|
||||
isError: isWinnersError } = useWinners();
|
||||
|
||||
const [decodedTeamId, setDecodedTeamId] = useState<string>('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [qrCodeUrl, setQrCodeUrl] = useState<string>('');
|
||||
const certificateRef = useRef<HTMLDivElement>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [certificateImage, setCertificateImage] = useState<string>('');
|
||||
const [showTemplate, setShowTemplate] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!certId) return;
|
||||
|
||||
decodeWinnerCertificateId(certId)
|
||||
.then((decoded) => setDecodedTeamId(decoded.teamId))
|
||||
.catch(() => setError('Invalid certificate ID'));
|
||||
}, [certId]);
|
||||
|
||||
const winners = useMemo(
|
||||
() => (winnersResponse?.data || []) as WinnerEntry[],
|
||||
[winnersResponse?.data]
|
||||
);
|
||||
const winnerEntry = useMemo(() => {
|
||||
if (!decodedTeamId) return undefined;
|
||||
return winners.find((w) => w.team_id === decodedTeamId);
|
||||
}, [decodedTeamId, winners]);
|
||||
|
||||
const rankLabel = useMemo(() => {
|
||||
if (!winnerEntry?.rank) return '';
|
||||
return formatOrdinalRank(winnerEntry.rank);
|
||||
}, [winnerEntry?.rank]);
|
||||
|
||||
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
|
||||
decodedTeamId,
|
||||
!!decodedTeamId
|
||||
);
|
||||
|
||||
const { data: submissionData, isLoading: isLoadingSubmission } =
|
||||
useTeamSubmission(decodedTeamId, !!decodedTeamId);
|
||||
|
||||
const team = teamData?.data;
|
||||
const submission = submissionData?.data;
|
||||
const submissionName = submission?.project_name || '(Submission unavailable)';
|
||||
|
||||
const memberNames = useMemo(() => {
|
||||
const members = team?.members || [];
|
||||
return members
|
||||
.map((m) => m.user?.fullname)
|
||||
.filter((name): name is string => !!name);
|
||||
}, [team?.members]);
|
||||
|
||||
const isWinnerTeam = !!winnerEntry;
|
||||
const isTeamMember =
|
||||
!!session?.user?.id &&
|
||||
!!decodedTeamId &&
|
||||
(myTeamsData?.data || []).some(
|
||||
(t) => (t as { id?: string } | null | undefined)?.id === decodedTeamId
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!certId) return;
|
||||
|
||||
const encodedCertId = encodeURIComponent(certId);
|
||||
const certificateUrl = `${window.location.origin}/certificate/winner/${encodedCertId}`;
|
||||
|
||||
QRCode.toDataURL(certificateUrl, {
|
||||
width: s(200),
|
||||
margin: 1,
|
||||
color: {
|
||||
dark: '#000000',
|
||||
light: '#ffffff' } })
|
||||
.then(setQrCodeUrl)
|
||||
.catch((err) => console.error('QR Code generation failed:', err));
|
||||
}, [certId]);
|
||||
|
||||
useEffect(() => {
|
||||
const generateCertificate = async () => {
|
||||
if (!certificateRef.current) return;
|
||||
if (!team?.name) return;
|
||||
if (!qrCodeUrl) return;
|
||||
if (!winnerEntry?.rank) return;
|
||||
if (isLoadingSubmission) return;
|
||||
|
||||
setIsGenerating(true);
|
||||
try {
|
||||
setShowTemplate(true);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1500));
|
||||
|
||||
const canvas = await html2canvas(certificateRef.current, {
|
||||
scale: EXPORT_SCALE,
|
||||
useCORS: true,
|
||||
backgroundColor: '#ffffff',
|
||||
logging: false,
|
||||
width: CERT_WIDTH,
|
||||
height: CERT_HEIGHT,
|
||||
allowTaint: true,
|
||||
imageTimeout: 0,
|
||||
removeContainer: true });
|
||||
|
||||
const imageUrl = canvas.toDataURL('image/png', 1.0);
|
||||
setCertificateImage(imageUrl);
|
||||
setShowTemplate(false);
|
||||
} catch (e) {
|
||||
console.error('Failed to generate certificate:', e);
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
generateCertificate();
|
||||
}, [
|
||||
team?.name,
|
||||
memberNames,
|
||||
qrCodeUrl,
|
||||
winnerEntry?.rank,
|
||||
isLoadingSubmission,
|
||||
submissionName,
|
||||
]);
|
||||
|
||||
const handleDownloadCertificate = () => {
|
||||
if (!certificateImage) return;
|
||||
|
||||
const link = document.createElement('a');
|
||||
link.href = certificateImage;
|
||||
link.download = `winner-certificate-${team?.name || 'hackathon'}.png`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
const handlePrintCertificate = () => {
|
||||
if (!certificateImage) return;
|
||||
|
||||
const printWindow = window.open('', '_blank');
|
||||
if (!printWindow) return;
|
||||
|
||||
printWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Certificate - ${team?.name}</title>
|
||||
<style>
|
||||
body { margin: 0; display: flex; justify-content: center; align-items: center; min-height: 100vh; }
|
||||
img { max-width: 100%; height: auto; }
|
||||
@media print {
|
||||
@page { size: A4 landscape; margin: 0; }
|
||||
body { margin: 0; }
|
||||
img { width: 100%; height: auto; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<img src="${certificateImage}" />
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
printWindow.onload = () => {
|
||||
printWindow.print();
|
||||
};
|
||||
};
|
||||
|
||||
if (error || !certId) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Invalid Certificate
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
{error || 'The certificate ID is invalid or malformed.'}
|
||||
</p>
|
||||
<Button onClick={() => navigate({ to: '/' })}>Back to Home</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!decodedTeamId || isLoadingTeam) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
Loading certificate...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isLoadingWinners) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
Loading winners...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isWinnersError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Unable to Load Winners
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Please try again later.
|
||||
</p>
|
||||
<Button onClick={() => navigate({ to: '/' })}>Back to Home</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isWinnerTeam) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Certificate Not Found
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
This team is not listed as a hackathon winner.
|
||||
</p>
|
||||
<Button onClick={() => navigate({ to: '/' })}>Back to Home</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!team?.name) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Team Not Found
|
||||
</h2>
|
||||
<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({ to: '/' })}>Back to Home</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700 no-print">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
Winner Certificate
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{team.name}
|
||||
</p>
|
||||
</div>
|
||||
{isTeamMember && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => navigate({ to: '/dashboard' })}
|
||||
>
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-12"
|
||||
id="certificate-wrapper"
|
||||
>
|
||||
<div
|
||||
className={showTemplate ? 'block' : 'hidden'}
|
||||
style={{ position: 'absolute', left: '-9999px' }}
|
||||
>
|
||||
<div
|
||||
ref={certificateRef}
|
||||
id="certificate-template"
|
||||
style={{
|
||||
position: 'relative',
|
||||
backgroundImage: 'url(/images/blank_winner_cert.svg)',
|
||||
backgroundSize: '100% 100%',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center',
|
||||
width: `${CERT_WIDTH}px`,
|
||||
height: `${CERT_HEIGHT}px` }}
|
||||
>
|
||||
<style>{`
|
||||
#winner-members li::marker {
|
||||
color: #59bef5;
|
||||
}
|
||||
`}</style>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '35%',
|
||||
left: '3.5%',
|
||||
width: '55%' }}
|
||||
>
|
||||
<h3
|
||||
style={{
|
||||
fontFamily: 'Poppins, sans-serif',
|
||||
fontWeight: 'bold',
|
||||
color: '#59bef5',
|
||||
textAlign: 'left',
|
||||
fontSize: `${s(28)}px`,
|
||||
lineHeight: '1.2',
|
||||
wordBreak: 'break-word',
|
||||
margin: 0 }}
|
||||
>
|
||||
{team.name}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '40.5%',
|
||||
left: '3.5%',
|
||||
width: '55%' }}
|
||||
>
|
||||
<ul
|
||||
id="winner-members"
|
||||
style={{
|
||||
margin: 0,
|
||||
fontFamily: 'Poppins, sans-serif',
|
||||
fontSize: `${s(18)}px`,
|
||||
lineHeight: '1.35',
|
||||
color: '#59bef5' }}
|
||||
>
|
||||
{(memberNames.length
|
||||
? memberNames
|
||||
: ['(Members unavailable)']
|
||||
).map((name) => (
|
||||
<li key={name}>• {name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '60%',
|
||||
left: '3.5%',
|
||||
width: '60%' }}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontFamily: 'Poppins, sans-serif',
|
||||
fontSize: `${s(18)}px`,
|
||||
lineHeight: '1.35',
|
||||
color: '#6B6B6B' }}
|
||||
>
|
||||
Diberikan sebagai penghargaan atas pencapaian meraih
|
||||
<br />
|
||||
<b>JUARA {winnerEntry.rank}</b> pada Hackathon IMPHNEN x
|
||||
Kolosal.ai
|
||||
<br />
|
||||
<span>
|
||||
dengan nama project: <b>{submissionName}</b>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{!!rankLabel && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '8%',
|
||||
right: '8.5%',
|
||||
width: `${s(190)}px`,
|
||||
display: 'flex',
|
||||
justifyContent: 'center' }}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: `${s(6)}px ${s(16)}px`,
|
||||
textAlign: 'center',
|
||||
fontFamily: 'Poppins, sans-serif',
|
||||
fontWeight: 700,
|
||||
color: '#78350F',
|
||||
fontSize: `${s(24)}px`,
|
||||
lineHeight: '1' }}
|
||||
>
|
||||
{rankLabel}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '33%',
|
||||
right: '9.3%',
|
||||
width: `${s(190)}px`,
|
||||
height: `${s(190)}px`,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center' }}
|
||||
>
|
||||
{qrCodeUrl && (
|
||||
<img
|
||||
src={qrCodeUrl}
|
||||
alt="Certificate QR Code"
|
||||
style={{
|
||||
width: `${s(190)}px`,
|
||||
height: `${s(190)}px`,
|
||||
display: 'block' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg shadow-xl dark:shadow-gray-950/50 overflow-hidden p-2">
|
||||
{isGenerating && (
|
||||
<div className="flex items-center justify-center p-12">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
Generating certificate...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{certificateImage && !isGenerating && (
|
||||
<img
|
||||
src={certificateImage}
|
||||
alt="Winner Certificate"
|
||||
className="w-full h-auto"
|
||||
style={{ maxWidth: '100%', height: 'auto' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isTeamMember && (
|
||||
<div className="bg-gray-50 dark:bg-gray-900 p-6 grid grid-cols-2 xl:grid-cols-3 gap-3 justify-center no-print">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleDownloadCertificate}
|
||||
className="flex items-center gap-2"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Icon
|
||||
icon="svg-spinners:ring-resize"
|
||||
width="18"
|
||||
height="18"
|
||||
/>
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon
|
||||
icon="heroicons:arrow-down-tray"
|
||||
width="18"
|
||||
height="18"
|
||||
/>
|
||||
Download
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handlePrintCertificate}
|
||||
className="flex items-center gap-2"
|
||||
disabled={isGenerating}
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Icon
|
||||
icon="svg-spinners:ring-resize"
|
||||
width="18"
|
||||
height="18"
|
||||
/>
|
||||
Generating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Icon icon="mdi:printer" width="18" height="18" />
|
||||
Print
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate({ to: '/dashboard' })}
|
||||
variant="secondary"
|
||||
className="col-span-2 flex items-center gap-2 xl:col-span-1"
|
||||
>
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6 no-print">
|
||||
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
|
||||
Certificate Information
|
||||
</h3>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
This certificate is a digital record of your hackathon achievement.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
|
||||
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>
|
||||
<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">
|
||||
Back to Homepage
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { FC, ReactElement } from 'react';
|
||||
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();
|
||||
|
||||
const winners = data?.data ?? [];
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
|
||||
<div className="text-gray-600 dark:text-gray-400">
|
||||
Loading winners...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="text-6xl mb-4">⚠️</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
Error Loading Winners
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Unable to load winners at this time. Please try again later.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sortedWinners = [...winners].sort((a, b) => a.rank - b.rank);
|
||||
|
||||
const getMedalEmoji = (rank: number) => {
|
||||
switch (rank) {
|
||||
case 1:
|
||||
return '🥇';
|
||||
case 2:
|
||||
return '🥈';
|
||||
case 3:
|
||||
return '🥉';
|
||||
default:
|
||||
return '🏆';
|
||||
}
|
||||
};
|
||||
|
||||
const getRankColor = (rank: number) => {
|
||||
switch (rank) {
|
||||
case 1:
|
||||
return 'from-yellow-400 to-yellow-600';
|
||||
case 2:
|
||||
return 'from-gray-300 to-gray-500';
|
||||
case 3:
|
||||
return 'from-amber-600 to-amber-800';
|
||||
default:
|
||||
return 'from-blue-500 to-blue-700';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="bg-white dark:bg-gray-900 border-b dark:border-gray-700">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-4">🏆</div>
|
||||
<h1 className="text-4xl font-bold text-gray-900 dark:text-white mb-2">
|
||||
Hackathon Winners
|
||||
</h1>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Congratulations to all the winning teams!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
{winners.length === 0 ? (
|
||||
<div className="text-center py-16">
|
||||
<div className="text-6xl mb-4">🎯</div>
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
No Winners Announced Yet
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-gray-400">
|
||||
Winners will be announced here once the hackathon concludes.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
<div className="md:hidden space-y-6">
|
||||
{[1, 2, 3].map((position) => {
|
||||
const winner = sortedWinners[position - 1];
|
||||
|
||||
if(!winner) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={winner.id}
|
||||
className={`bg-white dark:bg-gray-800 rounded-lg p-3 shadow-lg ${
|
||||
getRankColor(winner.rank).includes('yellow')
|
||||
? 'border-4 border-yellow-400 dark:border-yellow-600'
|
||||
: getRankColor(winner.rank).includes('gray')
|
||||
? 'border-4 border-gray-400 dark:border-gray-600'
|
||||
: 'border-4 border-amber-600 dark:border-amber-500'
|
||||
}}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div
|
||||
className={`aspect-square p-1 rounded-full bg-linear-to-br ${getRankColor(
|
||||
winner.rank
|
||||
)} flex items-center justify-center text-white font-bold text-2xl`}
|
||||
>
|
||||
<div className="text-4xl">
|
||||
{getMedalEmoji(winner.rank)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{winner.team.logo && (
|
||||
<img
|
||||
src={winner.team.logo}
|
||||
alt={`${winner.team.name} logo`}
|
||||
className="w-20 h-20 rounded-full object-cover border-3 border-white dark:border-gray-700 shadow-lg"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
<h2 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{winner.team.name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{winner.team.city}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex gap-8 items-end justify-center w-full">
|
||||
{[2, 1, 3].map((position) => {
|
||||
const winner = sortedWinners[position - 1];
|
||||
if(!winner) return null;
|
||||
return (
|
||||
<div
|
||||
key={winner.id}
|
||||
className="w-full max-w-xs flex flex-col items-center space-y-4"
|
||||
>
|
||||
<div className="text-5xl">{getMedalEmoji(winner.rank)}</div>
|
||||
|
||||
{winner.team.logo && (
|
||||
<img
|
||||
src={winner.team.logo}
|
||||
alt={`${winner.team.name} logo`}
|
||||
className="w-24 h-24 rounded-full object-cover border-4 border-white dark:border-gray-700 shadow-lg"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="text-center px-2">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{winner.team.name}
|
||||
</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
{winner.team.city}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={`${
|
||||
winner.rank === 1
|
||||
? 'h-42 bg-yellow-500'
|
||||
: winner.rank === 2
|
||||
? 'h-32 bg-gray-400'
|
||||
: 'h-16 bg-amber-600'
|
||||
} w-full flex items-end justify-center rounded-t-lg shadow-lg`}
|
||||
>
|
||||
<div className="text-white font-bold text-3xl pb-4">
|
||||
#{winner.rank}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{sortedWinners.filter((w) => w.rank >= 4 && w.rank <= 23).length >
|
||||
0 && (
|
||||
<div className="mt-12">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center">
|
||||
Favorite
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sortedWinners
|
||||
.filter((w) => w.rank >= 4 && w.rank <= 23)
|
||||
.map((winner) => (
|
||||
<div
|
||||
key={winner.id}
|
||||
className="bg-white dark:bg-gray-800 border-2 border-gray-400 dark:border-gray-600 rounded-lg p-4 hover:shadow-lg transition-shadow"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="shrink-0">
|
||||
<div className="w-12 h-12 rounded-full bg-linear-to-br from-gray-400 to-gray-600 flex items-center justify-center text-white font-bold text-lg">
|
||||
#{winner.rank}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{winner.team.logo && (
|
||||
<img
|
||||
src={winner.team.logo}
|
||||
alt={`${winner.team.name} logo`}
|
||||
className="w-16 h-16 rounded-full object-cover border-2 border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-bold text-gray-900 dark:text-white truncate">
|
||||
{winner.team.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{winner.team.city}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<span className="text-xs bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300 px-2 py-1 rounded-full">
|
||||
🎁 Prize Winner
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{sortedWinners.filter((w) => w.rank >= 24).length > 0 && (
|
||||
<div className="mt-12">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white mb-6 text-center">
|
||||
All Participants
|
||||
</h2>
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{sortedWinners
|
||||
.filter((w) => w.rank >= 24)
|
||||
.map((participant) => (
|
||||
<div
|
||||
key={participant.id}
|
||||
className="p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="shrink-0 w-10 text-center">
|
||||
<span className="text-sm font-semibold text-gray-600 dark:text-gray-400">
|
||||
#{participant.rank}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{participant.team.logo && (
|
||||
<img
|
||||
src={participant.team.logo}
|
||||
alt={`${participant.team.name} logo`}
|
||||
className="w-12 h-12 rounded-full object-cover border-2 border-gray-200 dark:border-gray-700"
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">
|
||||
{participant.team.name}
|
||||
</h3>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{participant.team.city}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{winners.length > 0 && (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-8">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-6">
|
||||
<h3 className="font-bold text-blue-900 dark:text-blue-100 mb-2">
|
||||
Congratulations! 🎉
|
||||
</h3>
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
Thank you to all participants for making this hackathon a success.
|
||||
Every project and idea contributed to an incredible showcase of
|
||||
innovation and creativity.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,683 @@
|
||||
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 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 = [
|
||||
{
|
||||
question: 'Siapa yang bisa mengikuti hackathon ini?',
|
||||
answer:
|
||||
'Hackathon ini terbuka untuk mahasiswa, fresh graduate, dan profesional muda yang memiliki passion di bidang teknologi. Peserta dapat mendaftar secara tim.',
|
||||
},
|
||||
{
|
||||
question: 'Apakah ada biaya pendaftaran?',
|
||||
answer:
|
||||
'Tidak, hackathon ini GRATIS dan terbuka untuk semua peserta yang memenuhi kriteria.',
|
||||
},
|
||||
{
|
||||
question: 'Apa tema hackathon kali ini?',
|
||||
answer:
|
||||
'Tema hackathon kali ini adalah "Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif".',
|
||||
},
|
||||
{
|
||||
question: 'Bagaimana format pelaksanaannya?',
|
||||
answer:
|
||||
'Hackathon dilaksanakan secara online dengan berbagai tahap mulai dari pendaftaran, technical meeting, tahap penyisihan, hingga final.',
|
||||
},
|
||||
{
|
||||
question: 'Apa hadiah lombanya?',
|
||||
answer:
|
||||
'Total hadiah senilai Rp14.500.000 dengan juara 1 mendapat Rp6.000.000, juara 2 Rp4.000.000, juara 3 Rp2.500.000, dan juara kategori lainnya Rp2.000.000.',
|
||||
},
|
||||
{
|
||||
question: 'Apakah harus membentuk tim? Boleh solo?',
|
||||
answer:
|
||||
'Tidak boleh solo. Peserta harus membentuk tim yang terdiri dari 2 - 5 orang per tim. Peserta bisa mencari anggota melalui website ini atau WA Group Hackathon.',
|
||||
},
|
||||
{
|
||||
question: 'Apakah boleh menggunakan AI (vibe coding)?',
|
||||
answer:
|
||||
'Ya, peserta diperbolehkan menggunakan AI tools untuk membantu development. Kami merekomendasikan menggunakan Kolosal.ai selama proses development (Free Credit)',
|
||||
},
|
||||
{
|
||||
question: 'Apa project wajib di-deploy?',
|
||||
answer:
|
||||
'Diusahakan project agar di-deploy dan dapat diakses secara online untuk meningkatkan penilaian.',
|
||||
},
|
||||
{
|
||||
question: 'Apakah peserta mendapatkan sertifikat?',
|
||||
answer:
|
||||
'Kami akan memberikan sertifikat kepada tim yang submit project dan menyelesaikan rangkaian hackathon.',
|
||||
},
|
||||
{
|
||||
question: 'Website error dan terjadi masalah?',
|
||||
answer:
|
||||
'Jika menemukan masalah teknis, silakan hubungi kami melalui grup WA Hackathon.',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-white dark:bg-gray-950">
|
||||
|
||||
<div id="#top" className="hidden"></div>
|
||||
<nav className="border-b border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 sticky top-0 z-50">
|
||||
<div className="flex items-center justify-between max-w-7xl mx-auto px-4 md:px-8 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg md:text-xl font-bold dark:text-white">
|
||||
IMPHNEN
|
||||
</span>
|
||||
<a
|
||||
href="#top"
|
||||
className="text-lg md:text-xl font-bold text-primary-500 hover:cursor-pointer"
|
||||
>
|
||||
Hackathon
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:flex text-label1 items-center gap-4 lg:gap-8">
|
||||
<a
|
||||
href="#timeline"
|
||||
className="text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
Timeline
|
||||
</a>
|
||||
<a
|
||||
href="#hadiah"
|
||||
className="text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
Hadiah
|
||||
</a>
|
||||
<a
|
||||
href="#faq"
|
||||
className="text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white transition-colors"
|
||||
>
|
||||
FAQ
|
||||
</a>
|
||||
<ThemeToggle />
|
||||
{isAuthenticated ? (
|
||||
<Button
|
||||
onClick={() => navigate({ to: '/dashboard' })}
|
||||
size="sm"
|
||||
className="rounded-lg text-base"
|
||||
>
|
||||
Dashboard
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => navigate({ to: '/auth/login' })}
|
||||
size="sm"
|
||||
variant="bordered"
|
||||
className="rounded-lg text-base dark:bg-gray-800"
|
||||
>
|
||||
Masuk
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate({ to: '/auth/signup' })}
|
||||
size="sm"
|
||||
className="rounded-lg text-base"
|
||||
>
|
||||
Daftar Sekarang
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 md:hidden">
|
||||
<ThemeToggle />
|
||||
<button
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
className="p-2 cursor-pointer text-gray-900 dark:text-white"
|
||||
>
|
||||
<svg
|
||||
className="w-6 h-6"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M4 6h16M4 12h16M4 18h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{mobileMenuOpen && (
|
||||
<nav className="md:hidden sticky top-18 border-b border-gray-200 dark:border-gray-800 bg-white z-40 dark:bg-gray-900">
|
||||
<div className="flex flex-col items-start gap-4 px-4 py-4">
|
||||
<a
|
||||
href="#timeline"
|
||||
className="ms-3 text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white"
|
||||
>
|
||||
Timeline
|
||||
</a>
|
||||
<a
|
||||
href="#hadiah"
|
||||
className="ms-3 text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white"
|
||||
>
|
||||
Hadiah
|
||||
</a>
|
||||
<a
|
||||
href="#faq"
|
||||
className="ms-3 text-gray-600 dark:text-gray-200 hover:text-gray-900 dark:hover:text-white"
|
||||
>
|
||||
FAQ
|
||||
</a>
|
||||
{isAuthenticated ? (
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
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({ 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
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<section className="relative w-full overflow-hidden py-20">
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div
|
||||
className="absolute top-1/4 -left-20 w-100 h-100 rounded-full bg-linear-to-r from-primary/20 to-blue-400/20 blur-3xl"
|
||||
style={{ transform: 'translate(10px, -5px)', opacity: 0.9 }}
|
||||
></div>
|
||||
<div
|
||||
className="absolute bottom-1/3 -right-20 w-100 h-100 rounded-full bg-linear-to-r from-blue-400/20 to-primary/20 blur-3xl"
|
||||
style={{ transform: 'translate(0px, 0px)', opacity: 1 }}
|
||||
></div>
|
||||
<div className="absolute inset-0 bg-[linear-gradient(rgba(59,130,246,0.05)_1px,transparent_1px),linear-gradient(to_right,rgba(59,130,246,0.05)_1px,transparent_1px)] dark:bg-[linear-gradient(rgba(59,130,246,0.1)_1px,transparent_1px),linear-gradient(to_right,rgba(59,130,246,0.1)_1px,transparent_1px)] bg-size-[40px_40px]"></div>
|
||||
</div>
|
||||
<div className="mx-auto container px-4 relative flex flex-col items-center">
|
||||
|
||||
<div className="flex items-center gap-4 md:gap-8 lg:gap-12 mb-8 md:mb-12 lg:mb-16 flex-wrap justify-center">
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
src="images/imphnen-logo.svg"
|
||||
alt="IMPHNEN"
|
||||
className="h-12 md:h-16"
|
||||
/>
|
||||
</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
|
||||
src="images/sponsors/kolosal-logo_rlxbck.svg"
|
||||
alt="Kolosal.ai"
|
||||
className="h-8 md:h-12 dark:invert"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-h1 font-bold text-gray-900 dark:text-white mb-4 text-center">
|
||||
Hackathon
|
||||
</h1>
|
||||
|
||||
<p className="text-p1 text-primary-500 font-semibold mb-6 md:mb-8 text-center px-4">
|
||||
"Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif"
|
||||
</p>
|
||||
|
||||
<p className="text-p3 text-gray-600 dark:text-gray-200 max-w-lg md:max-w-xl text-center mb-8 md:mb-12 px-4 font-sans">
|
||||
Kompetisi pengembangan teknologi untuk menciptakan solusi inovatif
|
||||
yang menghadirkan dampak nyata
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center gap-4 md:gap-8 mb-10 md:mb-16 text-base text-gray-600 dark:text-gray-200">
|
||||
<div className="flex items-center gap-2 md:gap-3">
|
||||
<Icon icon="streamline-plump:web" className="w-4 h-4" />
|
||||
<span>Online</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 md:gap-3 text-center">
|
||||
<Icon icon="heroicons:clock" className="w-4 h-4" />
|
||||
<span>Pendaftaran hingga 30 November 2025</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row items-center gap-4 px-4">
|
||||
<Button
|
||||
onClick={() => navigate({ to: '/auth/signup' })}
|
||||
className="rounded-lg text-base max-h-auto"
|
||||
>
|
||||
Daftar Sekarang
|
||||
</Button>
|
||||
<a
|
||||
href="https://chat.whatsapp.com/BlxrYh9uSC37d7VPhJslGL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center justify-center px-4 py-2.5 border-2 border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500 transition-colors text-center bg-transparent hover:text-gray-900 dark:hover:text-white hover:bg-gray-50 dark:hover:bg-gray-800 text-base text-gray-600 dark:text-gray-200 dark:bg-gray-800 rounded-lg font-bai-jamjuree font-semibold"
|
||||
>
|
||||
Gabung Grup WA Hackathon
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 md:mt-20">
|
||||
<svg
|
||||
className="w-6 h-6 text-gray-400 dark:text-gray-500 animate-bounce"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 14l-7 7m0 0l-7-7m7 7V3"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-16 md:py-24 px-4 md:px-8 bg-white dark:bg-gray-950">
|
||||
<div className="max-w-4xl mx-auto text-center">
|
||||
<h2 className="text-3xl md:text-5xl font-bold mb-10 dark:text-white">
|
||||
Tentang <span className="text-primary-500">Hackathon</span>
|
||||
</h2>
|
||||
<p className="font-sans text-lg md:text-xl text-left md:text-center text-gray-600 dark:text-gray-200 mb-6">
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
Hackathon
|
||||
</span>{' '}
|
||||
adalah kompetisi pengembangan teknologi yang mengajak talenta muda
|
||||
untuk berkolaborasi dalam menciptakan solusi inovatif.
|
||||
</p>
|
||||
<p className="font-sans text-lg md:text-xl text-left md:text-center text-gray-600 dark:text-gray-200">
|
||||
IMPHNEN bersama Kolosal.ai mengadakan Hackathon dengan tema lomba{' '}
|
||||
<span className="font-semibold text-primary-500">
|
||||
"Inovasi AI: Mendorong Usaha Lokal dengan AI Inklusif"
|
||||
</span>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="hadiah"
|
||||
className="py-16 md:py-24 px-4 md:px-8 bg-gray-50 dark:bg-linear-to-b dark:from-gray-950 dark:to-gray-900"
|
||||
>
|
||||
<div className="max-w-lg md:max-w-6xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl md:text-5xl font-bold mb-4 font-bai-jamjuree dark:text-white">
|
||||
Hadiah <span className="text-primary-500">Menarik</span>
|
||||
</h2>
|
||||
<p className="text-xl md:text-2xl text-primary-500 font-semibold font-sans">
|
||||
Total Prize Pool Rp14.500.000
|
||||
</p>
|
||||
</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" />
|
||||
</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>
|
||||
</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" />
|
||||
</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>
|
||||
</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" />
|
||||
</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>
|
||||
</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" />
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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">
|
||||
Timeline <span className="text-primary-500">Acara</span>
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-200">
|
||||
Jadwal lengkap pelaksanaan 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">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-gray-600 dark:text-gray-200">
|
||||
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>
|
||||
</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>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-16 md:py-24 px-4 md:px-8 bg-gray-50 dark:bg-gray-900 font-sans">
|
||||
<div className="md:max-w-6xl mx-auto">
|
||||
<div className="text-center mb-12 font-bai-jamjuree">
|
||||
<h2 className="text-3xl md:text-5xl font-bold mb-4 dark:text-white">
|
||||
Dewan <span className="text-primary-500">Juri</span>
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-200">
|
||||
Perwakilan dari IMPHNEN dan Kolosal.ai yang akan menilai karya
|
||||
</p>
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
<div>
|
||||
<p className="text-primary-500 font-semibold mb-1">Moderator</p>
|
||||
<p className="text-gray-600 dark:text-gray-200">IMPHNEN</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="faq"
|
||||
className="py-16 md:py-24 px-4 md:px-8 bg-white dark:bg-gray-950"
|
||||
>
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl md:text-5xl font-bold mb-4 text-primary-500">
|
||||
FAQ
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-200">
|
||||
Pertanyaan yang Sering Diajukan
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 font-sans">
|
||||
{faqs.map((faq, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="border border-gray-200 dark:border-gray-700 rounded-lg dark:bg-gray-900"
|
||||
>
|
||||
<button
|
||||
onClick={() => setOpenFaq(openFaq === index ? null : index)}
|
||||
className="w-full px-6 py-4 flex items-center justify-between text-left hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors cursor-pointer"
|
||||
>
|
||||
<span className="font-semibold text-gray-900 dark:text-white">
|
||||
{faq.question}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-5 h-5 text-primary-500 transform transition-transform ${
|
||||
openFaq === index ? 'rotate-180' : ''
|
||||
}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 9l-7 7-7-7"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{openFaq === index && (
|
||||
<div className="px-6 pb-4 text-gray-600 dark:text-gray-200">
|
||||
{faq.answer}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="py-20 md:py-28 px-4 md:px-8 bg-gray-50 dark:bg-gray-900">
|
||||
<div className="max-w-6xl mx-auto text-center">
|
||||
<h2 className="text-3xl md:text-5xl font-bold mb-4 dark:text-white">
|
||||
Sponsor & <span className="text-primary-500">Partner</span>
|
||||
</h2>
|
||||
<p className="text-lg text-gray-600 dark:text-gray-200 mb-8">
|
||||
Acara ini sepenuhnya disponsori oleh
|
||||
</p>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<a
|
||||
href="https://kolosal.ai"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="bg-white dark:bg-gray-800 rounded-xl p-8 shadow-lg inline-block border-2 border-transparent hover:border-gray-500 dark:hover:border-gray-500 transition-colors"
|
||||
>
|
||||
<img
|
||||
src="images/sponsors/kolosal-logo_rlxbck.svg"
|
||||
alt="Kolosal.ai"
|
||||
className="h-12 md:h-16 dark:invert"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section
|
||||
id="masuk"
|
||||
className="py-20 md:py-28 px-4 md:px-8 bg-linear-to-b from-white to-blue-50 dark:from-gray-950 dark:to-gray-900"
|
||||
>
|
||||
<div className="max-w-4xl mx-auto text-center font-sans">
|
||||
<h2 className="text-3xl md:text-5xl font-bold mb-6 font-bai-jamjuree dark:text-white">
|
||||
Segera Daftarkan <span className="text-primary-500">Timmu!</span>
|
||||
</h2>
|
||||
<p className="text-lg md:text-xl text-left md:text-center text-gray-600 dark:text-gray-200 mb-8">
|
||||
Jangan lewatkan kesempatan emas untuk bersaing dengan developer
|
||||
terbaik,
|
||||
<br className="hidden md:block" /> belajar dari para ahli, dan
|
||||
memenangkan hadiah jutaan rupiah!
|
||||
</p>
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4 justify-center">
|
||||
<button
|
||||
onClick={() => navigate({ 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
|
||||
</button>
|
||||
<a
|
||||
href="https://chat.whatsapp.com/BlxrYh9uSC37d7VPhJslGL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-8 py-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-lg rounded-lg border-2 border-gray-300 dark:border-gray-600 hover:border-gray-400 dark:hover:border-gray-500 transition-colors font-semibold"
|
||||
>
|
||||
Gabung Grup WA Hackathon
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-500 dark:text-gray-500 mt-6">
|
||||
Pendaftaran ditutup pada{' '}
|
||||
<span className="text-primary-500 font-semibold">
|
||||
30 November 2025
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">
|
||||
Wujudkan ide brilian mu menjadi solusi nyata. Bergabunglah dalam
|
||||
IMPHNEN Hackathon dan jadilah bagian dari perubahan teknologi
|
||||
masa depan.
|
||||
</p>
|
||||
<div className="flex gap-4 mt-4">
|
||||
<a href="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>
|
||||
<ul className="space-y-2 text-gray-400">
|
||||
<li><a href="#timeline" className="hover:text-white transition-colors">Timeline</a></li>
|
||||
<li><a href="#hadiah" className="hover:text-white transition-colors">Hadiah</a></li>
|
||||
<li><a href="#faq" className="hover:text-white transition-colors">FAQ</a></li>
|
||||
<li><a href="/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>
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-800 pt-8 text-center text-gray-400 text-sm">
|
||||
<p>
|
||||
© 2025 IMPHNEN - Ingin Menjadi Programmer Handal Namun Enggan
|
||||
Ngoding. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user