← back to Marketing Command Center
public/panels/templates.js
252 lines
// templates panel — Campaign Templates browser + filler.
// Loads /api/templates/list, renders card-per-template, opens a modal with a
// placeholder form on "Use this", POSTs /api/templates/:id/use to get the
// filled template, and provides copy-to-clipboard for subject and body.
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['templates'] = {
async init(root) {
const $ = s => root.querySelector(s);
const $$ = s => Array.from(root.querySelectorAll(s));
const esc = s => String(s == null ? '' : s)
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
const banner = $('#templates-banner');
const out = $('#templates-out');
const search = $('#templates-search');
const catSel = $('#templates-category');
const modal = $('#templates-modal');
const modalBody = $('#templates-modal-body');
const modalClose = $('#templates-modal-close');
let TEMPLATES = [];
let META = null;
// ── load ─────────────────────────────────────────────────────────────────
try {
const [metaRes, listRes] = await Promise.all([
fetch('/api/templates/meta').then(r => r.json()),
fetch('/api/templates/list').then(r => r.json()),
]);
META = metaRes;
TEMPLATES = Array.isArray(listRes.templates) ? listRes.templates : [];
} catch (e) {
out.innerHTML = '<div class="muted-banner">Could not reach the campaign templates service.</div>';
return;
}
if (META && META.gateMessage) {
banner.innerHTML = `<div class="muted-banner">📋 STAGED — ${esc(META.gateMessage)}</div>`;
}
// ── category select ─────────────────────────────────────────────────────
const cats = ['All categories', ...(META && META.categories ? META.categories : [])];
catSel.innerHTML = cats.map((c, i) =>
`<option value="${i === 0 ? '' : esc(c)}">${esc(c)}</option>`).join('');
// ── render ──────────────────────────────────────────────────────────────
function visible() {
const q = (search.value || '').trim().toLowerCase();
const cat = catSel.value;
return TEMPLATES.filter(t => {
if (cat && t.category !== cat) return false;
if (!q) return true;
const hay = `${t.title} ${t.category} ${t.description} ${t.subject} ${t.audience} ${(t.productLines || []).join(' ')}`.toLowerCase();
return hay.includes(q);
});
}
function render() {
const list = visible();
if (!list.length) {
out.innerHTML = '<div class="muted-banner">No templates match that filter.</div>';
return;
}
out.innerHTML = list.map(t => `
<div class="card" style="margin-bottom:0;display:flex;flex-direction:column">
<div class="row" style="justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:8px">
<span class="pill">${esc(t.category || '—')}</span>
<span class="muted" style="font-size:11px;font-weight:600;letter-spacing:.5px;text-transform:uppercase">${esc(t.sequencePosition || '')}</span>
</div>
<h3 style="margin:0 0 6px;font:600 16px/1.25 'Cormorant Garamond',Georgia,serif">${esc(t.title)}</h3>
<div style="font-size:13px;font-weight:600;color:var(--ink);margin-bottom:6px">${esc(t.subject)}</div>
<div class="muted" style="font-size:12.5px;line-height:1.5;margin-bottom:12px;flex:1">${esc(t.description)}</div>
<div class="row" style="gap:6px;margin-bottom:12px;flex-wrap:wrap">
${esc(t.audience) ? `<span class="pill" style="background:#efece5;color:var(--ink)">${esc(t.audience)}</span>` : ''}
${(t.productLines || []).slice(0, 3).map(p => `<span class="pill" style="background:#f4efe7;color:var(--accent)">${esc(p)}</span>`).join('')}
</div>
<div class="row" style="justify-content:flex-end;gap:8px;margin-top:auto">
<button class="btn ghost preview-btn" data-id="${esc(t.id)}" style="padding:7px 14px">👁 Preview</button>
<button class="btn gold use-btn" data-id="${esc(t.id)}" style="padding:7px 14px">📋 Use this</button>
</div>
</div>`).join('');
out.querySelectorAll('.preview-btn').forEach(b =>
b.addEventListener('click', () => openModal(b.dataset.id, 'preview')));
out.querySelectorAll('.use-btn').forEach(b =>
b.addEventListener('click', () => openModal(b.dataset.id, 'use')));
}
search.addEventListener('input', render);
catSel.addEventListener('change', render);
render();
// ── modal ────────────────────────────────────────────────────────────────
function showModal() { modal.style.display = 'flex'; document.body.style.overflow = 'hidden'; }
function hideModal() { modal.style.display = 'none'; modalBody.innerHTML = ''; document.body.style.overflow = ''; }
modalClose.addEventListener('click', hideModal);
modal.addEventListener('click', (e) => { if (e.target === modal) hideModal(); });
function openModal(id, mode) {
const tpl = TEMPLATES.find(t => t.id === id);
if (!tpl) return;
if (mode === 'preview') renderPreview(tpl);
else renderUseForm(tpl);
showModal();
}
function renderPreview(tpl) {
modalBody.innerHTML = `
<span class="pill">${esc(tpl.category)}</span>
<h2 style="margin:10px 0 4px">${esc(tpl.title)}</h2>
<div class="muted" style="font-size:12.5px;margin-bottom:14px">${esc(tpl.description)}</div>
<label>Subject (template)</label>
<div style="background:var(--paper);border:1px solid var(--line);border-radius:9px;padding:10px 12px;font-weight:600;margin-bottom:14px">${esc(tpl.subject)}</div>
${tpl.preheader ? `
<label>Preheader (template)</label>
<div style="background:var(--paper);border:1px solid var(--line);border-radius:9px;padding:10px 12px;margin-bottom:14px;font-size:13px;color:var(--mut)">${esc(tpl.preheader)}</div>
` : ''}
<label>Body (template — with {{placeholders}})</label>
<pre style="white-space:pre-wrap;font:13px/1.6 'Inter',sans-serif;background:var(--paper);border:1px solid var(--line);border-radius:9px;padding:14px;margin:0 0 14px;color:var(--ink)">${esc(tpl.body)}</pre>
<div class="muted-banner" style="margin:0 0 14px">⚠ Add CAN-SPAM footer (physical mailing address + working unsubscribe) before this template is queued in Constant Contact.</div>
<div class="row" style="justify-content:flex-end">
<button class="btn gold" id="from-preview-use" style="padding:8px 16px">📋 Use this template</button>
</div>`;
modalBody.querySelector('#from-preview-use').addEventListener('click', () => renderUseForm(tpl));
}
function renderUseForm(tpl) {
const placeholders = tpl.placeholders || [];
modalBody.innerHTML = `
<span class="pill">${esc(tpl.category)}</span>
<h2 style="margin:10px 0 4px">${esc(tpl.title)} — fill in</h2>
<div class="muted" style="font-size:12.5px;margin-bottom:14px">Fill the fields and click <b>Generate draft</b>. STAGED only — nothing sends.</div>
${placeholders.length === 0
? '<div class="muted-banner" style="margin-bottom:14px">This template has no placeholders — it\'s ready to copy as-is.</div>'
: `<div class="grid" style="grid-template-columns:repeat(2,1fr);margin-bottom:16px">
${placeholders.map(p => `
<div>
<label for="ph-${esc(p.key)}">${esc(p.label || p.key)}</label>
<input id="ph-${esc(p.key)}" data-key="${esc(p.key)}" type="text" placeholder="${esc(p.default || '')}" value="" />
</div>`).join('')}
</div>`}
<div class="row" style="justify-content:flex-end;gap:8px;margin-bottom:14px">
<button class="btn ghost" id="use-cancel" style="padding:7px 14px">Cancel</button>
<button class="btn gold" id="use-generate" style="padding:7px 14px">📋 Generate draft</button>
</div>
<div id="use-result"></div>`;
modalBody.querySelector('#use-cancel').addEventListener('click', hideModal);
modalBody.querySelector('#use-generate').addEventListener('click', async () => {
const fields = {};
modalBody.querySelectorAll('input[data-key]').forEach(el => {
fields[el.dataset.key] = el.value;
});
const btn = modalBody.querySelector('#use-generate');
btn.disabled = true; const orig = btn.textContent; btn.textContent = 'Drafting…';
try {
const r = await fetch(`/api/templates/${encodeURIComponent(tpl.id)}/use`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ fields }),
});
const data = await r.json();
renderUseResult(tpl, data);
} catch (e) {
modalBody.querySelector('#use-result').innerHTML =
'<div class="muted-banner">Could not generate the draft. Try again.</div>';
} finally {
btn.disabled = false; btn.textContent = orig;
}
});
}
function renderUseResult(tpl, data) {
const target = modalBody.querySelector('#use-result');
if (!target) return;
const unfilledNote = (data.unfilled && data.unfilled.length)
? `<div class="muted-banner" style="margin:14px 0 0">Still unfilled: ${data.unfilled.map(esc).join(', ')} — the placeholders are kept in the draft so you can finish them in Constant Contact.</div>`
: '';
target.innerHTML = `
<div style="margin-top:8px;padding-top:14px;border-top:1px solid var(--line)">
<div class="row" style="justify-content:space-between;align-items:center;margin-bottom:8px">
<h3 style="margin:0;font:600 15px/1.2 'Cormorant Garamond',Georgia,serif">Filled draft (STAGED)</h3>
<span class="pill" style="background:#fff0e6;color:var(--accent)">staged · no send</span>
</div>
<label>Subject</label>
<div class="row" style="align-items:center;gap:8px;margin-bottom:14px">
<div style="flex:1;background:var(--paper);border:1px solid var(--line);border-radius:9px;padding:10px 12px;font-weight:600">${esc(data.subject)}</div>
<button class="btn ghost copy-subject" style="padding:7px 12px">📋 Copy</button>
</div>
${data.preheader ? `
<label>Preheader</label>
<div class="row" style="align-items:center;gap:8px;margin-bottom:14px">
<div style="flex:1;background:var(--paper);border:1px solid var(--line);border-radius:9px;padding:10px 12px;font-size:13px;color:var(--mut)">${esc(data.preheader)}</div>
<button class="btn ghost copy-preheader" style="padding:7px 12px">📋 Copy</button>
</div>` : ''}
<label>Body</label>
<pre style="white-space:pre-wrap;font:13px/1.6 'Inter',sans-serif;background:var(--paper);border:1px solid var(--line);border-radius:9px;padding:14px;margin:0 0 8px;max-height:400px;overflow:auto">${esc(data.body)}</pre>
<div class="row" style="justify-content:flex-end;gap:8px;margin-bottom:14px">
<button class="btn ghost copy-body" style="padding:7px 12px">📋 Copy body</button>
<button class="btn ghost copy-all" style="padding:7px 12px">📋 Copy subject + body</button>
</div>
<div class="muted-banner" style="margin:0">⚠ Before this ships in Constant Contact: ${esc(data.complianceReminder || 'add CAN-SPAM footer (physical mailing address + working unsubscribe).')}</div>
${unfilledNote}
</div>`;
const setCopier = (sel, text) => {
const b = target.querySelector(sel);
if (!b) return;
b.addEventListener('click', async () => {
const ok = await copyText(text);
const prev = b.textContent;
b.textContent = ok ? '✓ Copied' : 'Copy failed';
setTimeout(() => { b.textContent = prev; }, 1400);
});
};
setCopier('.copy-subject', data.subject || '');
setCopier('.copy-preheader', data.preheader || '');
setCopier('.copy-body', data.body || '');
setCopier('.copy-all', `Subject: ${data.subject || ''}\n\n${data.body || ''}`);
}
async function copyText(text) {
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch { /* fall through to legacy */ }
try {
const ta = document.createElement('textarea');
ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.select();
const ok = document.execCommand('copy');
document.body.removeChild(ta);
return ok;
} catch { return false; }
}
},
};