'use client'; import { useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ChevronDown, Loader2, Plus, Star, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { InlineEditableField } from '@/components/shared/inline-editable-field'; import { InlinePhoneField } from '@/components/shared/inline-phone-field'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { apiFetch } from '@/lib/api/client'; import { toastError } from '@/lib/api/toast-error'; import { cn } from '@/lib/utils'; import { parsePhone } from '@/lib/i18n/phone'; type Channel = 'email' | 'phone'; export interface ContactRow { id: string; channel: 'email' | 'phone' | 'whatsapp' | 'other'; value: string; valueE164: string | null; label: string | null; isPrimary: boolean; } interface Props { clientId: string; /** * Channel filter — picker shows only `email` (or `phone` + `whatsapp` for * phone-style channels). Edits / promotions stay scoped to the chosen * channel. */ channel: Channel; /** Server-resolved primary contact for this channel (drives the inline * value rendering when the picker isn't open). */ primaryContactId: string | null; primaryValue: string | null; /** Phone channel only — E.164 form + ISO-3166-1 alpha-2 country code so the * inline phone editor can preserve the national-format roundtrip. */ primaryValueE164?: string | null; primaryValueCountry?: string | null; /** Query keys to invalidate after any mutation succeeds — the parent * detail view is usually keyed on `['interest', interestId]` or * `['clients', clientId]` so the picker can't hard-code which to bump. */ invalidateKeys?: ReadonlyArray; } /** * Combobox-style editor for the Email + Phone rows on the Interest / * Client Overview. Renders the current primary value with an inline * edit-on-click; the chevron opens a popover listing every contact in * the channel so the rep can: * - promote a different contact to primary * - edit any contact's value inline * - add a new contact (defaults to non-primary; can be flagged primary * at create time) * - delete a non-primary contact * * Backed by: * GET /api/v1/clients/[id]/contacts * POST /api/v1/clients/[id]/contacts * PATCH /api/v1/clients/[id]/contacts/[contactId] * DELETE /api/v1/clients/[id]/contacts/[contactId] * POST /api/v1/clients/[id]/contacts/[contactId]/promote-to-primary * * Phone channel shows `whatsapp` rows alongside `phone` so the picker * works as the rep's "all the ways I can call this client" surface. */ export function ClientChannelEditor({ clientId, channel, primaryContactId, primaryValue, primaryValueE164, primaryValueCountry, invalidateKeys = [], }: Props) { const qc = useQueryClient(); const [open, setOpen] = useState(false); const acceptedChannels: ContactRow['channel'][] = channel === 'email' ? ['email'] : ['phone', 'whatsapp']; const { data, isLoading } = useQuery<{ data: ContactRow[] }>({ queryKey: ['client-contacts', clientId, channel], queryFn: () => apiFetch<{ data: ContactRow[] }>(`/api/v1/clients/${clientId}/contacts`), enabled: open, staleTime: 30_000, }); const contacts = (data?.data ?? []).filter((c) => acceptedChannels.includes(c.channel)); function invalidate() { void qc.invalidateQueries({ queryKey: ['client-contacts', clientId, channel] }); for (const key of invalidateKeys) { void qc.invalidateQueries({ queryKey: key as unknown[] }); } } const formatPhone = (v: string) => parsePhone(v).international ?? v; const displayValue = (row: ContactRow) => row.channel === 'phone' || row.channel === 'whatsapp' ? formatPhone(row.value) : row.value; return (
{primaryContactId ? ( channel === 'phone' ? ( // Phone-specific editor: country picker + national-format input so // the rep doesn't have to type the +44 / +1 prefix manually. The // service auto-derives `valueE164` + `valueCountry` from the // submitted shape, then the `value` (display) form is updated. { try { await apiFetch(`/api/v1/clients/${clientId}/contacts/${primaryContactId}`, { method: 'PATCH', body: { value: next.e164 ?? '', valueE164: next.e164 ?? null, valueCountry: next.country, }, }); invalidate(); } catch (err) { toastError(err); } }} /> ) : ( { try { await apiFetch(`/api/v1/clients/${clientId}/contacts/${primaryContactId}`, { method: 'PATCH', body: { value: next }, }); invalidate(); } catch (err) { toastError(err); } }} /> ) ) : ( - )}
{channel === 'email' ? 'Email contacts' : 'Phone & WhatsApp contacts'}
{isLoading ? (
Loading…
) : contacts.length === 0 ? (
No {channel} contacts yet.
) : (
    {contacts.map((c) => ( ))}
)}
); } function ContactRowItem({ clientId, row, displayValue, invalidate, }: { clientId: string; row: ContactRow; displayValue: string; invalidate: () => void; }) { const promote = useMutation({ mutationFn: async () => { await apiFetch(`/api/v1/clients/${clientId}/contacts/${row.id}/promote-to-primary`, { method: 'POST', }); }, onSuccess: () => { toast.success('Primary updated'); invalidate(); }, onError: (err) => toastError(err), }); const remove = useMutation({ mutationFn: async () => { await apiFetch(`/api/v1/clients/${clientId}/contacts/${row.id}`, { method: 'DELETE' }); }, onSuccess: () => { toast.success('Contact removed'); invalidate(); }, onError: (err) => toastError(err), }); return (
  • {row.isPrimary ? ( ) : ( )} {displayValue} {row.label ? ( {row.label} ) : null}
    {!row.isPrimary ? ( ) : null} {!row.isPrimary ? ( ) : null}
  • ); } function AddContactRow({ clientId, channel, existingCount, invalidate, }: { clientId: string; channel: Channel; existingCount: number; invalidate: () => void; }) { const [adding, setAdding] = useState(false); const [value, setValue] = useState(''); const [setPrimary, setSetPrimary] = useState(false); const create = useMutation({ mutationFn: async () => { const v = value.trim(); if (!v) throw new Error(`Enter a${channel === 'email' ? 'n email' : ' phone number'}.`); await apiFetch(`/api/v1/clients/${clientId}/contacts`, { method: 'POST', body: { channel, value: v, isPrimary: setPrimary || existingCount === 0 }, }); }, onSuccess: () => { toast.success(`${channel === 'email' ? 'Email' : 'Phone'} added`); setValue(''); setSetPrimary(false); setAdding(false); invalidate(); }, onError: (err) => toastError(err), }); if (!adding) { return ( ); } return (
    setValue(e.target.value)} placeholder={channel === 'email' ? 'name@example.com' : '+1 555 123 4567'} className="h-8 text-sm" />
    ); }