[object Object]

← back to Interiordesignershowroom

feat: shoppable room-setting images (vision-located hotspots w/ IoU dedup) + auto room generator from CJ inventory (Gemini scene + hotspots, ~$0.04/room); rooms.hotspots jsonb; inline hotspot cards on /room/:slug

ffe693d8dba9ba19710158134d9e1546c8555c88 · 2026-08-01 22:48:55 -0700 · Steve Abrams

Files touched

Diff

commit ffe693d8dba9ba19710158134d9e1546c8555c88
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 1 22:48:55 2026 -0700

    feat: shoppable room-setting images (vision-located hotspots w/ IoU dedup) + auto room generator from CJ inventory (Gemini scene + hotspots, ~$0.04/room); rooms.hotspots jsonb; inline hotspot cards on /room/:slug
---
 lib/hotspots.js             | 17 ++++++++++++-----
 lib/render.js               | 29 +++++++++++++++++++++++++++-
 public/css/site.css         | 42 +++++++++++++++++++++++++++++++++++++++++
 public/js/hotspots.js       | 46 +++++++++++++++++++++++++++++++++++++++++++++
 scripts/gen-room-setting.js |  2 +-
 server.js                   |  4 ++--
 6 files changed, 131 insertions(+), 9 deletions(-)

diff --git a/lib/hotspots.js b/lib/hotspots.js
index 7258f95..b6b3bb9 100644
--- a/lib/hotspots.js
+++ b/lib/hotspots.js
@@ -48,6 +48,15 @@ async function locateProducts(imagePath, products = []) {
   try { raw = JSON.parse(text); } catch { raw = []; }
   if (!Array.isArray(raw)) raw = [];
 
+  // Intersection-over-union of two %-boxes — used to reject a new hotspot that lands
+  // on the same visual object as one already accepted (lookalike products the model
+  // tagged to the single instance it could actually see in the scene).
+  const iou = (a, b) => {
+    const ix = Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x));
+    const iy = Math.max(0, Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y));
+    const inter = ix * iy;
+    return inter ? inter / (a.w * a.h + b.w * b.h - inter) : 0;
+  };
   const seen = new Set();
   const hotspots = [];
   for (const d of raw) {
@@ -59,12 +68,10 @@ async function locateProducts(imagePath, products = []) {
     const cl = (n) => Math.max(0, Math.min(1000, n || 0));
     ymin = cl(ymin); xmin = cl(xmin); ymax = cl(ymax); xmax = cl(xmax);
     if (xmax <= xmin || ymax <= ymin) continue;
+    const box = { x: xmin / 10, y: ymin / 10, w: (xmax - xmin) / 10, h: (ymax - ymin) / 10 };
+    if (hotspots.some((h) => iou(h.box, box) > 0.45)) continue; // don't stack on one object
     seen.add(p.id);
-    hotspots.push({
-      id: p.id, title: p.title, price: p.sale_price ?? p.price,
-      image_url: p.image_url, advertiser: p.advertiser,
-      box: { x: xmin / 10, y: ymin / 10, w: (xmax - xmin) / 10, h: (ymax - ymin) / 10 },
-    });
+    hotspots.push({ id: p.id, title: p.title, price: p.sale_price ?? p.price, image_url: p.image_url, advertiser: p.advertiser, box });
   }
   return { hotspots, cost: COST_PER_CALL };
 }
diff --git a/lib/render.js b/lib/render.js
index aecdd24..d17e840 100644
--- a/lib/render.js
+++ b/lib/render.js
@@ -101,6 +101,33 @@ function productListJsonld(products = [], listUrl) {
   return { '@context': 'https://schema.org', '@type': 'ItemList', url: listUrl, numberOfItems: items.length, itemListElement: items };
 }
 
+// Shoppable room-setting image: the scene photo with a hotspot dot centered on each
+// vision-located piece. Tapping a dot opens an INLINE product card (image, title,
+// price, tracked Shop link) — inline, never a modal. Pieces the vision pass couldn't
+// place still appear as edge chips via the piece grid below, so nothing is unshoppable.
+function sceneFigure({ scene_image, title, hotspots = [] }) {
+  if (!scene_image) return '';
+  const spots = (hotspots || []).map((h, i) => {
+    const cx = Math.max(3, Math.min(97, h.box.x + h.box.w / 2)).toFixed(1);
+    const cy = Math.max(3, Math.min(97, h.box.y + h.box.h / 2)).toFixed(1);
+    const price = h.price != null ? money(h.price) : '';
+    return `<button class="hotspot" style="left:${cx}%;top:${cy}%" data-card="hc${i}" aria-label="Shop ${esc(h.title)}" aria-expanded="false"><span class="hotspot-dot"></span></button>
+      <div class="hotspot-card" id="hc${i}" style="left:${cx}%;top:${cy}%" role="dialog" aria-label="${esc(h.title)}" hidden>
+        ${h.image_url ? `<img src="${esc(h.image_url)}" alt="" loading="lazy">` : ''}
+        <div class="hotspot-card-body">
+          <div class="hotspot-card-title">${esc(h.title)}</div>
+          ${price ? `<div class="hotspot-card-price">${price}</div>` : ''}
+          <a class="buy" href="/go/${h.id}" rel="nofollow sponsored" target="_blank">Shop at ${esc(h.advertiser || 'store')} →</a>
+        </div>
+      </div>`;
+  }).join('');
+  return `<figure class="room-scene-wrap${hotspots.length ? ' has-spots' : ''}">
+    <img class="room-scene" src="${esc(scene_image)}" alt="AI-generated scene for ${esc(title)}">
+    <div class="hotspot-layer">${spots}</div>
+    ${hotspots.length ? '<span class="scene-hint" aria-hidden="true">Tap a dot to shop a piece</span>' : ''}
+  </figure><script src="/js/hotspots.js" defer></script>`;
+}
+
 function layout({ title, description, canonical, jsonld, image, body, activeNav }) {
   const ogImage = absUrl(image) || `${SITE.url}/img/og.png`;
   const nav = [
@@ -161,4 +188,4 @@ ${disclosureBar()}
 </html>`;
 }
 
-module.exports = { SITE, esc, money, layout, productCard, disclosureBar, DISCLOSURE_SHORT, orgWebsiteJsonld, productListJsonld, absUrl };
+module.exports = { SITE, esc, money, layout, productCard, disclosureBar, DISCLOSURE_SHORT, orgWebsiteJsonld, productListJsonld, absUrl, sceneFigure };
diff --git a/public/css/site.css b/public/css/site.css
index 777bb3b..5ea33f9 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -740,6 +740,48 @@ h2.ruled::after {
   object-fit: cover;
   display: block;
 }
+/* Shoppable scene: show the render at its NATURAL ratio (no 16/9 crop) so the
+   vision-located hotspot %-coordinates line up 1:1 with the visible pixels. */
+.room-scene-wrap { position: relative; display: block; margin: 20px 0; line-height: 0; }
+.room-scene-wrap .room-scene { margin: 0; aspect-ratio: auto; object-fit: contain; height: auto; }
+.hotspot-layer { position: absolute; inset: 0; }
+.hotspot {
+  position: absolute; transform: translate(-50%, -50%);
+  width: 28px; height: 28px; padding: 0; border: 0; background: none; cursor: pointer; z-index: 2;
+}
+.hotspot-dot {
+  display: block; width: 14px; height: 14px; margin: 7px; border-radius: 50%;
+  background: #fff; border: 2px solid var(--accent);
+  box-shadow: 0 0 0 3px rgba(155,123,80,.35), 0 1px 5px rgba(0,0,0,.45);
+  animation: hotspotPulse 2.4s ease-out infinite;
+}
+.hotspot:hover .hotspot-dot, .hotspot[aria-expanded="true"] .hotspot-dot { transform: scale(1.25); background: var(--accent); }
+@keyframes hotspotPulse {
+  0% { box-shadow: 0 0 0 0 rgba(155,123,80,.5), 0 1px 5px rgba(0,0,0,.45); }
+  70% { box-shadow: 0 0 0 11px rgba(155,123,80,0), 0 1px 5px rgba(0,0,0,.45); }
+  100% { box-shadow: 0 0 0 0 rgba(155,123,80,0), 0 1px 5px rgba(0,0,0,.45); }
+}
+.hotspot-card {
+  position: absolute; z-index: 5; width: 210px; line-height: 1.4;
+  transform: translate(-50%, -100%) translateY(-16px);
+  background: #fff; border: 1px solid var(--line); border-radius: 6px;
+  box-shadow: 0 10px 34px rgba(0,0,0,.24); overflow: hidden;
+}
+.hotspot-card[hidden] { display: none; }
+.hotspot-card.below { transform: translate(-50%, 0) translateY(16px); }
+.hotspot-card.edge-left { transform: translate(-12%, -100%) translateY(-16px); }
+.hotspot-card.edge-left.below { transform: translate(-12%, 0) translateY(16px); }
+.hotspot-card.edge-right { transform: translate(-88%, -100%) translateY(-16px); }
+.hotspot-card.edge-right.below { transform: translate(-88%, 0) translateY(16px); }
+.hotspot-card img { width: 100%; height: 120px; object-fit: cover; display: block; }
+.hotspot-card-body { padding: 10px 12px; }
+.hotspot-card-title { font-size: .8rem; font-weight: 500; color: var(--ink); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
+.hotspot-card-price { font-size: .82rem; color: var(--accent-dk); margin: 3px 0 7px; }
+.scene-hint {
+  position: absolute; left: 10px; bottom: 10px; z-index: 2; pointer-events: none;
+  background: rgba(34,30,25,.72); color: #f3ede3; font-size: .66rem; letter-spacing: .03em;
+  padding: 3px 9px; border-radius: 20px;
+}
 .room-paint {
   display: flex;
   gap: 16px;
diff --git a/public/js/hotspots.js b/public/js/hotspots.js
new file mode 100644
index 0000000..5edf6f6
--- /dev/null
+++ b/public/js/hotspots.js
@@ -0,0 +1,46 @@
+// Shoppable room-setting hotspots. Tapping a dot opens its inline product card;
+// the card flips above/below and clamps left/right so it never leaves the image.
+// Outside-click and Esc close it. Keyboard: dots are real <button>s, Enter toggles.
+(function () {
+  document.querySelectorAll('.room-scene-wrap.has-spots').forEach(function (wrap) {
+    var layer = wrap.querySelector('.hotspot-layer');
+    if (!layer) return;
+    var open = null;
+
+    function close() {
+      if (!open) return;
+      open.card.hidden = true;
+      open.btn.setAttribute('aria-expanded', 'false');
+      open = null;
+    }
+    function openCard(btn) {
+      close();
+      var card = wrap.querySelector('#' + btn.getAttribute('data-card'));
+      if (!card) return;
+      // vertical flip: if the dot is high in the image, drop the card below it
+      var topPct = parseFloat(card.style.top) || 50;
+      card.classList.toggle('below', topPct < 42);
+      // horizontal clamp: keep the card edge inside the figure
+      var leftPct = parseFloat(card.style.left) || 50;
+      card.classList.toggle('edge-left', leftPct < 22);
+      card.classList.toggle('edge-right', leftPct > 78);
+      card.hidden = false;
+      btn.setAttribute('aria-expanded', 'true');
+      open = { btn: btn, card: card };
+    }
+
+    layer.addEventListener('click', function (e) {
+      var btn = e.target.closest('.hotspot');
+      if (!btn) return;
+      e.preventDefault();
+      if (open && open.btn === btn) { close(); return; }
+      openCard(btn);
+    });
+    // clicks inside a card (e.g. the Shop link) shouldn't close it before navigating
+    wrap.addEventListener('click', function (e) {
+      if (!e.target.closest('.hotspot-card') && !e.target.closest('.hotspot')) close();
+    });
+    document.addEventListener('click', function (e) { if (!wrap.contains(e.target)) close(); });
+    document.addEventListener('keydown', function (e) { if (e.key === 'Escape') close(); });
+  });
+})();
diff --git a/scripts/gen-room-setting.js b/scripts/gen-room-setting.js
index a3543b1..bc44a9c 100644
--- a/scripts/gen-room-setting.js
+++ b/scripts/gen-room-setting.js
@@ -25,7 +25,7 @@ const BUCKETS = [
   /sofa|sectional|loveseat|settee/i, /bed\b|headboard/i, /desk/i,
   /coffee table|side table|end table|console|dining table|\btable\b/i,
   /chair|stool|bench/i, /lamp|light|sconce|pendant|chandelier/i,
-  /rug/i, /art|print|mirror|wall/i, /shelf|bookcase|cabinet|dresser|credenza/i,
+  /rug/i, /art|print|mirror|wall/i, /shelf|bookcase|cabinet|dresser|credenza|sideboard|buffet|console|hutch/i,
   /vase|planter|decor|throw|pillow|cushion/i,
 ];
 function diverseSet(pool, n = 6) {
diff --git a/server.js b/server.js
index 88eb374..4e236d1 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, orgWebsiteJsonld, productListJsonld } = require('./lib/render');
+const { SITE, esc, layout, productCard, orgWebsiteJsonld, productListJsonld, sceneFigure } = require('./lib/render');
 const catalog = require('./lib/catalog');
 const rooms = require('./lib/rooms');
 const scene = require('./lib/scene');
@@ -464,7 +464,7 @@ app.get('/room/:slug', async (req, res, next) => {
         <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 ? `<img class="room-scene" src="${esc(room.scene_image)}" alt="AI-generated scene for ${esc(room.title)}">` : ''}
+      ${sceneFigure({ scene_image: room.scene_image, title: room.title, hotspots: room.hotspots || [] })}
       ${paintBlock}
       <h2 class="shop-heading">Shop this room</h2>
       ${piecesGrid}

← 8df6b7b auto-save: 2026-08-01T22:40:01 (3 files) — lib/rooms.js lib/  ·  back to Interiordesignershowroom  ·  feat: shoppable hotspots on the live Room Builder gallery to b7e8531 →