[object Object]

← back to Interiordesignershowroom

Room Builder: tray+GO, collapsed left tabs (trending/style/hue/color/theme/period/categories/brand), draggable served products, live preview+ideas, Samplize wall color, style-only generation w/ loosening back-fill; rooms schema + /looks gallery + /room/:slug + /api catalog/paints/brands/rooms

1e3d7b1915da0bcace960b5f2868f2428fb1b875 · 2026-08-01 20:28:39 -0700 · Steve Abrams

Files touched

Diff

commit 1e3d7b1915da0bcace960b5f2868f2428fb1b875
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 1 20:28:39 2026 -0700

    Room Builder: tray+GO, collapsed left tabs (trending/style/hue/color/theme/period/categories/brand), draggable served products, live preview+ideas, Samplize wall color, style-only generation w/ loosening back-fill; rooms schema + /looks gallery + /room/:slug + /api catalog/paints/brands/rooms
---
 db/rooms.sql               |  26 +++++++++
 lib/render.js              |   5 +-
 lib/rooms.js               |  80 ++++++++++++++++++++++++++++
 public/css/site.css        |  93 ++++++++++++++++++++++++++++++++
 public/js/build.js         | 111 ++++++++++++++++++++++++++++++++++++++
 scripts/ingest-samplize.js |  40 ++++++++++++++
 server.js                  | 130 +++++++++++++++++++++++++++++++++++++++++++++
 7 files changed, 482 insertions(+), 3 deletions(-)

diff --git a/db/rooms.sql b/db/rooms.sql
new file mode 100644
index 0000000..08ac8ae
--- /dev/null
+++ b/db/rooms.sql
@@ -0,0 +1,26 @@
+-- Room Builder: a "room" is a saved, shoppable composition of catalog products +
+-- an optional Samplize wall color + an optional AI-rendered scene. Built by curators
+-- (featured) or visitors (public).
+CREATE TABLE IF NOT EXISTS rooms (
+  id           BIGSERIAL PRIMARY KEY,
+  slug         TEXT UNIQUE NOT NULL,
+  title        TEXT NOT NULL,
+  room_type    TEXT,                         -- living-room, office, bedroom, dining, ...
+  style        TEXT,                         -- modern, mid-century, coastal, ...
+  wall_paint_id BIGINT REFERENCES products(id) ON DELETE SET NULL,  -- a Samplize sample
+  wall_hex     TEXT,                          -- resolved swatch color for the board
+  product_ids  BIGINT[] DEFAULT '{}',         -- the shoppable pieces
+  scene_image  TEXT,                          -- AI-rendered room scene (nullable)
+  note         TEXT,                          -- short editorial caption
+  created_by   TEXT DEFAULT 'visitor',        -- 'curator' (featured) | 'visitor'
+  featured     BOOLEAN DEFAULT FALSE,
+  public       BOOLEAN DEFAULT TRUE,
+  created_at   TIMESTAMPTZ DEFAULT now(),
+  updated_at   TIMESTAMPTZ DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_rooms_featured ON rooms (featured, created_at DESC);
+CREATE INDEX IF NOT EXISTS idx_rooms_style ON rooms (style);
+
+-- Mark wall-paint products so the builder's color picker can find them.
+ALTER TABLE products ADD COLUMN IF NOT EXISTS is_wall_paint BOOLEAN DEFAULT FALSE;
+CREATE INDEX IF NOT EXISTS idx_products_paint ON products (is_wall_paint) WHERE is_wall_paint;
diff --git a/lib/render.js b/lib/render.js
index 13c3617..339444c 100644
--- a/lib/render.js
+++ b/lib/render.js
@@ -44,9 +44,8 @@ function productCard(p) {
 function layout({ title, description, canonical, jsonld, body, activeNav }) {
   const nav = [
     ['/shop', 'Shop'],
-    ['/rooms/living-room', 'Living'],
-    ['/rooms/bedroom', 'Bedroom'],
-    ['/rooms/dining', 'Dining'],
+    ['/looks', 'Rooms'],
+    ['/build', 'Build a Room'],
     ['/guides', 'Guides'],
   ].map(([href, label]) =>
     `<a href="${href}"${activeNav === href ? ' class="active"' : ''}>${label}</a>`).join('');
diff --git a/lib/rooms.js b/lib/rooms.js
new file mode 100644
index 0000000..2ddb923
--- /dev/null
+++ b/lib/rooms.js
@@ -0,0 +1,80 @@
+// Room Builder data layer: catalog search for the builder, the Samplize wall-paint
+// palette, and room create/fetch/list.
+const db = require('./db');
+
+const slugify = (s) => (s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'room';
+
+async function searchProducts({ q, room, style, color, advertiser, cat, max, limit = 40 } = {}) {
+  const where = ['in_stock', 'is_wall_paint = FALSE', 'image_url IS NOT NULL'];
+  const params = [];
+  if (room) { params.push(room); where.push(`room = $${params.length}`); }
+  if (style) { params.push(style); where.push(`style = $${params.length}`); }
+  if (color) { params.push(color); where.push(`color = $${params.length}`); }
+  if (advertiser) { params.push(advertiser); where.push(`advertiser = $${params.length}`); }
+  if (cat) { params.push('%' + cat + '%'); where.push(`title ILIKE $${params.length}`); }
+  if (max) { params.push(max); where.push(`COALESCE(sale_price, price) <= $${params.length}`); }
+  if (q) { params.push('%' + q + '%'); where.push(`(title ILIKE $${params.length} OR brand ILIKE $${params.length} OR advertiser ILIKE $${params.length})`); }
+  params.push(Math.min(limit, 80));
+  const { rows } = await db.query(
+    `SELECT id,title,brand,advertiser,price,sale_price,image_url,room,style,affiliate_url
+     FROM products WHERE ${where.join(' AND ')} ORDER BY featured DESC, created_at DESC LIMIT $${params.length}`, params);
+  return rows;
+}
+
+async function getPaints(limit = 80) {
+  const { rows } = await db.query(
+    `SELECT id,title,image_url,affiliate_url,price FROM products WHERE is_wall_paint ORDER BY title LIMIT $1`, [limit]);
+  return rows;
+}
+
+async function createRoom(d = {}) {
+  const base = slugify(d.title || `${d.style || ''} ${d.room_type || 'room'}`);
+  let slug = base, n = 1;
+  while ((await db.query('SELECT 1 FROM rooms WHERE slug=$1', [slug])).rowCount) slug = `${base}-${++n}`;
+  const isCurator = d.created_by === 'curator';
+  const title = (d.title || `${d.style || ''} ${d.room_type || 'Room'}`).trim().replace(/\b\w/g, c => c.toUpperCase());
+  const ids = Array.isArray(d.product_ids) ? d.product_ids.map(Number).filter(Boolean) : [];
+  const { rows } = await db.query(
+    `INSERT INTO rooms (slug,title,room_type,style,wall_paint_id,product_ids,scene_image,note,created_by,featured,public)
+     VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,TRUE) RETURNING slug`,
+    [slug, title, d.room_type || null, d.style || null, d.wall_paint_id || null, ids,
+     d.scene_image || null, d.note || null, isCurator ? 'curator' : 'visitor', isCurator]);
+  return rows[0].slug;
+}
+
+async function getRoom(slug) {
+  const { rows } = await db.query('SELECT * FROM rooms WHERE slug=$1', [slug]);
+  if (!rows.length) return null;
+  const r = rows[0];
+  let products = [];
+  if (r.product_ids && r.product_ids.length) {
+    const pr = await db.query('SELECT * FROM products WHERE id = ANY($1)', [r.product_ids]);
+    // preserve the saved order
+    products = r.product_ids.map(id => pr.rows.find(p => p.id === id)).filter(Boolean);
+  }
+  let paint = null;
+  if (r.wall_paint_id) paint = (await db.query('SELECT * FROM products WHERE id=$1', [r.wall_paint_id])).rows[0] || null;
+  return { room: r, products, paint };
+}
+
+async function listRooms({ limit = 60 } = {}) {
+  const { rows } = await db.query('SELECT * FROM rooms WHERE public ORDER BY featured DESC, created_at DESC LIMIT $1', [limit]);
+  // attach up to 4 thumbnails per room
+  for (const r of rows) {
+    r.thumbs = [];
+    if (r.product_ids && r.product_ids.length) {
+      const t = await db.query('SELECT image_url FROM products WHERE id = ANY($1) AND image_url IS NOT NULL LIMIT 4', [r.product_ids.slice(0, 4)]);
+      r.thumbs = t.rows.map(x => x.image_url);
+    }
+  }
+  return rows;
+}
+
+async function listBrands(limit = 40) {
+  const { rows } = await db.query(
+    `SELECT advertiser, count(*) n FROM products WHERE advertiser IS NOT NULL AND NOT is_wall_paint AND image_url IS NOT NULL
+     GROUP BY advertiser HAVING count(*) >= 3 ORDER BY n DESC LIMIT $1`, [limit]);
+  return rows;
+}
+
+module.exports = { slugify, searchProducts, getPaints, createRoom, getRoom, listRooms, listBrands };
diff --git a/public/css/site.css b/public/css/site.css
index 81083ca..a701af3 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -86,4 +86,97 @@ h2{font-size:1.4rem;margin:0 0 16px;padding-bottom:8px;border-bottom:1px solid v
 .chip:hover{border-color:var(--accent);color:var(--accent)}
 .chip.clear{background:none;font-weight:600}
 @media(max-width:820px){.catalog{grid-template-columns:1fr}.facets{position:static;flex-flow:row wrap}.facet-group{min-width:140px}}
+
+/* Room Builder */
+.cta.sm{padding:8px 16px;font-size:.85rem}
+.builder-head{display:flex;justify-content:space-between;align-items:baseline;flex-wrap:wrap;gap:10px}
+.builder-grid{display:grid;grid-template-columns:280px 1fr;gap:24px;align-items:start;margin-top:14px}
+.builder-controls{position:sticky;top:14px;display:flex;flex-direction:column;gap:12px;background:#fff;border:1px solid var(--line);border-radius:8px;padding:16px}
+.builder-controls label{display:flex;flex-direction:column;gap:4px;font-size:.8rem;color:var(--muted)}
+.builder-controls select,.builder-controls input{padding:7px 9px;border:1px solid var(--line);border-radius:5px;font-size:.9rem;background:#fff}
+.builder-controls h4{margin:6px 0 0;font-family:var(--sans);font-size:.75rem;text-transform:uppercase;letter-spacing:.06em;color:var(--muted)}
+.paint-strip{display:flex;flex-wrap:wrap;gap:6px;max-height:150px;overflow:auto}
+.paint{width:34px;height:34px;padding:0;border:2px solid transparent;border-radius:6px;overflow:hidden;cursor:pointer;background:none}
+.paint img{width:100%;height:100%;object-fit:cover}
+.paint.sel{border-color:var(--ink)}
+.builder-save{border-top:1px solid var(--line);padding-top:12px;display:flex;flex-direction:column;gap:8px}
+.builder-save input{padding:8px}
+.mini{font-size:.78rem;color:var(--accent);margin:0;min-height:1em}
+.board{background:#f3ede2;border:1px solid var(--line);border-radius:8px;padding:14px;margin-bottom:22px}
+.board h3,.builder-main>h3{font-family:var(--serif);font-size:1.05rem;margin:0 0 10px}
+.board-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px}
+.empty{color:var(--muted);font-size:.9rem;grid-column:1/-1}
+.bitem{background:#fff;border:1px solid var(--line);border-radius:6px;overflow:hidden;display:flex;flex-direction:column}
+.bitem>img,.bitem .noimg{aspect-ratio:1/1;object-fit:cover;width:100%;background:#efe8dd}
+.bitem-b{padding:7px 8px;display:flex;flex-direction:column;gap:4px;flex:1}
+.bitem-t{font-size:.8rem;line-height:1.25;font-weight:600}
+.bitem-m{font-size:.72rem;color:var(--muted);margin-top:auto}
+.bitem-btn{border:1px solid var(--line);background:#faf8f5;border-radius:4px;padding:4px;font-size:.75rem;cursor:pointer}
+.bitem-btn:hover{border-color:var(--accent);color:var(--accent)}
+
+/* Looks gallery + room page */
+.look-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:18px}
+.look-card{background:#fff;border:1px solid var(--line);border-radius:8px;overflow:hidden}
+.look-thumbs{display:grid;grid-template-columns:1fr 1fr;gap:1px;aspect-ratio:1/1;background:var(--line)}
+.look-thumbs img{width:100%;height:100%;object-fit:cover}
+.look-card h3{margin:10px 12px 2px;font-size:1rem}
+.look-card p{margin:0 12px 12px}
+.room-page{max-width:1100px;margin:0 auto}
+.room-scene{width:100%;border-radius:10px;margin:14px 0}
+.room-paint{display:flex;gap:14px;align-items:center;background:#f3ede2;border:1px solid var(--line);border-radius:8px;padding:12px;margin:14px 0;max-width:420px}
+.room-paint img{width:70px;height:70px;object-fit:cover;border-radius:6px}
+.room-cta{text-align:center;margin:30px 0}
+@media(max-width:760px){.builder-grid{grid-template-columns:1fr}.builder-controls{position:static}}
 @media(max-width:640px){.site-nav{gap:14px}.brand-word{display:none}.hero h1{font-size:2rem}}
+
+/* ---- Room Builder v2 (tray + left tabs + mid render + right products) ---- */
+.rb{max-width:1340px;margin:0 auto}
+.rb-tray{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:10px;background:#221e19;color:#f3ede3;padding:10px 14px;border-radius:8px;flex-wrap:wrap}
+.rb-tray select,.rb-tray input{padding:7px 10px;border:1px solid #4a4136;border-radius:5px;background:#2e2820;color:#f3ede3;font-size:.85rem}
+.rb-tray.drop{outline:2px dashed #e6c98a}
+.rb-chips{flex:1;display:flex;gap:6px;flex-wrap:wrap;min-height:30px;align-items:center}
+.rb-hint{color:#a99;font-size:.8rem}
+.rb-chip{display:inline-flex;align-items:center;gap:5px;background:#4a4136;color:#f3ede3;border-radius:20px;padding:3px 10px;font-size:.78rem;cursor:pointer;text-transform:capitalize}
+.rb-chip.prod{background:#5a4d38;padding:2px 8px 2px 2px}
+.rb-chip.prod img{width:22px;height:22px;object-fit:cover;border-radius:50%}
+.rb-tray .cta{background:#e6c98a;color:#221e19;font-weight:700;padding:9px 22px}
+.rb-tray .cta:hover{background:#f0d9a5}
+
+.rb-body{display:grid;grid-template-columns:220px 1fr 300px;gap:16px;margin-top:14px;align-items:start}
+.rb-left{display:flex;flex-direction:column;gap:6px}
+.rb-tab{border:1px solid var(--line);border-radius:6px;background:#fff;overflow:hidden}
+.rb-tab>summary{cursor:pointer;padding:9px 12px;font-family:var(--sans);font-weight:600;font-size:.85rem;list-style:none;display:flex;justify-content:space-between;align-items:center}
+.rb-tab>summary::-webkit-details-marker{display:none}
+.rb-tab>summary::after{content:'+';color:var(--muted)}
+.rb-tab[open]>summary::after{content:'\2013'}
+.rb-tab[open]>summary{border-bottom:1px solid var(--line)}
+.rb-opts{display:flex;flex-wrap:wrap;gap:5px;padding:10px}
+.rb-opt{border:1px solid var(--line);background:#faf8f5;border-radius:16px;padding:4px 11px;font-size:.78rem;cursor:pointer;text-transform:capitalize}
+.rb-opt:hover{border-color:var(--accent)}
+.rb-opt.sel{background:var(--ink);color:#fff;border-color:var(--ink)}
+.rb-opt.trend{background:#f3ece0;font-weight:600}
+
+.rb-mid{min-height:300px}
+.rb-render{background:#efe8dd;border:1px solid var(--line);border-radius:10px;min-height:300px;display:flex;align-items:center;justify-content:center;padding:16px}
+.rb-render.drop,.rb-mid.drop{outline:2px dashed var(--accent)}
+.rb-render-ph{color:var(--muted);text-align:center;font-family:var(--serif);font-size:1.1rem}
+.rb-scene{display:grid;grid-template-columns:repeat(auto-fill,minmax(90px,1fr));gap:8px;width:100%}
+.rb-scene.painted{padding:14px;border-radius:8px}
+.rb-scene-item{background:#fff;border-radius:6px;overflow:hidden;aspect-ratio:1/1;box-shadow:0 2px 8px rgba(0,0,0,.08)}
+.rb-scene-item img{width:100%;height:100%;object-fit:cover}
+.rb-ideas-h{margin:18px 0 8px;font-family:var(--serif)}
+.rb-ideas{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px}
+
+.rb-right{position:sticky;top:64px}
+.rb-right>input{width:100%;padding:8px 10px;border:1px solid var(--line);border-radius:6px;margin-bottom:10px;font-size:.88rem}
+.rb-products{display:grid;grid-template-columns:1fr 1fr;gap:10px;max-height:76vh;overflow:auto;padding-right:4px}
+.rbp{background:#fff;border:1px solid var(--line);border-radius:6px;overflow:hidden;display:flex;flex-direction:column;cursor:grab}
+.rbp:active{cursor:grabbing}
+.rbp>img,.rbp .noimg{aspect-ratio:1/1;object-fit:cover;width:100%;background:#efe8dd}
+.rbp-t{font-size:.75rem;font-weight:600;line-height:1.2;padding:6px 7px 2px}
+.rbp-m{font-size:.68rem;color:var(--muted);padding:0 7px}
+.rbp-add{margin:6px 7px 7px;border:1px solid var(--line);background:#faf8f5;border-radius:4px;padding:4px;font-size:.72rem;cursor:pointer}
+.rbp-add:hover{border-color:var(--accent);color:var(--accent)}
+.empty{color:var(--muted);font-size:.85rem;grid-column:1/-1}
+.noimg{display:grid;place-items:center;color:var(--muted);font-size:.7rem}
+@media(max-width:980px){.rb-body{grid-template-columns:1fr}.rb-left,.rb-right{position:static}.rb-products{grid-template-columns:repeat(auto-fill,minmax(110px,1fr));max-height:none}}
diff --git a/public/js/build.js b/public/js/build.js
new file mode 100644
index 0000000..4dd521b
--- /dev/null
+++ b/public/js/build.js
@@ -0,0 +1,111 @@
+// Room Builder client (v2). Left: Trending presets + dimension tabs
+// (style/hue/color/theme/period) + Categories + Brand, all collapsed. Top tray:
+// chips + dragged products + GO. Middle: live preview + ideas. Right: products.
+(function () {
+  var $ = function (id) { return document.getElementById(id); };
+  var sel = { style: '', hue: '', color: '', theme: '', period: '' };
+  var room = '', cat = '', brand = '';
+  var board = {}, wallPaint = null, lastRows = {};
+
+  var TREND = {
+    'Quiet Luxury': { style: 'glam', color: 'neutral' }, 'Japandi': { style: 'scandinavian', color: 'neutral' },
+    'Mid-Century Modern': { style: 'mid-century' }, 'Modern Farmhouse': { style: 'farmhouse' },
+    'Coastal': { style: 'coastal', color: 'blue' }, 'Dark & Moody': { style: 'industrial', color: 'black' },
+    'Boho': { style: 'boho' }, 'Scandinavian': { style: 'scandinavian' }, 'Maximalist': { style: 'glam', color: 'red' },
+  };
+  var money = function (p) { return p == null ? '' : '$' + Number(p).toLocaleString('en-US', { maximumFractionDigits: 0 }); };
+
+  function card(p, drag) {
+    lastRows[p.id] = p;
+    return '<div class="rbp" data-id="' + p.id + '"' + (drag ? ' draggable="true"' : '') + '>' +
+      (p.image_url ? '<img loading="lazy" src="' + p.image_url + '" alt="">' : '<div class="noimg"></div>') +
+      '<div class="rbp-t">' + (p.title || '').slice(0, 40) + '</div>' +
+      '<div class="rbp-m">' + (p.sale_price ? money(p.sale_price) : money(p.price)) + ' · ' + (p.advertiser || p.brand || '') + '</div>' +
+      '<button class="rbp-add" data-id="' + p.id + '">' + (board[p.id] ? '✓ Added' : '+ Add') + '</button></div>';
+  }
+
+  function chips() {
+    var parts = [];
+    if (room) parts.push('<span class="rb-chip" data-rmk="room">' + room + ' ✕</span>');
+    Object.keys(sel).forEach(function (d) { if (sel[d]) parts.push('<span class="rb-chip" data-rmdim="' + d + '">' + sel[d] + ' ✕</span>'); });
+    if (cat) parts.push('<span class="rb-chip" data-rmk="cat">' + cat + ' ✕</span>');
+    if (brand) parts.push('<span class="rb-chip" data-rmk="brand">' + brand + ' ✕</span>');
+    Object.keys(board).forEach(function (id) { var p = board[id]; parts.push('<span class="rb-chip prod" data-rm="' + id + '">' + (p.image_url ? '<img src="' + p.image_url + '">' : '') + (p.title || '').slice(0, 16) + ' ✕</span>'); });
+    $('rbChips').innerHTML = parts.join('') || '<span class="rb-hint">Pick a vibe on the left, drag products from the right →</span>';
+  }
+
+  function preview() {
+    var ids = Object.keys(board), el = $('rbRender');
+    if (!ids.length) { el.innerHTML = '<div class="rb-render-ph">Your room preview builds here.<br><small>Pick a style + drag pieces, or just hit <b>GO</b> to generate from your vibe.</small></div>'; return; }
+    el.innerHTML = '<div class="rb-scene">' + ids.map(function (id) { return '<div class="rb-scene-item"><img src="' + board[id].image_url + '" alt=""></div>'; }).join('') + '</div>';
+  }
+
+  function query() {
+    var q = new URLSearchParams();
+    if (room) q.set('room', room); if (sel.style) q.set('style', sel.style); if (sel.color) q.set('color', sel.color);
+    if (cat) q.set('cat', cat); if (brand) q.set('brand', brand);
+    var s = $('rbSearch').value.trim(); if (s) q.set('q', s);
+    return q.toString();
+  }
+
+  function loadProducts() {
+    $('rbProducts').innerHTML = '<p class="empty">Loading…</p>';
+    fetch('/api/catalog/search?' + query()).then(function (r) { return r.json(); }).then(function (rows) {
+      $('rbProducts').innerHTML = rows.length ? rows.map(function (p) { return card(p, true); }).join('') : '<p class="empty">No matches.</p>';
+      var ideas = rows.filter(function (p) { return !board[p.id]; }).slice(0, 8);
+      $('rbIdeas').innerHTML = ideas.length ? ideas.map(function (p) { return card(p, false); }).join('') : '<p class="empty">Adjust your vibe for ideas.</p>';
+    });
+  }
+
+  function loadPaints() {
+    fetch('/api/paints').then(function (r) { return r.json(); }).then(function (rows) {
+      $('rbPaints').innerHTML = rows.map(function (p) { return '<button class="paint" data-id="' + p.id + '" title="' + (p.title || '').replace(/"/g, '') + '"><img src="' + p.image_url + '" alt=""></button>'; }).join('');
+    });
+  }
+  function loadBrands() {
+    fetch('/api/brands').then(function (r) { return r.json(); }).then(function (rows) {
+      $('rbBrands').innerHTML = rows.map(function (b) { return '<button class="rb-opt" data-brand="' + b.advertiser.replace(/"/g, '') + '">' + b.advertiser + ' <span class="subtle">' + b.n + '</span></button>'; }).join('') || '<span class="empty">—</span>';
+    });
+  }
+
+  function selOne(attr, val) { // single-select toggle within a group
+    Array.prototype.forEach.call(document.querySelectorAll('[' + attr + ']'), function (b) { b.classList.toggle('sel', b.getAttribute(attr) === val); });
+  }
+  function add(id) { var p = lastRows[id]; if (p) { board[id] = p; chips(); preview(); refresh(); } }
+  function refresh() { Array.prototype.forEach.call(document.querySelectorAll('.rbp-add'), function (b) { b.textContent = board[b.getAttribute('data-id')] ? '✓ Added' : '+ Add'; }); }
+
+  document.addEventListener('click', function (e) {
+    var opt = e.target.closest('.rb-opt');
+    if (opt) {
+      if (opt.dataset.trend) { var m = TREND[opt.dataset.trend] || {}; sel.style = m.style || ''; sel.color = m.color || ''; selOne('data-val', sel.style); if (sel.color) selOne('data-val', sel.color); chips(); loadProducts(); return; }
+      if (opt.dataset.cat) { cat = (cat === opt.dataset.cat) ? '' : opt.dataset.cat; selOne('data-cat', cat); chips(); loadProducts(); return; }
+      if (opt.dataset.brand) { brand = (brand === opt.dataset.brand) ? '' : opt.dataset.brand; selOne('data-brand', brand); chips(); loadProducts(); return; }
+      if (opt.dataset.dim) { var d = opt.dataset.dim, v = opt.dataset.val; sel[d] = (sel[d] === v) ? '' : v; Array.prototype.forEach.call(document.querySelectorAll('.rb-opt[data-dim="' + d + '"]'), function (b) { b.classList.toggle('sel', b.getAttribute('data-val') === sel[d]); }); chips(); loadProducts(); return; }
+    }
+    var addb = e.target.closest('.rbp-add'); if (addb) { add(addb.getAttribute('data-id')); return; }
+    var rm = e.target.closest('[data-rm]'); if (rm) { delete board[rm.getAttribute('data-rm')]; chips(); preview(); refresh(); return; }
+    var rmd = e.target.closest('[data-rmdim]'); if (rmd) { var dd = rmd.getAttribute('data-rmdim'); sel[dd] = ''; Array.prototype.forEach.call(document.querySelectorAll('.rb-opt[data-dim="' + dd + '"]'), function (b) { b.classList.remove('sel'); }); chips(); loadProducts(); return; }
+    var rmk = e.target.closest('[data-rmk]'); if (rmk) { var k = rmk.getAttribute('data-rmk'); if (k === 'room') { room = ''; $('rbRoom').value = ''; } if (k === 'cat') { cat = ''; selOne('data-cat', ''); } if (k === 'brand') { brand = ''; selOne('data-brand', ''); } chips(); loadProducts(); return; }
+    var pt = e.target.closest('.paint'); if (pt) { Array.prototype.forEach.call(document.querySelectorAll('.paint'), function (x) { x.classList.remove('sel'); }); pt.classList.add('sel'); wallPaint = { id: pt.getAttribute('data-id') }; return; }
+  });
+
+  document.addEventListener('dragstart', function (e) { var c = e.target.closest('.rbp'); if (c) e.dataTransfer.setData('text/id', c.getAttribute('data-id')); });
+  ['rbTray', 'rbMid'].forEach(function (zid) { var z = $(zid);
+    z.addEventListener('dragover', function (e) { e.preventDefault(); z.classList.add('drop'); });
+    z.addEventListener('dragleave', function () { z.classList.remove('drop'); });
+    z.addEventListener('drop', function (e) { e.preventDefault(); z.classList.remove('drop'); var id = e.dataTransfer.getData('text/id'); if (id) add(id); });
+  });
+
+  $('rbRoom').addEventListener('change', function () { room = this.value; chips(); loadProducts(); });
+  $('rbSearch').addEventListener('keydown', function (e) { if (e.key === 'Enter') loadProducts(); });
+  $('rbGo').addEventListener('click', function () {
+    var go = $('rbGo'); go.textContent = 'Building…'; go.disabled = 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: sel.color,
+        note: [sel.hue, sel.theme, sel.period].filter(Boolean).join(', '), wall_paint_id: wallPaint && wallPaint.id, 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; alert(d.error || 'Error'); } })
+      .catch(function () { go.textContent = 'GO ▶'; go.disabled = false; });
+  });
+
+  loadPaints(); loadBrands(); loadProducts(); chips();
+})();
diff --git a/scripts/ingest-samplize.js b/scripts/ingest-samplize.js
new file mode 100644
index 0000000..8d280ab
--- /dev/null
+++ b/scripts/ingest-samplize.js
@@ -0,0 +1,40 @@
+// Ingest the Samplize peel-and-stick paint samples = the Room Builder's wall-color
+// layer. They're a CJ advertiser; pull across paint searches, filter to Samplize,
+// upsert, and flag is_wall_paint so the color picker can find them.
+try { require('dotenv').config(); } catch (_) {}
+const db = require('../lib/db');
+const cj = require('../lib/adapters/cj');
+const { normalizeProduct, UPSERT_SQL, upsertParams } = require('../lib/normalize');
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+const SEARCHES = [
+  'peel and stick paint sample', 'benjamin moore paint sample', 'sherwin williams paint sample',
+  'paint color sample', 'wall paint swatch', 'peel stick paint blue', 'peel stick paint green',
+  'peel stick paint white', 'peel stick paint gray', 'peel stick paint neutral',
+];
+
+async function main() {
+  await db.query('SELECT 1');
+  if (!cj.enabled(process.env)) { console.error('CJ not configured'); process.exit(1); }
+  const seen = new Set(); let ok = 0;
+  for (const q of SEARCHES) {
+    let raws = [];
+    try { raws = await cj.fetch(process.env, { limit: 60, keywords: q }); } catch (e) { continue; }
+    for (const raw of raws) {
+      if (!/samplize/i.test(raw.advertiser || '')) continue;
+      if (!raw.image_url || seen.has(raw.external_id)) continue;
+      seen.add(raw.external_id);
+      const norm = normalizeProduct(raw, 'cj');
+      if (!norm) continue;
+      norm.category = 'Wall Paint';
+      try { await db.query(UPSERT_SQL, upsertParams(norm)); ok++; } catch (_) {}
+    }
+    await sleep(400);
+  }
+  // flag them as the wall-paint layer
+  await db.query(`UPDATE products SET is_wall_paint=TRUE, room='paint', category='Wall Paint' WHERE advertiser ILIKE 'samplize'`);
+  const { rows } = await db.query(`SELECT count(*) n FROM products WHERE is_wall_paint`);
+  console.log(`Samplize wall-paint samples ingested: ${ok} new; total paint layer = ${rows[0].n}`);
+  await db.pool.end();
+}
+main().catch(e => { console.error(e); process.exit(1); });
diff --git a/server.js b/server.js
index 94398e3..1744ad2 100644
--- a/server.js
+++ b/server.js
@@ -4,10 +4,12 @@ const path = require('path');
 const db = require('./lib/db');
 const { SITE, esc, layout, productCard } = require('./lib/render');
 const catalog = require('./lib/catalog');
+const rooms = require('./lib/rooms');
 
 const app = express();
 const PORT = process.env.PORT || 9820;
 
+app.use(express.json({ limit: '256kb' }));
 app.use('/css', express.static(path.join(__dirname, 'public/css')));
 app.use('/js', express.static(path.join(__dirname, 'public/js')));
 app.use('/img', express.static(path.join(__dirname, 'public/img')));
@@ -196,6 +198,134 @@ app.get('/sitemap.xml', async (_req, res, next) => {
   } catch (e) { next(e); }
 });
 
+// ---------------------- Room Builder --------------------------------------
+const STYLES = ['modern', 'mid-century', 'traditional', 'farmhouse', 'industrial', 'boho', 'coastal', 'glam', 'scandinavian'];
+const ROOM_TYPES = [['living-room', 'Living Room'], ['bedroom', 'Bedroom'], ['dining', 'Dining'], ['office', 'Office'], ['kitchen', 'Kitchen'], ['outdoor', 'Outdoor']];
+
+app.get('/api/catalog/search', async (req, res, next) => {
+  try {
+    const r = await rooms.searchProducts({
+      q: req.query.q, room: req.query.room, style: req.query.style, color: req.query.color,
+      advertiser: req.query.brand, cat: req.query.cat,
+      max: req.query.max ? parseInt(req.query.max, 10) : null, limit: 48,
+    });
+    res.json(r);
+  } catch (e) { next(e); }
+});
+
+app.get('/api/paints', async (_req, res, next) => {
+  try { res.json(await rooms.getPaints(80)); } catch (e) { next(e); }
+});
+
+app.get('/api/brands', async (_req, res, next) => {
+  try { res.json(await rooms.listBrands(40)); } catch (e) { next(e); }
+});
+
+app.post('/api/rooms', async (req, res, next) => {
+  try {
+    const b = req.body || {};
+    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).
+    if (ids.length === 0) {
+      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: 8 }, attempt));
+        if (filler.length) { ids = filler.map(p => p.id); break; }
+      }
+    }
+    if (ids.length === 0) return res.status(400).json({ error: 'No products available to build a room.' });
+    const slug = await rooms.createRoom({
+      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, created_by: b.created_by === 'curator' ? 'curator' : 'visitor',
+    });
+    res.json({ slug, url: `/room/${slug}` });
+  } catch (e) { next(e); }
+});
+
+const DIMENSIONS = {
+  style: STYLES,
+  hue: ['warm', 'cool', 'neutral', 'bold', 'muted', 'earthy'],
+  color: ['neutral', 'gray', 'black', 'brown', 'blue', 'green', 'pink', 'yellow', 'red'],
+  theme: ['minimalist', 'cozy', 'luxe', 'eclectic', 'organic', 'moody', 'airy', 'playful'],
+  period: ['mid-century', 'art deco', 'victorian', 'contemporary', '70s retro', 'bauhaus', 'traditional'],
+};
+
+app.get('/build', (_req, res) => {
+  const roomOpts = ROOM_TYPES.map(([v, l]) => `<option value="${v}">${l}</option>`).join('');
+  const cap = s => s.replace(/\b\w/g, c => c.toUpperCase());
+  const TRENDING = ['Quiet Luxury', 'Japandi', 'Mid-Century Modern', 'Modern Farmhouse', 'Coastal', 'Dark & Moody', 'Boho', 'Scandinavian', 'Maximalist'];
+  const CATS = ['Sofas', 'Sectionals', 'Chairs', 'Desks', 'Tables', 'Beds', 'Dressers', 'Bookcases', 'Rugs', 'Lighting', 'Mirrors', 'Wall Art', 'Vases', 'Bar Stools', 'Appliances'];
+  const tab = (title, inner) => `<details class="rb-tab"><summary>${title}</summary><div class="rb-opts">${inner}</div></details>`;
+  // left panel: ALL tabs start collapsed
+  const tabs =
+    tab('🔥 Trending', TRENDING.map(t => `<button class="rb-opt trend" data-trend="${t}">${t}</button>`).join('')) +
+    Object.entries(DIMENSIONS).map(([dim, vals]) => tab(cap(dim), vals.map(v => `<button class="rb-opt" data-dim="${dim}" data-val="${v}">${cap(v)}</button>`).join(''))).join('') +
+    tab('Categories', CATS.map(c => `<button class="rb-opt" data-cat="${c}">${c}</button>`).join('')) +
+    `<details class="rb-tab"><summary>Brand</summary><div class="rb-opts" id="rbBrands"><span class="empty">Loading…</span></div></details>`;
+
+  const body = `<section class="rb">
+    <div class="rb-tray" id="rbTray">
+      <select id="rbRoom" title="Room type"><option value="">Room…</option>${roomOpts}</select>
+      <div class="rb-chips" id="rbChips"><span class="rb-hint">Pick a vibe on the left, drag products from the right →</span></div>
+      <input id="rbTitle" type="text" placeholder="Name (optional)">
+      <button id="rbGo" class="cta">GO ▶</button>
+    </div>
+    <div class="rb-body">
+      <aside class="rb-left">${tabs}
+        <details class="rb-tab"><summary>Wall color <span class="subtle">Samplize</span></summary>
+          <div class="paint-strip" id="rbPaints"></div></details>
+      </aside>
+      <div class="rb-mid" id="rbMid">
+        <div class="rb-render" id="rbRender"><div class="rb-render-ph">Your room preview builds here.<br><small>Pick a style + drag pieces, or just hit <b>GO</b> to generate from your vibe.</small></div></div>
+        <h4 class="rb-ideas-h">✨ New ideas <span class="subtle">— tap to add</span></h4>
+        <div class="rb-ideas" id="rbIdeas"></div>
+      </div>
+      <aside class="rb-right">
+        <input id="rbSearch" type="search" placeholder="Search catalog… (sofa, steelcase, vitamix)">
+        <div class="rb-products" id="rbProducts"></div>
+      </aside>
+    </div>
+  </section><script src="/js/build.js"></script>`;
+  res.send(layout({ title: 'Room Builder', description: 'Compose a shoppable room — pick a style, hue, color, theme & period, drag in pieces, and generate.', canonical: `${SITE.url}/build`, body, activeNav: '/build' }));
+});
+
+app.get('/looks', async (_req, res, next) => {
+  try {
+    const list = await rooms.listRooms({ limit: 60 });
+    const cards = list.map(r => `<a class="look-card" href="/room/${esc(r.slug)}">
+        <div class="look-thumbs">${(r.thumbs || []).slice(0, 4).map(t => `<img loading="lazy" src="${esc(t)}" alt="">`).join('') || '<div class="noimg"></div>'}</div>
+        <h3>${esc(r.title)}</h3><p class="subtle">${esc(r.style || '')} ${esc(r.room_type || '')} · ${(r.product_ids || []).length} pieces</p>
+      </a>`).join('');
+    const body = `<section><div class="builder-head"><h1>Rooms</h1><a class="cta sm" href="/build">+ Build a room</a></div>
+      <div class="look-grid">${cards || '<p class="subtle">No rooms yet — <a href="/build">build the first one</a>.</p>'}</div></section>`;
+    res.send(layout({ title: 'Shop the Look — Curated Rooms', description: 'Browse shoppable rooms built from real designer pieces.', canonical: `${SITE.url}/looks`, body, activeNav: '/looks' }));
+  } catch (e) { next(e); }
+});
+
+app.get('/room/:slug', async (req, res, next) => {
+  try {
+    const data = await rooms.getRoom(req.params.slug);
+    if (!data) return next();
+    const { room, products, paint } = data;
+    const jsonld = { '@context': 'https://schema.org', '@type': 'ItemList', name: room.title, numberOfItems: products.length };
+    const paintBlock = paint ? `<div class="room-paint">
+        <a href="/go/${paint.id}" rel="nofollow sponsored" target="_blank"><img src="${esc(paint.image_url)}" alt="${esc(paint.title)}"></a>
+        <div><span class="subtle">Wall color</span><br>${esc(paint.title)}<br><a class="buy" href="/go/${paint.id}" rel="nofollow sponsored" target="_blank">Get the sample →</a></div>
+      </div>` : '';
+    const body = `<article class="room-page">
+      <div class="room-head"><h1>${esc(room.title)}</h1>
+        <p class="subtle">${esc(room.style || '')} ${esc(room.room_type || '')} · ${products.length} shoppable pieces</p></div>
+      ${room.scene_image ? `<img class="room-scene" src="${esc(room.scene_image)}" alt="${esc(room.title)}">` : ''}
+      ${paintBlock}
+      <h2>Shop this room</h2>
+      <div class="grid">${products.map(productCard).join('')}</div>
+      <p class="room-cta"><a class="cta sm" href="/build">Build your own room →</a></p>
+    </article>`;
+    res.send(layout({ title: room.title, description: room.note || `A shoppable ${room.style || ''} ${room.room_type || 'room'} — ${products.length} designer pieces.`, canonical: `${SITE.url}/room/${room.slug}`, jsonld, body, activeNav: '/looks' }));
+  } catch (e) { next(e); }
+});
+
 app.use(require('./routes/admin'));
 
 app.use((_req, res) => res.status(404).send(layout({ title: 'Not found', body: '<section><h1>Not found</h1><p><a href="/">Back to the showroom →</a></p></section>' })));

← 365ba46 Scaled CJ catalog ingest (48 categories, image-required) ->  ·  back to Interiordesignershowroom  ·  Room Builder: AI photoreal render (Gemini nano-banana, feeds 628a4c7 →