← 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=<design></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>