feat(profile): first/last name fields + collapse notification preferences

Two related cleanups for the user profile surface area:

(1) Add canonical first_name + last_name columns to user_profiles.
    Migration 0049 backfills from display_name by splitting on the
    first whitespace run; single-token names land as
    (display_name, NULL) so we never throw away existing data.
    Display name becomes an optional override (nicknames, vanity
    formatting). /api/v1/me PATCH now accepts firstName/lastName,
    and the user-settings form surfaces them as the primary inputs
    with display name as a secondary "How your name appears" field.

(2) Remove the broken Notifications card from user-settings (it called
    PATCH on an endpoint that has GET/PUT only and used a flat shape
    vs the actual array shape). Replace with the working
    NotificationPreferencesForm + ReminderDigestForm under a
    #notifications anchor. /notifications/preferences becomes a
    server-side redirect to /settings#notifications for back-compat;
    the mobile More-sheet + user-menu Bell entry now deep-link to the
    new anchor directly.

Drops the auto-generated drizzle-kit catch-up migration so we're not
sneaking accumulated schema drift into the journal — only the targeted
0049 lands here.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 18:36:31 +02:00
parent 7c25d1aef6
commit 07b5756014
8 changed files with 124 additions and 96 deletions

View File

@@ -7,35 +7,32 @@ 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 { Switch } from '@/components/ui/switch';
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 { apiFetch } from '@/lib/api/client';
import { primaryTimezoneFor } from '@/lib/i18n/timezones';
import type { CountryCode } from '@/lib/i18n/countries';
interface NotificationPrefs {
reminder_due: boolean;
reminder_overdue: boolean;
eoi_signed: boolean;
eoi_completed: boolean;
invoice_overdue: boolean;
duplicate_alert: boolean;
[key: string]: boolean;
}
interface MeResponse {
user?: { name: string; email: string };
preferences?: { country?: string; timezone?: string };
profile?: { avatarFileId?: string | null };
profile?: {
avatarFileId?: string | null;
firstName?: string | null;
lastName?: string | null;
displayName?: string | null;
};
}
export function UserSettings() {
const [notifPrefs, setNotifPrefs] = useState<NotificationPrefs | null>(null);
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
const [displayName, setDisplayName] = useState('');
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
@@ -66,12 +63,15 @@ export function UserSettings() {
useEffect(() => {
void loadProfile();
void loadNotificationPrefs();
}, []);
async function loadProfile() {
const res = await apiFetch<{ data: MeResponse }>('/api/v1/me', { method: 'GET' });
setDisplayName(res.data.user?.name ?? '');
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 ?? '');
setCountry(res.data.preferences?.country ?? null);
@@ -113,22 +113,6 @@ export function UserSettings() {
setAvatarUrl(`/api/v1/files/${json.data.avatarFileId}/preview?t=${Date.now()}`);
}
async function loadNotificationPrefs() {
try {
const res = await apiFetch<{ data: NotificationPrefs }>('/api/v1/notifications/preferences');
setNotifPrefs(res.data);
} catch {
setNotifPrefs({
reminder_due: true,
reminder_overdue: true,
eoi_signed: true,
eoi_completed: true,
invoice_overdue: true,
duplicate_alert: true,
});
}
}
function handleCountryChange(iso: string | null) {
setCountry(iso);
// Auto-default timezone when the rep picks a country and hasn't
@@ -146,6 +130,8 @@ export function UserSettings() {
await apiFetch('/api/v1/me', {
method: 'PATCH',
body: {
firstName: firstName.trim() || null,
lastName: lastName.trim() || null,
displayName: displayName || undefined,
phone: phone || null,
preferences: {
@@ -190,32 +176,10 @@ export function UserSettings() {
}
}
async function toggleNotifPref(key: string, value: boolean) {
setSaving(key);
try {
await apiFetch('/api/v1/notifications/preferences', {
method: 'PATCH',
body: { [key]: value },
});
setNotifPrefs((prev) => (prev ? { ...prev, [key]: value } : prev));
} finally {
setSaving(null);
}
}
function adoptDetectedTz() {
if (detectedTz) setTimezone(detectedTz);
}
const NOTIF_LABELS: Record<string, string> = {
reminder_due: 'Reminder due',
reminder_overdue: 'Reminder overdue',
eoi_signed: 'EOI signed by a party',
eoi_completed: 'EOI fully completed',
invoice_overdue: 'Invoice overdue',
duplicate_alert: 'Duplicate client detected',
};
const tzMismatch = detectedTz && timezone && detectedTz !== timezone;
return (
@@ -263,14 +227,41 @@ export function UserSettings() {
</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</Label>
<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="Your name"
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>
@@ -377,25 +368,16 @@ export function UserSettings() {
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Notifications</CardTitle>
<CardDescription>Choose which notifications you receive</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{notifPrefs &&
Object.entries(NOTIF_LABELS).map(([key, label]) => (
<div key={key} className="flex items-center justify-between">
<Label>{label}</Label>
<Switch
checked={notifPrefs[key] ?? true}
disabled={saving === key}
onCheckedChange={(checked) => toggleNotifPref(key, checked)}
/>
</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