← 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');

    // ── 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' });
    });
  },
};