← back to Marketing Command Center
Add per-post "Post from" account selector + "Make unique" per-account captions to #vendors amplify
7e55f1aba45b16161ec0ed6881d20c46ccb1ef2b · 2026-08-25 10:09:50 -0700 · Steve Abrams
Feature A: compact "Post from ▾" popover (searchable checkbox list of the 35
owned DW IG accounts, All/None), count badge, selection persisted in
localStorage keyed by a stable post id (permalink, djb2-hash fallback), rebuilt
on render. Body-appended popover rides a document-level delegated listener; the
in-row triggers ride the existing #vend-rows listener — both survive re-render.
Feature B: "✦ Make unique" generates a deterministic unique caption per selected
account (seeded by postId+handle so stable across renders, distinct per account,
different per post) — same photo, varied hook/emoji/hashtag-order/sentence-order,
always vendor credit + DW CTA with the vendor-amplify UTM link. Editable textarea
per account (edits persisted on input), per-account copy, regenerate.
"Stage drafts" POSTs to a NEW data/vendor-amplify-drafts.json via a small
validated, atomic-write endpoint — never the live engine-queue/channels-outbox/
meta-pages files. No auto-post; publishing stays Steve-gated. Generation is $0
local (deterministic client-side engine, no API, no network).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M .deploy.confM public/panels/vendors.jsM server.js
Diff
commit 7e55f1aba45b16161ec0ed6881d20c46ccb1ef2b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Aug 25 10:09:50 2026 -0700
Add per-post "Post from" account selector + "Make unique" per-account captions to #vendors amplify
Feature A: compact "Post from ▾" popover (searchable checkbox list of the 35
owned DW IG accounts, All/None), count badge, selection persisted in
localStorage keyed by a stable post id (permalink, djb2-hash fallback), rebuilt
on render. Body-appended popover rides a document-level delegated listener; the
in-row triggers ride the existing #vend-rows listener — both survive re-render.
Feature B: "✦ Make unique" generates a deterministic unique caption per selected
account (seeded by postId+handle so stable across renders, distinct per account,
different per post) — same photo, varied hook/emoji/hashtag-order/sentence-order,
always vendor credit + DW CTA with the vendor-amplify UTM link. Editable textarea
per account (edits persisted on input), per-account copy, regenerate.
"Stage drafts" POSTs to a NEW data/vendor-amplify-drafts.json via a small
validated, atomic-write endpoint — never the live engine-queue/channels-outbox/
meta-pages files. No auto-post; publishing stays Steve-gated. Generation is $0
local (deterministic client-side engine, no API, no network).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
.deploy.conf | 2 +-
public/panels/vendors.js | 420 ++++++++++++++++++++++++++++++++++++++++++++++-
server.js | 57 +++++++
3 files changed, 471 insertions(+), 8 deletions(-)
diff --git a/.deploy.conf b/.deploy.conf
index 4a5a9c6..464768b 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="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 ig-activity-tombstones.json renders"
+RSYNC_EXTRA_EXCLUDES="quickpost-drafts.json vendor-amplify-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 ig-activity-tombstones.json renders"
diff --git a/public/panels/vendors.js b/public/panels/vendors.js
index 5a754a6..fb27e7d 100644
--- a/public/panels/vendors.js
+++ b/public/panels/vendors.js
@@ -47,6 +47,97 @@ window.MCC_PANELS['vendors'] = {
let ampSeq = 0;
const DW = 'https://designerwallcoverings.com/';
const dwLink = src => `${DW}?utm_source=${src}&utm_medium=social&utm_campaign=vendor-amplify`;
+ // The single campaign-level UTM link the "Make unique" variants + staged
+ // drafts credit-and-CTA to (spec: utm_source=ig&utm_medium=social&utm_campaign=vendor-amplify).
+ const IG_LINK = dwLink('ig');
+
+ // ── Owned-account roster + per-post "Post from" selection (Feature A) ────────
+ // Loaded once; the same canonical 35 the "owned" section uses. Populated async
+ // before first render() so the popover + Make-unique read a real list.
+ let OWNED = [];
+ const loadOwned = async () => {
+ try { OWNED = (window.MCC_ACCOUNTS && await window.MCC_ACCOUNTS.load()) || []; }
+ catch { OWNED = []; }
+ return OWNED;
+ };
+
+ // Stable per-post id (codex trap: prefer the verbatim permalink; djb2 hash of
+ // it ONLY as a fallback for a missing/blank permalink; never re-normalize). The
+ // id namespaces every localStorage key so a permalink change/collision can't
+ // silently reuse another post's selection.
+ const djb2 = (str) => { let h = 5381; for (let i = 0; i < str.length; i++) h = ((h << 5) + h + str.charCodeAt(i)) | 0; return (h >>> 0).toString(36); };
+ const postIdOf = (p) => {
+ const pl = (p && p.permalink) ? String(p.permalink).trim() : '';
+ return pl ? 'pl:' + pl : 'h:' + djb2(String((p && p.image) || '') + '|' + String((p && p.caption) || '').slice(0, 120));
+ };
+
+ // localStorage helpers — versioned + namespaced keys so a schema bump is clean.
+ const LS_V = 'v1';
+ const selKey = (postId) => `mcc.vamp.${LS_V}.sel.${postId}`;
+ const capKey = (postId, handle) => `mcc.vamp.${LS_V}.cap.${postId}.${handle}`;
+ const lsGet = (k) => { try { return localStorage.getItem(k); } catch { return null; } };
+ const lsSet = (k, v) => { try { localStorage.setItem(k, v); } catch { /* quota/denied — non-fatal */ } };
+ const lsDel = (k) => { try { localStorage.removeItem(k); } catch { /* */ } };
+ const getSelection = (postId) => {
+ const raw = lsGet(selKey(postId));
+ if (!raw) return new Set();
+ try { const a = JSON.parse(raw); return new Set(Array.isArray(a) ? a : []); } catch { return new Set(); }
+ };
+ const setSelection = (postId, set) => {
+ const arr = [...set];
+ if (arr.length) lsSet(selKey(postId), JSON.stringify(arr));
+ else lsDel(selKey(postId));
+ };
+
+ // ── Deterministic caption-variation engine (Feature B backend, $0 local) ─────
+ // Given ONE post caption, produce a UNIQUE variant per account so the SAME
+ // photo can be posted across N handles without identical text (Meta spam
+ // throttling). Seeded by postId+handle (NOT handle alone — codex trap — so an
+ // account gets a different variant on every post) via a small hashed PRNG, so
+ // the variant is STABLE across sort/refresh re-renders. Every variant keeps the
+ // core credit-the-vendor + DW visit CTA with the campaign UTM link.
+ const mulberry32 = (seed) => {
+ let a = seed >>> 0;
+ return () => { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
+ };
+ const seedInt = (s) => { let h = 2166136261 >>> 0; for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); } return h >>> 0; };
+ const shuffleSeeded = (arr, rnd) => { const a = arr.slice(); for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(rnd() * (i + 1)); [a[i], a[j]] = [a[j], a[i]]; } return a; };
+ const pick = (arr, rnd) => arr[Math.floor(rnd() * arr.length)];
+
+ const HOOKS = [
+ 'On the wall this week', 'A closer look', 'Texture worth touching',
+ 'Bringing this home', 'Our latest obsession', 'Design detail',
+ 'Made for the light', 'Layered and lived-in', 'Statement surface',
+ 'Slow down and notice', 'The one we keep coming back to', 'Room-ready',
+ ];
+ const EMOJI = ['✨', '🕊️', '🌿', '🤍', '🪞', '🎨', '🧵', '🏛️', '🕯️', '📐'];
+ const CTAS = [
+ 'Shop the look at Designer Wallcoverings', 'Explore it at Designer Wallcoverings',
+ 'See it at Designer Wallcoverings', 'Discover more at Designer Wallcoverings',
+ 'Find it at Designer Wallcoverings', 'Bring it home — Designer Wallcoverings',
+ ];
+ const HASHTAGS = ['#DesignerWallcoverings', '#wallcoverings', '#interiordesign', '#luxuryinteriors', '#designdetail', '#texture', '#interiorstyle'];
+
+ // Generate the unique caption text for one (post, handle). `brand` credits the
+ // vendor; `srcCap` is the original post caption (kept short so the variant reads
+ // as fresh copy, not a paste). Returns clean human text (never HTML entities).
+ const makeVariant = (postId, handle, brand, srcCap) => {
+ const rnd = mulberry32(seedInt(postId + '|' + handle));
+ const hook = pick(HOOKS, rnd);
+ const em1 = pick(EMOJI, rnd);
+ const em2 = pick(EMOJI.filter(e => e !== em1), rnd);
+ const cta = pick(CTAS, rnd);
+ const core = String(srcCap || '').replace(/\s+/g, ' ').trim().slice(0, 160);
+ const credit = brand ? `📷 via @${handle.replace(/^@/, '')} · in partnership with ${brand}` : `📷 via @${handle.replace(/^@/, '')}`;
+ const tags = shuffleSeeded(HASHTAGS, rnd).slice(0, 5 + Math.floor(rnd() * 2)).join(' ');
+ // Two seeded sentence orderings so the body itself varies, not just the ends.
+ const lineA = `${hook} ${em1} ${core}`.trim();
+ const lineB = `${cta} 👇 ${IG_LINK}`;
+ const parts = rnd() < 0.5
+ ? [lineA, credit, lineB, `${em2} ${tags}`]
+ : [`${em1} ${core}`, `${hook}.`, credit, lineB, tags];
+ return parts.filter(Boolean).join('\n\n');
+ };
// Build the raw amplify payloads for one post; register them under an id and
// return { id, xUrl } for the markup. `brand` and `handle` are raw strings.
@@ -60,7 +151,17 @@ window.MCC_PANELS['vendors'] = {
const xUrl = `https://x.com/intent/post?text=${encodeURIComponent(xText)}&url=${encodeURIComponent(dwLink('x'))}`;
// paste-ready caption kit
const kit = `${brand} — ${fullCap}\n\n📷 via @${handle}\n🛍️ Shop at Designer Wallcoverings: ${dwLink('copy')}\n\n#DesignerWallcoverings #wallcoverings #interiordesign`;
- ampKits[id] = { xUrl, kit };
+ // Carry the raw fields Feature A ("Post from") + Feature B ("Make unique" /
+ // "Stage drafts") need at click-time so the delegated handlers work off the
+ // registry, not off DOM scraping.
+ ampKits[id] = {
+ xUrl, kit,
+ postId: postIdOf(p),
+ brand, handle,
+ image: p.image || '',
+ permalink,
+ srcCaption: fullCap,
+ };
return { id, xUrl };
};
@@ -81,18 +182,28 @@ window.MCC_PANELS['vendors'] = {
? `<img class="ig-li-thumb" loading="lazy" src="${esc(p.image)}" alt="" onerror="this.style.visibility='hidden'">`
: `<span class="ig-li-thumb"></span>`;
const { id, xUrl } = buildKit(p, brand, handle);
+ const postId = ampKits[id].postId;
+ const selCount = getSelection(postId).size;
const anchor = `<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>`;
+ // amp-row is a SIBLING of the anchor (never nested inside the <a>), so the
+ // trigger buttons don't fire navigation. The "Post from" button carries a
+ // live count badge; "Make unique" opens the review area below.
const ampRow = `<div class="amp-row">
+ <button type="button" class="amp-btn amp-from" data-amp="from" data-amp-id="${id}" aria-haspopup="true" aria-expanded="false" title="Choose which owned DW accounts this post is for">Post from ▾${selCount ? ` <span class="amp-count">· ${selCount}</span>` : ''}</button>
+ <button type="button" class="amp-btn amp-uniq" data-amp="unique" data-amp-id="${id}" title="Generate a unique caption per selected account (same photo, different text)">✦ Make unique</button>
<button type="button" class="amp-btn" data-amp="x" data-amp-id="${id}" title="Open X composer with a DW visit link">𝕏 Post</button>
<button type="button" class="amp-btn" data-amp="copy" data-amp-id="${id}" title="Copy a paste-ready caption kit">⧉ Copy kit</button>
<button type="button" class="amp-btn amp-strong" data-amp="amplify" data-amp-id="${id}" title="Copy the kit AND open the X composer">⚡ Amplify</button>
</div>`;
- return `<div class="ig-item">${anchor}${ampRow}</div>`;
+ // Review area (empty until "Make unique"); id-tagged so the delegated
+ // handler can find + (re)fill it. Rebuilt every render from stored state.
+ const review = `<div class="amp-review" data-amp-review="${id}" hidden></div>`;
+ return `<div class="ig-item" data-post-id="${esc(postId)}">${anchor}${ampRow}${review}</div>`;
}).join('');
return `<div class="ig-list">${items}</div>`;
}
@@ -129,8 +240,119 @@ window.MCC_PANELS['vendors'] = {
}
};
+ // ── "Post from" popover (Feature A) ──────────────────────────────────────────
+ // The popover is appended to document.body (NOT inside the post <a> or the row)
+ // — codex trap: a body-appended element does NOT bubble to #vend-rows, so its
+ // interactions ride the DOCUMENT-level listener below, while the trigger button
+ // stays inside #vend-rows and rides that listener. Only one popover is ever open.
+ let openPopover = null; // { el, ampId }
+ const closePopover = () => {
+ if (openPopover) { openPopover.el.remove(); openPopover = null; }
+ // Reset every from-button's aria/label count from stored state (cheap; keeps
+ // the badge honest even if selection changed while the popover was open).
+ root.querySelectorAll('.amp-from[aria-expanded="true"]').forEach(b => b.setAttribute('aria-expanded', 'false'));
+ };
+ const refreshFromBadge = (ampId) => {
+ const kit = ampKits[ampId]; if (!kit) return;
+ const btn = root.querySelector(`.amp-from[data-amp-id="${ampId}"]`);
+ if (!btn) return;
+ const n = getSelection(kit.postId).size;
+ btn.innerHTML = `Post from ▾${n ? ` <span class="amp-count">· ${n}</span>` : ''}`;
+ };
+ const openFromPopover = (btn) => {
+ const ampId = btn.dataset.ampId;
+ const kit = ampKits[ampId]; if (!kit) return;
+ const wasThis = openPopover && openPopover.ampId === ampId;
+ closePopover();
+ if (wasThis) return; // toggle closed if re-clicking the same button
+ const postId = kit.postId;
+ const sel = getSelection(postId);
+ const el = document.createElement('div');
+ el.className = 'amp-pop';
+ el.setAttribute('role', 'dialog');
+ el.dataset.ampId = ampId;
+ const rows = OWNED.length
+ ? OWNED.map(a => {
+ const h = a.handle;
+ const checked = sel.has(h) ? ' checked' : '';
+ return `<label class="amp-pop-row" data-h="${esc(h)}">
+ <input type="checkbox" data-h="${esc(h)}"${checked}>
+ <span class="amp-pop-name">${esc(a.name || h)}</span>
+ <span class="amp-pop-h">@${esc(h)}</span>
+ </label>`;
+ }).join('')
+ : '<div class="amp-pop-empty">No owned accounts loaded.</div>';
+ el.innerHTML = `
+ <div class="amp-pop-hd">
+ <input type="search" class="amp-pop-search" placeholder="Search accounts…" aria-label="Search owned accounts">
+ <div class="amp-pop-bulk"><button type="button" class="amp-pop-all">All</button><button type="button" class="amp-pop-none">None</button></div>
+ </div>
+ <div class="amp-pop-list">${rows}</div>
+ <div class="amp-pop-ft"><span class="amp-pop-n">${sel.size} selected</span><button type="button" class="amp-pop-done">Done</button></div>`;
+ document.body.appendChild(el);
+ // Position under the button, clamped to the viewport.
+ const r = btn.getBoundingClientRect();
+ const w = 280;
+ let left = Math.min(r.left, window.innerWidth - w - 8);
+ left = Math.max(8, left);
+ el.style.top = (window.scrollY + r.bottom + 4) + 'px';
+ el.style.left = (window.scrollX + left) + 'px';
+ btn.setAttribute('aria-expanded', 'true');
+ openPopover = { el, ampId };
+ const s = el.querySelector('.amp-pop-search'); if (s) s.focus();
+ };
+
+ // ── "Make unique" review area (Feature B) ────────────────────────────────────
+ // Renders one editable <textarea> per SELECTED account, each seeded-generated +
+ // its own copy button, plus a "Stage drafts" action. Edited text is persisted to
+ // localStorage on every input (codex trap: never lose unsaved edits to a
+ // re-render); the area is rebuilt from stored state on render.
+ const renderReview = (ampId, { regenerate = false } = {}) => {
+ const kit = ampKits[ampId]; if (!kit) return;
+ const host = root.querySelector(`[data-amp-review="${ampId}"]`);
+ if (!host) return;
+ const postId = kit.postId;
+ const sel = [...getSelection(postId)];
+ if (!sel.length) {
+ host.hidden = false;
+ host.innerHTML = `<div class="amp-review-empty">Select one or more accounts under “Post from ▾” first.</div>`;
+ return;
+ }
+ const cards = sel.map(h => {
+ const stored = lsGet(capKey(postId, h));
+ // Use stored edit unless the manager asked to regenerate (or nothing stored).
+ const text = (!regenerate && stored != null) ? stored : makeVariant(postId, h, kit.brand, kit.srcCaption);
+ if (regenerate || stored == null) lsSet(capKey(postId, h), text);
+ return `<div class="amp-var" data-h="${esc(h)}">
+ <div class="amp-var-hd"><b>@${esc(h)}</b>
+ <span class="amp-var-tools">
+ <button type="button" class="amp-mini" data-amp-var="regen" data-h="${esc(h)}" title="Regenerate this variant">↻</button>
+ <button type="button" class="amp-mini" data-amp-var="copyone" data-h="${esc(h)}" title="Copy this caption">⧉</button>
+ </span></div>
+ <textarea class="amp-ta" data-h="${esc(h)}" rows="5" spellcheck="false">${esc(text)}</textarea>
+ </div>`;
+ }).join('');
+ host.hidden = false;
+ host.innerHTML = `
+ <div class="amp-review-hd">
+ <span class="muted">${sel.length} unique caption${sel.length > 1 ? 's' : ''} · same photo, distinct text</span>
+ <span class="amp-review-tools">
+ <button type="button" class="amp-mini" data-amp-var="regenall" title="Regenerate all variants">↻ Regenerate all</button>
+ <button type="button" class="amp-btn amp-strong" data-amp-var="stage" title="Stage these drafts for review (does NOT post)">⧗ Stage drafts</button>
+ </span>
+ </div>
+ <div class="amp-vars">${cards}</div>
+ <div class="amp-review-note muted">Staging saves a draft for review — it does <b>not</b> publish. Posting to these accounts is Steve-gated.</div>`;
+ };
+
+ // Close the popover when a re-render is about to replace the DOM under it
+ // (codex trap: a body-appended popover would otherwise orphan). render() calls
+ // this at its top.
+ const beforeRender = () => closePopover();
+
// One delegated listener on #vend-rows — survives every re-render (sort /
- // refresh) because it's bound to the stable container, not the buttons.
+ // refresh) because it's bound to the stable container, not the buttons. Handles
+ // the in-row trigger buttons (from / unique / x / copy / amplify).
const rowsHost = root.querySelector('#vend-rows');
if (rowsHost && !rowsHost.dataset.ampWired) {
rowsHost.dataset.ampWired = '1';
@@ -141,6 +363,8 @@ window.MCC_PANELS['vendors'] = {
const kit = ampKits[btn.dataset.ampId];
if (!kit) return;
const action = btn.dataset.amp;
+ if (action === 'from') { openFromPopover(btn); return; }
+ if (action === 'unique') { renderReview(btn.dataset.ampId); return; }
if (action === 'x') {
window.open(kit.xUrl, '_blank', 'noopener,noreferrer');
return;
@@ -160,7 +384,151 @@ window.MCC_PANELS['vendors'] = {
});
}
+ // DOCUMENT-level delegated listeners (bound ONCE, guarded by a body flag) —
+ // handle the body-appended popover (which can't bubble to #vend-rows) AND the
+ // review-area controls + textarea persistence. Bound to document so they too
+ // survive every panel re-render.
+ if (!document.body.dataset.vampDocWired) {
+ document.body.dataset.vampDocWired = '1';
+ // Click: popover checkbox/bulk/done, review regen/copy/stage.
+ document.addEventListener('click', async (e) => {
+ // — popover interactions —
+ const pop = e.target.closest('.amp-pop');
+ if (pop) {
+ const ampId = pop.dataset.ampId;
+ const kit = ampKits[ampId];
+ if (!kit) return;
+ const postId = kit.postId;
+ if (e.target.closest('.amp-pop-done')) { closePopover(); return; }
+ if (e.target.closest('.amp-pop-all')) {
+ const set = new Set(OWNED.map(a => a.handle));
+ setSelection(postId, set);
+ pop.querySelectorAll('input[type=checkbox]').forEach(c => { c.checked = true; });
+ pop.querySelector('.amp-pop-n').textContent = `${set.size} selected`;
+ refreshFromBadge(ampId);
+ return;
+ }
+ if (e.target.closest('.amp-pop-none')) {
+ setSelection(postId, new Set());
+ pop.querySelectorAll('input[type=checkbox]').forEach(c => { c.checked = false; });
+ pop.querySelector('.amp-pop-n').textContent = '0 selected';
+ refreshFromBadge(ampId);
+ return;
+ }
+ const cb = e.target.closest('input[type=checkbox]');
+ if (cb) {
+ const set = getSelection(postId);
+ if (cb.checked) set.add(cb.dataset.h); else set.delete(cb.dataset.h);
+ setSelection(postId, set);
+ pop.querySelector('.amp-pop-n').textContent = `${set.size} selected`;
+ refreshFromBadge(ampId);
+ // If the review area is open for this post, keep it in sync.
+ const rv = root.querySelector(`[data-amp-review="${ampId}"]`);
+ if (rv && !rv.hidden) renderReview(ampId);
+ }
+ return;
+ }
+ // — click outside an open popover closes it (but not on the trigger) —
+ if (openPopover && !e.target.closest('.amp-from')) { closePopover(); }
+
+ // — review-area interactions —
+ const rvBtn = e.target.closest('[data-amp-var]');
+ if (rvBtn) {
+ const reviewEl = rvBtn.closest('.amp-review');
+ if (!reviewEl) return;
+ const ampId = reviewEl.getAttribute('data-amp-review');
+ const kit = ampKits[ampId]; if (!kit) return;
+ const postId = kit.postId;
+ const kind = rvBtn.dataset.ampVar;
+ if (kind === 'regenall') { renderReview(ampId, { regenerate: true }); return; }
+ if (kind === 'regen') {
+ const h = rvBtn.dataset.h;
+ const text = makeVariant(postId, h, kit.brand, kit.srcCaption);
+ lsSet(capKey(postId, h), text);
+ const ta = reviewEl.querySelector(`textarea[data-h="${CSS.escape(h)}"]`);
+ if (ta) ta.value = text;
+ return;
+ }
+ if (kind === 'copyone') {
+ const h = rvBtn.dataset.h;
+ const ta = reviewEl.querySelector(`textarea[data-h="${CSS.escape(h)}"]`);
+ const label = rvBtn.textContent;
+ const ok = await copyText(ta ? ta.value : '');
+ rvBtn.textContent = ok ? '✓' : '⚠';
+ setTimeout(() => { rvBtn.textContent = label; }, 1500);
+ return;
+ }
+ if (kind === 'stage') {
+ const sel = [...getSelection(postId)];
+ if (!sel.length) return;
+ const perAccountCaptions = {};
+ sel.forEach(h => {
+ const ta = reviewEl.querySelector(`textarea[data-h="${CSS.escape(h)}"]`);
+ perAccountCaptions[h] = ta ? ta.value : (lsGet(capKey(postId, h)) || '');
+ });
+ const label = rvBtn.textContent; rvBtn.disabled = true; rvBtn.textContent = '⧗ Staging…';
+ try {
+ const res = await (await fetch(location.origin + '/api/vendor-amplify-drafts', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ credentials: 'same-origin',
+ body: JSON.stringify({
+ vendor: kit.brand,
+ postImage: kit.image,
+ permalink: kit.permalink,
+ targetAccounts: sel,
+ perAccountCaptions,
+ }),
+ })).json();
+ rvBtn.textContent = res && res.ok ? `✓ Staged ${res.staged} for review` : `⚠ ${(res && res.error) || 'failed'}`;
+ } catch (err) {
+ rvBtn.textContent = '⚠ ' + err.message;
+ }
+ setTimeout(() => { rvBtn.textContent = label; rvBtn.disabled = false; }, 3000);
+ return;
+ }
+ }
+ });
+ // Live-filter the popover list as the user types.
+ document.addEventListener('input', (e) => {
+ if (e.target.classList && e.target.classList.contains('amp-pop-search')) {
+ const q = e.target.value.trim().toLowerCase();
+ const pop = e.target.closest('.amp-pop');
+ if (!pop) return;
+ pop.querySelectorAll('.amp-pop-row').forEach(row => {
+ const hay = row.textContent.toLowerCase();
+ row.style.display = (!q || hay.includes(q)) ? '' : 'none';
+ });
+ return;
+ }
+ // Persist textarea edits on EVERY input (codex trap: survive re-render).
+ if (e.target.classList && e.target.classList.contains('amp-ta')) {
+ const reviewEl = e.target.closest('.amp-review');
+ if (!reviewEl) return;
+ const ampId = reviewEl.getAttribute('data-amp-review');
+ const kit = ampKits[ampId]; if (!kit) return;
+ lsSet(capKey(kit.postId, e.target.dataset.h), e.target.value);
+ }
+ });
+ // Re-anchor an open popover on scroll/resize so it doesn't drift off its
+ // button (it's absolutely-positioned in page coords).
+ const reanchor = () => {
+ if (!openPopover) return;
+ const btn = root.querySelector(`.amp-from[data-amp-id="${openPopover.ampId}"]`);
+ if (!btn) { closePopover(); return; }
+ const r = btn.getBoundingClientRect();
+ const w = 280;
+ let left = Math.max(8, Math.min(r.left, window.innerWidth - w - 8));
+ openPopover.el.style.top = (window.scrollY + r.bottom + 4) + 'px';
+ openPopover.el.style.left = (window.scrollX + left) + 'px';
+ };
+ window.addEventListener('scroll', reanchor, true);
+ window.addEventListener('resize', reanchor);
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closePopover(); });
+ }
+
const render = (sort) => {
+ beforeRender(); // close any open popover before the DOM under it is replaced
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 || ''));
@@ -180,7 +548,11 @@ window.MCC_PANELS['vendors'] = {
</div>`;
}).join('');
};
+ // Load the owned roster BEFORE first render so the "Post from" popover +
+ // "Make unique" variants have real accounts, then re-render once it lands (the
+ // first render is instant off cache; this fills the roster if it wasn't cached).
render('followers');
+ loadOwned().then(() => { if (OWNED.length) render(root.querySelector('#vs-sort').value); });
root.querySelector('#vs-sort').onchange = e => render(e.target.value);
// Refresh-posts control + last-fetched stamp, injected into the stats bar.
@@ -206,8 +578,10 @@ window.MCC_PANELS['vendors'] = {
}
// 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';
+ if (!document.getElementById('vend-ig-css3')) {
+ // remove the prior version so the bumped stylesheet fully supersedes it
+ const old = document.getElementById('vend-ig-css2'); if (old) old.remove();
+ const st = document.createElement('style'); st.id = 'vend-ig-css3';
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}
@@ -218,11 +592,43 @@ window.MCC_PANELS['vendors'] = {
.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}
.ig-item{display:flex;flex-direction:column;gap:2px}
- .amp-row{display:flex;gap:5px;justify-content:flex-end;padding:1px 2px 2px}
+ .amp-row{display:flex;gap:5px;justify-content:flex-end;padding:1px 2px 2px;flex-wrap:wrap}
.amp-btn{font-size:10.5px;line-height:1;padding:3px 7px;border:1px solid var(--line);border-radius:6px;background:transparent;color:var(--muted,#8a8372);cursor:pointer;opacity:.55;transition:opacity .12s,background .12s,color .12s;white-space:nowrap}
.amp-btn:hover{opacity:1;background:#f3eee2;color:inherit}
.amp-btn.amp-strong{border-color:#c9b98f;color:#8a6d2f}
- .amp-btn.amp-strong:hover{background:#f4ecd6;color:#6b531f}`;
+ .amp-btn.amp-strong:hover{background:#f4ecd6;color:#6b531f}
+ .amp-btn.amp-from,.amp-btn.amp-uniq{opacity:.8}
+ .amp-count{color:#8a6d2f;font-weight:600}
+ /* Post-from popover — body-appended, absolutely positioned in page coords */
+ .amp-pop{position:absolute;z-index:9999;width:280px;max-width:calc(100vw - 16px);background:#fffdf8;border:1px solid #d8cfb8;border-radius:10px;box-shadow:0 8px 28px rgba(60,48,20,.18);font-size:12px;overflow:hidden}
+ .amp-pop-hd{display:flex;gap:6px;align-items:center;padding:8px;border-bottom:1px solid var(--line,#e7e0cf)}
+ .amp-pop-search{flex:1;min-width:0;font-size:12px;padding:4px 7px;border:1px solid var(--line,#e7e0cf);border-radius:6px;background:#fff}
+ .amp-pop-bulk{display:flex;gap:4px}
+ .amp-pop-bulk button{font-size:11px;padding:3px 7px;border:1px solid var(--line,#e7e0cf);border-radius:6px;background:#fff;cursor:pointer}
+ .amp-pop-bulk button:hover{background:#f3eee2}
+ .amp-pop-list{max-height:280px;overflow:auto;padding:4px}
+ .amp-pop-row{display:flex;gap:7px;align-items:center;padding:5px 6px;border-radius:6px;cursor:pointer}
+ .amp-pop-row:hover{background:#f6f1e4}
+ .amp-pop-row input{flex:0 0 auto;cursor:pointer}
+ .amp-pop-name{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+ .amp-pop-h{color:var(--muted,#8a8372);margin-left:auto;font-size:11px;white-space:nowrap}
+ .amp-pop-empty{padding:14px;color:var(--muted,#8a8372);text-align:center}
+ .amp-pop-ft{display:flex;justify-content:space-between;align-items:center;padding:8px;border-top:1px solid var(--line,#e7e0cf)}
+ .amp-pop-n{color:var(--muted,#8a8372)}
+ .amp-pop-done{font-size:11px;padding:4px 12px;border:1px solid #c9b98f;border-radius:6px;background:#f4ecd6;color:#6b531f;cursor:pointer}
+ /* Make-unique review area */
+ .amp-review{margin:2px 2px 6px;border:1px solid #e2d8c0;border-radius:9px;background:#fdfbf5;padding:8px}
+ .amp-review-empty{font-size:11.5px;color:var(--muted,#8a8372);padding:2px}
+ .amp-review-hd{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-bottom:6px;flex-wrap:wrap}
+ .amp-review-tools{display:flex;gap:6px;align-items:center}
+ .amp-vars{display:flex;flex-direction:column;gap:8px}
+ .amp-var{border:1px solid var(--line,#e7e0cf);border-radius:8px;background:#fff;padding:6px}
+ .amp-var-hd{display:flex;justify-content:space-between;align-items:center;font-size:12px;margin-bottom:4px}
+ .amp-var-tools{display:flex;gap:4px}
+ .amp-mini{font-size:11px;line-height:1;padding:3px 7px;border:1px solid var(--line,#e7e0cf);border-radius:6px;background:transparent;color:var(--muted,#8a8372);cursor:pointer}
+ .amp-mini:hover{background:#f3eee2;color:inherit}
+ .amp-ta{width:100%;box-sizing:border-box;font-size:11.5px;line-height:1.4;font-family:inherit;padding:6px;border:1px solid var(--line,#e7e0cf);border-radius:6px;background:#fffdf8;resize:vertical}
+ .amp-review-note{font-size:10.5px;margin-top:6px}`;
document.head.appendChild(st);
}
diff --git a/server.js b/server.js
index ec9a15a..8b0cc75 100644
--- a/server.js
+++ b/server.js
@@ -122,6 +122,63 @@ app.get('/api/dw-accounts', (_req, res) => {
}
});
+// ── Vendor-amplify draft store (Feature B: "Stage drafts") ───────────────────
+// A DEDICATED drafts file for the #vendors panel's per-account amplify captions.
+// HARD RAIL: this writes ONLY to its own data/vendor-amplify-drafts.json — never
+// to the live-authoritative engine-queue.json / channels-outbox.json / meta-pages.json
+// (a stale write to those once wiped a live Meta token). Staging here does NOT
+// publish anything — actually posting to the selected IG accounts is a gated
+// send-to-list action owned by Steve. This endpoint is a review/hand-off staging
+// area, nothing more. Not a nav module, so it mounts here like clients-notes.
+// Shape: append-only array of
+// { id, staged_at, vendor, postImage, permalink, targetAccounts:[handle],
+// perAccountCaptions:{ handle: text } }
+const AMP_DRAFTS_FILE = path.join(__dirname, 'data', 'vendor-amplify-drafts.json');
+const readAmpDrafts = () => { try { const d = JSON.parse(fs.readFileSync(AMP_DRAFTS_FILE, 'utf8')); return Array.isArray(d) ? d : []; } catch { return []; } };
+const writeAmpDrafts = (arr) => {
+ fs.mkdirSync(path.dirname(AMP_DRAFTS_FILE), { recursive: true });
+ const tmp = AMP_DRAFTS_FILE + '.tmp';
+ fs.writeFileSync(tmp, JSON.stringify(arr, null, 2));
+ fs.renameSync(tmp, AMP_DRAFTS_FILE); // atomic swap — no partial/corrupt reads
+};
+app.get('/api/vendor-amplify-drafts', (_req, res) => {
+ res.json({ ok: true, drafts: readAmpDrafts().slice().reverse() });
+});
+app.post('/api/vendor-amplify-drafts', (req, res) => {
+ // ── server-side schema validation (codex trap: never trust the client shape) ─
+ const b = req.body || {};
+ const vendor = typeof b.vendor === 'string' ? b.vendor.slice(0, 200) : '';
+ const postImage = typeof b.postImage === 'string' ? b.postImage.slice(0, 2000) : '';
+ const permalink = typeof b.permalink === 'string' ? b.permalink.slice(0, 2000) : '';
+ const targetsRaw = Array.isArray(b.targetAccounts) ? b.targetAccounts : [];
+ const capsRaw = (b.perAccountCaptions && typeof b.perAccountCaptions === 'object' && !Array.isArray(b.perAccountCaptions)) ? b.perAccountCaptions : {};
+ // Normalize handles (strip @, cap length, cap count) and keep only captions for
+ // targeted handles. Reject an empty/oversized submission outright.
+ const targetAccounts = [...new Set(targetsRaw
+ .filter(h => typeof h === 'string')
+ .map(h => h.replace(/^@/, '').trim().slice(0, 80))
+ .filter(Boolean))].slice(0, 60);
+ if (!targetAccounts.length) return res.status(400).json({ ok: false, error: 'no target accounts' });
+ const perAccountCaptions = {};
+ for (const h of targetAccounts) {
+ const t = capsRaw[h];
+ perAccountCaptions[h] = (typeof t === 'string' ? t : '').slice(0, 4000);
+ }
+ const draft = {
+ id: 'vad_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6),
+ staged_at: new Date().toISOString(),
+ vendor, postImage, permalink, targetAccounts, perAccountCaptions,
+ };
+ try {
+ const all = readAmpDrafts();
+ all.push(draft);
+ writeAmpDrafts(all.slice(-2000)); // bound the file
+ } catch (e) {
+ return res.status(500).json({ ok: false, error: 'write failed' });
+ }
+ res.json({ ok: true, id: draft.id, staged: targetAccounts.length });
+});
+
app.use('/panels', express.static(path.join(__dirname, 'public', 'panels'), { fallthrough: true }));
app.use(express.static(path.join(__dirname, 'public')));
app.get('/', (_req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
← 96ae557 auto-data-snapshot: 2026-08-25T10:08:44 (3 data files) — dat
·
back to Marketing Command Center
·
Guard: suppress amplify controls for never-front-facing bran 3b784fd →