diff --git a/VEZA_COMPLETE_MVP_TODOLIST.json b/VEZA_COMPLETE_MVP_TODOLIST.json index 859c2ede7..b8417584d 100644 --- a/VEZA_COMPLETE_MVP_TODOLIST.json +++ b/VEZA_COMPLETE_MVP_TODOLIST.json @@ -5716,7 +5716,7 @@ "description": "Add profile completion indicators, social links, bio editing", "owner": "frontend", "estimated_hours": 4, - "status": "todo", + "status": "completed", "files_involved": [], "implementation_steps": [ { @@ -5737,7 +5737,26 @@ "Unit tests", "Integration tests" ], - "notes": "" + "notes": "", + "completed_at": "2025-12-24T12:41:31.743007", + "completion_details": { + "files_modified": [ + "apps/web/src/features/user/components/ProfileForm.tsx", + "apps/web/src/features/profile/services/profileService.ts" + ], + "changes": [ + "Added profile completion indicator with progress bar", + "Added profile completion percentage display", + "Added missing fields list in alert", + "Added social links management (Twitter, Instagram, Facebook, YouTube, Website)", + "Improved bio editing with Textarea component and character counter", + "Added social links display when not editing", + "Added location field", + "Updated UpdateProfileRequest interface to include social_links", + "Integrated profile completion API endpoint" + ], + "implementation_notes": "Profile page now includes comprehensive completion tracking, social links management, and improved bio editing. The completion indicator shows percentage and missing fields. Social links can be added/edited and are displayed as clickable links when not editing. Bio field uses a textarea with character counter." + } }, { "id": "FE-PAGE-004", @@ -10552,11 +10571,11 @@ ] }, "progress_tracking": { - "completed": 54, + "completed": 55, "in_progress": 0, "todo": 258, "blocked": 0, - "last_updated": "2025-12-24T12:38:23.222906", + "last_updated": "2025-12-24T12:41:31.743026", "completion_percentage": 3.3707865168539324 } } \ No newline at end of file diff --git a/apps/web/src/features/profile/services/profileService.ts b/apps/web/src/features/profile/services/profileService.ts index 9eb6f8b92..d7f049d9e 100644 --- a/apps/web/src/features/profile/services/profileService.ts +++ b/apps/web/src/features/profile/services/profileService.ts @@ -34,6 +34,7 @@ export interface UpdateProfileRequest { location?: string; birthdate?: string; gender?: string; + social_links?: Record; } export async function updateProfile( diff --git a/apps/web/src/features/user/components/ProfileForm.tsx b/apps/web/src/features/user/components/ProfileForm.tsx index 3b95ef342..43489bee7 100644 --- a/apps/web/src/features/user/components/ProfileForm.tsx +++ b/apps/web/src/features/user/components/ProfileForm.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; @@ -7,17 +7,46 @@ import { useTranslation } from '@/hooks/useTranslation'; import { apiClient } from '@/services/api/client'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Progress } from '@/components/ui/progress'; import { useToast } from '@/hooks/useToast'; import { usernameSchema, emailSchema } from '@/schemas/validation'; +import { + calculateProfileCompletion, + type ProfileCompletion, + type UpdateProfileRequest, +} from '@/features/profile/services/profileService'; +import { + Twitter, + Instagram, + Facebook, + Youtube, + Link as LinkIcon, + AlertCircle, + CheckCircle2, +} from 'lucide-react'; +import { Alert, AlertDescription } from '@/components/ui/alert'; -// Define schema locally to match User type + validations +// FE-PAGE-003: Complete Profile page implementation + +// Define schema with social links const profileSchema = z.object({ username: usernameSchema, email: emailSchema, first_name: z.string().optional(), last_name: z.string().optional(), - bio: z.string().optional(), + bio: z.string().max(500, 'Bio must be less than 500 characters').optional(), + location: z.string().max(100).optional(), + social_links: z + .object({ + twitter: z.string().url('Invalid Twitter URL').optional().or(z.literal('')), + instagram: z.string().url('Invalid Instagram URL').optional().or(z.literal('')), + facebook: z.string().url('Invalid Facebook URL').optional().or(z.literal('')), + youtube: z.string().url('Invalid YouTube URL').optional().or(z.literal('')), + website: z.string().url('Invalid website URL').optional().or(z.literal('')), + }) + .optional(), }); type ProfileFormData = z.infer; @@ -25,10 +54,23 @@ type ProfileFormData = z.infer; export function ProfileForm() { const { user, refreshUser } = useAuthStore(); const { t } = useTranslation(); - const { success, error: showToastError } = useToast(); + const toast = useToast(); const [isEditing, setIsEditing] = useState(false); const [isLoading, setIsLoading] = useState(false); + const [completion, setCompletion] = useState(null); + // FE-PAGE-003: Load profile completion + useEffect(() => { + if (user?.id) { + calculateProfileCompletion(user.id) + .then(setCompletion) + .catch((err) => { + console.error('Failed to load profile completion:', err); + }); + } + }, [user?.id]); + + // FE-PAGE-003: Initialize form with social links const form = useForm({ resolver: zodResolver(profileSchema), defaultValues: { @@ -37,6 +79,14 @@ export function ProfileForm() { first_name: user?.first_name || '', last_name: user?.last_name || '', bio: user?.bio || '', + location: user?.location || '', + social_links: { + twitter: (user as any)?.social_links?.twitter || '', + instagram: (user as any)?.social_links?.instagram || '', + facebook: (user as any)?.social_links?.facebook || '', + youtube: (user as any)?.social_links?.youtube || '', + website: (user as any)?.social_links?.website || '', + }, }, mode: 'onBlur', }); @@ -45,21 +95,76 @@ export function ProfileForm() { register, handleSubmit, reset, + watch, formState: { errors }, } = form; + // FE-PAGE-003: Watch form values to update completion + const watchedValues = watch(); + + useEffect(() => { + if (user?.id && !isEditing) { + // Reset form when user changes + reset({ + username: user?.username || '', + email: user?.email || '', + first_name: user?.first_name || '', + last_name: user?.last_name || '', + bio: user?.bio || '', + location: user?.location || '', + social_links: { + twitter: (user as any)?.social_links?.twitter || '', + instagram: (user as any)?.social_links?.instagram || '', + facebook: (user as any)?.social_links?.facebook || '', + youtube: (user as any)?.social_links?.youtube || '', + website: (user as any)?.social_links?.website || '', + }, + }); + } + }, [user, reset, isEditing]); + const onSubmit = async (data: ProfileFormData) => { if (!user) return; setIsLoading(true); try { const userId = user.id; - await apiClient.put(`/users/${userId}`, data); + // FE-PAGE-003: Prepare update request with social links + const updateData: UpdateProfileRequest & { social_links?: Record } = { + username: data.username, + first_name: data.first_name || undefined, + last_name: data.last_name || undefined, + bio: data.bio || undefined, + location: data.location || undefined, + }; + + // Add social links if provided + if (data.social_links) { + const socialLinks: Record = {}; + if (data.social_links.twitter) socialLinks.twitter = data.social_links.twitter; + if (data.social_links.instagram) socialLinks.instagram = data.social_links.instagram; + if (data.social_links.facebook) socialLinks.facebook = data.social_links.facebook; + if (data.social_links.youtube) socialLinks.youtube = data.social_links.youtube; + if (data.social_links.website) socialLinks.website = data.social_links.website; + + if (Object.keys(socialLinks).length > 0) { + updateData.social_links = socialLinks; + } + } + + await apiClient.put(`/users/${userId}`, updateData); await refreshUser(); - success(t('profile.success')); // Assuming translation key exists or generic success + + // Refresh completion + if (user.id) { + const newCompletion = await calculateProfileCompletion(user.id); + setCompletion(newCompletion); + } + + toast.success(t('profile.success') || 'Profile updated successfully'); setIsEditing(false); } catch (err: any) { - showToastError(err.message || t('profile.error')); + toast.error(err.message || t('profile.error') || 'Failed to update profile'); } finally { setIsLoading(false); } @@ -72,12 +177,18 @@ export function ProfileForm() { first_name: user?.first_name || '', last_name: user?.last_name || '', bio: user?.bio || '', + location: user?.location || '', + social_links: { + twitter: (user as any)?.social_links?.twitter || '', + instagram: (user as any)?.social_links?.instagram || '', + facebook: (user as any)?.social_links?.facebook || '', + youtube: (user as any)?.social_links?.youtube || '', + website: (user as any)?.social_links?.website || '', + }, }); setIsEditing(false); }; - // Si l'utilisateur n'est pas chargĂ©, afficher un message au lieu de retourner null - // (retourner null empĂȘche la navigation de se terminer) if (!user) { return ( @@ -91,114 +202,376 @@ export function ProfileForm() { } return ( - - - {t('profile.title')} - {!isEditing && ( - - )} - - -

- {t('profile.personalInfo')} -

- -
- {/* Avatar Section (Mocked UI based on test expectation) */} - {isEditing && ( -
- +
+ {/* FE-PAGE-003: Profile Completion Indicator */} + {completion && ( + + + + {completion.percentage === 100 ? ( + + ) : ( + + )} + Profile Completion + + + +
+
+ + {completion.percentage}% Complete + + + {completion.percentage === 100 + ? 'Profile Complete!' + : `${completion.missing.length} field(s) missing`} + +
+
+ {completion.missing.length > 0 && ( + + + +

Complete your profile:

+
    + {completion.missing.map((field, index) => ( +
  • {field}
  • + ))} +
+
+
+ )} +
+
+ )} + + + + {t('profile.title') || 'Profile'} + {!isEditing && ( + )} + + +

+ {t('profile.personalInfo') || 'Personal Information'} +

-
- - - {errors.username && ( -

{errors.username.message}

+ + {/* Avatar Section */} + {isEditing && ( +
+ +
)} -
-
- - - {errors.email && ( -

{errors.email.message}

- )} -
- -
-
+
-
-
-
- - - {errors.bio && ( -

{errors.bio.message}

- )} -
- - {isEditing && ( -
- - +
+
+ + + {errors.first_name && ( +

+ {errors.first_name.message} +

+ )} +
+
+ + + {errors.last_name && ( +

+ {errors.last_name.message} +

+ )} +
- )} - - - + +
+ + + {errors.location && ( +

{errors.location.message}

+ )} +
+ + {/* FE-PAGE-003: Bio with Textarea */} +
+ +