'use client'; import { useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; import { Loader2, Upload, Trash2, Mail, CircleAlert } from 'lucide-react'; import { createClient } from '@/lib/supabase/client'; import { useAuth } from '@/hooks/use-auth'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { Avatar, AvatarFallback, AvatarImage, } from '@/components/ui/avatar'; import { Card, CardContent, CardHeader, CardTitle, CardDescription, } from '@/components/ui/card'; const MAX_AVATAR_BYTES = 2 * 1024 * 1024; const ALLOWED_MIME = new Set([ 'image/png', 'image/jpeg', 'image/webp', 'image/gif', ]); // Rough email shape check — the real validator is Supabase Auth, which // rejects anything malformed when we call updateUser({ email }). We // just want to stop obvious typos before making a network call. const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; export function ProfileForm() { const { user, profile, refreshProfile } = useAuth(); const supabase = createClient(); const fileInputRef = useRef(null); const [fullName, setFullName] = useState(''); const [email, setEmail] = useState(''); const [pendingAvatar, setPendingAvatar] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); const [removeAvatar, setRemoveAvatar] = useState(false); const [saving, setSaving] = useState(false); const [emailChangePending, setEmailChangePending] = useState(false); // Seed form state once the profile loads. useEffect(() => { if (!profile) return; setFullName(profile.full_name ?? ''); setEmail(profile.email ?? ''); }, [profile]); // Cleanup object URLs to avoid leaks. useEffect(() => { return () => { if (previewUrl) URL.revokeObjectURL(previewUrl); }; }, [previewUrl]); const currentAvatar = previewUrl ?? (!removeAvatar ? profile?.avatar_url ?? null : null); const initial = (fullName || profile?.full_name || profile?.email || 'U') .charAt(0) .toUpperCase(); const onPickFile = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; e.target.value = ''; // reset so the same file can be re-picked if (!file) return; if (!ALLOWED_MIME.has(file.type)) { toast.error('Unsupported image type', { description: 'Use PNG, JPG, WebP, or GIF.', }); return; } if (file.size > MAX_AVATAR_BYTES) { toast.error('Image is too large', { description: 'Maximum 2 MB.', }); return; } if (previewUrl) URL.revokeObjectURL(previewUrl); setPendingAvatar(file); setPreviewUrl(URL.createObjectURL(file)); setRemoveAvatar(false); }; const onRemoveAvatar = () => { if (previewUrl) URL.revokeObjectURL(previewUrl); setPendingAvatar(null); setPreviewUrl(null); setRemoveAvatar(true); }; const onSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (!user || !profile) return; const trimmedName = fullName.trim(); if (!trimmedName) { toast.error('Display name is required'); return; } const trimmedEmail = email.trim(); if (!EMAIL_RE.test(trimmedEmail)) { toast.error('Enter a valid email address'); return; } setSaving(true); try { let nextAvatarUrl: string | null = profile.avatar_url ?? null; // Upload a newly-staged image, if any. if (pendingAvatar) { const ext = pendingAvatar.name.split('.').pop()?.toLowerCase() || 'png'; const path = `${user.id}/avatar-${Date.now()}.${ext}`; const { error: uploadError } = await supabase.storage .from('avatars') .upload(path, pendingAvatar, { cacheControl: '3600', upsert: true, contentType: pendingAvatar.type, }); if (uploadError) { throw new Error(`Upload failed: ${uploadError.message}`); } const { data: { publicUrl }, } = supabase.storage.from('avatars').getPublicUrl(path); nextAvatarUrl = publicUrl; } else if (removeAvatar) { nextAvatarUrl = null; } // Persist name + avatar to profiles. const { error: updateError } = await supabase .from('profiles') .update({ full_name: trimmedName, avatar_url: nextAvatarUrl, }) .eq('user_id', user.id); if (updateError) { throw new Error(`Save failed: ${updateError.message}`); } // Email change goes through Supabase Auth, which emails a // confirmation to both the old and new addresses. We don't // touch profiles.email — Supabase will push the change there // after the user clicks the link (handled by the handle_new_user // trigger pattern in production deployments). let emailSent = false; if (trimmedEmail.toLowerCase() !== profile.email.toLowerCase()) { const { error: emailError } = await supabase.auth.updateUser({ email: trimmedEmail, }); if (emailError) { // Partial success: name/avatar saved but email didn't. toast.success('Profile saved'); toast.error(`Email change failed: ${emailError.message}`); setSaving(false); await refreshProfile(); return; } emailSent = true; } setEmailChangePending(emailSent); setPendingAvatar(null); setPreviewUrl(null); setRemoveAvatar(false); await refreshProfile(); toast.success( emailSent ? 'Profile saved — check your email to confirm the address change' : 'Profile saved', ); } catch (err) { const msg = err instanceof Error ? err.message : 'Unknown error'; toast.error(msg); } finally { setSaving(false); } }; const dirty = !!profile && (fullName.trim() !== (profile.full_name ?? '') || email.trim().toLowerCase() !== (profile.email ?? '').toLowerCase() || pendingAvatar !== null || removeAvatar); const joined = user?.created_at ? new Date(user.created_at).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric', }) : '—'; return ( Profile How you show up across WaCRM. Your avatar and name appear in the header, sidebar, and anywhere your teammates see you.
{/* Avatar row */}
{currentAvatar ? ( ) : null} {initial}
{currentAvatar && ( )}

PNG, JPG, WebP, or GIF. Up to 2 MB.

{/* Name */}
setFullName(e.target.value)} placeholder="Ada Lovelace" maxLength={120} disabled={saving} required />
{/* Email */}
setEmail(e.target.value)} disabled={saving} required /> {emailChangePending && (

Check the inbox for {profile?.email} and{' '} {email} — both need to confirm before the change takes effect.

)}
{/* Read-only block */}

Account details

Role
{profile?.role ?? 'user'}
Joined
{joined}
User ID
{user?.id ?? '—'}
{!profile && (

Loading your profile…

)}
); }