[object Object]

← back to Interiordesignershowroom

feat: shoppable hotspots on the live Room Builder gallery too (/api/render returns vision-located hotspots; build.js overlays dots w/ hover-card + click-to-shop; saved rooms persist builder hotspots)

b7e85317bd0ade638e4e1c37cb7408929fef0708 · 2026-08-01 22:59:33 -0700 · Steve Abrams

Files touched

Diff

commit b7e85317bd0ade638e4e1c37cb7408929fef0708
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 1 22:59:33 2026 -0700

    feat: shoppable hotspots on the live Room Builder gallery too (/api/render returns vision-located hotspots; build.js overlays dots w/ hover-card + click-to-shop; saved rooms persist builder hotspots)
---
 public/css/site.css |  4 ++++
 public/js/build.js  | 34 ++++++++++++++++++++++++++++++----
 server.js           | 23 ++++++++++++++++++-----
 3 files changed, 52 insertions(+), 9 deletions(-)

diff --git a/public/css/site.css b/public/css/site.css
index 5ea33f9..1400d4b 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -782,6 +782,10 @@ h2.ruled::after {
   background: rgba(34,30,25,.72); color: #f3ede3; font-size: .66rem; letter-spacing: .03em;
   padding: 3px 9px; border-radius: 20px;
 }
+/* hotspots on the live Room Builder gallery tiles (smaller than the room page) */
+.rb-gen-scene { position: relative; line-height: 0; }
+.rb-hot { position: absolute; transform: translate(-50%, -50%); width: 22px; height: 22px; padding: 0; border: 0; background: none; cursor: pointer; z-index: 3; }
+.rb-hot .hotspot-dot { width: 11px; height: 11px; margin: 5.5px; }
 .room-paint {
   display: flex;
   gap: 16px;
diff --git a/public/js/build.js b/public/js/build.js
index 1892f18..ccd9d9c 100644
--- a/public/js/build.js
+++ b/public/js/build.js
@@ -7,6 +7,24 @@
   var swatches = {};                 // name -> {bucket,hex}
   var room = '', cat = '', brand = '';
   var board = {}, wallPaint = null, lastRows = {}, lastScene = null;
+  var sceneSpots = {}, lastSceneSpots = []; // hotspots per generated scene url
+
+  // Render a generated scene tile with its shoppable hotspot overlay (dots on the
+  // vision-located pieces). Mirrors the server-rendered /room/:slug overlay.
+  function sceneHtml(url, spots) {
+    spots = spots || [];
+    var dots = spots.map(function (h) {
+      var cx = Math.max(3, Math.min(97, h.box.x + h.box.w / 2)).toFixed(1);
+      var cy = Math.max(3, Math.min(97, h.box.y + h.box.h / 2)).toFixed(1);
+      var q = function (s) { return String(s == null ? '' : s).replace(/"/g, '&quot;'); };
+      return '<button class="rb-hot" style="left:' + cx + '%;top:' + cy + '%" data-id="' + h.id +
+        '" data-t="' + q(h.title) + '" data-img="' + q(h.image_url) + '" data-p="' + q(h.price != null ? money(h.price) : '') +
+        '" data-adv="' + q(h.advertiser) + '" aria-label="Shop ' + q(h.title) + '"><span class="hotspot-dot"></span></button>';
+    }).join('');
+    return '<div class="rb-gen-scene"><img src="' + url + '" alt="AI-generated room setting">' +
+      (dots ? '<div class="hotspot-layer">' + dots + '</div><span class="scene-hint" aria-hidden="true">Hover a dot to shop a piece</span>' : '') +
+      '</div>';
+  }
 
   var TREND = {
     'Quiet Luxury': { style: 'glam', color: 'neutral' }, 'Japandi': { style: 'scandinavian', color: 'neutral' },
@@ -76,7 +94,7 @@
   document.addEventListener('click', function (e) {
     var sw = e.target.closest('.rb-swatch');
     if (sw) { var nm = sw.getAttribute('data-name'), bk = sw.getAttribute('data-color'); if (swatches[nm]) { delete swatches[nm]; sw.classList.remove('sel'); } else { swatches[nm] = { bucket: bk, hex: sw.style.backgroundColor || sw.style.background }; sw.classList.add('sel'); } chips(); loadProducts(); return; }
-    var gen = e.target.closest('.rb-gen'); if (gen && gen.dataset.url) { Array.prototype.forEach.call(document.querySelectorAll('.rb-gen'), function (g) { g.classList.remove('sel'); }); gen.classList.add('sel'); lastScene = gen.dataset.url; return; }
+    var gen = e.target.closest('.rb-gen'); if (gen && gen.dataset.url) { Array.prototype.forEach.call(document.querySelectorAll('.rb-gen'), function (g) { g.classList.remove('sel'); }); gen.classList.add('sel'); lastScene = gen.dataset.url; lastSceneSpots = sceneSpots[lastScene] || []; return; }
     var opt = e.target.closest('.rb-opt');
     if (opt) {
       if (opt.dataset.trend) { var m = TREND[opt.dataset.trend] || {}; sel.style = m.style || ''; selOne('data-val', sel.style);
@@ -140,8 +158,10 @@
           Array.prototype.forEach.call(document.querySelectorAll('.rb-gen'), function (x) { x.classList.remove('sel'); });
           ph.className = 'rb-gen sel'; ph.dataset.url = d.url;
           ph.removeAttribute('role'); ph.removeAttribute('aria-label');
-          ph.innerHTML = '<img src="' + d.url + '" alt="AI-generated room setting">';
-          lastScene = d.url; cost.textContent = 'last render $' + Number(d.cost).toFixed(3);
+          sceneSpots[d.url] = d.hotspots || [];
+          ph.innerHTML = sceneHtml(d.url, sceneSpots[d.url]);
+          lastScene = d.url; lastSceneSpots = sceneSpots[d.url];
+          cost.textContent = 'last render $' + Number(d.cost).toFixed(3);
         } else { ph.remove(); cost.textContent = 'render failed: ' + (d.error || ''); }
       }).catch(function () { btn.disabled = false; btn.removeAttribute('aria-busy'); btn.textContent = 'Generate room setting'; ph.remove(); });
   });
@@ -153,11 +173,17 @@
   document.addEventListener('mousemove', function (e) { if (pop.style.display !== 'block') return; var x = e.clientX + 16, y = e.clientY + 16; if (x > window.innerWidth - 270) x = e.clientX - 262; if (y > window.innerHeight - 180) y = e.clientY - 176; pop.style.left = x + 'px'; pop.style.top = y + 'px'; });
   document.addEventListener('mouseout', function (e) { if (e.target.closest('.rbp,.card')) pop.style.display = 'none'; });
 
+  // hotspot dots on generated scenes: hover shows the piece, click shops it
+  document.addEventListener('mouseover', function (e) { var d = e.target.closest('.rb-hot'); if (!d) return;
+    pop.innerHTML = '<img src="' + (d.dataset.img || '') + '" alt=""><div class="rb-pop-b"><b>' + d.dataset.t + '</b><div class="rb-pop-p">' + d.dataset.p + (d.dataset.adv ? ' · ' + d.dataset.adv : '') + '</div></div>'; pop.style.display = 'block'; });
+  document.addEventListener('mouseout', function (e) { if (e.target.closest('.rb-hot')) pop.style.display = 'none'; });
+  document.addEventListener('click', function (e) { var d = e.target.closest('.rb-hot'); if (!d) return; e.stopPropagation(); e.preventDefault(); window.open('/go/' + d.dataset.id, '_blank', 'noopener'); }, true);
+
   $('rbGo').addEventListener('click', function () {
     var go = $('rbGo'); go.textContent = 'Building…'; go.disabled = true; go.setAttribute('aria-busy', 'true');
     fetch('/api/rooms', { method: 'POST', headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({ title: $('rbTitle').value.trim(), room_type: room, style: sel.style, color: (buckets()[0] || ''),
-        note: [sel.hue, sel.theme, sel.period].concat(Object.keys(swatches)).filter(Boolean).join(', '), wall_paint_id: wallPaint && wallPaint.id, scene_image: lastScene, product_ids: Object.keys(board).map(Number) }) })
+        note: [sel.hue, sel.theme, sel.period].concat(Object.keys(swatches)).filter(Boolean).join(', '), wall_paint_id: wallPaint && wallPaint.id, scene_image: lastScene, hotspots: lastSceneSpots, product_ids: Object.keys(board).map(Number) }) })
       .then(function (r) { return r.json(); }).then(function (d) {
         if (d.url) window.location = d.url;
         else { go.textContent = 'GO ▶'; go.disabled = false; go.removeAttribute('aria-busy'); alert(d.error || 'Error'); }
diff --git a/server.js b/server.js
index 4e236d1..7e7f426 100644
--- a/server.js
+++ b/server.js
@@ -7,6 +7,7 @@ const { SITE, esc, layout, productCard, orgWebsiteJsonld, productListJsonld, sce
 const catalog = require('./lib/catalog');
 const rooms = require('./lib/rooms');
 const scene = require('./lib/scene');
+const hotspots = require('./lib/hotspots');
 
 const app = express();
 const PORT = process.env.PORT || 9820;
@@ -250,6 +251,10 @@ app.post('/api/rooms', async (req, res, next) => {
     // scene_image is rendered raw into <img src> on /room/:slug — only accept our own
     // generated paths (/img/rooms/<16-hex>.png), never an attacker-supplied URL.
     if (b.scene_image && !/^\/img\/rooms\/[a-f0-9]{16}\.png$/.test(b.scene_image)) b.scene_image = null;
+    // hotspots from the builder — keep only well-formed {id, box:{x,y,w,h}} entries
+    const cleanSpots = Array.isArray(b.hotspots)
+      ? b.hotspots.filter((h) => h && Number(h.id) && h.box && ['x', 'y', 'w', 'h'].every((k) => typeof h.box[k] === 'number')).slice(0, 40)
+      : [];
     let ids = Array.isArray(b.product_ids) ? b.product_ids.map(Number).filter(Boolean).slice(0, 40) : [];
     // "Generate from a vibe alone": back-fill matching pieces, loosening filters
     // until we always land a populated room (room+style -> style -> color -> room -> any).
@@ -264,6 +269,7 @@ app.post('/api/rooms', async (req, res, next) => {
       title: b.title, room_type: b.room_type, style: b.style,
       wall_paint_id: b.wall_paint_id ? Number(b.wall_paint_id) : null,
       product_ids: ids, note: b.note, scene_image: b.scene_image || null,
+      hotspots: cleanSpots,
       created_by: b.created_by === 'curator' ? 'curator' : 'visitor',
     });
     res.json({ slug, url: `/room/${slug}` });
@@ -331,14 +337,21 @@ app.post('/api/render', async (req, res) => {
       || req.ip || '').trim();
     if (!renderAllowed(ip)) return res.status(429).json({ error: 'Render limit reached — try again in a bit.' });
     const b = req.body || {};
-    const ids = Array.isArray(b.product_ids) ? b.product_ids.map(Number).filter(Boolean).slice(0, 4) : [];
+    // full product set (order-preserved) for hotspot mapping; scene refs use the first few
+    const ids = Array.isArray(b.product_ids) ? b.product_ids.map(Number).filter(Boolean).slice(0, 12) : [];
     let products = [];
-    if (ids.length) products = (await db.query('SELECT image_url FROM products WHERE id = ANY($1)', [ids])).rows;
+    if (ids.length) {
+      const r = await db.query('SELECT id,title,price,sale_price,image_url,advertiser FROM products WHERE id = ANY($1)', [ids]);
+      products = ids.map((id) => r.rows.find((p) => p.id === id)).filter(Boolean);
+    }
     let wall = null;
     if (b.wall_paint_id) { const w = (await db.query('SELECT title FROM products WHERE id=$1', [Number(b.wall_paint_id)])).rows[0]; wall = w && w.title; }
-    const out = await scene.generateScene({ style: b.style, color: b.color, theme: b.theme, period: b.period, room_type: b.room_type, wall, products });
-    console.log(`[render] $${out.cost} scene (${out.refs} refs)`); // cost line per Steve's rule
-    res.json(out);
+    const out = await scene.generateScene({ style: b.style, color: b.color, theme: b.theme, period: b.period, room_type: b.room_type, wall, products: products.slice(0, 6) });
+    // vision-locate the pieces so the builder render is shoppable too (paid Flash, ~$0.001)
+    const loc = await hotspots.locateProducts(path.join(__dirname, 'public', out.url), products);
+    const cost = (out.cost || 0) + (loc.cost || 0);
+    console.log(`[render] $${cost.toFixed(3)} scene (${out.refs} refs) + ${loc.hotspots.length} hotspots`); // cost line per Steve's rule
+    res.json({ ...out, cost, hotspots: loc.hotspots });
   } catch (e) { console.error('[render]', e.message); res.status(500).json({ error: e.message }); }
 });
 

← ffe693d feat: shoppable room-setting images (vision-located hotspots  ·  back to Interiordesignershowroom  ·  ui(build): persistent 3-column Room Builder — left words, ri 439a773 →