[object Object]

← back to Wallco Ai

front-end: bring wall+room viewer to customer-facing /design/:id (iframe public/wall-room-viewer.html w/ 24/36/54 repeat pills + wall-width slider 96-240in + repeat-count + pinstripes + ft+in rulers). Add hidden admin-nav block to public hamburger w/ links to all curator tools; /api/admin/whoami probe unhides it for admin requests. Same wall/room tech now visible on both front (customers, no API cost) and back (admins, full curator)

557b826c35ad1e5e90857a13ed86a4edeadf62af · 2026-05-28 10:22:32 -0700 · Steve Abrams

Files touched

Diff

commit 557b826c35ad1e5e90857a13ed86a4edeadf62af
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 28 10:22:32 2026 -0700

    front-end: bring wall+room viewer to customer-facing /design/:id (iframe public/wall-room-viewer.html w/ 24/36/54 repeat pills + wall-width slider 96-240in + repeat-count + pinstripes + ft+in rulers). Add hidden admin-nav block to public hamburger w/ links to all curator tools; /api/admin/whoami probe unhides it for admin requests. Same wall/room tech now visible on both front (customers, no API cost) and back (admins, full curator)
---
 public/wall-room-viewer.html | 189 +++++++++++++++++++++++++++++++++++++++++++
 server.js                    |  35 ++++++++
 2 files changed, 224 insertions(+)

diff --git a/public/wall-room-viewer.html b/public/wall-room-viewer.html
new file mode 100644
index 0000000..d3850a5
--- /dev/null
+++ b/public/wall-room-viewer.html
@@ -0,0 +1,189 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Wall preview — wallco.ai</title>
+<style>
+  /* Customer-facing wall+repeat preview. Ported from the admin drunk-curator's
+     room-scale scene minus the curator-only bits (Furniture render, Extract,
+     mark-misaligned boxes). Standalone — designed to drop into the /design/:id
+     page either inline or via iframe; ?id=<design_id> picks the pattern. */
+  :root{--bg:#fafaf7;--panel:#fff;--line:#e5e0d4;--ink:#1c1b18;--mut:#6a6357;
+        --accent:#d2b15c;--accent-soft:#f4eccb;}
+  *{box-sizing:border-box;}
+  html,body{margin:0;background:var(--bg);color:var(--ink);
+       font:14px/1.45 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;}
+  .frame{max-width:1080px;margin:0 auto;padding:18px;}
+  .controls{display:flex;gap:18px;align-items:center;flex-wrap:wrap;
+       padding:12px 14px;background:var(--panel);border:1px solid var(--line);
+       border-radius:10px;margin-bottom:14px;}
+  .ctl{display:flex;flex-direction:column;gap:4px;}
+  .ctl .lab{font:600 11.5px/1 inherit;color:var(--mut);text-transform:uppercase;letter-spacing:.06em;}
+  .ctl .val{font:600 13px/1 inherit;color:var(--ink);font-variant-numeric:tabular-nums;}
+  input[type=range]{accent-color:var(--accent);width:200px;}
+  .pillset{display:flex;gap:4px;}
+  .pillset button{font:700 12px inherit;padding:6px 12px;border-radius:7px;
+       border:1px solid var(--line);background:#fff;color:var(--ink);cursor:pointer;}
+  .pillset button.on{background:var(--accent);color:#1a1a1a;border-color:var(--accent);}
+  .pillset button:hover:not(.on){border-color:var(--accent);}
+  .info{margin-left:auto;font-size:12.5px;color:var(--mut);}
+  .info b{color:var(--ink);font-variant-numeric:tabular-nums;}
+
+  .stage{position:relative;background:var(--panel);border:1px solid var(--line);
+       border-radius:12px;padding:32px 40px 40px;}
+  .room{position:relative;margin:0 auto;background-color:#f0ece2;
+       background-repeat:repeat;background-position:left top;
+       border:1px solid #c8c0ad;box-shadow:0 12px 36px rgba(0,0,0,.06) inset;}
+  .repeats{position:absolute;inset:0;pointer-events:none;}
+  .repeats .vline{position:absolute;top:0;bottom:0;width:1px;
+       background:rgba(0,0,0,.35);box-shadow:0 0 0 .5px rgba(255,255,255,.4);}
+  .repeats .vline.edge{background:rgba(0,0,0,.55);width:2px;}
+  .repbadge{position:absolute;top:8px;right:8px;background:rgba(0,0,0,.78);
+       color:#fff;font:700 11px ui-monospace,monospace;padding:4px 8px;border-radius:999px;}
+  /* room rulers in feet+inches */
+  .rh,.rv{position:absolute;color:var(--mut);font:10.5px ui-monospace,monospace;}
+  .rh{left:0;height:14px;bottom:-18px;}
+  .rv{top:0;width:24px;left:-30px;}
+  .rt{position:absolute;background:var(--mut);}
+  .rh .rt{top:0;width:1px;height:5px;}  .rh .rt.maj{height:9px;background:#000;}
+  .rv .rt{left:0;height:1px;width:5px;}  .rv .rt.maj{width:9px;background:#000;}
+  .rh .rl{position:absolute;top:6px;transform:translateX(-50%);white-space:nowrap;}
+  .rv .rl{position:absolute;left:12px;transform:translateY(-50%);white-space:nowrap;}
+  .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);}
+</style>
+</head>
+<body>
+<div class="frame">
+  <div id="err" class="empty" style="display:none">No design — append <code>?id=&lt;design&gt;</code> to the URL.</div>
+  <div id="root" style="display:none">
+    <div class="controls">
+      <div class="ctl">
+        <span class="lab">Repeat width</span>
+        <div class="pillset" id="scalepills">
+          <button data-w="24">24″</button>
+          <button data-w="36" class="on">36″</button>
+          <button data-w="54">54″</button>
+        </div>
+      </div>
+      <div class="ctl" style="min-width:230px">
+        <span class="lab">Wall width <span class="val" id="wallval">13.5′</span></span>
+        <input type="range" id="wallSlider" min="96" max="240" step="6" value="162">
+      </div>
+      <div class="info">
+        <b id="repCount">—</b> repeats across · pattern tile <b id="repInches">36</b>″ wide
+      </div>
+    </div>
+
+    <div class="stage">
+      <div class="room" id="room">
+        <div class="repeats" id="repeats"></div>
+        <div class="repbadge" id="repbadge">—</div>
+        <div class="roomtag" id="roomtag"></div>
+      </div>
+      <div class="rh" id="roomRulerH"></div>
+      <div class="rv" id="roomRulerV"></div>
+    </div>
+  </div>
+</div>
+
+<script>
+const params = new URLSearchParams(location.search);
+const DESIGN_ID = parseInt(params.get('id'), 10);
+if (!Number.isFinite(DESIGN_ID) || DESIGN_ID < 1) {
+  document.getElementById('err').style.display = 'block';
+} else {
+  document.getElementById('root').style.display = 'block';
+}
+
+const SCALE_WIDTHS = [24, 36, 54];   // standard wallpaper / panel widths
+let scaleInches  = parseInt(localStorage.getItem('wrv_scale') || '36', 10);
+if (!SCALE_WIDTHS.includes(scaleInches)) scaleInches = 36;
+let wallInches   = Math.min(240, Math.max(96, parseInt(localStorage.getItem('wrv_wall') || '162', 10)));
+
+const $ = id => document.getElementById(id);
+const fmtFt = inches => {
+  const f = Math.floor(inches / 12), i = Math.round(inches % 12);
+  return i ? `${f}′${i}″` : `${f}′`;
+};
+
+function applyScale(){
+  // Set the room canvas dims (square): width follows the viewport; cap at 900.
+  const stage = document.querySelector('.stage');
+  const roomW = Math.min(900, stage.clientWidth - 80);
+  const room = $('room');
+  room.style.width  = roomW + 'px';
+  room.style.height = roomW + 'px';
+  room.style.backgroundImage = `url(/designs/img/by-id/${DESIGN_ID})`;
+  // backgroundSize = repeat width / wall width × 100 (one pattern repeat as %
+  // of the wall). Smaller repeat = smaller % = more repeats fitting on the wall.
+  const sizePct = (scaleInches / wallInches * 100).toFixed(2);
+  room.style.backgroundSize = sizePct + '% auto';
+
+  // Pinstripe lines at each repeat boundary
+  const ppi = roomW / wallInches;
+  const repPx = scaleInches * ppi;
+  const nRep = wallInches / scaleInches;
+  const rep = $('repeats');
+  let s = '';
+  for (let x = 0; x <= roomW + 0.5; x += repPx) {
+    const edge = (x < 1 || x > roomW - 1);
+    s += `<div class="vline${edge ? ' edge' : ''}" style="left:${x.toFixed(1)}px"></div>`;
+  }
+  rep.innerHTML = s;
+  $('repbadge').textContent = `↔ ${nRep.toFixed(nRep % 1 ? 1 : 0)} × ${scaleInches}″`;
+  $('repCount').textContent = nRep.toFixed(nRep % 1 ? 1 : 0);
+  $('repInches').textContent = scaleInches;
+  $('roomtag').textContent = `${fmtFt(wallInches)} × ${fmtFt(wallInches)} wall · ${scaleInches}″ repeat`;
+  $('wallval').textContent = fmtFt(wallInches);
+
+  // Pill highlight
+  document.querySelectorAll('#scalepills button')
+    .forEach(b => b.classList.toggle('on', +b.dataset.w === scaleInches));
+
+  // Rulers in feet+inches (tick every 6", labels at every 12")
+  const rh = $('roomRulerH');
+  rh.style.width = roomW + 'px';
+  rh.style.left  = (room.offsetLeft) + 'px';
+  let rhs = '';
+  for (let inch = 0; inch <= wallInches; inch += 6) {
+    const maj = inch % 12 === 0, x = (inch * ppi).toFixed(1);
+    rhs += `<div class="rt${maj ? ' maj' : ''}" style="left:${x}px"></div>`;
+    if (maj) rhs += `<div class="rl" style="left:${x}px">${fmtFt(inch)}</div>`;
+  }
+  rh.innerHTML = rhs;
+  const rv = $('roomRulerV');
+  rv.style.height = roomW + 'px';
+  rv.style.top    = (room.offsetTop) + 'px';
+  let rvs = '';
+  for (let inch = 0; inch <= wallInches; inch += 6) {
+    const maj = inch % 12 === 0, y = (inch * ppi).toFixed(1);
+    rvs += `<div class="rt${maj ? ' maj' : ''}" style="top:${y}px"></div>`;
+    if (maj) rvs += `<div class="rl" style="top:${y}px">${fmtFt(inch)}</div>`;
+  }
+  rv.innerHTML = rvs;
+}
+
+// Wire controls
+document.querySelectorAll('#scalepills button').forEach(b => {
+  b.addEventListener('click', () => {
+    scaleInches = +b.dataset.w;
+    localStorage.setItem('wrv_scale', scaleInches);
+    applyScale();
+  });
+});
+$('wallSlider').value = wallInches;
+$('wallSlider').addEventListener('input', e => {
+  wallInches = Math.min(240, Math.max(96, parseInt(e.target.value, 10) || 162));
+  localStorage.setItem('wrv_wall', wallInches);
+  applyScale();
+});
+addEventListener('resize', applyScale);
+
+// Boot
+if (Number.isFinite(DESIGN_ID) && DESIGN_ID >= 1) applyScale();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index 8290f9e..1ee5caf 100644
--- a/server.js
+++ b/server.js
@@ -1325,6 +1325,12 @@ function writeSecretsLine(key, value) {
   else text += (text.endsWith('\n') ? '' : '\n') + `${key}=${value}\n`;
   fs.writeFileSync(p, text);
 }
+// GET /api/admin/whoami — boolean admin-state probe for the public hamburger
+// nav. NOT a 404 when non-admin (avoids noisy console errors on every page);
+// just returns {isAdmin:false}.
+app.get('/api/admin/whoami', (req, res) => {
+  res.json({ isAdmin: !!isAdmin(req) });
+});
 app.get('/api/etsy-setup/status', async (req, res) => {
   if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
   const env = readSecretsEnv();
@@ -4446,6 +4452,22 @@ function htmlHeader(active) {
   <a href="/me/saved" class="${active === '/me/saved' ? 'active' : ''}">My Saved</a>
   <a href="/me/activity" class="${active === '/me/activity' ? 'active' : ''}">My Activity</a>
   <a href="/logout" class="nav-logout">⎋ Logout</a>
+
+  <!-- Admin tools menu — hidden by default; the /api/admin/whoami probe in the
+       script block below unhides it when the requester is admin (loopback /
+       dw_auth JWT / ?admin=TOKEN). One menu to reach every curator tool from
+       any page on the public site. -->
+  <div id="admin-nav" hidden style="margin-top:14px;padding-top:14px;border-top:1px solid var(--line,#e5e0d4)">
+    <div style="font:600 10.5px/1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.14em;text-transform:uppercase;color:var(--ink-faint,#7a6e5a);margin:0 14px 8px">Admin</div>
+    <a href="/admin/cactus-curator" class="${active === '/admin/cactus-curator' ? 'active' : ''}">🌵 Cactus Curator (grid)</a>
+    <a href="/admin/drunk-curator"  class="${active === '/admin/drunk-curator'  ? 'active' : ''}">🥃 Hot-or-Not + Wall Viewer</a>
+    <a href="/admin/rooms"          class="${active === '/admin/rooms'          ? 'active' : ''}">🛋 Room Renders Gallery</a>
+    <a href="/admin/elements"       class="${active === '/admin/elements'       ? 'active' : ''}">✂️ Elements Library</a>
+    <a href="/admin/seam-debug"     class="${active === '/admin/seam-debug'     ? 'active' : ''}">🔬 Seam Debugger</a>
+    <a href="/admin/etsy-bucket"    class="${active === '/admin/etsy-bucket'    ? 'active' : ''}">🛒 Etsy Bucket</a>
+    <a href="/admin/etsy-setup"     class="${active === '/admin/etsy-setup'     ? 'active' : ''}">⚙️ Etsy Activation</a>
+    <a href="/admin/golive"         class="">📊 Go-Live Dashboard</a>
+  </div>
   <button class="theme-toggle" id="theme-toggle" aria-label="Toggle dark/light mode">
     <svg class="sun-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
     <svg class="moon-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
@@ -4481,6 +4503,19 @@ function htmlHeader(active) {
       try { window.dispatchEvent(new CustomEvent('trade-me-loaded', { detail: j })); } catch(e){}
     })
     .catch(function(){});
+
+  // Admin discovery — public pages don't render server-side admin flags, so
+  // ask the server. /api/admin/whoami is a cheap loopback-or-JWT check; reveals
+  // the #admin-nav block in the hamburger when isAdmin(req) is true.
+  fetch('/api/admin/whoami', { credentials: 'same-origin' })
+    .then(function(r){ return r.ok ? r.json() : {}; })
+    .then(function(j){
+      if (j && j.isAdmin) {
+        var box = document.getElementById('admin-nav');
+        if (box) box.hidden = false;
+      }
+    })
+    .catch(function(){});
   // Listen for save-state changes from any surface (PDP toggle, /me/saved Remove,
   // future autocomplete heart, etc.) and re-sync the nav pill count without a fetch.
   window.addEventListener('wallco-saved-changed', function(ev){

← 153d690 etsyEligible: pass --path to seam scanner (resolve from DESI  ·  back to Wallco Ai  ·  wall-viewer: ROOM_API render as sign-in upsell. /api/room no 86dd47e →