[object Object]

← back to Marketing Command Center

vendors panel: add 'Pull ALL vendor accounts' + IG coverage note + LinkedIn amplify section (reference/attribution-amplify, no <a>-wrapped cards); licdn img-proxy allowlist

b8fe12e59e8b5ef99136aaae23f75217d1359c2b · 2026-08-31 12:24:38 -0700 · Steve Abrams

Files touched

Diff

commit b8fe12e59e8b5ef99136aaae23f75217d1359c2b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 31 12:24:38 2026 -0700

    vendors panel: add 'Pull ALL vendor accounts' + IG coverage note + LinkedIn amplify section (reference/attribution-amplify, no <a>-wrapped cards); licdn img-proxy allowlist
---
 modules/vendors/index.js   |   9 ++-
 public/panels/vendors.html |  23 +++++++-
 public/panels/vendors.js   | 143 +++++++++++++++++++++++++++++++++++++++++++++
 server.js                  |   2 +-
 4 files changed, 172 insertions(+), 5 deletions(-)

diff --git a/modules/vendors/index.js b/modules/vendors/index.js
index 7a586da..bf39bab 100644
--- a/modules/vendors/index.js
+++ b/modules/vendors/index.js
@@ -100,7 +100,9 @@ async function fetchLiOg(url) {
     const status = r.status;
     if (!r.ok) return { ok: false, status, error: 'HTTP ' + status + (status === 999 || status === 429 ? ' (LinkedIn rate-limited / auth-walled this fetch)' : '') };
     const html = (await r.text()).slice(0, 400000);
-    const og = k => { const m = html.match(new RegExp('<meta[^>]+(?:property|name)=["\\\']og:' + k + '["\\\'][^>]+content=["\\\']([^"\\\']+)', 'i')); return m ? m[1].replace(/&amp;/g, '&').replace(/&#39;/g, "'").replace(/&quot;/g, '"') : ''; };
+    // Decode HTML entities (LinkedIn double-encodes &amp;amp; → run &amp;→& twice).
+    const dec = s => String(s || '').replace(/&amp;/g, '&').replace(/&amp;/g, '&').replace(/&#39;/g, "'").replace(/&#x27;/g, "'").replace(/&quot;/g, '"').replace(/&lt;/g, '<').replace(/&gt;/g, '>');
+    const og = k => { const m = html.match(new RegExp('<meta[^>]+(?:property|name)=["\\\']og:' + k + '["\\\'][^>]+content=["\\\']([^"\\\']+)', 'i')); return m ? dec(m[1]) : ''; };
     return { ok: true, status, title: og('title'), thumb: og('image'), description: og('description') };
   } catch (e) {
     return { ok: false, error: /abort/i.test(e.message) ? 'timeout' : e.message };
@@ -109,9 +111,12 @@ async function fetchLiOg(url) {
 // Reference/attribution-amplify kit for a LinkedIn company: copyable text that
 // CREDITS the vendor + links back to their public page, plus a DW-visit CTA. No
 // image is ever pulled into DW assets — attribution + link-share only.
+// DW standing rule — "Wallpaper" is banned in DW's own output; "Wallcovering(s)".
+// The amplify CAPTION is DW copy (even though it quotes the vendor), so de-wallpaper it.
+const deWallpaper = s => String(s || '').replace(/\bwallpapers\b/gi, 'wallcoverings').replace(/\bwallpaper\b/gi, 'wallcovering');
 function liAmplifyKit(brand, companyUrl, og) {
   const DWUTM = 'https://designerwallcoverings.com/?utm_source=linkedin&utm_medium=social&utm_campaign=vendor-amplify';
-  const snippet = String(og && og.description || og && og.title || '').replace(/\s+/g, ' ').trim().slice(0, 220);
+  const snippet = deWallpaper(String(og && og.description || og && og.title || '').replace(/\s+/g, ' ').trim().slice(0, 220));
   const text = `${brand}${snippet ? ' — ' + snippet : ''}\n\n🔗 via ${brand} on LinkedIn: ${companyUrl}\n🛍️ Explore the line at Designer Wallcoverings: ${DWUTM}\n\n#DesignerWallcoverings #wallcoverings #interiordesign`;
   return { text, dwLink: DWUTM };
 }
diff --git a/public/panels/vendors.html b/public/panels/vendors.html
index 9d9668e..47b6f5b 100644
--- a/public/panels/vendors.html
+++ b/public/panels/vendors.html
@@ -22,7 +22,26 @@
   <div id="vend-owned-rows"><div class="muted">Loading owned accounts…</div></div>
 </div>
 <div class="card">
-  <h2>Vendor brands</h2>
-  <div class="muted" style="margin-bottom:10px">External vendor / brand accounts (suppliers &amp; competitors — NOT owned). Click any handle to open the account. Use this roster for competitive/social reporting.</div>
+  <div class="row" style="justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">
+    <h2 style="margin:0">Vendor brands · Instagram</h2>
+    <div style="display:flex;flex-direction:column;align-items:flex-end;gap:3px">
+      <button id="vp-pull-all" class="btn amp-strong" title="Pull the last 10 posts for EVERY vendor Instagram account via Business Discovery (reference/attribution-amplify)">⬇ Pull ALL vendor accounts</button>
+      <small class="muted" id="vp-pull-when"></small>
+    </div>
+  </div>
+  <div class="muted" style="margin:8px 0 4px">External vendor / brand accounts (suppliers &amp; competitors — NOT owned). Click any handle to open the account. Use this roster for competitive/social reporting. <b>Reference + attribution-amplify only</b> — copy the caption text and share with credit back to the vendor.</div>
+  <div id="vend-ig-coverage" class="muted" style="font-size:11.5px;margin-bottom:8px"></div>
   <div id="vend-rows"></div>
 </div>
+<div class="card" id="vend-li-card">
+  <div class="row" style="justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">
+    <h2 style="margin:0">Vendor brands · LinkedIn</h2>
+    <div style="display:flex;flex-direction:column;align-items:flex-end;gap:3px">
+      <button id="vp-li-harvest" class="btn amp-strong" title="Public Open-Graph pull (thumbnail + text) of every mapped vendor LinkedIn company page — no API, no login">⬇ Harvest ALL LinkedIn pages</button>
+      <small class="muted" id="vp-li-when"></small>
+    </div>
+  </div>
+  <div class="muted" style="margin:8px 0 4px">Vendor LinkedIn <b>company pages</b> (B2B/trade). Public Open-Graph pull only — <b>no LinkedIn API, no login</b>. <b>Reference + attribution-amplify only</b>: browse, copy the text, share with a link back to the vendor. <span style="color:#8a6d2f">✓ verified</span> = slug confirmed against the real company page; <span style="color:#a1341f">? unverified</span> = best guess, confirm before trusting.</div>
+  <div id="vend-li-summary" class="muted" style="font-size:11.5px;margin-bottom:8px"></div>
+  <div id="vend-li-rows"><div class="muted">Loading LinkedIn roster…</div></div>
+</div>
diff --git a/public/panels/vendors.js b/public/panels/vendors.js
index de6a067..c1721ea 100644
--- a/public/panels/vendors.js
+++ b/public/panels/vendors.js
@@ -1204,6 +1204,149 @@ window.MCC_PANELS['vendors'] = {
       document.head.appendChild(st);
     }
 
+    // ── Part A · "Pull ALL vendor accounts" + IG coverage note ────────────────
+    // One-click pull of the last-10 posts for EVERY vendor IG account via
+    // Business Discovery (reference/attribution-amplify — never downloads their
+    // image into DW assets). Surfaces the accounts that CAN'T be covered honestly:
+    // an account with a handle but no cached posts, and the "none found" brands
+    // (no BD-readable Business/Creator IG account — Graph 110/2207013).
+    const renderCoverage = () => {
+      const el = root.querySelector('#vend-ig-coverage');
+      if (!el) return;
+      const accts = data.accounts || [];
+      const withIG = accts.filter(a => a.hasIG && a.vendorCode !== 'dw');
+      const covered = withIG.filter(a => a.posts && a.posts.length);
+      const noHandle = accts.filter(a => !a.hasIG && a.vendorCode !== 'dw');
+      const errored = withIG.filter(a => (!a.posts || !a.posts.length));
+      const parts = [`<b>${covered.length}/${withIG.length}</b> vendor IG accounts have posts pulled`];
+      if (errored.length) parts.push(`<span style="color:#a1341f">${errored.length} with a handle but no posts yet</span> (${esc(errored.map(a => a.brand).slice(0, 6).join(', '))}${errored.length > 6 ? '…' : ''})`);
+      if (noHandle.length) parts.push(`<span title="No public Business/Creator IG account is readable via Instagram Business Discovery (Graph error 110/2207013). These can't be amplified via the official API — confirmed 2026-08-31, not faked.">${noHandle.length} have no BD-readable IG</span> (${esc(noHandle.map(a => a.brand).join(', '))})`);
+      el.innerHTML = parts.join(' · ');
+    };
+    renderCoverage();
+
+    const pullBtn = root.querySelector('#vp-pull-all');
+    const pullWhen = root.querySelector('#vp-pull-when');
+    if (pullWhen) pullWhen.textContent = data.stats.postsFetchedAt ? 'last pulled ' + ago(data.stats.postsFetchedAt) : 'not pulled yet';
+    if (pullBtn && !pullBtn.dataset.wired) {
+      pullBtn.dataset.wired = '1';
+      pullBtn.onclick = async () => {
+        const label = pullBtn.textContent; pullBtn.disabled = true; pullBtn.textContent = '⬇ Pulling all accounts…';
+        try {
+          const res = await (await fetch(location.origin + '/api/vendors/posts/refresh', { method: 'POST', credentials: 'same-origin' })).json();
+          if (!res.ok) { pullBtn.textContent = '⚠ ' + (res.error || 'pull failed'); pullBtn.disabled = false; return; }
+          await loadData(); setStats(); renderCoverage();
+          render(root.querySelector('#vs-sort').value);
+          pullBtn.textContent = `✓ ${res.refreshed} pulled, ${res.failed} failed`;
+          if (pullWhen) pullWhen.textContent = 'last pulled just now · via ' + (res.discoveringIg || '@DW');
+          setTimeout(() => { pullBtn.textContent = label; pullBtn.disabled = false; }, 4000);
+        } catch (err) { pullBtn.textContent = '⚠ ' + err.message; pullBtn.disabled = false; }
+      };
+    }
+
+    // ── Part B · Vendor LinkedIn section (reference/attribution-amplify) ───────
+    // Mirrors the IG amplify-kit UX: browse each vendor's public LinkedIn company
+    // page (thumbnail + text via a public Open-Graph pull — NO API, NO login),
+    // copy an attribution-amplify caption, and open the page to share WITH credit.
+    // NO image is ever downloaded into DW assets; NO card is <a>-wrapped.
+    const liRoot = root.querySelector('#vend-li-rows');
+    if (liRoot && !liRoot.dataset.wired) {
+      liRoot.dataset.wired = '1';
+      // Registry of copyable LinkedIn amplify text, keyed by a stable id, so the
+      // delegated handler pastes clean human text (never DOM-scraped HTML).
+      const liKits = {};
+      let liData = null;
+      const loadLi = async () => { liData = await (await fetch(location.origin + '/api/vendors/linkedin/accounts', { credentials: 'same-origin' })).json(); };
+
+      const renderLi = () => {
+        const summary = root.querySelector('#vend-li-summary');
+        const st = (liData && liData.stats) || {};
+        if (summary) summary.innerHTML = `<b>${st.withLinkedIn || 0}</b>/${st.total || 0} mapped to a LinkedIn company · <span style="color:#8a6d2f">${st.verified || 0} verified</span> · <span style="color:#a1341f">${st.unverified || 0} unverified</span> · ${st.missing || 0} no page · <b>${st.harvested || 0}</b> harvested${st.harvestedAt ? ' · ' + ago(st.harvestedAt) : ''}`;
+        const accts = (liData && liData.accounts) || [];
+        // vendors WITHOUT a LinkedIn page sink to the bottom; verified first among the rest.
+        const rows = accts.slice().sort((a, b) => (b.hasLinkedIn - a.hasLinkedIn) || (b.verified - a.verified) || String(a.brand).localeCompare(String(b.brand)));
+        liRoot.innerHTML = rows.map(a => {
+          const restricted = /\bschumacher\b/i.test(a.brand || '');
+          if (!a.hasLinkedIn) {
+            return `<div class="vend-block" style="border-bottom:1px solid var(--line);padding:8px 2px;opacity:.6">
+              <div class="row" style="justify-content:space-between;align-items:center"><div style="min-width:200px"><b>${esc(a.brand)}</b></div>
+              <div class="muted" style="font-size:11px">${esc(a.note || 'no LinkedIn company page')}</div></div></div>`;
+          }
+          const badge = a.verified
+            ? `<span class="pill" style="background:#f4ecd6;color:#6b531f" title="Slug confirmed against the real company page">✓ verified</span>`
+            : `<span class="pill" style="background:#fbf1ee;color:#a1341f" title="Best-guess slug — confirm before trusting${a.note ? ': ' + a.note : ''}">? unverified</span>`;
+          const og = a.og;
+          const thumb = og && og.thumb
+            ? `<img class="ig-li-thumb" loading="lazy" src="${esc(location.origin + '/api/img-proxy?u=' + encodeURIComponent(og.thumb))}" alt="" onerror="this.style.visibility='hidden'">`
+            : `<span class="ig-li-thumb"></span>`;
+          const body = og
+            ? `<span class="ig-li-cap">${esc(og.title || a.brand)}</span><span class="ig-li-meta">${esc((og.description || '').replace(/\s+/g, ' ').slice(0, 140))}</span>`
+            : `<span class="ig-li-cap">${esc(a.brand)}</span><span class="ig-li-meta">${a.error ? esc('LinkedIn blocked this pull — ' + a.error) : 'not harvested yet — click ⬇ Harvest'}</span>`;
+          // The "card" is a plain div (NOT <a>-wrapped, per standing UI rule). The
+          // permalink is a normal link + the amplify controls are siblings.
+          let ampRow;
+          if (restricted) {
+            ampRow = `<div class="amp-row amp-restricted"><span class="amp-note" title="Brand policy: this line must not appear on any customer-facing / marketing / share surface. Internal reporting only.">🔒 Internal only — do not amplify</span></div>`;
+          } else {
+            const id = 'li' + Math.random().toString(36).slice(2, 8);
+            if (a.amplify) liKits[id] = a.amplify.text;
+            ampRow = `<div class="amp-row">
+              <a class="amp-btn" href="${esc(a.companyUrl)}" target="_blank" rel="noopener noreferrer" title="Open the vendor's LinkedIn company page">in Open ↗</a>
+              <button type="button" class="amp-btn" data-li="harvest" data-vc="${esc(a.vendorCode)}" title="Pull this page's public thumbnail + text (Open Graph)">↻ Harvest</button>
+              ${a.amplify ? `<button type="button" class="amp-btn" data-li="copy" data-li-id="${id}" title="Copy an attribution-amplify caption (credits the vendor + links to DW)">⧉ Copy amplify text</button>` : ''}
+            </div>`;
+          }
+          return `<div class="vend-block" style="border-bottom:1px solid var(--line);padding:9px 2px">
+            <div class="row" style="justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap">
+              <div style="min-width:200px"><b>${esc(a.brand)}</b> ${badge}</div>
+              <div style="flex:1"><a class="lnk" href="${esc(a.companyUrl)}" target="_blank" rel="noopener noreferrer">/company/${esc(a.slug)} ↗</a></div>
+            </div>
+            <div class="ig-list"><div class="ig-li" style="cursor:default">${thumb}<span class="ig-li-body">${body}</span></div></div>
+            ${ampRow}</div>`;
+        }).join('');
+      };
+
+      // One delegated listener on the LinkedIn rows host (survives re-render).
+      liRoot.addEventListener('click', async (e) => {
+        const btn = e.target.closest('[data-li]');
+        if (!btn) return;
+        if (btn.dataset.li === 'copy') {
+          const text = liKits[btn.dataset.liId] || '';
+          const label = btn.textContent;
+          const ok = await copyText(text);
+          btn.textContent = ok ? '✓ Copied' : '⚠ Copy failed';
+          setTimeout(() => { btn.textContent = label; }, 2000);
+          return;
+        }
+        if (btn.dataset.li === 'harvest') {
+          const label = btn.textContent; btn.disabled = true; btn.textContent = '↻ Harvesting…';
+          try {
+            const res = await (await fetch(location.origin + '/api/vendors/linkedin/harvest', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ vendorCode: btn.dataset.vc }) })).json();
+            await loadLi(); renderLi();
+            if (!res.ok) { btn.textContent = '⚠ failed'; setTimeout(() => { btn.disabled = false; btn.textContent = label; }, 2500); }
+          } catch (err) { btn.textContent = '⚠ ' + err.message; btn.disabled = false; }
+          return;
+        }
+      });
+
+      const liHarvestAll = root.querySelector('#vp-li-harvest');
+      if (liHarvestAll && !liHarvestAll.dataset.wired) {
+        liHarvestAll.dataset.wired = '1';
+        liHarvestAll.onclick = async () => {
+          const label = liHarvestAll.textContent; liHarvestAll.disabled = true; liHarvestAll.textContent = '⬇ Harvesting all pages…';
+          try {
+            const res = await (await fetch(location.origin + '/api/vendors/linkedin/harvest', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({}) })).json();
+            await loadLi(); renderLi();
+            liHarvestAll.textContent = res.ok ? `✓ ${res.harvested} harvested, ${res.failed} blocked` : '⚠ failed';
+            const when = root.querySelector('#vp-li-when'); if (when) when.textContent = 'harvested just now';
+            setTimeout(() => { liHarvestAll.textContent = label; liHarvestAll.disabled = false; }, 4000);
+          } catch (err) { liHarvestAll.textContent = '⚠ ' + err.message; liHarvestAll.disabled = false; }
+        };
+      }
+
+      loadLi().then(renderLi).catch(() => { liRoot.innerHTML = '<div class="muted">Failed to load LinkedIn roster.</div>'; });
+    }
+
     const s = data.stats || {};
     root.querySelector('#vend-banner').innerHTML = s.missing
       ? `<div class="muted-banner">${s.withIG} of ${s.total} brands have an official Instagram — ${s.missing} have none found (Atomic50, Bespoke, Folia, Naturale 54). Combined vendor reach ≈ ${fmt(s.totalReach)} followers. Last 3 posts shown inline per vendor via Instagram Business Discovery (click Refresh).</div>` : '';
diff --git a/server.js b/server.js
index a106846..011f849 100644
--- a/server.js
+++ b/server.js
@@ -374,7 +374,7 @@ app.get('/api/vendor-amplify-assets', (_req, res) => {
 // cross-origin and would otherwise block toDataURL). Read-only, image-only,
 // size-capped, allowlisted to the known IG/FB CDN + product-image hosts. Not a
 // general proxy — refuses anything that isn't an image from an allowed host.
-const IMG_PROXY_HOSTS = /(\.cdninstagram\.com|\.fbcdn\.net|instagram\.com|designerwallcoverings\.com|\.shopify\.com|cdn\.shopify\.com)$/i;
+const IMG_PROXY_HOSTS = /(\.cdninstagram\.com|\.fbcdn\.net|instagram\.com|designerwallcoverings\.com|\.shopify\.com|cdn\.shopify\.com|\.licdn\.com)$/i;
 app.get('/api/img-proxy', async (req, res) => {
   const raw = String(req.query.u || '');
   let u;

← 6ccae80 vendors: add LinkedIn vendor→company mapping + public OG har  ·  back to Marketing Command Center  ·  vendors: harden fetchLiOg — linkedin-host guard, redirect:ma be836bb →