← back to Marketing Command Center

public/app.js

199 lines

// Shell loader — builds a GROUPED nav from /api/panels, lazy-loads each panel's
// HTML+JS into #panel. A panel = public/panels/<id>.html (markup) + <id>.js
// (optional, exports window.MCC_PANELS[id] = { init(root) }). Hash routing.
// Boot is hardened: any failed fetch renders a visible error + Retry instead of
// hanging on "Loading…" forever.
const $ = s => document.querySelector(s);
window.MCC_PANELS = window.MCC_PANELS || {};
let PANELS = [];

// Curated nav groups — four plain-language actions for first-time users.
// Power tools not listed here fall into "More" and remain available via search.
const GROUPS = [
  { name: 'Make a post', ids: ['quickpost', 'compose'] },
  { name: 'Plan', ids: ['calendarhub', 'playbook', 'board'] },
  { name: 'See results', ids: ['insights', 'social', 'reels'] },
  { name: 'Accounts', ids: ['accounts', 'channels'] },
];

// Relabel a panel in the SIDEBAR only, WITHOUT editing its module (a module may be
// owned by another session — e.g. modules/vendors/index.js). Keyed by panel id.
const TITLE_OVERRIDES = {
  quickpost: 'Make a post',
  compose: 'Post ideas',
  social: 'Post history',
  reels: 'Videos',
  vendors: 'Account check',
  board: 'What’s happening',
  calendarhub: 'What’s planned',
  playbook: 'Post plan',
  insights: 'Results',
  channels: 'Social accounts',
  layouts: 'Layouts',             // trim "On-Demand Layouts" for the rail
};
// Hidden/merged panels → their host, so the command palette can show a "· via Host"
// breadcrumb and still let power users jump straight to them.
const HIDDEN_HOST = {
  calendars: 'calendarhub', calendar: 'calendarhub', engine: 'compose', composer: 'compose',
  performance: 'insights', 'segment-perf': 'insights', 'send-times': 'insights', segments: 'insights',
  journeys: 'insights', profiles: 'insights', 'follow-counts': 'insights', 'ab-tests': 'insights',
};
// Static maps the command palette reads (panel id → group label, and the nav order).
window.MCC_GROUP_MAP = Object.fromEntries(GROUPS.flatMap(g => g.ids.map(id => [id, g.name])));
window.MCC_HIDDEN_HOST = HIDDEN_HOST;
window.MCC_NAV_ORDER = GROUPS.flatMap(g => g.ids);

// Panels whose ROUTERS stay mounted (their APIs are still called) but whose NAV
// entries are hidden — because they were merged into a host panel above. Removing
// them from registry.js would unmount their APIs and break the host, so we hide
// them here instead. calendars/calendar → calendarhub; engine/composer → compose.
const HIDDEN = new Set([
  'calendars', 'calendar', 'engine', 'composer',           // → calendarhub / compose
  'performance', 'segment-perf', 'send-times', 'segments', // → insights (preview)
  'journeys', 'profiles', 'follow-counts', 'ab-tests',
]);

// Always fetch against the bare origin. If the page itself was opened with
// embedded credentials (http://user:pass@host), relative URLs resolve against
// that credentialed base and fetch() throws "URL that includes credentials".
// location.origin never carries credentials, so this is immune to that.
const ORIGIN = location.origin;
async function jget(url) {
  const r = await fetch(ORIGIN + url, { credentials: 'same-origin' });
  if (!r.ok) throw new Error(`${url} → HTTP ${r.status}`);
  return r.json();
}

function linkHTML(p) {
  const label = TITLE_OVERRIDES[p.id] || p.title;      // rail label only; the panel header keeps its module title
  return `<a data-id="${p.id}" class="${p.pending ? 'pending' : ''}">` +
    `<span class="i">${p.icon || '▪'}</span><span class="t">${label}</span></a>`;
}

function renderNav() {
  const visible = PANELS.filter(p => !HIDDEN.has(p.id));   // hide merged-away panels
  const byId = Object.fromEntries(visible.map(p => [p.id, p]));
  const collapsed = new Set(JSON.parse(localStorage.getItem('mcc_nav_collapsed') || '[]'));
  const group = (name, items, canCollapse = true) => {
    const isC = canCollapse && collapsed.has(name);
    return `<div class="navgroup${isC ? ' collapsed' : ''}">` +
      `<div class="navhead" data-group="${name}"><span class="caret">${isC ? '▸' : '▾'}</span>${name}</div>` +
      `<div class="navitems">${items.map(linkHTML).join('')}</div></div>`;
  };
  const used = new Set();
  let html = '';
  for (const g of GROUPS) {
    const items = g.ids.map(id => byId[id]).filter(Boolean);
    if (!items.length) continue;
    items.forEach(p => used.add(p.id));
    html += group(g.name, items);
  }
  const leftover = visible.filter(p => !used.has(p.id));
  if (leftover.length) html += group('More', leftover);   // should be empty now that ig-activity has a home
  $('#tabs').innerHTML = html;
  $('#tabs').querySelectorAll('a').forEach(a => a.onclick = () => {
    location.hash = a.dataset.id;
    if (window.matchMedia('(max-width:860px)').matches) document.body.classList.remove('nav-open');
  });
  // Collapse/expand a group (persisted). renderNav re-runs — cheap, and it re-inserts
  // the palette trigger below.
  $('#tabs').querySelectorAll('.navhead[data-group]').forEach(h => h.onclick = () => {
    const c = new Set(JSON.parse(localStorage.getItem('mcc_nav_collapsed') || '[]'));
    c.has(h.dataset.group) ? c.delete(h.dataset.group) : c.add(h.dataset.group);
    localStorage.setItem('mcc_nav_collapsed', JSON.stringify([...c]));
    renderNav();
  });
  // setting #tabs.innerHTML wiped the palette's "Search panels…" trigger — re-mount it
  // (setData re-inserts the trigger + refreshes the palette's item list).
  if (window.cmdPalette && window.cmdPalette.setData) window.cmdPalette.setData(PANELS);
}

function renderBootError(e) {
  $('#health').className = 'dot bad';
  $('#health').textContent = 'offline';
  $('#tabs').innerHTML = '';
  $('#panel').innerHTML =
    `<div class="card" style="max-width:520px;margin:40px auto">
       <h2>Couldn't load the dashboard</h2>
       <p class="muted">${e.message || e}</p>
       <p class="muted" style="font-size:12.5px">If this is an auth error, reload the page once and enter
         your MCC credentials in the browser dialog, then it will stick.</p>
       <button class="btn gold" onclick="boot()">Retry</button>
     </div>`;
}

async function boot() {
  $('#panel').innerHTML = '<div class="loading">Loading…</div>';
  try {
    const h = await jget('/api/health').catch(() => ({ ok: false, panels: 0 }));
    const d = await jget('/api/panels');
    PANELS = d.panels || [];
    $('#health').className = 'dot ' + (h.ok ? 'ok' : 'bad');
    $('#health').textContent = h.ok ? `${h.panels} panels online` : 'offline';
    renderNav();
    route();
  } catch (e) {
    renderBootError(e);
  }
}
window.boot = boot;
window.addEventListener('hashchange', route);

const loaded = new Set();
// Monotonic token: every route() call claims the next value. After each await we
// re-check that we're still the newest navigation; if a later click superseded us
// we abort BEFORE touching #panel. Without this, rapid panel-switching interleaves
// two route() calls that both write the single shared #panel — a stale one lands
// after the fresh render and wipes it, leaving the panel blank even though its API
// calls fired (the "all posts blank" bug). Latest-wins is the fix.
let routeToken = 0;
async function route() {
  const myToken = ++routeToken;
  const requested = location.hash.replace('#', '');
  const firstPanel = PANELS.find(p => p.id === 'quickpost') || PANELS[0];
  const id = requested || (firstPanel && firstPanel.id);
  if (!id) return;
  const meta = PANELS.find(p => p.id === id) || { id, title: id };
  // A hidden/merged panel (e.g. composer, follow-counts — reachable via the palette)
  // has no nav <a> of its own, so highlight its HOST group entry instead of leaving
  // the whole sidebar deselected.
  const activeId = (HIDDEN_HOST && HIDDEN_HOST[id]) || id;
  $('#tabs').querySelectorAll('a').forEach(a => a.classList.toggle('active', a.dataset.id === activeId));
  $('#paneltitle').textContent = meta.title;
  $('#crumb').textContent = meta.pending ? '— not built yet' : '';
  const panel = $('#panel');
  panel.innerHTML = '<div class="loading">Loading…</div>';
  let html = '';
  try { const r = await fetch(`${ORIGIN}/panels/${id}.html`, { credentials: 'same-origin' }); html = r.ok ? await r.text() : ''; } catch {}
  if (myToken !== routeToken) return;              // superseded during HTML fetch — don't clobber the newer panel
  panel.innerHTML = html || `<div class="muted-banner">The “${meta.title}” panel isn’t built yet.</div>`;
  if (!loaded.has(id)) {
    await new Promise(res => {
      const s = document.createElement('script'); s.src = `${ORIGIN}/panels/${id}.js`;
      s.onload = res; s.onerror = res; document.body.appendChild(s);
    });
    loaded.add(id);
  }
  if (myToken !== routeToken) return;              // superseded during panel-JS load — don't init a stale panel
  try { window.MCC_PANELS[id] && window.MCC_PANELS[id].init && window.MCC_PANELS[id].init(panel); } catch (e) { console.error(e); }
}

// ── Hamburger: collapse on desktop, off-canvas overlay on mobile ─────────────
function toggleNav() {
  if (window.matchMedia('(max-width:860px)').matches) {
    document.body.classList.toggle('nav-open');
  } else {
    const hidden = document.body.classList.toggle('nav-hidden');
    try { localStorage.setItem('mcc_nav_hidden', hidden ? '1' : ''); } catch {}
  }
}
document.addEventListener('DOMContentLoaded', () => {
  if (localStorage.getItem('mcc_nav_hidden') === '1' && !window.matchMedia('(max-width:860px)').matches) {
    document.body.classList.add('nav-hidden');
  }
  $('#burger').onclick = toggleNav;
  $('#scrim').onclick = () => document.body.classList.remove('nav-open');
});

boot();