← back to Marketing Command Center

public/panels/vendors.js

110 lines

window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['vendors'] = {
  async init(root) {
    const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
    const fmt = n => n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? Math.round(n / 1e3) + 'K' : (n || '—');
    const ago = iso => { if (!iso) return ''; const d = (Date.now() - new Date(iso)) / 36e5; return d < 1 ? Math.round(d * 60) + 'm ago' : d < 24 ? Math.round(d) + 'h ago' : Math.round(d / 24) + 'd ago'; };
    let data;
    const loadData = async () => { data = await (await fetch('/api/vendors/accounts')).json(); };
    try { await loadData(); } catch { root.querySelector('#vend-rows').innerHTML = '<div class="muted">Failed to load.</div>'; return; }

    const setStats = () => {
      const s = data.stats || {};
      root.querySelector('#vs-total').textContent = s.total || 0;
      root.querySelector('#vs-ig').textContent = s.withIG || 0;
      root.querySelector('#vs-reach').textContent = fmt(s.totalReach || 0);
      root.querySelector('#vs-missing').textContent = s.missing || 0;
    };
    setStats();

    // Last 3 posts rendered INLINE as a stacked listing directly below each
    // vendor row (not a photo grid) — thumb + caption ↗ + date · likes · comments.
    const postList = (r) => {
      if (!r.hasIG) return '';
      if (r.posts && r.posts.length) {
        const items = r.posts.slice(0, 3).map(p => {
          const cap = esc((p.caption || '').replace(/\s+/g, ' ').slice(0, 120)) || '(no caption)';
          const when = p.timestamp ? p.timestamp.slice(0, 10) : '';
          const vid = p.media_type === 'VIDEO' ? '▶ ' : '';
          const thumb = p.image
            ? `<img class="ig-li-thumb" loading="lazy" src="${esc(p.image)}" alt="" onerror="this.style.visibility='hidden'">`
            : `<span class="ig-li-thumb"></span>`;
          return `<a class="ig-li" href="${esc(p.permalink)}" target="_blank" rel="noopener noreferrer">
            ${thumb}
            <span class="ig-li-body">
              <span class="ig-li-cap">${vid}${cap} <span class="ig-li-ext">↗</span></span>
              <span class="ig-li-meta">${when ? when + '  ·  ' : ''}♥ ${fmt(p.likes)}  ·  💬 ${fmt(p.comments)}</span>
            </span></a>`;
        }).join('');
        return `<div class="ig-list">${items}</div>`;
      }
      const why = r.postsError ? `posts unavailable — ${esc(r.postsError).slice(0, 90)}` : 'no posts cached yet — click “Refresh posts”';
      return `<div class="muted" style="font-size:11px;padding:4px 2px 8px">${why}</div>`;
    };

    const render = (sort) => {
      let rows = [...data.accounts];
      if (sort === 'brand') rows.sort((a, b) => a.brand.localeCompare(b.brand));
      else if (sort === 'code') rows.sort((a, b) => (a.vendorCode || '').localeCompare(b.vendorCode || ''));
      else rows.sort((a, b) => (b.followersNum || 0) - (a.followersNum || 0));
      root.querySelector('#vend-rows').innerHTML = rows.map(r => {
        const dw = r.vendorCode === 'dw';
        const handle = r.hasIG
          ? `<a class="lnk" href="${esc(r.url)}" target="_blank" rel="noopener noreferrer">${esc(r.handle)} ↗</a>`
          : `<span class="muted">no IG</span>`;
        return `<div class="vend-block" style="border-bottom:1px solid var(--line);padding:10px 2px${dw ? ';background:#faf6ee' : ''}">
          <div class="row" style="justify-content:space-between;align-items:center">
            <div style="min-width:200px"><b>${esc(r.brand)}</b>${dw ? ' <span class="pill">OURS</span>' : ''}${r.note && !dw ? ` <span class="muted" style="font-size:11px">${esc(r.note)}</span>` : ''}</div>
            <div style="flex:1">${handle}</div>
            <div style="min-width:70px;text-align:right;font-variant-numeric:tabular-nums">${r.hasIG ? fmt(r.followersNum) : ''}</div>
          </div>
          ${postList(r)}
        </div>`;
      }).join('');
    };
    render('followers');
    root.querySelector('#vs-sort').onchange = e => render(e.target.value);

    // Refresh-posts control + last-fetched stamp, injected into the stats bar.
    const bar = root.querySelector('#vend-stats');
    if (bar && !bar.querySelector('#vp-refresh')) {
      const wrap = document.createElement('div');
      wrap.style.cssText = 'display:flex;flex-direction:column;align-items:flex-end;gap:2px';
      wrap.innerHTML = `<button id="vp-refresh" class="btn">↻ Refresh posts</button>
        <small class="muted" id="vp-when">${data.stats.postsFetchedAt ? 'posts updated ' + ago(data.stats.postsFetchedAt) : 'posts not fetched yet'} · ${data.stats.withPosts || 0}/${data.stats.withIG || 0} with posts</small>`;
      bar.appendChild(wrap);
      wrap.querySelector('#vp-refresh').onclick = async (e) => {
        const btn = e.target; btn.disabled = true; btn.textContent = '↻ Fetching latest posts…';
        try {
          const res = await (await fetch('/api/vendors/posts/refresh', { method: 'POST' })).json();
          if (!res.ok) { btn.textContent = '⚠ ' + (res.error || 'refresh failed'); btn.disabled = false; return; }
          await loadData(); setStats();
          render(root.querySelector('#vs-sort').value);
          btn.textContent = `✓ ${res.refreshed} ok, ${res.failed} failed (via ${res.discoveringIg})`;
          root.querySelector('#vp-when').textContent = 'posts updated just now · ' + (data.stats.withPosts || 0) + '/' + (data.stats.withIG || 0) + ' with posts';
          setTimeout(() => { btn.textContent = '↻ Refresh posts'; btn.disabled = false; }, 4000);
        } catch (err) { btn.textContent = '⚠ ' + err.message; btn.disabled = false; }
      };
    }

    // Inline last-3 listing styling (idempotent; id bumped so it re-injects over old grid CSS)
    if (!document.getElementById('vend-ig-css2')) {
      const st = document.createElement('style'); st.id = 'vend-ig-css2';
      st.textContent = `
      .ig-list{display:flex;flex-direction:column;gap:4px;margin-top:8px}
      .ig-li{display:flex;gap:9px;align-items:center;padding:5px 7px;border:1px solid var(--line);border-radius:8px;background:#fbf9f4;text-decoration:none;color:inherit}
      .ig-li:hover{background:#f3eee2}
      .ig-li-thumb{width:40px;height:40px;flex:0 0 40px;border-radius:6px;object-fit:cover;background:#ece7dd;display:inline-block}
      .ig-li-body{display:flex;flex-direction:column;min-width:0;gap:1px}
      .ig-li-cap{font-size:12px;line-height:1.35;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:min(70vw,640px)}
      .ig-li-ext{color:var(--muted,#8a8372);font-size:11px}
      .ig-li-meta{font-size:10.5px;color:var(--muted,#8a8372);font-variant-numeric:tabular-nums}`;
      document.head.appendChild(st);
    }

    const s = data.stats || {};
    root.querySelector('#vend-banner').innerHTML = s.missing
      ? `<div class="muted-banner">${s.withIG} of ${s.total} brands have an official Instagram — ${s.missing} have none found (Atomic50, Bespoke, Folia, Naturale 54). Combined vendor reach ≈ ${fmt(s.totalReach)} followers. Last 3 posts shown inline per vendor via Instagram Business Discovery (click Refresh).</div>` : '';
  },
};