← 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 { '&': '&', '<': '<', '>': '>', '"': '"' }[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 →