Merge branch 'develop' into hackathon/fix-ui

This commit is contained in:
Hafid Nur
2025-11-26 13:51:51 +07:00
11 changed files with 624 additions and 137 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

+33 -32
View File
@@ -73,61 +73,62 @@ const DashboardPage: FC = (): ReactElement => {
)}
{myTeams.length > 0 ? (
<div className="mb-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">My Team</h2>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
<div className="mb-6 md:mb-8">
<h2 className="text-xl md:text-2xl font-bold text-gray-900 mb-3 md:mb-4">My Team</h2>
<div className="grid gap-4 md:gap-6 md:grid-cols-2 lg:grid-cols-3">
{myTeams.map((team) => (
<Link key={team.id} to={'/teams/' + team.id} className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
{team.banner && <img src={team.banner} alt={team.name} className="w-full h-32 object-cover" />}
<div className="p-6">
<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="p-4 md:p-6">
<div className="flex items-center space-x-3 mb-2 md:mb-3">
{team.logo && <img src={team.logo} alt={team.name} className="w-10 h-10 md:w-12 md:h-12 rounded-full object-cover" />}
<div>
<h3 className="text-lg font-bold text-gray-900">{team.name}</h3>
<p className="text-sm text-gray-600">City: {team.city}</p>
<h3 className="text-base md:text-lg font-bold text-gray-900">{team.name}</h3>
<p className="text-xs md:text-sm text-gray-600">City: {team.city}</p>
</div>
</div>
<p className="text-gray-600 text-sm line-clamp-2">{team.description}</p>
<p className="text-gray-600 text-xs md:text-sm line-clamp-2">{team.description}</p>
</div>
</Link>
))}
</div>
</div>
) : (
<div className="mb-8 bg-white rounded-lg shadow-md p-8">
<div className="mb-6 md:mb-8 bg-white rounded-lg shadow-md p-6 md:p-8">
<div className="text-center">
<div className="text-4xl mb-3">Wave</div>
<h2 className="text-2xl font-bold text-gray-900 mb-2">You are not in a team yet</h2>
<p className="text-gray-600">Use the sidebar to browse teams or create your own</p>
<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 mb-1 md:mb-2">You are not in a team yet</h2>
<p className="text-sm md:text-base text-gray-600">Use the sidebar to browse teams or create your own</p>
</div>
</div>
)}
<div className="mt-8 bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl shadow-lg">
<div className="bg-gradient-to-r from-blue-600 to-indigo-600 h-24 rounded-t-xl"></div>
<div className="px-8 pb-8">
<div className="flex items-start -mt-12 mb-6">
{user?.avatar ? (
<img src={user.avatar} alt={user.fullname || 'User'} className="w-24 h-24 rounded-full border-4 border-white shadow-lg object-cover" />
) : (
<div className="w-24 h-24 rounded-full border-4 border-white shadow-lg bg-gray-200 flex items-center justify-center">
<span className="text-gray-400 text-4xl">User</span>
</div>
)}
<div className="ml-6 mt-14">
{/* User Name and Edit profile button in one line */}
<div className="flex items-center space-x-4">
<h2 className="text-2xl font-bold text-gray-900">{user?.fullname || user?.email?.split('@')[0] || 'Unnamed User'}</h2>
<Link to="/profile" className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg text-sm font-medium shadow-sm hover:bg-blue-700 transition-colors">Edit Profile</Link>
</div>
{user?.location ? (
<p className="text-gray-600 flex items-center mt-1">{user.location}</p>
<div className="px-4 md:px-8 pb-4 md:pb-8">
<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 shadow-lg object-cover" />
) : (
<Link to="/onboarding/user" className="text-blue-600 hover:text-blue-700 text-sm mt-1 inline-block">Complete your profile</Link>
<div className="w-20 h-20 md:w-24 md:h-24 rounded-full border-4 border-white shadow-lg bg-gray-200 flex items-center justify-center">
<span className="text-gray-400 text-3xl md:text-4xl">User</span>
</div>
)}
<div className="md:ml-6 mt-4 md:mt-14">
<h2 className="text-xl md:text-2xl font-bold text-gray-900">{user?.fullname || user?.email?.split('@')[0] || 'Unnamed User'}</h2>
{user?.location ? (
<p className="text-gray-600 flex items-center mt-1 text-sm md:text-base">{user.location}</p>
) : (
<Link to="/onboarding/user" className="text-blue-600 hover:text-blue-700 text-xs md:text-sm mt-1 inline-block">Complete your profile</Link>
)}
</div>
</div>
{user?.location && (
<Link to="/profile" 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-blue-600 text-white rounded-lg text-xs md:text-sm font-medium shadow-sm hover:bg-blue-700 transition-colors w-full md:w-auto">Edit Profile</Link>
)}
</div>
<div className="space-y-6">
<div className="space-y-4 md:space-y-6">
{user?.bio && (
<div className="bg-white rounded-lg p-4 shadow-sm">
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-2">About</h3>
+111 -3
View File
@@ -1,8 +1,8 @@
import { FC, ReactElement, useState, useEffect } from 'react';
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Button, Textarea } from '@imphnen-frontend-service/ui/atoms';
import { useNavigate, Link } from 'react-router';
import { useForm } from 'react-hook-form';
import { useForm, Controller } from 'react-hook-form';
import {
userEditProfileSchema,
TUserEditProfileForm,
@@ -12,6 +12,18 @@ import {
} from '@imphnen-frontend-service/service';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
import { CitySelect } from '../../components/city-select';
const ROLE_OPTIONS = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
];
const ProfilePage: FC = (): ReactElement => {
const navigate = useNavigate();
@@ -28,6 +40,9 @@ const ProfilePage: FC = (): ReactElement => {
defaultValues: {
fullname: session?.user?.fullname || '',
avatar: session?.user?.avatar || null,
location: session?.user?.location || '',
bio: session?.user?.bio || '',
skills: session?.user?.skills || [],
},
});
@@ -39,7 +54,16 @@ const ProfilePage: FC = (): ReactElement => {
if (session?.user?.fullname) {
form.setValue('fullname', session.user.fullname);
}
}, [session?.user?.avatar, session?.user?.fullname, avatarPreview, form]);
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];
@@ -79,6 +103,9 @@ const ProfilePage: FC = (): ReactElement => {
await updateUser({
fullname: data.fullname,
avatar: avatarUrl,
location: data.location,
bio: data.bio,
skills: data.skills,
});
toast.success('Profile updated successfully!');
@@ -192,6 +219,87 @@ const ProfilePage: FC = (): ReactElement => {
name="fullname"
/>
{/* City */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
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>
{/* Role/Skills */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
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 text-blue-600 focus:ring-blue-500"
/>
<span className="text-sm">{role}</span>
</label>
))}
</div>
</div>
)}
/>
</div>
{/* Bio */}
<div className="space-y-2">
<label className="block text-sm font-medium text-gray-700">
Bio <span className="text-gray-400">(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"
/>
{fieldState.error && (
<p className="text-sm text-red-500 mt-1">
{fieldState.error.message}
</p>
)}
</div>
)}
/>
</div>
{/* Action Buttons */}
<div className="flex space-x-3 pt-4">
<Button
@@ -15,6 +15,7 @@ import {
} from '@imphnen-frontend-service/service';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { toast } from 'sonner';
const ManageMembersPage: FC = (): ReactElement => {
const { teamId } = useParams<{ teamId: string }>();
@@ -75,16 +76,20 @@ const ManageMembersPage: FC = (): ReactElement => {
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');
}
};
+169 -67
View File
@@ -1,7 +1,15 @@
import { FC, ReactElement, useState, useEffect } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Link, useParams, useNavigate } from 'react-router';
import { useTeamById, useTeamMembers, useInviteMember, useTeamJoinRequests, useRespondToJoinRequest, ETeamMemberRole, useAuthStore } from '@imphnen-frontend-service/service';
import {
useTeamById,
useTeamMembers,
useInviteMember,
useTeamJoinRequests,
useRespondToJoinRequest,
ETeamMemberRole,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
const MAX_TEAM_MEMBERS = 5;
@@ -18,12 +26,16 @@ const ImageWithLoader: FC<{
return (
<div className="relative">
{!isLoaded && (
<div className={`absolute inset-0 bg-gray-200 animate-pulse ${className}`} />
<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`}
className={`${className} ${
isLoaded ? 'opacity-100' : 'opacity-0'
} transition-opacity duration-300`}
onLoad={() => {
setIsLoaded(true);
onLoad?.();
@@ -43,23 +55,36 @@ const TeamDashboardPage: FC = (): ReactElement => {
const [imagesLoaded, setImagesLoaded] = useState(false);
const [imageLoadCount, setImageLoadCount] = useState(0);
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(teamId || '');
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(teamId || '');
const { data: joinRequestsData } = useTeamJoinRequests(teamId || '', !!teamId);
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(teamId || '');
const { mutateAsync: respondToJoinRequest, isPending: isResponding } = useRespondToJoinRequest(teamId || '');
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
teamId || ''
);
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(
teamId || ''
);
const { data: joinRequestsData } = useTeamJoinRequests(
teamId || '',
!!teamId
);
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(
teamId || ''
);
const { mutateAsync: respondToJoinRequest, isPending: isResponding } =
useRespondToJoinRequest(teamId || '');
const team = teamData?.data;
const members = membersData?.data || [];
const joinRequests = joinRequestsData?.data || [];
const pendingJoinRequests = joinRequests.filter((req: any) => req.status === 'pending');
const pendingJoinRequests = joinRequests.filter(
(req: any) => req.status === 'pending'
);
const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id;
const canInvite = isLeader && members.length < MAX_TEAM_MEMBERS;
// Calculate total images to load
const totalImagesToLoad = (team?.banner ? 1 : 0) +
const totalImagesToLoad =
(team?.banner ? 1 : 0) +
(team?.logo ? 1 : 0) +
members.filter((m: any) => m.user?.avatar).length;
@@ -72,7 +97,13 @@ const TeamDashboardPage: FC = (): ReactElement => {
setImagesLoaded(true);
}
}
}, [isLoadingTeam, isLoadingMembers, team, imageLoadCount, totalImagesToLoad]);
}, [
isLoadingTeam,
isLoadingMembers,
team,
imageLoadCount,
totalImagesToLoad,
]);
// Fallback timeout - if images take too long, show content anyway
useEffect(() => {
@@ -93,7 +124,11 @@ const TeamDashboardPage: FC = (): ReactElement => {
setImageLoadCount((prev) => prev + 1);
};
console.log('Leader check:', { currentUserId, leaderId: team?.leader_id, isLeader });
console.log('Leader check:', {
currentUserId,
leaderId: team?.leader_id,
isLeader,
});
const handleInviteMember = async (e: React.FormEvent) => {
e.preventDefault();
@@ -110,10 +145,15 @@ const TeamDashboardPage: FC = (): ReactElement => {
}
};
const handleRespondToJoinRequest = async (requestId: string, action: 'approve' | 'reject') => {
const handleRespondToJoinRequest = async (
requestId: string,
action: 'approve' | 'reject'
) => {
try {
await respondToJoinRequest({ requestId, action });
toast.success(action === 'approve' ? 'Request approved!' : 'Request rejected');
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');
@@ -178,7 +218,9 @@ const TeamDashboardPage: FC = (): ReactElement => {
<div className="fixed inset-0 bg-white/60 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 team data...</p>
<p className="mt-4 text-gray-600 font-medium">
Loading team data...
</p>
</div>
</div>
</div>
@@ -188,8 +230,12 @@ const TeamDashboardPage: FC = (): ReactElement => {
if (!team) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Team not found</h2>
<Button onClick={() => navigate('/dashboard')}>Back to Dashboard</Button>
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Team not found
</h2>
<Button onClick={() => navigate('/dashboard')}>
Back to Dashboard
</Button>
</div>
);
}
@@ -202,7 +248,9 @@ const TeamDashboardPage: FC = (): ReactElement => {
<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>
<p className="text-sm text-gray-400 mt-1">
{imageLoadCount} / {totalImagesToLoad}
</p>
</div>
</div>
)}
@@ -210,20 +258,26 @@ const TeamDashboardPage: FC = (): ReactElement => {
{/* Header with Banner */}
<div className="bg-white border-b">
{team.banner && (
<div className="w-full h-48 overflow-hidden relative">
<div className={`absolute inset-0 bg-gray-200 animate-pulse ${imagesLoaded ? 'hidden' : ''}`} />
<div className="w-full h-32 md:h-48 overflow-hidden relative">
<div
className={`absolute inset-0 bg-gray-200 animate-pulse ${
imagesLoaded ? 'hidden' : ''
}`}
/>
<img
src={team.banner}
alt={team.name}
className={`w-full h-full object-cover transition-opacity duration-300 ${imagesLoaded ? 'opacity-100' : 'opacity-0'}`}
className={`w-full h-full object-cover transition-opacity duration-300 ${
imagesLoaded ? 'opacity-100' : 'opacity-0'
}`}
onLoad={handleImageLoad}
onError={handleImageError}
/>
</div>
)}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<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-4">
<div className="flex items-center space-x-3 md:space-x-4">
{team.logo && (
<div className="relative">
{!imagesLoaded && (
@@ -232,51 +286,64 @@ const TeamDashboardPage: FC = (): ReactElement => {
<img
src={team.logo}
alt={team.name}
className={`w-20 h-20 rounded-full object-cover border-4 border-white shadow-lg -mt-10 transition-opacity duration-300 ${imagesLoaded ? 'opacity-100' : 'opacity-0'}`}
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 ${
imagesLoaded ? 'opacity-100' : 'opacity-0'
}`}
onLoad={handleImageLoad}
onError={handleImageError}
/>
</div>
)}
<div>
<h1 className="text-3xl font-bold text-gray-900">{team.name}</h1>
<p className="text-gray-600 mt-1">📍 {team.city}</p>
<div className="flex items-center space-x-4 mt-2">
<h1 className="text-xl md:text-3xl font-bold text-gray-900 line-clamp-2">
{team.name}
</h1>
<p className="text-sm md:text-base text-gray-600 mt-1 line-clamp-1">
📍 {team.city}
</p>
<div className="flex flex-wrap items-center gap-2 md:space-x-4 mt-2">
<span className="text-sm text-gray-500">
{members.length} {members.length === 1 ? 'Member' : 'Members'}
{members.length}{' '}
{members.length === 1 ? 'Member' : 'Members'}
</span>
<span className="text-sm text-gray-500">
{team.visibility === 'public' ? '🌐 Public' : '🔒 Private'}
</span>
{isLeader && (
<span className="px-3 py-1 bg-blue-600 text-white rounded-md text-sm font-semibold shadow-sm">
<span className="hidden md:inline-flex px-3 py-1 bg-blue-600 text-white rounded-md text-sm font-semibold shadow-sm">
👑 Team Leader
</span>
)}
</div>
</div>
</div>
<Link to="/dashboard">
<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-8">
<div className="grid gap-6 lg:grid-cols-3">
<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">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
<div className="lg:col-span-2 space-y-4 md:space-y-6">
{/* Team Description */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">About Team</h2>
<p className="text-gray-700 whitespace-pre-wrap">{team.description}</p>
<div className="bg-white rounded-lg shadow-md p-4 md:p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 mb-3 md:mb-4">
About Team
</h2>
<p className="text-gray-700 whitespace-pre-wrap">
{team.description}
</p>
</div>
{/* Team Actions - Only for Leader */}
{isLeader && (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">Team Management</h2>
<div className="bg-white rounded-lg shadow-md p-4 md:p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 mb-3 md:mb-4">
Team Management
</h2>
<div className="grid gap-3 sm:grid-cols-2">
<Button
className="w-full"
@@ -284,7 +351,8 @@ const TeamDashboardPage: FC = (): ReactElement => {
onClick={() => setShowInviteModal(true)}
disabled={!canInvite}
>
Invite Member {!canInvite && `(${members.length}/${MAX_TEAM_MEMBERS})`}
Invite Member{' '}
{!canInvite && `(${members.length}/${MAX_TEAM_MEMBERS})`}
</Button>
<Button
className="w-full relative"
@@ -314,9 +382,7 @@ const TeamDashboardPage: FC = (): ReactElement => {
</Button>
</Link>
<Link to={`/teams/${teamId}/submit`}>
<Button className="w-full">
🚀 Submit Project
</Button>
<Button className="w-full">🚀 Submit Project</Button>
</Link>
</div>
{!canInvite && members.length >= MAX_TEAM_MEMBERS && (
@@ -329,8 +395,10 @@ const TeamDashboardPage: FC = (): ReactElement => {
{/* Quick Actions for Members */}
{!isLeader && (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">Quick Actions</h2>
<div className="bg-white rounded-lg shadow-md p-4 md:p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 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">
@@ -350,11 +418,13 @@ const TeamDashboardPage: FC = (): ReactElement => {
{/* Submission Status */}
{team.has_submission && (
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
<div className="bg-green-50 border border-green-200 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">Project Submitted</h3>
<h3 className="font-bold text-green-900">
Project Submitted
</h3>
<p className="text-green-700 text-sm">
Your team has successfully submitted a project
</p>
@@ -370,12 +440,17 @@ const TeamDashboardPage: FC = (): ReactElement => {
</div>
{/* Sidebar */}
<div className="space-y-6">
<div className="space-y-4 md:space-y-6">
{/* Team Leader */}
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="font-bold text-gray-900 mb-4">Team Leader</h3>
<div className="bg-white rounded-lg shadow-md p-4 md:p-6">
<h3 className="text-base md:text-lg font-bold text-gray-900 mb-3 md:mb-4">
Team Leader
</h3>
{team.leader && (
<div className="flex items-center space-x-3">
<button
onClick={() => navigate(`/users/${team.leader.id}`)}
className="w-full flex items-center space-x-3 hover:bg-gray-100 rounded-lg p-2 transition-colors text-left cursor-pointer"
>
{team.leader.avatar ? (
<img
src={team.leader.avatar}
@@ -388,21 +463,27 @@ const TeamDashboardPage: FC = (): ReactElement => {
</div>
)}
<div>
<p className="font-medium text-gray-900">{team.leader.fullname}</p>
<p className="font-medium text-gray-900">
{team.leader.fullname}
</p>
<p className="text-sm text-gray-600">{team.leader.email}</p>
</div>
</div>
</button>
)}
</div>
{/* Team Members */}
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="font-bold text-gray-900 mb-4">
<div className="bg-white rounded-lg shadow-md p-4 md:p-6">
<h3 className="text-base md:text-lg font-bold text-gray-900 mb-3 md:mb-4">
Members ({members.length})
</h3>
<div className="space-y-3">
{members.map((member: any) => (
<div key={member.id} className="flex items-center space-x-3">
<button
key={member.id}
onClick={() => navigate(`/users/${member.user.id}`)}
className="w-full flex items-center space-x-3 hover:bg-gray-100 rounded-lg p-2 transition-colors text-left cursor-pointer"
>
{member.user?.avatar ? (
<img
src={member.user.avatar}
@@ -421,10 +502,12 @@ const TeamDashboardPage: FC = (): ReactElement => {
{member.user?.fullname || 'Unknown'}
</p>
<p className="text-xs text-gray-500">
{member.role === ETeamMemberRole.LEADER ? 'Leader' : 'Member'}
{member.role === ETeamMemberRole.LEADER
? 'Leader'
: 'Member'}
</p>
</div>
</div>
</button>
))}
</div>
</div>
@@ -440,16 +523,21 @@ const TeamDashboardPage: FC = (): ReactElement => {
Invite Team Member
</h2>
<p className="text-gray-600 mb-4">
Send an invitation to join your team. The invited member will see the invitation on their dashboard after logging in.
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 border border-yellow-200 rounded-lg p-3 mb-6">
<p className="text-sm text-yellow-800">
<strong>Important:</strong> The email you enter must match the GitHub email address the member uses to sign in.
<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 mb-2">
<label
htmlFor="invite-email"
className="block text-sm font-medium text-gray-700 mb-2"
>
Email Address
</label>
<input
@@ -508,7 +596,9 @@ const TeamDashboardPage: FC = (): ReactElement => {
{pendingJoinRequests.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-600 text-lg">No pending join requests</p>
<p className="text-gray-600 text-lg">
No pending join requests
</p>
<p className="text-gray-500 text-sm mt-2">
When users request to join your team, they'll appear here
</p>
@@ -516,7 +606,10 @@ const TeamDashboardPage: FC = (): ReactElement => {
) : (
<div className="space-y-4">
{pendingJoinRequests.map((request: any) => (
<div key={request.id} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
<div
key={request.id}
className="border border-gray-200 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 ? (
@@ -545,20 +638,27 @@ const TeamDashboardPage: FC = (): ReactElement => {
</div>
)}
<p className="text-xs text-gray-500 mt-2">
Requested {new Date(request.created_at).toLocaleDateString()}
Requested{' '}
{new Date(request.created_at).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex space-x-2 ml-4">
<Button
onClick={() => handleRespondToJoinRequest(request.id, 'approve')}
disabled={isResponding || members.length >= MAX_TEAM_MEMBERS}
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')}
onClick={() =>
handleRespondToJoinRequest(request.id, 'reject')
}
disabled={isResponding}
variant="secondary"
className="px-4 py-2 text-sm"
@@ -570,7 +670,9 @@ const TeamDashboardPage: FC = (): ReactElement => {
{members.length >= MAX_TEAM_MEMBERS && (
<div className="mt-3 bg-yellow-50 border border-yellow-200 rounded-lg p-2">
<p className="text-xs text-yellow-800">
Team is full ({MAX_TEAM_MEMBERS}/{MAX_TEAM_MEMBERS} members). Remove a member before accepting new requests.
Team is full ({MAX_TEAM_MEMBERS}/{MAX_TEAM_MEMBERS}{' '}
members). Remove a member before accepting new
requests.
</p>
</div>
)}
+48 -33
View File
@@ -1,5 +1,5 @@
import { FC, ReactElement, useState } from 'react';
import { Button, Input } from '@imphnen-frontend-service/ui/atoms';
import { Button, Input, Select } from '@imphnen-frontend-service/ui/atoms';
import { Link, useNavigate } from 'react-router';
import { useTeams, useJoinTeam, useMyTeams, ETeamVisibility, joinTeamSchema, TJoinTeamForm } from '@imphnen-frontend-service/service';
import { useForm } from 'react-hook-form';
@@ -65,7 +65,7 @@ const BrowseTeamsPage: FC = (): ReactElement => {
<h1 className="text-3xl font-bold text-gray-900">Browse Teams</h1>
<p className="text-gray-600 mt-1">Find and join teams looking for members</p>
</div>
<Link to="/dashboard">
<Link to="/dashboard" className="hidden md:block">
<Button variant="secondary">Back to Dashboard</Button>
</Link>
</div>
@@ -85,23 +85,24 @@ const BrowseTeamsPage: FC = (): ReactElement => {
placeholder="Search by team name..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-12"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Filter by City
</label>
<select
<Select
value={selectedCity}
onChange={(e) => setSelectedCity(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
className="w-full"
>
{INDONESIAN_CITIES.map((city) => (
<option key={city} value={city}>
{city}
</option>
))}
</select>
</Select>
</div>
</div>
</div>
@@ -119,15 +120,13 @@ const BrowseTeamsPage: FC = (): ReactElement => {
) : (
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{teams.map((team) => (
<div key={team.id} className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
{team.banner && (
<img
src={team.banner}
alt={team.name}
className="w-full h-32 object-cover"
/>
)}
<div className="p-6">
<div key={team.id} className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow flex flex-col">
<img
src={team.banner || '/images/banner-imphnen.png'}
alt={team.name}
className="w-full h-32 object-cover"
/>
<div className="p-6 flex flex-col flex-1">
<div className="flex items-center space-x-3 mb-3">
{team.logo ? (
<img
@@ -140,30 +139,46 @@ const BrowseTeamsPage: FC = (): ReactElement => {
<span className="text-gray-500 text-xl">👥</span>
</div>
)}
<div className="flex-1">
<h3 className="text-lg font-bold text-gray-900">{team.name}</h3>
<p className="text-sm text-gray-600">📍 {team.city}</p>
<div className="flex-1 min-w-0">
<h3 className="text-lg font-bold text-gray-900 line-clamp-2">{team.name}</h3>
<div className="text-sm text-gray-600 flex gap-2">
<p className="truncate flex-1 min-w-0">📍 {team.city}</p>
<p className="whitespace-nowrap shrink-0">👥 {team.members?.length || 0} members</p>
</div>
</div>
</div>
<p className="text-gray-600 text-sm mb-4 line-clamp-3">
{team.description}
</p>
{isMyTeam(team.id) ? (
<Button
className="w-full"
variant="secondary"
onClick={() => navigate(`/teams/${team.id}`)}
>
Your Team
</Button>
) : (
<Button
className="w-full"
onClick={() => handleJoinRequest(team.id)}
>
Request to Join
</Button>
)}
<div className="space-y-3 mt-auto">
{isMyTeam(team.id) ? (
<Button
className="w-full"
variant="secondary"
onClick={() => navigate(`/teams/${team.id}`)}
>
Your Team
</Button>
) : (
<>
{myTeams.length === 0 && (team.members?.length || 0) < 5 && (
<Button
className="w-full"
onClick={() => handleJoinRequest(team.id)}
>
Request to Join
</Button>
)}
<Button
className="w-full"
variant="secondary"
onClick={() => navigate(`/teams/${team.id}`)}
>
View Team
</Button>
</>
)}
</div>
</div>
</div>
))}
@@ -0,0 +1,18 @@
import { FC, ReactNode } from 'react';
import { Outlet } from 'react-router';
import { Navigation } from '../../../components/navigation';
interface UserLayoutProps {
children?: ReactNode;
}
export const UserLayout: FC<UserLayoutProps> = () => {
return (
<div className="min-h-screen bg-gray-50">
<Navigation />
<Outlet />
</div>
);
};
export default UserLayout;
@@ -0,0 +1,158 @@
import { FC, ReactElement } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { useParams, useNavigate, Link } from 'react-router';
import { useUserDetailsById, useTeamsByUserId } from '@imphnen-frontend-service/service';
const UserProfilePage: FC = (): ReactElement => {
const { userId } = useParams<{ userId: string }>();
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">
<div className="text-gray-600">Loading user profile...</div>
</div>
);
}
if (error || !user) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold text-gray-900 mb-4">User not found</h2>
{error && <p className="text-red-600 mb-4">{String(error)}</p>}
<Button onClick={() => navigate('/dashboard')}>Back to Dashboard</Button>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white border-b">
<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 shadow-lg"
/>
) : (
<div className="w-16 h-16 md:w-20 md:h-20 rounded-full bg-gray-200 flex items-center justify-center border-4 border-white shadow-lg">
<span className="text-gray-500 text-2xl md:text-3xl">👤</span>
</div>
)}
<div>
<h1 className="text-xl md:text-3xl font-bold text-gray-900">{user.fullname}</h1>
<p className="text-sm md:text-base text-gray-600 mt-1">{user.email}</p>
{user.location && (
<div className="flex items-center space-x-4 mt-2">
<span className="text-sm text-gray-500">📍 {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">
{/* Main Content */}
<div className="lg:col-span-2 space-y-4 md:space-y-6">
{/* About Section */}
{user.bio && (
<div className="bg-white rounded-lg shadow-md p-4 md:p-6 overflow-hidden">
<h2 className="text-lg md:text-xl font-bold text-gray-900 mb-3 md:mb-4">About</h2>
<p className="text-gray-700 break-all overflow-wrap-anywhere">{user.bio}</p>
</div>
)}
{/* Skills Section */}
{user.skills && user.skills.length > 0 && (
<div className="bg-white rounded-lg shadow-md p-4 md:p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 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 bg-blue-600 text-white rounded-lg text-sm font-medium shadow-sm hover:bg-blue-700 transition-colors"
>
{skill}
</span>
))}
</div>
</div>
)}
{/* Teams Section */}
{userTeams.length > 0 ? (
<div className="bg-white rounded-lg shadow-md p-4 md:p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 mb-3 md:mb-4">Teams ({userTeams.length})</h2>
<div className="grid gap-4 md:grid-cols-2">
{userTeams.map((team: any) => (
<Link key={team.id} to={'/teams/' + team.id} className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
<img src={team.banner || '/images/banner-imphnen.png'} alt={team.name} className="w-full h-24 object-cover" />
<div className="p-4">
<div className="flex items-center space-x-3 mb-2">
{team.logo && <img src={team.logo} alt={team.name} className="w-10 h-10 rounded-full object-cover" />}
<div className="flex-1 min-w-0">
<h3 className="font-bold text-gray-900 line-clamp-2">{team.name}</h3>
<div className="text-sm text-gray-600 flex gap-2">
<p className="truncate flex-1 min-w-0">📍 {team.city}</p>
<p className="whitespace-nowrap shrink-0">👥 {team.members?.length || 0} members</p>
</div>
</div>
</div>
<p className="text-gray-600 text-sm line-clamp-2">{team.description}</p>
</div>
</Link>
))}
</div>
</div>
) : (
<div className="bg-white rounded-lg shadow-md p-4 md:p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 mb-3 md:mb-4">Teams</h2>
<div className="text-center py-8">
<div className="text-4xl mb-3">👥</div>
<p className="text-gray-600">Not in any team yet</p>
</div>
</div>
)}
</div>
{/* Sidebar */}
<div className="space-y-4 md:space-y-6">
{/* Contact Info */}
<div className="bg-white rounded-lg shadow-md p-4 md:p-6">
<h3 className="text-base md:text-lg font-bold text-gray-900 mb-3 md:mb-4">Contact Information</h3>
<div className="space-y-3">
<div>
<p className="text-sm text-gray-600">Email</p>
<p className="text-gray-900 font-medium">{user.email}</p>
</div>
{user.location && (
<div>
<p className="text-sm text-gray-600">Location</p>
<p className="text-gray-900 font-medium">{user.location}</p>
</div>
)}
</div>
</div>
</div>
</div>
</div>
</div>
);
};
export default UserProfilePage;
+59 -2
View File
@@ -37,7 +37,10 @@ export const useTeams = (params?: {
queryFn: async () => {
try {
// Supabase client now has auth context from setSession()
let query = supabase.from('teams').select('*');
let query = supabase.from('teams').select(`
*,
members:team_members(id)
`);
// Filter by visibility
if (params?.visibility) {
@@ -372,6 +375,7 @@ export const useTeamJoinRequests = (teamId: string, enabled = true) => {
user:users(id, email, fullname, avatar)
`)
.eq('team_id', teamId)
.eq('status', 'pending')
.order('created_at', { ascending: false });
if (error) {
@@ -438,7 +442,17 @@ export const useRespondToJoinRequest = (teamId: string) => {
.update({ status: 'pending' })
.eq('id', requestId);
throw new Error('Failed to add member to team: ' + memberError.message);
// Parse Supabase error for user-friendly message
let errorMsg = memberError.message;
if (errorMsg.includes('Team already has 5 members') || errorMsg.includes('Team cannot have more than 5 members')) {
errorMsg = 'Team is full! Maximum 5 members allowed.';
} else if (errorMsg.includes('already in a team') || errorMsg.includes('User is already in a team')) {
errorMsg = 'This user is already in another team.';
} else if (errorMsg.includes('Bulk insert')) {
errorMsg = 'Invalid operation detected.';
}
throw new Error(errorMsg);
}
return { success: true, action: 'accepted' };
@@ -690,3 +704,46 @@ export const useLeaveTeam = () => {
},
});
};
// Get Teams by User ID
export const useTeamsByUserId = (userId: string) => {
return useQuery({
queryKey: ['teams-by-user', userId],
queryFn: async () => {
if (!userId) {
return { data: [] };
}
// Query team_members to find teams where user is a member
const { data: memberships, error: membershipsError } = await supabase
.from('team_members')
.select(`
team_id,
team:teams(
id,
name,
logo,
banner,
description,
city,
visibility,
leader_id,
created_at
)
`)
.eq('user_id', userId)
.eq('status', 'active');
if (membershipsError) {
console.error('Failed to fetch user teams:', membershipsError);
return { data: [] };
}
// Extract teams from memberships
const teams = memberships?.map((m: any) => m.team).filter(Boolean) || [];
return { data: teams };
},
enabled: !!userId,
});
};
+20
View File
@@ -92,3 +92,23 @@ export const useUpdateUserById = () => {
},
});
};
export const useUserDetailsById = (userId: string) => {
return useQuery({
queryKey: ['user-supabase', userId],
queryFn: async () => {
const { data, error } = await supabase
.from('users')
.select('*')
.eq('id', userId)
.single();
if (error) {
throw new Error(error.message || 'Failed to fetch user');
}
return { data };
},
enabled: !!userId,
});
};
+3
View File
@@ -76,6 +76,9 @@ export const userOnboardingSchema = z.object({
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(),
location: z.string().optional(),
bio: z.string().max(500, 'Bio maksimal 500 karakter').optional(),
skills: z.array(z.string()).optional(),
});
export type TTeamCreateForm = z.infer<typeof teamCreateSchema>;