[object Object]

← back to Wallco Ai

wall-viewer: ROOM_API render as sign-in upsell. /api/room now requires admin OR trade-user (anonymous → 401 sign_in_required); requested_by_user_id stamped on wallco_rooms for attribution. Frontend: 'Try in your room' CTA + 4 room-type chips + render overlay + reset-back-to-wall pill; postMessage to parent /design/:id for trade-login redirect; replaces tiled wall preview w/ photoreal render in place

86dd47e5bb89f58943bbd3b11d5a04fa0b1ea2c4 · 2026-05-28 10:28:59 -0700 · Steve Abrams

Files touched

Diff

commit 86dd47e5bb89f58943bbd3b11d5a04fa0b1ea2c4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 28 10:28:59 2026 -0700

    wall-viewer: ROOM_API render as sign-in upsell. /api/room now requires admin OR trade-user (anonymous → 401 sign_in_required); requested_by_user_id stamped on wallco_rooms for attribution. Frontend: 'Try in your room' CTA + 4 room-type chips + render overlay + reset-back-to-wall pill; postMessage to parent /design/:id for trade-login redirect; replaces tiled wall preview w/ photoreal render in place
---
 public/wall-room-viewer.html | 149 +++++++++++++++++++++++++++++++++++++++++++
 server.js                    |  46 ++++++++++++-
 2 files changed, 192 insertions(+), 3 deletions(-)

diff --git a/public/wall-room-viewer.html b/public/wall-room-viewer.html
index d3850a5..2dbe4a2 100644
--- a/public/wall-room-viewer.html
+++ b/public/wall-room-viewer.html
@@ -53,6 +53,34 @@
   .roomtag{position:absolute;bottom:8px;left:8px;color:var(--mut);font:11px ui-monospace,monospace;background:rgba(255,255,255,.78);padding:3px 7px;border-radius:4px;}
 
   .empty{padding:80px 20px;text-align:center;color:var(--mut);}
+
+  /* Upsell card */
+  .upsell{display:flex;gap:20px;align-items:center;justify-content:space-between;
+       padding:18px 22px;background:linear-gradient(180deg,#f4eccb22,transparent);
+       border:1px solid var(--accent);border-radius:12px;margin-bottom:14px;flex-wrap:wrap;}
+  .upsell-left{flex:1;min-width:240px;}
+  .up-h{font:700 15px/1.2 inherit;color:var(--ink);margin-bottom:4px;}
+  .up-sub{font:13px/1.45 inherit;color:var(--mut);}
+  .upsell-right{display:flex;flex-direction:column;gap:8px;align-items:flex-end;}
+  .roomchips{display:flex;gap:6px;flex-wrap:wrap;justify-content:flex-end;}
+  .roomchips button{font:600 12px inherit;padding:6px 11px;border-radius:999px;
+       border:1px solid var(--line);background:#fff;color:var(--mut);cursor:pointer;}
+  .roomchips button.on{background:var(--ink);color:#fff;border-color:var(--ink);}
+  .cta{font:700 14px inherit;padding:10px 22px;border-radius:9px;
+       background:var(--accent);color:#1a1a1a;border:1px solid var(--accent);
+       cursor:pointer;box-shadow:0 2px 10px rgba(210,177,92,.35);transition:transform .1s;}
+  .cta:hover{transform:translateY(-1px);}
+  .cta:disabled{opacity:.5;cursor:wait;}
+
+  /* Render overlay */
+  .rendering{position:absolute;inset:0;background:rgba(0,0,0,.7);color:#fff;
+       display:flex;flex-direction:column;align-items:center;justify-content:center;
+       gap:8px;z-index:10;font:13px inherit;text-align:center;padding:20px;}
+  .rendering .spin{width:32px;height:32px;border:3px solid #ffffff44;
+       border-top-color:#fff;border-radius:50%;animation:spin 1s linear infinite;}
+  @keyframes spin{to{transform:rotate(360deg);}}
+  .reset-link{position:absolute;top:8px;right:8px;background:rgba(0,0,0,.7);color:#fff;
+       border:0;padding:5px 10px;border-radius:6px;font:600 11px inherit;cursor:pointer;z-index:11;}
 </style>
 </head>
 <body>
@@ -77,6 +105,25 @@
       </div>
     </div>
 
+    <!-- Sign-in-gated photoreal room upsell. Free wall-preview is above; this
+         hits the upstream ROOM_API (~$0.10/call) so we require a trade-user
+         identity. Anonymous click → postMessage parent → /trade/login. -->
+    <div class="upsell" id="upsell">
+      <div class="upsell-left">
+        <div class="up-h">🪑 Try this pattern in YOUR room — for free</div>
+        <div class="up-sub">Sign in to render a photoreal scene (bedroom · living room · dining · office) with this exact pattern at the scale you've selected.</div>
+      </div>
+      <div class="upsell-right">
+        <div class="roomchips" id="roomchips">
+          <button data-rt="bedroom"     class="on">🛏 Bedroom</button>
+          <button data-rt="living_room">🛋 Living room</button>
+          <button data-rt="dining_room">🍷 Dining</button>
+          <button data-rt="office">     💼 Office</button>
+        </div>
+        <button class="cta" id="tryRoom">Try in your room →</button>
+      </div>
+    </div>
+
     <div class="stage">
       <div class="room" id="room">
         <div class="repeats" id="repeats"></div>
@@ -182,6 +229,108 @@ $('wallSlider').addEventListener('input', e => {
 });
 addEventListener('resize', applyScale);
 
+// ── Photoreal room upsell ──────────────────────────────────────────────────
+// Free wall-preview is above. This button hits the upstream renderer
+// (/api/room → ROOM_API) which is $/call — so it's trade-user gated. If the
+// user isn't signed in, we postMessage the parent /design/:id page so it can
+// navigate to /trade/login. If we're standalone (no parent), navigate the
+// top window directly.
+let activeRoomType = 'bedroom';
+document.querySelectorAll('#roomchips button').forEach(b => {
+  b.addEventListener('click', () => {
+    activeRoomType = b.dataset.rt;
+    document.querySelectorAll('#roomchips button')
+      .forEach(x => x.classList.toggle('on', x === b));
+  });
+});
+
+async function checkAuth(){
+  try {
+    const r = await fetch('/api/trade/me', { credentials: 'same-origin' });
+    if (!r.ok) return { authenticated: false };
+    return await r.json();
+  } catch { return { authenticated: false }; }
+}
+
+function sendToSignIn(){
+  // Tell the parent page (the iframe host) to navigate to trade-login. Returns
+  // the user to the SAME design page after sign-in. Falls back to top-window
+  // navigation if there's no parent (standalone use).
+  const returnUrl = (window.top && window.top !== window)
+    ? window.top.location.pathname + window.top.location.search
+    : `/design/${DESIGN_ID}`;
+  const signInUrl = '/trade/login?return=' + encodeURIComponent(returnUrl);
+  try {
+    if (window.parent && window.parent !== window) {
+      window.parent.postMessage({ type: 'wallco-signin-redirect', url: signInUrl }, '*');
+    }
+  } catch {}
+  // Fallback: navigate top directly (works if same-origin)
+  try { window.top.location.href = signInUrl; }
+  catch { window.location.href = signInUrl; }
+}
+
+async function tryRoom(){
+  const btn = $('tryRoom');
+  const room = $('room');
+  // Auth check first
+  const me = await checkAuth();
+  if (!me || !me.authenticated) {
+    sendToSignIn();
+    return;
+  }
+  // Render overlay
+  const orig = btn.textContent;
+  btn.disabled = true; btn.textContent = '🪑 rendering…';
+  let ov = document.createElement('div');
+  ov.className = 'rendering';
+  ov.innerHTML = `<div class="spin"></div>
+    <div>🪑 rendering a ${activeRoomType.replace('_',' ')} at ${scaleInches}″ repeat…</div>
+    <div style="opacity:.7;font-size:11.5px">photoreal renderer · ~15-30s</div>`;
+  room.appendChild(ov);
+
+  try {
+    const r = await fetch('/api/room', {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      credentials: 'same-origin',
+      body: JSON.stringify({
+        design_id: DESIGN_ID,
+        roomType: activeRoomType,
+        patternWidth: scaleInches,
+        patternHeight: scaleInches,
+      }),
+    });
+    const j = await r.json();
+    if (r.status === 401 || (j && j.error === 'sign_in_required')) { sendToSignIn(); return; }
+    if (!r.ok || !j.ok || !j.image_url) throw new Error((j && (j.error||j.message)) || ('HTTP '+r.status));
+
+    // Replace the tiled wall preview with the photoreal render. Add a "← reset"
+    // pill that restores the wall preview so the buyer can A/B compare.
+    room.style.backgroundImage = `url(${j.image_url})`;
+    room.style.backgroundSize = 'cover';
+    room.style.backgroundPosition = 'center';
+    $('repeats').innerHTML = '';
+    $('repbadge').textContent = '🪑 ' + activeRoomType.replace('_',' ');
+    $('roomtag').textContent = `photoreal ${activeRoomType.replace('_',' ')} · ${scaleInches}″ pattern repeat`;
+
+    if (!document.getElementById('reset')) {
+      const reset = document.createElement('button');
+      reset.id = 'reset'; reset.className = 'reset-link';
+      reset.textContent = '← back to wall preview';
+      reset.onclick = () => { reset.remove(); applyScale(); };
+      document.querySelector('.stage').appendChild(reset);
+    }
+  } catch (e) {
+    ov.innerHTML = `⚠ render failed: ${e.message}<br><span style="font-size:11px;opacity:.8">try again in a moment — sometimes the renderer is busy</span>`;
+    setTimeout(() => ov.remove(), 5000);
+  } finally {
+    btn.disabled = false; btn.textContent = orig;
+    setTimeout(() => ov.remove(), 200);
+  }
+}
+$('tryRoom').addEventListener('click', tryRoom);
+
 // Boot
 if (Number.isFinite(DESIGN_ID) && DESIGN_ID >= 1) applyScale();
 </script>
diff --git a/server.js b/server.js
index 1ee5caf..a90775c 100644
--- a/server.js
+++ b/server.js
@@ -3305,6 +3305,18 @@ ensureTradeUserTables();
 // the spoon_all_designs view (which enumerates this column). Target the
 // base table all_designs — spoon_all_designs is a VIEW and ALTER/CREATE
 // INDEX on a view throws every server boot.
+// Add the requested_by_user_id column to wallco_rooms — used by the customer-
+// facing wall viewer's "Try in your room" upsell flow so we can attribute each
+// ROOM_API render (~$0.10/call) back to the trade user who triggered it.
+// Idempotent.
+function ensureRoomsUserIdColumn() {
+  try {
+    psqlQuery(`ALTER TABLE wallco_rooms ADD COLUMN IF NOT EXISTS requested_by_user_id BIGINT;`);
+    psqlQuery(`CREATE INDEX IF NOT EXISTS wallco_rooms_user_idx ON wallco_rooms(requested_by_user_id) WHERE requested_by_user_id IS NOT NULL;`);
+  } catch (e) { console.error('[migrate] ensureRoomsUserIdColumn:', e.message); }
+}
+ensureRoomsUserIdColumn();
+
 function ensureUserRemovedColumn() {
   try {
     psqlQuery(`ALTER TABLE all_designs ADD COLUMN IF NOT EXISTS user_removed BOOLEAN NOT NULL DEFAULT FALSE;`);
@@ -16212,9 +16224,21 @@ ${(() => {
       <span style="font:11px/1.4 ui-sans-serif,system-ui,sans-serif;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-faint,#6e6e68);">drag the slider — try 24″ / 36″ / 54″ repeats</span>
     </div>
     <iframe src="/wall-room-viewer.html?id=${design.id}"
-            style="width:100%;height:780px;border:1px solid var(--rule,#e5e5e0);border-radius:10px;display:block;background:#fafaf7"
+            style="width:100%;height:880px;border:1px solid var(--rule,#e5e5e0);border-radius:10px;display:block;background:#fafaf7"
             loading="lazy"
             title="Wall preview at scale"></iframe>
+    <script>
+      // The wall viewer iframe sends a postMessage when an anonymous user
+      // clicks "Try in your room" — we redirect THIS page to the trade-login,
+      // preserving return-to. Origin checked to our own origin only.
+      window.addEventListener('message', function(e) {
+        if (e.origin !== location.origin) return;
+        if (!e.data || e.data.type !== 'wallco-signin-redirect') return;
+        if (typeof e.data.url === 'string' && e.data.url.startsWith('/trade/login')) {
+          location.href = e.data.url;
+        }
+      });
+    </script>
   </section>
 
   <!-- Generated Rooms gallery — hydrates from /api/rooms?design_id={{id}} -->
@@ -18230,6 +18254,21 @@ function localPathForDesign(d) {
 
 // ── POST /api/room — generate room visualization for a design
 app.post('/api/room', async (req, res) => {
+  // Auth: admin (loopback/JWT/?admin=TOKEN) OR signed-in trade user. The
+  // upstream renderer is $/call (~$0.10 per Replicate), so we gate on identity
+  // — gives customers a clear sign-in upsell + lets us trace volume back to a
+  // user. Pre-existing internal callers (admin curators) keep working since
+  // they pass through isAdmin().
+  const _admin = isAdmin(req);
+  const _tradeUser = _admin ? null : getTradeUser(req);
+  if (!_admin && !_tradeUser) {
+    return res.status(401).json({
+      ok: false,
+      error: 'sign_in_required',
+      message: 'Sign in to render this design in your room.',
+      sign_in_url: '/trade/login?return=' + encodeURIComponent(req.headers.referer || '/'),
+    });
+  }
   const { design_id, roomType = 'bedroom', angle = 'straight_on',
           cameraDistance = 5 } = req.body || {};
   let { patternWidth = 27, patternHeight = 27 } = req.body || {};
@@ -18315,10 +18354,11 @@ app.post('/api/room', async (req, res) => {
     try {
       const row = psqlQuery(`INSERT INTO wallco_rooms
         (design_id, room_type, angle, camera_distance, pattern_width, pattern_height,
-         filename, image_path, file_size_bytes, c2pa_signed)
+         filename, image_path, file_size_bytes, c2pa_signed, requested_by_user_id)
         VALUES (${pgEsc(parseInt(design_id, 10))}, ${pgEsc(roomType)}, ${pgEsc(angle)},
                 ${pgEsc(Number(cameraDistance))}, ${pgEsc(Number(patternWidth))}, ${pgEsc(Number(patternHeight))},
-                ${pgEsc(fname)}, ${pgEsc(image_url)}, ${pgEsc(buf.length)}, TRUE)
+                ${pgEsc(fname)}, ${pgEsc(image_url)}, ${pgEsc(buf.length)}, TRUE,
+                ${_tradeUser ? pgEsc(_tradeUser.id) : 'NULL'})
         RETURNING id;`);
       room_id = parseInt(row, 10) || null;
     } catch (e) {

← 557b826 front-end: bring wall+room viewer to customer-facing /design  ·  back to Wallco Ai  ·  design grid: universal hover-preview for the ~93% of cards w a5228db →