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() {

+
+
+ + 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="Your name" + placeholder="How your name appears in the UI" /> +

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

@@ -377,25 +368,16 @@ export function UserSettings() { - - - Notifications - Choose which notifications you receive - - - {notifPrefs && - Object.entries(NOTIF_LABELS).map(([key, 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