← back to Marketing Command Center

public/quickpost.js

154 lines

// MCCQuickPost — the shared "easy post" component reused in 3 places (the Quick
// Post panel, the Compose panel, and each asset/video card). Renders a row of
// per-platform buttons; a click STAGES A DRAFT (gated) via /api/quickpost/draft
// and shows a toast. Live posting is intentionally NOT wired here — staging is
// the safe default that honors the "social posting is Steve-gated" rule.
(function () {
  const ORIGIN = location.origin;
  const PLATFORMS = [
    { id: 'instagram', label: 'Instagram', icon: '📷' },
    { id: 'tiktok',    label: 'TikTok',    icon: '🎵' },
    { id: 'facebook',  label: 'Facebook',  icon: '📘' },
    { id: 'linkedin',  label: 'LinkedIn',  icon: '💼' },
    { id: 'threads',   label: 'Threads',   icon: '🧵' },
    { id: 'bluesky',   label: 'Bluesky',   icon: '🦋' },
  ];
  // Live posting hands off to the Composer (targeted account picker + dry-run +
  // confirm) — Quick Post never fires a live post itself. This is the set of
  // platforms enabled for the live handoff (Steve, per-platform go).
  const LIVE_ENABLED = ['instagram', 'tiktok', 'facebook', 'linkedin', 'threads', 'bluesky'];

  // one-time CSS
  if (!document.getElementById('qp-css')) {
    const s = document.createElement('style'); s.id = 'qp-css';
    s.textContent = `
      .qp-row{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
      .qp-btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line,#e3ddd0);
        background:#fff;border-radius:9px;cursor:pointer;font:600 12px/1 Inter,sans-serif;color:var(--ink,#14110f);
        padding:8px 12px;transition:background .12s,border-color .12s}
      .qp-btn:hover{background:var(--cream,#f4efe7);border-color:var(--accent,#8a5a44)}
      .qp-btn.busy{opacity:.6;pointer-events:none}
      .qp-btn.ok{background:#e3efe0;border-color:#bcd8b6;color:#2f6b34}
      .qp-btn .qp-i{font-size:14px}
      .qp-btn.mini{padding:5px 8px;font-size:10.5px;border-radius:7px}
      .qp-all{border-style:dashed;color:var(--accent,#8a5a44)}
      .qp-btn.qp-live{border-color:var(--accent,#8a5a44);color:var(--accent,#8a5a44);font-weight:700}
      .qp-btn.qp-live:hover{background:var(--accent,#8a5a44);color:#fff}
      .qp-dot{width:8px;height:8px;border-radius:50%;display:inline-block;margin-left:4px;flex:none;background:#c9bfa8}
      .qp-dot.valid{background:#3a6b3a}
      .qp-dot.invalid{background:#c0563f}
      .qp-legend{font-size:10.5px;color:var(--mut,#8a8275);margin-top:6px;display:flex;gap:12px;flex-wrap:wrap}
      .qp-legend b{font-weight:600;color:var(--ink,#14110f)}
      .qp-hint{font-size:11px;color:var(--mut,#8a8275)}
      #qp-toast{position:fixed;right:18px;bottom:18px;z-index:9999;display:flex;flex-direction:column;gap:8px}
      #qp-toast .t{background:var(--ink,#14110f);color:#fff;border-radius:10px;padding:11px 15px;font:600 12.5px/1.3 Inter,sans-serif;
        box-shadow:0 10px 30px rgba(0,0,0,.25);opacity:0;transform:translateY(8px);transition:opacity .2s,transform .2s;max-width:340px}
      #qp-toast .t.show{opacity:1;transform:none}
      #qp-toast .t .g{color:var(--gold-soft,#d8c19a)}`;
    document.head.appendChild(s);
  }
  function toast(msg) {
    let box = document.getElementById('qp-toast');
    if (!box) { box = document.createElement('div'); box.id = 'qp-toast'; document.body.appendChild(box); }
    const t = document.createElement('div'); t.className = 't'; t.innerHTML = msg;
    box.appendChild(t);
    requestAnimationFrame(() => t.classList.add('show'));
    setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 250); }, 2600);
  }

  async function stage(platform, payload) {
    const r = await fetch(ORIGIN + '/api/quickpost/draft', {
      method: 'POST', credentials: 'same-origin',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ platform, ...payload }),
    });
    return r.json().catch(() => ({ error: 'bad response' }));
  }

  // Cached real token-validity per platform (GET /api/channels/health). One fetch
  // shared across every mount so the dots don't hammer the endpoint.
  let _healthPromise = null;
  function getHealth() {
    if (!_healthPromise) _healthPromise = fetch(ORIGIN + '/api/channels/health', { credentials: 'same-origin' })
      .then(r => r.json()).then(d => (d && d.platforms) || {}).catch(() => ({}));
    return _healthPromise;
  }
  function decorateHealth(mount) {
    getHealth().then(h => {
      mount.querySelectorAll('.qp-btn[data-qp]').forEach(btn => {
        const id = btn.getAttribute('data-qp');
        const st = h[id] && h[id].state;
        if (!st) return;
        const dot = document.createElement('span');
        dot.className = 'qp-dot ' + (st === 'valid' ? 'valid' : st === 'invalid' ? 'invalid' : '');
        dot.title = 'Token: ' + st;
        btn.appendChild(dot);
        btn.title = (btn.title || '') + ' · token ' + st;
      });
    });
  }

  // Render a button row into `mount`. getPayload(platform) → {caption, mediaUrl, source}.
  // opts: { mini:false, includeAll:true, live:false, health:true }
  function attach(mount, getPayload, opts) {
    opts = opts || {};
    const cls = opts.mini ? 'qp-btn mini' : 'qp-btn';
    let html = PLATFORMS.map(p =>
      `<button type="button" class="${cls}" data-qp="${p.id}" title="Stage a draft post to ${p.label}">` +
      `<span class="qp-i">${p.icon}</span>${opts.mini ? '' : p.label}</button>`).join('');
    if (opts.includeAll !== false && !opts.mini)
      html += `<button type="button" class="qp-btn qp-all" data-qp="__all" title="Stage a draft to all platforms">+ All</button>`;
    // Live handoff → opens the Composer prefilled (targeted account + confirm live there)
    if (opts.live) {
      const live = opts.mini
        ? `<button type="button" class="qp-btn mini qp-live" data-qp="__live" title="Publish live in Composer (pick account + confirm there)">↗ Live</button>`
        : `<button type="button" class="qp-btn qp-live" data-qp="__live" title="Publish live in Composer — pick the exact account + confirm there">↗ Publish live in Composer…</button>`;
      html += live;
    }
    mount.insertAdjacentHTML('beforeend', `<div class="qp-row">${html}</div>`);

    mount.querySelectorAll('[data-qp]').forEach(btn => btn.addEventListener('click', async () => {
      const which = btn.getAttribute('data-qp');
      const payload = (typeof getPayload === 'function' ? getPayload() : getPayload) || {};
      // LIVE handoff — hand media+caption to the Composer, preselect the enabled
      // platforms, switch there. The human picks the exact Page/IG account, does a
      // dry-run, and confirms in the Composer. Quick Post never posts live itself.
      if (which === '__live') {
        if (!payload.mediaUrl && !payload.caption) { toast('Nothing to hand off — add a caption or pick media first.'); return; }
        try {
          sessionStorage.setItem('mccComposePrefill', JSON.stringify({
            caption: payload.caption || '', mediaUrl: payload.mediaUrl || '',
            channels: LIVE_ENABLED, source: payload.source || 'quickpost', ts: Date.now(),
          }));
        } catch (_) {}
        toast('↗ Opening Composer — pick the account, then <span class="g">dry-run &amp; confirm</span> to post live.');
        location.hash = 'compose';
        return;
      }
      const targets = which === '__all' ? PLATFORMS.map(p => p.id) : [which];
      if (!payload.mediaUrl && !payload.caption) { toast('Nothing to post — add a caption or pick media first.'); return; }
      btn.classList.add('busy'); const orig = btn.innerHTML;
      let ok = 0;
      for (const pl of targets) { const res = await stage(pl, payload); if (res && res.ok) ok++; }
      btn.classList.remove('busy'); btn.classList.add('ok');
      btn.innerHTML = '✓ Staged';
      const label = which === '__all' ? `all ${ok} platforms` : PLATFORMS.find(p => p.id === which).label;
      toast(`🚀 Draft staged for <span class="g">${label}</span> — awaiting your approval.`);
      setTimeout(() => { btn.classList.remove('ok'); btn.innerHTML = orig; }, 1600);
      document.dispatchEvent(new CustomEvent('qp:staged'));
    }));

    // token-validity dots (green=valid · red=expired · grey=unverified/not set) —
    // shown on the full (non-mini) rows so tiny asset-card buttons stay clean.
    if (opts.health !== false && !opts.mini) {
      decorateHealth(mount);
      mount.insertAdjacentHTML('beforeend',
        `<div class="qp-legend"><span><span class="qp-dot valid"></span> token valid</span>` +
        `<span><span class="qp-dot invalid"></span> expired</span>` +
        `<span><span class="qp-dot"></span> not set / unverified</span></div>`);
    }
  }

  window.MCCQuickPost = { PLATFORMS, attach, stage, toast, getHealth };
})();