← 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 =>
      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 &amp; 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 &nbsp; ${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();
  },
};