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