← back to Marketing Command Center
Owned·DW Fleet: add 7-day cadence + 'slowing' signal
96908679edbe15519f2a6075d433ac35545c6c24 · 2026-08-25 10:44:47 -0700 · Steve
Next refinement on the health view: distinguish 'posting daily' from 'posted once
10 days ago' among the non-dormant accounts.
- week7: posts in the last 7 days, shown per account as '· N/7d'
- 🐢 slowing flag (blue): non-dormant but ≤1 post in the last 7 days — catches
accounts trailing off BEFORE they cross the 14-day dormant line
- summary now leads with 'active this week' + a slowing count
- attention-sort weighting: dormant(3) > repetitive(2) > slowing(1)
Verified: summary '27/35 active this week · 6 dormant · 2 slowing · 2 repetitive';
/7d recency renders; no page errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M public/panels/vendors.js
Diff
commit 96908679edbe15519f2a6075d433ac35545c6c24
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Aug 25 10:44:47 2026 -0700
Owned·DW Fleet: add 7-day cadence + 'slowing' signal
Next refinement on the health view: distinguish 'posting daily' from 'posted once
10 days ago' among the non-dormant accounts.
- week7: posts in the last 7 days, shown per account as '· N/7d'
- 🐢 slowing flag (blue): non-dormant but ≤1 post in the last 7 days — catches
accounts trailing off BEFORE they cross the 14-day dormant line
- summary now leads with 'active this week' + a slowing count
- attention-sort weighting: dormant(3) > repetitive(2) > slowing(1)
Verified: summary '27/35 active this week · 6 dormant · 2 slowing · 2 repetitive';
/7d recency renders; no page errors.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
public/panels/vendors.js | 294 ++++++++++++++++++++++++++++++++++++++---------
1 file changed, 241 insertions(+), 53 deletions(-)
diff --git a/public/panels/vendors.js b/public/panels/vendors.js
index 9b966ea..376d7fa 100644
--- a/public/panels/vendors.js
+++ b/public/panels/vendors.js
@@ -71,9 +71,12 @@ window.MCC_PANELS['vendors'] = {
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 => p.ts && new Date(p.ts) >= weekAgo).length; // cadence: posts in the last 7 days
const dormant = posts.length === 0 || daysOld > DORMANT_DAYS;
const repetitive = last6.length >= REP_MIN && repCount >= REP_MIN;
- return { a, posts, last6, count: posts.length, newestTs, daysOld, dormant, repetitive, repCount };
+ 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';
@@ -85,8 +88,8 @@ window.MCC_PANELS['vendors'] = {
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) => (y.newestTs ? new Date(y.newestTs) : 0) - (x.newestTs ? new Date(x.newestTs) : 0) || alpha(x, y));
- // 'attention' (default): worst first — dormant + repetitive on top, then stalest.
- const score = m => (m.dormant ? 2 : 0) + (m.repetitive ? 1 : 0);
+ // 'attention' (default): worst first — dormant, then repetitive, then slowing, then stalest.
+ const score = m => (m.dormant ? 3 : 0) + (m.repetitive ? 2 : 0) + (m.slowing ? 1 : 0);
return arr.sort((x, y) => score(y) - score(x) || (y.daysOld - x.daysOld) || alpha(x, y));
};
@@ -97,13 +100,14 @@ window.MCC_PANELS['vendors'] = {
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:120px;text-align:right;font-variant-numeric:tabular-nums;font-size:11.5px;color:var(--muted,#8a8372)" title="posts in the fleet ledger">${m.count ? `${m.count} posts · ${recency}` : ''}</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>`;
@@ -113,12 +117,14 @@ window.MCC_PANELS['vendors'] = {
// Summary line — the at-a-glance fleet health read.
const summaryEl = root.querySelector('#vend-owned-summary');
if (summaryEl) {
- const posting = meta.filter(m => m.count).length;
+ 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>${posting}</b>/${meta.length} posting · ` +
+ summaryEl.innerHTML = `<b>${activeWeek}</b>/${meta.length} active this week · ` +
`<b style="color:${dormant ? '#a53a3a' : 'inherit'}">${dormant}</b> dormant · ` +
- `<b style="color:${repetitive ? '#8a6a1e' : 'inherit'}">${repetitive}</b> repetitive — last 6 posts each.`;
+ `<b style="color:${slowing ? '#3a5a8a' : 'inherit'}">${slowing}</b> slowing · ` +
+ `<b style="color:${repetitive ? '#8a6a1e' : 'inherit'}">${repetitive}</b> repetitive`;
}
renderOwned(sortSel ? sortSel.value : 'attention');
@@ -486,11 +492,33 @@ window.MCC_PANELS['vendors'] = {
img.src = url;
});
- // Compose a DW-branded card from a loaded pattern image at W×H. Returns a
- // dataURL (PNG). This is the transformative brand creative — the vendor's photo
- // becomes a cropped texture field inside DW-owned chrome (frame + wordmark +
- // availability strip + pattern name), NOT a reposted photo.
- const composeBrandedCard = (img, W, H, opts) => {
+ // ── 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');
@@ -502,15 +530,7 @@ window.MCC_PANELS['vendors'] = {
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;
- // Cover-fit the pattern into the field (center-crop).
- 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();
+ 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);
@@ -541,10 +561,58 @@ window.MCC_PANELS['vendors'] = {
return cv.toDataURL('image/png');
};
- // Compose both feed sizes from one loaded pattern. Returns { '1080x1080', '1080x1350' }.
- const composeBoth = (img, brand, label) => ({
- '1080': composeBrandedCard(img, 1080, 1080, { brand, label }),
- '1350': composeBrandedCard(img, 1080, 1350, { brand, label }),
+ // 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
@@ -568,28 +636,97 @@ window.MCC_PANELS['vendors'] = {
} 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) + room-setting generator (shows cost before generating). Rebuilt on demand.
- const creativeState = {}; // ampId -> { cards:{1080,1350}, saved:[], runningTotal }
+ // $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 });
- const cardHtml = st.cards
- ? `<div class="amp-cre-cards">
- <figure class="amp-cre-fig"><img src="${st.cards['1080']}" alt="branded card 1080×1080"><figcaption>Feed · 1080×1080</figcaption>
+ 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</figcaption>
+ <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>`
- : `<div class="amp-cre-empty">Generating branded card…</div>`;
+ </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><span class="amp-cost">$0 (local)</span>
- <button type="button" class="amp-mini" data-cre="regen-card" title="Re-composite the branded card">↻ Regenerate</button></div>
+ <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">
@@ -597,16 +734,44 @@ window.MCC_PANELS['vendors'] = {
<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">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 $0 branded-card composite if not done yet.
- if (!st.cards) {
- loadPatternImage(kit.image).then(img => {
- st.cards = composeBoth(img, kit.brand, kit.brand);
+ <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);
- }).catch(err => {
- const h = root.querySelector(`[data-amp-creative="${ampId}"] .amp-cre-empty`);
- if (h) h.innerHTML = `<span style="color:#a1341f">Couldn't build the branded card: ${esc(err.message)}</span>`;
- });
+ })();
}
};
@@ -762,11 +927,25 @@ window.MCC_PANELS['vendors'] = {
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 });
+ const st = creativeState[ampId] || (creativeState[ampId] = { runningTotal: 0, phase: 'idle' });
const kind = creBtn.dataset.cre;
- if (kind === 'regen-card') {
- st.cards = null;
+ // 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;
}
@@ -803,7 +982,9 @@ window.MCC_PANELS['vendors'] = {
try {
// Route the pattern through the same-origin proxy → dataURL so the
// server gets a clean base64 (never a tainted / CORS-blocked read).
- const img = await loadPatternImage(kit.image);
+ // 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));
@@ -928,10 +1109,10 @@ window.MCC_PANELS['vendors'] = {
}
// Inline last-3 listing styling (idempotent; id bumped so it re-injects over old grid CSS)
- if (!document.getElementById('vend-ig-css4')) {
+ if (!document.getElementById('vend-ig-css5')) {
// remove the prior versions so the bumped stylesheet fully supersedes them
- ['vend-ig-css2', 'vend-ig-css3'].forEach(oid => { const o = document.getElementById(oid); if (o) o.remove(); });
- const st = document.createElement('style'); st.id = 'vend-ig-css4';
+ ['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}
@@ -997,7 +1178,14 @@ window.MCC_PANELS['vendors'] = {
.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}`;
+ .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);
}
← f91859a Refine Owned·DW Fleet: surface dormant + repetition health s
·
back to Marketing Command Center
·
auto-data-snapshot: 2026-08-25T10:46:10 (1 data files) — pub 834187d →