'use client'; import { useState, useEffect, useMemo, useRef } from 'react'; import { Save, KeyRound, Globe, Upload } from 'lucide-react'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; 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 { PageHeader } from '@/components/shared/page-header'; import { CountryCombobox } from '@/components/shared/country-combobox'; import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input'; import { TimezoneCombobox } from '@/components/shared/timezone-combobox'; import { ImageCropperDialog } from '@/components/shared/image-cropper-dialog'; import { NotificationPreferencesForm } from '@/components/notifications/notification-preferences-form'; import { ReminderDigestForm } from '@/components/notifications/reminder-digest-form'; import { DashboardWidgetsCard } from '@/components/settings/dashboard-widgets-card'; import { apiFetch } from '@/lib/api/client'; import { primaryTimezoneFor } from '@/lib/i18n/timezones'; import type { CountryCode } from '@/lib/i18n/countries'; interface MeResponse { user?: { name: string; email: string }; preferences?: { country?: string; timezone?: string }; profile?: { avatarFileId?: string | null; firstName?: string | null; lastName?: string | null; displayName?: string | null; username?: string | null; }; } export function UserSettings() { const [firstName, setFirstName] = useState(''); const [lastName, setLastName] = useState(''); const [displayName, setDisplayName] = useState(''); const [username, setUsername] = useState(''); const [originalUsername, setOriginalUsername] = useState(''); const [usernameMsg, setUsernameMsg] = useState(null); const [phone, setPhone] = useState(''); const [email, setEmail] = useState(''); const [originalEmail, setOriginalEmail] = useState(''); const [country, setCountry] = useState(null); const [timezone, setTimezone] = useState(null); const [saving, setSaving] = useState(null); const [message, setMessage] = useState(null); const [resetMsg, setResetMsg] = useState(null); const [emailMsg, setEmailMsg] = useState(null); const [avatarFileId, setAvatarFileId] = useState(null); const [avatarUrl, setAvatarUrl] = useState(null); const [pendingAvatarFile, setPendingAvatarFile] = useState(null); const [cropperOpen, setCropperOpen] = useState(false); const fileInputRef = useRef(null); // Browser-detected timezone — surfaces a banner when the rep is // travelling and the saved value no longer matches their device's // locale. Quietly absent in SSR; the picker still works without it. const detectedTz = useMemo(() => { if (typeof Intl === 'undefined') return null; try { return Intl.DateTimeFormat().resolvedOptions().timeZone; } catch { return null; } }, []); useEffect(() => { void loadProfile(); }, []); async function loadProfile() { const res = await apiFetch<{ data: MeResponse }>('/api/v1/me', { method: 'GET' }); setFirstName(res.data.profile?.firstName ?? ''); setLastName(res.data.profile?.lastName ?? ''); // Display name is the override; fall back to user.name if profile // doesn't carry one (e.g. legacy rows pre-Wave 10). setDisplayName(res.data.profile?.displayName ?? res.data.user?.name ?? ''); setEmail(res.data.user?.email ?? ''); setOriginalEmail(res.data.user?.email ?? ''); setUsername(res.data.profile?.username ?? ''); setOriginalUsername(res.data.profile?.username ?? ''); setCountry(res.data.preferences?.country ?? null); // Fall back to the browser-detected zone when no value has been saved // yet — first-time users land on a sensible default rather than an // empty picker. Doesn't overwrite an explicit choice. setTimezone(res.data.preferences?.timezone ?? detectedTz ?? null); const fid = res.data.profile?.avatarFileId ?? null; setAvatarFileId(fid); setAvatarUrl(fid ? `/api/v1/files/${fid}/preview` : null); } // When the user picks a country and no timezone is set, suggest the // primary zone for that country. Doesn't fight an explicit timezone // selection — only fires while the timezone slot is empty. useEffect(() => { if (!country || timezone) return; const primary = primaryTimezoneFor(country as CountryCode); if (primary) setTimezone(primary); }, [country, timezone]); function handleAvatarPicked(e: React.ChangeEvent) { const file = e.target.files?.[0] ?? null; if (!file) return; setPendingAvatarFile(file); setCropperOpen(true); // Reset so picking the same file twice still triggers onChange. if (fileInputRef.current) fileInputRef.current.value = ''; } async function uploadAvatar(blob: Blob) { const fd = new FormData(); fd.append('file', new File([blob], 'avatar.jpg', { type: 'image/jpeg' })); const res = await fetch('/api/v1/me/avatar', { method: 'POST', body: fd }); if (!res.ok) throw new Error('Avatar upload failed'); const json = (await res.json()) as { data: { avatarFileId: string } }; setAvatarFileId(json.data.avatarFileId); // Cache-bust the preview URL with a query so the new image renders. setAvatarUrl(`/api/v1/files/${json.data.avatarFileId}/preview?t=${Date.now()}`); } function handleCountryChange(iso: string | null) { setCountry(iso); // Auto-default timezone when the rep picks a country and hasn't // explicitly set a timezone yet. Same trick as the client overview. if (iso && !timezone) { const tz = primaryTimezoneFor(iso as CountryCode); if (tz) setTimezone(tz); } } async function saveProfile() { setSaving('profile'); setMessage(null); try { await apiFetch('/api/v1/me', { method: 'PATCH', body: { firstName: firstName.trim() || null, lastName: lastName.trim() || null, displayName: displayName || undefined, phone: phone || null, preferences: { country: country ?? undefined, timezone: timezone ?? undefined, }, }, }); setMessage('Profile saved'); } catch (err: unknown) { setMessage(err instanceof Error ? err.message : 'Failed to save'); } finally { setSaving(null); } } async function saveUsername() { if (username.trim().toLowerCase() === originalUsername.toLowerCase()) return; setSaving('username'); setUsernameMsg(null); try { const next = username.trim().toLowerCase() || null; await apiFetch('/api/v1/me', { method: 'PATCH', body: { username: next } }); setOriginalUsername(next ?? ''); setUsername(next ?? ''); setUsernameMsg( next ? `Username updated. You can now sign in with @${next} or your email.` : 'Username cleared.', ); } catch (err: unknown) { setUsernameMsg(err instanceof Error ? err.message : 'Failed to save username'); } finally { setSaving(null); } } async function saveEmail() { if (email === originalEmail) return; setSaving('email'); setEmailMsg(null); try { await apiFetch('/api/v1/me/email', { method: 'PATCH', body: { email } }); setOriginalEmail(email); setEmailMsg('Email updated. Use the new address next time you sign in.'); } catch (err: unknown) { setEmailMsg(err instanceof Error ? err.message : 'Failed to update email'); } finally { setSaving(null); } } async function requestPasswordReset() { setSaving('password'); setResetMsg(null); try { await apiFetch('/api/v1/me/password-reset', { method: 'POST' }); setResetMsg('Password-reset email sent. Check your inbox.'); } catch (err: unknown) { setResetMsg(err instanceof Error ? err.message : 'Failed to send reset email'); } finally { setSaving(null); } } function adoptDetectedTz() { if (detectedTz) setTimezone(detectedTz); } const tzMismatch = detectedTz && timezone && detectedTz !== timezone; return (
Profile Update your display name and contact info {/* Avatar — click the photo to pick a file, which opens the cropper. Cropped result uploads via /api/v1/me/avatar. */}
{avatarUrl ? : null} {(displayName || email || 'U').slice(0, 1).toUpperCase()}

PNG, JPG, or WebP up to 2 MB. You'll be able to crop after picking.

setFirstName(e.target.value)} placeholder="Given name" autoComplete="given-name" />
setLastName(e.target.value)} placeholder="Family name" autoComplete="family-name" />
setDisplayName(e.target.value)} placeholder="How your name appears in the UI" />

Defaults to first + last when blank. Override with a nickname if you prefer.

setPhone(next.e164 ?? '')} placeholder="555 0123" />

Sets the default timezone when you pick a country and haven't chosen one explicitly.

{tzMismatch && (
Looks like you're in {detectedTz} right now (saved:{' '} {timezone}).
)}
{message && {message}}
Account Sign-in email and password
setUsername(e.target.value.toLowerCase())} placeholder="yourname" autoCapitalize="none" spellCheck={false} pattern="^[a-z0-9._-]{2,30}$" />
{usernameMsg && ( {usernameMsg} )}

Optional alias you can use to sign in instead of your email. 2–30 lowercase letters, digits, dot, underscore, or hyphen.

setEmail(e.target.value)} />
{emailMsg && {emailMsg}}

Changing your email also changes the address you use to sign in.

{resetMsg && {resetMsg}}

We'll email you a link to set a new password.

Notifications

Choose which notifications you receive and how they're delivered.

); }