'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>; 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 (
{addresses.length === 0 && !adding && (

No addresses yet

)} {addresses.map((a) => ( updateMutation.mutateAsync({ id: a.id, patch })} onRemove={async () => { if (!confirm('Remove this address?')) return; await removeMutation.mutateAsync(a.id); }} /> ))} {adding ? ( setAdding(false)} onSave={async (data) => { await addMutation.mutateAsync(data); setAdding(false); }} /> ) : ( )}
); } function AddressCard({ address, onUpdate, onRemove, }: { address: Address; onUpdate: (patch: AddressPatch) => Promise; 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 (
{ if (!v) { toast.error('Label is required'); return; } await onUpdate({ label: v }); }} />
{ await onUpdate({ streetAddress: v }); }} /> { await onUpdate({ city: v }); }} /> { // 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); }} /> { await onUpdate({ subdivisionIso: code }); }} /> { await onUpdate({ postalCode: v }); }} />
); } function Field({ label, children }: { label: string; children: React.ReactNode }) { return (
{label} {children}
); } /** 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; }) { 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 ( { 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 ( ); } function SubdivisionFieldInline({ value, country, onSave, }: { value: string | null; country: CountryCode | null; onSave: (code: string | null) => Promise; }) { const [editing, setEditing] = useState(false); const pickedRef = useRef(false); if (editing) { return ( { 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 Pick country first; } const display = value ? getSubdivisionName(value) : null; return ( ); } function NewAddressForm({ onSave, onCancel, isFirst, }: { onSave: (data: AddressPatch) => Promise; onCancel: () => void; isFirst: boolean; }) { const [label, setLabel] = useState('Primary'); const [streetAddress, setStreet] = useState(''); const [city, setCity] = useState(''); const [countryIso, setCountryIso] = useState(null); const [subdivisionIso, setSubdivisionIso] = useState(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 (
setLabel(e.target.value)} placeholder="Label (Home, Office)" className="h-8" autoFocus disabled={saving} /> setStreet(e.target.value)} placeholder="Street address" className="h-8" disabled={saving} /> setCity(e.target.value)} placeholder="City" className="h-8" disabled={saving} /> { setCountryIso(iso ?? null); setSubdivisionIso(null); }} clearable placeholder="Country" /> setSubdivisionIso(code ?? null)} clearable placeholder="Region (optional)" /> setPostal(e.target.value)} placeholder="Postal code" className="h-8" disabled={saving} />
); }