veza/apps/web/src/features/user/components/ProfileForm.tsx
senke daf1f92155 [FE-PAGE-003] fe-page: Complete Profile page implementation
- 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
2025-12-24 12:41:34 +01:00

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>
);
}