← back to Marketing Command Center
public/panels/constant-contact.js
201 lines
// Constant Contact panel — campaigns, contacts, lists, stats + a draft/schedule
// mini-form. Sends are gated: the "Schedule send…" button defaults to a dry-run,
// requires an explicit confirm, and surfaces the CAN-SPAM check result.
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['constant-contact'] = {
init(root) {
const $ = s => root.querySelector(s);
const api = (p, opts) => fetch(`${location.origin}/api/constant-contact${p}`, Object.assign({ credentials: 'same-origin' }, opts)).then(r => r.json());
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const pct = n => `${(Number(n || 0) * 100).toFixed(1)}%`;
const fmt = d => d ? new Date(d).toLocaleString(undefined,
{ year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) : '—';
const STATUS = { DONE: 'sent', SCHEDULED: 'scheduled', DRAFT: 'draft' };
let anyMock = false;
const markMock = m => { if (m) anyMock = true; };
function banner() {
$('#cc-banner').innerHTML = anyMock
? `<div class="muted-banner">Mock data — paste your <b>CTCT_ACCESS_TOKEN</b> in <code>.env</code> to go live. Drafting & the gated schedule check both work in mock mode.</div>`
: '';
}
// ── Contacts + Lists ──────────────────────────────────────────────────────
Promise.all([api('/contacts'), api('/lists')]).then(([c, l]) => {
markMock(c.mock || l.mock); banner();
const lists = (l.lists || []).map(x =>
`<div class="row" style="justify-content:space-between;border-top:1px solid var(--line);padding:7px 0">
<span>${esc(x.name)}</span>
<span class="pill">${Number(x.membership_count || 0).toLocaleString()}</span>
</div>`).join('') || '<div class="muted">No lists.</div>';
const recent = (c.recent || []).map(r =>
`<div class="muted" style="font-size:12px">${esc(r.email)} <span style="opacity:.6">· ${fmt(r.created_at)}</span></div>`
).join('') || '';
$('#cc-contacts-body').innerHTML =
`<div style="font:600 30px/1 'Cormorant Garamond',Georgia,serif;margin:8px 0 2px">
${Number(c.count || 0).toLocaleString()}</div>
<div class="muted" style="font-size:12px;margin-bottom:10px">total contacts</div>
<div style="font-weight:600;font-size:12px;color:var(--mut);margin-bottom:2px">Lists</div>
${lists}
${recent ? `<div style="font-weight:600;font-size:12px;color:var(--mut);margin:12px 0 4px">Recent signups</div>${recent}` : ''}`;
}).catch(e => { $('#cc-contacts-body').innerHTML = `<div class="muted">Error: ${esc(e.message)}</div>`; });
// ── Stats ─────────────────────────────────────────────────────────────────
api('/stats').then(d => {
markMock(d.mock); banner();
const s = d.stats || {};
const cell = (label, val) =>
`<div><div style="font:600 22px/1 'Cormorant Garamond',Georgia,serif">${val}</div>
<div class="muted" style="font-size:11px">${label}</div></div>`;
$('#cc-stats-body').innerHTML =
`<div class="grid" style="grid-template-columns:repeat(3,1fr);margin-top:10px">
${cell('sends', Number(s.sends || 0).toLocaleString())}
${cell('open rate', pct(s.open_rate))}
${cell('click rate', pct(s.click_rate))}
${cell('opens', Number(s.opens || 0).toLocaleString())}
${cell('clicks', Number(s.clicks || 0).toLocaleString())}
${cell('campaigns', Number(s.campaigns_30d || 0).toLocaleString())}
</div>`;
}).catch(e => { $('#cc-stats-body').innerHTML = `<div class="muted">Error: ${esc(e.message)}</div>`; });
// ── Campaigns ─────────────────────────────────────────────────────────────
api('/campaigns').then(d => {
markMock(d.mock); banner();
const rows = (d.campaigns || []).map(c => {
const when = c.status === 'SCHEDULED' ? c.scheduled_at : c.sent_at;
return `<div class="row" style="justify-content:space-between;align-items:center;border-top:1px solid var(--line);padding:9px 0">
<div>
<div style="font-weight:600">${esc(c.name)}</div>
<div class="muted" style="font-size:11.5px">${fmt(when)}</div>
</div>
<div class="row" style="align-items:center;gap:8px">
<span class="pill">${esc(STATUS[c.status] || (c.status || '').toLowerCase() || '—')}</span>
<span class="muted" style="font-size:12px;min-width:74px;text-align:right">
${Number(c.sends || 0).toLocaleString()} sent</span>
<span style="font-weight:600;min-width:54px;text-align:right">${pct(c.open_rate)}</span>
</div>
</div>`;
}).join('') || '<div class="muted">No campaigns.</div>';
$('#cc-campaigns-body').innerHTML = rows;
}).catch(e => { $('#cc-campaigns-body').innerHTML = `<div class="muted">Error: ${esc(e.message)}</div>`; });
// ── Draft + Schedule ──────────────────────────────────────────────────────
let lastDraftId = null;
const field = id => $(`#cc-f-${id}`).value.trim();
const setMsg = (t, warn) => {
const el = $('#cc-draft-msg');
el.textContent = t || '';
el.style.color = warn ? 'var(--accent)' : 'var(--mut)';
};
function showCompliance(c) {
if (!c) { $('#cc-compliance').innerHTML = ''; return; }
const ok = v => v ? '✓' : '✗';
const color = c.ok ? '#5a7a52' : 'var(--accent)';
$('#cc-compliance').innerHTML =
`<div style="font-size:12.5px;color:${color};font-weight:600">
CAN-SPAM ${c.ok ? 'PASS' : 'FAIL'} —
${ok(c.hasAddress)} physical address ${ok(c.hasUnsub)} unsubscribe link
${c.ok ? '' : `<div style="font-weight:400;margin-top:3px">Missing: ${esc((c.missing || []).join(' + '))}. Sending is blocked until both are present.</div>`}
</div>`;
}
$('#cc-save-draft').onclick = async () => {
const body = { name: field('name'), subject: field('subject'), from: field('from'), html: field('html') };
if (!body.name || !body.subject || !body.from || !body.html) {
setMsg('Fill name, from, subject and HTML first.', true); return;
}
$('#cc-save-draft').disabled = true; setMsg('Saving draft…');
try {
const r = await api('/draft', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
markMock(r.mock); banner();
if (r.error) { setMsg(r.error, true); }
else {
lastDraftId = r.draft_id;
const pill = $('#cc-draft-id');
pill.style.display = ''; pill.textContent = `draft ${r.draft_id}`;
setMsg(r.message || 'Draft saved.');
showCompliance(r.compliance);
}
} catch (e) { setMsg(`Error: ${e.message}`, true); }
finally { $('#cc-save-draft').disabled = false; }
};
$('#cc-schedule').onclick = async () => {
if (!lastDraftId) { setMsg('Save a draft first, then schedule it.', true); return; }
const html = field('html');
// Step 1: dry-run with confirm — validates + runs the CAN-SPAM check.
setMsg('Running pre-flight (dry-run + CAN-SPAM)…');
let dry;
try {
dry = await api('/schedule', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ draft_id: lastDraftId, html, subject: field('subject'), from: field('from'), confirm: true, dryRun: true }),
});
} catch (e) { setMsg(`Error: ${e.message}`, true); return; }
showCompliance(dry.compliance);
if (dry.blocked) { setMsg(dry.message || 'Blocked by CAN-SPAM.', true); return; }
if (!dry.ok) { setMsg(dry.message || 'Pre-flight failed.', true); return; }
// Step 2: explicit human confirm before any real schedule.
const when = prompt(
'Pre-flight passed (CAN-SPAM OK).\n\n' +
'Enter a send time (ISO, e.g. 2026-06-20T15:00:00Z), or leave blank for IMMEDIATE.\n' +
'Press Cancel to abort. This is a GATED send.', '');
if (when === null) { setMsg('Schedule aborted.', true); return; }
if (!confirm(`CONFIRM gated send of draft ${lastDraftId} ${when ? `at ${when}` : 'IMMEDIATELY'}?`)) {
setMsg('Schedule aborted.', true); return;
}
$('#cc-schedule').disabled = true; setMsg('Scheduling…');
try {
const r = await api('/schedule', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ draft_id: lastDraftId, scheduled_time: when || undefined, html, subject: field('subject'), from: field('from'), confirm: true, dryRun: false }),
});
markMock(r.mock); banner();
showCompliance(r.compliance);
if (r.ok) setMsg(r.message || 'Scheduled.');
else setMsg(r.message || r.error || 'Schedule failed.', true);
} catch (e) { setMsg(`Error: ${e.message}`, true); }
finally { $('#cc-schedule').disabled = false; }
};
// ── Phase 1: CSV import + ranked opens ──────────────────────────────────────
const impMsg = (t, warn) => { const el = $('#cc-imp-msg'); el.textContent = t || ''; el.style.color = warn ? 'var(--accent)' : 'var(--mut)'; };
async function loadRanked() {
let d; try { d = await api('/opens-ranked'); } catch { return; }
const meta = d.meta || {};
if (meta.last_import) $('#cc-store-meta').textContent = `Imported: ${meta.campaigns || 0} campaigns · ${meta.contacts || 0} contacts · ${meta.opens || 0} open-events.`;
$('#cc-rank-campaigns').innerHTML = (d.campaigns || []).slice(0, 12).map(c =>
`<div class="row" style="justify-content:space-between;border-top:1px solid var(--line);padding:6px 0;font-size:12.5px">
<span>${c.rank}. ${esc(c.name)}</span>
<span><b>${pct(c.open_rate)}</b> <span class="muted">· ${Number(c.sends || 0).toLocaleString()} sent</span></span>
</div>`).join('') || '<div class="muted">No campaign data yet — import a campaign report CSV.</div>';
$('#cc-rank-contacts').innerHTML = (d.contacts || []).slice(0, 12).map(c =>
`<div class="row" style="justify-content:space-between;border-top:1px solid var(--line);padding:6px 0;font-size:12.5px">
<span>${c.rank}. ${esc(c.email)}</span><span class="pill">${Number(c.opens || 0).toLocaleString()} opens</span>
</div>`).join('') || '<div class="muted">No contact-engagement data — import a contacts CSV (with an Opens column) or a per-contact opens CSV.</div>';
}
async function doImport(csv) {
const kind = $('#cc-imp-kind').value;
if (!csv || !csv.trim()) { impMsg('Choose a CSV file or paste CSV text first.', true); return; }
impMsg('Importing…');
try {
const r = await api('/import/csv', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind, csv }) });
if (r.error) impMsg('✗ ' + r.error, true);
else { impMsg(`✓ Imported ${r.imported} ${kind} row(s).`); $('#cc-imp-text').value = ''; $('#cc-imp-file').value = ''; loadRanked(); }
} catch (e) { impMsg('Error: ' + e.message, true); }
}
$('#cc-imp-go').onclick = () => {
const f = $('#cc-imp-file').files[0];
const pasted = $('#cc-imp-text').value;
if (f) { const rd = new FileReader(); rd.onload = () => doImport(rd.result); rd.readAsText(f); }
else doImport(pasted);
};
loadRanked();
},
};