← back to Reno Visualizer
public/index.html
902 lines
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>See It On Your Wall — Designer Wallcoverings Visualizer</title>
<style>
/* ── Design tokens ── */
:root {
--brand: #2a4a3e;
--brand-light: #3d6b5a;
--accent: #c8a86a;
--surface: #fafaf8;
--card-bg: #ffffff;
--border: #e4e0d8;
--text: #1a1a18;
--muted: #7a7870;
--cols: 3;
--card-radius: 10px;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font: 15px/1.5 -apple-system, 'Segoe UI', Roboto, sans-serif; color: var(--text); background: var(--surface); }
/* ── Nav ── */
.nav {
display: flex; align-items: center; gap: 16px; padding: 14px 24px;
background: var(--brand); color: #fff; position: sticky; top: 0; z-index: 100;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
}
.nav-logo { font-size: 17px; font-weight: 700; letter-spacing: 0.03em; color: #fff; text-decoration: none; }
.nav-tag { font-size: 11px; opacity: 0.65; font-style: italic; }
.nav-spacer { flex: 1; }
.nav-cta { font-size: 12px; background: var(--accent); color: #1a1a18; border: none; border-radius: 6px;
padding: 6px 14px; font-weight: 600; cursor: pointer; text-decoration: none; }
/* ── Step progress ── */
.steps {
display: flex; gap: 0; background: #fff; border-bottom: 1px solid var(--border);
overflow-x: auto;
}
.step-btn {
flex: 1; min-width: 120px; padding: 12px 16px; border: none; background: none; cursor: pointer;
font: 13px/1.2 inherit; color: var(--muted); border-bottom: 3px solid transparent;
transition: color 0.2s, border-color 0.2s; white-space: nowrap; text-align: center;
}
.step-btn .step-num { display: block; font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 2px; }
.step-btn.active { color: var(--brand); border-bottom-color: var(--brand); font-weight: 600; }
.step-btn.done { color: var(--brand-light); }
.step-btn.done .step-num::after { content: ' ✓'; }
/* ── Panels ── */
.panel { display: none; }
.panel.active { display: block; }
/* ── Step 1: Room source ── */
.room-source { padding: 32px 24px; max-width: 900px; margin: 0 auto; }
.section-title { font-size: 22px; font-weight: 700; color: var(--brand); margin-bottom: 8px; }
.section-sub { color: var(--muted); margin-bottom: 24px; }
.room-tabs { display: flex; gap: 10px; margin-bottom: 20px; }
.room-tab { padding: 8px 18px; border-radius: 20px; border: 1.5px solid var(--border); font: inherit;
font-size: 13px; cursor: pointer; background: #fff; color: var(--muted); transition: all 0.15s; }
.room-tab.active { background: var(--brand); color: #fff; border-color: var(--brand); }
.stock-rooms { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 14px; }
.room-card { border: 2px solid var(--border); border-radius: var(--card-radius); overflow: hidden;
cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s; background: #fff; }
.room-card:hover { border-color: var(--brand-light); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.room-card.selected { border-color: var(--brand); box-shadow: 0 0 0 3px rgba(42,74,62,0.15); }
.room-card img, .room-card object { width: 100%; aspect-ratio: 4/3; object-fit: cover; display: block; pointer-events: none; }
.room-card .room-label { padding: 8px 10px; font-size: 12px; font-weight: 600; color: var(--brand); }
.upload-zone {
border: 2px dashed var(--border); border-radius: var(--card-radius); padding: 48px 24px;
text-align: center; cursor: pointer; transition: border-color 0.15s, background 0.15s;
background: #fff;
}
.upload-zone:hover, .upload-zone.drag { border-color: var(--brand); background: #f0f5f2; }
.upload-icon { font-size: 40px; margin-bottom: 12px; display: block; }
.upload-zone h3 { font-size: 16px; margin-bottom: 6px; color: var(--brand); }
.upload-zone p { color: var(--muted); font-size: 13px; }
.upload-zone input { display: none; }
#upload-preview { max-width: 400px; border-radius: var(--card-radius); margin: 16px auto 0; display: none; }
.step-next { margin-top: 24px; }
.btn-primary { background: var(--brand); color: #fff; border: none; border-radius: 8px;
padding: 12px 28px; font: 600 15px inherit; cursor: pointer; transition: background 0.15s; }
.btn-primary:hover { background: var(--brand-light); }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-ghost { background: none; color: var(--brand); border: 1.5px solid var(--brand); border-radius: 8px;
padding: 11px 24px; font: 600 15px inherit; cursor: pointer; margin-right: 10px; }
/* ── Step 2: Pattern picker ── */
.pattern-picker { padding: 24px; }
.toolbar {
display: flex; gap: 14px; align-items: center; flex-wrap: wrap;
background: #fff; border: 1px solid var(--border); border-radius: 10px;
padding: 12px 16px; margin-bottom: 20px;
position: sticky; top: 50px; z-index: 10;
}
.toolbar h2 { font-size: 16px; font-weight: 700; color: var(--brand); margin-right: 4px; }
.toolbar-spacer { flex: 1; }
.toolbar label { font-size: 12px; color: var(--muted); display: flex; gap: 6px; align-items: center; }
.toolbar select { font: 13px inherit; padding: 4px 8px; border: 1px solid var(--border); border-radius: 6px; }
.toolbar input[type=range] { width: 80px; }
.count-badge { font-size: 11px; color: var(--muted); background: var(--surface); border: 1px solid var(--border);
padding: 3px 8px; border-radius: 12px; }
.pattern-grid {
display: grid;
grid-template-columns: repeat(var(--cols), 1fr);
gap: 14px;
}
.pattern-card {
background: var(--card-bg); border: 2px solid var(--border); border-radius: var(--card-radius);
overflow: hidden; cursor: pointer; transition: border-color 0.15s, box-shadow 0.15s;
}
.pattern-card:hover { border-color: var(--brand-light); box-shadow: 0 4px 16px rgba(0,0,0,0.1); }
.pattern-card.selected { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(200,168,106,0.25); }
.pattern-card img { width: 100%; aspect-ratio: 1/1; object-fit: cover; display: block; background: #f0ede8; }
.pattern-meta { padding: 8px 10px; }
.pattern-title { font-size: 13px; font-weight: 600; margin-bottom: 2px; }
.pattern-sub { font-size: 11px; color: var(--muted); }
.pattern-price { font-size: 13px; color: var(--brand); font-weight: 600; margin-top: 4px; }
.pattern-sample-badge { display: inline-block; font-size: 9px; background: #e8f0e8; color: #4a7a4a;
border: 1px solid #b0d4b0; border-radius: 4px; padding: 1px 5px; margin-left: 4px; vertical-align: middle; }
.empty-state { padding: 60px 24px; text-align: center; color: var(--muted); }
/* ── Step 3: Render ── */
.render-panel { padding: 24px; max-width: 1100px; margin: 0 auto; }
.render-layout { display: grid; grid-template-columns: 1fr 320px; gap: 24px; align-items: start; }
@media (max-width: 768px) { .render-layout { grid-template-columns: 1fr; } }
.render-canvas-wrap {
position: relative; background: #1a1a18; border-radius: 12px; overflow: hidden;
box-shadow: 0 8px 32px rgba(0,0,0,0.25);
}
#render-canvas { display: block; width: 100%; border-radius: 12px; cursor: crosshair; }
.render-badge {
position: absolute; bottom: 10px; left: 10px;
background: rgba(0,0,0,0.65); color: #fff; font-size: 10px; padding: 4px 10px;
border-radius: 20px; backdrop-filter: blur(4px); pointer-events: none;
letter-spacing: 0.04em;
}
.render-controls { background: #fff; border: 1px solid var(--border); border-radius: 12px; padding: 20px; }
.render-controls h3 { font-size: 15px; font-weight: 700; color: var(--brand); margin-bottom: 16px; }
.ctrl-group { margin-bottom: 16px; }
.ctrl-label { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase;
letter-spacing: 0.06em; margin-bottom: 6px; display: block; }
.ctrl-row { display: flex; gap: 8px; align-items: center; }
.ctrl-row input[type=range] { flex: 1; }
.ctrl-val { font-size: 12px; color: var(--brand); font-weight: 600; min-width: 30px; text-align: right; }
.ctrl-select { width: 100%; font: 13px inherit; padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; }
.selected-pattern-info {
display: flex; gap: 12px; align-items: center; padding: 12px; background: var(--surface);
border-radius: 8px; margin-bottom: 16px; border: 1px solid var(--border);
}
.selected-pattern-thumb { width: 52px; height: 52px; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
.selected-pattern-meta .title { font-size: 13px; font-weight: 600; color: var(--brand); }
.selected-pattern-meta .sub { font-size: 11px; color: var(--muted); }
.wall-zone-hint { font-size: 12px; color: var(--muted); background: #f8f4ee; border: 1px solid #e4ddd0;
border-radius: 6px; padding: 8px 12px; margin-top: 12px; line-height: 1.4; }
.wall-zone-hint strong { color: var(--brand); }
.wall-mode-btns { display: flex; gap: 8px; flex-wrap: wrap; }
.wall-mode-btn { flex: 1; min-width: 80px; padding: 8px 10px; border: 1.5px solid var(--border);
border-radius: 6px; font: 12px inherit; cursor: pointer; background: #fff; text-align: center;
transition: all 0.15s; }
.wall-mode-btn.active { background: var(--brand); color: #fff; border-color: var(--brand); }
/* ── Step 4: Shop the look ── */
.shop-panel { padding: 32px 24px; max-width: 680px; margin: 0 auto; }
.look-preview {
display: flex; gap: 16px; align-items: center; padding: 20px; background: #fff;
border: 1px solid var(--border); border-radius: 12px; margin-bottom: 28px;
}
.look-room-thumb { width: 140px; height: 105px; object-fit: cover; border-radius: 8px; flex-shrink: 0; background: #e8e4dc; }
.look-pattern-thumb { width: 70px; height: 70px; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
.look-info h3 { font-size: 16px; font-weight: 700; color: var(--brand); margin-bottom: 4px; }
.look-info p { font-size: 13px; color: var(--muted); }
.look-info .price { font-size: 18px; font-weight: 700; color: var(--brand); margin-top: 6px; }
.form-group { margin-bottom: 16px; }
.form-group label { display: block; font-size: 13px; font-weight: 600; color: var(--brand); margin-bottom: 6px; }
.form-group input, .form-group textarea, .form-group select {
width: 100%; font: 14px inherit; padding: 10px 14px; border: 1.5px solid var(--border);
border-radius: 8px; background: #fff; color: var(--text); transition: border-color 0.15s; outline: none;
}
.form-group input:focus, .form-group textarea:focus { border-color: var(--brand); }
.form-group textarea { min-height: 80px; resize: vertical; }
.shop-privacy { font-size: 11px; color: var(--muted); margin-top: 8px; }
.lead-success { display: none; text-align: center; padding: 40px 24px; }
.lead-success .check { font-size: 48px; display: block; margin-bottom: 12px; }
.lead-success h3 { font-size: 20px; font-weight: 700; color: var(--brand); margin-bottom: 8px; }
.lead-success p { color: var(--muted); }
/* ── Utility ── */
.spinner { display: inline-block; width: 18px; height: 18px; border: 2px solid #eee;
border-top-color: var(--brand); border-radius: 50%; animation: spin 0.6s linear infinite; vertical-align: middle; }
@keyframes spin { to { transform: rotate(360deg); } }
.esc = { font-size: 11px; }
</style>
</head>
<body>
<nav class="nav">
<a class="nav-logo" href="/">Designer Wallcoverings</a>
<span class="nav-tag">See It On Your Wall</span>
<span class="nav-spacer"></span>
<span class="nav-tag" style="font-size:10px;opacity:0.6;">Links to the live Designer Wallcoverings product page.</span>
<a class="nav-cta" href="https://www.designerwallcoverings.com" target="_blank" rel="noopener noreferrer">Shop All Patterns</a>
</nav>
<!-- Step progress bar -->
<div class="steps" id="steps">
<button class="step-btn active" data-step="1" onclick="goStep(1)">
<span class="step-num">Step 1</span>Choose a Room
</button>
<button class="step-btn" data-step="2" onclick="goStep(2)">
<span class="step-num">Step 2</span>Pick a Pattern
</button>
<button class="step-btn" data-step="3" onclick="goStep(3)">
<span class="step-num">Step 3</span>See It On Your Wall
</button>
<button class="step-btn" data-step="4" onclick="goStep(4)">
<span class="step-num">Step 4</span>Shop the Look
</button>
</div>
<!-- ── STEP 1: Room source ── -->
<div class="panel active" id="panel-1">
<div class="room-source">
<h2 class="section-title">Choose your room</h2>
<p class="section-sub">Pick a stock room or upload your own photo. We'll apply your wallcovering to the wall.</p>
<div class="room-tabs">
<button class="room-tab active" onclick="showRoomTab('stock', this)">Stock Rooms</button>
<button class="room-tab" onclick="showRoomTab('upload', this)">Upload My Room</button>
</div>
<!-- Stock rooms -->
<div id="tab-stock">
<div class="stock-rooms" id="stock-rooms-grid">
<div class="empty-state">Loading rooms…</div>
</div>
</div>
<!-- Upload tab -->
<div id="tab-upload" style="display:none;">
<div class="upload-zone" id="upload-zone">
<span class="upload-icon">🏠</span>
<h3>Upload a room photo</h3>
<p>JPG, PNG — ideally a straight-on wall view<br>Max 8 MB</p>
<input type="file" id="file-input" accept="image/*">
<br><br>
<button class="btn-ghost" onclick="document.getElementById('file-input').click()">Choose File</button>
</div>
<img id="upload-preview" alt="Room preview">
</div>
<div class="step-next">
<button class="btn-primary" id="btn-to-step2" onclick="goStep(2)" disabled>Choose a Pattern →</button>
</div>
</div>
</div>
<!-- ── STEP 2: Pattern picker ── -->
<div class="panel" id="panel-2">
<div class="pattern-picker">
<div class="toolbar">
<h2>Patterns</h2>
<span class="count-badge" id="count-badge">…</span>
<span class="toolbar-spacer"></span>
<label>Sort
<select id="sort-sel" onchange="onSortChange()">
<option value="newest">Newest</option>
<option value="title">Title A→Z</option>
<option value="sku">SKU A→Z</option>
<option value="vendor">Vendor A→Z</option>
<option value="price-asc">Price ↑</option>
<option value="price-desc">Price ↓</option>
<option value="light">Light → Dark</option>
<option value="dark">Dark → Light</option>
</select>
</label>
<label>Density <input type="range" id="density-slider" min="2" max="6" value="3" oninput="onDensityChange()"></label>
</div>
<div class="pattern-grid" id="pattern-grid">
<div class="empty-state">Loading patterns…</div>
</div>
<div class="step-next" style="padding: 24px 0 8px;">
<button class="btn-ghost" onclick="goStep(1)">← Back</button>
<button class="btn-primary" id="btn-to-step3" onclick="goStep(3)" disabled>Preview on Wall →</button>
</div>
</div>
</div>
<!-- ── STEP 3: Render ── -->
<div class="panel" id="panel-3">
<div class="render-panel">
<div class="render-layout">
<!-- Canvas -->
<div>
<div class="render-canvas-wrap">
<canvas id="render-canvas" width="800" height="500"></canvas>
<div class="render-badge">⚠ Preview render — prototype quality</div>
</div>
<div class="wall-zone-hint" style="margin-top: 12px;">
<strong>Draw your wall zone:</strong> click and drag on the canvas to define the exact wall area, or use "Full Wall" below. A real compositing engine (ComfyUI / Replicate) plugs in at <code>/api/render</code>.
</div>
</div>
<!-- Controls -->
<div class="render-controls">
<h3>Render Controls</h3>
<div class="selected-pattern-info" id="selected-pattern-info">
<div style="color: var(--muted); font-size: 13px;">No pattern selected</div>
</div>
<div class="ctrl-group">
<span class="ctrl-label">Wall Zone</span>
<div class="wall-mode-btns">
<button class="wall-mode-btn active" id="btn-zone-full" onclick="setZoneFull()">Full Wall</button>
<button class="wall-mode-btn" id="btn-zone-draw" onclick="setZoneDraw()">Draw Zone</button>
</div>
</div>
<div class="ctrl-group">
<span class="ctrl-label">Pattern Scale</span>
<div class="ctrl-row">
<input type="range" id="ctrl-scale" min="20" max="200" value="80" oninput="rerenderCanvas()">
<span class="ctrl-val" id="ctrl-scale-val">80px</span>
</div>
</div>
<div class="ctrl-group">
<span class="ctrl-label">Blend Opacity</span>
<div class="ctrl-row">
<input type="range" id="ctrl-opacity" min="30" max="100" value="85" oninput="rerenderCanvas()">
<span class="ctrl-val" id="ctrl-opacity-val">85%</span>
</div>
</div>
<div class="ctrl-group">
<span class="ctrl-label">Blend Mode</span>
<select class="ctrl-select" id="ctrl-blend" onchange="rerenderCanvas()">
<option value="multiply">Multiply (natural)</option>
<option value="overlay">Overlay (vivid)</option>
<option value="normal">Normal (flat)</option>
<option value="soft-light">Soft Light</option>
<option value="hard-light">Hard Light</option>
</select>
</div>
<div class="ctrl-group">
<span class="ctrl-label">Perspective Tint</span>
<div class="ctrl-row">
<input type="range" id="ctrl-shadow" min="0" max="60" value="18" oninput="rerenderCanvas()">
<span class="ctrl-val" id="ctrl-shadow-val">18%</span>
</div>
</div>
<button class="btn-primary" style="width:100%; margin-bottom: 10px;" onclick="goStep(4)">Shop This Look →</button>
<button class="btn-ghost" style="width:100%;" onclick="goStep(2)">← Change Pattern</button>
</div>
</div>
</div>
</div>
<!-- ── STEP 4: Shop the look ── -->
<div class="panel" id="panel-4">
<div class="shop-panel">
<div id="shop-form-wrap">
<h2 class="section-title">Shop the Look</h2>
<p class="section-sub" style="margin-bottom: 24px;">We'll send you a link to order this pattern, a sample, or schedule a design consultation.</p>
<div class="look-preview" id="look-preview">
<!-- Filled by JS -->
</div>
<form id="lead-form" onsubmit="submitLead(event)">
<div class="form-group">
<label for="lead-email">Email address *</label>
<input type="email" id="lead-email" required placeholder="you@example.com">
</div>
<div class="form-group">
<label for="lead-name">Your name</label>
<input type="text" id="lead-name" placeholder="Optional">
</div>
<div class="form-group">
<label for="lead-interest">I'm interested in…</label>
<select id="lead-interest">
<option value="order">Ordering full rolls</option>
<option value="sample">Requesting a sample first</option>
<option value="consult">Design consultation</option>
<option value="quote">Getting a quote for a project</option>
</select>
</div>
<div class="form-group">
<label for="lead-message">Notes (optional)</label>
<textarea id="lead-message" placeholder="Room dimensions, project timeline, any details…"></textarea>
</div>
<button class="btn-primary" type="submit" id="submit-btn" style="width:100%;">Send My Look →</button>
<p class="shop-privacy" style="margin-top: 10px; text-align: center;">No spam. We'll reply within 1 business day.</p>
</form>
</div>
<div class="lead-success" id="lead-success">
<span class="check">✅</span>
<h3>We've got your look!</h3>
<p>Check your inbox — we'll send ordering details, a sample link, and any project notes.</p>
<br>
<div id="lead-success-product-link"></div>
<br>
<button class="btn-ghost" onclick="startOver()">Visualize another pattern</button>
</div>
<div style="margin-top: 24px; text-align: center;">
<button class="btn-ghost" onclick="goStep(3)">← Back to Preview</button>
</div>
</div>
</div>
<script>
/* ── State ── */
const state = {
selectedRoom: null, // { id, label, src, wall_region } or { custom: true, dataUrl, label }
selectedPattern: null, // product object
uploadedDataUrl: null,
wallZone: null, // { x, y, w, h } in canvas px — null = full wall
drawingZone: false,
drawStart: null,
};
/* ── Helpers ── */
const $ = s => document.querySelector(s);
const $$ = s => document.querySelectorAll(s);
function esc(s) { return (s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
function fmtPrice(p) { return p ? '$' + Number(p).toFixed(2) : ''; }
/* ── Steps ── */
function goStep(n) {
$$('.panel').forEach(p => p.classList.remove('active'));
$$('.step-btn').forEach(b => b.classList.remove('active'));
$(`#panel-${n}`).classList.add('active');
$(`.step-btn[data-step="${n}"]`).classList.add('active');
// Mark completed steps
$$('.step-btn').forEach(b => {
const s = parseInt(b.dataset.step);
b.classList.toggle('done', s < n);
});
if (n === 3) initRenderStep();
if (n === 4) initShopStep();
window.scrollTo(0, 0);
}
/* ── Step 1: Rooms ── */
async function loadRooms() {
try {
const r = await fetch('/api/rooms');
const d = await r.json();
renderStockRooms(d.rooms);
} catch(e) {
$('#stock-rooms-grid').innerHTML = '<div class="empty-state">Could not load rooms.</div>';
}
}
function renderStockRooms(rooms) {
$('#stock-rooms-grid').innerHTML = rooms.map(r => `
<div class="room-card" id="room-${r.id}" onclick="selectRoom(${JSON.stringify(JSON.stringify(r))})">
<img src="${esc(r.src)}" alt="${esc(r.label)}" loading="lazy" onerror="this.style.background='#e0ddd8';this.alt='Room image'">
<div class="room-label">${esc(r.label)}</div>
</div>`).join('');
}
function selectRoom(jsonStr) {
const room = JSON.parse(jsonStr);
state.selectedRoom = room;
$$('.room-card').forEach(c => c.classList.remove('selected'));
$(`#room-${room.id}`)?.classList.add('selected');
state.uploadedDataUrl = null;
$('#btn-to-step2').disabled = false;
}
function showRoomTab(tab, btn) {
$$('.room-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
$('#tab-stock').style.display = tab === 'stock' ? '' : 'none';
$('#tab-upload').style.display = tab === 'upload' ? '' : 'none';
}
// Upload handling
$('#upload-zone').addEventListener('dragover', e => { e.preventDefault(); e.currentTarget.classList.add('drag'); });
$('#upload-zone').addEventListener('dragleave', e => e.currentTarget.classList.remove('drag'));
$('#upload-zone').addEventListener('drop', e => { e.preventDefault(); e.currentTarget.classList.remove('drag'); handleFiles(e.dataTransfer.files); });
$('#file-input').addEventListener('change', e => handleFiles(e.target.files));
function handleFiles(files) {
const file = files[0];
if (!file || !file.type.startsWith('image/')) return;
if (file.size > 8 * 1024 * 1024) { alert('Image too large (max 8 MB)'); return; }
const reader = new FileReader();
reader.onload = e => {
const dataUrl = e.target.result;
state.uploadedDataUrl = dataUrl;
state.selectedRoom = { custom: true, dataUrl, label: 'My Room', wall_region: { x: 0, y: 0, w: 100, h: 65 } };
$('#upload-preview').src = dataUrl;
$('#upload-preview').style.display = 'block';
$('#btn-to-step2').disabled = false;
// Also upload to server (base64 JSON, zero-dep)
const mime = file.type;
fetch('/api/upload-room', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: dataUrl, mime })
}).catch(() => {}); // non-blocking, client-side dataUrl is canonical
};
reader.readAsDataURL(file);
}
/* ── Step 2: Patterns ── */
let _products = [];
async function loadProducts() {
const sort = localStorage.getItem('pattern-sort') || 'newest';
$('#sort-sel').value = sort;
applyDensity();
try {
const r = await fetch('/api/products?sort=' + encodeURIComponent(sort));
const d = await r.json();
_products = d.products || [];
renderPatterns(_products);
$('#count-badge').textContent = d.count + ' patterns';
} catch(e) {
$('#pattern-grid').innerHTML = '<div class="empty-state">Could not load patterns.</div>';
}
}
function renderPatterns(products) {
if (!products.length) {
$('#pattern-grid').innerHTML = '<div class="empty-state">No patterns found.</div>';
return;
}
$('#pattern-grid').innerHTML = products.map(p => {
const imgSrc = p.image_url || p.image || '';
const isSelected = state.selectedPattern && state.selectedPattern.sku === p.sku;
return `<div class="pattern-card${isSelected ? ' selected' : ''}" id="pc-${esc(p.sku)}" onclick="selectPattern(${JSON.stringify(JSON.stringify(p))})">
<img src="${esc(imgSrc)}" alt="${esc(p.title)}" loading="lazy" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%22200%22 height=%22200%22><rect width=%22200%22 height=%22200%22 fill=%22%23e8e4dc%22/></svg>'">
<div class="pattern-meta">
<div class="pattern-title">${esc(p.title)}${p.sample_only ? '<span class="pattern-sample-badge">Sample only</span>' : ''}</div>
<div class="pattern-sub">${esc(p.vendor || '')}</div>
${p.price_display ? `<div class="pattern-price">${esc(p.price_display)}${p.buyable ? '/roll' : ''}</div>` : ''}
</div>
</div>`;
}).join('');
}
function selectPattern(jsonStr) {
const p = JSON.parse(jsonStr);
state.selectedPattern = p;
$$('.pattern-card').forEach(c => c.classList.remove('selected'));
$(`#pc-${CSS.escape(p.sku)}`)?.classList.add('selected');
$('#btn-to-step3').disabled = false;
}
function onSortChange() {
const v = $('#sort-sel').value;
localStorage.setItem('pattern-sort', v);
fetch('/api/products?sort=' + encodeURIComponent(v))
.then(r => r.json())
.then(d => { _products = d.products || []; renderPatterns(_products); $('#count-badge').textContent = d.count + ' patterns'; });
}
function onDensityChange() {
const v = $('#density-slider').value;
localStorage.setItem('pattern-cols', v);
document.documentElement.style.setProperty('--cols', v);
}
function applyDensity() {
const v = localStorage.getItem('pattern-cols') || '3';
$('#density-slider').value = v;
document.documentElement.style.setProperty('--cols', v);
}
/* ── Step 3: Canvas render ── */
const canvas = document.getElementById('render-canvas');
const ctx = canvas.getContext('2d');
let _roomImg = null;
let _patternImg = null;
let _drawingActive = false;
let _drawRect = null;
async function initRenderStep() {
if (!state.selectedRoom || !state.selectedPattern) return;
updateSelectedPatternInfo();
await loadRoomImage();
await loadPatternImage();
// Set default wall zone from room's wall_region
state.wallZone = null; // null = full wall from wall_region
rerenderCanvas();
}
function updateSelectedPatternInfo() {
const p = state.selectedPattern;
if (!p) return;
const imgSrc = p.image_url || p.image || '';
$('#selected-pattern-info').innerHTML = `
<img class="selected-pattern-thumb" src="${esc(imgSrc)}" alt="${esc(p.title)}"
onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2252%22 height=%2252%22><rect width=%2252%22 height=%2252%22 fill=%22%23e8e4dc%22/></svg>'">
<div class="selected-pattern-meta">
<div class="title">${esc(p.title)}</div>
<div class="sub">${esc(p.vendor || '')} · ${esc(p.color || '')}</div>
<div class="sub" style="margin-top:2px;font-weight:600;color:var(--brand)">${esc(p.price_display || '')}${p.buyable ? '/roll' : ''}</div>
</div>`;
}
function loadRoomImage() {
return new Promise(resolve => {
const img = new Image();
img.crossOrigin = 'anonymous';
const src = state.selectedRoom.custom ? state.selectedRoom.dataUrl : state.selectedRoom.src;
img.onload = () => { _roomImg = img; resolve(); };
img.onerror = () => { _roomImg = null; resolve(); };
img.src = src;
});
}
function loadPatternImage() {
return new Promise(resolve => {
const p = state.selectedPattern;
const src = p.image_url || p.image || '';
if (!src) { _patternImg = null; resolve(); return; }
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => { _patternImg = img; resolve(); };
img.onerror = () => {
// Fallback: solid color swatch from hex
_patternImg = null;
resolve();
};
img.src = src;
});
}
function getWallRect() {
// If user drew a zone, use that
if (state.wallZone) return state.wallZone;
// Otherwise use the room's default wall_region (in % → px)
const wr = state.selectedRoom?.wall_region || { x: 0, y: 0, w: 100, h: 70 };
return {
x: Math.round(canvas.width * wr.x / 100),
y: Math.round(canvas.height * wr.y / 100),
w: Math.round(canvas.width * wr.w / 100),
h: Math.round(canvas.height * wr.h / 100),
};
}
function rerenderCanvas() {
// Update control value displays
$('#ctrl-scale-val').textContent = $('#ctrl-scale').value + 'px';
$('#ctrl-opacity-val').textContent = $('#ctrl-opacity').value + '%';
$('#ctrl-shadow-val').textContent = $('#ctrl-shadow').value + '%';
ctx.clearRect(0, 0, canvas.width, canvas.height);
// Draw room base
if (_roomImg) {
ctx.drawImage(_roomImg, 0, 0, canvas.width, canvas.height);
} else {
// Fallback: plain room sketch
ctx.fillStyle = '#e8e4dc';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#c8b89a';
ctx.fillRect(0, 350, canvas.width, 150);
}
// Wall zone
const wall = getWallRect();
const tileSize = parseInt($('#ctrl-scale').value) || 80;
const opacity = parseInt($('#ctrl-opacity').value) / 100;
const blendMode = $('#ctrl-blend').value;
const shadowPct = parseInt($('#ctrl-shadow').value) / 100;
// Composite pattern onto wall zone
ctx.save();
ctx.beginPath();
ctx.rect(wall.x, wall.y, wall.w, wall.h);
ctx.clip();
if (_patternImg) {
// Create tiling pattern
const offscreen = document.createElement('canvas');
offscreen.width = tileSize;
offscreen.height = tileSize;
const oc = offscreen.getContext('2d');
oc.drawImage(_patternImg, 0, 0, tileSize, tileSize);
const pat = ctx.createPattern(offscreen, 'repeat');
ctx.globalAlpha = opacity;
ctx.globalCompositeOperation = blendMode;
ctx.fillStyle = pat;
ctx.fillRect(wall.x, wall.y, wall.w, wall.h);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
} else if (state.selectedPattern?.hex) {
// Solid color fallback
ctx.globalAlpha = opacity;
ctx.globalCompositeOperation = blendMode;
ctx.fillStyle = state.selectedPattern.hex;
ctx.fillRect(wall.x, wall.y, wall.w, wall.h);
ctx.globalCompositeOperation = 'source-over';
ctx.globalAlpha = 1;
}
// Perspective shadow gradient (top lighter, bottom slightly darker — simulates ceiling/floor light)
if (shadowPct > 0) {
const grad = ctx.createLinearGradient(wall.x, wall.y, wall.x, wall.y + wall.h);
grad.addColorStop(0, `rgba(255,255,255,${shadowPct * 0.3})`);
grad.addColorStop(0.5, 'rgba(0,0,0,0)');
grad.addColorStop(1, `rgba(0,0,0,${shadowPct * 0.4})`);
ctx.globalCompositeOperation = 'source-over';
ctx.fillStyle = grad;
ctx.fillRect(wall.x, wall.y, wall.w, wall.h);
}
ctx.restore();
// Draw zone outline if user-drawn
if (state.wallZone) {
ctx.save();
ctx.strokeStyle = 'rgba(200,168,106,0.8)';
ctx.lineWidth = 2;
ctx.setLineDash([6, 3]);
ctx.strokeRect(wall.x, wall.y, wall.w, wall.h);
ctx.restore();
}
// Draw in-progress drag zone
if (_drawRect) {
ctx.save();
ctx.strokeStyle = 'rgba(200,168,106,0.9)';
ctx.lineWidth = 2;
ctx.setLineDash([6, 3]);
ctx.fillStyle = 'rgba(200,168,106,0.1)';
ctx.fillRect(_drawRect.x, _drawRect.y, _drawRect.w, _drawRect.h);
ctx.strokeRect(_drawRect.x, _drawRect.y, _drawRect.w, _drawRect.h);
ctx.restore();
}
}
// Zone drawing
function setZoneFull() {
state.wallZone = null;
_drawingActive = false;
canvas.style.cursor = 'default';
$('#btn-zone-full').classList.add('active');
$('#btn-zone-draw').classList.remove('active');
rerenderCanvas();
}
function setZoneDraw() {
state.wallZone = null;
_drawingActive = true;
canvas.style.cursor = 'crosshair';
$('#btn-zone-draw').classList.add('active');
$('#btn-zone-full').classList.remove('active');
}
function canvasXY(e) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
const scaleY = canvas.height / rect.height;
const src = e.touches ? e.touches[0] : e;
return {
x: (src.clientX - rect.left) * scaleX,
y: (src.clientY - rect.top) * scaleY
};
}
canvas.addEventListener('mousedown', e => {
if (!_drawingActive) return;
const p = canvasXY(e);
state.drawStart = p;
_drawRect = null;
});
canvas.addEventListener('mousemove', e => {
if (!_drawingActive || !state.drawStart) return;
const p = canvasXY(e);
_drawRect = {
x: Math.min(p.x, state.drawStart.x),
y: Math.min(p.y, state.drawStart.y),
w: Math.abs(p.x - state.drawStart.x),
h: Math.abs(p.y - state.drawStart.y),
};
rerenderCanvas();
});
canvas.addEventListener('mouseup', e => {
if (!_drawingActive || !_drawRect) return;
if (_drawRect.w > 10 && _drawRect.h > 10) {
state.wallZone = { ..._drawRect };
_drawingActive = false;
canvas.style.cursor = 'default';
$('#btn-zone-draw').classList.remove('active');
$('#btn-zone-full').classList.remove('active');
}
_drawRect = null;
rerenderCanvas();
});
/* ── Step 4: Shop ── */
function initShopStep() {
const p = state.selectedPattern;
const r = state.selectedRoom;
if (!p) return;
const imgSrc = p.image_url || p.image || '';
const roomSrc = r?.custom ? r.dataUrl : (r?.src || '');
const productUrl = p.product_url || p.shopify_url || (p.handle ? `https://www.designerwallcoverings.com/products/${p.handle}` : '');
const priceLabel = p.price_display ? (p.price_display + (p.buyable ? '/roll' : '')) : '';
$('#look-preview').innerHTML = `
<img class="look-room-thumb" src="${esc(roomSrc)}" alt="Room" onerror="this.style.display='none'">
<img class="look-pattern-thumb" src="${esc(imgSrc)}" alt="${esc(p.title)}" onerror="this.style.background='${esc(p.hex||'#e8e4dc')}'">
<div class="look-info">
<h3>${esc(p.title)}</h3>
<p>${esc(p.vendor || '')}${p.color ? ' · ' + esc(p.color) : ''}${p.collection ? ' · ' + esc(p.collection) : ''}</p>
${priceLabel ? `<p class="price">${esc(priceLabel)}</p>` : ''}
${productUrl ? `<p style="margin-top:6px;"><a href="${esc(productUrl)}" target="_blank" rel="noopener noreferrer" style="font-size:12px;color:var(--brand-light);text-decoration:underline;">View on Designer Wallcoverings →</a></p>` : ''}
</div>`;
}
async function submitLead(e) {
e.preventDefault();
const btn = $('#submit-btn');
btn.disabled = true;
btn.innerHTML = '<span class="spinner"></span> Sending…';
const p = state.selectedPattern;
const r = state.selectedRoom;
const productUrl = p?.product_url || p?.shopify_url || (p?.handle ? `https://www.designerwallcoverings.com/products/${p.handle}` : '');
const body = {
email: $('#lead-email').value.trim(),
name: $('#lead-name').value.trim(),
interest: $('#lead-interest').value,
message: $('#lead-message').value.trim(),
pattern_sku: p?.sku || null,
pattern_title: p?.title || null,
pattern_vendor: p?.vendor || null,
pattern_handle: p?.handle || null,
pattern_shopify_url: productUrl || null,
room_label: r?.label || null,
};
try {
const res = await fetch('/api/lead', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const d = await res.json();
if (d.ok) {
$('#shop-form-wrap').style.display = 'none';
$('#lead-success').style.display = 'block';
// Show direct product link after capture
const linkEl = $('#lead-success-product-link');
if (linkEl && productUrl) {
linkEl.innerHTML = `<a href="${esc(productUrl)}" target="_blank" rel="noopener noreferrer" class="btn-primary" style="display:inline-block;text-decoration:none;margin-bottom:8px;">View This Pattern on Designer Wallcoverings →</a><br><small style="color:var(--muted);font-size:11px;">Links to the live Designer Wallcoverings product page.</small>`;
}
} else {
btn.disabled = false;
btn.textContent = 'Send My Look →';
alert('Error: ' + (d.error || 'unknown'));
}
} catch(err) {
btn.disabled = false;
btn.textContent = 'Send My Look →';
alert('Network error — please try again.');
}
}
function startOver() {
state.selectedRoom = null;
state.selectedPattern = null;
state.uploadedDataUrl = null;
state.wallZone = null;
$('#lead-success').style.display = 'none';
$('#shop-form-wrap').style.display = 'block';
$('#lead-form').reset();
goStep(1);
}
/* ── Init ── */
loadRooms();
loadProducts();
applyDensity();
</script>
</body>
</html>