← 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, '&quot;'); };
      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();
})();