← 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
A db/rooms.sqlM lib/render.jsA lib/rooms.jsM public/css/site.cssA public/js/build.jsA scripts/ingest-samplize.jsM server.js
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 →