/* global React, Icon, Avatar, ChannelIcon, fmtAED */ const { useState, useEffect } = React; const STAGES_BY_PIPELINE = { outbound: ['New', 'Request Sent', 'Connected', 'Messaged', 'Replied', 'Interested', 'Discovery Call', 'Strategy Session', 'Follow Up', 'Deposit Taken', 'Deal Closed', 'Lost', 'Nurture'], inbound: ['New Lead', 'Qualified', 'Contacted', 'Replied', 'Discovery Call', 'Strategy Session', 'Follow Up', 'Deposit Taken', 'Deal Closed', 'Lost', 'Nurture'], }; const INBOUND_SOURCES = new Set(['meta-ads-fb', 'meta-ads-ig', 'referral', 'funnel-form', 'imported-list', 'inbound-other']); function pipelineFor(source) { return INBOUND_SOURCES.has(source) ? 'inbound' : 'outbound'; } function _initialsC(name) { if (!name) return '?'; const parts = String(name).replace(/[._@-]/g, ' ').split(/\s+/).filter(Boolean); if (!parts.length) return name.slice(0, 2).toUpperCase(); if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); } function _colorC(s) { const palette = ['#22c55e', '#3b82f6', '#a855f7', '#f97316', '#eab308', '#ec4899', '#06b6d4', '#84cc16']; let h = 0; for (let i = 0; i < (s || '').length; i++) h = ((h << 5) - h) + s.charCodeAt(i); return palette[Math.abs(h) % palette.length]; } function _relC(iso) { if (!iso) return '—'; const d = new Date(iso); const diff = (Date.now() - d.getTime()) / 1000; if (diff < 60) return 'now'; if (diff < 3600) return `${Math.round(diff / 60)}m`; if (diff < 86400) return `${Math.round(diff / 3600)}h`; if (diff < 86400 * 7) return `${Math.round(diff / 86400)}d`; return d.toLocaleDateString(); } function Contact({ contactId, goto }) { const [contact, setContact] = useState(null); const [tab, setTab] = useState('timeline'); const [deals, setDeals] = useState([]); const [tasks, setTasks] = useState([]); const [notes, setNotes] = useState([]); const [timeline, setTimeline] = useState([]); const [error, setError] = useState(null); const reloadAll = () => { if (!contactId) return; fetch(`/api/contacts/${contactId}`, { credentials: 'include' }) .then((r) => r.ok ? r.json() : Promise.reject(r.statusText)) .then(setContact) .catch((e) => setError(String(e))); fetch(`/api/deals?contact_id=${contactId}`, { credentials: 'include' }).then((r) => r.json()).then((d) => setDeals(d.items || [])); fetch(`/api/tasks?contact_id=${contactId}&only_open=false`, { credentials: 'include' }).then((r) => r.json()).then((d) => setTasks(d.items || [])); fetch(`/api/notes?contact_id=${contactId}`, { credentials: 'include' }).then((r) => r.json()).then((d) => setNotes(d.items || [])); fetch(`/api/lead-events/contact/${contactId}/timeline`, { credentials: 'include' }).then((r) => r.json()).then((d) => setTimeline(d.items || [])); }; useEffect(reloadAll, [contactId]); const onStageChange = async (newStage) => { if (!contact) return; setContact({ ...contact, pipeline_stage: newStage }); await fetch(`/api/pipeline/contacts/${contact.id}/stage`, { method: 'POST', credentials: 'include', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ stage: newStage }), }); reloadAll(); }; if (error) { return
Failed to load: {error}
; } if (!contact) { return
Loading…
; } const pipeline = pipelineFor(contact.source); const stages = STAGES_BY_PIPELINE[pipeline]; const channelKinds = [ contact.linkedin_url ? { kind: 'linkedin', value: contact.linkedin_url } : null, contact.whatsapp_number ? { kind: 'whatsapp', value: contact.whatsapp_number } : null, contact.instagram_handle ? { kind: 'instagram', value: '@' + contact.instagram_handle } : null, contact.email ? { kind: 'email', value: contact.email } : null, ].filter(Boolean); return (
{/* Left rail */}
{contact.name || '(unnamed)'}
{contact.title || '—'}
{contact.company || '—'}
{contact.temperature}
{channelKinds.length > 0 && (
Channels
{channelKinds.map((ch) => (
{ch.value}
))}
)}
Source
{contact.source || '—'} {contact.utm && Object.keys(contact.utm).length > 0 && (
{Object.entries(contact.utm).filter(([, v]) => v).map(([k, v]) =>
{k}={v}
)}
)}
Pipeline Stage ({pipeline})
{/* Middle */}
{[ { id: 'timeline', label: 'Timeline' }, { id: 'tasks', label: 'Tasks', count: tasks.filter((t) => !t.done).length || null }, { id: 'notes', label: 'Notes', count: notes.length || null }, ].map((t) => (
setTab(t.id)}> {t.label} {t.count != null && {t.count}}
))}
{tab === 'timeline' && } {tab === 'tasks' && } {tab === 'notes' && }
{/* Right rail: deals */}
Deals
{deals.length === 0 && (
No deals yet.
)} {deals.map((d) => (
{d.name}
AED {fmtAED(d.value_aed)}
{d.type} · {d.probability}% · {d.stage}
))}
); } function Timeline({ items }) { if (!items.length) { return
No activity yet.
; } return (
{items.map((a) => (
{a.summary}
{a.subkind} · {_relC(a.at)}
))}
); } function Tasks({ tasks, contactId, reload }) { const [title, setTitle] = useState(''); const add = async () => { if (!title.trim()) return; await fetch('/api/tasks', { method: 'POST', credentials: 'include', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ contact_id: contactId, title }), }); setTitle(''); reload(); }; const toggle = async (t) => { await fetch(`/api/tasks/${t.id}`, { method: 'PATCH', credentials: 'include', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ done: !t.done }), }); reload(); }; return (
setTitle(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && add()} style={{ flex: 1 }} />
{tasks.length === 0 &&
No tasks yet.
} {tasks.map((t) => (
toggle(t)} /> {t.title} {_relC(t.created_at)}
))}
); } function Notes({ notes, contactId, reload }) { const [body, setBody] = useState(''); const add = async () => { if (!body.trim()) return; await fetch('/api/notes', { method: 'POST', credentials: 'include', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ contact_id: contactId, body }), }); setBody(''); reload(); }; return (