/* 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 (
{notes.length === 0 &&
No notes yet.
}
{notes.map((n) => (
{n.body}
{_relC(n.created_at)}
))}
);
}
function NewDealButton({ contactId, pipeline, reload }) {
const [open, setOpen] = useState(false);
const [name, setName] = useState('');
const [value, setValue] = useState('');
const [type, setType] = useState('one-off');
const submit = async () => {
if (!name.trim()) return;
await fetch('/api/deals', {
method: 'POST',
credentials: 'include',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
contact_id: contactId,
name,
value_aed: parseFloat(value || '0'),
type,
pipeline,
}),
});
setName(''); setValue(''); setType('one-off');
setOpen(false);
reload();
};
if (!open) {
return ;
}
return (
);
}
/* ============================================================
ACCOUNT (placeholder — full wiring in Phase 2)
============================================================ */
function Account({ accountId, goto }) {
return (
Account detail page wires up in Phase 2.
id: {accountId}
);
}
Object.assign(window, { Contact, Account });