375 lines
12 KiB
TypeScript
375 lines
12 KiB
TypeScript
|
|
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>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|