[object Object]

← back to Interiordesignershowroom

feat(build): right panel shows the products IN the generated room (shoppable) + vibe-only render back-fills a full 6-piece room; middle keeps the glowing hotspot dots; Browse-all restores the catalog

a9db00f0581140876f505ac1f9ffff96303790b2 · 2026-08-01 23:34:43 -0700 · Steve Abrams

Files touched

Diff

commit a9db00f0581140876f505ac1f9ffff96303790b2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 1 23:34:43 2026 -0700

    feat(build): right panel shows the products IN the generated room (shoppable) + vibe-only render back-fills a full 6-piece room; middle keeps the glowing hotspot dots; Browse-all restores the catalog
---
 public/css/site.css |  9 +++++++++
 public/js/build.js  | 22 +++++++++++++++++++++-
 server.js           | 14 +++++++++++++-
 3 files changed, 43 insertions(+), 2 deletions(-)

diff --git a/public/css/site.css b/public/css/site.css
index 4d0e7e1..5c0c77c 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -1368,6 +1368,15 @@ h2.ruled::after {
   position: relative;
 }
 .rbp:hover { box-shadow: 0 6px 20px rgba(22,19,15,.12); transform: translateY(-2px); }
+/* right panel showing the products IN a generated room (shoppable) */
+.rbp-roomhead {
+  display: flex; align-items: center; justify-content: space-between;
+  font-size: .7rem; text-transform: uppercase; letter-spacing: .06em; color: var(--muted);
+  padding: 2px 2px 8px; margin-bottom: 2px; border-bottom: 1px solid var(--line-lt);
+}
+.rbp-browse { border: 0; background: none; color: var(--accent); font-size: .7rem; cursor: pointer; font-family: var(--sans); letter-spacing: .03em; }
+.rbp-browse:hover { text-decoration: underline; }
+.rbp.shop { text-decoration: none; color: inherit; }
 .rbp:active { cursor: grabbing; }
 .rbp > img, .rbp .noimg {
   aspect-ratio: 4/3;
diff --git a/public/js/build.js b/public/js/build.js
index f808073..6e0687a 100644
--- a/public/js/build.js
+++ b/public/js/build.js
@@ -8,6 +8,23 @@
   var room = '', cat = '', brand = '';
   var board = {}, wallPaint = null, lastRows = {}, lastScene = null;
   var sceneSpots = {}, lastSceneSpots = []; // hotspots per generated scene url
+  var sceneProducts = {};                    // products in each generated scene
+
+  // Right panel shows the products IN the generated room (shoppable), with a link
+  // back to the browse catalog. Called after each render / on selecting a scene tile.
+  function showRoomProducts(products) {
+    var el = $('rbProducts');
+    if (!products || !products.length) return;
+    el.innerHTML = '<div class="rbp-roomhead">In this room · ' + products.length +
+      ' <button class="rbp-browse" type="button">Browse all →</button></div>' +
+      products.map(function (p) {
+        var price = (p.sale_price != null ? money(p.sale_price) : money(p.price));
+        var meta = [price, (p.advertiser || p.brand || '')].filter(Boolean).join(' · ');
+        return '<a class="rbp shop" href="/go/' + p.id + '" target="_blank" rel="nofollow sponsored" aria-label="Shop ' + (p.title || '').slice(0, 40).replace(/"/g, '') + '">' +
+          (p.image_url ? '<img loading="lazy" src="' + p.image_url + '" alt="" onerror="this.style.display=\'none\'">' : '<div class="noimg"></div>') +
+          '<div class="rbp-t">' + (p.title || '').slice(0, 50) + '</div><div class="rbp-m">' + meta + '</div></a>';
+      }).join('');
+  }
 
   // Render a generated scene tile with its shoppable hotspot overlay (dots on the
   // vision-located pieces). Mirrors the server-rendered /room/:slug overlay.
@@ -107,7 +124,8 @@
   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; lastSceneSpots = sceneSpots[lastScene] || []; return; }
+    if (e.target.closest('.rbp-browse')) { loadProducts(); return; }   // restore browse catalog
+    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] || []; showRoomProducts(sceneProducts[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);
@@ -176,8 +194,10 @@
           ph.className = 'rb-gen sel'; ph.dataset.url = d.url;
           ph.removeAttribute('role'); ph.removeAttribute('aria-label');
           sceneSpots[d.url] = d.hotspots || [];
+          sceneProducts[d.url] = d.products || [];
           ph.innerHTML = sceneHtml(d.url, sceneSpots[d.url]);
           lastScene = d.url; lastSceneSpots = sceneSpots[d.url];
+          showRoomProducts(d.products);   // right panel = the products in this room
           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(); });
diff --git a/server.js b/server.js
index f76b107..b9929ae 100644
--- a/server.js
+++ b/server.js
@@ -344,6 +344,16 @@ app.post('/api/render', async (req, res) => {
     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);
+    } else {
+      // "generate from a vibe alone": back-fill real matching pieces so the room is
+      // populated with shoppable products (and shows their thumbs), loosening filters
+      // until we land some — same strategy the /api/rooms save uses.
+      for (const attempt of [{ room: b.room_type, style: b.style }, { style: b.style }, { color: b.color }, { room: b.room_type }, {}]) {
+        const filler = await rooms.searchProducts(Object.assign({ limit: 6 }, attempt));
+        // prefer a fuller room; only settle for a sparse result if nothing better turns up
+        if (filler.length >= 3) { products = filler; break; }
+        if (filler.length > products.length) products = filler;
+      }
     }
     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; }
@@ -352,7 +362,9 @@ app.post('/api/render', async (req, res) => {
     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 });
+    // return the products in the room so the right panel can show them (shoppable)
+    const roomProducts = products.map((p) => ({ id: p.id, title: p.title, image_url: p.image_url, price: p.price, sale_price: p.sale_price, advertiser: p.advertiser, brand: p.brand }));
+    res.json({ ...out, cost, hotspots: loc.hotspots, products: roomProducts });
   } catch (e) { console.error('[render]', e.message); res.status(500).json({ error: e.message }); }
 });
 

← 9906389 feat(build): infinite-scroll the products panel — offset pag  ·  back to Interiordesignershowroom  ·  room page: 3x-larger click-to-shop thumbnails (info + Buy on 47e1b8d →