[object Object]

← back to Interiordesignershowroom

feat: shop-time Mood Board — collect pieces while shopping, assemble, one button turns them into a photoreal room setting

e5ef1c47e380764e0c8b50686d968475e0cbf582 · 2026-08-03 07:32:34 -0700 · steve

- public/js/moodboard.js: self-contained client feature (mirrors cart.js). Injects a Mood Board button on every product card, floating FAB + slide-in drawer that assembles all selections, a star-Primary toggle, room-type picker, and a Create Room Setting button that POSTs the selected ids to the existing /api/render (Gemini 2.5 Flash Image, ~$0.039) and shows the rendered room + shoppable pieces inline
- lib/render.js: load moodboard.js globally so selection works site-wide (shop, rooms, home, looks)
- Primary piece is sent first so it anchors the room (scene refs the first 4 pieces)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit e5ef1c47e380764e0c8b50686d968475e0cbf582
Author: steve <steve@designerwallcoverings.com>
Date:   Mon Aug 3 07:32:34 2026 -0700

    feat: shop-time Mood Board — collect pieces while shopping, assemble, one button turns them into a photoreal room setting
    
    - public/js/moodboard.js: self-contained client feature (mirrors cart.js). Injects a Mood Board button on every product card, floating FAB + slide-in drawer that assembles all selections, a star-Primary toggle, room-type picker, and a Create Room Setting button that POSTs the selected ids to the existing /api/render (Gemini 2.5 Flash Image, ~$0.039) and shows the rendered room + shoppable pieces inline
    - lib/render.js: load moodboard.js globally so selection works site-wide (shop, rooms, home, looks)
    - Primary piece is sent first so it anchors the room (scene refs the first 4 pieces)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 lib/render.js          |   1 +
 public/js/moodboard.js | 245 +++++++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 246 insertions(+)

diff --git a/lib/render.js b/lib/render.js
index 26c9bb8..7714994 100644
--- a/lib/render.js
+++ b/lib/render.js
@@ -211,6 +211,7 @@ ${disclosureBar()}
   <p style="margin:0 0 6px">${esc(DISCLOSURE_SHORT)}</p>
   <p class="fine">© ${new Date().getFullYear()} ${esc(SITE.name)}. Prices and availability are accurate as of the date/time shown and are subject to change. As an Amazon Associate we earn from qualifying purchases.</p>
 </footer>
+<script src="/js/moodboard.js" defer></script>
 </body>
 </html>`;
 }
diff --git a/public/js/moodboard.js b/public/js/moodboard.js
new file mode 100644
index 0000000..c301565
--- /dev/null
+++ b/public/js/moodboard.js
@@ -0,0 +1,245 @@
+// Mood Board — collect pieces while shopping, then turn the whole board into ONE
+// photoreal room setting on a button push. Distinct from the buy-cart: the cart is
+// "things to purchase, each at its own store"; the mood board is "pieces I'm composing
+// into a room". Pure client-side behavior layer (mirrors cart.js): state in
+// localStorage, styles + FAB + drawer injected here, per-card buttons wired by
+// delegation so infinite-scroll-added cards work too. The heavy lifting (compose the
+// selected products into an Architectural-Digest-grade room) is done server-side by the
+// EXISTING POST /api/render (Gemini 2.5 Flash Image, ~$0.039/render).
+(function () {
+  var KEY = 'ids_moodboard';
+  var COST = 0.039; // per render, mirrors scene.COST_PER_IMAGE — shown before + after
+  function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }
+  function load() { try { return JSON.parse(localStorage.getItem(KEY)) || []; } catch (e) { return []; } }
+  function save(b) { localStorage.setItem(KEY, JSON.stringify(b)); sync(); }
+  function has(id) { return load().some(function (x) { return String(x.id) === String(id); }); }
+  function addItem(it) { var b = load(); if (!b.some(function (x) { return String(x.id) === String(it.id); })) { if (!b.some(function (x) { return x.primary; })) it.primary = true; b.push(it); save(b); } }
+  function removeItem(id) {
+    var b = load().filter(function (x) { return String(x.id) !== String(id); });
+    if (b.length && !b.some(function (x) { return x.primary; })) b[0].primary = true; // keep exactly one primary
+    save(b);
+  }
+  function setPrimary(id) { save(load().map(function (x) { x.primary = String(x.id) === String(id); return x; })); }
+
+  // ---------- injected styles ----------
+  var css = ''
+    // per-card add button (injected into each .card-body)
+    + '.mb-add{margin-top:9px;width:100%;border:1px solid var(--line,#e8e2d9);background:var(--bg,#faf8f5);border-radius:2px;'
+    + 'padding:7px 8px;font:.68rem/1 var(--sans,sans-serif);letter-spacing:.06em;text-transform:uppercase;cursor:pointer;color:#3a3530;'
+    + 'transition:border-color .18s,color .18s,background .18s}'
+    + '.mb-add:hover{border-color:#9b7b50;color:#9b7b50}'
+    + '.mb-add[aria-pressed="true"]{border-color:#9b7b50;color:#9b7b50;background:rgba(155,123,80,.08)}'
+    // floating action button (bottom-left, clear of any bottom-right cart fab)
+    + '.mbfab{position:fixed;left:20px;bottom:20px;z-index:300;display:flex;align-items:center;gap:8px;'
+    + 'background:#16130f;color:#faf8f5;border:0;border-radius:30px;padding:11px 18px;font:500 .8rem/1 var(--sans,sans-serif);'
+    + 'letter-spacing:.04em;cursor:pointer;box-shadow:0 8px 26px rgba(22,19,15,.28);transition:transform .15s,background .2s}'
+    + '.mbfab:hover{transform:translateY(-2px);background:#9b7b50}'
+    + '.mbfab .mbfab-n{display:inline-grid;place-items:center;min-width:19px;height:19px;padding:0 5px;border-radius:11px;'
+    + 'background:#c9a96e;color:#16130f;font-size:.72rem;font-weight:600}'
+    + '.mbfab:not(.has){opacity:.82}'
+    + '.mb-backdrop{position:fixed;inset:0;z-index:340;background:rgba(22,19,15,.42);backdrop-filter:blur(2px)}'
+    // drawer (slides from left)
+    + '.mbdrawer{position:fixed;top:0;left:0;height:100vh;width:400px;max-width:94vw;z-index:350;background:#fff;'
+    + 'border-right:1px solid var(--line,#e8e2d9);box-shadow:10px 0 40px rgba(22,19,15,.18);display:flex;flex-direction:column;'
+    + 'font-family:var(--sans,sans-serif);animation:mbslide .26s cubic-bezier(.22,1,.36,1)}'
+    + '@keyframes mbslide{from{transform:translateX(-20px);opacity:.6}to{transform:none;opacity:1}}'
+    + '.mbdrawer-head{display:flex;align-items:center;justify-content:space-between;padding:16px 18px;border-bottom:1px solid var(--line,#e8e2d9);'
+    + 'font-family:var(--serif,serif);font-size:1.2rem;color:#16130f}'
+    + '.mbdrawer-x{border:0;background:none;font-size:1.1rem;color:#7a7168;cursor:pointer;line-height:1}'
+    + '.mb-scroll{flex:1;overflow-y:auto;padding:12px 16px}'
+    + '.mb-empty{color:#7a7168;font-style:italic;text-align:center;padding:44px 20px;line-height:1.6;font-size:.86rem}'
+    + '.mb-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}'
+    + '.mb-tile{position:relative;border:1px solid var(--line,#e8e2d9);border-radius:6px;overflow:hidden;background:#f4f0e9}'
+    + '.mb-tile.is-primary{border-color:#9b7b50;box-shadow:0 0 0 1px #9b7b50}'
+    + '.mb-tile>img{display:block;width:100%;aspect-ratio:1/1;object-fit:cover}'
+    + '.mb-tile-t{font-size:.68rem;color:#3a3530;line-height:1.3;padding:5px 6px;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}'
+    + '.mb-tile-rm{position:absolute;top:4px;right:4px;width:22px;height:22px;border:0;border-radius:50%;background:rgba(22,19,15,.62);'
+    + 'color:#fff;cursor:pointer;font-size:.8rem;line-height:1;display:grid;place-items:center}'
+    + '.mb-tile-rm:hover{background:#8b3a3a}'
+    + '.mb-star{position:absolute;top:4px;left:4px;border:0;border-radius:20px;padding:2px 7px;font-size:.6rem;letter-spacing:.05em;'
+    + 'text-transform:uppercase;cursor:pointer;background:rgba(255,255,255,.9);color:#7a7168;font-family:var(--sans,sans-serif)}'
+    + '.mb-tile.is-primary .mb-star{background:#9b7b50;color:#fff}'
+    + '.mb-foot{border-top:1px solid var(--line,#e8e2d9);padding:14px 16px;display:flex;flex-direction:column;gap:9px}'
+    + '.mb-room{width:100%;padding:9px;border:1px solid var(--line,#e8e2d9);border-radius:2px;font:.8rem var(--sans,sans-serif);color:#3a3530;background:#fff}'
+    + '.mb-make{background:#16130f;color:#faf8f5;border:0;border-radius:2px;padding:14px;font:500 .82rem/1 var(--sans,sans-serif);'
+    + 'letter-spacing:.08em;text-transform:uppercase;cursor:pointer;transition:background .2s}'
+    + '.mb-make:hover:not(:disabled){background:#9b7b50}'
+    + '.mb-make:disabled{opacity:.55;cursor:not-allowed}'
+    + '.mb-clear{background:none;border:0;color:#7a7168;font-size:.72rem;cursor:pointer;letter-spacing:.03em}'
+    + '.mb-clear:hover{color:#8b3a3a}'
+    + '.mb-note{margin:0;font-size:.66rem;color:#b0a898;line-height:1.5}'
+    // result view (rendered room)
+    + '.mb-result{padding:0 0 8px}'
+    + '.mb-result img.mb-room-img{display:block;width:100%;border-radius:8px;border:1px solid var(--line,#e8e2d9)}'
+    + '.mb-result h4{font-family:var(--serif,serif);font-size:1rem;color:#16130f;margin:16px 2px 8px}'
+    + '.mb-piece{display:flex;gap:10px;align-items:center;padding:7px 2px;border-bottom:1px solid var(--line-lt,#f1ece5);text-decoration:none}'
+    + '.mb-piece img{width:44px;height:44px;object-fit:cover;border-radius:4px;border:1px solid var(--line,#e8e2d9);flex:0 0 44px;background:#f4f0e9}'
+    + '.mb-piece span{font-size:.76rem;color:#3a3530;line-height:1.3}'
+    + '.mb-spin{display:grid;place-items:center;gap:12px;padding:48px 20px;text-align:center;color:#7a7168;font-size:.85rem}'
+    + '.mb-spin .ring{width:34px;height:34px;border:3px solid #e8e2d9;border-top-color:#9b7b50;border-radius:50%;animation:mbspin .8s linear infinite}'
+    + '@keyframes mbspin{to{transform:rotate(360deg)}}'
+    + '.mb-err{color:#8b3a3a;font-size:.8rem;padding:14px 2px;line-height:1.5}'
+    + '.mb-back{background:none;border:0;color:#7a6038;font-size:.72rem;text-transform:uppercase;letter-spacing:.05em;cursor:pointer;padding:0;margin:4px 2px 10px}'
+    + '.mb-back:hover{text-decoration:underline}'
+    + '.mbdrawer[hidden],.mb-backdrop[hidden]{display:none}'
+    + '@media(max-width:520px){.mbfab{left:14px;bottom:14px}.mb-grid{grid-template-columns:1fr 1fr}}';
+  var st = document.createElement('style'); st.textContent = css; document.head.appendChild(st);
+
+  // ---------- FAB + drawer scaffold ----------
+  var fab = document.createElement('button');
+  fab.className = 'mbfab'; fab.type = 'button'; fab.setAttribute('aria-label', 'Open your mood board');
+  fab.innerHTML = '🎨 Mood Board <span class="mbfab-n">0</span>';
+  var backdrop = document.createElement('div'); backdrop.className = 'mb-backdrop'; backdrop.hidden = true;
+  var drawer = document.createElement('div'); drawer.className = 'mbdrawer'; drawer.hidden = true;
+  drawer.setAttribute('role', 'dialog'); drawer.setAttribute('aria-label', 'Your mood board');
+  drawer.innerHTML = '<div class="mbdrawer-head">Mood Board <button class="mbdrawer-x" type="button" aria-label="Close">✕</button></div>'
+    + '<div class="mb-scroll"></div>';
+  document.body.appendChild(fab); document.body.appendChild(backdrop); document.body.appendChild(drawer);
+
+  var ROOMS = ['Living Room', 'Bedroom', 'Dining Room', 'Home Office', 'Entryway', 'Kitchen', 'Bathroom', 'Nursery'];
+  var lastRender = null; // holds the current room result so it survives re-open until cleared
+
+  // ---------- drawer rendering: assemble view vs result view ----------
+  function renderScroll() {
+    var scroll = drawer.querySelector('.mb-scroll');
+    if (lastRender) return renderResult(scroll);
+    var b = load();
+    if (!b.length) { scroll.innerHTML = '<p class="mb-empty">Your mood board is empty.<br>While you shop, tap <strong>◇ Mood Board</strong> on any piece to collect it here, then turn the whole board into a room.</p>'; return; }
+    var roomOpts = ROOMS.map(function (r) { return '<option value="' + esc(r) + '">' + esc(r) + '</option>'; }).join('');
+    scroll.innerHTML =
+      '<div class="mb-grid">' + b.map(function (x) {
+        return '<div class="mb-tile' + (x.primary ? ' is-primary' : '') + '" data-id="' + esc(x.id) + '">'
+          + '<button class="mb-star" type="button" data-star="' + esc(x.id) + '" aria-label="Make this the primary piece">' + (x.primary ? '★ Primary' : '☆ Set primary') + '</button>'
+          + '<button class="mb-tile-rm" type="button" data-rm="' + esc(x.id) + '" aria-label="Remove from mood board">✕</button>'
+          + '<img src="' + esc(x.image) + '" alt="" onerror="this.style.visibility=\'hidden\'">'
+          + '<div class="mb-tile-t">' + esc(x.title) + '</div></div>';
+      }).join('') + '</div>'
+      + '<div class="mb-foot">'
+      + '<select class="mb-room" aria-label="Room type for the setting"><option value="">Room type (optional)…</option>' + roomOpts + '</select>'
+      + '<button class="mb-make" type="button">Create Room Setting</button>'
+      + '<p class="mb-note">Composes your ' + b.length + ' piece' + (b.length === 1 ? '' : 's') + ' into one photoreal room — your ★ primary piece anchors it. Est. cost ~$' + COST.toFixed(3) + ' per room.</p>'
+      + '<button class="mb-clear" type="button">Clear board</button>'
+      + '</div>';
+  }
+
+  function renderResult(scroll) {
+    var r = lastRender;
+    if (r.loading) {
+      scroll.innerHTML = '<div class="mb-spin"><div class="ring"></div><div>Designing your room setting…<br><small>Placing your pieces into one photoreal space (~15–30s).</small></div></div>';
+      return;
+    }
+    if (r.error) {
+      scroll.innerHTML = '<button class="mb-back" type="button">← Back to board</button><div class="mb-err">' + esc(r.error) + '</div>';
+      return;
+    }
+    var pieces = (r.products || []).map(function (p) {
+      return '<a class="mb-piece" href="/go/' + esc(p.id) + '" target="_blank" rel="nofollow sponsored">'
+        + '<img src="' + esc(p.image_url) + '" alt="" onerror="this.style.visibility=\'hidden\'">'
+        + '<span>' + esc(p.title) + (p.advertiser ? ' — ' + esc(p.advertiser) : '') + '</span></a>';
+    }).join('');
+    scroll.innerHTML = '<button class="mb-back" type="button">← Back to board</button>'
+      + '<div class="mb-result"><img class="mb-room-img" src="' + esc(r.url) + '" alt="Your generated room setting">'
+      + '<div class="mb-foot" style="border:0;padding:12px 0 0">'
+      + '<a class="mb-make" style="text-align:center;text-decoration:none" href="' + esc(r.url) + '" download="room-setting.png">Download image</a>'
+      + '<p class="mb-note">Rendered for ~$' + (r.cost || COST).toFixed(3) + '. As an affiliate we earn from qualifying purchases.</p></div>'
+      + (pieces ? '<h4>Shop the pieces in this room</h4>' + pieces : '') + '</div>';
+  }
+
+  function open() { renderScroll(); backdrop.hidden = false; drawer.hidden = false; document.body.style.overflow = 'hidden'; }
+  function close() { drawer.hidden = true; backdrop.hidden = true; document.body.style.overflow = ''; }
+
+  // ---------- create the room setting (calls the existing /api/render) ----------
+  function createRoom() {
+    var b = load(); if (!b.length) return;
+    var roomSel = drawer.querySelector('.mb-room');
+    var room_type = roomSel ? roomSel.value : '';
+    // primary piece first so it becomes the dominant reference (scene refs the first 4)
+    var ordered = b.slice().sort(function (a, c) { return (c.primary ? 1 : 0) - (a.primary ? 1 : 0); });
+    var product_ids = ordered.map(function (x) { return Number(x.id); }).filter(Boolean).slice(0, 12);
+    lastRender = { loading: true };
+    renderScroll();
+    fetch('/api/render', {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ product_ids: product_ids, room_type: (room_type || '').toLowerCase().replace(/\s+/g, '_') }),
+    }).then(function (res) {
+      if (res.status === 429) return res.json().then(function (j) { throw new Error(j.error || 'Render limit reached — try again shortly.'); });
+      if (!res.ok) return res.json().then(function (j) { throw new Error(j.error || 'Could not create the room.'); }).catch(function () { throw new Error('Could not create the room (' + res.status + ').'); });
+      return res.json();
+    }).then(function (j) {
+      lastRender = { url: j.url, cost: j.cost, products: j.products || [] };
+      renderScroll();
+    }).catch(function (e) {
+      lastRender = { error: e.message || 'Something went wrong. Please try again.' };
+      renderScroll();
+    });
+  }
+
+  // ---------- derive a mood-board item straight from a product card ----------
+  function itemFromCard(card) {
+    var id = card.getAttribute('data-id'); if (!id) return null;
+    var img = card.querySelector('.card-img img');
+    var brand = card.querySelector('.card-brand');
+    return {
+      id: id,
+      title: card.getAttribute('data-title') || (card.querySelector('.card-title') || {}).textContent || '',
+      price: card.getAttribute('data-price') || '',
+      image: (img && (img.getAttribute('src') || img.src)) || '',
+      advertiser: (card.getAttribute('data-network') || (brand ? brand.textContent : '') || '').trim(),
+      url: '/go/' + id,
+      primary: false,
+    };
+  }
+
+  // inject an "add to mood board" button into each product card (once)
+  function decorateCards() {
+    document.querySelectorAll('article.card[data-id]').forEach(function (card) {
+      if (card.querySelector('.mb-add')) return;
+      var body = card.querySelector('.card-body') || card;
+      var btn = document.createElement('button');
+      btn.className = 'mb-add'; btn.type = 'button';
+      btn.setAttribute('data-mb-id', card.getAttribute('data-id'));
+      body.appendChild(btn);
+      syncBtn(btn);
+    });
+  }
+  function syncBtn(btn) {
+    var on = has(btn.getAttribute('data-mb-id'));
+    btn.setAttribute('aria-pressed', on ? 'true' : 'false');
+    btn.textContent = on ? '✓ On mood board' : '◇ Mood Board';
+  }
+
+  function sync() {
+    var n = load().length;
+    fab.querySelector('.mbfab-n').textContent = n;
+    fab.classList.toggle('has', n > 0);
+    document.querySelectorAll('.mb-add').forEach(syncBtn);
+    if (!drawer.hidden) renderScroll();
+  }
+
+  // ---------- events ----------
+  fab.addEventListener('click', open);
+  backdrop.addEventListener('click', close);
+  document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && !drawer.hidden) close(); });
+  drawer.addEventListener('click', function (e) {
+    if (e.target.closest('.mbdrawer-x')) return close();
+    if (e.target.closest('.mb-back')) { lastRender = null; return renderScroll(); }
+    if (e.target.closest('.mb-make') && e.target.closest('.mb-foot') && !e.target.closest('.mb-result')) return createRoom();
+    if (e.target.closest('.mb-clear')) { lastRender = null; save([]); return; }
+    var star = e.target.closest('[data-star]'); if (star) return setPrimary(star.getAttribute('data-star'));
+    var rm = e.target.closest('[data-rm]'); if (rm) return removeItem(rm.getAttribute('data-rm'));
+  });
+  drawer.addEventListener('change', function (e) { /* room select — read at create time */ });
+  // per-card add/remove (delegated so infinite-scroll cards work)
+  document.addEventListener('click', function (e) {
+    var b = e.target.closest('.mb-add'); if (!b) return;
+    e.preventDefault();
+    var card = b.closest('article.card'); if (!card) return;
+    var id = b.getAttribute('data-mb-id');
+    if (has(id)) removeItem(id);
+    else { var it = itemFromCard(card); if (it) addItem(it); }
+  });
+
+  // keep buttons decorated + in sync as cards stream in (infinite scroll / filters)
+  var t; new MutationObserver(function () { clearTimeout(t); t = setTimeout(function () { decorateCards(); sync(); }, 150); }).observe(document.body, { childList: true, subtree: true });
+  decorateCards(); sync();
+})();

← 713ee0d fix(render): match bigint product ids type-safe — /api/rende  ·  back to Interiordesignershowroom  ·  feat(moodboard): client-side spend guard on Create Room Sett 28e228f →