Files
pn-new-crm/src/components/settings/user-settings.tsx
Matt 4329db7fc3 fix(compiler): React Compiler safety triage — 5 categories cleared
Cleared 4 rule buckets (37 violations, including 5 real bugs) and
silenced 1 informational bucket from the Next 16 / react-hooks v7
upgrade. Cleared rules promoted from `warn` back to `error` so new
regressions block CI.

Real bug fixes:
- `interest-contact-log-tab.tsx`: `useMemo` used for side effects
  (5 setState calls inside a memo body); converted to `useEffect`.
- `PieChart.tsx`: cumulative `let angle` mutation in a render-phase
  `map`; converted to `reduce` so the slice array is built without
  re-assignment.
- `documents-hub.tsx`: `useMemo(() => ({ count: 0 }))` used as a
  mutable drag counter; converted to `useRef`.
- `notes-list.tsx`: `Date.now()` read during render for note-edit
  countdown (impure) → pinned to a `now` state ticked every 30s.
- `onboarding-checklist.tsx` / `user-profile.tsx` /
  `user-settings.tsx`: `useEffect(() => void load(), [])` with the
  `load` function declared AFTER the effect — relied on hoisting,
  trips Compiler's "access before declared" rule. Declared inside
  the effect.

Pattern fixes (intentional cache-via-ref → state or layout-effect):
- 6 `ref.current = x` writes during render moved into layout
  effects (`use-realtime-invalidation`, `settings-form-card`,
  `inbox`).
- 3 `ref.current` reads during render (search totals cache,
  scanner file ref) rewritten to backed-by-state.
- `use-is-mobile.ts` rewritten on `useSyncExternalStore` to avoid
  the SSR-then-rehydrate setState dance.
- `use-notifications.ts` rewritten to write socket pushes directly
  into the React Query cache via `setQueryData`, removing a local
  state mirror.

Rule config (`eslint.config.mjs`):
- `react-hooks/purity` → error (was warn, cleared)
- `react-hooks/set-state-in-render` → error (was warn, cleared)
- `react-hooks/immutability` → error (was warn, cleared)
- `react-hooks/refs` → error (was warn, cleared)
- `react-hooks/incompatible-library` → off (informational only)
- `react-hooks/set-state-in-effect` → warn (51 remaining, all the
  useEffect→fetch→setState data-fetch pattern; migration to
  useQuery tracked in BACKLOG)

Verified: tsc clean, eslint 0 errors / 69 warnings (down from 105),
vitest 1315/1315, next build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 23:14:16 +02:00

458 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<string | null>(null);
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
const [originalEmail, setOriginalEmail] = useState('');
const [country, setCountry] = useState<string | null>(null);
const [timezone, setTimezone] = useState<string | null>(null);
const [saving, setSaving] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const [resetMsg, setResetMsg] = useState<string | null>(null);
const [emailMsg, setEmailMsg] = useState<string | null>(null);
const [avatarFileId, setAvatarFileId] = useState<string | null>(null);
const [avatarUrl, setAvatarUrl] = useState<string | null>(null);
const [pendingAvatarFile, setPendingAvatarFile] = useState<File | null>(null);
const [cropperOpen, setCropperOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(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(() => {
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);
}
void loadProfile();
}, [detectedTz]);
// 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<HTMLInputElement>) {
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 (
<div>
<PageHeader title="Settings" description="Manage your profile and notification preferences" />
<div className="mt-6 space-y-6">
<Card>
<CardHeader>
<CardTitle>Profile</CardTitle>
<CardDescription>Update your display name and contact info</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Avatar — click the photo to pick a file, which opens the
cropper. Cropped result uploads via /api/v1/me/avatar. */}
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16">
{avatarUrl ? <AvatarImage src={avatarUrl} alt="Profile photo" /> : null}
<AvatarFallback>
{(displayName || email || 'U').slice(0, 1).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="space-y-1">
<Label className="text-sm">Profile photo</Label>
<div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
>
<Upload className="mr-1.5 h-3.5 w-3.5" />
{avatarFileId ? 'Replace photo' : 'Upload photo'}
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={handleAvatarPicked}
className="hidden"
/>
</div>
<p className="text-xs text-muted-foreground">
PNG, JPG, or WebP up to 2 MB. You&apos;ll be able to crop after picking.
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label htmlFor="settings-first-name">First name</Label>
<Input
id="settings-first-name"
value={firstName}
onChange={(e) => setFirstName(e.target.value)}
placeholder="Given name"
autoComplete="given-name"
/>
</div>
<div className="space-y-2">
<Label htmlFor="settings-last-name">Last name</Label>
<Input
id="settings-last-name"
value={lastName}
onChange={(e) => setLastName(e.target.value)}
placeholder="Family name"
autoComplete="family-name"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="settings-name">
Display name <span className="text-muted-foreground">(optional)</span>
</Label>
<Input
id="settings-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="How your name appears in the UI"
/>
<p className="text-xs text-muted-foreground">
Defaults to first + last when blank. Override with a nickname if you prefer.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="settings-phone">Phone</Label>
<PhoneInput
id="settings-phone"
value={
phone
? ({ e164: phone, country: (country as never) ?? 'US' } as PhoneInputValue)
: null
}
onChange={(next) => setPhone(next.e164 ?? '')}
placeholder="555 0123"
/>
</div>
<div className="space-y-2">
<Label>Country</Label>
<CountryCombobox value={country} onChange={handleCountryChange} />
<p className="text-xs text-muted-foreground">
Sets the default timezone when you pick a country and haven&apos;t chosen one
explicitly.
</p>
</div>
<div className="space-y-2">
<Label>Timezone</Label>
<TimezoneCombobox
value={timezone}
onChange={setTimezone}
countryHint={(country as CountryCode | null) ?? undefined}
/>
{tzMismatch && (
<div className="flex items-start gap-2 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-xs text-amber-900">
<Globe className="h-3.5 w-3.5 shrink-0 mt-0.5" />
<div className="flex-1">
Looks like you&apos;re in <strong>{detectedTz}</strong> right now (saved:{' '}
{timezone}).
<button
type="button"
onClick={adoptDetectedTz}
className="ml-1 underline underline-offset-2 hover:no-underline"
>
Update?
</button>
</div>
</div>
)}
</div>
<div className="flex items-center gap-3">
<Button onClick={saveProfile} disabled={saving === 'profile'}>
<Save className="mr-1.5 h-4 w-4" />
{saving === 'profile' ? 'Saving…' : 'Save profile'}
</Button>
{message && <span className="text-sm text-muted-foreground">{message}</span>}
</div>
</CardContent>
</Card>
<DashboardWidgetsCard />
<Card>
<CardHeader>
<CardTitle>Account</CardTitle>
<CardDescription>Sign-in email and password</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="settings-username">
Username <span className="text-muted-foreground">(optional)</span>
</Label>
<Input
id="settings-username"
value={username}
onChange={(e) => setUsername(e.target.value.toLowerCase())}
placeholder="yourname"
autoCapitalize="none"
spellCheck={false}
pattern="^[a-z0-9._-]{2,30}$"
/>
<div className="flex items-center gap-3">
<Button
size="sm"
variant="outline"
onClick={saveUsername}
disabled={
saving === 'username' ||
username.trim().toLowerCase() === originalUsername.toLowerCase()
}
>
{saving === 'username' ? 'Saving…' : 'Save username'}
</Button>
{usernameMsg && (
<span className="text-xs text-muted-foreground">{usernameMsg}</span>
)}
</div>
<p className="text-xs text-muted-foreground">
Optional alias you can use to sign in instead of your email. 230 lowercase letters,
digits, dot, underscore, or hyphen.
</p>
</div>
<div className="space-y-2 pt-2 border-t">
<Label htmlFor="settings-email">Email</Label>
<Input
id="settings-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<div className="flex items-center gap-3">
<Button
size="sm"
variant="outline"
onClick={saveEmail}
disabled={saving === 'email' || email === originalEmail || !email}
>
{saving === 'email' ? 'Updating…' : 'Update email'}
</Button>
{emailMsg && <span className="text-xs text-muted-foreground">{emailMsg}</span>}
</div>
<p className="text-xs text-muted-foreground">
Changing your email also changes the address you use to sign in.
</p>
</div>
<div className="space-y-2 pt-2 border-t">
<Label>Password</Label>
<div className="flex items-center gap-3">
<Button
size="sm"
variant="outline"
onClick={requestPasswordReset}
disabled={saving === 'password'}
>
<KeyRound className="mr-1.5 h-3.5 w-3.5" />
{saving === 'password' ? 'Sending…' : 'Send reset email'}
</Button>
{resetMsg && <span className="text-xs text-muted-foreground">{resetMsg}</span>}
</div>
<p className="text-xs text-muted-foreground">
We&apos;ll email you a link to set a new password.
</p>
</div>
</CardContent>
</Card>
<section id="notifications" className="space-y-4">
<div>
<h2 className="text-lg font-semibold">Notifications</h2>
<p className="text-sm text-muted-foreground">
Choose which notifications you receive and how they&apos;re delivered.
</p>
</div>
<NotificationPreferencesForm />
<ReminderDigestForm />
</section>
</div>
<ImageCropperDialog
open={cropperOpen}
onOpenChange={setCropperOpen}
file={pendingAvatarFile}
aspect={1}
outputWidth={256}
title="Crop profile photo"
onUpload={uploadAvatar}
/>
</div>
);
}