veza/apps/web/src/features/user/components/ProfileForm.tsx

375 lines
12 KiB
TypeScript
Raw Normal View History

import { useState, useRef } 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 { EmailVerificationBadge } from '@/features/auth/components/EmailVerificationBadge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Camera, Save, X } from 'lucide-react';
import { useUIStore } from '@/stores/ui';
import { apiService } from '@/services/api';
// Schéma de validation pour le profil
const profileSchema = z.object({
first_name: z.string().min(1, 'Le prénom est requis').optional(),
last_name: z.string().min(1, 'Le nom est requis').optional(),
username: z.string().min(3, 'Le nom d\'utilisateur doit contenir au moins 3 caractères'),
email: z.string().email('Email invalide'),
bio: z.string().max(500, 'La bio ne peut pas dépasser 500 caractères').optional(),
});
type ProfileFormData = z.infer<typeof profileSchema>;
/**
* Composant formulaire de profil utilisateur avec validation et upload d'avatar.
*/
export function ProfileForm() {
const { user, refreshUser } = useAuthStore();
const { t } = useTranslation();
const { addNotification } = useUIStore();
const [isEditing, setIsEditing] = useState(false);
const [avatarPreview, setAvatarPreview] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
reset,
setValue,
} = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
defaultValues: {
first_name: user?.first_name || '',
last_name: user?.last_name || '',
username: user?.username || '',
email: user?.email || '',
bio: user?.bio || '',
},
});
const handleEdit = () => {
setIsEditing(true);
reset({
first_name: user?.first_name || '',
last_name: user?.last_name || '',
username: user?.username || '',
email: user?.email || '',
bio: user?.bio || '',
});
};
const handleCancel = () => {
setIsEditing(false);
setAvatarPreview(null);
reset({
first_name: user?.first_name || '',
last_name: user?.last_name || '',
username: user?.username || '',
email: user?.email || '',
bio: user?.bio || '',
});
};
const handleAvatarClick = () => {
if (isEditing && fileInputRef.current) {
fileInputRef.current.click();
}
};
const handleAvatarChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
// Validation du type de fichier
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
if (!allowedTypes.includes(file.type)) {
addNotification({
type: 'error',
title: 'Type de fichier invalide',
message: 'Veuillez sélectionner une image (JPEG, PNG, GIF, WebP)',
});
return;
}
// Validation de la taille (max 5MB)
const maxSize = 5 * 1024 * 1024; // 5MB
if (file.size > maxSize) {
addNotification({
type: 'error',
title: 'Fichier trop volumineux',
message: 'L\'image ne peut pas dépasser 5MB',
});
return;
}
// Créer une preview
const reader = new FileReader();
reader.onloadend = () => {
setAvatarPreview(reader.result as string);
};
reader.readAsDataURL(file);
// Upload de l'avatar
setIsUploading(true);
try {
const formData = new FormData();
formData.append('avatar', file);
// TODO: Implémenter l'endpoint API pour l'upload d'avatar
// const response = await apiService.uploadAvatar(formData);
// await refreshUser();
addNotification({
type: 'success',
title: 'Avatar mis à jour',
message: 'Votre photo de profil a été mise à jour avec succès',
});
} catch (error) {
console.error('Error uploading avatar:', error);
addNotification({
type: 'error',
title: 'Erreur',
message: 'Impossible de mettre à jour l\'avatar',
});
} finally {
setIsUploading(false);
}
};
const onSubmit = async (data: ProfileFormData) => {
try {
if (!user?.id) return;
// Mettre à jour le profil via l'API
await apiService.updateUser(user.id, {
first_name: data.first_name,
last_name: data.last_name,
username: data.username,
email: data.email,
bio: data.bio,
});
// Rafraîchir les données utilisateur
await refreshUser();
setIsEditing(false);
setAvatarPreview(null);
addNotification({
type: 'success',
title: 'Profil mis à jour',
message: 'Vos informations ont été mises à jour avec succès',
});
} catch (error) {
console.error('Error updating profile:', error);
addNotification({
type: 'error',
title: 'Erreur',
message: 'Impossible de mettre à jour le profil',
});
}
};
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">
{t('profile.title')}
</h1>
<p className="text-muted-foreground">{t('profile.subtitle')}</p>
</div>
<div className="grid gap-6 md:grid-cols-3">
<div className="md:col-span-2">
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>{t('profile.personalInfo')}</CardTitle>
<CardDescription>
{t('profile.updateProfile')}
</CardDescription>
</div>
{!isEditing && (
<Button onClick={handleEdit}>
{t('profile.edit')}
</Button>
)}
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="first_name">
{t('profile.fields.firstName')}
</Label>
<Input
id="first_name"
{...register('first_name')}
disabled={!isEditing}
aria-invalid={errors.first_name ? 'true' : 'false'}
/>
{errors.first_name && (
<p className="text-sm text-destructive">
{errors.first_name.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="last_name">
{t('profile.fields.lastName')}
</Label>
<Input
id="last_name"
{...register('last_name')}
disabled={!isEditing}
aria-invalid={errors.last_name ? 'true' : 'false'}
/>
{errors.last_name && (
<p className="text-sm text-destructive">
{errors.last_name.message}
</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="username">
{t('profile.fields.username')}
</Label>
<Input
id="username"
{...register('username')}
disabled={!isEditing}
aria-invalid={errors.username ? 'true' : 'false'}
/>
{errors.username && (
<p className="text-sm text-destructive">
{errors.username.message}
</p>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="email">{t('profile.fields.email')}</Label>
{/* T0190: Afficher badge de vérification d'email */}
{user && (
<EmailVerificationBadge verified={user.is_verified} />
)}
</div>
<Input
id="email"
type="email"
{...register('email')}
disabled={!isEditing}
aria-invalid={errors.email ? 'true' : 'false'}
/>
{errors.email && (
<p className="text-sm text-destructive">
{errors.email.message}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="bio">{t('profile.fields.bio')}</Label>
<textarea
id="bio"
{...register('bio')}
disabled={!isEditing}
className="flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
placeholder={t('profile.fields.bioPlaceholder')}
aria-invalid={errors.bio ? 'true' : 'false'}
/>
{errors.bio && (
<p className="text-sm text-destructive">
{errors.bio.message}
</p>
)}
</div>
{isEditing && (
<div className="flex space-x-2">
<Button type="submit" disabled={isSubmitting || isUploading}>
<Save className="mr-2 h-4 w-4" />
{t('profile.save')}
</Button>
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={isSubmitting || isUploading}
>
<X className="mr-2 h-4 w-4" />
{t('profile.cancel')}
</Button>
</div>
)}
</form>
</CardContent>
</Card>
</div>
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle>{t('profile.avatar.title')}</CardTitle>
</CardHeader>
<CardContent className="text-center">
<div className="relative inline-block">
<Avatar className="mx-auto h-24 w-24">
<AvatarImage
src={avatarPreview || user?.avatar_url}
alt={user?.username}
/>
<AvatarFallback>
{user?.first_name?.[0] || user?.username?.[0] || 'U'}
</AvatarFallback>
</Avatar>
{isEditing && (
<Button
variant="outline"
size="sm"
className="mt-4"
onClick={handleAvatarClick}
disabled={isUploading}
>
<Camera className="mr-2 h-4 w-4" />
{isUploading
? 'Upload...'
: t('profile.avatar.changePhoto')}
</Button>
)}
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
onChange={handleAvatarChange}
className="hidden"
aria-label="Upload avatar"
/>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}