← 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
M public/css/site.cssM public/js/build.jsM server.js
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, '"'); };
+ 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 →