← back to Marketing Command Center
public/panels/copy.js
270 lines
// copy panel — on-brand marketing copy generator.
// Loads presets, builds the form, POSTs /api/copy/generate, renders variants as
// copy-to-clipboard cards. Banner shows when running in templates (mock) mode.
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['copy'] = {
async init(root) {
const $ = s => root.querySelector(s);
const esc = s => String(s == null ? '' : s)
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
// ── presets → populate selects ───────────────────────────────────────────
let presets;
try {
presets = await (await fetch('/api/copy/presets')).json();
} catch (e) {
$('#copy-out').innerHTML = '<div class="muted-banner">Could not reach the copy service.</div>';
return;
}
const fill = (sel, items, { value = x => x, label = x => x, lead } = {}) => {
const el = $(sel);
if (!el) return;
el.innerHTML =
(lead ? `<option value="any">${lead}</option>` : '') +
items.map(it => `<option value="${esc(value(it))}">${esc(label(it))}</option>`).join('');
};
fill('#copy-kind', presets.kinds || [], { value: k => k.id, label: k => k.label });
fill('#copy-product', presets.products || [], { label: p => cap(p), lead: 'Any / not product-specific' });
fill('#copy-audience', presets.audiences || [], { lead: 'Any audience' });
fill('#copy-tone', presets.tones || [], { label: t => cap(t) });
function cap(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; }
// ── count slider live label ──────────────────────────────────────────────
const nEl = $('#copy-n'), nVal = $('#copy-n-val');
nEl.addEventListener('input', () => { nVal.textContent = nEl.value; });
// ── generate ─────────────────────────────────────────────────────────────
const out = $('#copy-out');
const banner = $('#copy-banner');
const goBtn = $('#copy-go');
async function generate() {
const payload = {
kind: $('#copy-kind').value,
topic: $('#copy-topic').value.trim(),
product: $('#copy-product').value,
audience: $('#copy-audience').value,
tone: $('#copy-tone').value,
n: parseInt(nEl.value, 10) || 3,
};
goBtn.disabled = true;
const orig = goBtn.textContent;
goBtn.textContent = 'Writing…';
out.innerHTML = '<div class="loading">Drafting copy…</div>';
try {
const r = await fetch('/api/copy/generate', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
});
const data = await r.json();
render(data, payload.kind);
} catch (e) {
out.innerHTML = '<div class="muted-banner">Generation failed — please try again.</div>';
} finally {
goBtn.disabled = false;
goBtn.textContent = orig;
}
}
function render(data, kind) {
const variants = (data && data.variants) || [];
banner.innerHTML = data && data.mock
? '<div class="muted-banner">Templates mode — add <b>ANTHROPIC_API_KEY</b> for AI-written copy. These are crafted brand-voice templates.</div>'
: '';
if (!variants.length) {
out.innerHTML = '<div class="muted-banner">No variants returned. Try a different topic.</div>';
return;
}
out.innerHTML = variants.map((v, i) => {
const text = v.text || '';
const meta = v.meta || '';
const footer = (kind === 'email' || v.footerRequired)
? '<div class="pill" style="margin-top:8px;background:#fff0e6;color:#8a5a44">⚠ Add CAN-SPAM footer (physical mailing address + working unsubscribe) before sending</div>'
: '';
return `<div class="card" style="margin-bottom:0">
<div class="row" style="justify-content:space-between;align-items:center;margin-bottom:8px">
<span class="pill">Variant ${i + 1}</span>
<button class="btn ghost copy-btn" data-i="${i}" style="padding:5px 12px">📋 Copy</button>
</div>
<div style="white-space:pre-wrap;font-size:14px;line-height:1.55">${esc(text)}</div>
${meta ? `<div class="muted" style="margin-top:8px;font-size:12px">${esc(meta)}</div>` : ''}
${footer}
</div>`;
}).join('');
out.querySelectorAll('.copy-btn').forEach(btn => {
btn.addEventListener('click', async () => {
const v = variants[+btn.dataset.i];
const ok = await copyText(v && v.text ? v.text : '');
const prev = btn.textContent;
btn.textContent = ok ? '✓ Copied' : 'Copy failed';
setTimeout(() => { btn.textContent = prev; }, 1400);
});
});
}
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; }
}
goBtn.addEventListener('click', generate);
$('#copy-topic').addEventListener('keydown', e => { if (e.key === 'Enter') generate(); });
// Initial banner reflecting whether the LLM key is configured.
if (presets && presets.llm === false) {
banner.innerHTML = '<div class="muted-banner">Templates mode — add <b>ANTHROPIC_API_KEY</b> for AI-written copy.</div>';
}
// ── inline copy scoring ──────────────────────────────────────────────────
const scoreOut = $('#score-out');
const scoreBanner = $('#score-banner');
const scoreBtn = $('#score-go');
const scoreKind = $('#score-kind');
const scoreText = $('#score-text');
if (presets && presets.llm === false) {
scoreBanner.innerHTML = '<div class="muted-banner">Heuristic mode — add <b>GEMINI_API_KEY</b> for a model-graded second opinion.</div>';
}
function classColor(cls) {
return { excellent:'#3f7a4d', strong:'#6f7a63', fair:'#b8925a', weak:'#a26b48', poor:'#8a4a3a' }[cls] || '#8a8275';
}
function bar(label, pct, color) {
const p = Math.max(0, Math.min(100, +pct || 0));
return `<div style="margin-bottom:10px">
<div class="row" style="justify-content:space-between;margin-bottom:4px;font-size:12.5px">
<span style="font-weight:600">${esc(label)}</span><span class="muted">${p}%</span>
</div>
<div style="height:10px;background:#efe9dc;border-radius:999px;overflow:hidden">
<div style="width:${p}%;height:100%;background:${color}"></div>
</div>
</div>`;
}
function renderScore(data) {
if (!data || data.error) {
scoreOut.innerHTML = `<div class="muted-banner">${esc((data && data.error) || 'Could not score that copy.')}</div>`;
return;
}
const c = classColor(data.openClass);
const tone = data.tone || { reassurance:0, storytelling:0, urgency:0 };
const pers = data.personalization || { tokens:[], recommended:[], missing:[], coverage:0 };
const recList = (pers.recommended || []).map(r =>
`<span class="pill" style="background:${r.present ? '#e8f1ea' : '#fff0e6'};color:${r.present ? '#3f7a4d' : '#8a5a44'};margin-right:6px">${r.present ? '✓' : '○'} ${esc(r.label)}</span>`
).join('') || '<span class="muted">No personalization recommended for this format.</span>';
const tokenList = (pers.tokens || []).length
? '<div style="margin-top:6px">' + pers.tokens.map(t =>
`<code style="background:#f4efe7;border:1px solid var(--line);border-radius:6px;padding:2px 7px;margin:2px 4px 2px 0;display:inline-block;font-size:12px">${esc(t.raw)}<span class="muted" style="margin-left:5px">${esc(t.label)}</span></code>`
).join('') + '</div>'
: '<div class="muted" style="margin-top:6px;font-size:12.5px">No merge tags detected in this copy.</div>';
const flags = (data.flags || []).length
? '<ul style="margin:6px 0 0 18px;padding:0">' + data.flags.map(f => `<li style="color:#8a5a44">${esc(f)}</li>`).join('') + '</ul>'
: '';
const reasons = (data.reasons || []).slice(0, 6).map(r =>
`<li><b style="color:${r.d >= 0 ? '#3f7a4d' : '#8a4a3a'}">${r.d >= 0 ? '+' : ''}${r.d}</b> — ${esc(r.why)}</li>`
).join('');
const detail = data.detail
? `<div class="muted" style="font-size:12px;margin-top:6px">Heuristic: ${data.detail.heuristic.score} · Gemini: ${data.detail.gemini.score}</div>`
: '';
const sourceLabel = data.source === 'gemini+heuristic' ? '🧠 Gemini + heuristic' : '📐 Heuristic';
scoreOut.innerHTML = `
<div class="grid" style="grid-template-columns:1fr 2fr;align-items:flex-start;gap:20px">
<div class="card" style="margin:0;text-align:center;padding:24px 18px;border-color:${c}">
<div class="muted" style="font-size:11px;letter-spacing:1px;text-transform:uppercase">Predicted open</div>
<div style="font:600 56px/1 'Cormorant Garamond',Georgia,serif;color:${c};margin:6px 0 4px">${data.score}</div>
<div class="pill" style="background:${c};color:#fff">${esc(data.openClass)}</div>
${detail}
<div class="muted" style="margin-top:12px;font-size:11px">${sourceLabel}</div>
</div>
<div class="card" style="margin:0">
<div style="font-weight:600;margin-bottom:10px">Luxury tone breakdown</div>
${bar('Reassurance', tone.reassurance, '#6f7a63')}
${bar('Storytelling', tone.storytelling, '#b8925a')}
${bar('Urgency', tone.urgency, '#8a5a44')}
${tone.confidence === 'low' ? '<div class="muted" style="font-size:12px;margin-top:4px">⚠ Low signal — try adding more brand-voice words.</div>' : ''}
</div>
</div>
<div class="card" style="margin-top:16px">
<div style="font-weight:600;margin-bottom:6px">Personalization coverage <span class="muted" style="font-weight:400">— ${pers.coverage}%</span></div>
<div>${recList}</div>
${tokenList}
</div>
${(data.flags && data.flags.length) || reasons ? `
<div class="card" style="margin-top:16px">
${data.flags && data.flags.length ? `<div style="font-weight:600;margin-bottom:4px">Flags</div>${flags}` : ''}
${reasons ? `<div style="font-weight:600;margin:${data.flags && data.flags.length ? '12px' : '0'} 0 4px">Why this score</div><ul style="margin:0 0 0 18px;padding:0;font-size:13px">${reasons}</ul>` : ''}
</div>` : ''}
${data.notes ? `<div class="muted" style="font-size:12.5px;margin-top:10px">${esc(data.notes)}</div>` : ''}
`;
}
async function score() {
const text = scoreText.value;
if (!text.trim()) {
scoreOut.innerHTML = '<div class="muted-banner">Paste some copy to score.</div>';
return;
}
scoreBtn.disabled = true;
const orig = scoreBtn.textContent;
scoreBtn.textContent = 'Scoring…';
scoreOut.innerHTML = '<div class="loading">Reading the copy…</div>';
try {
const r = await fetch('/api/copy/score', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ kind: scoreKind.value, text }),
});
const data = await r.json();
if (!r.ok) {
scoreOut.innerHTML = `<div class="muted-banner">${esc(data.error || ('Score failed: HTTP ' + r.status))}</div>`;
} else {
renderScore(data);
}
} catch (e) {
scoreOut.innerHTML = '<div class="muted-banner">Could not reach the scoring service.</div>';
} finally {
scoreBtn.disabled = false;
scoreBtn.textContent = orig;
}
}
scoreBtn.addEventListener('click', score);
// Lightweight "use last generated variant" — if the user clicks a generated
// card's text, drop it straight into the score box. (Wires in generically
// after each render via event delegation on the output container.)
out.addEventListener('click', e => {
const card = e.target.closest('.card');
if (!card || e.target.closest('.copy-btn')) return;
const txt = card.querySelector('[style*="white-space:pre-wrap"]');
if (!txt) return;
scoreText.value = txt.textContent || '';
const kindVal = $('#copy-kind').value;
if (Array.from(scoreKind.options).some(o => o.value === kindVal)) {
scoreKind.value = kindVal;
}
scoreText.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
},
};