← back to Marketing Command Center

public/app.js

142 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. Social Media leads. Any panel not listed falls into "More".
const GROUPS = [
  { name: 'Social Media', ids: ['accounts', 'board', 'social', 'channels', 'linkedin'] },
  { name: 'Content Studio', ids: ['copy', 'layouts', 'assets', 'sounds', 'templates'] },
  { name: 'Email', ids: ['constant-contact', 'send-times'] },
  { name: 'Audience', ids: ['clients', 'segments', 'profiles', 'segment-perf'] },
  { name: 'Automation', ids: ['journeys', 'browse-abandon', 'playbook'] },
  { name: 'Analytics', ids: ['performance', 'follow-counts', 'ab-tests'] },
  { name: 'Planning', ids: ['calendars', 'calendar', 'vendors'] },
];

// 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) {
  return `<a data-id="${p.id}" class="${p.pending ? 'pending' : ''}">` +
    `<span class="i">${p.icon || '▪'}</span><span class="t">${p.title}</span></a>`;
}

function renderNav() {
  const byId = Object.fromEntries(PANELS.map(p => [p.id, p]));
  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 += `<div class="navgroup"><div class="navhead">${g.name}</div>` +
      items.map(linkHTML).join('') + `</div>`;
  }
  const leftover = PANELS.filter(p => !used.has(p.id));
  if (leftover.length) {
    html += `<div class="navgroup"><div class="navhead">More</div>` +
      leftover.map(linkHTML).join('') + `</div>`;
  }
  $('#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');
  });
}

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 id = location.hash.replace('#', '') || (PANELS[0] && PANELS[0].id);
  if (!id) return;
  const meta = PANELS.find(p => p.id === id) || { id, title: id };
  $('#tabs').querySelectorAll('a').forEach(a => a.classList.toggle('active', a.dataset.id === id));
  $('#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();