← back to Marketing Command Center
public/panels/browse-abandon.js
393 lines
// browse-abandon panel — Browse Abandonment drip designer + dry-run dashboard.
// PLANNING ONLY. Talks to /api/browse-abandon; never triggers a live send.
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['browse-abandon'] = {
async init(root) {
const $ = s => root.querySelector(s);
const $$ = s => Array.from(root.querySelectorAll(s));
const api = (p, opts) => fetch(`/api/browse-abandon${p}`, opts).then(r => r.json());
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const fmtDate = d => d ? new Date(d).toLocaleString(undefined,
{ month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—';
let META = null;
let CONFIG = null;
let EVENTS = [];
let SUMMARY = null;
let FILTER = 'all';
function banner() {
$('#ba-banner').innerHTML = `<div class="muted-banner">
🔁 <b>STAGED — planning only.</b> ${esc(META && META.gateMessage || 'This module never sends.')}
</div>`;
}
// ── load ────────────────────────────────────────────────────────────────
try {
META = await api('/meta');
} catch (e) {
$('#ba-config').innerHTML = '<div class="muted-banner">Could not reach the browse-abandon service.</div>';
return;
}
banner();
async function loadConfig() {
const data = await api('/config');
CONFIG = data.config;
renderConfig();
}
async function loadEvents() {
const data = await api('/events');
EVENTS = data.events || [];
SUMMARY = data.summary || {};
renderStats();
renderEvents();
}
async function loadSchedule() {
const data = await api('/schedule?days=7');
renderSchedule(data);
}
// ── stats ───────────────────────────────────────────────────────────────
function renderStats() {
const s = SUMMARY || {};
$('#ba-summary-pill').textContent = `${s.total || 0} browse event${s.total === 1 ? '' : 's'}`;
$('#ba-stat-total').textContent = (s.total || 0).toLocaleString();
$('#ba-stat-cooling').textContent = (s.cooling || 0).toLocaleString();
$('#ba-stat-indrip').textContent = (s.inDrip || 0).toLocaleString();
$('#ba-stat-today').textContent = (s.wouldFireToday || 0).toLocaleString();
$('#ba-stat-out').textContent = ((s.sampleRequested || 0) + (s.suppressed || 0)).toLocaleString();
}
// ── config editor ───────────────────────────────────────────────────────
function renderConfig() {
const target = $('#ba-config');
target.classList.remove('loading');
if (!CONFIG) { target.innerHTML = '<div class="muted">No config.</div>'; return; }
target.innerHTML = `
<div class="grid" style="grid-template-columns:1fr 1fr;gap:10px;margin-bottom:12px">
<div>
<label for="ba-enabled">Drip status</label>
<select id="ba-enabled">
<option value="true" ${CONFIG.enabled ? 'selected' : ''}>Enabled (would evaluate events)</option>
<option value="false" ${!CONFIG.enabled ? 'selected' : ''}>Paused</option>
</select>
</div>
<div>
<label for="ba-threshold">No-sample window (days)</label>
<input id="ba-threshold" type="number" min="0" max="90" value="${Number(CONFIG.noSampleThresholdDays) || 1}" />
</div>
<div>
<label for="ba-cap">Daily send cap</label>
<input id="ba-cap" type="number" min="0" max="10000" value="${Number(CONFIG.dailySendCap) || 0}" />
</div>
<div>
<label> </label>
<div class="muted" style="font-size:11.5px;padding:8px 0">
Tokens you can use in subject + body: ${(META.tokens || []).map(t => `<code style="background:var(--paper);padding:1px 4px;border-radius:4px">{${esc(t)}}</code>`).join(' ')}
</div>
</div>
</div>
<div id="ba-steps"></div>
<div class="row" style="justify-content:space-between;align-items:center;margin-top:10px;border-top:1px solid var(--line);padding-top:12px">
<span class="muted" id="ba-msg" style="font-size:12px"> </span>
<div class="row" style="gap:8px">
<button class="btn ghost" id="ba-reset" style="padding:7px 12px">↺ Reset to defaults</button>
<button class="btn gold" id="ba-save" style="padding:7px 14px">💾 Save drip</button>
</div>
</div>
`;
renderSteps();
$('#ba-enabled').onchange = e => { CONFIG.enabled = e.target.value === 'true'; };
$('#ba-threshold').oninput = e => { CONFIG.noSampleThresholdDays = Number(e.target.value) || 0; };
$('#ba-cap').oninput = e => { CONFIG.dailySendCap = Number(e.target.value) || 0; };
$('#ba-save').onclick = saveConfig;
$('#ba-reset').onclick = resetConfig;
}
function renderSteps() {
const target = $('#ba-steps');
if (!target) return;
const steps = (CONFIG.steps || []).slice().sort((a, b) => a.dayOffset - b.dayOffset);
target.innerHTML = steps.map((s, i) => stepCard(s, i)).join('');
$$('[data-step-id]').forEach(card => {
const id = card.dataset.stepId;
const step = CONFIG.steps.find(x => x.id === id);
if (!step) return;
card.querySelectorAll('[data-field]').forEach(input => {
const f = input.dataset.field;
input.oninput = () => {
if (input.type === 'number') step[f] = Number(input.value) || 0;
else if (input.type === 'checkbox') step[f] = input.checked;
else step[f] = input.value;
};
input.onchange = input.oninput;
});
});
}
function stepCard(step, idx) {
const fieldHtml = step.kind === 'email' ? `
<div style="grid-column:1/-1">
<label>Subject line</label>
<input type="text" data-field="subject" value="${esc(step.subject)}" />
</div>
<div style="grid-column:1/-1">
<label>Body template <span class="muted" style="font-weight:400">— supports {tokens}</span></label>
<textarea rows="6" data-field="bodyTemplate">${esc(step.bodyTemplate)}</textarea>
</div>
<div>
<label>CTA label</label>
<input type="text" data-field="cta" value="${esc(step.cta || '')}" />
</div>
<div>
<label>Day offset</label>
<input type="number" min="0" max="365" data-field="dayOffset" value="${Number(step.dayOffset) || 0}" />
</div>
` : `
<div>
<label>Suppress cohort</label>
<input type="text" data-field="suppressList" value="${esc(step.suppressList || '')}" />
</div>
<div>
<label>Day offset</label>
<input type="number" min="0" max="365" data-field="dayOffset" value="${Number(step.dayOffset) || 0}" />
</div>
<div style="grid-column:1/-1">
<label>Internal note</label>
<textarea rows="2" data-field="note">${esc(step.note || '')}</textarea>
</div>
`;
const kindIcon = step.kind === 'email' ? '✉️' : '⏹';
return `
<div data-step-id="${esc(step.id)}"
style="border:1px solid var(--line);border-radius:11px;padding:12px 14px;margin-bottom:10px;background:#fff">
<div class="row" style="justify-content:space-between;align-items:center;margin-bottom:10px">
<div class="row" style="gap:10px;align-items:center">
<span style="font:600 14px/1.2 'Cormorant Garamond',Georgia,serif;color:var(--mut)">${idx + 1}.</span>
<span style="font-size:18px">${kindIcon}</span>
<input type="text" data-field="title" value="${esc(step.title || '')}" style="font-weight:700;max-width:300px" />
<span class="pill" style="background:var(--cream)">Day ${Number(step.dayOffset) || 0}</span>
<span class="pill" style="background:#efece5;color:var(--ink)">${esc(step.kind)}</span>
</div>
<label style="display:inline-flex;gap:6px;align-items:center;margin:0;font-weight:600">
<input type="checkbox" data-field="enabled" ${step.enabled !== false ? 'checked' : ''} style="width:auto" />
<span style="font-size:12px">Enabled</span>
</label>
</div>
<div class="grid" style="grid-template-columns:repeat(2,1fr);gap:10px">
${fieldHtml}
</div>
</div>
`;
}
function setMsg(text, kind) {
const el = $('#ba-msg'); if (!el) return;
el.textContent = text || '';
el.style.color = kind === 'err' ? 'var(--accent)' : kind === 'ok' ? 'var(--sage)' : 'var(--mut)';
}
async function saveConfig() {
setMsg('Saving…');
const res = await fetch('/api/browse-abandon/config', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(CONFIG),
}).then(r => r.json());
if (res.error) {
setMsg(res.error + (res.issues ? ' — ' + res.issues.join(', ') : ''), 'err');
return;
}
CONFIG = res.config;
setMsg('Saved · drip re-evaluating mock events…', 'ok');
await Promise.all([loadEvents(), loadSchedule()]);
renderSteps();
}
async function resetConfig() {
if (!confirm('Reset the drip to default Day 1 / Day 14 / Day 30?')) return;
const res = await api('/config/reset', { method: 'POST' });
CONFIG = res.config;
renderConfig();
await Promise.all([loadEvents(), loadSchedule()]);
}
// ── schedule projection ─────────────────────────────────────────────────
function renderSchedule(data) {
const target = $('#ba-schedule');
target.classList.remove('loading');
const cap = Number(data.dailySendCap) || 0;
const proj = data.projection || [];
const max = Math.max(1, ...proj.map(d => d.totalWouldFire || 0));
target.innerHTML = `
<div class="muted" style="font-size:11.5px;margin-bottom:10px">
Daily cap: <b>${cap.toLocaleString()}</b> ·
<span class="muted">Bars show how many mock events WOULD trigger a drip step each day.</span>
</div>
${proj.map(day => {
const pct = Math.round(((day.totalWouldFire || 0) / max) * 100);
const overCap = day.hitCap;
const barColor = overCap ? 'var(--accent)' : 'var(--gold)';
return `
<div style="display:grid;grid-template-columns:90px 1fr 70px;gap:10px;align-items:center;padding:6px 0;border-top:1px solid var(--line)">
<div style="font-weight:600;font-size:12.5px">${esc(day.label)}</div>
<div>
<div style="background:var(--paper);border:1px solid var(--line);border-radius:99px;height:10px;overflow:hidden">
<div style="background:${barColor};height:100%;width:${pct}%"></div>
</div>
<div class="muted" style="font-size:10.5px;margin-top:3px">
${(day.byStep || []).map(b => `<span style="margin-right:8px">D${b.dayOffset} ${esc(b.title)}: <b>${b.count}</b></span>`).join('') || 'no steps due'}
</div>
</div>
<div style="text-align:right;font-weight:700">${(day.totalWouldFire || 0).toLocaleString()}${overCap ? ' <span class="pill" style="background:#fff0e6;color:var(--accent);font-size:9px">cap</span>' : ''}</div>
</div>
`;
}).join('')}
<div class="muted-banner" style="margin-top:12px">${esc(data.sendNote || 'Planning only — no sends.')}</div>
`;
}
// ── events table ────────────────────────────────────────────────────────
function renderEvents() {
const target = $('#ba-events');
target.classList.remove('loading');
const filtered = EVENTS.filter(e => {
if (FILTER === 'all') return true;
if (FILTER === 'would-fire') return !!e.wouldFire;
return e.stage === FILTER;
});
if (!filtered.length) {
target.innerHTML = '<div class="muted">No events match this filter.</div>';
return;
}
target.innerHTML = `
<div style="overflow-x:auto">
<table style="width:100%;border-collapse:collapse;font-size:12.5px">
<thead>
<tr style="text-align:left;color:var(--mut);font-weight:600">
<th style="padding:8px 6px;border-bottom:1px solid var(--line)">Contact</th>
<th style="padding:8px 6px;border-bottom:1px solid var(--line)">Product</th>
<th style="padding:8px 6px;border-bottom:1px solid var(--line)">Browsed</th>
<th style="padding:8px 6px;border-bottom:1px solid var(--line)">Stage</th>
<th style="padding:8px 6px;border-bottom:1px solid var(--line)">Would fire</th>
<th style="padding:8px 6px;border-bottom:1px solid var(--line)"></th>
</tr>
</thead>
<tbody>
${filtered.map(e => `
<tr data-event-id="${esc(e.id)}" style="border-bottom:1px solid var(--line)">
<td style="padding:9px 6px;vertical-align:top">
<div style="font-weight:600">${esc(e.contactFirstName)}</div>
<div class="muted" style="font-size:11px">${esc(e.contactEmail)}</div>
<div class="muted" style="font-size:10.5px">${esc(e.segment || '')}</div>
</td>
<td style="padding:9px 6px;vertical-align:top">
<div style="font-weight:600">${esc(e.productPattern)} — ${esc(e.productColor)}</div>
<div class="muted" style="font-size:11px">${esc(e.vendor)} · ${esc(e.productSku)}</div>
</td>
<td style="padding:9px 6px;vertical-align:top">
<div>${e.browsedDaysAgo == null ? '—' : 'Day ' + e.browsedDaysAgo}</div>
<div class="muted" style="font-size:10.5px">${fmtDate(e.browsedAt)}</div>
</td>
<td style="padding:9px 6px;vertical-align:top">
${stagePill(e.stage)}
${e.currentStep ? `<div class="muted" style="font-size:10.5px;margin-top:3px">at ${esc(e.currentStep.title)}</div>` : ''}
</td>
<td style="padding:9px 6px;vertical-align:top">
${e.wouldFire
? `<span class="pill" style="background:#fff0e6;color:var(--accent)">${esc(e.wouldFire.title)}</span>`
: `<span class="muted" style="font-size:11px">—</span>`}
</td>
<td style="padding:9px 6px;vertical-align:top;text-align:right">
<button class="btn ghost ba-preview" data-id="${esc(e.id)}" style="padding:4px 9px;font-size:11.5px">👁 Preview</button>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
target.querySelectorAll('.ba-preview').forEach(b => b.onclick = () => previewEvent(b.dataset.id));
}
function stagePill(stage) {
const map = {
'pending': { bg: '#efece5', fg: 'var(--ink)', label: 'pending' },
'cooling': { bg: '#eef2eb', fg: 'var(--sage)', label: 'cooling' },
'in-drip': { bg: 'var(--cream)', fg: 'var(--accent)', label: 'in drip' },
'sample-requested': { bg: '#e7f4ea', fg: 'var(--sage)', label: 'sample requested' },
'suppressed': { bg: '#f2eee5', fg: 'var(--mut)', label: 'suppressed' },
};
const s = map[stage] || map.pending;
return `<span class="pill" style="background:${s.bg};color:${s.fg}">${esc(s.label)}</span>`;
}
async function previewEvent(id) {
const res = await fetch(`/api/browse-abandon/fire/${encodeURIComponent(id)}`, { method: 'POST' }).then(r => r.json());
$('#ba-preview-card').style.display = '';
const target = $('#ba-preview');
if (res.error) { target.innerHTML = `<div class="muted-banner">${esc(res.error)}</div>`; return; }
if (!res.preview && !res.suppress) {
target.innerHTML = `
<div class="muted-banner">${esc(res.reason || 'No step is due for this event today.')}</div>
<div class="muted" style="font-size:12px">Stage: <b>${esc(res.stage)}</b></div>
`;
return;
}
if (res.preview) {
const p = res.preview;
target.innerHTML = `
<div class="row" style="gap:8px;align-items:center;margin-bottom:10px">
<span class="pill" style="background:var(--cream)">${esc(res.step.title)}</span>
<span class="pill" style="background:#efece5;color:var(--ink)">Day ${res.step.dayOffset}</span>
<span class="pill" style="background:#fff0e6;color:var(--accent)">NOT sent · dry-run</span>
</div>
<div style="border:1px solid var(--line);border-radius:11px;padding:14px 16px;background:#fff">
<div class="muted" style="font-size:11px;letter-spacing:.4px;text-transform:uppercase">To</div>
<div style="font-weight:600;margin-bottom:8px">${esc(p.to)}</div>
<div class="muted" style="font-size:11px;letter-spacing:.4px;text-transform:uppercase">Subject</div>
<div style="font:600 16px/1.3 'Cormorant Garamond',Georgia,serif;margin-bottom:10px">${esc(p.subject)}</div>
<pre style="white-space:pre-wrap;font:13px/1.55 Georgia,serif;background:var(--paper);border:1px solid var(--line);border-radius:8px;padding:12px;margin:0">${esc(p.body)}</pre>
${p.cta ? `<div style="margin-top:10px"><button class="btn gold" style="padding:7px 14px" disabled title="Dry-run only">${esc(p.cta)}</button></div>` : ''}
<div class="muted" style="font-size:10.5px;margin-top:14px;border-top:1px solid var(--line);padding-top:8px">${esc(p.canSpamFooter)}</div>
</div>
<div class="muted-banner" style="margin-top:10px">${esc(res.note || 'Planning only — no send.')}</div>
`;
} else if (res.suppress) {
target.innerHTML = `
<div class="row" style="gap:8px;align-items:center;margin-bottom:10px">
<span class="pill" style="background:var(--cream)">${esc(res.step.title)}</span>
<span class="pill" style="background:#efece5;color:var(--ink)">Day ${res.step.dayOffset}</span>
<span class="pill" style="background:#fff0e6;color:var(--accent)">NO ACTION · dry-run</span>
</div>
<div style="border:1px solid var(--line);border-radius:11px;padding:14px 16px;background:#fff">
<div style="font-weight:600;margin-bottom:6px">Would suppress to: <code>${esc(res.suppress.list)}</code></div>
<div class="muted" style="font-size:12px;line-height:1.55">${esc(res.suppress.note || '')}</div>
</div>
<div class="muted-banner" style="margin-top:10px">${esc(res.note || 'Planning only — no action.')}</div>
`;
}
$('#ba-preview-card').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// ── controls ────────────────────────────────────────────────────────────
$('#ba-filter').onchange = e => { FILTER = e.target.value; renderEvents(); };
$('#ba-reseed').onclick = async () => {
const btn = $('#ba-reseed'); btn.disabled = true; btn.textContent = 'Reseeding…';
await api('/events/refresh-mock', { method: 'POST' });
await Promise.all([loadEvents(), loadSchedule()]);
btn.disabled = false; btn.textContent = '↻ Reseed mock events';
};
// ── initial render ──────────────────────────────────────────────────────
await Promise.all([loadConfig(), loadEvents(), loadSchedule()]);
},
};