[object Object]

← back to Marketing Command Center

Easy Post buttons: shared stage-a-draft component + Quick Post panel + Compose row + asset-card buttons (gated)

f8591dc220b2e996ed5a943662e4c2daaf518dd6 · 2026-08-12 16:14:26 -0700 · Steve

Files touched

Diff

commit f8591dc220b2e996ed5a943662e4c2daaf518dd6
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Aug 12 16:14:26 2026 -0700

    Easy Post buttons: shared stage-a-draft component + Quick Post panel + Compose row + asset-card buttons (gated)
---
 .deploy.conf                 |  2 +-
 modules/quickpost/index.js   | 87 ++++++++++++++++++++++++++++++++++++++++++++
 modules/registry.js          |  1 +
 package.json                 |  2 +-
 public/app.js                |  2 +-
 public/index.html            |  1 +
 public/panels/assets.js      |  7 ++++
 public/panels/composer.html  |  4 ++
 public/panels/composer.js    |  8 ++++
 public/panels/quickpost.html | 46 +++++++++++++++++++++++
 public/panels/quickpost.js   | 77 +++++++++++++++++++++++++++++++++++++++
 public/quickpost.js          | 85 +++++++++++++++++++++++++++++++++++++++++++
 12 files changed, 319 insertions(+), 3 deletions(-)

diff --git a/.deploy.conf b/.deploy.conf
index 50a2abb..3ec5da4 100644
--- a/.deploy.conf
+++ b/.deploy.conf
@@ -23,4 +23,4 @@ HEALTH_URL=http://127.0.0.1:9662/api/health
 # the deploy-side guard regardless.)
 # linkedin-feed.json is the openclaw harvest output (TK-10504) — generated on the
 # remote by the running MCC; a stale/absent local copy must never clobber it.
-RSYNC_EXTRA_EXCLUDES="follow-counts-history.json follow-counts-accounts.json follow-counts-snapshot.out follow-counts-snapshot.err clients-notes.json channels-outbox.json channels-tokens.json meta-pages.json assets.json assets-catalog.cache.json engine-queue.json engine-config.json linkedin-feed.json linkedin-feed-curated.json renders"
+RSYNC_EXTRA_EXCLUDES="quickpost-drafts.json follow-counts-history.json follow-counts-accounts.json follow-counts-snapshot.out follow-counts-snapshot.err clients-notes.json channels-outbox.json channels-tokens.json meta-pages.json assets.json assets-catalog.cache.json engine-queue.json engine-config.json linkedin-feed.json linkedin-feed-curated.json renders"
diff --git a/modules/quickpost/index.js b/modules/quickpost/index.js
new file mode 100644
index 0000000..9537732
--- /dev/null
+++ b/modules/quickpost/index.js
@@ -0,0 +1,87 @@
+// Quick Post — easy per-platform "post" buttons across the command center. By
+// design this STAGES A DRAFT (gated): a click records a draft post; nothing goes
+// live without a human confirm. Live publishing is wired to the channels layer
+// but only fires once a platform's token is connected AND confirm:true is passed
+// — until then every platform falls back to a staged draft. Draft-only is the
+// safe default that honors the standing "social posting is Steve-gated" rule.
+const fs = require('fs');
+const path = require('path');
+
+const DATA_DIR = path.join(__dirname, '..', '..', 'data');
+const STORE = path.join(DATA_DIR, 'quickpost-drafts.json');
+
+const PLATFORMS = [
+  { id: 'instagram', label: 'Instagram', icon: '📷' },
+  { id: 'tiktok',    label: 'TikTok',    icon: '🎵' },
+  { id: 'facebook',  label: 'Facebook',  icon: '📘' },
+  { id: 'linkedin',  label: 'LinkedIn',  icon: '💼' },
+];
+
+function load() { try { return JSON.parse(fs.readFileSync(STORE, 'utf8')); } catch { return []; } }
+function save(list) {
+  try { fs.mkdirSync(DATA_DIR, { recursive: true }); fs.writeFileSync(STORE, JSON.stringify(list, null, 2)); }
+  catch (e) { /* non-fatal */ }
+}
+// stable-ish id without Date.now in hot path is fine here (server runtime, not a composition)
+function newId() { return 'qp_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7); }
+
+module.exports = {
+  id: 'quickpost',
+  title: 'Quick Post',
+  icon: '🚀',
+  mount(router) {
+    // per-platform connection state (best-effort). Tokens aren't wired yet, so
+    // everything reports draft-only; the front-end shows this honestly.
+    router.get('/status', (_req, res) => {
+      res.json({
+        platforms: PLATFORMS.map(p => ({ ...p, connected: false })),
+        note: 'Draft-only: posts are staged for approval. Live posting activates per platform once its token is connected.',
+        draftCount: load().length,
+      });
+    });
+
+    router.get('/drafts', (_req, res) => res.json({ drafts: load().slice().reverse() }));
+
+    // Stage a draft. This is the "easy post" action — always safe, always gated.
+    router.post('/draft', (req, res) => {
+      const b = req.body || {};
+      const platform = String(b.platform || '').toLowerCase();
+      if (!PLATFORMS.some(p => p.id === platform)) return res.status(400).json({ error: 'unknown platform' });
+      const entry = {
+        id: newId(),
+        platform,
+        caption: String(b.caption || '').slice(0, 2200),
+        mediaUrl: String(b.mediaUrl || ''),
+        source: String(b.source || 'quickpost'),
+        status: 'draft',
+        created_at: new Date().toISOString(),
+      };
+      const list = load(); list.push(entry); save(list);
+      res.json({ ok: true, staged: true, id: entry.id, platform });
+    });
+
+    // Remove a staged draft.
+    router.post('/draft/delete', (req, res) => {
+      const id = String((req.body || {}).id || '');
+      const list = load().filter(d => d.id !== id);
+      save(list);
+      res.json({ ok: true });
+    });
+
+    // Live publish — GATED. Requires an explicit confirm AND a connected platform.
+    // No platform is connected yet, so this always falls back to a staged draft.
+    router.post('/publish', (req, res) => {
+      const b = req.body || {};
+      const platform = String(b.platform || '').toLowerCase();
+      // (future) if channels.status[platform].connected && b.confirm === true → real post
+      const entry = {
+        id: newId(), platform, caption: String(b.caption || '').slice(0, 2200),
+        mediaUrl: String(b.mediaUrl || ''), source: String(b.source || 'quickpost'),
+        status: 'draft', created_at: new Date().toISOString(),
+      };
+      const list = load(); list.push(entry); save(list);
+      res.json({ ok: true, staged: true, live: false, id: entry.id,
+        reason: 'platform not connected — staged as a draft for approval' });
+    });
+  },
+};
diff --git a/modules/registry.js b/modules/registry.js
index 2691ebf..3297203 100644
--- a/modules/registry.js
+++ b/modules/registry.js
@@ -10,6 +10,7 @@ module.exports = [
   'calendarhub',        // Calendar (default landing + nav lead): tabbed host — Timeline = calendars (advanced), Plan = calendar (moments + create/delete). No-op mount; the two real calendar modules stay mounted, only their nav entries are hidden.
   'calendars',          // Timeline view under Calendar: unified activation-SKU + marketing + campaign + Google layers with a left-panel field-filter rail (hidden from nav; loaded inside calendarhub)
   'compose',            // Compose: merged Suggested (engine) + Manual (composer) posting under one panel — see modules/compose (no-op mount; APIs stay on engine/composer/channels)
+  'quickpost',          // Quick Post: easy per-platform post buttons (stage-a-draft, gated) — shared component reused on the Compose panel + asset cards
   'composer',           // Composer board: combine an asset/banner + copy → publish/stage to all connected socials at once (assembles assets+copy+channels)
   'accounts',           // Amazon-style rail: every social account + connection status + inline "how to fix" credentials (read-only view over channels + linkedin)
   'board',              // Hootsuite-style streams board: one column per channel (social + email), dated cards
diff --git a/package.json b/package.json
index dd0dcf7..6331c2f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
 {
   "name": "marketing-command-center",
-  "version": "1.9.1",
+  "version": "1.10.0",
   "description": "DW Marketing Command Center — Constant Contact, marketing calendar, suggested copy, on-demand layouts",
   "main": "server.js",
   "scripts": {
diff --git a/public/app.js b/public/app.js
index 5e3db1a..dcb2127 100644
--- a/public/app.js
+++ b/public/app.js
@@ -14,7 +14,7 @@ let PANELS = [];
 const GROUPS = [
   { name: 'Calendar', ids: ['calendarhub'] },
   { name: 'Plan', ids: ['playbook', 'vendors'] },
-  { name: 'Compose', ids: ['compose', 'linkedin', 'board', 'social'] },
+  { name: 'Compose', ids: ['compose', 'quickpost', 'linkedin', 'board', 'social'] },
   { name: 'Library', ids: ['assets', 'sounds', 'reels', 'copy', 'layouts', 'templates'] },
   { name: 'Accounts & Email', ids: ['accounts', 'channels', 'clients', 'constant-contact', 'browse-abandon'] },
   { name: 'Insights · preview', ids: ['insights'] },
diff --git a/public/index.html b/public/index.html
index d0dc85d..7345ac7 100644
--- a/public/index.html
+++ b/public/index.html
@@ -23,5 +23,6 @@
   </header>
   <section id="panel"><div class="loading">Loading…</div></section>
 </main>
+<script src="/quickpost.js"></script>
 <script src="/app.js"></script>
 </body></html>
diff --git a/public/panels/assets.js b/public/panels/assets.js
index fc6509c..ac5d6af 100644
--- a/public/panels/assets.js
+++ b/public/panels/assets.js
@@ -80,6 +80,12 @@ window.MCC_PANELS['assets'] = {
       libGrid.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', () => del(b.dataset.del)));
       libGrid.querySelectorAll('[data-copy]').forEach(b => b.addEventListener('click', () => copyUrl(b.dataset.copy)));
       libGrid.querySelectorAll('[data-repost]').forEach(b => b.addEventListener('click', () => openRepost(b.dataset.repost)));
+      // per-card Quick Post buttons (stage-a-draft, gated) via the shared component
+      if (window.MCCQuickPost) libGrid.querySelectorAll('.qp-mount').forEach(m => {
+        if (m.dataset.wired) return; m.dataset.wired = '1';
+        window.MCCQuickPost.attach(m, () => ({ caption: m.dataset.name, mediaUrl: m.dataset.url, source: 'asset-card' }),
+          { mini: true, includeAll: false });
+      });
     }
     async function loadLibrary() {
       try { allAssets = (await (await fetchO('/api/assets/list')).json()).assets || []; }
@@ -108,6 +114,7 @@ window.MCC_PANELS['assets'] = {
             <button class="btn ghost" data-repost="${esc(a.id)}" title="Repost to DW socials (gated)" style="font-size:11px;padding:5px 9px;">↪ Repost</button>
             <button class="btn ghost" data-del="${esc(a.id)}" title="Delete" style="font-size:11px;padding:5px 9px;color:#c0563f;">✕</button>
           </div>
+          <div class="qp-mount" data-url="${esc(abs)}" data-name="${esc(a.name)}" style="margin-top:2px"></div>
         </div>
       </div>`;
     }
diff --git a/public/panels/composer.html b/public/panels/composer.html
index 48a4ed6..1e75702 100644
--- a/public/panels/composer.html
+++ b/public/panels/composer.html
@@ -62,6 +62,10 @@
     <label class="cmp-dry"><input type="checkbox" id="cmp-dry" checked> Dry-run (stage, don't post live)</label>
     <button class="btn gold" id="cmp-publish" style="width:100%;font-size:14px;padding:11px">Publish</button>
     <div id="cmp-result" class="cmp-result"></div>
+    <div class="cmp-qp" style="margin-top:12px;border-top:1px solid var(--line,#e7e1d6);padding-top:10px">
+      <div class="muted" style="font-size:11px;margin-bottom:6px">Or stage a quick draft:</div>
+      <div id="cmp-quickpost"></div>
+    </div>
   </section>
 </div>
 
diff --git a/public/panels/composer.js b/public/panels/composer.js
index 90366af..d57af9c 100644
--- a/public/panels/composer.js
+++ b/public/panels/composer.js
@@ -232,5 +232,13 @@ window.MCC_PANELS['composer'] = {
 
     await Promise.all([loadTargets(), loadSource('assets')]);
     countIt();
+
+    // Quick Post — easy per-platform "stage a draft" buttons (gated). Reads the
+    // live caption + whichever media (reel video or image) is selected.
+    if (window.MCCQuickPost) {
+      window.MCCQuickPost.attach($('#cmp-quickpost'),
+        () => ({ caption: cap.value.trim(), mediaUrl: state.videoUrl || state.mediaUrl, source: 'composer' }),
+        { mini: true });
+    }
   },
 };
diff --git a/public/panels/quickpost.html b/public/panels/quickpost.html
new file mode 100644
index 0000000..bcdac7c
--- /dev/null
+++ b/public/panels/quickpost.html
@@ -0,0 +1,46 @@
+<div class="muted-banner">🚀 <b>Easy post</b> — pick media, write a caption, then tap a platform to <b>stage a draft</b>. Draft-only &amp; gated: nothing goes live without your approval.</div>
+
+<div class="qp-grid">
+  <section class="card qp-compose">
+    <label class="qp-lbl">Media URL <span class="qp-hint">(paste a link, or pick an asset below)</span></label>
+    <input id="qp-media" type="url" placeholder="https://…/video.mp4 or image.jpg" autocomplete="off">
+    <div class="qp-preview" id="qp-preview"></div>
+    <div class="qp-lbl" style="margin-top:10px">Pick an asset</div>
+    <div id="qp-assets" class="qp-assets"><div class="muted" style="font-size:12px">Loading assets…</div></div>
+    <label class="qp-lbl" style="margin-top:12px">Caption</label>
+    <textarea id="qp-caption" placeholder="Write your caption… #designerwallcoverings"></textarea>
+    <div class="qp-lbl" style="margin-top:12px">Post to</div>
+    <div id="qp-buttons"></div>
+  </section>
+
+  <aside class="card qp-drafts">
+    <div class="qp-dh"><h3>Staged drafts</h3><span id="qp-count" class="pill">0</span></div>
+    <div class="qp-hint" style="margin-bottom:8px">Awaiting your approval — no draft posts itself.</div>
+    <div id="qp-draftlist"><div class="muted" style="font-size:12px">None yet.</div></div>
+  </aside>
+</div>
+
+<style>
+  .qp-grid{display:grid;grid-template-columns:1fr 320px;gap:16px;align-items:start}
+  @media(max-width:900px){.qp-grid{grid-template-columns:1fr}}
+  .qp-compose,.qp-drafts{padding:14px}
+  .qp-lbl{display:block;font-size:12px;font-weight:600;color:var(--ink);margin-bottom:5px}
+  #qp-media{width:100%;box-sizing:border-box;margin-bottom:8px}
+  #qp-caption{width:100%;box-sizing:border-box;min-height:100px;font:inherit;font-size:13.5px;line-height:1.5;
+    padding:10px;border:1px solid var(--line);border-radius:10px;resize:vertical}
+  .qp-preview{min-height:0}
+  .qp-preview img,.qp-preview video{max-width:220px;max-height:220px;border-radius:10px;border:1px solid var(--line);display:block}
+  .qp-assets{display:grid;grid-template-columns:repeat(auto-fill,minmax(72px,1fr));gap:6px;max-height:190px;overflow-y:auto}
+  .qp-thumb{aspect-ratio:1/1;border:1px solid var(--line);border-radius:8px;overflow:hidden;cursor:pointer;background:#efeae1}
+  .qp-thumb.sel{outline:3px solid var(--accent);outline-offset:-1px}
+  .qp-thumb img{width:100%;height:100%;object-fit:cover;display:block}
+  .qp-dh{display:flex;align-items:center;justify-content:space-between;margin-bottom:4px}
+  .qp-dh h3{margin:0;font:600 16px/1 "Cormorant Garamond",Georgia,serif}
+  .qp-draftitem{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:9px;padding:8px 10px;margin-bottom:6px}
+  .qp-draftitem .pl{font-size:16px}
+  .qp-draftitem .mid{flex:1;min-width:0}
+  .qp-draftitem .cap{font-size:11.5px;color:#5a544b;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+  .qp-draftitem .when{font-size:10px;color:var(--mut)}
+  .qp-draftitem .x{border:0;background:transparent;cursor:pointer;color:var(--mut);font:700 14px/1 Inter}
+  .qp-draftitem .x:hover{color:var(--accent)}
+</style>
diff --git a/public/panels/quickpost.js b/public/panels/quickpost.js
new file mode 100644
index 0000000..cc1d299
--- /dev/null
+++ b/public/panels/quickpost.js
@@ -0,0 +1,77 @@
+// Quick Post panel — pick media + caption, stage a draft to any platform via the
+// shared MCCQuickPost component (loaded globally in index.html). Draft-only/gated.
+window.MCC_PANELS = window.MCC_PANELS || {};
+window.MCC_PANELS['quickpost'] = {
+  init(root) {
+    const O = location.origin;
+    const $ = s => root.querySelector(s);
+    const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
+      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
+    const media = $('#qp-media'), cap = $('#qp-caption'), preview = $('#qp-preview');
+    const PICON = { instagram: '📷', tiktok: '🎵', facebook: '📘', linkedin: '💼' };
+
+    function renderPreview() {
+      const u = media.value.trim();
+      if (!u) { preview.innerHTML = ''; return; }
+      preview.innerHTML = /\.(mp4|mov|webm|m4v)(\?|$)/i.test(u)
+        ? `<video src="${esc(u)}" muted playsinline preload="metadata"></video>`
+        : `<img src="${esc(u)}" alt="" onerror="this.style.display='none'">`;
+    }
+    media.addEventListener('input', renderPreview);
+
+    // wire the shared button row — payload is read live at click time
+    if (window.MCCQuickPost) {
+      window.MCCQuickPost.attach($('#qp-buttons'),
+        () => ({ caption: cap.value.trim(), mediaUrl: media.value.trim(), source: 'quickpost-panel' }));
+    } else {
+      $('#qp-buttons').innerHTML = '<div class="muted" style="font-size:12px">Quick-post component not loaded.</div>';
+    }
+
+    // asset quick-pick
+    (async function loadAssets() {
+      const box = $('#qp-assets');
+      let items = [];
+      try { const d = await fetch(O + '/api/assets/list', { credentials: 'same-origin' }).then(r => r.json()); items = (d && d.assets) || []; }
+      catch (_) {}
+      const withImg = items.filter(a => a.src).slice(0, 60);
+      if (!withImg.length) { box.innerHTML = '<div class="muted" style="font-size:12px">No assets yet — add some in the Library.</div>'; return; }
+      box.innerHTML = withImg.map(a =>
+        `<div class="qp-thumb" data-url="${esc(location.origin + a.src)}" title="${esc(a.name || '')}"><img loading="lazy" src="${esc(a.src)}"></div>`).join('');
+      box.querySelectorAll('.qp-thumb').forEach(el => el.onclick = () => {
+        box.querySelectorAll('.qp-thumb').forEach(x => x.classList.remove('sel'));
+        el.classList.add('sel');
+        media.value = el.getAttribute('data-url'); renderPreview();
+      });
+    })();
+
+    // staged-drafts list
+    async function loadDrafts() {
+      const box = $('#qp-draftlist'), cnt = $('#qp-count');
+      let drafts = [];
+      try { const d = await fetch(O + '/api/quickpost/drafts', { credentials: 'same-origin' }).then(r => r.json()); drafts = (d && d.drafts) || []; }
+      catch (_) {}
+      if (cnt) cnt.textContent = drafts.length;
+      if (!drafts.length) { box.innerHTML = '<div class="muted" style="font-size:12px">None yet.</div>'; return; }
+      box.innerHTML = drafts.map(d => {
+        const when = new Date(d.created_at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' });
+        return `<div class="qp-draftitem">
+          <span class="pl">${PICON[d.platform] || '•'}</span>
+          <div class="mid">
+            <div class="cap" title="${esc(d.caption)}">${esc(d.caption || '(no caption)')}</div>
+            <div class="when">🕓 ${esc(when)} · ${esc(d.platform)}${d.mediaUrl ? ' · has media' : ''}</div>
+          </div>
+          <button class="x" data-del="${esc(d.id)}" title="Remove draft">✕</button>
+        </div>`;
+      }).join('');
+      box.querySelectorAll('[data-del]').forEach(b => b.onclick = async () => {
+        await fetch(O + '/api/quickpost/draft/delete', {
+          method: 'POST', credentials: 'same-origin',
+          headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: b.getAttribute('data-del') }),
+        }).catch(() => {});
+        loadDrafts();
+      });
+    }
+    document.addEventListener('qp:staged', loadDrafts);
+    loadDrafts();
+  },
+};
diff --git a/public/quickpost.js b/public/quickpost.js
new file mode 100644
index 0000000..fe8463e
--- /dev/null
+++ b/public/quickpost.js
@@ -0,0 +1,85 @@
+// MCCQuickPost — the shared "easy post" component reused in 3 places (the Quick
+// Post panel, the Compose panel, and each asset/video card). Renders a row of
+// per-platform buttons; a click STAGES A DRAFT (gated) via /api/quickpost/draft
+// and shows a toast. Live posting is intentionally NOT wired here — staging is
+// the safe default that honors the "social posting is Steve-gated" rule.
+(function () {
+  const ORIGIN = location.origin;
+  const PLATFORMS = [
+    { id: 'instagram', label: 'Instagram', icon: '📷' },
+    { id: 'tiktok',    label: 'TikTok',    icon: '🎵' },
+    { id: 'facebook',  label: 'Facebook',  icon: '📘' },
+    { id: 'linkedin',  label: 'LinkedIn',  icon: '💼' },
+  ];
+
+  // one-time CSS
+  if (!document.getElementById('qp-css')) {
+    const s = document.createElement('style'); s.id = 'qp-css';
+    s.textContent = `
+      .qp-row{display:flex;gap:6px;flex-wrap:wrap;align-items:center}
+      .qp-btn{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--line,#e3ddd0);
+        background:#fff;border-radius:9px;cursor:pointer;font:600 12px/1 Inter,sans-serif;color:var(--ink,#14110f);
+        padding:8px 12px;transition:background .12s,border-color .12s}
+      .qp-btn:hover{background:var(--cream,#f4efe7);border-color:var(--accent,#8a5a44)}
+      .qp-btn.busy{opacity:.6;pointer-events:none}
+      .qp-btn.ok{background:#e3efe0;border-color:#bcd8b6;color:#2f6b34}
+      .qp-btn .qp-i{font-size:14px}
+      .qp-btn.mini{padding:5px 8px;font-size:10.5px;border-radius:7px}
+      .qp-all{border-style:dashed;color:var(--accent,#8a5a44)}
+      .qp-hint{font-size:11px;color:var(--mut,#8a8275)}
+      #qp-toast{position:fixed;right:18px;bottom:18px;z-index:9999;display:flex;flex-direction:column;gap:8px}
+      #qp-toast .t{background:var(--ink,#14110f);color:#fff;border-radius:10px;padding:11px 15px;font:600 12.5px/1.3 Inter,sans-serif;
+        box-shadow:0 10px 30px rgba(0,0,0,.25);opacity:0;transform:translateY(8px);transition:opacity .2s,transform .2s;max-width:340px}
+      #qp-toast .t.show{opacity:1;transform:none}
+      #qp-toast .t .g{color:var(--gold-soft,#d8c19a)}`;
+    document.head.appendChild(s);
+  }
+  function toast(msg) {
+    let box = document.getElementById('qp-toast');
+    if (!box) { box = document.createElement('div'); box.id = 'qp-toast'; document.body.appendChild(box); }
+    const t = document.createElement('div'); t.className = 't'; t.innerHTML = msg;
+    box.appendChild(t);
+    requestAnimationFrame(() => t.classList.add('show'));
+    setTimeout(() => { t.classList.remove('show'); setTimeout(() => t.remove(), 250); }, 2600);
+  }
+
+  async function stage(platform, payload) {
+    const r = await fetch(ORIGIN + '/api/quickpost/draft', {
+      method: 'POST', credentials: 'same-origin',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ platform, ...payload }),
+    });
+    return r.json().catch(() => ({ error: 'bad response' }));
+  }
+
+  // Render a button row into `mount`. getPayload(platform) → {caption, mediaUrl, source}.
+  // opts: { mini:false, includeAll:true }
+  function attach(mount, getPayload, opts) {
+    opts = opts || {};
+    const cls = opts.mini ? 'qp-btn mini' : 'qp-btn';
+    let html = PLATFORMS.map(p =>
+      `<button type="button" class="${cls}" data-qp="${p.id}" title="Stage a draft post to ${p.label}">` +
+      `<span class="qp-i">${p.icon}</span>${opts.mini ? '' : p.label}</button>`).join('');
+    if (opts.includeAll !== false && !opts.mini)
+      html += `<button type="button" class="qp-btn qp-all" data-qp="__all" title="Stage a draft to all platforms">+ All</button>`;
+    mount.insertAdjacentHTML('beforeend', `<div class="qp-row">${html}</div>`);
+
+    mount.querySelectorAll('[data-qp]').forEach(btn => btn.addEventListener('click', async () => {
+      const which = btn.getAttribute('data-qp');
+      const targets = which === '__all' ? PLATFORMS.map(p => p.id) : [which];
+      const payload = (typeof getPayload === 'function' ? getPayload() : getPayload) || {};
+      if (!payload.mediaUrl && !payload.caption) { toast('Nothing to post — add a caption or pick media first.'); return; }
+      btn.classList.add('busy'); const orig = btn.innerHTML;
+      let ok = 0;
+      for (const pl of targets) { const res = await stage(pl, payload); if (res && res.ok) ok++; }
+      btn.classList.remove('busy'); btn.classList.add('ok');
+      btn.innerHTML = '✓ Staged';
+      const label = which === '__all' ? `all ${ok} platforms` : PLATFORMS.find(p => p.id === which).label;
+      toast(`🚀 Draft staged for <span class="g">${label}</span> — awaiting your approval.`);
+      setTimeout(() => { btn.classList.remove('ok'); btn.innerHTML = orig; }, 1600);
+      document.dispatchEvent(new CustomEvent('qp:staged'));
+    }));
+  }
+
+  window.MCCQuickPost = { PLATFORMS, attach, stage, toast };
+})();

← eacbee8 MCC promo v3: DTD fix — hub screenshots as intentional app-w  ·  back to Marketing Command Center  ·  deploy: Easy Post buttons live on marketing.dw (v1.10.0) a03f7e3 →