import { useEffect, useRef } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { updateProfileSchema, type UpdateProfileInput, type Profile, } from '@family-wishlist/shared'; import { toast } from 'sonner'; import { Gift, Loader2, Upload } from 'lucide-react'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { Label } from '@/components/ui/Label'; import { Textarea } from '@/components/ui/Textarea'; import { api, ApiError } from '@/lib/api'; import { useAuthStore } from '@/features/auth/authStore'; import { translateValidation, useI18n } from '@/i18n/i18n'; const MAX_AVATAR_BYTES = 2 * 1024 * 1024; const AVATAR_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']); export function ProfileSettingsPage() { const { t } = useI18n(); const refresh = useAuthStore((s) => s.refresh); const queryClient = useQueryClient(); const fileInputRef = useRef(null); const { data, isLoading } = useQuery({ queryKey: ['profile'], queryFn: () => api.get('/api/profile'), }); const { register, handleSubmit, reset, watch, formState: { errors, isSubmitting, isDirty }, } = useForm({ resolver: zodResolver(updateProfileSchema), defaultValues: { slug: '', displayName: '', bio: '', avatarUrl: '' }, }); useEffect(() => { if (data) { reset({ slug: data.slug, displayName: data.displayName, bio: data.bio ?? '', avatarUrl: data.avatarUrl ?? '', }); } }, [data, reset]); const update = useMutation({ mutationFn: (values: UpdateProfileInput) => api.patch('/api/profile', values), onSuccess: (p) => { toast.success(t('profile.saved')); void queryClient.invalidateQueries({ queryKey: ['profile'] }); void refresh(); reset({ slug: p.slug, displayName: p.displayName, bio: p.bio ?? '', avatarUrl: p.avatarUrl ?? '', }); }, onError: (err) => { if (err instanceof ApiError) toast.error(err.message); else toast.error(t('profile.saveFailed')); }, }); const uploadAvatar = useMutation({ mutationFn: (file: File) => { const form = new FormData(); form.append('file', file); return api.upload('/api/profile/avatar', form); }, onSuccess: (p) => { toast.success(t('profile.avatarUploaded')); void queryClient.invalidateQueries({ queryKey: ['profile'] }); void refresh(); reset({ slug: p.slug, displayName: p.displayName, bio: p.bio ?? '', avatarUrl: p.avatarUrl ?? '', }); }, onError: (err) => { if (err instanceof ApiError) toast.error(err.message); else toast.error(t('profile.saveFailed')); }, }); function handleAvatarFile(file: File | undefined): void { if (!file) return; if (!AVATAR_MIME_TYPES.has(file.type)) { toast.error(t('profile.avatarUnsupported')); return; } if (file.size > MAX_AVATAR_BYTES) { toast.error(t('profile.avatarTooLarge')); return; } uploadAvatar.mutate(file); } const submit = handleSubmit((values) => { const payload: UpdateProfileInput = { ...values, bio: values.bio ? values.bio : null, avatarUrl: values.avatarUrl ? values.avatarUrl : null, }; update.mutate(payload); }); const avatarPreview = watch('avatarUrl') || data?.avatarUrl; return (

{t('profile.title')}

{t('profile.publicPage')} /u/{data?.slug ?? '...'}.

{isLoading ? (
{t('common.loading')}
) : (
{avatarPreview ? ( ) : ( )}

{t('profile.avatar')}

{t('profile.avatarHint')}

{ handleAvatarFile(e.target.files?.[0]); e.currentTarget.value = ''; }} />
{errors.slug && ( {translateValidation(t, errors.slug.message)} )}
{errors.displayName && ( {translateValidation(t, errors.displayName.message)} )}