← 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
M public/wall-room-viewer.htmlM server.js
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 →