- Added profile completion indicator with progress bar - Added profile completion percentage and missing fields display - 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
577 lines
21 KiB
TypeScript
577 lines
21 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useForm } from 'react-hook-form';
|
|
import { zodResolver } from '@hookform/resolvers/zod';
|
|
import { z } from 'zod';
|
|
import { useAuthStore } from '@/stores/auth';
|
|
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';
|
|
|
|
// 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().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<typeof profileSchema>;
|
|
|
|
export function ProfileForm() {
|
|
const { user, refreshUser } = useAuthStore();
|
|
const { t } = useTranslation();
|
|
const toast = useToast();
|
|
const [isEditing, setIsEditing] = useState(false);
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [completion, setCompletion] = useState<ProfileCompletion | null>(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<ProfileFormData>({
|
|
resolver: zodResolver(profileSchema),
|
|
defaultValues: {
|
|
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 || '',
|
|
},
|
|
},
|
|
mode: 'onBlur',
|
|
});
|
|
|
|
const {
|
|
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;
|
|
|
|
// FE-PAGE-003: Prepare update request with social links
|
|
const updateData: UpdateProfileRequest & { social_links?: Record<string, string> } = {
|
|
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<string, string> = {};
|
|
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();
|
|
|
|
// 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) {
|
|
toast.error(err.message || t('profile.error') || 'Failed to update profile');
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
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 || '',
|
|
},
|
|
});
|
|
setIsEditing(false);
|
|
};
|
|
|
|
if (!user) {
|
|
return (
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="text-center text-muted-foreground">
|
|
<p>Chargement du profil...</p>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* FE-PAGE-003: Profile Completion Indicator */}
|
|
{completion && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
{completion.percentage === 100 ? (
|
|
<CheckCircle2 className="h-5 w-5 text-green-600" />
|
|
) : (
|
|
<AlertCircle className="h-5 w-5 text-yellow-600" />
|
|
)}
|
|
Profile Completion
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between text-sm">
|
|
<span className="text-muted-foreground">
|
|
{completion.percentage}% Complete
|
|
</span>
|
|
<span className="font-medium">
|
|
{completion.percentage === 100
|
|
? 'Profile Complete!'
|
|
: `${completion.missing.length} field(s) missing`}
|
|
</span>
|
|
</div>
|
|
<Progress value={completion.percentage} className="h-2" />
|
|
</div>
|
|
{completion.missing.length > 0 && (
|
|
<Alert>
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertDescription>
|
|
<p className="font-medium mb-1">Complete your profile:</p>
|
|
<ul className="list-disc list-inside text-sm space-y-1">
|
|
{completion.missing.map((field, index) => (
|
|
<li key={index}>{field}</li>
|
|
))}
|
|
</ul>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
<Card>
|
|
<CardHeader className="flex flex-row items-center justify-between">
|
|
<CardTitle>{t('profile.title') || 'Profile'}</CardTitle>
|
|
{!isEditing && (
|
|
<Button onClick={() => setIsEditing(true)} variant="outline">
|
|
{t('profile.edit') || 'Edit'}
|
|
</Button>
|
|
)}
|
|
</CardHeader>
|
|
<CardContent>
|
|
<h3 className="text-lg font-medium mb-4">
|
|
{t('profile.personalInfo') || 'Personal Information'}
|
|
</h3>
|
|
|
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
|
{/* Avatar Section */}
|
|
{isEditing && (
|
|
<div className="mb-4">
|
|
<Button type="button" variant="ghost">
|
|
{t('profile.avatar.changePhoto') || 'Change Photo'}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid gap-2">
|
|
<label className="text-sm font-medium" htmlFor="username">
|
|
Username
|
|
</label>
|
|
<Input
|
|
id="username"
|
|
{...register('username')}
|
|
disabled={!isEditing}
|
|
/>
|
|
{errors.username && (
|
|
<p className="text-sm text-red-500">{errors.username.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<label className="text-sm font-medium" htmlFor="email">
|
|
Email
|
|
</label>
|
|
<Input
|
|
id="email"
|
|
type="email"
|
|
{...register('email')}
|
|
disabled={!isEditing}
|
|
/>
|
|
{errors.email && (
|
|
<p className="text-sm text-red-500">{errors.email.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="grid gap-2">
|
|
<label className="text-sm font-medium" htmlFor="first_name">
|
|
First Name
|
|
</label>
|
|
<Input
|
|
id="first_name"
|
|
{...register('first_name')}
|
|
disabled={!isEditing}
|
|
/>
|
|
{errors.first_name && (
|
|
<p className="text-sm text-red-500">
|
|
{errors.first_name.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="grid gap-2">
|
|
<label className="text-sm font-medium" htmlFor="last_name">
|
|
Last Name
|
|
</label>
|
|
<Input
|
|
id="last_name"
|
|
{...register('last_name')}
|
|
disabled={!isEditing}
|
|
/>
|
|
{errors.last_name && (
|
|
<p className="text-sm text-red-500">
|
|
{errors.last_name.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<label className="text-sm font-medium" htmlFor="location">
|
|
Location
|
|
</label>
|
|
<Input
|
|
id="location"
|
|
{...register('location')}
|
|
disabled={!isEditing}
|
|
placeholder="City, Country"
|
|
/>
|
|
{errors.location && (
|
|
<p className="text-sm text-red-500">{errors.location.message}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* FE-PAGE-003: Bio with Textarea */}
|
|
<div className="grid gap-2">
|
|
<label className="text-sm font-medium" htmlFor="bio">
|
|
Bio
|
|
</label>
|
|
<Textarea
|
|
id="bio"
|
|
{...register('bio')}
|
|
disabled={!isEditing}
|
|
placeholder="Tell us about yourself..."
|
|
rows={4}
|
|
maxLength={500}
|
|
/>
|
|
<div className="flex justify-between text-xs text-muted-foreground">
|
|
{errors.bio && (
|
|
<p className="text-red-500">{errors.bio.message}</p>
|
|
)}
|
|
<span className="ml-auto">
|
|
{watchedValues.bio?.length || 0}/500 characters
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* FE-PAGE-003: Social Links Section */}
|
|
{isEditing && (
|
|
<div className="space-y-4 pt-4 border-t">
|
|
<h4 className="text-md font-medium">Social Links</h4>
|
|
<div className="grid gap-4">
|
|
<div className="grid gap-2">
|
|
<label
|
|
className="text-sm font-medium flex items-center gap-2"
|
|
htmlFor="twitter"
|
|
>
|
|
<Twitter className="h-4 w-4" />
|
|
Twitter
|
|
</label>
|
|
<Input
|
|
id="twitter"
|
|
type="url"
|
|
placeholder="https://twitter.com/username"
|
|
{...register('social_links.twitter')}
|
|
/>
|
|
{errors.social_links?.twitter && (
|
|
<p className="text-sm text-red-500">
|
|
{errors.social_links.twitter.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<label
|
|
className="text-sm font-medium flex items-center gap-2"
|
|
htmlFor="instagram"
|
|
>
|
|
<Instagram className="h-4 w-4" />
|
|
Instagram
|
|
</label>
|
|
<Input
|
|
id="instagram"
|
|
type="url"
|
|
placeholder="https://instagram.com/username"
|
|
{...register('social_links.instagram')}
|
|
/>
|
|
{errors.social_links?.instagram && (
|
|
<p className="text-sm text-red-500">
|
|
{errors.social_links.instagram.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<label
|
|
className="text-sm font-medium flex items-center gap-2"
|
|
htmlFor="facebook"
|
|
>
|
|
<Facebook className="h-4 w-4" />
|
|
Facebook
|
|
</label>
|
|
<Input
|
|
id="facebook"
|
|
type="url"
|
|
placeholder="https://facebook.com/username"
|
|
{...register('social_links.facebook')}
|
|
/>
|
|
{errors.social_links?.facebook && (
|
|
<p className="text-sm text-red-500">
|
|
{errors.social_links.facebook.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<label
|
|
className="text-sm font-medium flex items-center gap-2"
|
|
htmlFor="youtube"
|
|
>
|
|
<Youtube className="h-4 w-4" />
|
|
YouTube
|
|
</label>
|
|
<Input
|
|
id="youtube"
|
|
type="url"
|
|
placeholder="https://youtube.com/@username"
|
|
{...register('social_links.youtube')}
|
|
/>
|
|
{errors.social_links?.youtube && (
|
|
<p className="text-sm text-red-500">
|
|
{errors.social_links.youtube.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid gap-2">
|
|
<label
|
|
className="text-sm font-medium flex items-center gap-2"
|
|
htmlFor="website"
|
|
>
|
|
<LinkIcon className="h-4 w-4" />
|
|
Website
|
|
</label>
|
|
<Input
|
|
id="website"
|
|
type="url"
|
|
placeholder="https://example.com"
|
|
{...register('social_links.website')}
|
|
/>
|
|
{errors.social_links?.website && (
|
|
<p className="text-sm text-red-500">
|
|
{errors.social_links.website.message}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* FE-PAGE-003: Display social links when not editing */}
|
|
{!isEditing && watchedValues.social_links && (
|
|
<div className="space-y-4 pt-4 border-t">
|
|
<h4 className="text-md font-medium">Social Links</h4>
|
|
<div className="flex flex-wrap gap-4">
|
|
{watchedValues.social_links.twitter && (
|
|
<a
|
|
href={watchedValues.social_links.twitter}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-2 text-blue-500 hover:underline"
|
|
>
|
|
<Twitter className="h-4 w-4" />
|
|
Twitter
|
|
</a>
|
|
)}
|
|
{watchedValues.social_links.instagram && (
|
|
<a
|
|
href={watchedValues.social_links.instagram}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-2 text-pink-500 hover:underline"
|
|
>
|
|
<Instagram className="h-4 w-4" />
|
|
Instagram
|
|
</a>
|
|
)}
|
|
{watchedValues.social_links.facebook && (
|
|
<a
|
|
href={watchedValues.social_links.facebook}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-2 text-blue-600 hover:underline"
|
|
>
|
|
<Facebook className="h-4 w-4" />
|
|
Facebook
|
|
</a>
|
|
)}
|
|
{watchedValues.social_links.youtube && (
|
|
<a
|
|
href={watchedValues.social_links.youtube}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-2 text-red-500 hover:underline"
|
|
>
|
|
<Youtube className="h-4 w-4" />
|
|
YouTube
|
|
</a>
|
|
)}
|
|
{watchedValues.social_links.website && (
|
|
<a
|
|
href={watchedValues.social_links.website}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-2 text-muted-foreground hover:underline"
|
|
>
|
|
<LinkIcon className="h-4 w-4" />
|
|
Website
|
|
</a>
|
|
)}
|
|
{!watchedValues.social_links.twitter &&
|
|
!watchedValues.social_links.instagram &&
|
|
!watchedValues.social_links.facebook &&
|
|
!watchedValues.social_links.youtube &&
|
|
!watchedValues.social_links.website && (
|
|
<p className="text-sm text-muted-foreground">
|
|
No social links added
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{isEditing && (
|
|
<div className="flex justify-end gap-2 mt-4">
|
|
<Button type="button" variant="secondary" onClick={handleCancel}>
|
|
{t('profile.cancel') || 'Cancel'}
|
|
</Button>
|
|
<Button type="submit" disabled={isLoading}>
|
|
{isLoading
|
|
? 'Saving...'
|
|
: t('profile.save') || 'Save'}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</form>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|