[object Object]

← 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

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 →