Merge branch 'develop' into hackathon/fix-ui
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -73,61 +73,62 @@ const DashboardPage: FC = (): ReactElement => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{myTeams.length > 0 ? (
|
{myTeams.length > 0 ? (
|
||||||
<div className="mb-8">
|
<div className="mb-6 md:mb-8">
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">My Team</h2>
|
<h2 className="text-xl md:text-2xl font-bold text-gray-900 mb-3 md:mb-4">My Team</h2>
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-4 md:gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{myTeams.map((team) => (
|
{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">
|
<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" />}
|
{team.banner && <img src={team.banner} alt={team.name} className="w-full h-32 object-cover" />}
|
||||||
<div className="p-6">
|
<div className="p-4 md:p-6">
|
||||||
<div className="flex items-center space-x-3 mb-3">
|
<div className="flex items-center space-x-3 mb-2 md:mb-3">
|
||||||
{team.logo && <img src={team.logo} alt={team.name} className="w-12 h-12 rounded-full object-cover" />}
|
{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>
|
<div>
|
||||||
<h3 className="text-lg font-bold text-gray-900">{team.name}</h3>
|
<h3 className="text-base md:text-lg font-bold text-gray-900">{team.name}</h3>
|
||||||
<p className="text-sm text-gray-600">City: {team.city}</p>
|
<p className="text-xs md:text-sm text-gray-600">City: {team.city}</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</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-center">
|
||||||
<div className="text-4xl mb-3">Wave</div>
|
<div className="text-3xl md:text-4xl mb-2 md:mb-3">👋</div>
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-2">You are not in a team yet</h2>
|
<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-gray-600">Use the sidebar to browse teams or create your own</p>
|
<p className="text-sm md:text-base text-gray-600">Use the sidebar to browse teams or create your own</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mt-8 bg-gradient-to-br from-blue-50 to-indigo-50 rounded-xl shadow-lg">
|
<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="bg-gradient-to-r from-blue-600 to-indigo-600 h-24 rounded-t-xl"></div>
|
||||||
<div className="px-8 pb-8">
|
<div className="px-4 md:px-8 pb-4 md:pb-8">
|
||||||
<div className="flex items-start -mt-12 mb-6">
|
<div className="flex flex-col md:flex-row md:items-start -mt-12 mb-4 md:mb-6">
|
||||||
{user?.avatar ? (
|
<div className="flex flex-col md:flex-row items-start">
|
||||||
<img src={user.avatar} alt={user.fullname || 'User'} className="w-24 h-24 rounded-full border-4 border-white shadow-lg object-cover" />
|
{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" />
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<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>
|
</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>
|
||||||
<div className="space-y-6">
|
<div className="space-y-4 md:space-y-6">
|
||||||
{user?.bio && (
|
{user?.bio && (
|
||||||
<div className="bg-white rounded-lg p-4 shadow-sm">
|
<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>
|
<h3 className="text-sm font-semibold text-gray-700 uppercase tracking-wide mb-2">About</h3>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { FC, ReactElement, useState, useEffect } from 'react';
|
import { FC, ReactElement, useState, useEffect } from 'react';
|
||||||
import { ControlledInputField } from '@imphnen-frontend-service/ui/organisms';
|
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 { useNavigate, Link } from 'react-router';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm, Controller } from 'react-hook-form';
|
||||||
import {
|
import {
|
||||||
userEditProfileSchema,
|
userEditProfileSchema,
|
||||||
TUserEditProfileForm,
|
TUserEditProfileForm,
|
||||||
@@ -12,6 +12,18 @@ import {
|
|||||||
} from '@imphnen-frontend-service/service';
|
} from '@imphnen-frontend-service/service';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { toast } from 'sonner';
|
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 ProfilePage: FC = (): ReactElement => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -28,6 +40,9 @@ const ProfilePage: FC = (): ReactElement => {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
fullname: session?.user?.fullname || '',
|
fullname: session?.user?.fullname || '',
|
||||||
avatar: session?.user?.avatar || null,
|
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) {
|
if (session?.user?.fullname) {
|
||||||
form.setValue('fullname', 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 handleAvatarChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -79,6 +103,9 @@ const ProfilePage: FC = (): ReactElement => {
|
|||||||
await updateUser({
|
await updateUser({
|
||||||
fullname: data.fullname,
|
fullname: data.fullname,
|
||||||
avatar: avatarUrl,
|
avatar: avatarUrl,
|
||||||
|
location: data.location,
|
||||||
|
bio: data.bio,
|
||||||
|
skills: data.skills,
|
||||||
});
|
});
|
||||||
|
|
||||||
toast.success('Profile updated successfully!');
|
toast.success('Profile updated successfully!');
|
||||||
@@ -192,6 +219,87 @@ const ProfilePage: FC = (): ReactElement => {
|
|||||||
name="fullname"
|
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 */}
|
{/* Action Buttons */}
|
||||||
<div className="flex space-x-3 pt-4">
|
<div className="flex space-x-3 pt-4">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from '@imphnen-frontend-service/service';
|
} from '@imphnen-frontend-service/service';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
const ManageMembersPage: FC = (): ReactElement => {
|
const ManageMembersPage: FC = (): ReactElement => {
|
||||||
const { teamId } = useParams<{ teamId: string }>();
|
const { teamId } = useParams<{ teamId: string }>();
|
||||||
@@ -75,16 +76,20 @@ const ManageMembersPage: FC = (): ReactElement => {
|
|||||||
const handleApproveRequest = async (requestId: string) => {
|
const handleApproveRequest = async (requestId: string) => {
|
||||||
try {
|
try {
|
||||||
await respondToRequest({ requestId, action: 'approve' });
|
await respondToRequest({ requestId, action: 'approve' });
|
||||||
|
toast.success('Member added successfully!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to approve request:', error);
|
console.error('Failed to approve request:', error);
|
||||||
|
toast.error((error as Error).message || 'Failed to approve request');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRejectRequest = async (requestId: string) => {
|
const handleRejectRequest = async (requestId: string) => {
|
||||||
try {
|
try {
|
||||||
await respondToRequest({ requestId, action: 'reject' });
|
await respondToRequest({ requestId, action: 'reject' });
|
||||||
|
toast.success('Request rejected');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to reject request:', error);
|
console.error('Failed to reject request:', error);
|
||||||
|
toast.error((error as Error).message || 'Failed to reject request');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import { FC, ReactElement, useState, useEffect } from 'react';
|
import { FC, ReactElement, useState, useEffect } from 'react';
|
||||||
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
import { Button } from '@imphnen-frontend-service/ui/atoms';
|
||||||
import { Link, useParams, useNavigate } from 'react-router';
|
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';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
const MAX_TEAM_MEMBERS = 5;
|
const MAX_TEAM_MEMBERS = 5;
|
||||||
@@ -18,12 +26,16 @@ const ImageWithLoader: FC<{
|
|||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{!isLoaded && (
|
{!isLoaded && (
|
||||||
<div className={`absolute inset-0 bg-gray-200 animate-pulse ${className}`} />
|
<div
|
||||||
|
className={`absolute inset-0 bg-gray-200 animate-pulse ${className}`}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
alt={alt}
|
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={() => {
|
onLoad={() => {
|
||||||
setIsLoaded(true);
|
setIsLoaded(true);
|
||||||
onLoad?.();
|
onLoad?.();
|
||||||
@@ -43,23 +55,36 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
const [imagesLoaded, setImagesLoaded] = useState(false);
|
const [imagesLoaded, setImagesLoaded] = useState(false);
|
||||||
const [imageLoadCount, setImageLoadCount] = useState(0);
|
const [imageLoadCount, setImageLoadCount] = useState(0);
|
||||||
|
|
||||||
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(teamId || '');
|
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
|
||||||
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(teamId || '');
|
teamId || ''
|
||||||
const { data: joinRequestsData } = useTeamJoinRequests(teamId || '', !!teamId);
|
);
|
||||||
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(teamId || '');
|
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(
|
||||||
const { mutateAsync: respondToJoinRequest, isPending: isResponding } = useRespondToJoinRequest(teamId || '');
|
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 team = teamData?.data;
|
||||||
const members = membersData?.data || [];
|
const members = membersData?.data || [];
|
||||||
const joinRequests = joinRequestsData?.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 currentUserId = session?.user?.id;
|
||||||
|
|
||||||
const isLeader = currentUserId === team?.leader_id;
|
const isLeader = currentUserId === team?.leader_id;
|
||||||
const canInvite = isLeader && members.length < MAX_TEAM_MEMBERS;
|
const canInvite = isLeader && members.length < MAX_TEAM_MEMBERS;
|
||||||
|
|
||||||
// Calculate total images to load
|
// Calculate total images to load
|
||||||
const totalImagesToLoad = (team?.banner ? 1 : 0) +
|
const totalImagesToLoad =
|
||||||
|
(team?.banner ? 1 : 0) +
|
||||||
(team?.logo ? 1 : 0) +
|
(team?.logo ? 1 : 0) +
|
||||||
members.filter((m: any) => m.user?.avatar).length;
|
members.filter((m: any) => m.user?.avatar).length;
|
||||||
|
|
||||||
@@ -72,7 +97,13 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
setImagesLoaded(true);
|
setImagesLoaded(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [isLoadingTeam, isLoadingMembers, team, imageLoadCount, totalImagesToLoad]);
|
}, [
|
||||||
|
isLoadingTeam,
|
||||||
|
isLoadingMembers,
|
||||||
|
team,
|
||||||
|
imageLoadCount,
|
||||||
|
totalImagesToLoad,
|
||||||
|
]);
|
||||||
|
|
||||||
// Fallback timeout - if images take too long, show content anyway
|
// Fallback timeout - if images take too long, show content anyway
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -93,7 +124,11 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
setImageLoadCount((prev) => prev + 1);
|
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) => {
|
const handleInviteMember = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
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 {
|
try {
|
||||||
await respondToJoinRequest({ requestId, action });
|
await respondToJoinRequest({ requestId, action });
|
||||||
toast.success(action === 'approve' ? 'Request approved!' : 'Request rejected');
|
toast.success(
|
||||||
|
action === 'approve' ? 'Request approved!' : 'Request rejected'
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to respond to join request:', error);
|
console.error('Failed to respond to join request:', error);
|
||||||
toast.error('Failed to process request');
|
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="fixed inset-0 bg-white/60 flex items-center justify-center z-50">
|
||||||
<div className="text-center">
|
<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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -188,8 +230,12 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
if (!team) {
|
if (!team) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center min-h-screen">
|
<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>
|
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
||||||
<Button onClick={() => navigate('/dashboard')}>Back to Dashboard</Button>
|
Team not found
|
||||||
|
</h2>
|
||||||
|
<Button onClick={() => navigate('/dashboard')}>
|
||||||
|
Back to Dashboard
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -202,7 +248,9 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
<div className="text-center">
|
<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>
|
<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="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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -210,20 +258,26 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
{/* Header with Banner */}
|
{/* Header with Banner */}
|
||||||
<div className="bg-white border-b">
|
<div className="bg-white border-b">
|
||||||
{team.banner && (
|
{team.banner && (
|
||||||
<div className="w-full h-48 overflow-hidden relative">
|
<div className="w-full h-32 md:h-48 overflow-hidden relative">
|
||||||
<div className={`absolute inset-0 bg-gray-200 animate-pulse ${imagesLoaded ? 'hidden' : ''}`} />
|
<div
|
||||||
|
className={`absolute inset-0 bg-gray-200 animate-pulse ${
|
||||||
|
imagesLoaded ? 'hidden' : ''
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
<img
|
<img
|
||||||
src={team.banner}
|
src={team.banner}
|
||||||
alt={team.name}
|
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}
|
onLoad={handleImageLoad}
|
||||||
onError={handleImageError}
|
onError={handleImageError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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-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 && (
|
{team.logo && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
{!imagesLoaded && (
|
{!imagesLoaded && (
|
||||||
@@ -232,51 +286,64 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
<img
|
<img
|
||||||
src={team.logo}
|
src={team.logo}
|
||||||
alt={team.name}
|
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}
|
onLoad={handleImageLoad}
|
||||||
onError={handleImageError}
|
onError={handleImageError}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-gray-900">{team.name}</h1>
|
<h1 className="text-xl md:text-3xl font-bold text-gray-900 line-clamp-2">
|
||||||
<p className="text-gray-600 mt-1">📍 {team.city}</p>
|
{team.name}
|
||||||
<div className="flex items-center space-x-4 mt-2">
|
</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">
|
<span className="text-sm text-gray-500">
|
||||||
{members.length} {members.length === 1 ? 'Member' : 'Members'}
|
{members.length}{' '}
|
||||||
|
{members.length === 1 ? 'Member' : 'Members'}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-sm text-gray-500">
|
<span className="text-sm text-gray-500">
|
||||||
{team.visibility === 'public' ? '🌐 Public' : '🔒 Private'}
|
{team.visibility === 'public' ? '🌐 Public' : '🔒 Private'}
|
||||||
</span>
|
</span>
|
||||||
{isLeader && (
|
{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
|
👑 Team Leader
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Link to="/dashboard">
|
<Link to="/dashboard" className="hidden md:block">
|
||||||
<Button variant="secondary">Back to Dashboard</Button>
|
<Button variant="secondary">Back to Dashboard</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-8">
|
||||||
<div className="grid gap-6 lg:grid-cols-3">
|
<div className="grid gap-4 md:gap-6 lg:grid-cols-3">
|
||||||
{/* Main Content */}
|
{/* 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 */}
|
{/* Team Description */}
|
||||||
<div className="bg-white rounded-lg shadow-md p-6">
|
<div className="bg-white rounded-lg shadow-md p-4 md:p-6">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">About Team</h2>
|
<h2 className="text-lg md:text-xl font-bold text-gray-900 mb-3 md:mb-4">
|
||||||
<p className="text-gray-700 whitespace-pre-wrap">{team.description}</p>
|
About Team
|
||||||
|
</h2>
|
||||||
|
<p className="text-gray-700 whitespace-pre-wrap">
|
||||||
|
{team.description}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Team Actions - Only for Leader */}
|
{/* Team Actions - Only for Leader */}
|
||||||
{isLeader && (
|
{isLeader && (
|
||||||
<div className="bg-white rounded-lg shadow-md p-6">
|
<div className="bg-white rounded-lg shadow-md p-4 md:p-6">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Team Management</h2>
|
<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">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<Button
|
<Button
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -284,7 +351,8 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
onClick={() => setShowInviteModal(true)}
|
onClick={() => setShowInviteModal(true)}
|
||||||
disabled={!canInvite}
|
disabled={!canInvite}
|
||||||
>
|
>
|
||||||
➕ Invite Member {!canInvite && `(${members.length}/${MAX_TEAM_MEMBERS})`}
|
➕ Invite Member{' '}
|
||||||
|
{!canInvite && `(${members.length}/${MAX_TEAM_MEMBERS})`}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="w-full relative"
|
className="w-full relative"
|
||||||
@@ -314,9 +382,7 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
<Link to={`/teams/${teamId}/submit`}>
|
<Link to={`/teams/${teamId}/submit`}>
|
||||||
<Button className="w-full">
|
<Button className="w-full">🚀 Submit Project</Button>
|
||||||
🚀 Submit Project
|
|
||||||
</Button>
|
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{!canInvite && members.length >= MAX_TEAM_MEMBERS && (
|
{!canInvite && members.length >= MAX_TEAM_MEMBERS && (
|
||||||
@@ -329,8 +395,10 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
|
|
||||||
{/* Quick Actions for Members */}
|
{/* Quick Actions for Members */}
|
||||||
{!isLeader && (
|
{!isLeader && (
|
||||||
<div className="bg-white rounded-lg shadow-md p-6">
|
<div className="bg-white rounded-lg shadow-md p-4 md:p-6">
|
||||||
<h2 className="text-xl font-bold text-gray-900 mb-4">Quick Actions</h2>
|
<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">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<Link to={`/teams/${teamId}/chat`}>
|
<Link to={`/teams/${teamId}/chat`}>
|
||||||
<Button className="w-full" variant="secondary">
|
<Button className="w-full" variant="secondary">
|
||||||
@@ -350,11 +418,13 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
|
|
||||||
{/* Submission Status */}
|
{/* Submission Status */}
|
||||||
{team.has_submission && (
|
{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">
|
<div className="flex items-center space-x-3">
|
||||||
<span className="text-3xl">✅</span>
|
<span className="text-3xl">✅</span>
|
||||||
<div>
|
<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">
|
<p className="text-green-700 text-sm">
|
||||||
Your team has successfully submitted a project
|
Your team has successfully submitted a project
|
||||||
</p>
|
</p>
|
||||||
@@ -370,12 +440,17 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-4 md:space-y-6">
|
||||||
{/* Team Leader */}
|
{/* Team Leader */}
|
||||||
<div className="bg-white rounded-lg shadow-md p-6">
|
<div className="bg-white rounded-lg shadow-md p-4 md:p-6">
|
||||||
<h3 className="font-bold text-gray-900 mb-4">Team Leader</h3>
|
<h3 className="text-base md:text-lg font-bold text-gray-900 mb-3 md:mb-4">
|
||||||
|
Team Leader
|
||||||
|
</h3>
|
||||||
{team.leader && (
|
{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 ? (
|
{team.leader.avatar ? (
|
||||||
<img
|
<img
|
||||||
src={team.leader.avatar}
|
src={team.leader.avatar}
|
||||||
@@ -388,21 +463,27 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<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>
|
<p className="text-sm text-gray-600">{team.leader.email}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Team Members */}
|
{/* Team Members */}
|
||||||
<div className="bg-white rounded-lg shadow-md p-6">
|
<div className="bg-white rounded-lg shadow-md p-4 md:p-6">
|
||||||
<h3 className="font-bold text-gray-900 mb-4">
|
<h3 className="text-base md:text-lg font-bold text-gray-900 mb-3 md:mb-4">
|
||||||
Members ({members.length})
|
Members ({members.length})
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{members.map((member: any) => (
|
{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 ? (
|
{member.user?.avatar ? (
|
||||||
<img
|
<img
|
||||||
src={member.user.avatar}
|
src={member.user.avatar}
|
||||||
@@ -421,10 +502,12 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
{member.user?.fullname || 'Unknown'}
|
{member.user?.fullname || 'Unknown'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
{member.role === ETeamMemberRole.LEADER ? 'Leader' : 'Member'}
|
{member.role === ETeamMemberRole.LEADER
|
||||||
|
? 'Leader'
|
||||||
|
: 'Member'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -440,16 +523,21 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
Invite Team Member
|
Invite Team Member
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-gray-600 mb-4">
|
<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>
|
</p>
|
||||||
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-6">
|
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-6">
|
||||||
<p className="text-sm text-yellow-800">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleInviteMember} className="space-y-4">
|
<form onSubmit={handleInviteMember} className="space-y-4">
|
||||||
<div>
|
<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
|
Email Address
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -508,7 +596,9 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
|
|
||||||
{pendingJoinRequests.length === 0 ? (
|
{pendingJoinRequests.length === 0 ? (
|
||||||
<div className="text-center py-12">
|
<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">
|
<p className="text-gray-500 text-sm mt-2">
|
||||||
When users request to join your team, they'll appear here
|
When users request to join your team, they'll appear here
|
||||||
</p>
|
</p>
|
||||||
@@ -516,7 +606,10 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{pendingJoinRequests.map((request: any) => (
|
{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 justify-between">
|
||||||
<div className="flex items-start space-x-3 flex-1">
|
<div className="flex items-start space-x-3 flex-1">
|
||||||
{request.user?.avatar ? (
|
{request.user?.avatar ? (
|
||||||
@@ -545,20 +638,27 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
Requested {new Date(request.created_at).toLocaleDateString()}
|
Requested{' '}
|
||||||
|
{new Date(request.created_at).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-2 ml-4">
|
<div className="flex space-x-2 ml-4">
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleRespondToJoinRequest(request.id, 'approve')}
|
onClick={() =>
|
||||||
disabled={isResponding || members.length >= MAX_TEAM_MEMBERS}
|
handleRespondToJoinRequest(request.id, 'approve')
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
isResponding || members.length >= MAX_TEAM_MEMBERS
|
||||||
|
}
|
||||||
className="px-4 py-2 text-sm"
|
className="px-4 py-2 text-sm"
|
||||||
>
|
>
|
||||||
✓ Accept
|
✓ Accept
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleRespondToJoinRequest(request.id, 'reject')}
|
onClick={() =>
|
||||||
|
handleRespondToJoinRequest(request.id, 'reject')
|
||||||
|
}
|
||||||
disabled={isResponding}
|
disabled={isResponding}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="px-4 py-2 text-sm"
|
className="px-4 py-2 text-sm"
|
||||||
@@ -570,7 +670,9 @@ const TeamDashboardPage: FC = (): ReactElement => {
|
|||||||
{members.length >= MAX_TEAM_MEMBERS && (
|
{members.length >= MAX_TEAM_MEMBERS && (
|
||||||
<div className="mt-3 bg-yellow-50 border border-yellow-200 rounded-lg p-2">
|
<div className="mt-3 bg-yellow-50 border border-yellow-200 rounded-lg p-2">
|
||||||
<p className="text-xs text-yellow-800">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FC, ReactElement, useState } from 'react';
|
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 { Link, useNavigate } from 'react-router';
|
||||||
import { useTeams, useJoinTeam, useMyTeams, ETeamVisibility, joinTeamSchema, TJoinTeamForm } from '@imphnen-frontend-service/service';
|
import { useTeams, useJoinTeam, useMyTeams, ETeamVisibility, joinTeamSchema, TJoinTeamForm } from '@imphnen-frontend-service/service';
|
||||||
import { useForm } from 'react-hook-form';
|
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>
|
<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>
|
<p className="text-gray-600 mt-1">Find and join teams looking for members</p>
|
||||||
</div>
|
</div>
|
||||||
<Link to="/dashboard">
|
<Link to="/dashboard" className="hidden md:block">
|
||||||
<Button variant="secondary">Back to Dashboard</Button>
|
<Button variant="secondary">Back to Dashboard</Button>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -85,23 +85,24 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
|||||||
placeholder="Search by team name..."
|
placeholder="Search by team name..."
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="h-12"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Filter by City
|
Filter by City
|
||||||
</label>
|
</label>
|
||||||
<select
|
<Select
|
||||||
value={selectedCity}
|
value={selectedCity}
|
||||||
onChange={(e) => setSelectedCity(e.target.value)}
|
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) => (
|
{INDONESIAN_CITIES.map((city) => (
|
||||||
<option key={city} value={city}>
|
<option key={city} value={city}>
|
||||||
{city}
|
{city}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -119,15 +120,13 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{teams.map((team) => (
|
{teams.map((team) => (
|
||||||
<div key={team.id} className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
|
<div key={team.id} className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow flex flex-col">
|
||||||
{team.banner && (
|
<img
|
||||||
<img
|
src={team.banner || '/images/banner-imphnen.png'}
|
||||||
src={team.banner}
|
alt={team.name}
|
||||||
alt={team.name}
|
className="w-full h-32 object-cover"
|
||||||
className="w-full h-32 object-cover"
|
/>
|
||||||
/>
|
<div className="p-6 flex flex-col flex-1">
|
||||||
)}
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex items-center space-x-3 mb-3">
|
<div className="flex items-center space-x-3 mb-3">
|
||||||
{team.logo ? (
|
{team.logo ? (
|
||||||
<img
|
<img
|
||||||
@@ -140,30 +139,46 @@ const BrowseTeamsPage: FC = (): ReactElement => {
|
|||||||
<span className="text-gray-500 text-xl">👥</span>
|
<span className="text-gray-500 text-xl">👥</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<h3 className="text-lg font-bold text-gray-900">{team.name}</h3>
|
<h3 className="text-lg font-bold text-gray-900 line-clamp-2">{team.name}</h3>
|
||||||
<p className="text-sm text-gray-600">📍 {team.city}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-gray-600 text-sm mb-4 line-clamp-3">
|
<p className="text-gray-600 text-sm mb-4 line-clamp-3">
|
||||||
{team.description}
|
{team.description}
|
||||||
</p>
|
</p>
|
||||||
{isMyTeam(team.id) ? (
|
<div className="space-y-3 mt-auto">
|
||||||
<Button
|
{isMyTeam(team.id) ? (
|
||||||
className="w-full"
|
<Button
|
||||||
variant="secondary"
|
className="w-full"
|
||||||
onClick={() => navigate(`/teams/${team.id}`)}
|
variant="secondary"
|
||||||
>
|
onClick={() => navigate(`/teams/${team.id}`)}
|
||||||
Your Team
|
>
|
||||||
</Button>
|
Your Team
|
||||||
) : (
|
</Button>
|
||||||
<Button
|
) : (
|
||||||
className="w-full"
|
<>
|
||||||
onClick={() => handleJoinRequest(team.id)}
|
{myTeams.length === 0 && (team.members?.length || 0) < 5 && (
|
||||||
>
|
<Button
|
||||||
Request to Join
|
className="w-full"
|
||||||
</Button>
|
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>
|
||||||
</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;
|
||||||
@@ -37,7 +37,10 @@ export const useTeams = (params?: {
|
|||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
try {
|
try {
|
||||||
// Supabase client now has auth context from setSession()
|
// 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
|
// Filter by visibility
|
||||||
if (params?.visibility) {
|
if (params?.visibility) {
|
||||||
@@ -372,6 +375,7 @@ export const useTeamJoinRequests = (teamId: string, enabled = true) => {
|
|||||||
user:users(id, email, fullname, avatar)
|
user:users(id, email, fullname, avatar)
|
||||||
`)
|
`)
|
||||||
.eq('team_id', teamId)
|
.eq('team_id', teamId)
|
||||||
|
.eq('status', 'pending')
|
||||||
.order('created_at', { ascending: false });
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@@ -438,7 +442,17 @@ export const useRespondToJoinRequest = (teamId: string) => {
|
|||||||
.update({ status: 'pending' })
|
.update({ status: 'pending' })
|
||||||
.eq('id', requestId);
|
.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' };
|
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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ export const userOnboardingSchema = z.object({
|
|||||||
export const userEditProfileSchema = z.object({
|
export const userEditProfileSchema = z.object({
|
||||||
fullname: z.string().min(3, 'Nama lengkap minimal 3 karakter'),
|
fullname: z.string().min(3, 'Nama lengkap minimal 3 karakter'),
|
||||||
avatar: z.string().url('Avatar harus berupa URL yang valid').nullable().optional(),
|
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>;
|
export type TTeamCreateForm = z.infer<typeof teamCreateSchema>;
|
||||||
|
|||||||
Reference in New Issue
Block a user