← back to Marketing Command Center

public/panels/social.js

352 lines

// Social Scheduler panel — queue board grouped by status + add/edit form,
// gated dry-run publish, and a curated best-times helper card.
// Registers into the shell's MCC_PANELS map (see public/app.js).
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['social'] = {
  init(root) {
    const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));

    const CHAN_ICON = { instagram: '📷', tiktok: '🎵', pinterest: '📌', facebook: '👍' };
    const CHAN_NAME = { instagram: 'Instagram', tiktok: 'TikTok', pinterest: 'Pinterest', facebook: 'Facebook' };
    // Approval pipeline: Save draft → Submit for approval → Steve sign-off → Publish.
    const COLUMNS = [
      { status: 'idea', title: 'Ideas' },
      { status: 'drafted', title: 'Drafted' },
      { status: 'pending-approval', title: 'Pending approval' },
      { status: 'approved', title: 'Approved · ready to publish' },
      { status: 'posted', title: 'Posted' },
    ];

    const $ = sel => root.querySelector(sel);
    const board = $('#soc-board');
    const form = $('#soc-form');
    const cardTpl = $('#soc-card-tpl');

    // Whether a real social backend is connected (so the UI tells the truth about
    // live vs simulated instead of always saying "dry-run"). Filled by loadStatus().
    const state = { posts: [], editingId: null, live: false, backend: null, statusNote: '' };

    async function load() {
      try {
        const r = await fetch('/api/social/posts').then(r => r.json());
        state.posts = (r && r.posts) || [];
      } catch (_) { state.posts = []; }
      renderBoard();
    }

    // Pull live/simulated state and update the banner so it's accurate.
    async function loadStatus() {
      try {
        const s = await fetch('/api/social/status').then(r => r.json());
        state.live = !!s.live; state.backend = s.backend || null; state.statusNote = s.note || '';
      } catch (_) { state.live = false; }
      const banner = root.querySelector('.muted-banner');
      if (banner) {
        banner.innerHTML = state.live
          ? `<b>● LIVE</b> — backend <b>${esc(state.backend)}</b> connected. Approved + confirmed posts go to <b>real accounts</b>. Pipeline: Save draft → Submit for approval → Steve sign-off ✓ → Publish.`
          : `<b>● SIMULATED</b> — no social backend connected, so a publish <b>stages only</b> (nothing is sent). ${esc(state.statusNote)} Pipeline still works end-to-end: Save draft → Submit for approval → Steve sign-off ✓ → Publish.`;
        banner.style.borderLeft = state.live ? '3px solid #3a8a4a' : '3px solid #c79a3a';
        banner.style.paddingLeft = '10px';
      }
    }

    function whenLabel(p) {
      if (!p.date && !p.time) return 'no date';
      let out = '';
      if (p.date) {
        const d = new Date(p.date + 'T00:00:00');
        out = isNaN(d) ? p.date : d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
      }
      if (p.time) out = (out ? out + ' · ' : '') + p.time;
      return out;
    }

    function renderBoard() {
      board.innerHTML = '';
      for (const col of COLUMNS) {
        const items = state.posts.filter(p => p.status === col.status);
        const el = document.createElement('div');
        el.className = 'soc-col';
        el.innerHTML = `<h3>${esc(col.title)}<span class="ct">${items.length}</span></h3>`;
        if (!items.length) {
          const e = document.createElement('div');
          e.className = 'soc-empty';
          e.textContent = '—';
          el.appendChild(e);
        } else {
          for (const p of items) el.appendChild(buildCard(p));
        }
        board.appendChild(el);
      }
    }

    function buildCard(p) {
      const node = cardTpl.content.firstElementChild.cloneNode(true);
      node.querySelector('.sc-chan').textContent =
        `${CHAN_ICON[p.channel] || '•'} ${CHAN_NAME[p.channel] || p.channel}`;
      node.querySelector('.sc-when').textContent = whenLabel(p);

      const cap = node.querySelector('.sc-cap');
      cap.textContent = p.caption || '(no caption yet)';
      if (!p.caption) cap.classList.add('muted');

      const tags = node.querySelector('.sc-tags');
      (p.hashtags || []).slice(0, 12).forEach(t => {
        const s = document.createElement('span');
        s.className = 'pill';
        s.textContent = t;
        tags.appendChild(s);
      });

      // Status-driven pipeline action on each card:
      //   drafted → Submit for approval · pending-approval → Sign off ✓ (Steve) · approved → Publish
      const pub = node.querySelector('.sc-pub');
      if (p.status === 'drafted') {
        pub.className = 'btn ghost sc-pub';
        pub.textContent = 'Submit for approval →';
        pub.title = 'Move into the approval queue';
        pub.addEventListener('click', () => transition(p, 'submit'));
      } else if (p.status === 'pending-approval') {
        pub.className = 'btn gold sc-pub';
        pub.textContent = 'Sign off ✓';
        pub.title = "Steve's approval — clears this post for live publishing";
        pub.addEventListener('click', () => transition(p, 'approve'));
      } else if (p.status === 'approved' || p.status === 'scheduled') {
        pub.className = 'btn gated sc-pub';
        pub.textContent = state.live ? 'Publish (live)…' : 'Publish (staged)…';
        pub.title = state.live ? 'Gated — posts to the real account' : 'Gated — stages only (no backend connected)';
        pub.addEventListener('click', () => publishFlow(p, node));
      } else {
        pub.remove(); // idea / posted → no pipeline action
      }

      node.querySelector('.sc-edit').addEventListener('click', () => startEdit(p));
      node.querySelector('.sc-del').addEventListener('click', () => removePost(p.id));
      return node;
    }

    // ── pipeline transitions: Submit for approval / Steve sign-off ───────────
    async function transition(p, action) {
      if (action === 'approve' && !confirm(
        `Sign off on this ${CHAN_NAME[p.channel] || p.channel} post?\n\n` +
        `This is Steve's approval — it clears the post for live publishing.`)) return;
      try {
        await fetch(`/api/social/${action}`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ id: p.id }),
        });
      } catch (_) {}
      await load();
    }

    // ── gated publish: dry-run → confirm → real publish (or stage if no backend)
    async function publishFlow(p, node) {
      const box = node.querySelector('.sc-result');
      box.hidden = false;
      box.classList.remove('staged');
      box.textContent = 'Running dry run…';

      let dry;
      try {
        dry = await fetch('/api/social/publish', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ id: p.id, dryRun: true }),
        }).then(r => r.json());
      } catch (_) {
        box.textContent = 'Could not reach the publish endpoint.';
        return;
      }
      if (!dry || !dry.ok) {
        box.textContent = (dry && dry.error) || 'Dry run failed.';
        return;
      }

      const w = dry.would || {};
      const summary = `Post to ${CHAN_NAME[w.channel] || w.channel} at ${w.when}.`;
      const prompt = state.live
        ? `${summary}\n\n⚠️ LIVE — this posts to the REAL ${CHAN_NAME[w.channel] || w.channel} account right now. This cannot be undone from here.\n\nPublish for real?`
        : `${summary}\n\nThis is a GATED action. No social backend is connected, so it will be STAGED only — nothing goes to a live account.\n\nStage this publish?`;
      if (!confirm(prompt)) {
        box.textContent = state.live ? 'Publish cancelled. Nothing was sent.' : 'Dry run only — staging cancelled. Nothing was sent.';
        return;
      }

      box.textContent = state.live ? 'Publishing…' : 'Staging…';
      let res;
      try {
        res = await fetch('/api/social/publish', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ id: p.id, dryRun: false, confirm: true }),
        }).then(r => r.json());
      } catch (_) {
        box.textContent = 'Staging request failed.';
        return;
      }
      if (res && res.staged) {
        box.classList.add('staged');
        box.textContent = res.message || 'Staged — connect a social backend to enable live posting.';
      } else if (res && res.live && res.ok) {
        box.textContent = '✓ Posted live to the real account.';
        await load(); // moves the card into Posted
      } else if (res && res.live && !res.ok) {
        const err = res.result && (res.result.error || (res.result.message)) || res.error;
        box.textContent = 'Live publish failed: ' + (typeof err === 'string' ? err : JSON.stringify(err || 'unknown'));
      } else {
        box.textContent = (res && (res.message || res.error)) || 'Unexpected response.';
      }
    }

    async function removePost(id) {
      if (!confirm('Remove this post from the queue?')) return;
      try {
        await fetch(`/api/social/posts?delete=${encodeURIComponent(id)}`, { method: 'POST' });
      } catch (_) {}
      if (state.editingId === id) resetForm();
      await load();
    }

    // ── add / edit form ──────────────────────────────────────────────────────
    function startEdit(p) {
      state.editingId = p.id;
      form.channel.value = p.channel || 'instagram';
      form.date.value = p.date || '';
      form.time.value = p.time || '';
      form.status.value = p.status || 'idea';
      form.caption.value = p.caption || '';
      form.hashtags.value = (p.hashtags || []).join(' ');
      form.imageUrl.value = p.imageUrl || '';
      form.notes.value = p.notes || '';
      $('#soc-save').textContent = 'Save changes';
      $('#soc-editing').textContent = 'Editing existing post';
      form.scrollIntoView({ behavior: 'smooth', block: 'start' });
      form.caption.focus();
    }

    function resetForm() {
      state.editingId = null;
      form.reset();
      $('#soc-save').textContent = 'Add to queue';
      $('#soc-editing').textContent = '';
    }

    form.addEventListener('submit', async e => {
      e.preventDefault();
      const fd = new FormData(form);
      const body = {
        id: state.editingId || undefined,
        channel: fd.get('channel'),
        date: (fd.get('date') || '').toString(),
        time: (fd.get('time') || '').toString(),
        status: fd.get('status'),
        caption: (fd.get('caption') || '').toString().trim(),
        hashtags: (fd.get('hashtags') || '').toString(),
        imageUrl: (fd.get('imageUrl') || '').toString().trim(),
        notes: (fd.get('notes') || '').toString().trim(),
      };
      const btn = $('#soc-save');
      btn.disabled = true;
      const prev = btn.textContent;
      btn.textContent = 'Saving…';
      try {
        await fetch('/api/social/posts', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(body),
        });
      } catch (_) {}
      btn.disabled = false;
      btn.textContent = prev;
      resetForm();
      await load();
    });

    $('#soc-reset').addEventListener('click', resetForm);

    // ── Instant post: Save + auto-approve + publish in one gated step ─────────
    $('#soc-instant').addEventListener('click', async () => {
      const fd = new FormData(form);
      const channel = fd.get('channel');
      const caption = (fd.get('caption') || '').toString().trim();
      const box = $('#soc-instant-result');
      box.hidden = false; box.classList.remove('staged');
      if (!caption) { box.textContent = 'Add a caption first.'; return; }

      // 1) create/update the post, pre-approved (instant skips the queue stages).
      box.textContent = 'Saving…';
      let entry;
      try {
        const r = await fetch('/api/social/posts', {
          method: 'POST', headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            id: state.editingId || undefined, channel,
            date: (fd.get('date') || '').toString(), time: (fd.get('time') || '').toString(),
            caption, hashtags: (fd.get('hashtags') || '').toString(),
            imageUrl: (fd.get('imageUrl') || '').toString().trim(),
            notes: (fd.get('notes') || '').toString().trim(),
            status: 'approved', approved: true,
          }),
        }).then(r => r.json());
        entry = r && r.entry;
      } catch (_) {}
      if (!entry) { box.textContent = 'Could not save the post.'; return; }

      // 2) gated confirm — truthful about live vs staged.
      const where = CHAN_NAME[channel] || channel;
      const prompt = state.live
        ? `⚡ INSTANT LIVE POST\n\nThis posts to the REAL ${where} account right now. This cannot be undone.\n\nPost now?`
        : `⚡ Instant post\n\nNo backend connected, so this STAGES only — nothing is sent to ${where}.\n\nStage it?`;
      if (!confirm(prompt)) { box.textContent = 'Cancelled — saved to the board as Approved.'; resetForm(); await load(); return; }

      // 3) publish (confirm + approve inline).
      box.textContent = state.live ? 'Publishing…' : 'Staging…';
      let res;
      try {
        res = await fetch('/api/social/publish', {
          method: 'POST', headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ id: entry.id, dryRun: false, confirm: true, approve: true }),
        }).then(r => r.json());
      } catch (_) { box.textContent = 'Publish request failed.'; return; }

      if (res && res.staged) { box.classList.add('staged'); box.textContent = res.message || 'Staged — no backend connected.'; }
      else if (res && res.live && res.ok) box.textContent = '✓ Posted live to the real account.';
      else if (res && res.live && !res.ok) { const e = res.result && (res.result.error || res.result.message) || res.error; box.textContent = 'Live publish failed: ' + (typeof e === 'string' ? e : JSON.stringify(e || 'unknown')); }
      else box.textContent = (res && (res.message || res.error)) || 'Unexpected response.';
      resetForm();
      await load();
    });

    // ── best times helper ────────────────────────────────────────────────────
    async function loadBestTimes() {
      const box = $('#soc-besttimes');
      let data;
      try {
        data = await fetch('/api/social/best-times').then(r => r.json());
      } catch (_) {
        box.innerHTML = `<div class="cal-empty muted">Could not load guidance.</div>`;
        return;
      }
      const chans = (data && data.channels) || [];
      if (!chans.length) {
        box.innerHTML = `<div class="cal-empty muted">No guidance available.</div>`;
        return;
      }
      box.innerHTML = chans.map(c => {
        const wins = (c.windows || []).map(w => `<span class="pill">${esc(w)}</span>`).join('');
        return `<div class="bt-row">
            <div class="bh">${CHAN_ICON[c.channel] || '•'} ${esc(c.label || c.channel)}</div>
            <div class="bt-win">${wins}</div>
            <div class="bt-note">${esc(c.note || '')}</div>
          </div>`;
      }).join('');
    }

    loadStatus();
    load();
    loadBestTimes();
  },
};