← back to Marketing Command Center

public/panels/channels.js

243 lines

window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['channels'] = {
  async init(root) {
    const ORIGIN = location.origin;
    const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
    const $ = s => root.querySelector(s);
    const jget = async u => (await fetch(ORIGIN + u, { credentials: 'same-origin' })).json();
    let status = {};
    // 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' }); };

    async function loadStatus() {
      status = await jget('/api/channels/status');
      const ps = status.platforms || {};
      $('#ch-banner').innerHTML = `${status.connectedCount}/${status.total} channels connected · ${status.outbox} in outbox. ` +
        (status.connectedCount === 0 ? 'No channels connected yet — activate each platform below to enable live posting.' : 'Connected channels post live; the rest stage.');
      $('#ch-targets').innerHTML = Object.entries(ps).map(([k, p]) =>
        `<label style="display:flex;align-items:center;gap:6px;font-weight:600;color:var(--ink)" ${p.caveat ? `title="${esc(p.caveat)}"` : ''}><input type="checkbox" class="ch-t" value="${k}" style="width:auto"> ${p.icon} ${esc(p.label)}${p.connected ? '' : ' <span class="muted" style="font-weight:400">(stages)</span>'}${p.caveat ? ' <span class="muted" style="font-weight:400" title="' + esc(p.caveat) + '">⚠</span>' : ''}</label>`).join('');
      // the Page picker is only relevant for Facebook/Instagram → show it when either is ticked
      root.querySelectorAll('.ch-t').forEach(c => c.addEventListener('change', syncPagesVisibility));
      syncPagesVisibility();
    }

    // ── Page picker (select WHICH FB Pages / IG accounts to post to) ──────────
    const PAGE_KEY = 'mcc-ch-pages';
    let allPages = [], pagesLoaded = false;
    const selectedPages = new Set((() => { try { return JSON.parse(localStorage.getItem(PAGE_KEY) || '[]'); } catch { return []; } })());
    const savePageSel = () => { try { localStorage.setItem(PAGE_KEY, JSON.stringify([...selectedPages])); } catch {} };
    const metaSelected = () => [...root.querySelectorAll('.ch-t:checked')].some(c => c.value === 'facebook' || c.value === 'instagram');

    function syncPagesVisibility() {
      const wrap = $('#ch-pages-wrap'); if (!wrap) return;
      const show = metaSelected();
      wrap.style.display = show ? 'block' : 'none';
      if (show) { if (!pagesLoaded) loadPages(false); else renderPages(); } // re-render to reflect IG-vs-Pages mode
    }

    // IG-only mode = ONLY Instagram is ticked → reframe the picker as "Instagram
    // accounts" so you select 1-to-all IG accounts directly (each IG account is
    // reached via its linked Page, so selection is still keyed by page id).
    const igModeOn = () => metaSelected() && [...root.querySelectorAll('.ch-t:checked')].every(c => c.value === 'instagram');
    // The rows currently shown — honors the search box AND (in IG mode) the
    // IG-linked filter. All/None act on THIS list so "All" = all visible.
    function visiblePages() {
      const q = ($('#ch-pages-search') && $('#ch-pages-search').value.trim().toLowerCase()) || '';
      let list = allPages.filter(p => !q || (p.name || '').toLowerCase().includes(q) || ('@' + (p.igUsername || '')).toLowerCase().includes(q));
      if (igModeOn()) list = list.filter(p => p.hasIG); // IG posts can only go to IG-linked pages
      return list;
    }
    function renderPages() {
      const box = $('#ch-pages'); if (!box) return;
      const igOnly = igModeOn();
      const lbl = $('#ch-pages-label'); if (lbl) lbl.textContent = igOnly ? 'Instagram accounts' : 'Pages';
      const list = visiblePages();
      box.innerHTML = list.length ? list.map(p => igOnly
        ? `<label style="display:flex;align-items:center;gap:7px;padding:3px 4px;font-size:12.5px;border-radius:5px">
             <input type="checkbox" class="ch-pg" value="${esc(p.id)}" style="width:auto" ${selectedPages.has(p.id) ? 'checked' : ''}>
             <span style="font-weight:600;white-space:nowrap">📸 @${esc(p.igUsername || '')}</span>
             <span class="muted" style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px" title="via Page: ${esc(p.name)}">${esc(p.name)}</span>
           </label>`
        : `<label style="display:flex;align-items:center;gap:7px;padding:3px 4px;font-size:12.5px;border-radius:5px">
             <input type="checkbox" class="ch-pg" value="${esc(p.id)}" style="width:auto" ${selectedPages.has(p.id) ? 'checked' : ''}>
             <span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(p.name)}</span>
             ${p.hasIG ? `<span class="muted" title="IG: @${esc(p.igUsername || '')}" style="font-size:11px">📸 @${esc(p.igUsername || '')}</span>` : ''}
           </label>`).join('') : `<div class="muted">${igOnly ? 'No Instagram accounts match.' : 'No pages match.'}</div>`;
      box.querySelectorAll('.ch-pg').forEach(c => c.addEventListener('change', () => {
        if (c.checked) selectedPages.add(c.value); else selectedPages.delete(c.value);
        savePageSel(); updatePagesCount();
      }));
      updatePagesCount();
    }
    function updatePagesCount() {
      const el = $('#ch-pages-count'); if (!el) return;
      if (igModeOn()) {
        const igIds = new Set(allPages.filter(p => p.hasIG).map(p => p.id));
        const selIg = [...selectedPages].filter(id => igIds.has(id)).length;
        el.textContent = `· ${selIg} of ${igIds.size} Instagram accounts`;
      } else {
        el.textContent = `· ${selectedPages.size} of ${allPages.length} selected`;
      }
    }
    async function loadPages(refresh) {
      const box = $('#ch-pages'); if (box) box.innerHTML = '<div class="muted">Loading pages…</div>';
      try {
        const d = await jget('/api/channels/pages' + (refresh ? '?refresh=1' : ''));
        if (d.error) { if (box) box.innerHTML = `<div class="muted">✗ ${esc(d.error)}</div>`; return; }
        allPages = d.pages || []; pagesLoaded = true;
        // drop any stale selections no longer in the account
        const ids = new Set(allPages.map(p => p.id));
        [...selectedPages].forEach(id => { if (!ids.has(id)) selectedPages.delete(id); });
        $('#ch-pages-note').textContent = `${d.count} Pages · ${d.igCount} with Instagram · fetched ${fmtWhen(d.fetchedAt)}`;
        renderPages();
      } catch (e) { if (box) box.innerHTML = `<div class="muted">✗ ${esc(e.message)}</div>`; }
    }
    // picker controls
    if ($('#ch-pages-search')) $('#ch-pages-search').addEventListener('input', renderPages);
    if ($('#ch-pages-all')) $('#ch-pages-all').onclick = () => { visiblePages().forEach(p => selectedPages.add(p.id)); savePageSel(); renderPages(); };
    if ($('#ch-pages-none')) $('#ch-pages-none').onclick = () => { selectedPages.clear(); savePageSel(); renderPages(); };
    if ($('#ch-pages-ig')) $('#ch-pages-ig').onclick = () => { selectedPages.clear(); allPages.filter(p => p.hasIG).forEach(p => selectedPages.add(p.id)); savePageSel(); renderPages(); };
    if ($('#ch-pages-refresh')) $('#ch-pages-refresh').onclick = () => loadPages(true);

    // ── ① Activate page ──────────────────────────────────────────────────────
    async function loadSetup() {
      const d = await jget('/api/channels/setup');
      // Host hint: OAuth completes on the redirect-base host, so creds must live there.
      try {
        const baseHost = new URL(d.redirectBase).host;
        if (location.host !== baseHost) {
          $('#ch-hosthint').innerHTML =
            `<div class="muted-banner" style="background:#fff8ec;border-color:#f0e0c0;color:#7a5a2a">
              ⚠ Authorization finishes on <b>${esc(baseHost)}</b> (your OAuth redirect host). Save your keys and click Connect on
              <a href="${esc(d.redirectBase)}/#channels" target="_blank" rel="noopener noreferrer"><b>the live activation page ↗</b></a> so the browser hand-off can complete.</div>`;
        } else { $('#ch-hosthint').innerHTML = ''; }
      } catch { $('#ch-hosthint').innerHTML = ''; }

      $('#ch-setup').innerHTML = d.providers.map(p => {
        const statusPills = p.platforms.map(pl => {
          // needsReconnect (token present but expired/invalid) takes precedence over
          // the presence-based states so a dead token reads amber, not a false "ready".
          const s = pl.needsReconnect ? { bg: '#fff8ec', fg: '#7a5a2a', txt: 'reconnect needed' }
            : pl.connected ? { bg: '#e3efe0', fg: '#3a6b3a', txt: 'connected' }
            : pl.configured ? { bg: '#eef3f8', fg: '#3a5a8a', txt: 'ready' }
            : { bg: '#f3e9e9', fg: '#9a5a5a', txt: 'needs keys' };
          return `<span class="pill" style="background:${s.bg};color:${s.fg}"${pl.tokenError ? ` title="${esc(pl.tokenError)}"` : ''}>${esc(pl.label)}: ${s.txt}</span>`;
        }).join(' ');
        const redirects = p.redirectUris.map(r =>
          `<div class="copyrow"><code>${esc(r.uri)}</code><button class="btn ghost xs" data-copy="${esc(r.uri)}">copy</button></div>`).join('');
        const fields = p.fields.map(f =>
          `<div class="fld"><label>${esc(f.label)} ${f.set ? '<span class="pill saved-pill">saved</span>' : ''}</label>
            <input type="${f.secret ? 'password' : 'text'}" data-k="${esc(f.key)}" autocomplete="off"
              placeholder="${f.set ? '•••••• saved — leave blank to keep' : esc(f.ph || '')}"></div>`).join('');
        const connects = p.platforms.map(pl =>
          pl.connected ? `<span class="pill" style="background:#e3efe0;color:#3a6b3a">✓ ${esc(pl.label)}</span>`
            : (pl.connectUrl ? `<a class="btn gated" style="text-decoration:none" href="${ORIGIN}${esc(pl.connectUrl)}">${pl.needsReconnect ? 'Reconnect' : 'Connect'} ${esc(pl.label)} →</a>` : '')
        ).join(' ');
        // Meta has a known gotcha: Ads-scoped apps can't grant posting permissions.
        // Bake the exact 4-step fix into the card so it's waiting whenever Steve returns.
        const metaDoc = p.id === 'meta' ? `
          <details style="margin-top:10px;background:#fff8ec;border:1px solid #f0e0c0;border-radius:8px;padding:8px 10px">
            <summary style="cursor:pointer;font-size:12px;font-weight:700;color:#7a5a2a">⚠ Token only offering 1 permission / "Facebook Login unavailable"? Read this first</summary>
            <div style="font-size:12px;color:#7a5a2a;margin-top:8px;line-height:1.5">
              The cause is always the same: an <b>Ads-scoped app</b> can't grant posting permissions, so the token comes back useless. Build the app in this exact order <b>before</b> generating any token:
              <ol style="margin:8px 0 0;padding-left:18px">
                <li>Create app → choose <b>“Other”</b> → type <b>Business</b> (NOT an Ads use case).</li>
                <li>Add <b>two</b> products: <b>Instagram</b> <i>and</i> <b>Facebook Login</b>.</li>
                <li><b>App Review → Permissions and Features</b> → confirm <code>pages_manage_posts</code> + <code>instagram_content_publish</code> show <b>Standard Access</b>.</li>
                <li><b>Only then</b> generate the token (Graph API Explorer or a Business-Settings System User) — all 6 permissions will finally appear. Paste App ID + Secret above and Connect.</li>
              </ol>
              Norma's IG agent is a simulation stub — it has no token to reuse. There is no existing Meta token in the stack; one must be minted via the steps above.
            </div>
          </details>` : '';
        return `<div class="card setup-card" style="margin:0">
          <div style="display:flex;align-items:center;gap:8px"><span style="font-size:20px">${p.icon}</span><b>${esc(p.title)}</b></div>
          <div style="margin:6px 0 4px">${statusPills}</div>
          <div class="muted" style="font-size:12px;margin:6px 0 10px">${esc(p.blurb)}</div>
          <a class="btn ghost xs" target="_blank" rel="noopener noreferrer" href="${esc(p.portal)}">Open ${esc(p.portalLabel)} ↗</a>
          <div class="ch-sublabel">Redirect URI — add in portal</div>${redirects}
          <div class="ch-sublabel">Permissions / scopes</div>
          <div class="muted" style="font-size:11.5px">${esc(p.permissions)}</div>
          <div class="ch-sublabel">App credentials</div>${fields}
          ${metaDoc}
          <div class="row" style="margin-top:12px;align-items:center">
            <button class="btn gold" data-save="${esc(p.id)}">Save credentials</button>
            ${connects}
            <span class="muted setup-msg" data-msg="${esc(p.id)}" style="font-size:12px"></span>
          </div>
        </div>`;
      }).join('');

      // copy buttons
      root.querySelectorAll('[data-copy]').forEach(b => b.onclick = async () => {
        try { await navigator.clipboard.writeText(b.dataset.copy); const t = b.textContent; b.textContent = 'copied ✓'; setTimeout(() => b.textContent = t, 1200); }
        catch { b.textContent = 'select & ⌘C'; }
      });
      // save buttons
      root.querySelectorAll('[data-save]').forEach(b => b.onclick = async () => {
        const card = b.closest('.setup-card');
        const msg = card.querySelector('.setup-msg');
        const keys = {};
        card.querySelectorAll('input[data-k]').forEach(i => { if (i.value.trim()) keys[i.dataset.k] = i.value.trim(); });
        if (!Object.keys(keys).length) { msg.textContent = 'nothing to save (fields blank)'; return; }
        msg.textContent = 'saving…'; b.disabled = true;
        try {
          const r = await fetch(ORIGIN + '/api/channels/config', { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ keys }) });
          const j = await r.json();
          if (j.error) { msg.textContent = '✗ ' + j.error; }
          else { msg.textContent = `✓ saved ${j.saved.length} key(s) — Connect now lit`; await loadStatus(); await loadSetup(); }
        } catch (e) { msg.textContent = '✗ ' + e.message; }
        b.disabled = false;
      });
    }

    let outboxItems = [];
    function renderOutbox() {
      const sort = ($('#ch-outbox-sort') && $('#ch-outbox-sort').value) || 'newest';
      const rows = outboxItems.slice();
      rows.sort((a, b) => sort === 'oldest' ? (a.at || '').localeCompare(b.at || '')
        : sort === 'channel' ? (a.channel || '').localeCompare(b.channel || '') || (b.at || '').localeCompare(a.at || '')
        : (b.at || '').localeCompare(a.at || '')); // newest
      $('#ch-outbox').innerHTML = rows.length ? rows.map(it => `
        <div class="row" style="justify-content:space-between;align-items:center;border-bottom:1px solid var(--line);padding:7px 2px">
          <div style="min-width:0">
            <div>${esc(it.channel)} · <span class="muted">${esc((it.caption || '').slice(0, 50))}</span></div>
            <div class="muted" style="font-size:11px" title="${esc(it.at || '')}">🕓 ${esc(fmtWhen(it.at))}</div>
          </div>
          <span class="pill">${esc(it.status)}</span></div>`).join('') : '<div class="muted">No posts yet.</div>';
    }
    async function loadOutbox() {
      const d = await jget('/api/channels/outbox');
      outboxItems = d.items || [];
      renderOutbox();
    }
    if ($('#ch-outbox-sort')) $('#ch-outbox-sort').addEventListener('change', renderOutbox);

    async function send(live) {
      const channels = [...root.querySelectorAll('.ch-t:checked')].map(c => c.value);
      const caption = $('#ch-caption').value.trim(); const mediaUrl = $('#ch-media').value.trim();
      const pages = [...selectedPages];
      if (!channels.length) { $('#ch-msg').textContent = 'pick at least one channel'; return; }
      if (!caption && !mediaUrl) { $('#ch-msg').textContent = 'write a caption or add media'; return; }
      // Facebook/Instagram need at least one Page selected (that's where the post goes)
      if (metaSelected() && !pages.length) { $('#ch-msg').textContent = 'select at least one Page to post to (Pages picker above)'; return; }
      if (live) {
        const ps = status.platforms || {};
        const caveats = channels.filter(c => ps[c] && ps[c].caveat).map(c => `• ${ps[c].label}: ${ps[c].caveat}`);
        const warn = caveats.length ? `\n\n⚠ Heads up:\n${caveats.join('\n')}` : '';
        const pageNote = metaSelected() ? `\n\nFacebook/Instagram → ${pages.length} selected Page(s).` : '';
        if (!confirm(`PUBLISH LIVE to: ${channels.join(', ')}?${pageNote}\n\nConnected channels post immediately; unconnected ones stage.${warn}\n\nProceed?`)) return;
      }
      $('#ch-msg').textContent = live ? 'publishing…' : 'staging…';
      const body = { channels, caption, mediaUrl, pages, confirm: live, dryRun: !live };
      const r = await fetch(ORIGIN + '/api/channels/publish', { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(body) });
      const d = await r.json();
      $('#ch-msg').textContent = d.error ? '✗ ' + d.error : (d.note || 'done');
      loadOutbox(); loadStatus();
    }
    $('#ch-stage').onclick = () => send(false);
    $('#ch-publish').onclick = () => send(true);

    await loadStatus(); await loadSetup(); await loadOutbox();
  },
};