feat(forms): country→timezone autoset, "Other" channel hint, polish
Client form: when nationality is picked and timezone empty, primary IANA zone for the country is pre-filled (skips when user has chosen a zone explicitly). When a contact's preferred channel is `'other'`, the inline `Label` field flips to "Specify" / "e.g. Telegram, Signal" so the rep records what the channel actually is. Yacht form: replace the free-text 2-letter flag input with the shared `CountryCombobox` so flags stay valid ISO codes. User settings: timezone pre-populates from `Intl.DateTimeFormat().resolvedOptions().timeZone` on first load (was empty before); country change auto-fills timezone with the same helper as the client form. Phone field upgraded to the shared `<PhoneInput>` (country-flag dropdown + AsYouType formatter) seeded from the page's country state. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -27,6 +27,7 @@ import { DedupSuggestionPanel } from '@/components/clients/dedup-suggestion-pane
|
|||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
import { createClientSchema, type CreateClientInput } from '@/lib/validators/clients';
|
import { createClientSchema, type CreateClientInput } from '@/lib/validators/clients';
|
||||||
import type { CountryCode } from '@/lib/i18n/countries';
|
import type { CountryCode } from '@/lib/i18n/countries';
|
||||||
|
import { primaryTimezoneFor } from '@/lib/i18n/timezones';
|
||||||
|
|
||||||
interface ClientFormProps {
|
interface ClientFormProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -84,6 +85,17 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
|
|||||||
const { fields, append, remove } = useFieldArray({ control, name: 'contacts' });
|
const { fields, append, remove } = useFieldArray({ control, name: 'contacts' });
|
||||||
const tagIds = watch('tagIds') ?? [];
|
const tagIds = watch('tagIds') ?? [];
|
||||||
|
|
||||||
|
// When the rep picks a country and no timezone is set yet, pre-fill the
|
||||||
|
// timezone with the country's primary IANA zone. Skips when the user has
|
||||||
|
// already chosen a zone explicitly so we never clobber a deliberate pick.
|
||||||
|
const watchedNationality = watch('nationalityIso');
|
||||||
|
const watchedTimezone = watch('timezone');
|
||||||
|
useEffect(() => {
|
||||||
|
if (!watchedNationality || watchedTimezone) return;
|
||||||
|
const primary = primaryTimezoneFor(watchedNationality as CountryCode);
|
||||||
|
if (primary) setValue('timezone', primary);
|
||||||
|
}, [watchedNationality, watchedTimezone, setValue]);
|
||||||
|
|
||||||
// Populate form when editing
|
// Populate form when editing
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (client && open) {
|
if (client && open) {
|
||||||
@@ -287,11 +299,17 @@ export function ClientForm({ open, onOpenChange, client, onUseExistingClient }:
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="col-span-2 space-y-1">
|
<div className="col-span-2 space-y-1">
|
||||||
<Label className="text-xs">Label</Label>
|
<Label className="text-xs">
|
||||||
|
{watch(`contacts.${index}.channel`) === 'other' ? 'Specify' : 'Label'}
|
||||||
|
</Label>
|
||||||
<Input
|
<Input
|
||||||
{...register(`contacts.${index}.label`)}
|
{...register(`contacts.${index}.label`)}
|
||||||
className="h-8"
|
className="h-8"
|
||||||
placeholder="work"
|
placeholder={
|
||||||
|
watch(`contacts.${index}.channel`) === 'other'
|
||||||
|
? 'e.g. Telegram, Signal'
|
||||||
|
: 'work'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Switch } from '@/components/ui/switch';
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { PageHeader } from '@/components/shared/page-header';
|
import { PageHeader } from '@/components/shared/page-header';
|
||||||
import { CountryCombobox } from '@/components/shared/country-combobox';
|
import { CountryCombobox } from '@/components/shared/country-combobox';
|
||||||
|
import { PhoneInput, type PhoneInputValue } from '@/components/shared/phone-input';
|
||||||
import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
|
import { TimezoneCombobox } from '@/components/shared/timezone-combobox';
|
||||||
import { ImageCropperDialog } from '@/components/shared/image-cropper-dialog';
|
import { ImageCropperDialog } from '@/components/shared/image-cropper-dialog';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
@@ -74,12 +75,24 @@ export function UserSettings() {
|
|||||||
setEmail(res.data.user?.email ?? '');
|
setEmail(res.data.user?.email ?? '');
|
||||||
setOriginalEmail(res.data.user?.email ?? '');
|
setOriginalEmail(res.data.user?.email ?? '');
|
||||||
setCountry(res.data.preferences?.country ?? null);
|
setCountry(res.data.preferences?.country ?? null);
|
||||||
setTimezone(res.data.preferences?.timezone ?? 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;
|
const fid = res.data.profile?.avatarFileId ?? null;
|
||||||
setAvatarFileId(fid);
|
setAvatarFileId(fid);
|
||||||
setAvatarUrl(fid ? `/api/v1/files/${fid}/preview` : null);
|
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>) {
|
function handleAvatarPicked(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = e.target.files?.[0] ?? null;
|
const file = e.target.files?.[0] ?? null;
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
@@ -261,12 +274,11 @@ export function UserSettings() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="settings-phone">Phone</Label>
|
<Label htmlFor="settings-phone">Phone</Label>
|
||||||
<Input
|
<PhoneInput
|
||||||
id="settings-phone"
|
id="settings-phone"
|
||||||
type="tel"
|
value={phone ? ({ e164: phone, country: (country as never) ?? 'US' } as PhoneInputValue) : null}
|
||||||
value={phone}
|
onChange={(next) => setPhone(next.e164 ?? '')}
|
||||||
onChange={(e) => setPhone(e.target.value)}
|
placeholder="555 0123"
|
||||||
placeholder="+1 555-0123"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetFooter } from '@/components/ui/sheet';
|
||||||
import { Separator } from '@/components/ui/separator';
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { CountryCombobox } from '@/components/shared/country-combobox';
|
||||||
import { OwnerPicker, type OwnerRef } from '@/components/shared/owner-picker';
|
import { OwnerPicker, type OwnerRef } from '@/components/shared/owner-picker';
|
||||||
import { TagPicker } from '@/components/shared/tag-picker';
|
import { TagPicker } from '@/components/shared/tag-picker';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
@@ -185,7 +186,12 @@ export function YachtForm({ open, onOpenChange, yacht, initialOwner }: YachtForm
|
|||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Flag</Label>
|
<Label>Flag</Label>
|
||||||
<Input {...register('flag')} placeholder="e.g. MT" />
|
<CountryCombobox
|
||||||
|
value={watch('flag') ?? null}
|
||||||
|
onChange={(iso) => setValue('flag', iso ?? undefined)}
|
||||||
|
placeholder="Select flag country…"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|||||||
Reference in New Issue
Block a user