[object Object]

← back to Interiordesignershowroom

feat(moodboard): client-side spend guard on Create Room Setting — two-step confirm ("Generate room ~$0.039? Tap again") + per-session cap of 8 renders, so a public visitor can't silently rack up Gemini spend (server per-IP limit still applies)

28e228ff586af12f0bab27c1c2884084d9f5bc57 · 2026-08-03 08:01:05 -0700 · steve

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

Files touched

Diff

commit 28e228ff586af12f0bab27c1c2884084d9f5bc57
Author: steve <steve@designerwallcoverings.com>
Date:   Mon Aug 3 08:01:05 2026 -0700

    feat(moodboard): client-side spend guard on Create Room Setting — two-step confirm ("Generate room ~$0.039? Tap again") + per-session cap of 8 renders, so a public visitor can't silently rack up Gemini spend (server per-IP limit still applies)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/js/moodboard.js | 24 +++++++++++++++++++++++-
 1 file changed, 23 insertions(+), 1 deletion(-)

diff --git a/public/js/moodboard.js b/public/js/moodboard.js
index c301565..99d17b7 100644
--- a/public/js/moodboard.js
+++ b/public/js/moodboard.js
@@ -9,6 +9,9 @@
 (function () {
   var KEY = 'ids_moodboard';
   var COST = 0.039; // per render, mirrors scene.COST_PER_IMAGE — shown before + after
+  var SESSION_CAP = 8; // client-side spend guard: max renders per browser session (server also rate-limits per-IP)
+  function renderCount() { try { return Number(sessionStorage.getItem('ids_mb_renders') || 0); } catch (e) { return 0; } }
+  function bumpRenderCount() { try { sessionStorage.setItem('ids_mb_renders', String(renderCount() + 1)); } catch (e) {} }
   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(); }
@@ -148,9 +151,27 @@
   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 = ''; }
 
+  // two-step confirm on the make button so a tap can't silently spend ~$0.04.
+  // First tap arms it ("Generate for ~$0.039? Tap again"); second tap within 4s fires.
+  var confirmTimer = null;
+  function armOrFire(btn) {
+    if (renderCount() >= SESSION_CAP) {
+      btn.disabled = true;
+      btn.textContent = 'Session limit reached (' + SESSION_CAP + ' rooms)';
+      var note = drawer.querySelector('.mb-note');
+      if (note) note.textContent = 'You’ve made ' + SESSION_CAP + ' rooms this visit — reload the page to make more. (Keeps render costs in check.)';
+      return;
+    }
+    if (btn.dataset.armed === '1') { clearTimeout(confirmTimer); btn.dataset.armed = ''; return createRoom(); }
+    btn.dataset.armed = '1';
+    btn.textContent = 'Generate room — ~$' + COST.toFixed(3) + '? Tap again';
+    confirmTimer = setTimeout(function () { btn.dataset.armed = ''; btn.textContent = 'Create Room Setting'; }, 4000);
+  }
+
   // ---------- create the room setting (calls the existing /api/render) ----------
   function createRoom() {
     var b = load(); if (!b.length) return;
+    bumpRenderCount();
     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)
@@ -223,7 +244,8 @@
   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();
+    var mk = e.target.closest('.mb-make');
+    if (mk && e.target.closest('.mb-foot') && !e.target.closest('.mb-result')) return armOrFire(mk);
     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'));

← e5ef1c4 feat: shop-time Mood Board — collect pieces while shopping,  ·  back to Interiordesignershowroom  ·  refactor: replace all SELECT * with explicit column lists (l f14bc91 →