← back to Marketing Command Center
public/panels/segments.js
301 lines
// Audience Segments panel — left: saved segments (size + edit/delete); right: a
// rule builder with a live "Preview audience" (count + sample table) and Save.
// Shows a .muted-banner whenever the preview/list ran on the mock contact pool.
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['segments'] = {
init(root) {
const $ = s => root.querySelector(s);
const api = (p, opts) => fetch(`/api/segments${p}`, opts).then(r => r.json());
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const fmt = d => d ? new Date(d).toLocaleDateString(undefined,
{ year: 'numeric', month: 'short', day: 'numeric' }) : 'never';
let anyMock = false;
const markMock = m => { if (m) { anyMock = true; banner(); } };
function banner() {
$('#seg-banner').innerHTML = anyMock
? `<div class="muted-banner">Running on a <b>mock contact pool</b> (~300 luxury-design contacts).
Paste your <code>CTCT_ACCESS_TOKEN</code> in <code>.env</code> to segment your live Constant Contact audience.</div>`
: '';
}
let SCHEMA = { fields: [], lists: [], tags: [] };
let editingId = null;
const setMsg = (t, warn) => {
const el = $('#seg-msg');
el.textContent = t || '';
el.style.color = warn ? 'var(--accent)' : 'var(--mut)';
};
// ── rule rows ──────────────────────────────────────────────────────────────
function fieldSpec(key) { return SCHEMA.fields.find(f => f.key === key); }
function valueControl(field, value) {
const spec = fieldSpec(field);
const vals = (spec && spec.values) || [];
if (Array.isArray(vals) && vals.length) {
const opts = vals.map(v =>
`<option value="${esc(v)}" ${v === value ? 'selected' : ''}>${esc(v)}</option>`).join('');
return `<select class="seg-rule-value">${opts}</select>`;
}
return `<input class="seg-rule-value" value="${esc(value || '')}" placeholder="value">`;
}
function ruleRow(rule) {
rule = rule || {};
const field = rule.field || (SCHEMA.fields[0] && SCHEMA.fields[0].key) || 'type';
const spec = fieldSpec(field) || { ops: [] };
const fieldOpts = SCHEMA.fields.map(f =>
`<option value="${esc(f.key)}" ${f.key === field ? 'selected' : ''}>${esc(f.label)}</option>`).join('');
const op = (spec.ops.some(o => o.key === rule.op) ? rule.op : (spec.ops[0] && spec.ops[0].key)) || 'is';
const opOpts = spec.ops.map(o =>
`<option value="${esc(o.key)}" ${o.key === op ? 'selected' : ''}>${esc(o.label)}</option>`).join('');
const wrap = document.createElement('div');
wrap.className = 'row seg-rule';
wrap.style.cssText = 'gap:8px;align-items:center;margin-top:8px';
wrap.innerHTML =
`<select class="seg-rule-field" style="flex:1.1">${fieldOpts}</select>
<select class="seg-rule-op" style="flex:.8">${opOpts}</select>
<span class="seg-rule-valwrap" style="flex:1.3">${valueControl(field, rule.value)}</span>
<button class="btn ghost seg-rule-del" title="Remove rule" style="padding:9px 12px">✕</button>`;
// field change → rebuild op + value controls for the new field
wrap.querySelector('.seg-rule-field').onchange = (e) => {
const f = fieldSpec(e.target.value) || { ops: [] };
wrap.querySelector('.seg-rule-op').innerHTML = f.ops.map(o =>
`<option value="${esc(o.key)}">${esc(o.label)}</option>`).join('');
const firstVal = Array.isArray(f.values) && f.values.length ? f.values[0] : '';
wrap.querySelector('.seg-rule-valwrap').innerHTML = valueControl(e.target.value, firstVal);
};
wrap.querySelector('.seg-rule-del').onclick = () => wrap.remove();
return wrap;
}
function addRule(rule) { $('#seg-rules').appendChild(ruleRow(rule)); }
function readRules() {
return [...$('#seg-rules').querySelectorAll('.seg-rule')].map(row => ({
field: row.querySelector('.seg-rule-field').value,
op: row.querySelector('.seg-rule-op').value,
value: row.querySelector('.seg-rule-value').value,
})).filter(r => r.field);
}
function loadIntoBuilder(seg) {
editingId = seg ? seg.id : null;
$('#seg-builder-title').textContent = seg ? 'Edit segment' : 'Build a segment';
$('#seg-name').value = seg ? (seg.name || '') : '';
$('#seg-desc').value = seg ? (seg.description || '') : '';
$('#seg-rules').innerHTML = '';
const rules = (seg && seg.rules && seg.rules.length) ? seg.rules : [{}];
rules.forEach(addRule);
const pill = $('#seg-editing');
if (seg) { pill.style.display = ''; pill.textContent = `editing ${seg.id}`; }
else { pill.style.display = 'none'; }
$('#seg-preview-body').innerHTML = '';
setMsg('');
}
// ── saved segment list ──────────────────────────────────────────────────────
function renderList(segments) {
const el = $('#seg-list');
if (!segments.length) { el.innerHTML = '<div class="muted">No segments yet — build one on the right.</div>'; return; }
el.classList.remove('loading');
el.innerHTML = segments.map(s => {
const nRules = (s.rules || []).length;
return `<div class="seg-item" data-id="${esc(s.id)}"
style="border-top:1px solid var(--line);padding:11px 0;cursor:pointer">
<div class="row" style="justify-content:space-between;align-items:baseline">
<div style="font-weight:600">${esc(s.name)}</div>
<span class="pill">${Number(s.estimatedSize || 0).toLocaleString()}</span>
</div>
<div class="muted" style="font-size:12px;margin:3px 0 6px">${esc(s.description || '—')}</div>
<div class="row" style="gap:8px;align-items:center">
<span class="muted" style="font-size:11px">${nRules} rule${nRules === 1 ? '' : 's'}</span>
<button class="btn ghost seg-edit" data-id="${esc(s.id)}" style="padding:5px 11px;font-size:12px">Edit</button>
<button class="btn ghost seg-del" data-id="${esc(s.id)}" style="padding:5px 11px;font-size:12px">Delete</button>
</div>
</div>`;
}).join('');
el.querySelectorAll('.seg-edit').forEach(b => b.onclick = (e) => {
e.stopPropagation();
const seg = segments.find(s => s.id === b.dataset.id);
if (seg) loadIntoBuilder(seg);
});
el.querySelectorAll('.seg-del').forEach(b => b.onclick = async (e) => {
e.stopPropagation();
const seg = segments.find(s => s.id === b.dataset.id);
if (!seg) return;
if (!confirm(`Delete segment “${seg.name}”? This cannot be undone.`)) return;
const r = await api(`/segments?delete=${encodeURIComponent(seg.id)}`, { method: 'POST' });
markMock(r.mock);
renderList(r.segments || []);
if (editingId === seg.id) loadIntoBuilder(null);
});
// clicking the item previews that saved segment
el.querySelectorAll('.seg-item').forEach(it => it.onclick = () => previewSaved(it.dataset.id));
}
function loadList() {
return api('/segments').then(d => { markMock(d.mock); renderList(d.segments || []); })
.catch(e => { $('#seg-list').innerHTML = `<div class="muted">Error: ${esc(e.message)}</div>`; });
}
// ── preview rendering ───────────────────────────────────────────────────────
function renderPreview(r) {
markMock(r.mock);
if (r.error) { $('#seg-preview-body').innerHTML = `<div class="muted-banner">${esc(r.error)}</div>`; return; }
const sample = r.sample || [];
const rows = sample.map(c => {
const tags = (c.tags || []).map(t => `<span class="pill" style="margin:0 3px 3px 0">${esc(t)}</span>`).join('') || '';
return `<tr style="border-top:1px solid var(--line)">
<td style="padding:7px 8px">${esc(c.email)}</td>
<td style="padding:7px 8px"><span class="pill">${esc(c.type)}</span></td>
<td style="padding:7px 8px;font-size:12px;color:var(--mut)">${esc((c.lists || []).join(', ') || '—')}</td>
<td style="padding:7px 8px;font-size:12px;color:var(--mut)">${fmt(c.lastOpen)}</td>
<td style="padding:7px 8px">${tags}</td>
</tr>`;
}).join('');
const pctOfTotal = r.total ? ((r.count / r.total) * 100).toFixed(1) : '0.0';
$('#seg-preview-body').innerHTML =
`<div class="row" style="align-items:baseline;gap:10px;border-top:1px solid var(--line);padding-top:14px">
<span style="font:600 30px/1 'Cormorant Garamond',Georgia,serif">${Number(r.count).toLocaleString()}</span>
<span class="muted" style="font-size:12px">matching contacts · ${pctOfTotal}% of ${Number(r.total || 0).toLocaleString()}</span>
</div>
${sample.length ? `<div style="overflow:auto;margin-top:10px">
<table style="width:100%;border-collapse:collapse;font-size:13px">
<thead><tr style="text-align:left;color:var(--mut);font-size:11px">
<th style="padding:4px 8px">Email</th><th style="padding:4px 8px">Type</th>
<th style="padding:4px 8px">Lists</th><th style="padding:4px 8px">Last open</th>
<th style="padding:4px 8px">Tags</th>
</tr></thead>
<tbody>${rows}</tbody>
</table>
${r.count > sample.length ? `<div class="muted" style="font-size:12px;margin-top:8px">Showing first ${sample.length} of ${Number(r.count).toLocaleString()}.</div>` : ''}
</div>` : `<div class="muted" style="margin-top:10px">No contacts match these rules.</div>`}`;
}
function previewLive() {
setMsg('Previewing…');
api('/preview', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ rules: readRules() }),
}).then(r => { setMsg(''); renderPreview(r); })
.catch(e => setMsg(`Error: ${e.message}`, true));
}
function previewSaved(id) {
setMsg('Previewing saved segment…');
api(`/contacts/preview?segmentId=${encodeURIComponent(id)}`)
.then(r => { setMsg(''); renderPreview(r); })
.catch(e => setMsg(`Error: ${e.message}`, true));
}
// ── save ────────────────────────────────────────────────────────────────────
async function save() {
const name = $('#seg-name').value.trim();
if (!name) { setMsg('Give the segment a name first.', true); return; }
const body = { id: editingId || undefined, name, description: $('#seg-desc').value.trim(), rules: readRules() };
$('#seg-save').disabled = true; setMsg('Saving…');
try {
const r = await api('/segments', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
});
markMock(r.mock);
if (r.error) { setMsg(r.error, true); }
else {
editingId = r.entry && r.entry.id;
const pill = $('#seg-editing');
if (editingId) { pill.style.display = ''; pill.textContent = `editing ${editingId}`; }
$('#seg-builder-title').textContent = 'Edit segment';
setMsg(`Saved “${r.entry.name}” · est. ${Number(r.entry.estimatedSize || 0).toLocaleString()} contacts.`);
renderList(r.segments || []);
}
} catch (e) { setMsg(`Error: ${e.message}`, true); }
finally { $('#seg-save').disabled = false; }
}
// ── RFM auto-segments ───────────────────────────────────────────────────────
const BUCKET_COLOR = {
champions: '#b8925a', // brand gold
loyal: '#6f7a63', // sage
'at-risk': '#c47a4a', // warm warning
hibernating: '#8a8275', // muted
promising: '#7a8b9a', // cool fresh
};
function rfmCard(b) {
const pct = (b.share * 100).toFixed(1);
const accent = BUCKET_COLOR[b.id] || 'var(--accent)';
const samp = (b.sample || []).slice(0, 5).map(s => {
const tail = `R${s.r}·F${s.f}·M${s.m}`;
return `<div style="display:flex;justify-content:space-between;gap:8px;
border-top:1px solid var(--line);padding:5px 0;font-size:12px">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">${esc(s.email)}</span>
<span class="muted" style="font-size:11px;flex-shrink:0">${tail}</span>
</div>`;
}).join('') || '<div class="muted" style="font-size:12px;padding:6px 0">No contacts in this bucket.</div>';
const moreCount = Math.max(0, b.count - 5);
return `<div class="card" style="margin:0;border-top:3px solid ${accent}">
<div class="row" style="justify-content:space-between;align-items:baseline">
<div style="font:600 16px/1.2 'Cormorant Garamond',Georgia,serif">
<span style="font-size:18px;margin-right:6px">${esc(b.icon)}</span>${esc(b.name)}
</div>
<span class="pill" style="background:${accent};color:#fff">${b.count.toLocaleString()}</span>
</div>
<div class="muted" style="font-size:11.5px;margin:4px 0 2px">${esc(b.description)}</div>
<div class="muted" style="font-size:11px;margin-bottom:8px">
<code style="font-size:10.5px">${esc(b.criteria)}</code> · ${pct}% of pool
</div>
<div style="margin-top:6px">${samp}
${moreCount > 0 ? `<div class="muted" style="font-size:11px;padding-top:5px">+ ${moreCount.toLocaleString()} more…</div>` : ''}
</div>
<div class="muted" style="font-size:11.5px;border-top:1px solid var(--line);
padding-top:8px;margin-top:8px;font-style:italic">${esc(b.nextStep || '')}</div>
</div>`;
}
function renderRfm(r) {
markMock(r.mock);
$('#seg-rfm-total').textContent = `${Number(r.total || 0).toLocaleString()} contacts`;
const s = r.summary || {};
$('#seg-rfm-summary').innerHTML =
`Pool averages — R <b>${s.avgR ?? '—'}</b> · F <b>${s.avgF ?? '—'}</b> · M <b>${s.avgM ?? '—'}</b>`;
const grid = $('#seg-rfm-grid');
grid.classList.remove('loading');
const buckets = r.buckets || [];
if (!buckets.length) {
grid.innerHTML = '<div class="muted">No RFM data.</div>';
return;
}
grid.style.cssText =
'margin-top:12px;display:grid;gap:14px;' +
'grid-template-columns:repeat(auto-fit,minmax(220px,1fr))';
grid.innerHTML = buckets.map(rfmCard).join('');
}
function loadRfm() {
$('#seg-rfm-grid').classList.add('loading');
$('#seg-rfm-grid').innerHTML = 'Computing RFM…';
return api('/rfm').then(renderRfm)
.catch(e => { $('#seg-rfm-grid').innerHTML = `<div class="muted">Error: ${esc(e.message)}</div>`; });
}
$('#seg-rfm-refresh').onclick = loadRfm;
// ── wire up ──────────────────────────────────────────────────────────────────
$('#seg-add-rule').onclick = () => addRule({});
$('#seg-new').onclick = () => loadIntoBuilder(null);
$('#seg-preview').onclick = previewLive;
$('#seg-save').onclick = save;
// load schema first (rule builder depends on it), then list + an empty builder
api('/schema').then(s => {
SCHEMA = s || SCHEMA;
loadIntoBuilder(null);
loadList();
loadRfm();
}).catch(e => setMsg(`Error loading schema: ${e.message}`, true));
},
};