diff --git a/src/app/(dashboard)/[portSlug]/notifications/preferences/page.tsx b/src/app/(dashboard)/[portSlug]/notifications/preferences/page.tsx
index df1fd132..bc6d1c2d 100644
--- a/src/app/(dashboard)/[portSlug]/notifications/preferences/page.tsx
+++ b/src/app/(dashboard)/[portSlug]/notifications/preferences/page.tsx
@@ -1,17 +1,15 @@
-import { NotificationPreferencesForm } from '@/components/notifications/notification-preferences-form';
-import { ReminderDigestForm } from '@/components/notifications/reminder-digest-form';
+import { redirect } from 'next/navigation';
-export default function NotificationPreferencesPage() {
- return (
-
-
-
Notification Preferences
-
- Choose which notifications you receive and how.
-
-
-
-
-
- );
+interface PageProps {
+ params: Promise<{ portSlug: string }>;
+}
+
+/**
+ * Legacy route. Notification preferences now live on the user-settings
+ * page alongside every other personal preference. Kept as a redirect so
+ * older bookmarks / email links still land somewhere useful.
+ */
+export default async function NotificationPreferencesRedirect({ params }: PageProps) {
+ const { portSlug } = await params;
+ redirect(`/${portSlug}/settings#notifications`);
}
diff --git a/src/app/api/v1/me/route.ts b/src/app/api/v1/me/route.ts
index 65cf246c..35aaf7e0 100644
--- a/src/app/api/v1/me/route.ts
+++ b/src/app/api/v1/me/route.ts
@@ -9,6 +9,8 @@ import { errorResponse, NotFoundError, ValidationError } from '@/lib/errors';
import { z } from 'zod';
const updateProfileSchema = z.object({
+ firstName: z.string().min(1).max(120).nullable().optional(),
+ lastName: z.string().min(1).max(120).nullable().optional(),
displayName: z.string().min(1).max(200).optional(),
phone: z.string().nullable().optional(),
// Refuse `javascript:` / `data:` schemes — z.string().url() lets them
@@ -55,7 +57,14 @@ export const GET = withAuth(async (_req, ctx: AuthContext) => {
// round-trip on app boot.
const profile = await db.query.userProfiles.findFirst({
where: eq(userProfiles.userId, ctx.userId),
- columns: { preferences: true, avatarFileId: true, avatarUrl: true },
+ columns: {
+ preferences: true,
+ avatarFileId: true,
+ avatarUrl: true,
+ firstName: true,
+ lastName: true,
+ displayName: true,
+ },
});
return NextResponse.json({
@@ -70,6 +79,9 @@ export const GET = withAuth(async (_req, ctx: AuthContext) => {
profile: {
avatarFileId: profile?.avatarFileId ?? null,
avatarUrl: profile?.avatarUrl ?? null,
+ firstName: profile?.firstName ?? null,
+ lastName: profile?.lastName ?? null,
+ displayName: profile?.displayName ?? null,
},
},
});
@@ -85,6 +97,8 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => {
if (!profile) throw new NotFoundError('profile');
const updates: Record = { updatedAt: new Date() };
+ if (body.firstName !== undefined) updates.firstName = body.firstName;
+ if (body.lastName !== undefined) updates.lastName = body.lastName;
if (body.displayName !== undefined) updates.displayName = body.displayName;
if (body.phone !== undefined) updates.phone = body.phone;
if (body.avatarUrl !== undefined) updates.avatarUrl = body.avatarUrl;
@@ -119,6 +133,8 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => {
return NextResponse.json({
data: {
userId: updated!.userId,
+ firstName: updated!.firstName,
+ lastName: updated!.lastName,
displayName: updated!.displayName,
phone: updated!.phone,
avatarUrl: updated!.avatarUrl,
diff --git a/src/components/layout/mobile/more-sheet.tsx b/src/components/layout/mobile/more-sheet.tsx
index a695ea41..23bd215c 100644
--- a/src/components/layout/mobile/more-sheet.tsx
+++ b/src/components/layout/mobile/more-sheet.tsx
@@ -50,9 +50,8 @@ const MORE_ITEMS: MoreItem[] = [
{ label: 'Expenses', icon: Receipt, segment: 'expenses' },
{ label: 'Reservations', icon: Anchor, segment: 'berth-reservations' },
// Notifications themselves live on the topbar bell — this entry deep-links
- // to the per-channel preferences page. Pointing at the bare `/notifications`
- // segment 404s today (no page.tsx, only `/preferences`).
- { label: 'Notification preferences', icon: BellRing, segment: 'notifications/preferences' },
+ // to the notification panel inside user-settings (collapsed in 2026-05-09).
+ { label: 'Notification preferences', icon: BellRing, segment: 'settings#notifications' },
{ label: 'Residential', icon: Home, segment: 'residential/clients' },
{ label: 'Website analytics', icon: Globe, segment: 'website-analytics' },
{ label: 'Alerts', icon: ShieldAlert, segment: 'alerts' },
diff --git a/src/components/layout/user-menu.tsx b/src/components/layout/user-menu.tsx
index b735c1ee..39130c9e 100644
--- a/src/components/layout/user-menu.tsx
+++ b/src/components/layout/user-menu.tsx
@@ -126,7 +126,7 @@ export function UserMenu({ trigger, align = 'end', user, ports }: UserMenuProps)
router.push(`${base}/notifications/preferences` as any)}
+ onClick={() => router.push(`${base}/settings#notifications` as any)}
>
Notification preferences
diff --git a/src/components/settings/user-settings.tsx b/src/components/settings/user-settings.tsx
index 3502bb8a..3273e410 100644
--- a/src/components/settings/user-settings.tsx
+++ b/src/components/settings/user-settings.tsx
@@ -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(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 = {
- 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() {
+
-
Display name
+
+ Display name (optional)
+
setDisplayName(e.target.value)}
- placeholder="Your name"
+ placeholder="How your name appears in the UI"
/>
+
+ Defaults to first + last when blank. Override with a nickname if you prefer.
+
Phone
@@ -377,25 +368,16 @@ export function UserSettings() {
-
-
- Notifications
- Choose which notifications you receive
-
-
- {notifPrefs &&
- Object.entries(NOTIF_LABELS).map(([key, label]) => (
-
- {label}
- toggleNotifPref(key, checked)}
- />
-
- ))}
-
-
+
+
+
Notifications
+
+ Choose which notifications you receive and how they're delivered.
+
+
+
+
+
crypto.randomUUID()),
userId: text('user_id').notNull().unique(), // references Better Auth user ID
+ /**
+ * Canonical first/last name pair. Added 2026-05-09 as the primary
+ * source for greetings, invoicing, and DocSign field-merging — the
+ * older `displayName` is now kept around as a derived/optional
+ * override (e.g. for nicknames or vanity formatting). When migrating
+ * production, backfill these columns from displayName by splitting
+ * on the first space and zero-pad the trailing column with NULL so
+ * single-token names don't fail the not-null assumption.
+ */
+ firstName: text('first_name'),
+ lastName: text('last_name'),
displayName: text('display_name').notNull(),
avatarUrl: text('avatar_url'),
/** FK into the polymorphic `files` table — the avatar is stored