← back to Wallco Ai

public/wall-room-viewer.html

356 lines

<!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);}

  /* 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>
<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="252" step="6" value="162">
      </div>
      <div class="ctl" style="min-width:230px">
        <span class="lab">Wall height <span class="val" id="wallvalH">9′</span></span>
        <input type="range" id="wallSliderH" min="84" max="144" step="6" value="108">
      </div>
      <div class="info">
        <b id="repCount">—</b> repeats across · <b id="repCountV">—</b> tall · tile <b id="repInches">36</b>″
      </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>
        <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(252, Math.max(96, parseInt(localStorage.getItem('wrv_wall') || '162', 10)));
let wallInchesH  = Math.min(144, Math.max(84,  parseInt(localStorage.getItem('wrv_wall_h') || '108', 10)));   // 9′ default; 7′-12′ slider

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(){
  // Room canvas: width follows the viewport; height follows wallInchesH at the
  // SAME px-per-inch so the preview is true-to-life across both axes. Caps the
  // pixel-width at 900 so wide walls don't blow out the layout.
  const stage = document.querySelector('.stage');
  const roomW = Math.min(900, stage.clientWidth - 80);
  const ppi   = roomW / wallInches;
  const roomH = Math.round(wallInchesH * ppi);
  const room = $('room');
  room.style.width  = roomW + 'px';
  room.style.height = roomH + '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 (horizontal repeat count)
  const repPx = scaleInches * ppi;
  const nRep  = wallInches  / scaleInches;
  const nRepV = wallInchesH / 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)} × ↕ ${nRepV.toFixed(nRepV % 1 ? 1 : 0)} · ${scaleInches}″`;
  $('repCount').textContent  = nRep.toFixed(nRep % 1 ? 1 : 0);
  $('repCountV').textContent = nRepV.toFixed(nRepV % 1 ? 1 : 0);
  $('repInches').textContent = scaleInches;
  $('roomtag').textContent = `${fmtFt(wallInches)} × ${fmtFt(wallInchesH)} wall · ${scaleInches}″ repeat`;
  $('wallval').textContent  = fmtFt(wallInches);
  $('wallvalH').textContent = fmtFt(wallInchesH);

  // 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 = roomH + 'px';
  rv.style.top    = (room.offsetTop) + 'px';
  let rvs = '';
  for (let inch = 0; inch <= wallInchesH; 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(252, Math.max(96, parseInt(e.target.value, 10) || 162));
  localStorage.setItem('wrv_wall', wallInches);
  applyScale();
});
$('wallSliderH').value = wallInchesH;
$('wallSliderH').addEventListener('input', e => {
  wallInchesH = Math.min(144, Math.max(84, parseInt(e.target.value, 10) || 108));
  localStorage.setItem('wrv_wall_h', wallInchesH);
  applyScale();
});
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>
</body>
</html>