← back to Marketing Command Center
public/panels/profiles.js
297 lines
// Contact Profiles panel — left: searchable contact list; right: a 360° card
// for the selected contact (lifecycle, lists, tags, segment memberships, email
// engagement timeline, cross-channel placeholders). Read-only.
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['profiles'] = {
init(root) {
const $ = s => root.querySelector(s);
const api = (p) => fetch(`/api/profiles${p}`).then(r => r.json());
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const fmtDate = d => d ? new Date(d).toLocaleDateString(undefined,
{ year: 'numeric', month: 'short', day: 'numeric' }) : '—';
const fmtDateTime = d => d ? new Date(d).toLocaleString(undefined,
{ month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '—';
const fmtRelative = d => {
if (!d) return 'never';
const days = Math.round((Date.now() - Date.parse(d)) / 86400000);
if (days <= 0) return 'today';
if (days === 1) return 'yesterday';
if (days < 30) return `${days}d ago`;
if (days < 365) return `${Math.round(days / 30)}mo ago`;
return `${Math.round(days / 365)}y ago`;
};
let anyMock = false;
let selectedEmail = null;
let listCache = [];
let listIsSearch = false;
function setMock(m) {
if (m) {
anyMock = true;
$('#prof-banner').innerHTML =
`<div class="muted-banner">Running on a <b>mock contact pool</b> (~240 luxury-design contacts).
Paste your <code>CTCT_ACCESS_TOKEN</code> in <code>.env</code> to power profiles from your live Constant Contact roster.</div>`;
}
}
// ── list rendering ────────────────────────────────────────────────────────
function renderList(payload) {
const el = $('#prof-list');
listCache = payload.contacts || [];
const meta = $('#prof-list-meta');
if (payload.query) {
meta.textContent = `${Number(payload.count).toLocaleString()} match${payload.count === 1 ? '' : 'es'} for “${payload.query}” · ${Number(payload.total).toLocaleString()} contacts`;
} else {
meta.textContent = `${Number(payload.total).toLocaleString()} contacts · sorted by most recent open`;
}
if (!listCache.length) {
el.classList.remove('loading');
el.innerHTML = `<div class="muted" style="padding:18px 0">No contacts match.</div>`;
return;
}
el.classList.remove('loading');
el.innerHTML = listCache.map(c => {
const isSel = selectedEmail && c.email.toLowerCase() === selectedEmail.toLowerCase();
return `<div class="prof-row" data-email="${esc(c.email)}"
style="border-top:1px solid var(--line);padding:10px 0;cursor:pointer;${isSel ? 'background:#f7f2e8' : ''}">
<div class="row" style="justify-content:space-between;align-items:baseline;gap:8px">
<div style="font-weight:600">${esc(c.name || c.email)}</div>
<span class="pill">${esc(c.type)}</span>
</div>
<div class="muted" style="font-size:12px">${esc(c.email)}</div>
<div class="muted" style="font-size:11.5px;margin-top:3px">
${esc(c.company || '')}${c.role ? ` · ${esc(c.role)}` : ''}
</div>
<div class="row" style="gap:6px;margin-top:5px;align-items:center">
<span class="pill" style="background:#eee5d4">${esc(c.lifecycle)}</span>
<span class="muted" style="font-size:11px">last open ${fmtRelative(c.lastOpen)}</span>
</div>
</div>`;
}).join('');
el.querySelectorAll('.prof-row').forEach(row => {
row.onclick = () => loadDetail(row.dataset.email);
});
}
function loadList() {
$('#prof-list').classList.add('loading');
$('#prof-list').textContent = 'Loading contacts…';
listIsSearch = false;
return api('/list?limit=25').then(p => { setMock(p.mock); renderList(p); })
.catch(e => { $('#prof-list').innerHTML = `<div class="muted">Error: ${esc(e.message)}</div>`; });
}
function runSearch(q) {
listIsSearch = true;
if (!q) return loadList();
$('#prof-list').classList.add('loading');
$('#prof-list').textContent = 'Searching…';
return api(`/search?q=${encodeURIComponent(q)}`)
.then(p => { setMock(p.mock); renderList(p); })
.catch(e => { $('#prof-list').innerHTML = `<div class="muted">Error: ${esc(e.message)}</div>`; });
}
// ── detail rendering ───────────────────────────────────────────────────────
function renderTimeline(events) {
if (!events || !events.length) {
return `<div class="muted" style="padding:14px 0">No email events recorded.</div>`;
}
// Limit to 60 newest so the card doesn't get unwieldy
const visible = events.slice(0, 60);
const iconFor = t => t === 'sent' ? '✉️' : t === 'open' ? '👁️' : t === 'click' ? '🔗' : '•';
const colorFor = t => t === 'open' ? '#6f7a63' : t === 'click' ? '#8a5a44' : '#8a8275';
const rows = visible.map(e => `
<div class="row" style="gap:10px;align-items:baseline;border-top:1px solid var(--line);padding:8px 0">
<span style="width:22px;text-align:center" title="${esc(e.type)}">${iconFor(e.type)}</span>
<div style="flex:1;min-width:0">
<div style="font-weight:600;font-size:13px;color:${colorFor(e.type)}">
${e.type === 'sent' ? 'Sent' : e.type === 'open' ? 'Opened' : 'Clicked'}
<span style="color:var(--ink);font-weight:500">· ${esc(e.campaignName || '(campaign)')}</span>
</div>
<div class="muted" style="font-size:11.5px">
${esc(fmtDateTime(e.at))}${e.link ? ` · <code style="font-size:11px">${esc(e.link)}</code>` : ''}
${e.derived ? ' · <span title="Derived from CC aggregate fields — true per-event streams require ETL">derived</span>' : ''}
</div>
</div>
</div>`).join('');
return `<div>${rows}</div>
${events.length > visible.length
? `<div class="muted" style="font-size:12px;margin-top:8px">Showing ${visible.length} of ${events.length} events.</div>`
: ''}`;
}
function lifecyclePill(stage, tone) {
const bg = tone === 'hot' ? '#fde6d8' : tone === 'warm' ? '#fff1d2' :
tone === 'cold' ? '#e6e2da' : '#eee5d4';
const fg = tone === 'hot' ? '#8a5a44' : tone === 'warm' ? '#7a5a2a' :
tone === 'cold' ? '#5a5347' : '#5a5347';
return `<span class="pill" style="background:${bg};color:${fg}">${esc(stage)}</span>`;
}
function renderDetail(payload) {
setMock(payload.mock);
$('#prof-detail-empty').style.display = 'none';
const c = payload.contact;
const lc = payload.lifecycle || { stage: '—', tone: 'neutral' };
const segs = payload.segments || [];
const cx = payload.crossChannel || {};
const lastVisit = cx.lastSiteVisit;
const social = cx.social;
const counts = payload.timelineCounts || { sent: 0, open: 0, click: 0 };
const tags = (c.tags || []).map(t =>
`<span class="pill" style="margin:0 4px 4px 0">${esc(t)}</span>`).join('') ||
'<span class="muted" style="font-size:12px">no tags</span>';
const lists = (c.lists || []).map(l =>
`<span class="pill" style="margin:0 4px 4px 0;background:#eee5d4">${esc(l)}</span>`).join('') ||
'<span class="muted" style="font-size:12px">no list memberships</span>';
const segChips = segs.length
? segs.map(s => `<span class="pill" title="${esc(s.description)}" style="margin:0 4px 4px 0;background:#e6dec8">${esc(s.name)}</span>`).join('')
: '<span class="muted" style="font-size:12px">Does not match any saved segment.</span>';
const loc = c.location || {};
const locStr = [loc.city, loc.region, loc.country].filter(Boolean).join(', ') || '—';
const detail = $('#prof-detail');
detail.style.display = '';
detail.innerHTML = `
<div class="row" style="justify-content:space-between;align-items:baseline;gap:12px">
<div style="min-width:0">
<h2 style="margin:0">${esc(c.name || c.email)}</h2>
<div class="muted" style="font-size:12.5px">${esc(c.email)}</div>
</div>
<div class="row" style="gap:6px;align-items:center">
${lifecyclePill(lc.stage, lc.tone)}
<span class="pill">${esc(c.type)}</span>
</div>
</div>
<div class="grid" style="margin-top:14px;grid-template-columns:repeat(4,1fr);gap:10px">
<div><label>Company</label><div style="font-weight:600">${esc(c.company || '—')}</div></div>
<div><label>Role</label><div>${esc(c.role || '—')}</div></div>
<div><label>Location</label><div>${esc(locStr)}</div></div>
<div><label>Contact since</label><div>${esc(fmtDate(c.since))}</div></div>
</div>
<div class="grid" style="margin-top:14px;grid-template-columns:repeat(4,1fr);gap:10px">
<div style="border:1px solid var(--line);border-radius:10px;padding:10px 12px">
<div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px">Lifetime opens</div>
<div style="font:600 24px/1 'Cormorant Garamond',Georgia,serif">${Number(c.opens || 0).toLocaleString()}</div>
</div>
<div style="border:1px solid var(--line);border-radius:10px;padding:10px 12px">
<div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px">Lifetime clicks</div>
<div style="font:600 24px/1 'Cormorant Garamond',Georgia,serif">${Number(c.clicks || 0).toLocaleString()}</div>
</div>
<div style="border:1px solid var(--line);border-radius:10px;padding:10px 12px">
<div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px">Last open</div>
<div style="font-weight:600">${esc(fmtRelative(c.lastOpen))}</div>
<div class="muted" style="font-size:11px">${esc(fmtDate(c.lastOpen))}</div>
</div>
<div style="border:1px solid var(--line);border-radius:10px;padding:10px 12px">
<div class="muted" style="font-size:11px;text-transform:uppercase;letter-spacing:.5px">Last click</div>
<div style="font-weight:600">${esc(fmtRelative(c.lastClick))}</div>
<div class="muted" style="font-size:11px">${esc(fmtDate(c.lastClick))}</div>
</div>
</div>
<div class="grid" style="margin-top:18px;grid-template-columns:1fr 1fr;gap:16px;align-items:start">
<div>
<label style="margin-bottom:6px">List memberships</label>
<div>${lists}</div>
<label style="margin:14px 0 6px">Tags</label>
<div>${tags}</div>
<label style="margin:14px 0 6px">Segment memberships</label>
<div>${segChips}</div>
</div>
<div>
<label style="margin-bottom:6px">Cross-channel</label>
<div style="border:1px solid var(--line);border-radius:10px;padding:10px 12px">
<div class="row" style="justify-content:space-between;align-items:baseline">
<div style="font-weight:600;font-size:13px">Last site visit</div>
<span class="muted" style="font-size:11px">placeholder</span>
</div>
${lastVisit ? `
<div style="margin-top:4px;font-size:13px">${esc(lastVisit.pageTitle || lastVisit.page)}</div>
<div class="muted" style="font-size:11.5px">${esc(fmtRelative(lastVisit.at))} · ${esc(fmtDate(lastVisit.at))} · source: ${esc(lastVisit.source || '—')}</div>
<div class="muted" style="font-size:11px;margin-top:3px"><code>/${esc(lastVisit.page)}</code></div>
` : `<div class="muted" style="font-size:12px;margin-top:4px">No tracked site visits.</div>`}
</div>
<div style="border:1px solid var(--line);border-radius:10px;padding:10px 12px;margin-top:10px">
<div class="row" style="justify-content:space-between;align-items:baseline">
<div style="font-weight:600;font-size:13px">Social</div>
<span class="muted" style="font-size:11px">placeholder</span>
</div>
${social ? `
<div style="margin-top:4px;font-size:13px">
<span class="pill" style="margin-right:6px">${esc(social.platform)}</span>
<code style="font-size:12px">${esc(social.handle)}</code>
</div>
<div class="muted" style="font-size:11.5px;margin-top:3px">${esc(social.action || 'last touch')} · ${esc(fmtRelative(social.lastTouch))}</div>
` : `<div class="muted" style="font-size:12px;margin-top:4px">No social signal on file.</div>`}
</div>
${cx.note ? `<div class="muted" style="font-size:11.5px;margin-top:8px">${esc(cx.note)}</div>` : ''}
</div>
</div>
<div style="margin-top:18px">
<div class="row" style="justify-content:space-between;align-items:baseline">
<label style="margin:0">Email engagement timeline</label>
<span class="muted" style="font-size:11.5px">
${Number(counts.sent || 0)} sent · ${Number(counts.open || 0)} opens · ${Number(counts.click || 0)} clicks
</span>
</div>
<div style="margin-top:6px">${renderTimeline(payload.timeline || [])}</div>
</div>
`;
}
function loadDetail(email) {
selectedEmail = email;
// re-style the list row selection
root.querySelectorAll('.prof-row').forEach(r => {
r.style.background = (r.dataset.email.toLowerCase() === email.toLowerCase()) ? '#f7f2e8' : '';
});
$('#prof-detail-empty').style.display = 'none';
$('#prof-detail').style.display = '';
$('#prof-detail').innerHTML = '<div class="loading" style="padding:30px 0">Loading profile…</div>';
return api(`/contact?email=${encodeURIComponent(email)}`)
.then(r => {
if (r.error) {
$('#prof-detail').innerHTML = `<div class="muted-banner">${esc(r.error)}</div>`;
return;
}
renderDetail(r);
})
.catch(e => {
$('#prof-detail').innerHTML = `<div class="muted-banner">Error: ${esc(e.message)}</div>`;
});
}
// ── wire up ────────────────────────────────────────────────────────────────
let searchTimer = null;
$('#prof-q').addEventListener('input', (e) => {
const q = e.target.value.trim();
clearTimeout(searchTimer);
searchTimer = setTimeout(() => runSearch(q), 220);
});
$('#prof-q').addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
clearTimeout(searchTimer);
runSearch(e.target.value.trim());
}
});
$('#prof-list-refresh').addEventListener('click', () => {
$('#prof-q').value = '';
loadList();
});
loadList();
},
};