← back to Marketing Command Center

public/panels/vendors.js

1367 lines

window.MCC_PANELS = window.MCC_PANELS || {};
window.MCC_PANELS['vendors'] = {
  async init(root) {
    const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
    const fmt = n => n >= 1e6 ? (n / 1e6).toFixed(1) + 'M' : n >= 1e3 ? Math.round(n / 1e3) + 'K' : (n || '—');
    const ago = iso => { if (!iso) return ''; const d = (Date.now() - new Date(iso)) / 36e5; return d < 1 ? Math.round(d * 60) + 'm ago' : d < 24 ? Math.round(d) + 'h ago' : Math.round(d / 24) + 'd ago'; };
    let data;
    const loadData = async () => { data = await (await fetch('/api/vendors/accounts')).json(); };
    try { await loadData(); } catch { root.querySelector('#vend-rows').innerHTML = '<div class="muted">Failed to load.</div>'; return; }

    const setStats = () => {
      const s = data.stats || {};
      root.querySelector('#vs-total').textContent = s.total || 0;
      root.querySelector('#vs-ig').textContent = s.withIG || 0;
      root.querySelector('#vs-reach').textContent = fmt(s.totalReach || 0);
      root.querySelector('#vs-missing').textContent = s.missing || 0;
    };
    setStats();

    // "My vendor contacts" — reveal the button + count if the internal contact sheet exists.
    (async () => {
      try {
        const c = await (await fetch('/api/vendors/contacts')).json();
        if (c && c.totalContacts > 0) {
          const a = root.querySelector('#vp-li-contacts');
          root.querySelector('#vp-li-contacts-n').textContent = `(${c.totalContacts})`;
          if (a) a.style.display = '';
        }
      } catch { /* no sheet yet — button stays hidden */ }
    })();

    // Owned · DW Fleet — all 35 DW-OWNED Instagram accounts from the canonical
    // source (window.MCC_ACCOUNTS), each showing its LAST 6 posts inline (thumb +
    // title + date · type), mirroring the external-vendor rows. The owned posts come
    // from the fleet post ledger (/api/ig-activity/posts — the same data the IG
    // Activity panel renders), NOT the vendor Business-Discovery cache. Additive;
    // never touches the external vendor roster.
    (async () => {
      const host = root.querySelector('#vend-owned-rows');
      if (!host) return;
      let owned = [];
      try { owned = (window.MCC_ACCOUNTS && await window.MCC_ACCOUNTS.load()) || []; }
      catch { host.innerHTML = '<div class="muted">Failed to load owned accounts.</div>'; return; }
      if (!owned.length) { host.innerHTML = '<div class="muted">No owned accounts found.</div>'; return; }

      // Group the fleet ledger by handle → last 6 each (newest first). Best-effort:
      // if the ledger is unreachable, the rows still render (just without posts).
      const byHandle = {};
      try {
        const j = await (await fetch(location.origin + '/api/ig-activity/posts', { credentials: 'same-origin' })).json();
        for (const p of (j.posts || [])) { (byHandle[p.handle] = byHandle[p.handle] || []).push(p); }
        for (const k of Object.keys(byHandle)) byHandle[k].sort((a, b) => new Date(b.ts) - new Date(a.ts));
      } catch { /* ledger unreachable — rows render without posts */ }

      const ownedPosts = (list) => {
        if (!list.length) return '<div class="muted" style="font-size:11px;padding:2px 2px 4px">No posts yet.</div>';
        const items = list.map(p => {
          const cap = esc((p.product_title || '').replace(/\s+/g, ' ').slice(0, 120)) || '(no title)';
          const when = p.ts ? String(p.ts).slice(0, 10) : '';
          const kind = esc(p.kind || 'IMAGE');
          const thumb = p.image_url
            ? `<img class="ig-li-thumb" loading="lazy" src="${esc(p.image_url)}" alt="" onerror="this.style.visibility='hidden'">`
            : `<span class="ig-li-thumb"></span>`;
          const link = /^https?:\/\//i.test(p.permalink || '') ? p.permalink : '#';   // reject javascript:/data: URIs from ledger data
          return `<a class="ig-li" href="${esc(link)}" target="_blank" rel="noopener noreferrer">
            ${thumb}
            <span class="ig-li-body">
              <span class="ig-li-cap">${cap} <span class="ig-li-ext">↗</span></span>
              <span class="ig-li-meta">${when ? when + '  ·  ' : ''}${kind}</span>
            </span></a>`;
        }).join('');
        return `<div class="ig-list">${items}</div>`;
      };

      // Per-account health signals computed from the ledger — surfaced so the two
      // real problems in the data (dormant accounts + one-product repetition) are
      // visible at a glance instead of hidden in a flat alpha list.
      const DORMANT_DAYS = 14, REP_MIN = 4;   // repetitive = ≥4 of the last 6 posts share one title
      const meta = owned.map(a => {
        const posts = (byHandle[a.handle] || []);
        const last6 = posts.slice(0, 6);
        const newestTs = posts.length ? posts[0].ts : null;
        const newestMs = newestTs ? new Date(newestTs).getTime() : NaN;
        // Guard against an unparseable ts (external ledger data): treat as no-date →
        // Infinity, so the account reads as dormant rather than silently "healthy"
        // and the sort comparator never gets a NaN (which breaks ordering).
        const daysOld = isFinite(newestMs) ? (Date.now() - newestMs) / 864e5 : Infinity;
        const freq = {};
        let repCount = 0;
        for (const p of last6) { const t = (p.product_title || '').trim().toLowerCase(); if (!t) continue; freq[t] = (freq[t] || 0) + 1; if (freq[t] > repCount) repCount = freq[t]; }
        const weekAgo = Date.now() - 7 * 864e5;
        const week7 = posts.filter(p => { const ms = p.ts ? new Date(p.ts).getTime() : NaN; return isFinite(ms) && ms >= weekAgo; }).length;   // cadence: posts in the last 7 days
        const dormant = posts.length === 0 || daysOld > DORMANT_DAYS;
        // repetition is only actionable on a LIVE account — a dormant/dead account
        // repeating its last (old) posts needs no editorial fix, so don't flag/score it.
        const repetitive = !dormant && last6.length >= REP_MIN && repCount >= REP_MIN;
        const slowing = !dormant && week7 <= 1;   // trailing off — within the dormant window but posting ≤1/wk
        return { a, posts, last6, count: posts.length, week7, newestTs, daysOld, dormant, repetitive, slowing, repCount };
      });

      const SORT_KEY = 'mcc_vendors_owned_sort';
      const sortSel = root.querySelector('#vend-owned-sort');
      if (sortSel) sortSel.value = localStorage.getItem(SORT_KEY) || 'attention';
      const sortMeta = (mode) => {
        const alpha = (x, y) => String(x.a.name || x.a.handle).localeCompare(String(y.a.name || y.a.handle));
        const arr = meta.slice();
        if (mode === 'alpha') return arr.sort(alpha);
        if (mode === 'posts') return arr.sort((x, y) => y.count - x.count || alpha(x, y));
        if (mode === 'recent') return arr.sort((x, y) => x.daysOld - y.daysOld || alpha(x, y));   // newest first; 0-post (Infinity) last
        // 'attention' (default): most ACTIONABLE first, not just "worst". An active account
        // spamming one product is fixable RIGHT NOW, so it tops the list; a once-active
        // account gone dark is next (investigate); then slowing; then never-posted (no
        // creds — structural/known, needs no daily action); then healthy.
        const score = m =>
          m.repetitive ? 4                    // active + repeating one product → editorial fix now
          : (m.dormant && m.count > 0) ? 3     // was posting, went silent >14d → investigate
          : m.slowing ? 2                      // trailing off → re-engage
          : m.dormant ? 1                      // never posted (no creds) → known/structural
          : 0;
        return arr.sort((x, y) => score(y) - score(x) || (y.daysOld - x.daysOld) || alpha(x, y));
      };

      const flag = (bg, fg, txt, title) => `<span class="pill" style="background:${bg};color:${fg}" title="${esc(title)}">${txt}</span>`;
      const renderOwned = (mode) => {
        host.innerHTML = sortMeta(mode).map(m => {
          const a = m.a, h = esc(a.handle);
          const recency = m.count ? ago(m.newestTs) : '—';
          const flags = [
            m.dormant ? flag('#f7e2e2', '#a53a3a', '💤 dormant', m.count ? `No posts in ${Math.round(m.daysOld)} days` : 'No posts yet (account has no creds / hasn’t published)') : '',
            m.slowing ? flag('#eef3f8', '#3a5a8a', '🐢 slowing', `Only ${m.week7} post${m.week7 === 1 ? '' : 's'} in the last 7 days`) : '',
            m.repetitive ? flag('#fdf3dc', '#8a6a1e', `↻ ${m.repCount}× same`, `${m.repCount} of the last ${m.last6.length} posts are the same product — vary the content`) : '',
          ].filter(Boolean).join(' ');
          return `<div class="vend-block" style="border-bottom:1px solid var(--line);padding:10px 2px;background:${m.dormant ? '#fbf3f2' : '#faf6ee'}">
            <div class="row" style="justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap">
              <div style="min-width:200px"><b>${esc(a.name || a.handle)}</b> <span class="pill">OURS</span> ${flags}</div>
              <div style="flex:1"><a class="lnk" href="https://www.instagram.com/${h}/" target="_blank" rel="noopener noreferrer">@${h} ↗</a></div>
              <div style="min-width:150px;text-align:right;font-variant-numeric:tabular-nums;font-size:11.5px;color:var(--muted,#8a8372)" title="total posts in the ledger · newest · posts in the last 7 days">${m.count ? `${m.count} posts · ${recency} · ${m.week7}/7d` : ''}</div>
            </div>
            ${ownedPosts(m.last6)}
          </div>`;
        }).join('');
      };

      // Summary line — the at-a-glance fleet health read.
      const summaryEl = root.querySelector('#vend-owned-summary');
      if (summaryEl) {
        const activeWeek = meta.filter(m => m.week7 > 0).length;
        const dormant = meta.filter(m => m.dormant).length;
        const slowing = meta.filter(m => m.slowing).length;
        const repetitive = meta.filter(m => m.repetitive).length;
        summaryEl.innerHTML = `<b>${activeWeek}</b>/${meta.length} active this week · ` +
          `<b style="color:${dormant ? '#a53a3a' : 'inherit'}">${dormant}</b> dormant · ` +
          `<b style="color:${slowing ? '#3a5a8a' : 'inherit'}">${slowing}</b> slowing · ` +
          `<b style="color:${repetitive ? '#8a6a1e' : 'inherit'}">${repetitive}</b> repetitive`;
      }

      renderOwned(sortSel ? sortSel.value : 'attention');
      if (sortSel && !sortSel.dataset.wired) {
        sortSel.dataset.wired = '1';
        sortSel.onchange = () => { localStorage.setItem(SORT_KEY, sortSel.value); renderOwned(sortSel.value); };
      }
    })();

    // 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`;
    // 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.
    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`;
      // 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,
        // Carried for refresh-on-click matching (TK-10843): the IG media id is the
        // strongest key, then permalink, then timestamp+caption.
        mediaId: p.id || '',
        timestamp: p.timestamp || '',
      };
      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(/^@/, '');
        // Never-front-facing brands (standing brand policy): must NOT appear on any
        // customer-facing / marketing / SHARE surface. Amplifying their posts to DW's
        // public X/IG would leak them, so the amplify + account controls are suppressed
        // and replaced with an internal-only note. Extend this list as policy dictates.
        const restricted = /\bschumacher\b/i.test(brand);
        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) : '';
          const vid = p.media_type === 'VIDEO' ? '▶ ' : '';
          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>`;
          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 = restricted
            ? `<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>`
            : `<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 amp-ours" data-amp="ours" data-amp-id="${id}" title="Transform this pattern into a DW-ORIGINAL branded asset (never reposts the vendor's raw photo)">✦ Make it ours</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>`;
          // Review area (empty until "Make unique"); id-tagged so the delegated
          // handler can find + (re)fill it. Rebuilt every render from stored state.
          // Suppressed entirely for restricted (never-front-facing) brands.
          const review = restricted ? '' : `<div class="amp-review" data-amp-review="${id}" hidden></div>`;
          // Creative review area ("Make it ours") — empty until the button is clicked;
          // also suppressed for restricted brands (they get no creative controls).
          const creative = restricted ? '' : `<div class="amp-creative" data-amp-creative="${id}" hidden></div>`;
          return `<div class="ig-item" data-post-id="${esc(postId)}">${anchor}${ampRow}${review}${creative}</div>`;
        }).join('');
        return `<div class="ig-list">${items}</div>`;
      }
      const why = r.postsError ? `posts unavailable — ${esc(r.postsError).slice(0, 90)}` : 'no posts cached yet — click “Refresh posts”';
      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;
        }
      }
    };

    // ── "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>`;
    };

    // ── "Make it ours" creative image system (TK-10842) ─────────────────────────
    // Transforms the vendor's pattern image (p.image, carried on the kit) into a
    // GENUINELY DW-ORIGINAL branded asset DW can post as its own creative — so
    // amplifying a vendor post NEVER reposts the vendor's raw copyrighted photo.
    //
    // Treatment 1 "Branded card" ($0 local): composites the pattern onto a DW-branded
    //   canvas (wordmark + hairline frame + availability strip + pattern/vendor name)
    //   with the browser Canvas API, at 1080x1080 AND 1080x1350. Transformative brand
    //   creative, no external API. The finished PNG is POSTed to /api/vendor-amplify-assets,
    //   which runs it past the settlement post-gen-vision gate and saves it.
    // Treatment 2 "Room setting" (small $): POSTs the pattern to /api/vendor-amplify-room,
    //   which calls the EXISTING DW room-setting-generator pipeline (:8106) and
    //   settlement-gates the render. Cost is shown BEFORE the user generates.

    // Load an image element from a URL, CORS-safe for canvas readback. IG/fbcdn
    // images taint the canvas, so we route through the same-origin /api/img-proxy.
    const loadPatternImage = (src) => new Promise((resolve, reject) => {
      if (!src) return reject(new Error('no pattern image on this post'));
      const isCrossCdn = /cdninstagram\.com|fbcdn\.net|instagram\.com|shopify/i.test(src);
      const url = isCrossCdn ? (location.origin + '/api/img-proxy?u=' + encodeURIComponent(src)) : src;
      const img = new Image();
      img.crossOrigin = 'anonymous';
      img.onload = () => resolve(img);
      img.onerror = () => {
        // last-ditch fallback: try the proxy even for non-CDN hosts
        if (!isCrossCdn) {
          const img2 = new Image();
          img2.crossOrigin = 'anonymous';
          img2.onload = () => resolve(img2);
          img2.onerror = () => reject(new Error('pattern image failed to load'));
          img2.src = location.origin + '/api/img-proxy?u=' + encodeURIComponent(src);
        } else reject(new Error('pattern image failed to load'));
      };
      img.src = url;
    });

    // ── Template style (Feature 2, TK-10843): editorial is DEFAULT, bold is a toggle.
    // Persisted in localStorage so the choice sticks across posts/sessions.
    const STYLE_KEY = `mcc.vamp.${LS_V}.cardStyle`;
    const getCardStyle = () => (lsGet(STYLE_KEY) === 'bold' ? 'bold' : 'editorial');
    const setCardStyle = (s) => lsSet(STYLE_KEY, s === 'bold' ? 'bold' : 'editorial');

    // Deep DW brand color for the bold band.
    const DW_BRAND = '#2a2318';   // deep DW ink (matches the wordmark)
    const DW_BRAND_ACCENT = '#8a6d2f';

    // Cover-fit (center-crop) a loaded pattern into the target field. Shared by both
    // templates so the pattern crop is identical; only the chrome differs.
    const drawPatternCover = (ctx, img, fx, fy, fw, fh) => {
      const ir = img.width / img.height, fr = fw / fh;
      let sw = img.width, sh = img.height, sx = 0, sy = 0;
      if (ir > fr) { sw = Math.round(img.height * fr); sx = Math.round((img.width - sw) / 2); }
      else { sh = Math.round(img.width / fr); sy = Math.round((img.height - sh) / 2); }
      ctx.save();
      ctx.beginPath(); ctx.rect(fx, fy, fw, fh); ctx.clip();
      ctx.drawImage(img, sx, sy, sw, sh, fx, fy, fw, fh);
      ctx.restore();
    };

    // EDITORIAL template (the DEFAULT): warm paper ground, serif wordmark top-left,
    // hairline double-rule frame, centered pattern name + availability strip. This is
    // the original "Make it ours" look, unchanged.
    const composeEditorial = (img, W, H, opts) => {
      const brand = (opts && opts.brand) || '';
      const label = (opts && opts.label) || '';
      const cv = document.createElement('canvas');
      cv.width = W; cv.height = H;
      const ctx = cv.getContext('2d');
      // Warm DW paper ground.
      ctx.fillStyle = '#f7f2e8'; ctx.fillRect(0, 0, W, H);
      // Inset the pattern into a framed field; reserve a bottom strip for the brand line.
      const pad = Math.round(W * 0.055);
      const stripH = Math.round(H * 0.135);
      const fx = pad, fy = pad, fw = W - pad * 2, fh = H - pad * 2 - stripH;
      drawPatternCover(ctx, img, fx, fy, fw, fh);
      // Hairline frame around the pattern field.
      ctx.strokeStyle = '#3a3226'; ctx.lineWidth = Math.max(1.5, W * 0.0016);
      ctx.strokeRect(fx + 0.5, fy + 0.5, fw - 1, fh - 1);
      // Thin inner hairline (editorial double-rule).
      const g = Math.round(W * 0.012);
      ctx.strokeStyle = 'rgba(58,50,38,0.35)'; ctx.lineWidth = 1;
      ctx.strokeRect(fx + g + 0.5, fy + g + 0.5, fw - g * 2 - 1, fh - g * 2 - 1);
      // DW wordmark (top-left, over the paper margin so it never fights the photo).
      ctx.fillStyle = '#2a2318';
      ctx.textBaseline = 'alphabetic';
      const wm = Math.round(W * 0.030);
      ctx.font = `600 ${wm}px Georgia, "Times New Roman", serif`;
      ctx.fillText('DESIGNER WALLCOVERINGS', pad, Math.round(pad * 0.72));
      // Pattern / vendor name in the strip.
      const cx = W / 2, sTop = H - pad - stripH;
      ctx.textAlign = 'center';
      const nm = (label || brand || 'Featured pattern').replace(/\s+/g, ' ').trim().slice(0, 48);
      ctx.fillStyle = '#2a2318';
      const nmSize = Math.round(W * 0.040);
      ctx.font = `500 ${nmSize}px Georgia, "Times New Roman", serif`;
      ctx.fillText(nm, cx, sTop + Math.round(stripH * 0.40));
      // Availability strip.
      ctx.fillStyle = '#6b5a3a';
      const avSize = Math.round(W * 0.0225);
      ctx.font = `500 ${avSize}px Georgia, "Times New Roman", serif`;
      ctx.fillText('Available at Designer Wallcoverings · designerwallcoverings.com', cx, sTop + Math.round(stripH * 0.78));
      ctx.textAlign = 'left';
      return cv.toDataURL('image/png');
    };

    // BOLD template (Feature 2): a punchier social card — the pattern fills nearly the
    // whole frame, and a full-width DEEP DW-brand band across the BOTTOM carries a
    // large wordmark + pattern name + availability strip in high-contrast light type.
    // Same 1080×1080 / 1080×1350 outputs, same availability strip + pattern name.
    const composeBold = (img, W, H, opts) => {
      const brand = (opts && opts.brand) || '';
      const label = (opts && opts.label) || '';
      const cv = document.createElement('canvas');
      cv.width = W; cv.height = H;
      const ctx = cv.getContext('2d');
      // Full-bleed pattern behind everything (fills the whole frame).
      const bandH = Math.round(H * 0.235);      // deep brand band along the bottom
      drawPatternCover(ctx, img, 0, 0, W, H - bandH);
      // A thin accent rule where the pattern meets the band.
      ctx.fillStyle = DW_BRAND_ACCENT;
      ctx.fillRect(0, H - bandH - Math.max(4, Math.round(H * 0.006)), W, Math.max(4, Math.round(H * 0.006)));
      // Deep DW-brand band.
      ctx.fillStyle = DW_BRAND;
      ctx.fillRect(0, H - bandH, W, bandH);
      const bandTop = H - bandH;
      const padX = Math.round(W * 0.06);
      // Large light wordmark, left-aligned.
      ctx.textAlign = 'left';
      ctx.textBaseline = 'alphabetic';
      ctx.fillStyle = '#f7f2e8';
      const wm = Math.round(W * 0.052);
      ctx.font = `700 ${wm}px Georgia, "Times New Roman", serif`;
      ctx.fillText('DESIGNER WALLCOVERINGS', padX, bandTop + Math.round(bandH * 0.40));
      // Pattern / vendor name, accent color, beneath the wordmark.
      const nm = (label || brand || 'Featured pattern').replace(/\s+/g, ' ').trim().slice(0, 48);
      ctx.fillStyle = '#e8d9b0';
      const nmSize = Math.round(W * 0.038);
      ctx.font = `600 ${nmSize}px Georgia, "Times New Roman", serif`;
      ctx.fillText(nm, padX, bandTop + Math.round(bandH * 0.66));
      // Availability strip (same copy as editorial), lighter weight.
      ctx.fillStyle = 'rgba(247,242,232,0.72)';
      const avSize = Math.round(W * 0.023);
      ctx.font = `500 ${avSize}px Georgia, "Times New Roman", serif`;
      ctx.fillText('Available at Designer Wallcoverings · designerwallcoverings.com', padX, bandTop + Math.round(bandH * 0.88));
      ctx.textAlign = 'left';
      return cv.toDataURL('image/png');
    };

    // Compose one card in the chosen style. `style` = 'editorial' (default) | 'bold'.
    const composeBrandedCard = (img, W, H, opts) =>
      ((opts && opts.style) === 'bold' ? composeBold : composeEditorial)(img, W, H, opts);

    // Compose both feed sizes from one loaded pattern in the given style.
    // Returns { '1080', '1350' }.
    const composeBoth = (img, brand, label, style) => ({
      '1080': composeBrandedCard(img, 1080, 1080, { brand, label, style }),
      '1350': composeBrandedCard(img, 1080, 1350, { brand, label, style }),
    });

    // POST a data-URL asset to the server (settlement-gated save). Optionally attach
    // to a staged draft (replacing the raw vendor photo). Returns the JSON response.
    const stageBrandedCard = async (kit, dataUrl, variant, attachToDraftId) => {
      const res = await fetch(location.origin + '/api/vendor-amplify-assets', {
        method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin',
        body: JSON.stringify({ vendor: kit.brand, treatment: 'branded-card', image: dataUrl, variant, attachToDraftId }),
      });
      return res.json();
    };

    // Find the newest staged draft id for this post's image (so "Use this in the draft"
    // attaches to an existing Make-unique staged draft when one exists). Best-effort.
    const findDraftIdForKit = async (kit) => {
      try {
        const j = await (await fetch(location.origin + '/api/vendor-amplify-drafts', { credentials: 'same-origin' })).json();
        const list = (j && j.drafts) || [];
        const hit = list.find(d => d && (d.permalink === kit.permalink || d.postImage === kit.image || d.originalPostImage === kit.image) && d.vendor === kit.brand);
        return hit ? hit.id : null;
      } catch { return null; }
    };

    // ── Refresh-on-click fresh image (Feature 1, TK-10843) ───────────────────────
    // The branded-card compositor sources kit.image — a SIGNED IG CDN url that
    // expires (~a day). A cached post that's days old 403s → img-proxy 502s → the
    // card can't compose. So BEFORE compositing we fetch a FRESH image url for this
    // specific post via a targeted single-handle refresh, then feed the fresh url to
    // the existing loadPatternImage → composeBrandedCard path.
    //
    // Matching (fresh post → the clicked one): IG media id FIRST, else permalink,
    // else timestamp+caption. On a match we mutate the kit's image/permalink/mediaId/
    // timestamp in place so the room-render path (which also reads kit.image) gets the
    // fresh url too. Returns { ok, refreshed, error } — never throws.
    const matchFresh = (kit, posts) => {
      if (!Array.isArray(posts) || !posts.length) return null;
      if (kit.mediaId) { const m = posts.find(p => p && p.id && String(p.id) === String(kit.mediaId)); if (m) return m; }
      if (kit.permalink) { const m = posts.find(p => p && p.permalink && p.permalink === kit.permalink); if (m) return m; }
      if (kit.timestamp) {
        const src = String(kit.srcCaption || '').replace(/\s+/g, ' ').trim().slice(0, 80).toLowerCase();
        const m = posts.find(p => p && p.timestamp === kit.timestamp &&
          (!src || String(p.caption || '').replace(/\s+/g, ' ').trim().slice(0, 80).toLowerCase() === src));
        if (m) return m;
      }
      return null;
    };
    const refreshFreshImage = async (kit) => {
      const handle = (kit.handle || '').replace(/^@/, '').trim();
      if (!handle) return { ok: false, error: 'no handle on this post' };
      let j;
      try {
        j = await (await fetch(location.origin + '/api/vendors/posts/refresh-one', {
          method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin',
          body: JSON.stringify({ handle }),
        })).json();
      } catch (e) { return { ok: false, error: 'refresh request failed (' + e.message + ')' }; }
      if (!j || !j.ok) return { ok: false, error: (j && j.error) || 'refresh failed' };
      const fresh = matchFresh(kit, j.posts);
      if (!fresh) return { ok: false, error: 'no match', noMatch: true };
      // Mutate the kit in place with the fresh fields (image is the point; the others
      // keep future matches + the room path consistent).
      if (fresh.image) kit.image = fresh.image;
      if (fresh.id) kit.mediaId = fresh.id;
      if (fresh.permalink) kit.permalink = fresh.permalink;
      if (fresh.timestamp) kit.timestamp = fresh.timestamp;
      return { ok: true, refreshed: true };
    };

    // Render the creative review area for one post: branded-card generator (default,
    // $0, editorial|bold) + room-setting generator (shows cost before generating).
    // Rebuilt on demand.
    // st: { runningTotal, img?(loaded fresh HTMLImageElement, cached across style
    //   switches), cards?{1080,1350}, phase:'idle'|'fetching'|'ready'|'error',
    //   fetchNote?(graceful-degrade message), triedFresh?(bool) }
    const creativeState = {};
    const composeFromStateImg = (ampId) => {
      const st = creativeState[ampId]; const kit = ampKits[ampId];
      if (!st || !kit || !st.img) return;
      st.cards = composeBoth(st.img, kit.brand, kit.brand, getCardStyle());
    };
    const renderCreative = (ampId) => {
      const kit = ampKits[ampId]; if (!kit) return;
      const host = root.querySelector(`[data-amp-creative="${ampId}"]`);
      if (!host) return;
      host.hidden = false;
      const st = creativeState[ampId] || (creativeState[ampId] = { runningTotal: 0, phase: 'idle' });
      const style = getCardStyle();
      // Editorial | Bold segmented control (default = Editorial; persisted).
      const seg = `<span class="amp-seg" role="group" aria-label="Card template">
          <button type="button" class="amp-seg-btn${style === 'editorial' ? ' is-on' : ''}" data-cre="style" data-style="editorial" aria-pressed="${style === 'editorial'}">Editorial</button>
          <button type="button" class="amp-seg-btn${style === 'bold' ? ' is-on' : ''}" data-cre="style" data-style="bold" aria-pressed="${style === 'bold'}">Bold</button>
        </span>`;
      let cardHtml;
      if (st.cards) {
        cardHtml = `<div class="amp-cre-cards">
            <figure class="amp-cre-fig"><img src="${st.cards['1080']}" alt="branded card 1080×1080"><figcaption>Feed · 1080×1080 · ${style}</figcaption>
              <button type="button" class="amp-mini" data-cre="use" data-var="1080" title="Save + attach this to the draft (replaces the raw vendor photo)">Use this in the draft</button></figure>
            <figure class="amp-cre-fig"><img src="${st.cards['1350']}" alt="branded card 1080×1350"><figcaption>Portrait · 1080×1350 · ${style}</figcaption>
              <button type="button" class="amp-mini" data-cre="use" data-var="1350" title="Save + attach this to the draft (replaces the raw vendor photo)">Use this in the draft</button></figure>
          </div>`;
      } else if (st.phase === 'error') {
        // GRACEFUL DEGRADE: clear inline message, retry button, no hard-error.
        cardHtml = `<div class="amp-cre-empty amp-cre-degrade">
            <span style="color:#a1341f">${esc(st.fetchNote || "Couldn't fetch a fresh image — click ↻ Refresh posts, then retry")}</span>
            <button type="button" class="amp-mini" data-cre="retry-card" style="margin-left:8px" title="Try fetching a fresh image again">↻ Retry</button>
          </div>`;
      } else {
        cardHtml = `<div class="amp-cre-empty">${esc(st.phase === 'fetching' ? 'Fetching a fresh image…' : 'Generating branded card…')}</div>`;
      }
      host.innerHTML = `
        <div class="amp-cre-hd"><b>✦ Make it ours</b> <span class="muted">— a DW-original asset (never reposts the vendor's photo)</span></div>
        <div class="amp-cre-block">
          <div class="amp-cre-row"><span class="amp-cre-t">1 · Branded card</span>${seg}<span class="amp-cost">$0 (local)</span>
            <button type="button" class="amp-mini" data-cre="regen-card" title="Fetch a fresh image + re-composite">↻ Regenerate</button></div>
          ${cardHtml}
        </div>
        <div class="amp-cre-block">
          <div class="amp-cre-row"><span class="amp-cre-t">2 · Room setting</span><span class="amp-cost amp-cost-paid">~$0.03 / render (Gemini 2.5 flash image)</span>
            <button type="button" class="amp-btn amp-strong" data-cre="room" title="Generate an ORIGINAL DW room mockup with this pattern on the wall (small cost)">Generate room render</button></div>
          <div class="amp-cre-room" data-cre-room="${ampId}"></div>
        </div>
        <div class="amp-cre-note muted">Refresh-on-click keeps the pattern image fresh · settlement-gated before save · nothing auto-posts · posting is Steve-gated.${st.runningTotal ? ` <b>Session render spend: $${st.runningTotal.toFixed(2)}</b>` : ''}</div>`;

      // Kick off the fetch+composite pipeline if we don't already have cards.
      if (!st.cards && st.phase !== 'fetching') {
        st.phase = 'fetching';
        // Re-render once so the "Fetching a fresh image…" state paints immediately.
        const showFetching = root.querySelector(`[data-amp-creative="${ampId}"] .amp-cre-empty`);
        if (showFetching) showFetching.textContent = 'Fetching a fresh image…';
        (async () => {
          // 1) Refresh-on-click: get a FRESH image url for this specific post.
          let note = '';
          if (!st.triedFresh) {
            st.triedFresh = true;
            const r = await refreshFreshImage(kit);
            if (!r.ok) {
              // Don't hard-fail yet — the cached url MIGHT still be valid. Remember the
              // reason to surface if the cached load also fails.
              note = r.noMatch
                ? "Couldn't match a fresh image for this post — click ↻ Refresh posts, then retry"
                : `Couldn't fetch a fresh image (${r.error}) — click ↻ Refresh posts, then retry`;
            }
          }
          // 2) Load (fresh url if we got one, else the cached url) via the same-origin
          //    proxy so the canvas stays untainted for toDataURL readback.
          try {
            const img = await loadPatternImage(kit.image);
            st.img = img;
            composeFromStateImg(ampId);
            st.phase = 'ready';
            st.fetchNote = '';
          } catch (err) {
            st.phase = 'error';
            // Prefer the refresh-degrade note; else the load error (both point the
            // user at ↻ Refresh posts). Never throw — inline message only.
            st.fetchNote = note || `Couldn't fetch a fresh image (${err.message}) — click ↻ Refresh posts, then retry`;
          }
          renderCreative(ampId);
        })();
      }
    };

    // 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. 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';
      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 === 'from') { openFromPopover(btn); return; }
        if (action === 'unique') { renderReview(btn.dataset.ampId); return; }
        if (action === 'ours') { renderCreative(btn.dataset.ampId); return; }
        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();
        }
      });
    }

    // 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;
          }
        }

        // — creative "Make it ours" area interactions —
        const creBtn = e.target.closest('[data-cre]');
        if (creBtn) {
          const creEl = creBtn.closest('.amp-creative');
          if (!creEl) return;
          const ampId = creEl.getAttribute('data-amp-creative');
          const kit = ampKits[ampId]; if (!kit) return;
          const st = creativeState[ampId] || (creativeState[ampId] = { runningTotal: 0, phase: 'idle' });
          const kind = creBtn.dataset.cre;

          // Editorial | Bold toggle (Feature 2). Persist the choice; re-composite from
          // the CACHED fresh image (no re-fetch) when we already have it — else fall
          // through to the fetch pipeline via renderCreative.
          if (kind === 'style') {
            const next = creBtn.dataset.style === 'bold' ? 'bold' : 'editorial';
            if (getCardStyle() === next && st.cards) return;   // no-op if already shown
            setCardStyle(next);
            if (st.img) { composeFromStateImg(ampId); }        // instant re-composite
            else { st.cards = null; }                          // let renderCreative fetch
            renderCreative(ampId);
            return;
          }

          // Regenerate / retry — force a FRESH refresh-on-click fetch + re-composite.
          if (kind === 'regen-card' || kind === 'retry-card') {
            st.cards = null; st.img = null; st.triedFresh = false; st.phase = 'idle'; st.fetchNote = '';
            renderCreative(ampId);
            return;
          }

          if (kind === 'use') {
            if (!st.cards) return;
            const variant = creBtn.dataset.var === '1350' ? '1350' : '1080';
            const label = creBtn.textContent; creBtn.disabled = true; creBtn.textContent = 'Saving…';
            try {
              // Attach to an existing staged draft for this post if one exists.
              const draftId = await findDraftIdForKit(kit);
              const res = await stageBrandedCard(kit, st.cards[variant], variant, draftId || undefined);
              if (res && res.blocked) {
                creBtn.textContent = '⚠ Blocked (settlement)';
                alert('Settlement gate BLOCKED this asset — not saved.\n\n' + (res.reason || ''));
              } else if (res && res.ok) {
                const attachMsg = res.attached ? ' + attached to draft' : (draftId ? ' (attach failed)' : ' (no draft yet — staged asset)');
                const rv = res.verdict && res.verdict !== 'OK' ? ` · ${res.verdict}` : '';
                creBtn.textContent = `✓ Saved${attachMsg}${rv}`;
              } else {
                creBtn.textContent = '⚠ ' + ((res && res.error) || 'failed');
              }
            } catch (err) {
              creBtn.textContent = '⚠ ' + err.message;
            }
            setTimeout(() => { creBtn.textContent = label; creBtn.disabled = false; }, 4000);
            return;
          }

          if (kind === 'room') {
            const roomHost = creEl.querySelector(`[data-cre-room="${ampId}"]`);
            const label = creBtn.textContent; creBtn.disabled = true; creBtn.textContent = 'Rendering… (~30–60s)';
            if (roomHost) roomHost.innerHTML = '<div class="amp-cre-empty">Rendering an original DW room mockup…</div>';
            try {
              // Route the pattern through the same-origin proxy → dataURL so the
              // server gets a clean base64 (never a tainted / CORS-blocked read).
              // Reuse the fresh image the branded card already loaded (refresh-on-click
              // mutated kit.image), else load kit.image now.
              const img = st.img || await loadPatternImage(kit.image);
              const cv = document.createElement('canvas');
              const MAX = 1024;
              const scale = Math.min(1, MAX / Math.max(img.width, img.height));
              cv.width = Math.round(img.width * scale); cv.height = Math.round(img.height * scale);
              cv.getContext('2d').drawImage(img, 0, 0, cv.width, cv.height);
              const patternBase64 = cv.toDataURL('image/jpeg', 0.9).replace(/^data:[^,]*,/, '');
              const draftId = await findDraftIdForKit(kit);
              const res = await (await fetch(location.origin + '/api/vendor-amplify-room', {
                method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin',
                body: JSON.stringify({ vendor: kit.brand, patternBase64, roomType: 'living_room', attachToDraftId: draftId || undefined }),
              })).json();
              if (res && res.cost) { st.runningTotal += res.cost; }
              if (res && res.blocked) {
                if (roomHost) roomHost.innerHTML = `<div class="amp-cre-empty" style="color:#a1341f">Settlement gate BLOCKED this render — not saved. ${esc(res.reason || '')}</div><div class="amp-cost amp-cost-paid">${esc(res.costLabel || '')}</div>`;
              } else if (res && res.ok) {
                const rv = res.verdict && res.verdict !== 'OK' ? ` · <b>${esc(res.verdict)}</b>` : '';
                const att = res.attached ? ' + attached to draft' : (draftId ? '' : '');
                if (roomHost) roomHost.innerHTML = `
                  <figure class="amp-cre-fig"><img src="${esc(res.asset.url)}" alt="DW room setting">
                    <figcaption>Room · ${esc(res.asset.variant)}${rv}${att}</figcaption></figure>
                  <div class="amp-cost amp-cost-paid">${esc(res.costLabel || '')}${st.runningTotal ? ` · session total $${st.runningTotal.toFixed(2)}` : ''}</div>`;
              } else {
                if (roomHost) roomHost.innerHTML = `<div class="amp-cre-empty" style="color:#a1341f">${esc((res && res.error) || 'render failed')}</div>`;
              }
              // Refresh the note line's running-total.
              const note = creEl.querySelector('.amp-cre-note');
              if (note && st.runningTotal) note.innerHTML = note.innerHTML.replace(/(<b>Session render spend:.*?<\/b>)?$/, `<b>Session render spend: $${st.runningTotal.toFixed(2)}</b>`);
            } catch (err) {
              if (roomHost) roomHost.innerHTML = `<div class="amp-cre-empty" style="color:#a1341f">${esc(err.message)}</div>`;
            }
            creBtn.textContent = label; creBtn.disabled = false;
            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 || ''));
      else rows.sort((a, b) => (b.followersNum || 0) - (a.followersNum || 0));
      root.querySelector('#vend-rows').innerHTML = rows.map(r => {
        const dw = r.vendorCode === 'dw';
        const handle = r.hasIG
          ? `<a class="lnk" href="${esc(r.url)}" target="_blank" rel="noopener noreferrer">${esc(r.handle)} ↗</a>`
          : `<span class="muted">no IG</span>`;
        return `<div class="vend-block" style="border-bottom:1px solid var(--line);padding:10px 2px${dw ? ';background:#faf6ee' : ''}">
          <div class="row" style="justify-content:space-between;align-items:center">
            <div style="min-width:200px"><b>${esc(r.brand)}</b>${dw ? ' <span class="pill">OURS</span>' : ''}${r.note && !dw ? ` <span class="muted" style="font-size:11px">${esc(r.note)}</span>` : ''}</div>
            <div style="flex:1">${handle}</div>
            <div style="min-width:70px;text-align:right;font-variant-numeric:tabular-nums">${r.hasIG ? fmt(r.followersNum) : ''}</div>
          </div>
          ${postList(r)}
        </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.
    const bar = root.querySelector('#vend-stats');
    if (bar && !bar.querySelector('#vp-refresh')) {
      const wrap = document.createElement('div');
      wrap.style.cssText = 'display:flex;flex-direction:column;align-items:flex-end;gap:2px';
      wrap.innerHTML = `<button id="vp-refresh" class="btn">↻ Refresh posts</button>
        <small class="muted" id="vp-when">${data.stats.postsFetchedAt ? 'posts updated ' + ago(data.stats.postsFetchedAt) : 'posts not fetched yet'} · ${data.stats.withPosts || 0}/${data.stats.withIG || 0} with posts</small>`;
      bar.appendChild(wrap);
      wrap.querySelector('#vp-refresh').onclick = async (e) => {
        const btn = e.target; btn.disabled = true; btn.textContent = '↻ Fetching latest posts…';
        try {
          const res = await (await fetch('/api/vendors/posts/refresh', { method: 'POST' })).json();
          if (!res.ok) { btn.textContent = '⚠ ' + (res.error || 'refresh failed'); btn.disabled = false; return; }
          await loadData(); setStats();
          render(root.querySelector('#vs-sort').value);
          btn.textContent = `✓ ${res.refreshed} ok, ${res.failed} failed (via ${res.discoveringIg})`;
          root.querySelector('#vp-when').textContent = 'posts updated just now · ' + (data.stats.withPosts || 0) + '/' + (data.stats.withIG || 0) + ' with posts';
          setTimeout(() => { btn.textContent = '↻ Refresh posts'; btn.disabled = false; }, 4000);
        } catch (err) { btn.textContent = '⚠ ' + err.message; btn.disabled = false; }
      };
    }

    // Inline last-3 listing styling (idempotent; id bumped so it re-injects over old grid CSS)
    if (!document.getElementById('vend-ig-css5')) {
      // remove the prior versions so the bumped stylesheet fully supersedes them
      ['vend-ig-css2', 'vend-ig-css3', 'vend-ig-css4'].forEach(oid => { const o = document.getElementById(oid); if (o) o.remove(); });
      const st = document.createElement('style'); st.id = 'vend-ig-css5';
      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}
      .ig-li:hover{background:#f3eee2}
      .ig-li-thumb{width:40px;height:40px;flex:0 0 40px;border-radius:6px;object-fit:cover;background:#ece7dd;display:inline-block}
      .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-item{display:flex;flex-direction:column;gap:2px}
      .amp-row{display:flex;gap:5px;justify-content:flex-end;padding:1px 2px 2px;flex-wrap:wrap}
      .amp-note{font-size:10.5px;line-height:1;color:#a1341f;border:1px solid #e5c9c2;background:#fbf1ee;border-radius:6px;padding:4px 8px;white-space:nowrap}
      .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-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}
      /* Make-it-ours creative review area */
      .amp-btn.amp-ours{opacity:.8;border-color:#c9b98f;color:#8a6d2f}
      .amp-btn.amp-ours:hover{background:#f4ecd6;color:#6b531f}
      .amp-creative{margin:2px 2px 8px;border:1px solid #d8cfb8;border-radius:9px;background:#fdfbf5;padding:9px}
      .amp-cre-hd{font-size:12.5px;margin-bottom:8px}
      .amp-cre-block{border:1px solid var(--line,#e7e0cf);border-radius:8px;background:#fff;padding:8px;margin-bottom:8px}
      .amp-cre-row{display:flex;align-items:center;gap:9px;flex-wrap:wrap}
      .amp-cre-t{font-weight:600;font-size:12px}
      .amp-cost{font-size:10.5px;color:#3f7a3f;background:#eef6ee;border:1px solid #cfe4cf;border-radius:5px;padding:2px 7px;font-variant-numeric:tabular-nums}
      .amp-cost.amp-cost-paid{color:#8a6d2f;background:#f6efdd;border-color:#e2d3ac}
      .amp-cre-cards{display:flex;gap:12px;margin-top:9px;flex-wrap:wrap}
      .amp-cre-fig{margin:0;display:flex;flex-direction:column;gap:5px;align-items:flex-start;max-width:200px}
      .amp-cre-fig img{max-width:200px;width:100%;height:auto;border:1px solid var(--line,#e7e0cf);border-radius:6px;background:#f4efe4}
      .amp-cre-fig figcaption{font-size:10.5px;color:var(--muted,#8a8372)}
      .amp-cre-room{margin-top:9px}
      .amp-cre-room .amp-cre-fig,.amp-cre-room .amp-cre-fig img{max-width:320px}
      .amp-cre-empty{font-size:11.5px;color:var(--muted,#8a8372);padding:6px 2px}
      .amp-cre-note{font-size:10.5px;margin-top:2px}
      /* Editorial | Bold segmented control (Make it ours template toggle) */
      .amp-seg{display:inline-flex;border:1px solid #c9b98f;border-radius:7px;overflow:hidden}
      .amp-seg-btn{font-size:10.5px;line-height:1;padding:3px 9px;border:0;background:transparent;color:#8a6d2f;cursor:pointer}
      .amp-seg-btn+.amp-seg-btn{border-left:1px solid #dccea6}
      .amp-seg-btn:hover{background:#f4ecd6}
      .amp-seg-btn.is-on{background:#2a2318;color:#f7f2e8}
      .amp-cre-degrade{display:flex;align-items:center;flex-wrap:wrap;gap:4px}`;
      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>` : '';
  },
};