← back to Interiordesignershowroom
refine: soften freshness chip to 'Price checked' + SEO structured-data (Organization/WebSite/SearchAction on home, ItemList+Product on shop/rooms, per-page OG image, sitemap adds looks/build/public-rooms + lastmod)
082c4383056035cdb96d7cecf76819356cdf2ce3 · 2026-08-01 22:30:45 -0700 · Steve Abrams
Files touched
M lib/render.jsM server.js
Diff
commit 082c4383056035cdb96d7cecf76819356cdf2ce3
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Aug 1 22:30:45 2026 -0700
refine: soften freshness chip to 'Price checked' + SEO structured-data (Organization/WebSite/SearchAction on home, ItemList+Product on shop/rooms, per-page OG image, sitemap adds looks/build/public-rooms + lastmod)
---
lib/render.js | 41 +++++++++++++++++++++++++++++++++++------
server.js | 32 +++++++++++++++++++++-----------
2 files changed, 56 insertions(+), 17 deletions(-)
diff --git a/lib/render.js b/lib/render.js
index 2d76703..aecdd24 100644
--- a/lib/render.js
+++ b/lib/render.js
@@ -26,8 +26,8 @@ function freshness(ts) {
const label = days <= 0 ? 'today' : days === 1 ? 'yesterday'
: days < 30 ? `${days}d ago` : `${Math.floor(days / 30)}mo ago`;
const stale = days > 14;
- return `<span class="price-fresh${stale ? ' stale' : ''}" title="Price last verified ${esc(new Date(ts).toISOString())}">`
- + `Price verified ${label}</span>`;
+ return `<span class="price-fresh${stale ? ' stale' : ''}" title="Price last checked ${esc(new Date(ts).toISOString())}">`
+ + `Price checked ${label}</span>`;
}
// FTC 16 CFR Part 255 requires a clear, conspicuous disclosure. This is not optional.
@@ -73,7 +73,36 @@ function productCard(p) {
</article>`;
}
-function layout({ title, description, canonical, jsonld, body, activeNav }) {
+// Absolute-URL an image path for OG/JSON-LD (crawlers require absolute URLs).
+const absUrl = (u) => (!u ? '' : /^https?:\/\//.test(u) ? u : `${SITE.url}${u.startsWith('/') ? '' : '/'}${u}`);
+
+// Organization + WebSite schema for the home page. WebSite carries a SearchAction
+// so Google can render a sitelinks search box pointed at /shop.
+function orgWebsiteJsonld() {
+ return [
+ { '@context': 'https://schema.org', '@type': 'Organization', name: SITE.name, url: SITE.url, logo: `${SITE.url}/img/apple-touch-icon.png` },
+ { '@context': 'https://schema.org', '@type': 'WebSite', name: SITE.name, url: SITE.url,
+ potentialAction: { '@type': 'SearchAction', target: `${SITE.url}/shop?q={q}`, 'query-input': 'required name=q' } },
+ ];
+}
+
+// ItemList of Product schema for the listing pages (/shop, /rooms/:room). This is
+// the structured data that lets the commercial pages surface in rich results — the
+// gap that previously left only editorial guides with schema.
+function productListJsonld(products = [], listUrl) {
+ const items = products.slice(0, 60).map((p, i) => {
+ const price = p.sale_price ?? p.price;
+ const prod = { '@type': 'Product', name: p.title, url: `${SITE.url}/go/${p.id}` };
+ if (p.image_url) prod.image = absUrl(p.image_url);
+ if (p.brand || p.advertiser) prod.brand = { '@type': 'Brand', name: p.brand || p.advertiser };
+ if (price != null) prod.offers = { '@type': 'Offer', price: Number(price).toFixed(2), priceCurrency: 'USD', availability: 'https://schema.org/InStock' };
+ return { '@type': 'ListItem', position: i + 1, item: prod };
+ });
+ return { '@context': 'https://schema.org', '@type': 'ItemList', url: listUrl, numberOfItems: items.length, itemListElement: items };
+}
+
+function layout({ title, description, canonical, jsonld, image, body, activeNav }) {
+ const ogImage = absUrl(image) || `${SITE.url}/img/og.png`;
const nav = [
['/shop', 'Shop'],
['/looks', 'Rooms'],
@@ -94,9 +123,9 @@ function layout({ title, description, canonical, jsonld, body, activeNav }) {
<meta property="og:title" content="${esc(title)}">
<meta property="og:description" content="${esc(description || SITE.tagline)}">
<meta property="og:type" content="website">
-<meta property="og:image" content="${esc(SITE.url)}/img/og.png">
+<meta property="og:image" content="${esc(ogImage)}">
<meta name="twitter:card" content="summary_large_image">
-<meta name="twitter:image" content="${esc(SITE.url)}/img/og.png">
+<meta name="twitter:image" content="${esc(ogImage)}">
<link rel="icon" href="/img/favicon.svg" type="image/svg+xml">
<link rel="icon" type="image/png" sizes="32x32" href="/img/favicon-32.png">
<link rel="apple-touch-icon" href="/img/apple-touch-icon.png">
@@ -132,4 +161,4 @@ ${disclosureBar()}
</html>`;
}
-module.exports = { SITE, esc, money, layout, productCard, disclosureBar, DISCLOSURE_SHORT };
+module.exports = { SITE, esc, money, layout, productCard, disclosureBar, DISCLOSURE_SHORT, orgWebsiteJsonld, productListJsonld, absUrl };
diff --git a/server.js b/server.js
index f6d1726..88eb374 100644
--- a/server.js
+++ b/server.js
@@ -3,7 +3,7 @@ const express = require('express');
const path = require('path');
const fs = require('fs');
const db = require('./lib/db');
-const { SITE, esc, layout, productCard } = require('./lib/render');
+const { SITE, esc, layout, productCard, orgWebsiteJsonld, productListJsonld } = require('./lib/render');
const catalog = require('./lib/catalog');
const rooms = require('./lib/rooms');
const scene = require('./lib/scene');
@@ -94,7 +94,7 @@ app.get('/', async (_req, res, next) => {
${featured.length ? `<section><h2>Editor's picks</h2>${grid(featured)}</section>` : ''}
${guideCards ? `<section class="guides-strip"><h2>Buying guides</h2><div class="guide-grid">${guideCards}</div></section>` : ''}
<section><h2>New in the showroom</h2>${grid(latest)}</section>`;
- res.send(layout({ title: 'Curated Designer Furniture & Decor', description: SITE.tagline, canonical: SITE.url, body, activeNav: '/' }));
+ res.send(layout({ title: 'Curated Designer Furniture & Decor', description: SITE.tagline, canonical: SITE.url, jsonld: orgWebsiteJsonld(), body, activeNav: '/' }));
} catch (e) { next(e); }
});
@@ -110,7 +110,7 @@ async function renderCatalog(res, { f, basePath, title, description, canonical,
<div class="catalog-main">${gridControls(products.length)}${grid(products)}</div>
</div>
</section><script src="/js/grid.js"></script>`;
- res.send(layout({ title, description, canonical, body, activeNav }));
+ res.send(layout({ title, description, canonical, jsonld: productListJsonld(products, canonical), body, activeNav }));
}
app.get('/shop', async (req, res, next) => {
@@ -162,7 +162,7 @@ app.get('/guides/:slug', async (req, res, next) => {
<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>`;
- res.send(layout({ title: g.title, description: g.dek, canonical: `${SITE.url}/guides/${g.slug}`, jsonld, body, activeNav: '/guides' }));
+ res.send(layout({ title: g.title, description: g.dek, canonical: `${SITE.url}/guides/${g.slug}`, jsonld, image: g.hero_image, body, activeNav: '/guides' }));
} catch (e) { next(e); }
});
@@ -200,12 +200,22 @@ app.get('/robots.txt', (_req, res) => {
app.get('/sitemap.xml', async (_req, res, next) => {
try {
- const { rows: guides } = await db.query(`SELECT slug, updated_at FROM guides WHERE published`);
- const urls = [
- '', 'shop', 'guides', 'disclosure', 'privacy',
- ...ROOMS.map(([s]) => `rooms/${s}`),
- ...guides.map((g) => `guides/${g.slug}`),
- ].map((u) => `<url><loc>${SITE.url}/${u}</loc></url>`).join('');
+ const [{ rows: guides }, { rows: rooms }] = await Promise.all([
+ db.query(`SELECT slug, updated_at FROM guides WHERE published`),
+ db.query(`SELECT slug, updated_at FROM rooms WHERE public`),
+ ]);
+ const iso = (d) => (d ? new Date(d).toISOString().slice(0, 10) : null);
+ // {loc, lastmod?} — static pages first, then room categories, guides, and the
+ // public visitor-built rooms (real indexable content that was missing before).
+ const entries = [
+ { loc: '' }, { loc: 'shop' }, { loc: 'looks' }, { loc: 'build' },
+ { loc: 'guides' }, { loc: 'disclosure' }, { loc: 'privacy' },
+ ...ROOMS.map(([s]) => ({ loc: `rooms/${s}` })),
+ ...guides.map((g) => ({ loc: `guides/${g.slug}`, lastmod: iso(g.updated_at) })),
+ ...rooms.map((r) => ({ loc: `room/${r.slug}`, lastmod: iso(r.updated_at) })),
+ ];
+ const urls = entries.map((e) =>
+ `<url><loc>${SITE.url}/${e.loc}</loc>${e.lastmod ? `<lastmod>${e.lastmod}</lastmod>` : ''}</url>`).join('');
res.type('application/xml').send(`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls}</urlset>`);
} catch (e) { next(e); }
});
@@ -467,7 +477,7 @@ app.get('/room/:slug', async (req, res, next) => {
title: room.title,
description: room.note || `A shoppable ${styleLabel || 'room'} — ${products.length} designer pieces.`,
canonical: `${SITE.url}/room/${room.slug}`,
- jsonld, body, activeNav: '/looks'
+ jsonld, image: room.scene_image, body, activeNav: '/looks'
}));
} catch (e) { next(e); }
});
← 1ead1eb refine: price-freshness trust chip on cards (price_checked_a
·
back to Interiordesignershowroom
·
refine: real Gemini guide heroes (replace picsum placeholder 4a02b54 →