← back to Marketing Command Center

public/panels/board.js

205 lines

window.MCC_PANELS = window.MCC_PANELS || {};
// Branded "media unavailable" tile shown when a post image fails to load (e.g. an
// expired IG/fbcdn signed URL that 403s). Inline data-URI so it needs no request;
// URL-encoded so it can drop straight into an <img src>. Reads as intentional,
// not blank. The durable fix is lib/media-cache (localizes fresh URLs); this is
// the graceful fallback for images that were already dead before caching.
const PLACEHOLDER_SVG = encodeURIComponent(
  `<svg xmlns="http://www.w3.org/2000/svg" width="400" height="220" viewBox="0 0 400 220">` +
  `<rect width="400" height="220" fill="#f4f1ea"/>` +
  `<rect x="0" y="0" width="400" height="220" fill="url(#g)" opacity="0.5"/>` +
  `<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#efe9dc"/><stop offset="1" stop-color="#e2dccb"/></linearGradient></defs>` +
  `<g fill="none" stroke="#b8ad93" stroke-width="2"><rect x="163" y="86" width="74" height="52" rx="6"/><circle cx="200" cy="112" r="13"/><path d="M175 86l7-10h36l7 10"/></g>` +
  `<text x="200" y="170" font-family="Georgia,serif" font-size="13" fill="#9a8f76" text-anchor="middle">image unavailable</text></svg>`
);
window.MCC_PANELS['board'] = {
  async init(root) {
    const ORIGIN = location.origin;
    const $ = s => root.querySelector(s);
    const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
    const jget = async u => { try { const r = await fetch(ORIGIN + u, { credentials: 'same-origin' }); return r.ok ? await r.json() : null; } catch { return null; } };
    // created date + time in the admin's local tz (admin-card convention)
    const fmtWhen = iso => { if (!iso) return ''; const d = new Date(iso); return isNaN(d) ? '' : d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); };
    const pickDate = o => o && (o.at || o.created_at || o.createdAt || o.date || o.scheduled_at || o.scheduledAt || o.scheduled || o.send_time || o.when || '');
    const pickText = o => o && (o.caption || o.body || o.text || o.message || o.subject || o.name || o.title || '');

    // persisted controls
    const board = $('#bd-board');
    const setWidth = w => { board.style.setProperty('--bd-w', w + 'px'); };
    let WIDTH = +(localStorage.getItem('mcc_board_w') || 300);
    let SORT = localStorage.getItem('mcc_board_sort') || 'newest';
    $('#bd-width').value = WIDTH; setWidth(WIDTH);
    $('#bd-sort').value = SORT;

    // ── IG re-pull health strip (job that keeps published-IG thumbnails durable) ──
    // Inserted once above the board; shows last-run time + result + a health dot,
    // and a "Run now" fix-it button. Amber if stale (>36h) or never run, red on
    // Graph/download errors, green when the last run completed clean.
    if (!$('#bd-repull-health')) {
      const strip = document.createElement('div');
      strip.id = 'bd-repull-health';
      strip.style.cssText = 'display:flex;align-items:center;gap:10px;margin:0 0 10px;padding:7px 12px;border:1px solid #e4ddcd;border-radius:8px;background:#faf7f0;font-size:12.5px;color:#6b6350';
      board.parentNode.insertBefore(strip, board);
    }
    const healthEl = $('#bd-repull-health');
    async function loadRepullHealth() {
      if (!healthEl) return;
      const s = await jget('/api/channels/ig-repull-status') || {};
      const ageH = s.ranAt ? (Date.now() - new Date(s.ranAt).getTime()) / 3600000 : Infinity;
      let dot = '#4a8f5f', label; // green
      if (!s.ranAt) { dot = '#c9a227'; label = 'never run'; }
      else if (s.ok === false) { dot = '#c0392b'; label = `error (graphErr ${s.graphErr}, dead ${s.downloadDead})`; }
      else if (ageH > 36) { dot = '#c9a227'; label = `stale — last ran ${fmtWhen(s.ranAt)}`; }
      else label = `ran ${fmtWhen(s.ranAt)} · ${s.recovered || 0} recovered`;
      healthEl.innerHTML =
        `<span style="width:9px;height:9px;border-radius:50%;background:${dot};flex:0 0 auto"></span>` +
        `<span style="flex:1 1 auto">🔄 IG image re-pull · <strong>${esc(label)}</strong>${s.unrecoverable ? ` · ${s.unrecoverable} unrecoverable` : ''}</span>` +
        `<button id="bd-repull-run" class="btn" style="font-size:12px;padding:3px 10px">Run now</button>`;
      $('#bd-repull-run').addEventListener('click', async () => {
        const btn = $('#bd-repull-run'); btn.disabled = true; btn.textContent = '…running';
        try {
          const r = await fetch(ORIGIN + '/api/channels/ig-repull-run', { method: 'POST', credentials: 'same-origin' });
          if (r.status === 409) { btn.textContent = 'already running'; }
          else { await new Promise(res => setTimeout(res, 4000)); await loadRepullHealth(); await load(); return; }
        } catch { btn.textContent = 'failed'; }
        setTimeout(() => { if ($('#bd-repull-run')) { $('#bd-repull-run').disabled = false; $('#bd-repull-run').textContent = 'Run now'; } }, 2500);
      });
    }

    let DATA = {};
    async function load() {
      board.innerHTML = '<div class="loading">Loading streams…</div>';
      const [cols, status, outbox, cc, ccCal, li] = await Promise.all([
        jget('/api/board/columns'),
        jget('/api/channels/status'),
        jget('/api/channels/outbox'),
        jget('/api/constant-contact/campaigns'),
        jget('/api/constant-contact/calendar'),
        jget('/api/linkedin/drafts'),
      ]);
      DATA = {
        columns: (cols && cols.columns) || [],
        platforms: (status && status.platforms) || {},
        outbox: (outbox && outbox.items) || [],
        campaigns: (cc && (cc.campaigns || cc.items)) || [],
        ccCal: (ccCal && (ccCal.items || ccCal.campaigns || ccCal.events)) || [],
        drafts: (li && li.drafts) || [],
      };
      render();
    }

    function sortItems(arr) {
      const a = arr.slice();
      a.sort((x, y) => {
        const dx = pickDate(x) || '', dy = pickDate(y) || '';
        return SORT === 'oldest' ? String(dx).localeCompare(dy) : String(dy).localeCompare(dx);
      });
      return a;
    }

    function statusClass(s) {
      s = (s || '').toLowerCase();
      if (s.includes('post')) return 'posted';
      if (s.includes('fail')) return 'failed';
      if (s.includes('draft')) return 'draft';
      if (s.includes('schedul')) return 'staged';
      return 'pending';
    }

    function cardHTML({ when, text, media, status, metric }) {
      const w = fmtWhen(when);
      return `<div class="bd-card">
        ${w ? `<div class="when" title="${esc(when)}">🕓 ${esc(w)}</div>` : ''}
        <div class="txt">${esc((text || '').slice(0, 220)) || '<span class="bd-metric">(no text)</span>'}</div>
        ${media ? `<img class="media" src="${esc(media)}" loading="lazy" onerror="this.onerror=null;this.classList.add('media-missing');this.src='data:image/svg+xml;utf8,${PLACEHOLDER_SVG}'">` : ''}
        <div class="foot">
          ${metric ? `<span class="bd-metric">${esc(metric)}</span>` : '<span></span>'}
          ${status ? `<span class="bd-status ${statusClass(status)}">${esc(status)}</span>` : ''}
        </div>
      </div>`;
    }

    // build the cards + header pill for one column
    function columnContent(col) {
      // ── Threads / Bluesky: no publish path yet ──
      if (col.soon) {
        return { pill: { cls: 'soon', txt: 'Coming soon' }, count: 0,
          body: `<div class="bd-soonmsg">🔌 <b>${esc(col.label)}</b> isn't wired for publishing yet.<br>The column is here so it slots in the moment the connector lands.</div>` };
      }
      // ── LinkedIn: drafts from the linkedin module ──
      if (col.kind === 'linkedin') {
        const items = sortItems(DATA.drafts);
        const body = items.length
          ? items.map(d => cardHTML({ when: pickDate(d), text: pickText(d), status: d.status || 'draft' })).join('')
          : `<div class="bd-empty">No LinkedIn drafts yet.</div>`;
        return { pill: { cls: 'stage', txt: 'Drafts · manual' }, count: items.length, body };
      }
      // ── Email: Constant Contact campaigns (reports) + scheduled calendar ──
      if (col.kind === 'email') {
        const cal = sortItems(DATA.ccCal).map(e => cardHTML({
          when: pickDate(e), text: pickText(e), status: 'scheduled',
        }));
        const camps = DATA.campaigns.slice(0, 40).map(c => cardHTML({
          when: pickDate(c), text: c.name || pickText(c),
          metric: [c.sends != null ? c.sends + ' sent' : null,
                   c.opens != null ? c.opens + ' opens' : null,
                   c.open_rate != null ? Math.round(c.open_rate * 100) + '% open' : null].filter(Boolean).join(' · '),
          status: 'report',
        }));
        const all = cal.concat(camps);
        const has = DATA.campaigns.length || DATA.ccCal.length;
        return { pill: { cls: has ? 'on' : 'stage', txt: has ? 'Reports loaded' : 'Import CSV' },
          count: all.length, body: all.length ? all.join('') : `<div class="bd-empty">No campaigns yet — import a CSV in the Email panel.</div>` };
      }
      // ── Social: facebook / instagram / tiktok / youtube from the channels outbox ──
      const pf = DATA.platforms[col.id];
      const items = sortItems(DATA.outbox.filter(it => (it.channel || '').toLowerCase() === col.id));
      let pill;
      if (pf && pf.connected) pill = { cls: 'on', txt: 'Connected · live' };
      else if (pf) pill = { cls: 'stage', txt: 'Stages (not connected)' };
      else pill = { cls: 'off', txt: 'Not connected' };
      const body = items.length
        ? items.map(it => cardHTML({ when: it.at, text: it.caption, media: it.mediaUrl, status: it.status })).join('')
        : `<div class="bd-empty">Nothing queued for ${esc(col.label)}.</div>`;
      return { pill, count: items.length, body };
    }

    function render() {
      if (!DATA.columns.length) { board.innerHTML = `<div class="muted-banner">No columns configured.</div>`; return; }
      board.innerHTML = DATA.columns.map(col => {
        const c = columnContent(col);
        return `<div class="bd-col ${col.soon ? 'soon' : ''}">
          <div class="bd-colhead">
            <div class="row1"><span class="ico">${col.icon || '▪'}</span>
              <span class="nm">${esc(col.label)}</span>
              <span class="cnt">${c.count}</span></div>
            <span class="bd-pill ${c.pill.cls}">${esc(c.pill.txt)}</span>
          </div>
          <div class="bd-body">${c.body}</div>
        </div>`;
      }).join('');
    }

    // controls
    $('#bd-width').addEventListener('input', e => { WIDTH = +e.target.value; setWidth(WIDTH); localStorage.setItem('mcc_board_w', WIDTH); });
    $('#bd-sort').addEventListener('change', e => { SORT = e.target.value; localStorage.setItem('mcc_board_sort', SORT); render(); });
    $('#bd-refresh').addEventListener('click', load);
    // Clear staged/pending/failed cards (keeps posted history), then reload the board.
    $('#bd-clear').addEventListener('click', async () => {
      const btn = $('#bd-clear'); const label = btn.textContent;
      if (!confirm('Remove all staged / pending / failed cards? (Posted history is kept.)')) return;
      btn.disabled = true; btn.textContent = '…clearing';
      try {
        const r = await fetch(ORIGIN + '/api/channels/outbox/clear', {
          method: 'POST', credentials: 'same-origin',
          headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ scope: 'staged' }),
        });
        await r.json().catch(() => ({}));
      } finally { btn.disabled = false; btn.textContent = label; await load(); }
    });

    await load();
    loadRepullHealth();
  },
};