← back to Marketing Command Center
Channels: add Facebook/Instagram Page picker — select WHICH of your 80 Pages (+ 11 linked IG) to post to.
5435736ff0280a7406e169399e1803ee0152e8ba · 2026-06-15 12:18:59 -0700 · SteveStudio2
- /api/channels/pages discovers all Pages via META_ACCESS_TOKEN (/me/accounts), caches per-Page tokens + IG links to data/meta-pages.json (gitignored); tokens stripped before sending to browser.
- postFacebook/postInstagram now post per-selected-Page using each Page's own token (legacy FB_PAGE_IDS path preserved as fallback).
- Composer: searchable Page picker (All/None/IG-linked/refresh), selection persisted to localStorage, shown when FB/IG targeted; publish guard requires >=1 Page.
- platformStatus marks FB/IG connected when META_ACCESS_TOKEN present.
Tested locally: /pages 200 (80 pages, 11 IG, no token leak), no-auth 401.
Files touched
M .gitignoreM modules/channels/index.jsM public/panels/channels.htmlM public/panels/channels.js
Diff
commit 5435736ff0280a7406e169399e1803ee0152e8ba
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Mon Jun 15 12:18:59 2026 -0700
Channels: add Facebook/Instagram Page picker — select WHICH of your 80 Pages (+ 11 linked IG) to post to.
- /api/channels/pages discovers all Pages via META_ACCESS_TOKEN (/me/accounts), caches per-Page tokens + IG links to data/meta-pages.json (gitignored); tokens stripped before sending to browser.
- postFacebook/postInstagram now post per-selected-Page using each Page's own token (legacy FB_PAGE_IDS path preserved as fallback).
- Composer: searchable Page picker (All/None/IG-linked/refresh), selection persisted to localStorage, shown when FB/IG targeted; publish guard requires >=1 Page.
- platformStatus marks FB/IG connected when META_ACCESS_TOKEN present.
Tested locally: /pages 200 (80 pages, 11 IG, no token leak), no-auth 401.
---
.gitignore | 1 +
modules/channels/index.js | 132 ++++++++++++++++++++++++++++++++++----------
public/panels/channels.html | 18 ++++++
public/panels/channels.js | 67 +++++++++++++++++++++-
4 files changed, 186 insertions(+), 32 deletions(-)
diff --git a/.gitignore b/.gitignore
index 97cb27f..064d32b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,3 +20,4 @@ data/reposts.json
# Meta go-live creds — real Page token, never commit
scripts/meta-creds.env
+data/meta-pages.json
diff --git a/modules/channels/index.js b/modules/channels/index.js
index 70a140d..1838634 100644
--- a/modules/channels/index.js
+++ b/modules/channels/index.js
@@ -14,6 +14,38 @@ const OUTBOX = path.join(__dirname, '..', '..', 'data', 'channels-outbox.json');
const readOutbox = () => { try { return JSON.parse(fs.readFileSync(OUTBOX, 'utf8')); } catch { return []; } };
const writeOutbox = (a) => { fs.mkdirSync(path.dirname(OUTBOX), { recursive: true }); fs.writeFileSync(OUTBOX, JSON.stringify(a, null, 2)); };
const env = (k) => (process.env[k] || '').trim();
+const GRAPH = 'https://graph.facebook.com/v23.0';
+
+// ── Meta Pages cache (the user's 80 FB Pages + per-Page tokens + linked IG) ─────
+// Discovered from /me/accounts with META_ACCESS_TOKEN (a user token). Each Page
+// carries its OWN access_token (that's what you post to a Page with) + an optional
+// linked Instagram business account. Cached here so the picker + per-Page posting
+// don't re-hit Graph on every render. CONTAINS PAGE TOKENS → gitignored, never
+// sent to the browser (the /pages route sanitizes the tokens out).
+const META_PAGES = path.join(__dirname, '..', '..', 'data', 'meta-pages.json');
+const readMetaPages = () => { try { return JSON.parse(fs.readFileSync(META_PAGES, 'utf8')); } catch { return { fetchedAt: null, pages: [] }; } };
+const writeMetaPages = (o) => { fs.mkdirSync(path.dirname(META_PAGES), { recursive: true }); fs.writeFileSync(META_PAGES, JSON.stringify(o, null, 2)); };
+async function fetchMetaPages() {
+ const token = env('META_ACCESS_TOKEN');
+ if (!token) throw new Error('META_ACCESS_TOKEN not set — paste it into .env first');
+ let pages = [], url = `${GRAPH}/me/accounts?fields=name,id,access_token,instagram_business_account{id,username},tasks&limit=100&access_token=${encodeURIComponent(token)}`;
+ let guard = 0;
+ while (url && guard++ < 20) {
+ const r = await fetch(url); const j = await r.json();
+ if (j.error) throw new Error(j.error.message);
+ for (const p of (j.data || [])) pages.push({
+ id: p.id, name: p.name, token: p.access_token || null,
+ igId: p.instagram_business_account?.id || null,
+ igUsername: p.instagram_business_account?.username || null,
+ canPost: (p.tasks || []).includes('CREATE_CONTENT'),
+ });
+ url = j.paging?.next || null;
+ }
+ pages.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
+ const cache = { fetchedAt: new Date().toISOString(), pages };
+ writeMetaPages(cache);
+ return cache;
+}
// ── OAuth connect machinery ───────────────────────────────────────────────────
// Tokens minted by the connect flow are stored here (gitignored), so a one-time
@@ -148,16 +180,20 @@ function platformStatus() {
const base = {
facebook: {
label: 'Facebook Pages', icon: '📘',
- connected: !!(env('META_APP_ID') && env('FB_PAGE_ACCESS_TOKEN')),
- accounts: env('FB_PAGE_IDS') ? env('FB_PAGE_IDS').split(',').map(s => s.trim()).filter(Boolean) : [],
- needs: 'Meta app (App ID/Secret) + per-Page access tokens with pages_manage_posts. "All your FB accounts" = paste each Page ID + token.',
+ // A user META_ACCESS_TOKEN unlocks per-Page tokens (via /me/accounts) — that's
+ // "connected" for the page-picker flow, independent of the legacy single-page env.
+ connected: !!(env('META_ACCESS_TOKEN') || (env('META_APP_ID') && env('FB_PAGE_ACCESS_TOKEN'))),
+ accounts: env('META_ACCESS_TOKEN') ? readMetaPages().pages.map(p => p.id)
+ : (env('FB_PAGE_IDS') ? env('FB_PAGE_IDS').split(',').map(s => s.trim()).filter(Boolean) : []),
+ needs: 'Meta user token (META_ACCESS_TOKEN) → per-Page tokens auto-discovered. Live posting needs pages_manage_posts on the token.',
scopes: ['pages_manage_posts', 'pages_read_engagement', 'business_management'],
},
instagram: {
label: 'Instagram', icon: '📸',
- connected: !!(env('META_APP_ID') && env('IG_ACCESS_TOKEN') && env('IG_BUSINESS_ACCOUNT_ID')),
- accounts: env('IG_BUSINESS_ACCOUNT_ID') ? [env('IG_BUSINESS_ACCOUNT_ID')] : [],
- needs: 'IG Business account linked to a FB Page + long-lived token with instagram_content_publish (same Meta app as Facebook).',
+ connected: !!(env('META_ACCESS_TOKEN') || (env('META_APP_ID') && env('IG_ACCESS_TOKEN') && env('IG_BUSINESS_ACCOUNT_ID'))),
+ accounts: env('META_ACCESS_TOKEN') ? readMetaPages().pages.filter(p => p.igId).map(p => p.igUsername || p.igId)
+ : (env('IG_BUSINESS_ACCOUNT_ID') ? [env('IG_BUSINESS_ACCOUNT_ID')] : []),
+ needs: 'IG Business account linked to a selected Page. Live posting needs instagram_content_publish on the token.',
scopes: ['instagram_content_publish', 'instagram_basic'],
},
tiktok: {
@@ -190,34 +226,57 @@ function platformStatus() {
}
// ── Real adapters (fire only when connected) ──────────────────────────────────
-async function postFacebook(content) {
- const token = env('FB_PAGE_ACCESS_TOKEN'); const pages = env('FB_PAGE_IDS').split(',').map(s => s.trim()).filter(Boolean);
+async function postFacebook(content, opts = {}) {
+ // Two modes: (a) selected-pages — post to a chosen subset using each Page's OWN
+ // token from the cache; (b) legacy — FB_PAGE_IDS + one FB_PAGE_ACCESS_TOKEN.
+ let targets = [];
+ if (opts.pages && opts.pages.length) {
+ const byId = Object.fromEntries(readMetaPages().pages.map(p => [String(p.id), p]));
+ for (const id of opts.pages) { const p = byId[String(id)]; if (p && p.token) targets.push({ id: p.id, token: p.token, name: p.name }); }
+ if (!targets.length) return [{ ok: false, error: 'selected Pages not in cache — click ↻ refresh on the Pages picker' }];
+ } else {
+ const token = env('FB_PAGE_ACCESS_TOKEN');
+ targets = env('FB_PAGE_IDS').split(',').map(s => s.trim()).filter(Boolean).map(id => ({ id, token, name: id }));
+ }
const results = [];
- for (const pageId of pages) {
- const url = `https://graph.facebook.com/v21.0/${pageId}/${content.mediaUrl ? 'photos' : 'feed'}`;
- const body = content.mediaUrl ? { url: content.mediaUrl, caption: content.caption, access_token: token }
- : { message: content.caption, access_token: token };
+ for (const t of targets) {
+ const url = `${GRAPH}/${t.id}/${content.mediaUrl ? 'photos' : 'feed'}`;
+ const body = content.mediaUrl ? { url: content.mediaUrl, caption: content.caption, access_token: t.token }
+ : { message: content.caption, access_token: t.token };
const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
const j = await r.json().catch(() => ({}));
- results.push({ pageId, ok: r.ok, id: j.id || j.post_id, error: r.ok ? null : (j.error?.message || `HTTP ${r.status}`) });
+ results.push({ pageId: t.id, name: t.name, ok: r.ok, id: j.id || j.post_id, error: r.ok ? null : (j.error?.message || `HTTP ${r.status}`) });
}
return results;
}
-async function postInstagram(content) {
- const token = env('IG_ACCESS_TOKEN'); const igId = env('IG_BUSINESS_ACCOUNT_ID');
+async function postInstagram(content, opts = {}) {
if (!content.mediaUrl) return [{ ok: false, error: 'Instagram requires an image/video URL' }];
- const create = await fetch(`https://graph.facebook.com/v21.0/${igId}/media`, {
- method: 'POST', headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ image_url: content.mediaUrl, caption: content.caption, access_token: token }),
- });
- const cj = await create.json().catch(() => ({}));
- if (!create.ok || !cj.id) return [{ ok: false, error: cj.error?.message || 'media create failed' }];
- const pub = await fetch(`https://graph.facebook.com/v21.0/${igId}/media_publish`, {
- method: 'POST', headers: { 'content-type': 'application/json' },
- body: JSON.stringify({ creation_id: cj.id, access_token: token }),
- });
- const pj = await pub.json().catch(() => ({}));
- return [{ ok: pub.ok, id: pj.id, error: pub.ok ? null : (pj.error?.message || 'publish failed') }];
+ // selected-pages → post to each selected Page's LINKED IG account (Page token);
+ // legacy → single IG_BUSINESS_ACCOUNT_ID + IG_ACCESS_TOKEN.
+ let igTargets = [];
+ if (opts.pages && opts.pages.length) {
+ const byId = Object.fromEntries(readMetaPages().pages.map(p => [String(p.id), p]));
+ for (const id of opts.pages) { const p = byId[String(id)]; if (p && p.igId && p.token) igTargets.push({ igId: p.igId, token: p.token, name: p.igUsername || p.name }); }
+ if (!igTargets.length) return [{ ok: false, error: 'none of the selected Pages have a linked Instagram account' }];
+ } else {
+ igTargets = [{ igId: env('IG_BUSINESS_ACCOUNT_ID'), token: env('IG_ACCESS_TOKEN'), name: 'IG' }];
+ }
+ const results = [];
+ for (const t of igTargets) {
+ const create = await fetch(`${GRAPH}/${t.igId}/media`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ image_url: content.mediaUrl, caption: content.caption, access_token: t.token }),
+ });
+ const cj = await create.json().catch(() => ({}));
+ if (!create.ok || !cj.id) { results.push({ igId: t.igId, name: t.name, ok: false, error: cj.error?.message || 'media create failed' }); continue; }
+ const pub = await fetch(`${GRAPH}/${t.igId}/media_publish`, {
+ method: 'POST', headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({ creation_id: cj.id, access_token: t.token }),
+ });
+ const pj = await pub.json().catch(() => ({}));
+ results.push({ igId: t.igId, name: t.name, ok: pub.ok, id: pj.id, error: pub.ok ? null : (pj.error?.message || 'publish failed') });
+ }
+ return results;
}
async function postTikTok(content) {
// TikTok Content Posting API (direct post). Requires audited app.
@@ -248,6 +307,18 @@ module.exports = {
});
router.get('/outbox', (_req, res) => res.json({ items: readOutbox().slice(-100).reverse() }));
+ // The Page picker's data source: the user's FB Pages (+ linked IG), discovered
+ // from /me/accounts. Tokens are STRIPPED before sending to the browser. Pass
+ // ?refresh=1 to re-fetch from Graph; otherwise serves the cache.
+ router.get('/pages', async (req, res) => {
+ try {
+ let cache = readMetaPages();
+ if (req.query.refresh === '1' || !cache.pages.length) cache = await fetchMetaPages();
+ const pages = cache.pages.map(p => ({ id: p.id, name: p.name, igUsername: p.igUsername, hasIG: !!p.igId, canPost: !!p.canPost }));
+ res.json({ fetchedAt: cache.fetchedAt, count: pages.length, igCount: pages.filter(p => p.hasIG).length, hasToken: !!env('META_ACCESS_TOKEN'), pages });
+ } catch (e) { res.status(500).json({ error: e.message }); }
+ });
+
// Activate page data: provider cards + which keys are already set (booleans only,
// never the values) + the redirect URIs to register in each portal.
router.get('/setup', (_req, res) => {
@@ -310,6 +381,7 @@ module.exports = {
try {
const d = req.body || {};
const channels = Array.isArray(d.channels) ? d.channels.filter(c => ADAPTERS[c]) : [];
+ const selectedPages = Array.isArray(d.pages) ? d.pages.map(String).filter(Boolean) : [];
const content = { caption: (d.caption || '').slice(0, 2200), mediaUrl: d.mediaUrl || '' };
if (!channels.length) return res.status(400).json({ error: 'no channels selected' });
if (!content.caption && !content.mediaUrl) return res.status(400).json({ error: 'empty post' });
@@ -320,9 +392,9 @@ module.exports = {
for (const ch of channels) {
const connected = st[ch].connected;
if (live && connected) {
- const r = await ADAPTERS[ch](content).catch(e => [{ ok: false, error: e.message }]);
- const ok = r.every(x => x.ok);
- outbox.push({ id: 'pub-' + ch + '-' + outbox.length, channel: ch, caption: content.caption, mediaUrl: content.mediaUrl, status: ok ? 'posted' : 'failed', detail: r, at: new Date().toISOString() });
+ const r = await ADAPTERS[ch](content, { pages: selectedPages }).catch(e => [{ ok: false, error: e.message }]);
+ const ok = r.length > 0 && r.every(x => x.ok);
+ outbox.push({ id: 'pub-' + ch + '-' + outbox.length, channel: ch, caption: content.caption, mediaUrl: content.mediaUrl, pages: selectedPages.length || undefined, status: ok ? 'posted' : 'failed', detail: r, at: new Date().toISOString() });
results.push({ channel: ch, live: true, ok, detail: r });
} else {
const reason = !connected ? 'pending-connection' : (d.dryRun !== false ? 'staged (dry-run)' : 'staged (needs confirm)');
diff --git a/public/panels/channels.html b/public/panels/channels.html
index 0af3813..0fdc8c9 100644
--- a/public/panels/channels.html
+++ b/public/panels/channels.html
@@ -19,6 +19,24 @@
<input type="text" id="ch-media" placeholder="https://… (use the Layouts or Assets panel)">
<label style="margin-top:10px">Post to</label>
<div class="row" id="ch-targets"></div>
+
+ <!-- Page picker: appears when Facebook/Instagram is selected. Choose WHICH
+ of your FB Pages (and their linked IG accounts) the post goes to. -->
+ <div id="ch-pages-wrap" style="margin-top:12px;display:none">
+ <label>Pages <span class="muted" id="ch-pages-count" style="font-weight:400"></span></label>
+ <div class="row" style="gap:6px;margin:4px 0;align-items:center">
+ <input type="text" id="ch-pages-search" placeholder="filter pages…" style="flex:1;min-width:150px">
+ <button class="btn ghost xs" id="ch-pages-all" type="button">All</button>
+ <button class="btn ghost xs" id="ch-pages-none" type="button">None</button>
+ <button class="btn ghost xs" id="ch-pages-ig" type="button" title="select only Pages with a linked Instagram">📸 IG-linked</button>
+ <button class="btn ghost xs" id="ch-pages-refresh" type="button" title="re-fetch your Pages from Meta">↻</button>
+ </div>
+ <div id="ch-pages" style="max-height:240px;overflow:auto;border:1px solid var(--line);border-radius:8px;padding:6px">
+ <div class="muted">Loading pages…</div>
+ </div>
+ <div class="muted" id="ch-pages-note" style="font-size:11px;margin-top:4px"></div>
+ </div>
+
<div class="row" style="margin-top:14px;align-items:center">
<button class="btn gold" id="ch-stage">Stage post</button>
<button class="btn gated" id="ch-publish">🚀 Publish live…</button>
diff --git a/public/panels/channels.js b/public/panels/channels.js
index c04ccfd..9938654 100644
--- a/public/panels/channels.js
+++ b/public/panels/channels.js
@@ -16,8 +16,67 @@ window.MCC_PANELS['channels'] = {
(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 && !pagesLoaded) loadPages(false);
+ }
+
+ function renderPages() {
+ const box = $('#ch-pages'); if (!box) return;
+ const q = ($('#ch-pages-search') && $('#ch-pages-search').value.trim().toLowerCase()) || '';
+ const igOnly = metaSelected() && [...root.querySelectorAll('.ch-t:checked')].every(c => c.value === 'instagram');
+ let list = allPages.filter(p => !q || (p.name || '').toLowerCase().includes(q));
+ if (igOnly) list = list.filter(p => p.hasIG); // posting IG-only → only IG-linked pages can receive it
+ box.innerHTML = list.length ? list.map(p =>
+ `<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">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;
+ 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 = () => { allPages.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');
@@ -127,16 +186,20 @@ window.MCC_PANELS['channels'] = {
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')}` : '';
- if (!confirm(`PUBLISH LIVE to: ${channels.join(', ')}?\n\nConnected channels post immediately; unconnected ones stage.${warn}\n\nProceed?`)) return;
+ 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, confirm: live, dryRun: !live };
+ 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');
← a32a889 Add Streams Board — Hootsuite-style column view (social chan
·
back to Marketing Command Center
·
Add Meta App Review prep doc (System-User route + full revie e76aecd →