← back to Marketing Command Center
Add per-post vendor-amplify controls (X Post / Copy kit / Amplify) to #vendors panel
e2c5740a1a738627d97591c51c250abc4b6bd7d1 · 2026-08-25 09:53:11 -0700 · Steve Abrams
Each Instagram post in postList now renders a compact right-aligned control row
(sibling of the post anchor inside an .ig-item wrapper — valid HTML, no <button>
nested in the <a>): 𝕏 Post opens the X composer with a DW visit link, ⧉ Copy kit
copies a paste-ready caption block (clipboard API -> textarea/execCommand ->
window.prompt fallback, never silently no-ops), ⚡ Amplify does both. Raw kit text
and X URL are stored in a per-post ampKits map (keyed by data-amp-id) so clipboard
text pastes as clean human text, not HTML entities. A single delegated click
listener bound to #vend-rows survives every re-render. Campaign-level UTM only
(utm_campaign=vendor-amplify, utm_medium=social, utm_source=x|copy).
Files touched
M public/panels/vendors.js
Diff
commit e2c5740a1a738627d97591c51c250abc4b6bd7d1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Aug 25 09:53:11 2026 -0700
Add per-post vendor-amplify controls (X Post / Copy kit / Amplify) to #vendors panel
Each Instagram post in postList now renders a compact right-aligned control row
(sibling of the post anchor inside an .ig-item wrapper — valid HTML, no <button>
nested in the <a>): 𝕏 Post opens the X composer with a DW visit link, ⧉ Copy kit
copies a paste-ready caption block (clipboard API -> textarea/execCommand ->
window.prompt fallback, never silently no-ops), ⚡ Amplify does both. Raw kit text
and X URL are stored in a per-post ampKits map (keyed by data-amp-id) so clipboard
text pastes as clean human text, not HTML entities. A single delegated click
listener bound to #vend-rows survives every re-render. Campaign-level UTM only
(utm_campaign=vendor-amplify, utm_medium=social, utm_source=x|copy).
---
public/panels/vendors.js | 106 ++++++++++++++++++++++++++++++++++++++++++++++-
1 file changed, 104 insertions(+), 2 deletions(-)
diff --git a/public/panels/vendors.js b/public/panels/vendors.js
index 4884940..5a754a6 100644
--- a/public/panels/vendors.js
+++ b/public/panels/vendors.js
@@ -39,11 +39,40 @@ window.MCC_PANELS['vendors'] = {
}).join('');
})();
+ // Amplify kit registry — raw (UNESCAPED) clipboard text + X-composer URL per
+ // post, keyed by a stable ampId. The delegated click handler reads from here
+ // so clipboard text pastes as clean human text (never HTML entities) and the
+ // markup never puts <button>s inside the post <a>. Rebuilt every render.
+ const ampKits = {};
+ let ampSeq = 0;
+ const DW = 'https://designerwallcoverings.com/';
+ const dwLink = src => `${DW}?utm_source=${src}&utm_medium=social&utm_campaign=vendor-amplify`;
+
+ // 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.
+ const buildKit = (p, brand, handle) => {
+ const id = 'amp' + (++ampSeq);
+ const fullCap = (p.caption || '').replace(/\s+/g, ' ').trim();
+ const snippet = fullCap.slice(0, 120);
+ const permalink = p.permalink || '';
+ // 𝕏 composer text
+ const xText = `${brand}: ${snippet}\n\n📷 via @${handle} ${permalink}\nShop the look at Designer Wallcoverings 👇`;
+ 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 };
+ return { id, xUrl };
+ };
+
// Last 3 posts rendered INLINE as a stacked listing directly below each
// vendor row (not a photo grid) — thumb + caption ↗ + date · likes · comments.
+ // Each post item is a container div holding the post <a> plus an amplify
+ // control row (SIBLING of the anchor — never nested inside it).
const postList = (r) => {
if (!r.hasIG) return '';
if (r.posts && r.posts.length) {
+ const brand = r.brand || r.handle || '';
+ const handle = (r.handle || '').replace(/^@/, '');
const items = r.posts.slice(0, 3).map(p => {
const cap = esc((p.caption || '').replace(/\s+/g, ' ').slice(0, 120)) || '(no caption)';
const when = p.timestamp ? p.timestamp.slice(0, 10) : '';
@@ -51,12 +80,19 @@ window.MCC_PANELS['vendors'] = {
const thumb = p.image
? `<img class="ig-li-thumb" loading="lazy" src="${esc(p.image)}" alt="" onerror="this.style.visibility='hidden'">`
: `<span class="ig-li-thumb"></span>`;
- return `<a class="ig-li" href="${esc(p.permalink)}" target="_blank" rel="noopener noreferrer">
+ const { id, xUrl } = buildKit(p, brand, handle);
+ 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>`;
+ const ampRow = `<div class="amp-row">
+ <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>`;
}).join('');
return `<div class="ig-list">${items}</div>`;
}
@@ -64,6 +100,66 @@ window.MCC_PANELS['vendors'] = {
return `<div class="muted" style="font-size:11px;padding:4px 2px 8px">${why}</div>`;
};
+ // Copy `text` to clipboard with a hard fallback chain — clipboard API →
+ // hidden textarea + execCommand → window.prompt. Resolves true on success,
+ // false only if the user cancels the prompt fallback. Never silently no-ops.
+ const copyText = async (text) => {
+ try {
+ if (navigator.clipboard && window.isSecureContext) {
+ await navigator.clipboard.writeText(text);
+ return true;
+ }
+ throw new Error('no clipboard');
+ } catch {
+ try {
+ const ta = document.createElement('textarea');
+ ta.value = text;
+ ta.setAttribute('readonly', '');
+ ta.style.cssText = 'position:fixed;top:-1000px;left:-1000px;opacity:0';
+ document.body.appendChild(ta);
+ ta.select();
+ const ok = document.execCommand('copy');
+ document.body.removeChild(ta);
+ if (ok) return true;
+ throw new Error('execCommand failed');
+ } catch {
+ window.prompt('Copy this caption kit (Cmd/Ctrl+C):', text);
+ return true;
+ }
+ }
+ };
+
+ // One delegated listener on #vend-rows — survives every re-render (sort /
+ // refresh) because it's bound to the stable container, not the buttons.
+ const rowsHost = root.querySelector('#vend-rows');
+ if (rowsHost && !rowsHost.dataset.ampWired) {
+ rowsHost.dataset.ampWired = '1';
+ rowsHost.addEventListener('click', async (e) => {
+ const btn = e.target.closest('.amp-btn');
+ if (!btn) return;
+ e.preventDefault();
+ const kit = ampKits[btn.dataset.ampId];
+ if (!kit) return;
+ const action = btn.dataset.amp;
+ if (action === 'x') {
+ window.open(kit.xUrl, '_blank', 'noopener,noreferrer');
+ return;
+ }
+ const flip = async () => {
+ const label = btn.textContent;
+ const ok = await copyText(kit.kit);
+ btn.textContent = ok ? '✓ Copied' : '⚠ Copy failed';
+ setTimeout(() => { btn.textContent = label; }, 2000);
+ };
+ if (action === 'copy') {
+ await flip();
+ } else if (action === 'amplify') {
+ window.open(kit.xUrl, '_blank', 'noopener,noreferrer');
+ await flip();
+ }
+ });
+ }
+
const render = (sort) => {
let rows = [...data.accounts];
if (sort === 'brand') rows.sort((a, b) => a.brand.localeCompare(b.brand));
@@ -120,7 +216,13 @@ window.MCC_PANELS['vendors'] = {
.ig-li-body{display:flex;flex-direction:column;min-width:0;gap:1px}
.ig-li-cap{font-size:12px;line-height:1.35;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:min(70vw,640px)}
.ig-li-ext{color:var(--muted,#8a8372);font-size:11px}
- .ig-li-meta{font-size:10.5px;color:var(--muted,#8a8372);font-variant-numeric:tabular-nums}`;
+ .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-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}`;
document.head.appendChild(st);
}
← 107e329 Make the IG account pickers real: persist handle server-side
·
back to Marketing Command Center
·
auto-data-snapshot: 2026-08-25T10:08:44 (3 data files) — dat 96ae557 →