← back to Marketing Command Center

public/panels/journeys.js

428 lines

// journeys panel — Cross-Channel Journeys builder + recommendations + dry-run.
// PLANNING ONLY. The panel persists the journey to data/journeys.json via the
// /api/journeys endpoints; nothing here triggers a live send.
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['journeys'] = {
  async init(root) {
    const $  = s => root.querySelector(s);
    const $$ = s => Array.from(root.querySelectorAll(s));
    const api = (p, opts) => fetch(`/api/journeys${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,
      { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : '—';

    let META = null;
    let REFS = { segments: [], templates: [] };
    let SELECTED = null;            // currently-open journey id (or 'draft')
    let DRAFT = null;               // working copy {name, description, segmentId, ..., steps:[]}

    function banner() {
      $('#journeys-banner').innerHTML = `<div class="muted-banner">
        🗺️ <b>STAGED — planning only.</b> ${esc(META && META.gateMessage || 'This module never sends.')}
      </div>`;
    }

    // ── load ────────────────────────────────────────────────────────────────
    try {
      [META, REFS] = await Promise.all([
        api('/meta'),
        api('/refs'),
      ]);
    } catch (e) {
      $('#journeys-list').innerHTML = '<div class="muted-banner">Could not reach the journeys service.</div>';
      return;
    }
    banner();

    // ── list ────────────────────────────────────────────────────────────────
    async function loadList() {
      const data = await api('/journeys');
      $('#journeys-count').textContent = `${data.count} journey${data.count === 1 ? '' : 's'}`;
      const target = $('#journeys-list');
      target.classList.remove('loading');
      if (!data.journeys || !data.journeys.length) {
        target.innerHTML = '<div class="muted">No journeys yet. Click + New to plan one.</div>';
        return;
      }
      target.innerHTML = data.journeys.map(j => `
        <div class="jny-item${SELECTED === j.id ? ' active' : ''}" data-id="${esc(j.id)}"
             style="border:1px solid ${SELECTED === j.id ? 'var(--gold)' : 'var(--line)'};
                    background:${SELECTED === j.id ? 'var(--cream)' : '#fff'};
                    border-radius:10px;padding:10px 12px;margin-bottom:8px;cursor:pointer">
          <div style="font-weight:600;font-size:13.5px;line-height:1.25">${esc(j.name)}</div>
          <div class="muted" style="font-size:11.5px;margin-top:3px">
            ${esc(j.segmentName || '— no segment —')} · ${Number(j.audienceSize || 0).toLocaleString()} contacts
          </div>
          <div class="row" style="gap:4px;margin-top:6px;flex-wrap:wrap">
            <span class="pill" style="font-size:10px">${j.stepCount} step${j.stepCount === 1 ? '' : 's'}</span>
            ${(j.kinds || []).map(k => `<span class="pill" style="font-size:10px;background:#efece5;color:var(--ink)">${esc(k)}</span>`).join('')}
          </div>
        </div>
      `).join('');
      target.querySelectorAll('.jny-item').forEach(el => el.onclick = () => openJourney(el.dataset.id));
    }

    // ── new (draft) ─────────────────────────────────────────────────────────
    $('#journeys-new').onclick = () => {
      SELECTED = 'draft';
      DRAFT = {
        name: '',
        description: '',
        segmentId: '',
        segmentName: '',
        audienceSize: 500,
        steps: [],
      };
      renderEditor();
      loadList();
    };

    // ── open existing ───────────────────────────────────────────────────────
    async function openJourney(id) {
      SELECTED = id;
      const data = await api(`/journeys/${encodeURIComponent(id)}`);
      if (data.error) { alert(data.error); return; }
      DRAFT = JSON.parse(JSON.stringify(data.journey));
      renderEditor();
      loadList();
      loadRecommendations();
      $('#journeys-run-card').style.display = 'none';
    }

    // ── editor ──────────────────────────────────────────────────────────────
    function renderEditor() {
      if (!DRAFT) { $('#journeys-editor-card').style.display = 'none'; return; }
      $('#journeys-editor-card').style.display = '';
      const isNew = SELECTED === 'draft';

      const segmentOptions = ['<option value="">— pick a segment —</option>'].concat(
        (REFS.segments || []).map(s =>
          `<option value="${esc(s.id)}" data-name="${esc(s.name)}" ${DRAFT.segmentId === s.id ? 'selected' : ''}>${esc(s.name)}${s.estimatedSize != null ? ` (~${s.estimatedSize})` : ''}</option>`
        )).join('');

      $('#journeys-editor').innerHTML = `
        <div class="row" style="justify-content:space-between;align-items:baseline">
          <h2 style="margin:0">${isNew ? 'New journey' : esc(DRAFT.name || 'Untitled journey')}</h2>
          <span class="pill" style="background:#fff0e6;color:var(--accent)">staged · planning only</span>
        </div>

        <div class="grid" style="grid-template-columns:1fr 1fr;margin-top:14px">
          <div>
            <label for="j-name">Journey name</label>
            <input id="j-name" type="text" placeholder="e.g. Designer Welcome — Cross-Channel" value="${esc(DRAFT.name)}" />
          </div>
          <div>
            <label for="j-segment">Segment</label>
            <select id="j-segment">${segmentOptions}</select>
          </div>
          <div style="grid-column:1/-1">
            <label for="j-desc">Description</label>
            <textarea id="j-desc" rows="2" placeholder="What is this journey for?">${esc(DRAFT.description)}</textarea>
          </div>
          <div>
            <label for="j-audience">Audience size (estimated)</label>
            <input id="j-audience" type="number" min="0" value="${Number(DRAFT.audienceSize) || 0}" />
          </div>
          <div>
            <label>&nbsp;</label>
            <div class="muted" style="font-size:12px;padding:9px 0">
              Used by the dry-run simulation. Does not affect a live send.
            </div>
          </div>
        </div>

        <div style="border-top:1px solid var(--line);margin:14px 0 10px;padding-top:12px">
          <div class="row" style="justify-content:space-between;align-items:baseline">
            <h3 style="margin:0;font:600 14px/1.2 'Cormorant Garamond',Georgia,serif">Steps</h3>
            <div class="row" style="gap:6px">
              ${(META.kinds || []).map(k =>
                `<button class="btn ghost j-add" data-kind="${esc(k.key)}" style="padding:5px 10px;font-size:12px">+ ${esc(k.icon)} ${esc(k.label)}</button>`
              ).join('')}
            </div>
          </div>
          <div id="j-steps" style="margin-top:10px"></div>
        </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="j-msg" style="font-size:12px">&nbsp;</span>
          <div class="row" style="gap:8px">
            ${isNew ? '' : `<button class="btn ghost" id="j-delete" style="color:var(--accent);padding:7px 14px">Delete</button>`}
            <button class="btn ghost" id="j-run" ${isNew ? 'disabled style="opacity:.5;cursor:not-allowed"' : ''} style="padding:7px 14px">▶ Dry-run</button>
            <button class="btn gold" id="j-save" style="padding:7px 14px">${isNew ? '💾 Save journey' : '💾 Save changes'}</button>
          </div>
        </div>
      `;

      renderSteps();

      $('#j-name').oninput = e => { DRAFT.name = e.target.value; };
      $('#j-desc').oninput = e => { DRAFT.description = e.target.value; };
      $('#j-audience').oninput = e => { DRAFT.audienceSize = Number(e.target.value) || 0; };
      $('#j-segment').onchange = e => {
        const opt = e.target.selectedOptions[0];
        DRAFT.segmentId = e.target.value || '';
        DRAFT.segmentName = opt ? (opt.dataset.name || opt.textContent.replace(/\s*\(~\d+\)\s*$/, '')) : '';
      };

      $$('#j-steps + *, .j-add').forEach(() => {});
      $$('.j-add').forEach(b => b.onclick = () => addStep(b.dataset.kind));
      $('#j-save').onclick = saveJourney;
      $('#j-run').onclick = dryRun;
      const delBtn = $('#j-delete');
      if (delBtn) delBtn.onclick = deleteJourney;
    }

    function renderSteps() {
      const target = $('#j-steps');
      if (!target) return;
      if (!DRAFT.steps.length) {
        target.innerHTML = '<div class="muted-banner" style="margin:0">No steps yet — start with an email, then layer cross-channel follow-up below.</div>';
        return;
      }
      target.innerHTML = DRAFT.steps.map((step, i) => stepCard(step, i)).join('');
      // Wire up field bindings, condition select, remove, move
      $$('[data-step-idx]').forEach(card => {
        const idx = Number(card.dataset.stepIdx);
        card.querySelectorAll('[data-field]').forEach(input => {
          input.oninput = () => { DRAFT.steps[idx][input.dataset.field] = input.value; };
          input.onchange = () => { DRAFT.steps[idx][input.dataset.field] = input.value; };
        });
        const condSel = card.querySelector('[data-condition]');
        if (condSel) condSel.onchange = () => { DRAFT.steps[idx].condition = condSel.value; };
        const tplSel = card.querySelector('[data-template]');
        if (tplSel) {
          tplSel.onchange = () => {
            const v = tplSel.value;
            DRAFT.steps[idx].templateId = v;
            if (v) {
              const tpl = (REFS.templates || []).find(t => t.id === v);
              if (tpl && !DRAFT.steps[idx].subject) {
                DRAFT.steps[idx].subject = tpl.subject;
                renderSteps();
              }
            }
          };
        }
        const rm = card.querySelector('.step-remove');
        if (rm) rm.onclick = () => { DRAFT.steps.splice(idx, 1); renderSteps(); };
        const up = card.querySelector('.step-up');
        if (up) up.onclick = () => { if (idx > 0) { const s = DRAFT.steps[idx]; DRAFT.steps[idx] = DRAFT.steps[idx - 1]; DRAFT.steps[idx - 1] = s; renderSteps(); } };
        const dn = card.querySelector('.step-down');
        if (dn) dn.onclick = () => { if (idx < DRAFT.steps.length - 1) { const s = DRAFT.steps[idx]; DRAFT.steps[idx] = DRAFT.steps[idx + 1]; DRAFT.steps[idx + 1] = s; renderSteps(); } };
      });
    }

    function kindSpec(key) {
      return (META && META.kinds || []).find(k => k.key === key) || null;
    }

    function stepCard(step, idx) {
      const spec = kindSpec(step.kind);
      if (!spec) return '';
      const conditions = (META.conditions || []);
      const tpls = (REFS.templates || []);
      const fieldHtml = spec.fields.map(f => {
        const val = step[f.key] == null ? '' : step[f.key];
        if (f.enum) {
          const opts = ['<option value="">— pick —</option>'].concat(
            f.enum.map(o => `<option value="${esc(o)}" ${val === o ? 'selected' : ''}>${esc(o)}</option>`)).join('');
          return `<div><label>${esc(f.label)}</label><select data-field="${esc(f.key)}">${opts}</select></div>`;
        }
        if (f.type === 'number') {
          return `<div><label>${esc(f.label)}</label><input type="number" min="0" data-field="${esc(f.key)}" value="${esc(val)}" /></div>`;
        }
        if (step.kind === 'email' && f.key === 'templateId') {
          const opts = ['<option value="">— pick template (optional) —</option>'].concat(
            tpls.map(t => `<option value="${esc(t.id)}" ${val === t.id ? 'selected' : ''}>${esc(t.title)} · ${esc(t.category)}</option>`)).join('');
          return `<div><label>${esc(f.label)}</label><select data-field="${esc(f.key)}" data-template>${opts}</select></div>`;
        }
        if (f.key === 'caption' || f.key === 'message' || f.key === 'note') {
          return `<div style="grid-column:1/-1"><label>${esc(f.label)}</label><textarea rows="2" data-field="${esc(f.key)}">${esc(val)}</textarea></div>`;
        }
        return `<div><label>${esc(f.label)}</label><input type="text" data-field="${esc(f.key)}" value="${esc(val)}" /></div>`;
      }).join('');

      const condHtml = spec.supportsCondition
        ? `<div><label>Condition</label><select data-condition>${
            conditions.map(c => `<option value="${esc(c.key)}" ${(step.condition || 'always') === c.key ? 'selected' : ''}>${esc(c.label)}</option>`).join('')
          }</select></div>`
        : '';

      return `
        <div data-step-idx="${idx}"
             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:8px;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">${esc(spec.icon)}</span>
              <span style="font-weight:700">${esc(spec.label)}</span>
              ${step.kind !== 'wait' && (step.condition && step.condition !== 'always')
                ? `<span class="pill" style="background:#fff0e6;color:var(--accent)">${esc(step.condition)}</span>`
                : ''}
            </div>
            <div class="row" style="gap:4px">
              <button class="btn ghost step-up" style="padding:3px 8px;font-size:11px" title="Move up">↑</button>
              <button class="btn ghost step-down" style="padding:3px 8px;font-size:11px" title="Move down">↓</button>
              <button class="btn ghost step-remove" style="padding:3px 8px;font-size:11px;color:var(--accent)" title="Remove">✕</button>
            </div>
          </div>
          <div class="grid" style="grid-template-columns:repeat(2,1fr);gap:10px">
            ${fieldHtml}
            ${condHtml}
          </div>
        </div>`;
    }

    function addStep(kind) {
      const spec = kindSpec(kind);
      if (!spec) return;
      const step = { kind };
      // sensible defaults
      if (kind === 'wait') { step.hours = 24; step.label = '24 hours'; }
      if (kind === 'email') { step.subject = ''; step.templateId = ''; step.condition = 'always'; }
      if (kind === 'sms')   { step.message = ''; step.condition = 'if-no-click'; }
      if (kind === 'social'){ step.platform = 'instagram'; step.caption = ''; step.condition = 'if-no-open'; }
      if (kind === 'retarget'){ step.platform = 'meta'; step.audienceType = 'non-openers'; step.condition = 'if-no-open'; }
      DRAFT.steps.push(step);
      renderSteps();
    }

    function setMsg(text, kind) {
      const el = $('#j-msg'); if (!el) return;
      el.textContent = text || '';
      el.style.color = kind === 'err' ? 'var(--accent)' : kind === 'ok' ? 'var(--sage)' : 'var(--mut)';
    }

    async function saveJourney() {
      if (!DRAFT.name || !DRAFT.name.trim()) { setMsg('Name is required.', 'err'); return; }
      if (!DRAFT.steps.length) { setMsg('Add at least one step before saving.', 'err'); return; }
      setMsg('Saving…');
      const isNew = SELECTED === 'draft';
      const url = isNew ? '/journeys' : `/journeys/${encodeURIComponent(SELECTED)}`;
      const method = isNew ? 'POST' : 'PUT';
      const res = await fetch(`/api/journeys${url}`, {
        method,
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(DRAFT),
      }).then(r => r.json());
      if (res.error) {
        setMsg(res.error + (res.issues ? ' — ' + res.issues.join(', ') : ''), 'err');
        return;
      }
      setMsg(isNew ? `Created: ${res.journey.id}` : 'Saved.', 'ok');
      SELECTED = res.journey.id;
      DRAFT = JSON.parse(JSON.stringify(res.journey));
      await loadList();
      await loadRecommendations();
      renderEditor();
    }

    async function deleteJourney() {
      if (!confirm('Delete this journey? This cannot be undone.')) return;
      await api(`/journeys/${encodeURIComponent(SELECTED)}`, { method: 'DELETE' });
      SELECTED = null;
      DRAFT = null;
      $('#journeys-editor-card').style.display = 'none';
      $('#journeys-recs-card').style.display = 'none';
      $('#journeys-run-card').style.display = 'none';
      await loadList();
    }

    async function dryRun() {
      if (SELECTED === 'draft' || !SELECTED) return;
      const data = await fetch(`/api/journeys/${encodeURIComponent(SELECTED)}/run`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ audienceSize: DRAFT.audienceSize }),
      }).then(r => r.json());
      if (data.error) { alert(data.error); return; }
      renderDryRun(data);
    }

    function renderDryRun(data) {
      $('#journeys-run-card').style.display = '';
      const target = $('#journeys-run');
      const rows = (data.stepResults || []).map(r => {
        const flowPct = data.audienceSize > 0 ? Math.round((r.proceeded / data.audienceSize) * 100) : 0;
        return `
          <div style="display:grid;grid-template-columns:30px 1fr 100px 100px 120px;gap:10px;align-items:center;padding:8px 0;border-top:1px solid var(--line)">
            <div style="font-weight:700;color:var(--mut)">${r.index + 1}.</div>
            <div>
              <div style="font-weight:600">${esc(r.kind)} <span class="muted" style="font-weight:400;font-size:11px">${esc(r.condition)}</span></div>
              <div class="muted" style="font-size:11.5px">${esc(r.note || '')}</div>
            </div>
            <div style="font-size:12.5px"><span class="muted">in</span> <b>${Number(r.entry || 0).toLocaleString()}</b></div>
            <div style="font-size:12.5px"><span class="muted">out</span> <b>${Number(r.proceeded || 0).toLocaleString()}</b></div>
            <div>
              <div style="background:var(--paper);border:1px solid var(--line);border-radius:99px;height:8px;overflow:hidden">
                <div style="background:var(--gold);height:100%;width:${flowPct}%"></div>
              </div>
              <div class="muted" style="font-size:10.5px;margin-top:2px">${flowPct}% of start</div>
            </div>
          </div>
        `;
      }).join('');
      target.innerHTML = `
        <div class="row" style="gap:18px;margin-bottom:10px;flex-wrap:wrap">
          <div><div class="muted" style="font-size:11px">Audience</div>
            <div style="font-weight:700;font-size:17px">${Number(data.audienceSize || 0).toLocaleString()}</div></div>
          <div><div class="muted" style="font-size:11px">Emails</div>
            <div style="font-weight:700;font-size:17px">${data.totalEmails || 0}</div></div>
          <div><div class="muted" style="font-size:11px">SMS</div>
            <div style="font-weight:700;font-size:17px">${data.totalSms || 0}</div></div>
          <div><div class="muted" style="font-size:11px">Social</div>
            <div style="font-weight:700;font-size:17px">${data.totalSocial || 0}</div></div>
          <div><div class="muted" style="font-size:11px">Retargets</div>
            <div style="font-weight:700;font-size:17px">${data.totalRetargets || 0}</div></div>
          <div><div class="muted" style="font-size:11px">Reach final step</div>
            <div style="font-weight:700;font-size:17px">${Number(data.finalActive || 0).toLocaleString()}</div></div>
        </div>
        ${rows}
        <div class="muted-banner" style="margin-top:12px">${esc(data.sendNote || 'Planning only — no sends.')}</div>
      `;
    }

    // ── recommendations ─────────────────────────────────────────────────────
    async function loadRecommendations() {
      $('#journeys-recs-card').style.display = '';
      const target = $('#journeys-recs');
      target.innerHTML = '<div class="muted">Loading recommendations…</div>';
      const id = SELECTED === 'draft' ? 'draft' : SELECTED;
      const res = await fetch(`/api/journeys/${encodeURIComponent(id)}/recommend`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ steps: DRAFT.steps || [] }),
      }).then(r => r.json());
      if (!res.recommendations || !res.recommendations.length) {
        target.innerHTML = '<div class="muted">No recommendations right now — the journey looks complete.</div>';
        return;
      }
      target.innerHTML = res.recommendations.map((rec, i) => `
        <div style="border:1px solid var(--line);border-radius:11px;padding:12px 14px;margin-bottom:10px;background:var(--paper)">
          <div class="row" style="justify-content:space-between;align-items:baseline">
            <h3 style="margin:0;font:600 14px/1.2 'Cormorant Garamond',Georgia,serif">${esc(rec.title)}</h3>
            <button class="btn ghost rec-apply" data-i="${i}" style="padding:5px 11px;font-size:12px">+ Append to journey</button>
          </div>
          <div class="muted" style="font-size:12px;margin:5px 0 10px;line-height:1.5">${esc(rec.reason)}</div>
          <div class="row" style="gap:6px;flex-wrap:wrap">
            ${(rec.steps || []).map(s => {
              const spec = kindSpec(s.kind);
              return `<span class="pill" style="background:#fff;border:1px solid var(--line);color:var(--ink)">${esc(spec ? spec.icon : '')} ${esc(spec ? spec.label : s.kind)}${s.condition && s.condition !== 'always' ? ` · ${esc(s.condition)}` : ''}${s.kind === 'wait' ? ` · ${s.hours}h` : ''}</span>`;
            }).join('')}
          </div>
        </div>
      `).join('');
      target.querySelectorAll('.rec-apply').forEach(b => b.onclick = () => {
        const rec = res.recommendations[Number(b.dataset.i)];
        for (const s of rec.steps || []) DRAFT.steps.push(JSON.parse(JSON.stringify(s)));
        renderSteps();
        setMsg('Appended — review and save.', 'ok');
      });
    }

    // ── initial render ──────────────────────────────────────────────────────
    await loadList();
  },
};