← back to Marketing Command Center
public/panels/channels.js
243 lines
window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['channels'] = {
async init(root) {
const ORIGIN = location.origin;
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
const $ = s => root.querySelector(s);
const jget = async u => (await fetch(ORIGIN + u, { credentials: 'same-origin' })).json();
let status = {};
// created date + time in the admin's local tz (admin-card convention)
const fmtWhen = iso => { if (!iso) return ''; const d = new Date(iso); return isNaN(d) ? '' : d.toLocaleString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); };
async function loadStatus() {
status = await jget('/api/channels/status');
const ps = status.platforms || {};
$('#ch-banner').innerHTML = `${status.connectedCount}/${status.total} channels connected · ${status.outbox} in outbox. ` +
(status.connectedCount === 0 ? 'No channels connected yet — activate each platform below to enable live posting.' : 'Connected channels post live; the rest stage.');
$('#ch-targets').innerHTML = Object.entries(ps).map(([k, p]) =>
`<label style="display:flex;align-items:center;gap:6px;font-weight:600;color:var(--ink)" ${p.caveat ? `title="${esc(p.caveat)}"` : ''}><input type="checkbox" class="ch-t" value="${k}" style="width:auto"> ${p.icon} ${esc(p.label)}${p.connected ? '' : ' <span class="muted" style="font-weight:400">(stages)</span>'}${p.caveat ? ' <span class="muted" style="font-weight:400" title="' + esc(p.caveat) + '">⚠</span>' : ''}</label>`).join('');
// the Page picker is only relevant for Facebook/Instagram → show it when either is ticked
root.querySelectorAll('.ch-t').forEach(c => c.addEventListener('change', syncPagesVisibility));
syncPagesVisibility();
}
// ── Page picker (select WHICH FB Pages / IG accounts to post to) ──────────
const PAGE_KEY = 'mcc-ch-pages';
let allPages = [], pagesLoaded = false;
const selectedPages = new Set((() => { try { return JSON.parse(localStorage.getItem(PAGE_KEY) || '[]'); } catch { return []; } })());
const savePageSel = () => { try { localStorage.setItem(PAGE_KEY, JSON.stringify([...selectedPages])); } catch {} };
const metaSelected = () => [...root.querySelectorAll('.ch-t:checked')].some(c => c.value === 'facebook' || c.value === 'instagram');
function syncPagesVisibility() {
const wrap = $('#ch-pages-wrap'); if (!wrap) return;
const show = metaSelected();
wrap.style.display = show ? 'block' : 'none';
if (show) { if (!pagesLoaded) loadPages(false); else renderPages(); } // re-render to reflect IG-vs-Pages mode
}
// IG-only mode = ONLY Instagram is ticked → reframe the picker as "Instagram
// accounts" so you select 1-to-all IG accounts directly (each IG account is
// reached via its linked Page, so selection is still keyed by page id).
const igModeOn = () => metaSelected() && [...root.querySelectorAll('.ch-t:checked')].every(c => c.value === 'instagram');
// The rows currently shown — honors the search box AND (in IG mode) the
// IG-linked filter. All/None act on THIS list so "All" = all visible.
function visiblePages() {
const q = ($('#ch-pages-search') && $('#ch-pages-search').value.trim().toLowerCase()) || '';
let list = allPages.filter(p => !q || (p.name || '').toLowerCase().includes(q) || ('@' + (p.igUsername || '')).toLowerCase().includes(q));
if (igModeOn()) list = list.filter(p => p.hasIG); // IG posts can only go to IG-linked pages
return list;
}
function renderPages() {
const box = $('#ch-pages'); if (!box) return;
const igOnly = igModeOn();
const lbl = $('#ch-pages-label'); if (lbl) lbl.textContent = igOnly ? 'Instagram accounts' : 'Pages';
const list = visiblePages();
box.innerHTML = list.length ? list.map(p => igOnly
? `<label style="display:flex;align-items:center;gap:7px;padding:3px 4px;font-size:12.5px;border-radius:5px">
<input type="checkbox" class="ch-pg" value="${esc(p.id)}" style="width:auto" ${selectedPages.has(p.id) ? 'checked' : ''}>
<span style="font-weight:600;white-space:nowrap">📸 @${esc(p.igUsername || '')}</span>
<span class="muted" style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:11px" title="via Page: ${esc(p.name)}">${esc(p.name)}</span>
</label>`
: `<label style="display:flex;align-items:center;gap:7px;padding:3px 4px;font-size:12.5px;border-radius:5px">
<input type="checkbox" class="ch-pg" value="${esc(p.id)}" style="width:auto" ${selectedPages.has(p.id) ? 'checked' : ''}>
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(p.name)}</span>
${p.hasIG ? `<span class="muted" title="IG: @${esc(p.igUsername || '')}" style="font-size:11px">📸 @${esc(p.igUsername || '')}</span>` : ''}
</label>`).join('') : `<div class="muted">${igOnly ? 'No Instagram accounts match.' : 'No pages match.'}</div>`;
box.querySelectorAll('.ch-pg').forEach(c => c.addEventListener('change', () => {
if (c.checked) selectedPages.add(c.value); else selectedPages.delete(c.value);
savePageSel(); updatePagesCount();
}));
updatePagesCount();
}
function updatePagesCount() {
const el = $('#ch-pages-count'); if (!el) return;
if (igModeOn()) {
const igIds = new Set(allPages.filter(p => p.hasIG).map(p => p.id));
const selIg = [...selectedPages].filter(id => igIds.has(id)).length;
el.textContent = `· ${selIg} of ${igIds.size} Instagram accounts`;
} else {
el.textContent = `· ${selectedPages.size} of ${allPages.length} selected`;
}
}
async function loadPages(refresh) {
const box = $('#ch-pages'); if (box) box.innerHTML = '<div class="muted">Loading pages…</div>';
try {
const d = await jget('/api/channels/pages' + (refresh ? '?refresh=1' : ''));
if (d.error) { if (box) box.innerHTML = `<div class="muted">✗ ${esc(d.error)}</div>`; return; }
allPages = d.pages || []; pagesLoaded = true;
// drop any stale selections no longer in the account
const ids = new Set(allPages.map(p => p.id));
[...selectedPages].forEach(id => { if (!ids.has(id)) selectedPages.delete(id); });
$('#ch-pages-note').textContent = `${d.count} Pages · ${d.igCount} with Instagram · fetched ${fmtWhen(d.fetchedAt)}`;
renderPages();
} catch (e) { if (box) box.innerHTML = `<div class="muted">✗ ${esc(e.message)}</div>`; }
}
// picker controls
if ($('#ch-pages-search')) $('#ch-pages-search').addEventListener('input', renderPages);
if ($('#ch-pages-all')) $('#ch-pages-all').onclick = () => { visiblePages().forEach(p => selectedPages.add(p.id)); savePageSel(); renderPages(); };
if ($('#ch-pages-none')) $('#ch-pages-none').onclick = () => { selectedPages.clear(); savePageSel(); renderPages(); };
if ($('#ch-pages-ig')) $('#ch-pages-ig').onclick = () => { selectedPages.clear(); allPages.filter(p => p.hasIG).forEach(p => selectedPages.add(p.id)); savePageSel(); renderPages(); };
if ($('#ch-pages-refresh')) $('#ch-pages-refresh').onclick = () => loadPages(true);
// ── ① Activate page ──────────────────────────────────────────────────────
async function loadSetup() {
const d = await jget('/api/channels/setup');
// Host hint: OAuth completes on the redirect-base host, so creds must live there.
try {
const baseHost = new URL(d.redirectBase).host;
if (location.host !== baseHost) {
$('#ch-hosthint').innerHTML =
`<div class="muted-banner" style="background:#fff8ec;border-color:#f0e0c0;color:#7a5a2a">
⚠ Authorization finishes on <b>${esc(baseHost)}</b> (your OAuth redirect host). Save your keys and click Connect on
<a href="${esc(d.redirectBase)}/#channels" target="_blank" rel="noopener noreferrer"><b>the live activation page ↗</b></a> so the browser hand-off can complete.</div>`;
} else { $('#ch-hosthint').innerHTML = ''; }
} catch { $('#ch-hosthint').innerHTML = ''; }
$('#ch-setup').innerHTML = d.providers.map(p => {
const statusPills = p.platforms.map(pl => {
// needsReconnect (token present but expired/invalid) takes precedence over
// the presence-based states so a dead token reads amber, not a false "ready".
const s = pl.needsReconnect ? { bg: '#fff8ec', fg: '#7a5a2a', txt: 'reconnect needed' }
: pl.connected ? { bg: '#e3efe0', fg: '#3a6b3a', txt: 'connected' }
: pl.configured ? { bg: '#eef3f8', fg: '#3a5a8a', txt: 'ready' }
: { bg: '#f3e9e9', fg: '#9a5a5a', txt: 'needs keys' };
return `<span class="pill" style="background:${s.bg};color:${s.fg}"${pl.tokenError ? ` title="${esc(pl.tokenError)}"` : ''}>${esc(pl.label)}: ${s.txt}</span>`;
}).join(' ');
const redirects = p.redirectUris.map(r =>
`<div class="copyrow"><code>${esc(r.uri)}</code><button class="btn ghost xs" data-copy="${esc(r.uri)}">copy</button></div>`).join('');
const fields = p.fields.map(f =>
`<div class="fld"><label>${esc(f.label)} ${f.set ? '<span class="pill saved-pill">saved</span>' : ''}</label>
<input type="${f.secret ? 'password' : 'text'}" data-k="${esc(f.key)}" autocomplete="off"
placeholder="${f.set ? '•••••• saved — leave blank to keep' : esc(f.ph || '')}"></div>`).join('');
const connects = p.platforms.map(pl =>
pl.connected ? `<span class="pill" style="background:#e3efe0;color:#3a6b3a">✓ ${esc(pl.label)}</span>`
: (pl.connectUrl ? `<a class="btn gated" style="text-decoration:none" href="${ORIGIN}${esc(pl.connectUrl)}">${pl.needsReconnect ? 'Reconnect' : 'Connect'} ${esc(pl.label)} →</a>` : '')
).join(' ');
// Meta has a known gotcha: Ads-scoped apps can't grant posting permissions.
// Bake the exact 4-step fix into the card so it's waiting whenever Steve returns.
const metaDoc = p.id === 'meta' ? `
<details style="margin-top:10px;background:#fff8ec;border:1px solid #f0e0c0;border-radius:8px;padding:8px 10px">
<summary style="cursor:pointer;font-size:12px;font-weight:700;color:#7a5a2a">⚠ Token only offering 1 permission / "Facebook Login unavailable"? Read this first</summary>
<div style="font-size:12px;color:#7a5a2a;margin-top:8px;line-height:1.5">
The cause is always the same: an <b>Ads-scoped app</b> can't grant posting permissions, so the token comes back useless. Build the app in this exact order <b>before</b> generating any token:
<ol style="margin:8px 0 0;padding-left:18px">
<li>Create app → choose <b>“Other”</b> → type <b>Business</b> (NOT an Ads use case).</li>
<li>Add <b>two</b> products: <b>Instagram</b> <i>and</i> <b>Facebook Login</b>.</li>
<li><b>App Review → Permissions and Features</b> → confirm <code>pages_manage_posts</code> + <code>instagram_content_publish</code> show <b>Standard Access</b>.</li>
<li><b>Only then</b> generate the token (Graph API Explorer or a Business-Settings System User) — all 6 permissions will finally appear. Paste App ID + Secret above and Connect.</li>
</ol>
Norma's IG agent is a simulation stub — it has no token to reuse. There is no existing Meta token in the stack; one must be minted via the steps above.
</div>
</details>` : '';
return `<div class="card setup-card" style="margin:0">
<div style="display:flex;align-items:center;gap:8px"><span style="font-size:20px">${p.icon}</span><b>${esc(p.title)}</b></div>
<div style="margin:6px 0 4px">${statusPills}</div>
<div class="muted" style="font-size:12px;margin:6px 0 10px">${esc(p.blurb)}</div>
<a class="btn ghost xs" target="_blank" rel="noopener noreferrer" href="${esc(p.portal)}">Open ${esc(p.portalLabel)} ↗</a>
<div class="ch-sublabel">Redirect URI — add in portal</div>${redirects}
<div class="ch-sublabel">Permissions / scopes</div>
<div class="muted" style="font-size:11.5px">${esc(p.permissions)}</div>
<div class="ch-sublabel">App credentials</div>${fields}
${metaDoc}
<div class="row" style="margin-top:12px;align-items:center">
<button class="btn gold" data-save="${esc(p.id)}">Save credentials</button>
${connects}
<span class="muted setup-msg" data-msg="${esc(p.id)}" style="font-size:12px"></span>
</div>
</div>`;
}).join('');
// copy buttons
root.querySelectorAll('[data-copy]').forEach(b => b.onclick = async () => {
try { await navigator.clipboard.writeText(b.dataset.copy); const t = b.textContent; b.textContent = 'copied ✓'; setTimeout(() => b.textContent = t, 1200); }
catch { b.textContent = 'select & ⌘C'; }
});
// save buttons
root.querySelectorAll('[data-save]').forEach(b => b.onclick = async () => {
const card = b.closest('.setup-card');
const msg = card.querySelector('.setup-msg');
const keys = {};
card.querySelectorAll('input[data-k]').forEach(i => { if (i.value.trim()) keys[i.dataset.k] = i.value.trim(); });
if (!Object.keys(keys).length) { msg.textContent = 'nothing to save (fields blank)'; return; }
msg.textContent = 'saving…'; b.disabled = true;
try {
const r = await fetch(ORIGIN + '/api/channels/config', { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ keys }) });
const j = await r.json();
if (j.error) { msg.textContent = '✗ ' + j.error; }
else { msg.textContent = `✓ saved ${j.saved.length} key(s) — Connect now lit`; await loadStatus(); await loadSetup(); }
} catch (e) { msg.textContent = '✗ ' + e.message; }
b.disabled = false;
});
}
let outboxItems = [];
function renderOutbox() {
const sort = ($('#ch-outbox-sort') && $('#ch-outbox-sort').value) || 'newest';
const rows = outboxItems.slice();
rows.sort((a, b) => sort === 'oldest' ? (a.at || '').localeCompare(b.at || '')
: sort === 'channel' ? (a.channel || '').localeCompare(b.channel || '') || (b.at || '').localeCompare(a.at || '')
: (b.at || '').localeCompare(a.at || '')); // newest
$('#ch-outbox').innerHTML = rows.length ? rows.map(it => `
<div class="row" style="justify-content:space-between;align-items:center;border-bottom:1px solid var(--line);padding:7px 2px">
<div style="min-width:0">
<div>${esc(it.channel)} · <span class="muted">${esc((it.caption || '').slice(0, 50))}</span></div>
<div class="muted" style="font-size:11px" title="${esc(it.at || '')}">🕓 ${esc(fmtWhen(it.at))}</div>
</div>
<span class="pill">${esc(it.status)}</span></div>`).join('') : '<div class="muted">No posts yet.</div>';
}
async function loadOutbox() {
const d = await jget('/api/channels/outbox');
outboxItems = d.items || [];
renderOutbox();
}
if ($('#ch-outbox-sort')) $('#ch-outbox-sort').addEventListener('change', renderOutbox);
async function send(live) {
const channels = [...root.querySelectorAll('.ch-t:checked')].map(c => c.value);
const caption = $('#ch-caption').value.trim(); const mediaUrl = $('#ch-media').value.trim();
const pages = [...selectedPages];
if (!channels.length) { $('#ch-msg').textContent = 'pick at least one channel'; return; }
if (!caption && !mediaUrl) { $('#ch-msg').textContent = 'write a caption or add media'; return; }
// Facebook/Instagram need at least one Page selected (that's where the post goes)
if (metaSelected() && !pages.length) { $('#ch-msg').textContent = 'select at least one Page to post to (Pages picker above)'; return; }
if (live) {
const ps = status.platforms || {};
const caveats = channels.filter(c => ps[c] && ps[c].caveat).map(c => `• ${ps[c].label}: ${ps[c].caveat}`);
const warn = caveats.length ? `\n\n⚠ Heads up:\n${caveats.join('\n')}` : '';
const pageNote = metaSelected() ? `\n\nFacebook/Instagram → ${pages.length} selected Page(s).` : '';
if (!confirm(`PUBLISH LIVE to: ${channels.join(', ')}?${pageNote}\n\nConnected channels post immediately; unconnected ones stage.${warn}\n\nProceed?`)) return;
}
$('#ch-msg').textContent = live ? 'publishing…' : 'staging…';
const body = { channels, caption, mediaUrl, pages, confirm: live, dryRun: !live };
const r = await fetch(ORIGIN + '/api/channels/publish', { method: 'POST', headers: { 'content-type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify(body) });
const d = await r.json();
$('#ch-msg').textContent = d.error ? '✗ ' + d.error : (d.note || 'done');
loadOutbox(); loadStatus();
}
$('#ch-stage').onclick = () => send(false);
$('#ch-publish').onclick = () => send(true);
await loadStatus(); await loadSetup(); await loadOutbox();
},
};