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