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