[object Object]

← back to Interiordesignershowroom

fix(TK-10390): guide image fallback — no more imageless guides after reconcile

3407b7fcf79996c3ee49d946ed696de9081ff4d3 · 2026-08-10 08:59:39 -0700 · Steve Abrams

The 257-path reconcile NULLed 107 guides.hero_image, leaving guides imageless
(JSON-LD Article.image dropped, og:image -> logo, home/index cards blank) — a
real SEO/social regression cre-agent's contrarian gate caught (M-00475).

Option A (render-layer, $0): guides now fall back to a representative product
image (first non-suppressed product of the guide) — exactly like rooms fall back
to product thumbs. Applied to all 3 surfaces: detail route (Article.image +
og:image + visible hero via guideImg), home guide-strip + /guides index (via a
first-product-image subquery). data-hide-on-error still guards the img.

Verified: fallback subquery returns real product images 5/5 guides; absUrl passes
external CDN URLs through unchanged (no double-prefix); simulated prod-NULL path
-> product image; syntax OK, routes 200; guides WITH a real hero unchanged.
Deploy gated -> pending-approval.

Files touched

Diff

commit 3407b7fcf79996c3ee49d946ed696de9081ff4d3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 10 08:59:39 2026 -0700

    fix(TK-10390): guide image fallback — no more imageless guides after reconcile
    
    The 257-path reconcile NULLed 107 guides.hero_image, leaving guides imageless
    (JSON-LD Article.image dropped, og:image -> logo, home/index cards blank) — a
    real SEO/social regression cre-agent's contrarian gate caught (M-00475).
    
    Option A (render-layer, $0): guides now fall back to a representative product
    image (first non-suppressed product of the guide) — exactly like rooms fall back
    to product thumbs. Applied to all 3 surfaces: detail route (Article.image +
    og:image + visible hero via guideImg), home guide-strip + /guides index (via a
    first-product-image subquery). data-hide-on-error still guards the img.
    
    Verified: fallback subquery returns real product images 5/5 guides; absUrl passes
    external CDN URLs through unchanged (no double-prefix); simulated prod-NULL path
    -> product image; syntax OK, routes 200; guides WITH a real hero unchanged.
    Deploy gated -> pending-approval.
---
 server.js | 21 ++++++++++++++-------
 1 file changed, 14 insertions(+), 7 deletions(-)

diff --git a/server.js b/server.js
index 1af4d32..dedb781 100644
--- a/server.js
+++ b/server.js
@@ -127,13 +127,15 @@ app.get('/', async (_req, res, next) => {
     const [{ rows: featured }, { rows: latest }, { rows: guides }] = await Promise.all([
       db.query(`SELECT ${COLS.PRODUCT} FROM products WHERE featured=TRUE AND in_stock AND NOT suppressed ORDER BY created_at DESC LIMIT 8`),
       db.query(`SELECT ${COLS.PRODUCT} FROM products WHERE in_stock AND NOT suppressed ORDER BY created_at DESC LIMIT 12`),
-      db.query(`SELECT slug,title,dek,hero_image FROM guides WHERE published ORDER BY created_at DESC LIMIT 3`),
+      db.query(`SELECT slug,title,dek,hero_image,
+        (SELECT p.image_url FROM products p WHERE p.id = ANY(guides.product_ids) AND NOT p.suppressed AND p.image_url IS NOT NULL LIMIT 1) AS fallback_image
+        FROM guides WHERE published ORDER BY created_at DESC LIMIT 3`),
     ]);
     const roomTiles = ROOMS.slice(0, 6).map(([slug, label]) =>
       `<a class="room-tile" href="/rooms/${slug}"><span>${esc(label)}</span></a>`).join('');
     const guideCards = guides.map((g) =>
       `<a class="guide-card" href="/guides/${esc(g.slug)}">
-         ${g.hero_image ? `<img loading="lazy" data-hide-on-error src="${esc(g.hero_image)}" alt="${esc(g.title)}">` : ''}
+         ${(g.hero_image || g.fallback_image) ? `<img loading="lazy" data-hide-on-error src="${esc(g.hero_image || g.fallback_image)}" alt="${esc(g.title)}">` : ''}
          <h3>${esc(g.title)}</h3><p>${esc(g.dek || '')}</p></a>`).join('');
     const body = `
       <section class="hero">
@@ -254,10 +256,12 @@ app.get('/rooms/:room', async (req, res, next) => {
 
 app.get('/guides', async (_req, res, next) => {
   try {
-    const { rows } = await db.query(`SELECT slug,title,dek,hero_image FROM guides WHERE published ORDER BY created_at DESC`);
+    const { rows } = await db.query(`SELECT slug,title,dek,hero_image,
+      (SELECT p.image_url FROM products p WHERE p.id = ANY(guides.product_ids) AND NOT p.suppressed AND p.image_url IS NOT NULL LIMIT 1) AS fallback_image
+      FROM guides WHERE published ORDER BY created_at DESC`);
     const cards = rows.map((g) =>
       `<a class="guide-card" href="/guides/${esc(g.slug)}">
-        ${g.hero_image ? `<img loading="lazy" data-hide-on-error src="${esc(g.hero_image)}" alt="${esc(g.title)}">` : ''}
+        ${(g.hero_image || g.fallback_image) ? `<img loading="lazy" data-hide-on-error src="${esc(g.hero_image || g.fallback_image)}" alt="${esc(g.title)}">` : ''}
         <h3>${esc(g.title)}</h3><p>${esc(g.dek || '')}</p></a>`).join('');
     const crumbs = [{ name: 'Home', url: `${SITE.url}/` }, { name: 'Guides', url: `${SITE.url}/guides` }];
     const body = `<section>${breadcrumbNav(crumbs)}<h1>Buying Guides</h1><div class="guide-grid">${cards || '<p>Guides coming soon.</p>'}</div></section>`;
@@ -282,18 +286,21 @@ app.get('/guides/:slug', async (req, res, next) => {
       const r = await db.query(`SELECT ${COLS.PRODUCT} FROM products WHERE id = ANY($1) AND NOT suppressed`, [g.product_ids]);
       picks = r.rows;
     }
-    const article = { '@context': 'https://schema.org', '@type': 'Article', headline: g.title, description: g.dek, image: g.hero_image ? absUrl(g.hero_image) : undefined, url: `${SITE.url}/guides/${g.slug}`, datePublished: g.created_at, dateModified: g.updated_at };
+    // Guide imagery falls back to a representative product image (like rooms fall back to
+    // thumbs) so a null hero_image doesn't leave the guide imageless in JSON-LD / OG / hero.
+    const guideImg = g.hero_image || (picks[0] && picks[0].image_url) || null;
+    const article = { '@context': 'https://schema.org', '@type': 'Article', headline: g.title, description: g.dek, image: guideImg ? absUrl(guideImg) : undefined, url: `${SITE.url}/guides/${g.slug}`, datePublished: g.created_at, dateModified: g.updated_at };
     const crumbs = [{ name: 'Home', url: `${SITE.url}/` }, { name: 'Guides', url: `${SITE.url}/guides` },
       { name: g.title, url: `${SITE.url}/guides/${g.slug}` }];
     const body = `<article class="guide">
       ${breadcrumbNav(crumbs)}
       <h1>${esc(g.title)}</h1><p class="dek">${esc(g.dek || '')}</p>
-      ${g.hero_image ? `<img class="guide-hero" data-hide-on-error src="${esc(g.hero_image)}" alt="${esc(g.title)}">` : ''}
+      ${guideImg ? `<img class="guide-hero" data-hide-on-error src="${esc(guideImg)}" alt="${esc(g.title)}">` : ''}
       <div class="guide-body">${md(g.body_md || '')}</div>
       ${picks.length ? `<h2>Shop this guide</h2><div class="grid">${picks.map(productCard).join('')}</div>` : ''}
     </article>`;
     const jsonld = [article, breadcrumbJsonld(crumbs)].filter(Boolean);
-    res.send(layout({ title: g.title, description: g.dek, canonical: `${SITE.url}/guides/${g.slug}`, jsonld, image: g.hero_image, body, activeNav: '/guides', ogType: 'article', ogArticle: { published: new Date(g.created_at).toISOString(), modified: new Date(g.updated_at).toISOString() } }));
+    res.send(layout({ title: g.title, description: g.dek, canonical: `${SITE.url}/guides/${g.slug}`, jsonld, image: guideImg, body, activeNav: '/guides', ogType: 'article', ogArticle: { published: new Date(g.created_at).toISOString(), modified: new Date(g.updated_at).toISOString() } }));
   } catch (e) { next(e); }
 });
 

← 771434d chore: bump v0.4.3 (session close) — 404/CSP/image-degrade/r  ·  back to Interiordesignershowroom  ·  chore: bump v0.4.4 (session close) — guide-image fallback (3 666bd0c →