← back to Marketing Command Center

public/panels/accounts.js

201 lines

// Accounts panel — Amazon-style faceted rail over every social account.
// LEFT: status facets (All / Connected / Staged-only / Needs credentials) + a
// searchable list of all accounts, each with a live status dot. RIGHT: the
// selected account's connection detail and, when creds are missing, an inline
// "how to fix" (paste keys → saved to .env via the channels config route, plus
// the portal link, redirect URI, permissions, and a Connect button for OAuth).
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['accounts'] = {
  init(root) {
    const ORIGIN = location.origin;
    const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
    const $ = sel => root.querySelector(sel);
    const jget = async u => {
      const r = await fetch(ORIGIN + u, { credentials: 'same-origin' });
      if (!r.ok) throw new Error(`${u} → HTTP ${r.status}`);
      return r.json();
    };

    const FACETS = [
      { key: 'all',         label: 'All accounts',     dot: null },
      { key: 'connected',   label: 'Connected',        dot: 'dot-ok' },
      { key: 'staged-only', label: 'Staged only',      dot: 'dot-warn' },
      { key: 'needs-creds', label: 'Needs credentials', dot: 'dot-off' },
    ];
    const DOT = { 'connected': 'dot-ok', 'staged-only': 'dot-warn', 'needs-creds': 'dot-off' };
    const BADGE = { 'connected': ['badge-ok', 'Connected · live'], 'staged-only': ['badge-warn', 'Connected · stages'], 'needs-creds': ['badge-off', 'Needs credentials'] };

    const state = { accounts: [], counts: {}, facet: 'all', q: '', selected: null };
    const rowTpl = $('#acc-row-tpl');

    async function load() {
      let d;
      try { d = await jget('/api/accounts/list'); }
      catch (_) { $('#acc-banner').textContent = 'Could not load accounts.'; return; }
      state.accounts = d.accounts || [];
      state.counts = d.counts || {};
      const c = state.counts;
      $('#acc-banner').innerHTML =
        `<b>${c.connected || 0}</b> connected · <b>${c['staged-only'] || 0}</b> staged-only · <b>${c['needs-creds'] || 0}</b> need credentials — of ${c.all || 0} accounts. ` +
        `Pick one to see its status and how to fix it.`;
      // keep the current selection if it still exists, else default to first that needs creds
      if (state.selected && !state.accounts.find(a => a.platform === state.selected))
        state.selected = null;
      if (!state.selected) {
        const firstBroken = state.accounts.find(a => a.bucket === 'needs-creds');
        state.selected = firstBroken ? firstBroken.platform : (state.accounts[0] && state.accounts[0].platform) || null;
      }
      renderFacets();
      renderList();
      renderDetail();
    }

    function renderFacets() {
      const box = $('#acc-facets');
      box.innerHTML = '';
      for (const f of FACETS) {
        const b = document.createElement('button');
        b.type = 'button';
        b.className = 'acc-facet' + (state.facet === f.key ? ' on' : '');
        const n = state.counts[f.key] != null ? state.counts[f.key] : (f.key === 'all' ? state.accounts.length : 0);
        b.innerHTML =
          (f.dot ? `<span class="fdot ${f.dot}"></span>` : `<span class="fdot" style="background:transparent;border:1px solid var(--line)"></span>`) +
          `<span>${esc(f.label)}</span><span class="fct">${n}</span>`;
        b.onclick = () => { state.facet = f.key; renderFacets(); renderList(); };
        box.appendChild(b);
      }
    }

    function visible() {
      const q = state.q.trim().toLowerCase();
      return state.accounts.filter(a => {
        if (state.facet !== 'all' && a.bucket !== state.facet) return false;
        if (q && !(a.label.toLowerCase().includes(q) || a.platform.includes(q) ||
                   (a.accounts || []).some(h => String(h).toLowerCase().includes(q)))) return false;
        return true;
      });
    }

    function renderList() {
      const box = $('#acc-list');
      const list = visible();
      $('#acc-listcount').textContent = `(${list.length})`;
      box.innerHTML = '';
      if (!list.length) { box.innerHTML = '<div class="muted" style="font-size:12px;padding:6px">No accounts match.</div>'; return; }
      for (const a of list) {
        const node = rowTpl.content.firstElementChild.cloneNode(true);
        node.querySelector('.acc-ic').textContent = a.icon || '•';
        node.querySelector('.acc-name').textContent = a.label;
        node.querySelector('.acc-dot').className = 'acc-dot ' + (DOT[a.bucket] || 'dot-off');
        node.title = BADGE[a.bucket] ? BADGE[a.bucket][1] : '';
        if (a.platform === state.selected) node.classList.add('on');
        node.onclick = () => { state.selected = a.platform; renderList(); renderDetail(); };
        box.appendChild(node);
      }
    }

    function renderDetail() {
      const main = $('#acc-main');
      const a = state.accounts.find(x => x.platform === state.selected);
      if (!a) { main.innerHTML = '<div class="muted">Select an account on the left.</div>'; return; }

      const [badgeCls, badgeTxt] = BADGE[a.bucket] || ['badge-off', 'Unknown'];
      const handles = (a.accounts || []).length
        ? `<div class="acc-handles">${a.accounts.map(h => `<span class="pill">${esc(h)}</span>`).join('')}</div>`
        : `<div class="acc-note muted">No account handle discovered yet.</div>`;
      const caveat = a.caveat ? `<div class="acc-caveat">⚠ ${esc(a.caveat)}</div>` : '';

      let html =
        `<div class="acc-head">
           <span class="h-ic">${a.icon || '•'}</span>
           <h2>${esc(a.label)}</h2>
           <span class="acc-badge ${badgeCls}">${esc(badgeTxt)}</span>
         </div>
         ${handles}
         ${a.needs ? `<div class="acc-note"><b>What it needs:</b> ${esc(a.needs)}</div>` : ''}
         ${caveat}`;

      // "How to fix" — only when there's something to do (not fully connected) and
      // we have provider docs for it.
      if (a.bucket !== 'connected' && a.howToFix) {
        html += renderFix(a);
      } else if (a.bucket === 'connected') {
        html += `<div class="fix"><h3>Connected</h3><div class="acc-note">This account is wired for live posting${a.scopes && a.scopes.length ? ` (scopes: ${a.scopes.map(esc).join(', ')})` : ''}. Nothing to fix.</div></div>`;
      }
      main.innerHTML = html;
      wireFix(a, main);
    }

    function renderFix(a) {
      const f = a.howToFix;
      const redirects = (f.redirectUris || []).map(r =>
        `<div class="copyrow"><code>${esc(r.uri)}</code><button class="btn ghost xs acc-copy" data-copy="${esc(r.uri)}" type="button">Copy</button></div>`).join('');
      const fields = (f.fields || []).map(fl =>
        `<div class="fld">
           <label>${esc(fl.label)} <span class="${fl.set ? 'set-pill' : 'miss-pill'}">${fl.set ? 'SET' : 'MISSING'}</span></label>
           <input type="${fl.secret ? 'password' : 'text'}" data-key="${esc(fl.key)}" placeholder="${fl.set ? '•••••• (already saved — leave blank to keep)' : 'paste ' + esc(fl.key)}" autocomplete="off">
         </div>`).join('');

      let steps = '';
      let n = 1;
      steps += `<div class="step"><div class="n">${n++}</div><div class="sbody">
          <b>Create / open the app</b>
          <div class="acc-note" style="margin:4px 0 0">${f.portal ? `<a href="${esc(f.portal)}" target="_blank" rel="noopener noreferrer">${esc(f.portalLabel || 'Developer portal')} ↗</a>` : 'See the platform developer portal.'}</div>
        </div></div>`;
      if (f.permissions) steps += `<div class="step"><div class="n">${n++}</div><div class="sbody">
          <b>Request these permissions</b><div class="perm" style="margin-top:5px">${esc(f.permissions)}</div>
        </div></div>`;
      if (redirects) steps += `<div class="step"><div class="n">${n++}</div><div class="sbody">
          <b>Register this redirect URI</b><div class="acc-note" style="margin:2px 0">Add it to the app's OAuth settings exactly.</div>${redirects}
        </div></div>`;
      if (fields) steps += `<div class="step"><div class="n">${n++}</div><div class="sbody">
          <b>Paste the keys</b><div class="acc-note" style="margin:2px 0">Saved to the server <code>.env</code> and never shown back.</div>${fields}
          <div style="margin-top:8px"><button class="btn gold xs acc-save" type="button">Save keys</button> <span class="muted acc-savemsg" style="font-size:12px"></span></div>
        </div></div>`;
      const connect = a.connectUrl
        ? `<div class="step"><div class="n">${n++}</div><div class="sbody">
             <b>Authorize in your browser</b><div class="acc-note" style="margin:2px 0">Keys are set — one click to mint the token.</div>
             <a class="btn gated xs" href="${esc(ORIGIN + a.connectUrl)}" target="_blank" rel="noopener noreferrer">🔗 Connect ${esc(a.label)}</a>
           </div></div>`
        : '';

      return `<div class="fix"><h3>How to fix</h3>${steps}${connect}</div>`;
    }

    function wireFix(a, main) {
      main.querySelectorAll('.acc-copy').forEach(b => b.onclick = () => {
        const t = b.getAttribute('data-copy');
        navigator.clipboard && navigator.clipboard.writeText(t);
        const o = b.textContent; b.textContent = 'Copied ✓'; setTimeout(() => b.textContent = o, 1200);
      });
      const saveBtn = main.querySelector('.acc-save');
      if (saveBtn) saveBtn.onclick = async () => {
        const keys = {};
        main.querySelectorAll('input[data-key]').forEach(inp => {
          const v = inp.value.trim();
          if (v) keys[inp.getAttribute('data-key')] = v;
        });
        const msg = main.querySelector('.acc-savemsg');
        if (!Object.keys(keys).length) { msg.textContent = 'Nothing to save — paste at least one key.'; return; }
        saveBtn.disabled = true; msg.textContent = 'Saving…';
        let r;
        try {
          r = await fetch(ORIGIN + '/api/channels/config', {
            method: 'POST', headers: { 'Content-Type': 'application/json' },
            credentials: 'same-origin', body: JSON.stringify({ keys }),
          }).then(x => x.json());
        } catch (_) { r = { error: 'request failed' }; }
        saveBtn.disabled = false;
        if (r && r.ok) { msg.textContent = '✓ Saved ' + (r.saved || []).join(', '); await load(); }
        else { msg.textContent = '✗ ' + ((r && r.error) || 'save failed'); }
      };
    }

    // search
    $('#acc-search').addEventListener('input', e => { state.q = e.target.value; renderList(); });

    load();
  },
};