← 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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>` : '';
},
};