Mobile + responsive - berth-form full-width on phones (was 480px fixed → overflowed iPhone) - currency-input switched to inputMode=decimal with live thousands separator - client-form Country/Timezone/Source/Preferred-Contact full-width <sm - contacts row restructured so Primary toggle + Remove get their own strip - customize-dashboard footer stacks vertically on mobile; Done full-width - interest-form client/berth pickers no longer cmdk-filter on UUID (typing "Carlos" now returns Carlos Vega instead of "No clients found") Data + consistency - SOURCES + SOURCE_LABELS + formatSource() in lib/constants; 9 surfaces now resolve interest/client source from one place - INTEREST_OUTCOMES adds lost_other (picker, badge, timeline) - Berth options natural-sort A1 → A2 → … → A10 via lib/utils/mooring-sort - archiver downgraded ^8 → ^7.0.1 so the GDPR export route compiles - TableBody last-row uses border-b-0 (not border-0); colored left-accent on the bottom berth row now renders - Hide Invite-to-Portal until port setting === true (was !== false default-show) - OwnerPicker primer query resolves entity name on first paint (no more UUID flash before the popover opens) Terminology - Replaced user-facing "Documenso" with "signing service" / "Generated EOI" / "Manual EOI" in 8 components (admin/internal references kept) - Plainer status-change copy on berth-detail-header Forms + editing - InlineEditableField gained a `date` variant (native picker); applied to company incorporation date and ready for other YYYY-MM-DD plaintext fields - Inline source picker on interest-tabs detail (was free text) - TagPicker self-hides when port has no tags AND nothing is selected - New ReminderDaysInput with preset chips (1d / 3d / 1wk / 2wk / 1mo / custom) - Compose dialog follow-up is now a toggle that reveals datetime picker Pipeline milestones - changeStageSchema accepts optional milestoneDate; service stamps it on the matching date column instead of always using now - MilestoneAdvanceButton popover collects a back-date before stage advance - Applied to every "Mark X manually" surface on the interest overview EOI / linked-berths polish - Add-bypass row aligned inline with toggle descriptions - Tooltips on "Specifically pitching" / "Mark in EOI bundle" explain their legal vs. public-map consequences Surfaces - Companies list now has the column picker + persisted hidden-column prefs - NotesList aggregate flag enabled on clients, companies, residential_clients (yachts already aggregated) ft/m unit toggle (interim, before drift fix) - "Berth size desired" gets a section-level ft/m toggle; per-field hint shows the converted value. Storage stays canonical-ft for now; the drift-safe persistence migration is the next step. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
398 lines
16 KiB
TypeScript
398 lines
16 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;
|
|
};
|
|
}
|
|
|
|
export function UserSettings() {
|
|
const [firstName, setFirstName] = useState('');
|
|
const [lastName, setLastName] = useState('');
|
|
const [displayName, setDisplayName] = useState('');
|
|
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(() => {
|
|
void loadProfile();
|
|
}, []);
|
|
|
|
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 ?? '');
|
|
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);
|
|
}
|
|
|
|
// 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 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-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>
|
|
);
|
|
}
|