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>
458 lines
18 KiB
TypeScript
458 lines
18 KiB
TypeScript
'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'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'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'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. 2–30 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'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'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>
|
||
);
|
||
}
|