-
-
-
- {user?.avatar && (
-

- )}
-
-
{user?.fullname}
- {user?.location ? (
-
-
- {user.location}
-
- ) : (
-
- Complete your profile β
-
- )}
-
-
-
-
- {user?.bio && (
-
-
About
-
{user.bio}
+
{team.description}
- )}
+
+ ))}
+
+
+ ) : (
+
+
+
Wave
+
You are not in a team yet
+
Use the sidebar to browse teams or create your own
+
+
+ )}
+
+
+
+
+ {user?.avatar ? (
+

+ ) : (
+
+ User
+
+ )}
+
+ {/* User Name and Edit profile button in one line */}
+
+
{user?.fullname || user?.email?.split('@')[0] || 'Unnamed User'}
+ Edit Profile
+
+ {user?.location ? (
+
{user.location}
+ ) : (
+
Complete your profile
+ )}
+
+
+
+ {user?.bio && (
-
Contact
-
-
-
{user?.email}
+
About
+
{user.bio}
+
+ )}
+
+
Contact
+
+ {user?.email}
+
+
+ {user?.skills && user.skills.length > 0 && (
+
+
Skills
+
+ {user.skills.map((skill: string) => (
+ {skill}
+ ))}
-
- {user?.skills && user.skills.length > 0 && (
-
-
Skills
-
- {user.skills.map((skill: string) => (
-
- {skill}
-
- ))}
-
-
- )}
-
+ )}
diff --git a/apps/hackathon/src/app/profile/page.tsx b/apps/hackathon/src/app/profile/page.tsx
new file mode 100644
index 0000000..7e07f3b
--- /dev/null
+++ b/apps/hackathon/src/app/profile/page.tsx
@@ -0,0 +1,245 @@
+import { FC, ReactElement, useState, useEffect } from 'react';
+import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
+import { Button } from '@imphnen-frontend-service/ui/atoms';
+import { useNavigate, Link } from 'react-router';
+import { useForm } 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';
+
+const ProfilePage: FC = (): ReactElement => {
+ const navigate = useNavigate();
+ const [avatarFile, setAvatarFile] = useState
(null);
+ const [avatarPreview, setAvatarPreview] = useState('');
+
+ const { mutateAsync: updateUser, isPending: isUpdating } = useUpdateUserMe();
+ const { mutateAsync: uploadAvatar, isPending: isUploading } = useUploadAvatar();
+ const { session } = useAuthStore();
+
+ const form = useForm({
+ resolver: zodResolver(userEditProfileSchema),
+ mode: 'all',
+ defaultValues: {
+ fullname: session?.user?.fullname || '',
+ avatar: session?.user?.avatar || null,
+ },
+ });
+
+ // Set initial avatar preview from current user avatar
+ useEffect(() => {
+ if (session?.user?.avatar && !avatarPreview) {
+ setAvatarPreview(session.user.avatar);
+ }
+ if (session?.user?.fullname) {
+ form.setValue('fullname', session.user.fullname);
+ }
+ }, [session?.user?.avatar, session?.user?.fullname, avatarPreview, form]);
+
+ const handleAvatarChange = (e: React.ChangeEvent) => {
+ const file = e.target.files?.[0];
+ if (file) {
+ // Validate file size (max 5MB)
+ if (file.size > 5 * 1024 * 1024) {
+ toast.error('The file is too large. Maximum 5MB');
+ return;
+ }
+
+ // Validate file type
+ if (!file.type.startsWith('image/')) {
+ toast.error('The file must be an image');
+ 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;
+
+ // Upload avatar if a new file was selected
+ if (avatarFile) {
+ const uploadResult = await uploadAvatar(avatarFile);
+ avatarUrl = uploadResult.data.url;
+ }
+
+ // Update user profile
+ await updateUser({
+ fullname: data.fullname,
+ avatar: avatarUrl,
+ });
+
+ toast.success('Profile updated successfully!');
+
+ // Wait a bit for the onSuccess handler to update localStorage
+ await new Promise((resolve) => setTimeout(resolve, 100));
+
+ // Navigate back to dashboard
+ navigate('/dashboard');
+ } 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 (
+
+
+
+
+
Edit Profile
+
+
+
+
+
Update your photo and name
+
+
+
+
+
+ );
+};
+
+export default ProfilePage;
diff --git a/apps/hackathon/src/app/teams/layout.tsx b/apps/hackathon/src/app/teams/layout.tsx
new file mode 100644
index 0000000..182d6ad
--- /dev/null
+++ b/apps/hackathon/src/app/teams/layout.tsx
@@ -0,0 +1,34 @@
+import { FC, ReactElement, useState } from 'react';
+import { Outlet } from 'react-router';
+import { Sidebar } from '../../components/sidebar';
+
+const TeamsLayout: FC = (): ReactElement => {
+ const [sidebarOpen, setSidebarOpen] = useState(false);
+
+ return (
+
+
setSidebarOpen(false)} />
+
+
+ {/* Mobile Header with Hamburger */}
+
+
+
π Hackathon
+
+
+
+
+
+
+
+ );
+};
+
+export default TeamsLayout;
diff --git a/apps/hackathon/src/components/sidebar.tsx b/apps/hackathon/src/components/sidebar.tsx
new file mode 100644
index 0000000..7f38df9
--- /dev/null
+++ b/apps/hackathon/src/components/sidebar.tsx
@@ -0,0 +1,200 @@
+import { FC, useState, useEffect } from 'react';
+import { Link, useLocation } from 'react-router';
+import { useMyTeams, useAuthStore, supabase } from '@imphnen-frontend-service/service';
+import { useNavigate } from 'react-router';
+import { toast } from 'sonner';
+
+interface NavItem {
+ name: string;
+ path: string;
+ icon: React.ReactNode;
+ show: boolean;
+}
+
+interface SidebarProps {
+ isOpen?: boolean;
+ onClose?: () => void;
+}
+
+export const Sidebar: FC = ({ isOpen = true, onClose }) => {
+ const location = useLocation();
+ const navigate = useNavigate();
+ const { session, clearSession } = useAuthStore();
+ const { data: teamsData } = useMyTeams();
+
+ const user = session?.user;
+ const myTeams = teamsData?.data || [];
+ const hasTeam = myTeams.length > 0;
+
+ // Close sidebar on route change (mobile)
+ useEffect(() => {
+ if (onClose) {
+ onClose();
+ }
+ }, [location.pathname]);
+
+ const handleLogout = async () => {
+ try {
+ await supabase.auth.signOut();
+ clearSession();
+ localStorage.clear();
+ toast.success('Logged out successfully');
+ navigate('/auth/login');
+ } catch (error) {
+ console.error('Logout error:', error);
+ clearSession();
+ localStorage.clear();
+ navigate('/auth/login');
+ }
+ };
+
+ const navItems: NavItem[] = [
+ {
+ name: 'Dashboard',
+ path: '/dashboard',
+ icon: (
+
+ ),
+ show: true,
+ },
+ {
+ name: 'Browse Teams',
+ path: '/teams/browse',
+ icon: (
+
+ ),
+ show: true,
+ },
+ {
+ name: 'Create Team',
+ path: '/teams/create',
+ icon: (
+
+ ),
+ show: !hasTeam,
+ },
+ {
+ name: 'Edit Profile',
+ path: '/profile',
+ icon: (
+
+ ),
+ show: false,
+ },
+ ];
+
+ const sidebarContent = (
+
+ {/* Logo / Brand with Close Button */}
+
+
π Hackathon
+ {onClose && (
+
+ )}
+
+
+ {/* User Info */}
+
+
+ {user?.avatar ? (
+

+ ) : (
+
+ π€
+
+ )}
+
+
+ {user?.fullname || user?.email?.split('@')[0] || 'User'}
+
+
{user?.email}
+
+
+
+
+ {/* Navigation */}
+
+
+ {/* Logout Button */}
+
+
+ );
+
+ return (
+ <>
+ {/* Desktop Sidebar - Always visible on lg+, sticky position */}
+
+ {sidebarContent}
+
+
+ {/* Mobile Sidebar - Overlay */}
+ {isOpen && (
+
+ {/* Backdrop */}
+
+ {/* Sidebar */}
+
+ {sidebarContent}
+
+
+ )}
+ >
+ );
+};
+
+export default Sidebar;
diff --git a/libs/service/src/schemas/teams/index.ts b/libs/service/src/schemas/teams/index.ts
index ee775cf..056bb48 100644
--- a/libs/service/src/schemas/teams/index.ts
+++ b/libs/service/src/schemas/teams/index.ts
@@ -73,9 +73,15 @@ export const userOnboardingSchema = z.object({
skills: z.array(z.string()).optional(),
});
+export const userEditProfileSchema = z.object({
+ fullname: z.string().min(3, 'Nama lengkap minimal 3 karakter'),
+ avatar: z.string().url('Avatar harus berupa URL yang valid').nullable().optional(),
+});
+
export type TTeamCreateForm = z.infer;
export type TTeamUpdateForm = z.infer;
export type TInviteMemberForm = z.infer;
export type TJoinTeamForm = z.infer;
export type TProjectSubmissionForm = z.infer;
export type TUserOnboardingForm = z.infer;
+export type TUserEditProfileForm = z.infer;