[object Object]

← back to Interiordesignershowroom

room page: 3x-larger click-to-shop thumbnails (info + Buy on click); big thumbs are the shop surface on scene-less rooms so they no longer dead-end

47e1b8dbdabaaca6e066cdc3e6dea12299757885 · 2026-08-01 23:46:24 -0700 · Steve Abrams

- roomThumbs: each thumb is now a <button> that expands product info (brand/title/price) + a tracked Buy button inline, instead of jumping straight out to the affiliate link
- thumbs 56px -> 168px (3x); new /js/room-thumbs.js toggle (one-open, Esc/outside-click closes), mirrors hotspot-card grammar
- server room route: render the big thumbs as the shoppable surface when a room has no scene_image (fixes glam-room dead-end); scene rooms unchanged (scene + side rail + grid)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 47e1b8dbdabaaca6e066cdc3e6dea12299757885
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 1 23:46:24 2026 -0700

    room page: 3x-larger click-to-shop thumbnails (info + Buy on click); big thumbs are the shop surface on scene-less rooms so they no longer dead-end
    
    - roomThumbs: each thumb is now a <button> that expands product info (brand/title/price) + a tracked Buy button inline, instead of jumping straight out to the affiliate link
    - thumbs 56px -> 168px (3x); new /js/room-thumbs.js toggle (one-open, Esc/outside-click closes), mirrors hotspot-card grammar
    - server room route: render the big thumbs as the shoppable surface when a room has no scene_image (fixes glam-room dead-end); scene rooms unchanged (scene + side rail + grid)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 lib/render.js            | 31 +++++++++++++++++++--------
 public/css/site.css      | 55 ++++++++++++++++++++++++++++++++----------------
 public/js/room-thumbs.js | 34 ++++++++++++++++++++++++++++++
 server.js                | 28 ++++++++++++++++--------
 4 files changed, 112 insertions(+), 36 deletions(-)

diff --git a/lib/render.js b/lib/render.js
index 3d5f0c1..26c9bb8 100644
--- a/lib/render.js
+++ b/lib/render.js
@@ -128,18 +128,31 @@ function sceneFigure({ scene_image, title, hotspots = [] }) {
   </figure><script src="/js/hotspots.js" defer></script>`;
 }
 
-// Small thumbnail rail of the room's pieces — stacks to the RIGHT of the scene on
-// desktop, BELOW it on mobile (CSS). Each thumb is image-only; the item name shows
-// on hover. Every thumb is a tracked shop link, so the rail is a compact shop-list.
+// Large thumbnail rail of the room's pieces — stacks to the RIGHT of the scene on
+// desktop, BELOW it on mobile (CSS). Each thumb is a BUTTON: clicking it reveals
+// that piece's product info (brand · title · price) plus a tracked Buy button,
+// inline right below the thumb — instead of jumping the shopper straight out.
 function roomThumbs(products = []) {
   const withImg = (products || []).filter((p) => p.image_url);
   if (!withImg.length) return '';
-  const thumbs = withImg.map((p) =>
-    `<a class="room-thumb" href="/go/${p.id}" rel="nofollow sponsored" target="_blank" role="listitem" aria-label="Shop ${esc(p.title)}">
-      <img src="${esc(p.image_url)}" alt="${esc(p.title)}" loading="lazy">
-      <span class="room-thumb-label">${esc(p.title)}</span>
-    </a>`).join('');
-  return `<div class="room-thumbs" role="list" aria-label="Items in this room">${thumbs}</div>`;
+  const items = withImg.map((p, i) => {
+    let priceHtml = '';
+    if (p.sale_price != null) priceHtml = `<span class="price sale">${money(p.sale_price)}</span> <span class="price was">${money(p.price)}</span>`;
+    else if (p.price != null) priceHtml = `<span class="price">${money(p.price)}</span>`;
+    const brand = p.brand || p.advertiser || '';
+    return `<div class="room-thumb-item">
+      <button type="button" class="room-thumb" aria-expanded="false" aria-controls="rt${i}" aria-label="Show details for ${esc(p.title)}">
+        <img src="${esc(p.image_url)}" alt="${esc(p.title)}" loading="lazy">
+      </button>
+      <div class="room-thumb-info" id="rt${i}" role="group" aria-label="${esc(p.title)}" hidden>
+        ${brand ? `<div class="rt-brand">${esc(brand)}</div>` : ''}
+        <div class="rt-title">${esc(p.title)}</div>
+        ${priceHtml ? `<div class="rt-price">${priceHtml}</div>` : ''}
+        <a class="buy" href="/go/${p.id}" rel="nofollow sponsored" target="_blank">Buy${p.advertiser ? ' at ' + esc(p.advertiser) : ''} &rarr;</a>
+      </div>
+    </div>`;
+  }).join('');
+  return `<div class="room-thumbs" role="list" aria-label="Items in this room">${items}</div><script src="/js/room-thumbs.js" defer></script>`;
 }
 
 function layout({ title, description, canonical, jsonld, image, body, activeNav }) {
diff --git a/public/css/site.css b/public/css/site.css
index 5c0c77c..955be54 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -744,29 +744,48 @@ h2.ruled::after {
    BELOW it on mobile. Each thumb is image-only; the item name shows on hover. */
 .room-scene-row { display: flex; gap: 12px; align-items: flex-start; margin: 20px 0; }
 .room-scene-row .room-scene-wrap { flex: 1 1 auto; min-width: 0; margin: 0; }
+/* Thumbs are 3x larger (168px) than the old 56px rail; each is a click-to-expand
+   button that reveals its product info + Buy button inline below it. */
 .room-thumbs {
-  display: flex; flex-direction: column; gap: 8px;
-  flex: 0 0 60px; width: 60px; max-height: 78vh; overflow-y: auto; padding: 1px;
+  display: flex; flex-direction: column; gap: 10px;
+  flex: 0 0 180px; width: 180px; max-height: 82vh; overflow-y: auto; padding: 1px;
+}
+.room-thumb-item { flex: 0 0 auto; }
+.room-thumb {
+  position: relative; display: block; line-height: 0; width: 100%;
+  padding: 0; border: 0; background: none; cursor: pointer;
 }
-.room-thumb { position: relative; display: block; line-height: 0; flex: 0 0 auto; }
 .room-thumb img {
-  width: 56px; height: 56px; object-fit: cover; border-radius: 4px;
-  border: 1px solid var(--line); transition: border-color .15s, transform .15s;
-}
-.room-thumb:hover img, .room-thumb:focus-visible img { border-color: var(--accent); transform: scale(1.06); }
-.room-thumb-label {
-  position: absolute; right: calc(100% + 8px); top: 50%; transform: translateY(-50%);
-  white-space: nowrap; max-width: 220px; overflow: hidden; text-overflow: ellipsis;
-  background: rgba(34,30,25,.93); color: #f3ede3; font-size: .68rem; letter-spacing: .01em;
-  padding: 4px 9px; border-radius: 4px; pointer-events: none; z-index: 10;
-  opacity: 0; visibility: hidden; transition: opacity .14s;
-}
-.room-thumb:hover .room-thumb-label, .room-thumb:focus-visible .room-thumb-label { opacity: 1; visibility: visible; }
-/* mobile: rail drops BELOW the image as a horizontal wrap; label appears above the thumb */
+  width: 168px; height: 168px; object-fit: cover; border-radius: 6px;
+  border: 1px solid var(--line); transition: border-color .15s, transform .15s, box-shadow .15s;
+}
+.room-thumb:hover img, .room-thumb:focus-visible img { border-color: var(--accent); transform: scale(1.03); box-shadow: 0 6px 18px rgba(0,0,0,.14); }
+.room-thumb-item.open .room-thumb img { border-color: var(--accent); box-shadow: 0 6px 18px rgba(0,0,0,.18); }
+/* Inline product-info card revealed on click */
+.room-thumb-info {
+  width: 168px; margin-top: 6px; padding: 10px 12px 12px;
+  background: var(--card); border: 1px solid var(--line); border-radius: 6px;
+  box-shadow: 0 8px 24px rgba(0,0,0,.12); line-height: 1.4;
+}
+.room-thumb-info[hidden] { display: none; }
+.rt-brand { font-size: .66rem; letter-spacing: .06em; text-transform: uppercase; color: var(--ink-80); }
+.rt-title { font-size: .82rem; font-weight: 500; color: var(--ink); margin: 2px 0 4px;
+  display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
+.rt-price { font-size: .9rem; color: var(--accent-dk); margin: 0 0 9px; }
+.rt-price .price.was { color: var(--ink-80); text-decoration: line-through; font-size: .78rem; margin-left: 4px; }
+.rt-price .price.sale { color: #b23b2e; font-weight: 600; }
+.room-thumb-info .buy { display: block; text-align: center; width: 100%; }
+/* When the thumbs are the standalone shop surface (scene-less rooms), lay them
+   out as a horizontal wrapping grid instead of a narrow side column. */
+.room-thumbs-hero { margin: 20px 0; }
+.room-thumbs-hero .room-thumbs {
+  flex-direction: row; flex-wrap: wrap; width: auto; flex: none;
+  max-height: none; gap: 16px; align-items: flex-start;
+}
+/* mobile: rail drops BELOW the scene as a horizontal wrap of large thumbs */
 @media (max-width: 700px) {
   .room-scene-row { flex-direction: column; }
-  .room-thumbs { flex-direction: row; flex-wrap: wrap; width: auto; flex: none; max-height: none; }
-  .room-thumb-label { right: auto; left: 50%; top: auto; bottom: calc(100% + 8px); transform: translateX(-50%); }
+  .room-thumbs { flex-direction: row; flex-wrap: wrap; width: auto; flex: none; max-height: none; align-items: flex-start; }
 }
 
 /* Shoppable scene: show the render at its NATURAL ratio (no 16/9 crop) so the
diff --git a/public/js/room-thumbs.js b/public/js/room-thumbs.js
new file mode 100644
index 0000000..2c6f2c0
--- /dev/null
+++ b/public/js/room-thumbs.js
@@ -0,0 +1,34 @@
+// Room thumbnail rail. Each thumb is a <button>; clicking it reveals that piece's
+// product info (brand · title · price) + a tracked Buy button, inline below the
+// thumb. One open at a time; Esc or an outside click closes it. Mirrors the
+// hotspot-card grammar in /js/hotspots.js so the two shop surfaces behave alike.
+(function () {
+  document.querySelectorAll('.room-thumbs').forEach(function (rail) {
+    var open = null;
+
+    function close() {
+      if (!open) return;
+      open.info.hidden = true;
+      open.btn.setAttribute('aria-expanded', 'false');
+      open.btn.parentElement.classList.remove('open');
+      open = null;
+    }
+
+    rail.addEventListener('click', function (e) {
+      var btn = e.target.closest('.room-thumb');
+      if (!btn) return;                 // let clicks on the Buy link fall through
+      e.preventDefault();
+      var info = document.getElementById(btn.getAttribute('aria-controls'));
+      if (!info) return;
+      if (open && open.btn === btn) { close(); return; }
+      close();
+      info.hidden = false;
+      btn.setAttribute('aria-expanded', 'true');
+      btn.parentElement.classList.add('open');
+      open = { btn: btn, info: info };
+    });
+
+    document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
+    document.addEventListener('click', function (e) { if (!rail.contains(e.target)) close(); });
+  });
+})();
diff --git a/server.js b/server.js
index b9929ae..399ae78 100644
--- a/server.js
+++ b/server.js
@@ -479,23 +479,33 @@ app.get('/room/:slug', async (req, res, next) => {
         </div>
       </div>` : '';
 
-    // 0 pieces state
-    const piecesGrid = products.length
-      ? `<div class="grid">${products.map(productCard).join('')}</div>`
+    const styleLabel = [room.style, room.room_type].filter(Boolean).join(' ');
+    const hasScene = !!room.scene_image;
+
+    // Shoppable visual. With a generated scene: the scene + a side thumb-rail.
+    // WITHOUT a scene (e.g. a freshly built room): the large click-to-shop thumbs
+    // ARE the shoppable surface — each thumb expands to product info + a Buy
+    // button — so a scene-less room no longer dead-ends at a bare card list.
+    const sceneOrThumbs = hasScene
+      ? `<div class="room-scene-row">${sceneFigure({ scene_image: room.scene_image, title: room.title, hotspots: room.hotspots || [] })}${roomThumbs(products)}</div>`
+      : (products.length ? `<div class="room-thumbs-hero">${roomThumbs(products)}</div>` : '');
+
+    // Detailed productCard grid: shown alongside a scene as the itemized list.
+    // On scene-less rooms it's redundant with the thumbs (which already reveal
+    // full info + Buy on click), so we omit it — the thumbs' info cards keep all
+    // product text in the HTML for crawlers.
+    const shopSection = products.length
+      ? (hasScene ? `<h2 class="shop-heading">Shop this room</h2><div class="grid">${products.map(productCard).join('')}</div>` : '')
       : `<div class="room-empty"><p>No shoppable pieces in this room yet.</p></div>`;
 
-    const styleLabel = [room.style, room.room_type].filter(Boolean).join(' ');
     const body = `<article class="room-page">
       <div class="room-head">
         <h1>${esc(room.title)}</h1>
         <p class="subtle">${esc(styleLabel)}${styleLabel && products.length ? ' · ' : ''}${products.length ? `${products.length} shoppable piece${products.length === 1 ? '' : 's'}` : ''}</p>
       </div>
-      ${room.scene_image
-        ? `<div class="room-scene-row">${sceneFigure({ scene_image: room.scene_image, title: room.title, hotspots: room.hotspots || [] })}${roomThumbs(products)}</div>`
-        : ''}
+      ${sceneOrThumbs}
       ${paintBlock}
-      <h2 class="shop-heading">Shop this room</h2>
-      ${piecesGrid}
+      ${shopSection}
       <div class="room-cta">
         <a class="cta sm" href="/build">Build your own room →</a>
       </div>

← a9db00f feat(build): right panel shows the products IN the generated  ·  back to Interiordesignershowroom  ·  feat: affiliate cart — collect pieces, checkout opens each p 64a5449 →