[object Object]

← 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

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 →