← 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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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>` : '';
},
};