When a user clicks an inline-edit affordance for country / timezone / subdivision, the field flipped to its combobox trigger but the popover didn't open — they had to click again. And if they dismissed the popover without picking, the field stayed in edit mode showing a "Select country…" trigger they couldn't get out of. Combobox primitives (country / timezone / subdivision) now accept: - defaultOpen — open on first render - onOpenChange — fired on every open/close transition InlineCountryField / InlineTimezoneField / and the country + subdivision fields inside addresses-editor pass defaultOpen=true and use onOpenChange to auto-exit edit mode when the popover closes without a selection. A pickedRef gate prevents the close-handler from racing the commit() exit when the user does pick a value. Bonus: addresses-editor now renders a flag emoji next to the country name in the read-only state (regional-indicator pair from the ISO code). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
442 lines
13 KiB
TypeScript
442 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import { useRef, useState } from 'react';
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
import { Loader2, MapPin, Plus, Star, Trash2 } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { CountryCombobox } from '@/components/shared/country-combobox';
|
|
import { SubdivisionCombobox } from '@/components/shared/subdivision-combobox';
|
|
import { InlineEditableField } from '@/components/shared/inline-editable-field';
|
|
import { apiFetch } from '@/lib/api/client';
|
|
import type { CountryCode } from '@/lib/i18n/countries';
|
|
import { getCountryName } from '@/lib/i18n/countries';
|
|
import { getSubdivisionName } from '@/lib/i18n/subdivisions';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
export interface Address {
|
|
id: string;
|
|
label: string;
|
|
streetAddress: string | null;
|
|
city: string | null;
|
|
subdivisionIso: string | null;
|
|
postalCode: string | null;
|
|
countryIso: string | null;
|
|
isPrimary: boolean;
|
|
}
|
|
|
|
type AddressPatch = Partial<Omit<Address, 'id'>>;
|
|
|
|
interface AddressesEditorProps {
|
|
/** Base API endpoint, e.g. `/api/v1/clients/abc/addresses` */
|
|
endpoint: string;
|
|
/** React-Query invalidation key for the parent entity. */
|
|
invalidateKey: readonly unknown[];
|
|
addresses: Address[];
|
|
}
|
|
|
|
export function AddressesEditor({ endpoint, invalidateKey, addresses }: AddressesEditorProps) {
|
|
const qc = useQueryClient();
|
|
const [adding, setAdding] = useState(false);
|
|
|
|
function invalidate() {
|
|
qc.invalidateQueries({ queryKey: invalidateKey });
|
|
}
|
|
|
|
const updateMutation = useMutation({
|
|
mutationFn: async ({ id, patch }: { id: string; patch: AddressPatch }) =>
|
|
apiFetch(`${endpoint}/${id}`, { method: 'PATCH', body: patch }),
|
|
onSuccess: invalidate,
|
|
});
|
|
|
|
const addMutation = useMutation({
|
|
mutationFn: async (data: AddressPatch) => apiFetch(endpoint, { method: 'POST', body: data }),
|
|
onSuccess: invalidate,
|
|
});
|
|
|
|
const removeMutation = useMutation({
|
|
mutationFn: async (id: string) => apiFetch(`${endpoint}/${id}`, { method: 'DELETE' }),
|
|
onSuccess: invalidate,
|
|
});
|
|
|
|
return (
|
|
<div className="space-y-2">
|
|
{addresses.length === 0 && !adding && (
|
|
<p className="text-sm text-muted-foreground">No addresses yet</p>
|
|
)}
|
|
|
|
{addresses.map((a) => (
|
|
<AddressCard
|
|
key={a.id}
|
|
address={a}
|
|
onUpdate={(patch) => updateMutation.mutateAsync({ id: a.id, patch })}
|
|
onRemove={async () => {
|
|
if (!confirm('Remove this address?')) return;
|
|
await removeMutation.mutateAsync(a.id);
|
|
}}
|
|
/>
|
|
))}
|
|
|
|
{adding ? (
|
|
<NewAddressForm
|
|
isFirst={addresses.length === 0}
|
|
onCancel={() => setAdding(false)}
|
|
onSave={async (data) => {
|
|
await addMutation.mutateAsync(data);
|
|
setAdding(false);
|
|
}}
|
|
/>
|
|
) : (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setAdding(true)}
|
|
className="w-full justify-center"
|
|
data-testid="add-address-button"
|
|
>
|
|
<Plus className="h-3.5 w-3.5 mr-1.5" />
|
|
Add address
|
|
</Button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AddressCard({
|
|
address,
|
|
onUpdate,
|
|
onRemove,
|
|
}: {
|
|
address: Address;
|
|
onUpdate: (patch: AddressPatch) => Promise<unknown>;
|
|
onRemove: () => void;
|
|
}) {
|
|
async function togglePrimary() {
|
|
if (address.isPrimary) return; // already primary; demoting via toggle would orphan all
|
|
try {
|
|
await onUpdate({ isPrimary: true });
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to update');
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="group rounded-lg border bg-muted/30 p-3 text-sm space-y-2">
|
|
<div className="flex items-center gap-2">
|
|
<MapPin className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
|
<div className="flex-1 min-w-0">
|
|
<InlineEditableField
|
|
value={address.label}
|
|
placeholder="Label (e.g. Home, Office)"
|
|
onSave={async (v) => {
|
|
if (!v) {
|
|
toast.error('Label is required');
|
|
return;
|
|
}
|
|
await onUpdate({ label: v });
|
|
}}
|
|
/>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={togglePrimary}
|
|
title={address.isPrimary ? 'Primary address' : 'Make primary'}
|
|
className={cn(
|
|
'p-1 rounded hover:bg-background/60 transition-colors',
|
|
address.isPrimary ? 'text-primary' : 'text-muted-foreground/50',
|
|
)}
|
|
data-testid="address-primary-toggle"
|
|
>
|
|
<Star className={cn('h-3.5 w-3.5', address.isPrimary && 'fill-current')} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onRemove}
|
|
title="Remove"
|
|
className="p-1 rounded text-muted-foreground/50 hover:text-destructive hover:bg-background/60 opacity-0 group-hover:opacity-100 transition-all"
|
|
data-testid="address-remove-button"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-1 pl-5">
|
|
<Field label="Street">
|
|
<InlineEditableField
|
|
value={address.streetAddress}
|
|
placeholder="123 Main St"
|
|
onSave={async (v) => {
|
|
await onUpdate({ streetAddress: v });
|
|
}}
|
|
/>
|
|
</Field>
|
|
<Field label="City">
|
|
<InlineEditableField
|
|
value={address.city}
|
|
placeholder="City"
|
|
onSave={async (v) => {
|
|
await onUpdate({ city: v });
|
|
}}
|
|
/>
|
|
</Field>
|
|
<Field label="Country">
|
|
<CountryFieldInline
|
|
value={address.countryIso}
|
|
onSave={async (iso) => {
|
|
// Clear subdivision if country changes — codes are scoped per country.
|
|
const patch: AddressPatch = { countryIso: iso };
|
|
if (iso !== address.countryIso) patch.subdivisionIso = null;
|
|
await onUpdate(patch);
|
|
}}
|
|
/>
|
|
</Field>
|
|
<Field label="Region">
|
|
<SubdivisionFieldInline
|
|
value={address.subdivisionIso}
|
|
country={(address.countryIso as CountryCode | null) ?? null}
|
|
onSave={async (code) => {
|
|
await onUpdate({ subdivisionIso: code });
|
|
}}
|
|
/>
|
|
</Field>
|
|
<Field label="Postal Code">
|
|
<InlineEditableField
|
|
value={address.postalCode}
|
|
placeholder="ZIP / Postal"
|
|
onSave={async (v) => {
|
|
await onUpdate({ postalCode: v });
|
|
}}
|
|
/>
|
|
</Field>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex items-baseline gap-2">
|
|
<span className="text-xs text-muted-foreground w-20 shrink-0">{label}</span>
|
|
<span className="flex-1 min-w-0">{children}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Regional-indicator emoji flag for an ISO alpha-2 code (e.g. 'FR' → 🇫🇷). */
|
|
function flagEmoji(code: string | null | undefined): string {
|
|
if (!code || code.length !== 2) return '';
|
|
const A = 0x1f1e6;
|
|
const a = 'A'.charCodeAt(0);
|
|
return String.fromCodePoint(A + code.charCodeAt(0) - a, A + code.charCodeAt(1) - a);
|
|
}
|
|
|
|
function CountryFieldInline({
|
|
value,
|
|
onSave,
|
|
}: {
|
|
value: string | null;
|
|
onSave: (iso: string | null) => Promise<void>;
|
|
}) {
|
|
const [editing, setEditing] = useState(false);
|
|
// Tracks whether a value was picked this edit cycle so the open-change
|
|
// handler doesn't double-exit while commit is still in flight.
|
|
const pickedRef = useRef(false);
|
|
|
|
if (editing) {
|
|
return (
|
|
<CountryCombobox
|
|
value={value ?? null}
|
|
onChange={async (iso) => {
|
|
pickedRef.current = true;
|
|
setEditing(false);
|
|
await onSave(iso ?? null);
|
|
}}
|
|
clearable
|
|
className="w-full"
|
|
// Drop the user straight into the picker — no extra click on the
|
|
// trigger required.
|
|
defaultOpen
|
|
onOpenChange={(open) => {
|
|
// Auto-exit edit mode when the popover closes without a pick so
|
|
// the user isn't stuck staring at a "Select country…" trigger.
|
|
if (!open && !pickedRef.current) setEditing(false);
|
|
if (open) pickedRef.current = false;
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
const display = value ? `${flagEmoji(value)} ${getCountryName(value, 'en')}` : null;
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditing(true)}
|
|
className="text-left w-full hover:bg-background/60 rounded px-1 py-0.5 -mx-1 -my-0.5 truncate"
|
|
>
|
|
{display ?? <span className="text-muted-foreground italic">Not set</span>}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function SubdivisionFieldInline({
|
|
value,
|
|
country,
|
|
onSave,
|
|
}: {
|
|
value: string | null;
|
|
country: CountryCode | null;
|
|
onSave: (code: string | null) => Promise<void>;
|
|
}) {
|
|
const [editing, setEditing] = useState(false);
|
|
const pickedRef = useRef(false);
|
|
|
|
if (editing) {
|
|
return (
|
|
<SubdivisionCombobox
|
|
value={value ?? null}
|
|
country={country}
|
|
onChange={async (code) => {
|
|
pickedRef.current = true;
|
|
setEditing(false);
|
|
await onSave(code ?? null);
|
|
}}
|
|
clearable
|
|
className="w-full"
|
|
defaultOpen
|
|
onOpenChange={(open) => {
|
|
if (!open && !pickedRef.current) setEditing(false);
|
|
if (open) pickedRef.current = false;
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
if (!country) {
|
|
return <span className="text-muted-foreground italic text-xs">Pick country first</span>;
|
|
}
|
|
const display = value ? getSubdivisionName(value) : null;
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditing(true)}
|
|
className="text-left w-full hover:bg-background/60 rounded px-1 py-0.5 -mx-1 -my-0.5 truncate"
|
|
>
|
|
{display ?? <span className="text-muted-foreground italic">Not set</span>}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function NewAddressForm({
|
|
onSave,
|
|
onCancel,
|
|
isFirst,
|
|
}: {
|
|
onSave: (data: AddressPatch) => Promise<void>;
|
|
onCancel: () => void;
|
|
isFirst: boolean;
|
|
}) {
|
|
const [label, setLabel] = useState('Primary');
|
|
const [streetAddress, setStreet] = useState('');
|
|
const [city, setCity] = useState('');
|
|
const [countryIso, setCountryIso] = useState<string | null>(null);
|
|
const [subdivisionIso, setSubdivisionIso] = useState<string | null>(null);
|
|
const [postalCode, setPostal] = useState('');
|
|
const [makePrimary, setMakePrimary] = useState(isFirst);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
async function submit() {
|
|
if (!label.trim()) {
|
|
toast.error('Label is required');
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
await onSave({
|
|
label: label.trim(),
|
|
streetAddress: streetAddress.trim() || null,
|
|
city: city.trim() || null,
|
|
countryIso,
|
|
subdivisionIso,
|
|
postalCode: postalCode.trim() || null,
|
|
isPrimary: makePrimary,
|
|
});
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to add address');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="rounded-lg border bg-muted/30 p-3 text-sm space-y-2">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
|
|
<Input
|
|
value={label}
|
|
onChange={(e) => setLabel(e.target.value)}
|
|
placeholder="Label (Home, Office)"
|
|
className="h-8"
|
|
autoFocus
|
|
disabled={saving}
|
|
/>
|
|
<Input
|
|
value={streetAddress}
|
|
onChange={(e) => setStreet(e.target.value)}
|
|
placeholder="Street address"
|
|
className="h-8"
|
|
disabled={saving}
|
|
/>
|
|
<Input
|
|
value={city}
|
|
onChange={(e) => setCity(e.target.value)}
|
|
placeholder="City"
|
|
className="h-8"
|
|
disabled={saving}
|
|
/>
|
|
<CountryCombobox
|
|
value={countryIso}
|
|
onChange={(iso) => {
|
|
setCountryIso(iso ?? null);
|
|
setSubdivisionIso(null);
|
|
}}
|
|
clearable
|
|
placeholder="Country"
|
|
/>
|
|
<SubdivisionCombobox
|
|
value={subdivisionIso}
|
|
country={(countryIso as CountryCode | null) ?? null}
|
|
onChange={(code) => setSubdivisionIso(code ?? null)}
|
|
clearable
|
|
placeholder="Region (optional)"
|
|
/>
|
|
<Input
|
|
value={postalCode}
|
|
onChange={(e) => setPostal(e.target.value)}
|
|
placeholder="Postal code"
|
|
className="h-8"
|
|
disabled={saving}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<label className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
<input
|
|
type="checkbox"
|
|
checked={makePrimary}
|
|
onChange={(e) => setMakePrimary(e.target.checked)}
|
|
disabled={saving}
|
|
/>
|
|
Set as primary address
|
|
</label>
|
|
<div className="flex gap-2">
|
|
<Button type="button" size="sm" variant="ghost" onClick={onCancel} disabled={saving}>
|
|
Cancel
|
|
</Button>
|
|
<Button type="button" size="sm" onClick={submit} disabled={saving}>
|
|
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : 'Save'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|