← back to Interiordesignershowroom
public/js/build.js
233 lines
// Room Builder client (v4). Left: dimension tabs + 12-color swatches. Top tray:
// vibe chips + dragged product images + GO. Middle: ONLY generated room settings
// (AI renders, gallery). Right: products, collapsed under a ☰ hamburger.
(function () {
var $ = function (id) { return document.getElementById(id); };
var sel = { style: '', hue: '', theme: '', period: '' };
var swatches = {}; // name -> {bucket,hex}
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="" data-hide-on-error="1">' : '<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.
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' },
'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 }); };
var buckets = function () { return Array.from(new Set(Object.keys(swatches).map(function (n) { return swatches[n].bucket; }))); };
function card(p) {
lastRows[p.id] = p;
var priceStr = (p.sale_price != null ? money(p.sale_price) : money(p.price));
var metaStr = [priceStr, (p.advertiser || p.brand || '')].filter(Boolean).join(' · ');
var imgHtml = p.image_url
? '<img loading="lazy" src="' + p.image_url + '" alt="' + (p.title || '').replace(/"/g, '') + '" data-hide-on-error="1">'
: '<div class="noimg"></div>';
return '<div class="rbp" data-id="' + p.id + '" draggable="true" tabindex="0" role="button" aria-label="Add ' + (p.title || '').slice(0, 40).replace(/"/g, '') + '">' +
imgHtml +
'<div class="rbp-t">' + (p.title || '').slice(0, 50) + '</div>' +
'<div class="rbp-m">' + metaStr + '</div>' +
'<button class="rbp-add" data-id="' + p.id + '" type="button" aria-label="' + (board[p.id] ? 'Added to room' : 'Add to room') + '">' + (board[p.id] ? '✓ Added' : '+ Add') + '</button></div>';
}
function chips() {
var parts = [];
if (room) parts.push('<span class="rb-chip" data-rmk="room">' + room + ' ✕</span>');
['style', 'hue', 'theme', 'period'].forEach(function (d) { if (sel[d]) parts.push('<span class="rb-chip" data-rmdim="' + d + '">' + sel[d] + ' ✕</span>'); });
Object.keys(swatches).forEach(function (n) { parts.push('<span class="rb-chip" data-rmsw="' + n + '"><span class="sw-dot" style="background:' + swatches[n].hex + '"></span>' + n + ' ✕</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, add pieces from Products on the right →</span>';
}
function query() {
var q = new URLSearchParams();
if (room) q.set('room', room); if (sel.style) q.set('style', sel.style);
var bk = buckets(); if (bk.length) q.set('colors', bk.join(','));
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();
}
// Products panel: infinite scroll. A filter change calls loadProducts() (resets to
// page 0); scrolling near the bottom calls loadMoreProducts() (appends the next page).
var PAGE = 48, prodOffset = 0, prodLoading = false, prodMore = true;
function loadProducts() {
prodOffset = 0; prodMore = true; prodLoading = true;
$('rbProducts').innerHTML = '<p class="empty">Loading…</p>';
fetch('/api/catalog/search?' + query() + '&offset=0').then(function (r) { return r.json(); }).then(function (rows) {
$('rbProducts').innerHTML = rows.length ? rows.map(card).join('') : '<p class="empty">No matches.</p>';
prodMore = rows.length >= PAGE; prodLoading = false; $('rbProducts').scrollTop = 0;
}).catch(function () { prodLoading = false; });
}
function loadMoreProducts() {
if (prodLoading || !prodMore) return;
prodLoading = true; prodOffset += PAGE;
fetch('/api/catalog/search?' + query() + '&offset=' + prodOffset).then(function (r) { return r.json(); }).then(function (rows) {
if (rows.length) $('rbProducts').insertAdjacentHTML('beforeend', rows.map(card).join(''));
prodMore = rows.length >= PAGE; prodLoading = false;
}).catch(function () { prodLoading = false; prodOffset -= PAGE; });
}
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) { 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(); refresh(); renderPieces(); } }
function refresh() { Array.prototype.forEach.call(document.querySelectorAll('.rbp-add'), function (b) { b.textContent = board[b.getAttribute('data-id')] ? '✓ Added' : '+ Add'; }); }
function renderPieces() {
var ids = Object.keys(board), h = $('rbPiecesH'), el = $('rbPieces');
if (!ids.length) { h.hidden = true; el.innerHTML = ''; return; }
h.hidden = false;
el.innerHTML = ids.map(function (id) { var p = board[id];
return '<a class="rb-piece" href="/go/' + id + '" target="_blank" rel="nofollow sponsored">' +
'<img loading="lazy" src="' + p.image_url + '" alt=""><div class="rb-piece-b">' +
'<div class="rb-piece-t">' + (p.title || '').slice(0, 46) + '</div>' +
'<div class="rb-piece-m">' + (p.sale_price ? money(p.sale_price) : money(p.price)) + ' · ' + (p.advertiser || p.brand || '') + '</div></div></a>'; }).join('');
}
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; }
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);
swatches = {}; Array.prototype.forEach.call(document.querySelectorAll('.rb-swatch'), function (b) { b.classList.remove('sel'); });
if (m.color) { var b = document.querySelector('.rb-swatch[data-color="' + m.color + '"]'); if (b) { swatches[b.getAttribute('data-name')] = { bucket: m.color, hex: b.style.backgroundColor || b.style.background }; b.classList.add('sel'); } }
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 rbpc = e.target.closest('.rbp'); if (rbpc) { add(rbpc.getAttribute('data-id')); return; }
var rm = e.target.closest('[data-rm]'); if (rm) { delete board[rm.getAttribute('data-rm')]; chips(); refresh(); renderPieces(); 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 rmsw = e.target.closest('[data-rmsw]'); if (rmsw) { var n = rmsw.getAttribute('data-rmsw'); delete swatches[n]; var sb = document.querySelector('.rb-swatch[data-name="' + n + '"]'); if (sb) sb.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; }
});
// hamburger: open/close the right products panel; maintain aria-expanded state
$('rbHam').addEventListener('click', function () {
$('rbRight').classList.remove('collapsed');
$('rbHam').setAttribute('aria-expanded', 'true');
$('rbHamClose').setAttribute('aria-expanded', 'true');
// Move focus into panel for a11y
var si = $('rbSearch'); if (si) si.focus();
});
$('rbHamClose').addEventListener('click', function () {
$('rbRight').classList.add('collapsed');
$('rbHam').setAttribute('aria-expanded', 'false');
$('rbHamClose').setAttribute('aria-expanded', 'false');
// Return focus to the hamburger trigger
$('rbHam').focus();
});
// drag products into the tray
document.addEventListener('dragstart', function (e) { var c = e.target.closest('.rbp'); if (c) e.dataTransfer.setData('text/id', c.getAttribute('data-id')); });
var tray = $('rbTray');
tray.addEventListener('dragover', function (e) { e.preventDefault(); tray.classList.add('drop'); });
tray.addEventListener('dragleave', function () { tray.classList.remove('drop'); });
tray.addEventListener('drop', function (e) { e.preventDefault(); tray.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(); });
// infinite scroll: load the next page as the products panel nears its bottom
$('rbProducts').addEventListener('scroll', function () {
if (this.scrollTop + this.clientHeight >= this.scrollHeight - 260) loadMoreProducts();
});
// Generate a room setting -> add to the middle gallery
$('rbRenderBtn').addEventListener('click', function () {
var btn = this, cost = $('rbCost'), g = $('rbRenders');
if (g.querySelector('.rb-render-ph')) g.innerHTML = '';
var ph = document.createElement('div');
ph.className = 'rb-gen loading';
ph.setAttribute('role', 'status');
ph.setAttribute('aria-label', 'Generating room setting…');
ph.innerHTML = '<div class="rb-gen-spin">Painting…<small>~10–20 seconds</small></div>';
g.insertBefore(ph, g.firstChild);
btn.disabled = true; btn.setAttribute('aria-busy', 'true'); btn.textContent = 'Generating…';
fetch('/api/render', { method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ style: sel.style, color: Object.keys(swatches).join(' and '), theme: sel.theme, period: sel.period, room_type: room, wall_paint_id: wallPaint && wallPaint.id, product_ids: Object.keys(board).map(Number) }) })
.then(function (r) { return r.json(); }).then(function (d) {
btn.disabled = false; btn.removeAttribute('aria-busy'); btn.textContent = 'Generate room setting';
if (d.url) {
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');
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(); });
});
// hover popup with item details
var pop = document.createElement('div'); pop.className = 'rb-pop'; pop.style.display = 'none'; document.body.appendChild(pop);
document.addEventListener('mouseover', function (e) { var c = e.target.closest('.rbp,.card'); if (!c) return; var p = lastRows[c.getAttribute('data-id')]; if (!p) return;
pop.innerHTML = '<img src="' + (p.image_url || '') + '" alt=""><div class="rb-pop-b"><b>' + (p.title || '') + '</b><div class="rb-pop-p">' + (p.sale_price ? money(p.sale_price) : money(p.price)) + ' · ' + (p.advertiser || p.brand || '') + '</div><div class="rb-pop-t">' + [p.room, p.style, p.color].filter(Boolean).join(' · ') + '</div></div>'; pop.style.display = 'block'; });
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, 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'); }
})
.catch(function () { go.textContent = 'GO ▶'; go.disabled = false; go.removeAttribute('aria-busy'); });
});
loadPaints(); loadBrands(); loadProducts(); chips();
})();