← 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 →