← back to Reno Visualizer
reno-visualizer: full 4-step wall visualizer prototype
12f92ba84397045613406e167b67775284da5ca5 · 2026-07-15 17:02:18 -0700 · Steve
- 4-step flow: room source → pattern picker → canvas render → shop the look email capture
- 15 real patterns pulled from dw_unified (Brewster + Arte) with live CDN images
- 4 stock room SVGs (living room, bedroom, dining room, home office) drawn in SVG $0-local
- Client-side canvas composite render: tile pattern image, mask to wall zone, blend modes,
perspective shadow gradient; user can draw custom wall zone via drag
- /api/render seam endpoint: prototype returns metadata; real engine (ComfyUI/Replicate) swaps in
- /api/lead POST → data/leads.json; /api/rooms; /api/upload-room (base64 JSON upload)
- /admin: lead cards with created date+time (toLocaleString, full ISO in title=), sort + density
- Sort + density slider (localStorage-persisted) on pattern grid
- Optional BASIC_AUTH + ADMIN_AUTH gating via env vars
Files touched
M data/products.jsonA data/rooms.jsonA public/admin.htmlM public/index.htmlA public/rooms/room-bedroom.svgA public/rooms/room-dining-room.svgA public/rooms/room-home-office.svgA public/rooms/room-living-room.svgM server.js
Diff
commit 12f92ba84397045613406e167b67775284da5ca5
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jul 15 17:02:18 2026 -0700
reno-visualizer: full 4-step wall visualizer prototype
- 4-step flow: room source → pattern picker → canvas render → shop the look email capture
- 15 real patterns pulled from dw_unified (Brewster + Arte) with live CDN images
- 4 stock room SVGs (living room, bedroom, dining room, home office) drawn in SVG $0-local
- Client-side canvas composite render: tile pattern image, mask to wall zone, blend modes,
perspective shadow gradient; user can draw custom wall zone via drag
- /api/render seam endpoint: prototype returns metadata; real engine (ComfyUI/Replicate) swaps in
- /api/lead POST → data/leads.json; /api/rooms; /api/upload-room (base64 JSON upload)
- /admin: lead cards with created date+time (toLocaleString, full ISO in title=), sort + density
- Sort + density slider (localStorage-persisted) on pattern grid
- Optional BASIC_AUTH + ADMIN_AUTH gating via env vars
---
data/products.json | 185 +++++++-
data/rooms.json | 26 ++
public/admin.html | 235 ++++++++++
public/index.html | 935 +++++++++++++++++++++++++++++++++++---
public/rooms/room-bedroom.svg | 41 ++
public/rooms/room-dining-room.svg | 51 +++
public/rooms/room-home-office.svg | 60 +++
public/rooms/room-living-room.svg | 41 ++
server.js | 248 ++++++++--
9 files changed, 1738 insertions(+), 84 deletions(-)
diff --git a/data/products.json b/data/products.json
index 8eee5f2..57805d0 100644
--- a/data/products.json
+++ b/data/products.json
@@ -1,6 +1,183 @@
[
- { "title": "Sample One", "sku": "SMP-001", "price": 129.00, "hex": "#2b3a55", "image": "" },
- { "title": "Sample Two", "sku": "SMP-002", "price": 89.00, "hex": "#d8c3a5", "image": "" },
- { "title": "Sample Three", "sku": "SMP-003", "price": 210.00, "hex": "#8e8d8a", "image": "" },
- { "title": "Sample Four", "sku": "SMP-004", "price": 54.00, "hex": "#e98074", "image": "" }
+ {
+ "sku": "2622-65600",
+ "title": "Andrei Grasscloth",
+ "color": "Olive",
+ "collection": "Jade Grasscloth",
+ "vendor": "Brewster",
+ "image_url": "https://bhf-cdn.azureedge.net/bhf-blob-prod/0016481_andrei-olive-grasscloth.jpeg",
+ "description": "A textured grasscloth wallcovering in a neutral beige and tan palette with subtle mauve accents, ideal for adding a natural and organic feel to a space.",
+ "tags": ["Beige", "Tan", "Coastal", "Textured", "Grasscloth"],
+ "price": 89.00,
+ "hex": "#8b7355"
+ },
+ {
+ "sku": "2838-AW87731",
+ "title": "Lyla Trellis",
+ "color": "Pink",
+ "collection": "Vista",
+ "vendor": "Brewster",
+ "image_url": "https://bhf-cdn.azureedge.net/bhf-blob-prod/0033792_lyla-pink-trellis-wallpaper.jpeg",
+ "description": "A geometric trellis pattern in light pink and gray tones against an off-white background. Contemporary aesthetic with subtle texture.",
+ "tags": ["Light Pink", "Light Gray", "Contemporary", "Geometric", "Trellis"],
+ "price": 79.00,
+ "hex": "#f4ccd6"
+ },
+ {
+ "sku": "2908-87101",
+ "title": "Paragon Geometric",
+ "color": "Black",
+ "collection": "Alchemy",
+ "vendor": "Brewster",
+ "image_url": "https://bhf-cdn.azureedge.net/bhf-blob-prod/0038225_paragon-black-geometric-wallpaper.jpeg",
+ "description": "A modern geometric wallcovering featuring repeating 3D cubes and triangular shapes rendered in fine black lines on white.",
+ "tags": ["White", "Black", "Contemporary", "Minimalist", "Geometric"],
+ "price": 95.00,
+ "hex": "#2a2a2a"
+ },
+ {
+ "sku": "2908-87120",
+ "title": "Lithos Marble",
+ "color": "Light Yellow",
+ "collection": "Alchemy",
+ "vendor": "Brewster",
+ "image_url": "https://bhf-cdn.azureedge.net/bhf-blob-prod/0038242_lithos-light-yellow-geometric-marble-wallpaper.jpeg",
+ "description": "A contemporary vinyl wallcovering featuring an abstract stripe pattern in off-white, beige, and tan creating a modern aesthetic.",
+ "tags": ["Off-white", "Beige", "Contemporary", "Abstract", "Stripe"],
+ "price": 89.00,
+ "hex": "#e8dcc8"
+ },
+ {
+ "sku": "2810-SH01004",
+ "title": "Audrey Texture",
+ "color": "Yellow",
+ "collection": "Traditional",
+ "vendor": "Brewster",
+ "image_url": "https://bhf-cdn.azureedge.net/bhf-blob-prod/0028585_audrey-yellow-texture-wallpaper.jpeg",
+ "description": "A subtle vertical stripe with textured effect in off-white and light blue-gray hues for a calming, refined atmosphere.",
+ "tags": ["Off-white", "Light Blue-gray", "Textured", "Stripe", "Scandinavian"],
+ "price": 69.00,
+ "hex": "#e8e4d0"
+ },
+ {
+ "sku": "2622-30216",
+ "title": "Ruslan Grasscloth",
+ "color": "Champagne",
+ "collection": "Jade Grasscloth",
+ "vendor": "Brewster",
+ "image_url": "https://bhf-cdn.azureedge.net/bhf-blob-prod/0016470_ruslan-champagne-grasscloth.jpeg",
+ "description": "A textured grasscloth wallcovering in a neutral beige palette offering a subtle and sophisticated backdrop.",
+ "tags": ["Beige", "Tan", "Traditional", "Textured", "Grasscloth"],
+ "price": 89.00,
+ "hex": "#c8b89a"
+ },
+ {
+ "sku": "2838-IH2252",
+ "title": "Makayla Stripe",
+ "color": "Apricot",
+ "collection": "Vista",
+ "vendor": "Brewster",
+ "image_url": "https://bhf-cdn.azureedge.net/bhf-blob-prod/0033842_makayla-apricot-stripe-wallpaper.jpeg",
+ "description": "An abstract stripe pattern in beige and khaki tones creating a textured, modern look with understated elegance.",
+ "tags": ["Beige", "Khaki", "Contemporary", "Abstract", "Stripe"],
+ "price": 75.00,
+ "hex": "#d4b896"
+ },
+ {
+ "sku": "63-54742",
+ "title": "Yue Grasscloth",
+ "color": "Olive",
+ "collection": "Shangri La",
+ "vendor": "Brewster",
+ "image_url": "https://bhf-cdn.azureedge.net/bhf-blob-prod/0033279_yue-yan-olive-grasscloth.jpeg",
+ "description": "A contemporary grasscloth featuring vertical stripes in varying shades of tan for a textured, sophisticated look.",
+ "tags": ["Dark Tan", "Tan", "Contemporary", "Stripe", "Grasscloth"],
+ "price": 95.00,
+ "hex": "#6b5d3f"
+ },
+ {
+ "sku": "67816",
+ "title": "Muga Silk",
+ "color": "Seafoam Green",
+ "collection": "Arte Natural",
+ "vendor": "Arte",
+ "image_url": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/muga-silk.jpg?v=1761578887",
+ "description": "Luxurious silk wallcovering in seafoam green for a sophisticated, textured wall surface.",
+ "tags": ["Seafoam Green", "Silk", "Luxury", "Textured"],
+ "price": 145.00,
+ "hex": "#93c5b5"
+ },
+ {
+ "sku": "67731",
+ "title": "Flemish Flax",
+ "color": "Pale Green",
+ "collection": "Arte Natural",
+ "vendor": "Arte",
+ "image_url": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/flemish-flax.jpg?v=1761578848",
+ "description": "Natural flax wallcovering in pale green with an organic, artisanal character.",
+ "tags": ["Pale Green", "Natural", "Textured", "Organic"],
+ "price": 125.00,
+ "hex": "#b5c9a8"
+ },
+ {
+ "sku": "67229",
+ "title": "Macau Wallcovering",
+ "color": "Olive",
+ "collection": "Arte Collection",
+ "vendor": "Arte",
+ "image_url": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/67229.jpg?v=1751966964",
+ "description": "Sophisticated olive-toned wallcovering with an elegant surface texture.",
+ "tags": ["Olive", "Contemporary", "Textured"],
+ "price": 115.00,
+ "hex": "#7a8c5c"
+ },
+ {
+ "sku": "67319",
+ "title": "Fuego Wallcovering",
+ "color": "Dark Gray",
+ "collection": "Arte Collection",
+ "vendor": "Arte",
+ "image_url": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/67319.jpg?v=1751966923",
+ "description": "Bold dark gray wallcovering with dramatic textural depth for statement walls.",
+ "tags": ["Dark Gray", "Bold", "Contemporary", "Dramatic"],
+ "price": 135.00,
+ "hex": "#4a4a4a"
+ },
+ {
+ "sku": "51120",
+ "title": "Stream Wallcovering",
+ "color": "Cream",
+ "collection": "Arte Collection",
+ "vendor": "Arte",
+ "image_url": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/51120.jpg?v=1751966831",
+ "description": "A soft cream wallcovering with a flowing, organic pattern reminiscent of gentle water currents.",
+ "tags": ["Cream", "Organic", "Soft", "Contemporary"],
+ "price": 110.00,
+ "hex": "#f0ead8"
+ },
+ {
+ "sku": "67523",
+ "title": "Cedar Wallcovering",
+ "color": "Dark Gray",
+ "collection": "Arte Collection",
+ "vendor": "Arte",
+ "image_url": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/ARTE-CEDAR_67523__KOROSEAL.jpg?v=1751965554",
+ "description": "Rich dark gray wallcovering inspired by cedar wood grain for a warm, sophisticated interior.",
+ "tags": ["Dark Gray", "Wood-inspired", "Warm", "Sophisticated"],
+ "price": 128.00,
+ "hex": "#5a5a52"
+ },
+ {
+ "sku": "SAMPLE-FLORAL-01",
+ "title": "Garden Toile (Sample Pattern)",
+ "color": "Navy Blue",
+ "collection": "DW Signature",
+ "vendor": "Designer Wallcoverings",
+ "image_url": "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Crect width='200' height='200' fill='%23e8eef5'/%3E%3Ccircle cx='50' cy='50' r='18' fill='none' stroke='%232a4a7f' stroke-width='2'/%3E%3Ccircle cx='150' cy='50' r='18' fill='none' stroke='%232a4a7f' stroke-width='2'/%3E%3Ccircle cx='50' cy='150' r='18' fill='none' stroke='%232a4a7f' stroke-width='2'/%3E%3Ccircle cx='150' cy='150' r='18' fill='none' stroke='%232a4a7f' stroke-width='2'/%3E%3Ccircle cx='100' cy='100' r='28' fill='none' stroke='%232a4a7f' stroke-width='2'/%3E%3Cline x1='68' y1='50' x2='132' y2='50' stroke='%232a4a7f' stroke-width='1'/%3E%3Cline x1='50' y1='68' x2='50' y2='132' stroke='%232a4a7f' stroke-width='1'/%3E%3Cline x1='68' y1='150' x2='132' y2='150' stroke='%232a4a7f' stroke-width='1'/%3E%3Cline x1='150' y1='68' x2='150' y2='132' stroke='%232a4a7f' stroke-width='1'/%3E%3C/svg%3E",
+ "description": "[SAMPLE — representative pattern] Classic toile-inspired geometric in navy and ice blue.",
+ "tags": ["Navy", "Blue", "Traditional", "Toile", "Geometric"],
+ "price": 99.00,
+ "hex": "#2a4a7f",
+ "is_sample": true
+ }
]
diff --git a/data/rooms.json b/data/rooms.json
new file mode 100644
index 0000000..925b0da
--- /dev/null
+++ b/data/rooms.json
@@ -0,0 +1,26 @@
+[
+ {
+ "id": "living-room",
+ "label": "Modern Living Room",
+ "src": "/rooms/room-living-room.svg",
+ "wall_region": { "x": 0, "y": 0, "w": 100, "h": 70 }
+ },
+ {
+ "id": "bedroom",
+ "label": "Cozy Bedroom",
+ "src": "/rooms/room-bedroom.svg",
+ "wall_region": { "x": 0, "y": 0, "w": 100, "h": 72 }
+ },
+ {
+ "id": "dining-room",
+ "label": "Elegant Dining Room",
+ "src": "/rooms/room-dining-room.svg",
+ "wall_region": { "x": 0, "y": 0, "w": 100, "h": 52 }
+ },
+ {
+ "id": "home-office",
+ "label": "Home Office",
+ "src": "/rooms/room-home-office.svg",
+ "wall_region": { "x": 0, "y": 0, "w": 100, "h": 70 }
+ }
+]
diff --git a/public/admin.html b/public/admin.html
new file mode 100644
index 0000000..6cf2e13
--- /dev/null
+++ b/public/admin.html
@@ -0,0 +1,235 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Reno Visualizer — Admin</title>
+<style>
+:root {
+ --brand: #2a4a3e;
+ --accent: #c8a86a;
+ --border: #e4e0d8;
+ --surface: #fafaf8;
+ --card-bg: #fff;
+ --muted: #7a7870;
+ --cols: 2;
+}
+*,*::before,*::after { box-sizing: border-box; margin:0; padding:0; }
+body { font: 14px/1.5 -apple-system,'Segoe UI',Roboto,sans-serif; color: #1a1a18; background: var(--surface); }
+
+.nav {
+ display: flex; align-items: center; gap: 16px; padding: 12px 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 h1 { font-size: 16px; font-weight: 700; }
+.nav .badge { font-size: 11px; background: rgba(255,255,255,0.15); border-radius: 12px; padding: 2px 10px; }
+.nav-spacer { flex: 1; }
+.nav a { color: rgba(255,255,255,0.75); font-size: 12px; text-decoration: none; }
+.nav a:hover { color: #fff; }
+
+.toolbar {
+ display: flex; gap: 14px; align-items: center; flex-wrap: wrap;
+ padding: 14px 24px; background: #fff; border-bottom: 1px solid var(--border);
+ position: sticky; top: 52px; z-index: 10;
+}
+.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; }
+.toolbar .spacer { flex: 1; }
+.count-badge { font-size: 11px; color: var(--muted); background: var(--surface); border: 1px solid var(--border);
+ padding: 3px 10px; border-radius: 12px; }
+
+.grid {
+ display: grid;
+ grid-template-columns: repeat(var(--cols), 1fr);
+ gap: 16px;
+ padding: 24px;
+}
+
+.card {
+ background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px;
+ overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,0.06);
+}
+.card-header {
+ display: flex; gap: 12px; align-items: flex-start; padding: 14px 16px 10px;
+ border-bottom: 1px solid var(--border); background: var(--surface);
+}
+.card-pattern-thumb {
+ width: 56px; height: 56px; object-fit: cover; border-radius: 6px;
+ flex-shrink: 0; background: #e8e4dc; border: 1px solid var(--border);
+}
+.card-meta { flex: 1; min-width: 0; }
+.card-meta h3 { font-size: 14px; font-weight: 700; color: var(--brand); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+.card-meta .sub { font-size: 11px; color: var(--muted); margin-top: 2px; }
+.card-meta .sku { font-size: 11px; font-family: monospace; background: #f0ede8; border-radius: 4px; padding: 1px 5px; }
+.card-body { padding: 12px 16px; }
+.card-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
+.card-row:last-child { margin-bottom: 0; }
+.card-key { font-size: 11px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; }
+.card-val { font-size: 12px; color: #1a1a18; text-align: right; max-width: 60%; word-break: break-word; }
+.card-val.email { color: var(--brand); font-weight: 600; }
+.card-val.interest { background: #f0f5f0; color: var(--brand); border-radius: 10px; padding: 2px 8px; font-size: 11px; }
+
+/* HARD RULE: created date+time always visible on every admin card */
+.card-when {
+ display: flex; align-items: center; gap: 5px;
+ font-size: 11px; color: var(--muted); margin-top: 10px;
+ padding-top: 8px; border-top: 1px solid var(--border);
+}
+.card-when .clock { opacity: 0.7; }
+.card-id { font-size: 10px; font-family: monospace; color: #b0b0a8; margin-left: auto; }
+
+.empty-state { padding: 60px 24px; text-align: center; color: var(--muted); font-size: 15px; }
+.empty-state .big { font-size: 40px; display: block; margin-bottom: 12px; }
+
+.loading { text-align: center; padding: 40px; color: var(--muted); }
+.spinner { display: inline-block; width: 20px; height: 20px; border: 2px solid #e0e0d8;
+ border-top-color: var(--brand); border-radius: 50%; animation: spin 0.6s linear infinite; }
+@keyframes spin { to { transform: rotate(360deg); } }
+</style>
+</head>
+<body>
+
+<nav class="nav">
+ <h1>Reno Visualizer Admin</h1>
+ <span class="badge" id="nav-count">0 leads</span>
+ <span class="nav-spacer"></span>
+ <a href="/">← Back to Visualizer</a>
+</nav>
+
+<div class="toolbar">
+ <label>Sort
+ <select id="sort-sel" onchange="loadLeads()">
+ <option value="newest">Newest first</option>
+ <option value="oldest">Oldest first</option>
+ </select>
+ </label>
+ <label>Density <input type="range" id="density-slider" min="1" max="4" value="2" oninput="onDensityChange()"></label>
+ <span class="spacer"></span>
+ <span class="count-badge" id="count-badge">Loading…</span>
+</div>
+
+<div id="grid-wrap">
+ <div class="loading"><span class="spinner"></span></div>
+</div>
+
+<script>
+const $ = s => document.querySelector(s);
+function esc(s) { return (s||'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); }
+
+/* HARD RULE — created date+time on every card, both human readable + ISO in title= */
+function fmtDate(iso) {
+ if (!iso) return '—';
+ try {
+ return new Date(iso).toLocaleString(undefined, {
+ year: 'numeric', month: 'short', day: 'numeric',
+ hour: 'numeric', minute: '2-digit'
+ });
+ } catch { return iso; }
+}
+
+function interestLabel(v) {
+ return { order: 'Order rolls', sample: 'Request sample', consult: 'Design consult', quote: 'Get a quote' }[v] || v || '—';
+}
+
+function renderCard(lead) {
+ return `<div class="card">
+ <div class="card-header">
+ ${lead.image_url
+ ? `<img class="card-pattern-thumb" src="${esc(lead.image_url)}" alt="${esc(lead.pattern_title||'')}" onerror="this.style.background='#e8e4dc'">`
+ : `<div class="card-pattern-thumb" style="background:${esc(lead.pattern_color||'#e8e4dc')};display:flex;align-items:center;justify-content:center;font-size:22px;">🖼</div>`
+ }
+ <div class="card-meta">
+ <h3>${esc(lead.email)}</h3>
+ ${lead.pattern_title ? `<div class="sub">${esc(lead.pattern_title)}</div>` : ''}
+ ${lead.pattern_sku ? `<div class="sub"><span class="sku">${esc(lead.pattern_sku)}</span></div>` : ''}
+ </div>
+ </div>
+ <div class="card-body">
+ <div class="card-row">
+ <span class="card-key">Room</span>
+ <span class="card-val">${esc(lead.room_label||'—')}</span>
+ </div>
+ <div class="card-row">
+ <span class="card-key">Vendor</span>
+ <span class="card-val">${esc(lead.pattern_vendor||'—')}</span>
+ </div>
+ <div class="card-row">
+ <span class="card-key">Interest</span>
+ <span class="card-val interest">${esc(interestLabel(lead.interest))}</span>
+ </div>
+ ${lead.message ? `<div class="card-row" style="align-items:flex-start;">
+ <span class="card-key">Notes</span>
+ <span class="card-val" style="font-style:italic;color:#5a5a50;">${esc(lead.message)}</span>
+ </div>` : ''}
+ <!-- HARD RULE: created date+time, full ISO in title= -->
+ <div class="card-when">
+ <span class="clock">🕓</span>
+ <span title="${esc(lead.created_at||'')}">${fmtDate(lead.created_at)}</span>
+ <span class="card-id">${esc(lead.id||'')}</span>
+ </div>
+ </div>
+ </div>`;
+}
+
+async function loadLeads() {
+ const sort = $('#sort-sel').value;
+ try {
+ const r = await fetch('/api/leads?sort=' + encodeURIComponent(sort));
+ if (r.status === 401) {
+ document.body.innerHTML = '<div style="text-align:center;padding:80px;font-size:16px;color:#666;">Access denied — admin auth required.</div>';
+ return;
+ }
+ const d = await r.json();
+ const leads = d.leads || [];
+ $('#nav-count').textContent = leads.length + ' lead' + (leads.length !== 1 ? 's' : '');
+ $('#count-badge').textContent = leads.length + ' captured looks';
+
+ if (!leads.length) {
+ $('#grid-wrap').innerHTML = `<div class="empty-state">
+ <span class="big">📋</span>
+ No leads yet — they'll appear here after someone shops a look.
+ </div>`;
+ return;
+ }
+
+ // Enrich with pattern image from cached products list
+ const products = window._products || [];
+ const bySkuImg = {};
+ const bySkuColor = {};
+ products.forEach(p => { bySkuImg[p.sku] = p.image_url || p.image || ''; bySkuColor[p.sku] = p.hex || ''; });
+
+ const cards = leads.map(lead => {
+ lead.image_url = lead.pattern_sku ? (bySkuImg[lead.pattern_sku] || '') : '';
+ lead.pattern_color = lead.pattern_sku ? (bySkuColor[lead.pattern_sku] || '#e8e4dc') : '#e8e4dc';
+ return renderCard(lead);
+ }).join('');
+
+ $('#grid-wrap').innerHTML = `<div class="grid" id="leads-grid">${cards}</div>`;
+ } catch(e) {
+ $('#grid-wrap').innerHTML = '<div class="empty-state">Error loading leads: ' + esc(String(e)) + '</div>';
+ }
+}
+
+function onDensityChange() {
+ const v = $('#density-slider').value;
+ localStorage.setItem('admin-cols', v);
+ document.documentElement.style.setProperty('--cols', v);
+}
+
+// Restore density
+(function() {
+ const v = localStorage.getItem('admin-cols') || '2';
+ $('#density-slider').value = v;
+ document.documentElement.style.setProperty('--cols', v);
+})();
+
+// Load products for enriching leads
+fetch('/api/products').then(r=>r.json()).then(d => {
+ window._products = d.products || [];
+ loadLeads();
+}).catch(() => loadLeads());
+</script>
+</body>
+</html>
diff --git a/public/index.html b/public/index.html
index ceb7ab1..d2ecf08 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,55 +1,888 @@
-<!doctype html><html lang="en"><head><meta charset="utf-8">
-<meta name="viewport" content="width=device-width,initial-scale=1"><title>Storefront starter</title>
+<!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>
- :root{--cols:4}
- *{box-sizing:border-box} body{margin:0;font:15px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;color:#1a1a1a;background:#fafafa}
- header{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:14px 20px;border-bottom:1px solid #e6e6e6;position:sticky;top:0;background:#fff;z-index:5}
- h1{font-size:17px;margin:0;font-weight:700} .spacer{flex:1}
- label{font-size:12px;color:#666;display:flex;gap:6px;align-items:center}
- select,input[type=range]{font:inherit} .count{font-size:12px;color:#999}
- .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:14px;padding:20px}
- .card{background:#fff;border:1px solid #e6e6e6;border-radius:8px;overflow:hidden}
- .card img{width:100%;aspect-ratio:1/1;object-fit:cover;display:block;background:#f0f0f0}
- .meta{padding:8px 10px} .t{font-size:13px;font-weight:600;margin:0 0 2px} .s{font-size:11px;color:#888}
- .p{font-size:13px;margin-top:4px} .empty{padding:40px;color:#999;text-align:center}
-</style></head><body>
-<header>
- <h1>Storefront starter</h1>
- <label>Sort
- <select id="sort">
- <option value="newest">Newest</option>
- <option value="title">Title A→Z</option>
- <option value="sku">SKU 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 id="density" type="range" min="2" max="8" value="4"></label>
- <span class="spacer"></span><span class="count" id="count"></span>
-</header>
-<div class="grid" id="grid"><div class="empty">Loading…</div></div>
+/* ── 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>
+ <a class="nav-cta" href="https://designerwallcoverings.com" target="_blank" rel="noopener">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>
+ <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>
-// House rule: sort + density both persist in localStorage so the choice survives reloads.
-const $=s=>document.querySelector(s), esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
-const sortSel=$('#sort'), dens=$('#density');
-sortSel.value=localStorage.getItem('sort')||'newest'; dens.value=localStorage.getItem('cols')||'4';
-function applyCols(){document.documentElement.style.setProperty('--cols',dens.value);localStorage.setItem('cols',dens.value);}
-applyCols();
-async function load(){
- const r=await fetch('/api/products?sort='+encodeURIComponent(sortSel.value)); const d=await r.json();
- $('#count').textContent=d.count+' items';
- $('#grid').innerHTML = d.products.length ? d.products.map(p=>`
- <div class="card">
- <img src="${esc(p.image||'')}" alt="${esc(p.title||'')}" loading="lazy">
- <div class="meta"><p class="t">${esc(p.title||'Untitled')}</p>
- <div class="s">${esc(p.sku||p.handle||'')}</div>
- ${p.price?`<div class="p">$${Number(p.price).toFixed(2)}</div>`:''}
+/* ── 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.is_sample ? '<span class="pattern-sample-badge">Sample</span>' : ''}</div>
+ <div class="pattern-sub">${esc(p.color || '')}${p.collection ? ' · ' + esc(p.collection) : ''}</div>
+ <div class="pattern-sub">${esc(p.vendor || '')}</div>
+ ${p.price ? `<div class="pattern-price">${fmtPrice(p.price)}/roll</div>` : ''}
</div>
- </div>`).join('') : '<div class="empty">No products yet — fill data/products.json.</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)">${fmtPrice(p.price)}/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 || '');
+ $('#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 || '')} · ${esc(p.color || '')}${p.collection ? ' · ' + esc(p.collection) : ''}</p>
+ <p class="price">${fmtPrice(p.price)}/roll</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 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,
+ 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';
+ } 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);
}
-sortSel.onchange=()=>{localStorage.setItem('sort',sortSel.value);load();};
-dens.oninput=applyCols;
-load();
-</script></body></html>
+
+/* ── Init ── */
+loadRooms();
+loadProducts();
+applyDensity();
+</script>
+</body>
+</html>
diff --git a/public/rooms/room-bedroom.svg b/public/rooms/room-bedroom.svg
new file mode 100644
index 0000000..6358cee
--- /dev/null
+++ b/public/rooms/room-bedroom.svg
@@ -0,0 +1,41 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 500" width="800" height="500">
+ <!-- Floor -->
+ <rect x="0" y="370" width="800" height="130" fill="#d4c4a0"/>
+ <!-- Back wall -->
+ <rect x="0" y="0" width="800" height="370" fill="#ede8e2" id="wall"/>
+ <!-- Floor shadow -->
+ <rect x="0" y="368" width="800" height="4" fill="#b0a080" opacity="0.5"/>
+ <!-- Baseboard -->
+ <rect x="0" y="352" width="800" height="18" fill="#f5f2ee"/>
+ <rect x="0" y="370" width="800" height="2" fill="#d0cbc0"/>
+ <!-- Bed headboard -->
+ <rect x="180" y="160" width="440" height="100" rx="12" fill="#7a6a5a"/>
+ <rect x="190" y="165" width="420" height="90" rx="10" fill="#8b7a6a"/>
+ <!-- Bed frame -->
+ <rect x="160" y="260" width="480" height="120" rx="6" fill="#9a8a7a"/>
+ <!-- Mattress/bedding -->
+ <rect x="170" y="250" width="460" height="105" rx="6" fill="#f0ece8"/>
+ <!-- Pillows -->
+ <rect x="185" y="255" width="130" height="55" rx="8" fill="#e8ddd0"/>
+ <rect x="330" y="255" width="140" height="55" rx="8" fill="#e0d5c8"/>
+ <rect x="485" y="255" width="130" height="55" rx="8" fill="#e8ddd0"/>
+ <!-- Duvet fold -->
+ <rect x="170" y="310" width="460" height="45" rx="4" fill="#d8cfc4"/>
+ <!-- Nightstand left -->
+ <rect x="65" y="300" width="90" height="70" rx="4" fill="#8b7a6a"/>
+ <rect x="75" y="325" width="70" height="30" rx="2" fill="#7a6a5a"/>
+ <!-- Nightstand lamp left -->
+ <rect x="100" y="275" width="8" height="30" fill="#c8b89a"/>
+ <ellipse cx="104" cy="272" rx="20" ry="10" fill="#f0e8c8" opacity="0.9"/>
+ <!-- Nightstand right -->
+ <rect x="645" y="300" width="90" height="70" rx="4" fill="#8b7a6a"/>
+ <rect x="655" y="325" width="70" height="30" rx="2" fill="#7a6a5a"/>
+ <!-- Nightstand lamp right -->
+ <rect x="685" y="275" width="8" height="30" fill="#c8b89a"/>
+ <ellipse cx="689" cy="272" rx="20" ry="10" fill="#f0e8c8" opacity="0.9"/>
+ <!-- Wall art -->
+ <rect x="330" y="50" width="140" height="90" rx="3" fill="#c8bca8"/>
+ <rect x="336" y="56" width="128" height="78" rx="2" fill="#a8b8c8"/>
+ <!-- Area rug suggestion -->
+ <ellipse cx="400" cy="390" rx="200" ry="25" fill="#b8a888" opacity="0.4"/>
+</svg>
\ No newline at end of file
diff --git a/public/rooms/room-dining-room.svg b/public/rooms/room-dining-room.svg
new file mode 100644
index 0000000..2b5cd2d
--- /dev/null
+++ b/public/rooms/room-dining-room.svg
@@ -0,0 +1,51 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 500" width="800" height="500">
+ <!-- Floor (hardwood) -->
+ <rect x="0" y="360" width="800" height="140" fill="#a07850"/>
+ <!-- Floor planks -->
+ <line x1="0" y1="380" x2="800" y2="380" stroke="#906840" stroke-width="1" opacity="0.4"/>
+ <line x1="0" y1="400" x2="800" y2="400" stroke="#906840" stroke-width="1" opacity="0.4"/>
+ <line x1="0" y1="420" x2="800" y2="420" stroke="#906840" stroke-width="1" opacity="0.4"/>
+ <line x1="0" y1="440" x2="800" y2="440" stroke="#906840" stroke-width="1" opacity="0.4"/>
+ <!-- Back wall -->
+ <rect x="0" y="0" width="800" height="360" fill="#f0ebe4" id="wall"/>
+ <!-- Wainscoting -->
+ <rect x="0" y="260" width="800" height="100" fill="#e8e0d5"/>
+ <rect x="0" y="258" width="800" height="4" fill="#d0c8bc"/>
+ <rect x="0" y="358" width="800" height="4" fill="#c0b8ac"/>
+ <!-- Wainscoting panels -->
+ <rect x="20" y="272" width="110" height="74" rx="2" fill="none" stroke="#c8c0b4" stroke-width="2"/>
+ <rect x="150" y="272" width="110" height="74" rx="2" fill="none" stroke="#c8c0b4" stroke-width="2"/>
+ <rect x="280" y="272" width="240" height="74" rx="2" fill="none" stroke="#c8c0b4" stroke-width="2"/>
+ <rect x="540" y="272" width="110" height="74" rx="2" fill="none" stroke="#c8c0b4" stroke-width="2"/>
+ <rect x="670" y="272" width="110" height="74" rx="2" fill="none" stroke="#c8c0b4" stroke-width="2"/>
+ <!-- Dining table -->
+ <ellipse cx="400" cy="400" rx="220" ry="30" fill="#6b5040"/>
+ <rect x="185" y="370" width="430" height="35" rx="4" fill="#7a5e48"/>
+ <rect x="220" y="405" width="14" height="50" fill="#6b5040"/>
+ <rect x="566" y="405" width="14" height="50" fill="#6b5040"/>
+ <!-- Chairs -->
+ <rect x="120" y="360" width="70" height="40" rx="5" fill="#5a4030"/>
+ <rect x="130" y="320" width="50" height="45" rx="5" fill="#5a4030"/>
+ <rect x="610" y="360" width="70" height="40" rx="5" fill="#5a4030"/>
+ <rect x="620" y="320" width="50" height="45" rx="5" fill="#5a4030"/>
+ <!-- Centerpiece/candles on table -->
+ <rect x="385" y="355" width="30" height="18" rx="2" fill="#c8a870"/>
+ <line x1="395" y1="355" x2="395" y2="348" stroke="#e0c090" stroke-width="2"/>
+ <line x1="400" y1="355" x2="400" y2="345" stroke="#e0c090" stroke-width="2"/>
+ <line x1="405" y1="355" x2="405" y2="349" stroke="#e0c090" stroke-width="2"/>
+ <!-- Chandelier -->
+ <line x1="400" y1="0" x2="400" y2="80" stroke="#8b7355" stroke-width="3"/>
+ <ellipse cx="400" cy="90" rx="60" ry="15" fill="#c8b070" opacity="0.9"/>
+ <circle cx="370" cy="95" r="6" fill="#f0e080" opacity="0.9"/>
+ <circle cx="400" cy="100" r="6" fill="#f0e080" opacity="0.9"/>
+ <circle cx="430" cy="95" r="6" fill="#f0e080" opacity="0.9"/>
+ <!-- Wall sconces -->
+ <rect x="100" y="130" width="6" height="30" fill="#c8b070"/>
+ <ellipse cx="103" cy="128" rx="15" ry="8" fill="#f0e080" opacity="0.8"/>
+ <rect x="694" y="130" width="6" height="30" fill="#c8b070"/>
+ <ellipse cx="697" cy="128" rx="15" ry="8" fill="#f0e080" opacity="0.8"/>
+ <!-- Mirror/art above wainscoting -->
+ <rect x="310" y="80" width="180" height="130" rx="4" fill="#c8bcac"/>
+ <rect x="316" y="86" width="168" height="118" rx="3" fill="#a8c0b8"/>
+ <rect x="316" y="86" width="168" height="118" rx="3" fill="url(#mirrorGrad)" opacity="0.3"/>
+</svg>
\ No newline at end of file
diff --git a/public/rooms/room-home-office.svg b/public/rooms/room-home-office.svg
new file mode 100644
index 0000000..38ab16c
--- /dev/null
+++ b/public/rooms/room-home-office.svg
@@ -0,0 +1,60 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 500" width="800" height="500">
+ <!-- Floor -->
+ <rect x="0" y="365" width="800" height="135" fill="#c0b090"/>
+ <!-- Back wall -->
+ <rect x="0" y="0" width="800" height="365" fill="#eae6e0" id="wall"/>
+ <!-- Floor shadow -->
+ <rect x="0" y="363" width="800" height="4" fill="#a09060" opacity="0.5"/>
+ <!-- Baseboard -->
+ <rect x="0" y="348" width="800" height="17" fill="#f2eee8"/>
+ <!-- Desk -->
+ <rect x="120" y="290" width="560" height="85" rx="4" fill="#7a6a52"/>
+ <rect x="130" y="375" width="18" height="60" rx="2" fill="#6a5a42"/>
+ <rect x="652" y="375" width="18" height="60" rx="2" fill="#6a5a42"/>
+ <!-- Monitor -->
+ <rect x="335" y="210" width="190" height="125" rx="6" fill="#2a2a2a"/>
+ <rect x="340" y="215" width="180" height="110" rx="4" fill="#1a2a3a"/>
+ <!-- Monitor stand -->
+ <rect x="418" y="335" width="24" height="20" fill="#2a2a2a"/>
+ <rect x="408" y="352" width="44" height="6" rx="2" fill="#2a2a2a"/>
+ <!-- Keyboard -->
+ <rect x="340" y="340" width="180" height="28" rx="4" fill="#3a3a3a"/>
+ <!-- Books on desk -->
+ <rect x="140" y="258" width="14" height="35" rx="1" fill="#8b3a2a"/>
+ <rect x="155" y="262" width="12" height="31" rx="1" fill="#2a5a8b"/>
+ <rect x="168" y="260" width="14" height="33" rx="1" fill="#4a8b3a"/>
+ <rect x="183" y="264" width="10" height="29" rx="1" fill="#8b7a2a"/>
+ <!-- Desk lamp -->
+ <rect x="590" y="285" width="6" height="40" fill="#8b8888"/>
+ <line x1="593" y1="285" x2="625" y2="268" stroke="#8b8888" stroke-width="5"/>
+ <ellipse cx="628" cy="267" rx="18" ry="10" fill="#f0e8c0" opacity="0.9"/>
+ <!-- Coffee mug -->
+ <rect x="540" y="320" width="24" height="28" rx="4" fill="#6b5a48"/>
+ <path d="M564 328 Q575 330 575 338 Q575 346 564 348" fill="none" stroke="#6b5a48" stroke-width="3"/>
+ <!-- Chair -->
+ <rect x="330" y="390" width="200" height="20" rx="5" fill="#4a4a4a"/>
+ <rect x="350" y="410" width="160" height="12" rx="3" fill="#3a3a3a"/>
+ <rect x="388" y="422" width="24" height="40" fill="#3a3a3a"/>
+ <rect x="355" y="462" width="90" height="8" rx="3" fill="#2a2a2a"/>
+ <!-- Bookshelf on wall -->
+ <rect x="60" y="100" width="160" height="220" rx="4" fill="#9a8a72"/>
+ <rect x="65" y="105" width="150" height="5" fill="#8a7a62"/>
+ <rect x="65" y="175" width="150" height="5" fill="#8a7a62"/>
+ <rect x="65" y="245" width="150" height="5" fill="#8a7a62"/>
+ <!-- Books on shelf -->
+ <rect x="68" y="110" width="10" height="63" rx="1" fill="#c03030"/>
+ <rect x="80" y="115" width="8" height="58" rx="1" fill="#3060a0"/>
+ <rect x="90" y="112" width="12" height="61" rx="1" fill="#309030"/>
+ <rect x="104" y="118" width="8" height="55" rx="1" fill="#a08030"/>
+ <rect x="68" y="182" width="12" height="61" rx="1" fill="#802080"/>
+ <rect x="82" y="185" width="10" height="58" rx="1" fill="#308080"/>
+ <rect x="94" y="180" width="8" height="63" rx="1" fill="#c06030"/>
+ <!-- Plant on shelf -->
+ <rect x="160" y="218" width="15" height="25" rx="2" fill="#8b7355"/>
+ <ellipse cx="168" cy="213" rx="14" ry="18" fill="#3a6a2a" opacity="0.85"/>
+ <!-- Art on wall -->
+ <rect x="580" y="80" width="170" height="130" rx="3" fill="#c8bcac"/>
+ <rect x="586" y="86" width="158" height="118" rx="2" fill="#d0c0a0"/>
+ <circle cx="665" cy="145" r="35" fill="#8b3a2a" opacity="0.5"/>
+ <rect x="620" y="110" width="90" height="2" fill="#6b5a3a" opacity="0.4"/>
+</svg>
\ No newline at end of file
diff --git a/public/rooms/room-living-room.svg b/public/rooms/room-living-room.svg
new file mode 100644
index 0000000..af86a5e
--- /dev/null
+++ b/public/rooms/room-living-room.svg
@@ -0,0 +1,41 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 800 500" width="800" height="500">
+ <!-- Floor -->
+ <rect x="0" y="350" width="800" height="150" fill="#c8b89a"/>
+ <!-- Back wall (the wall we'll texture) -->
+ <rect x="0" y="0" width="800" height="350" fill="#e8e4dc" id="wall"/>
+ <!-- Floor line shadow -->
+ <rect x="0" y="348" width="800" height="4" fill="#a89878" opacity="0.5"/>
+ <!-- Left wall panel shadow -->
+ <rect x="0" y="0" width="4" height="350" fill="#c0bbb0" opacity="0.4"/>
+ <!-- Right wall panel shadow -->
+ <rect x="796" y="0" width="4" height="350" fill="#c0bbb0" opacity="0.4"/>
+ <!-- Ceiling line -->
+ <rect x="0" y="0" width="800" height="4" fill="#d0ccc4" opacity="0.6"/>
+ <!-- Baseboard -->
+ <rect x="0" y="335" width="800" height="15" fill="#f0ece8"/>
+ <rect x="0" y="349" width="800" height="2" fill="#d0ccc4"/>
+ <!-- Sofa -->
+ <rect x="150" y="295" width="500" height="70" rx="8" fill="#8b7d6b"/>
+ <rect x="150" y="260" width="500" height="40" rx="6" fill="#9d8e7c"/>
+ <rect x="150" y="255" width="50" height="85" rx="4" fill="#9d8e7c"/>
+ <rect x="600" y="255" width="50" height="85" rx="4" fill="#9d8e7c"/>
+ <!-- Sofa cushions -->
+ <rect x="165" y="265" width="140" height="30" rx="4" fill="#b0a090" opacity="0.8"/>
+ <rect x="330" y="265" width="140" height="30" rx="4" fill="#b0a090" opacity="0.8"/>
+ <rect x="495" y="265" width="140" height="30" rx="4" fill="#b0a090" opacity="0.8"/>
+ <!-- Coffee table -->
+ <rect x="290" y="360" width="220" height="10" rx="3" fill="#6b5d4f"/>
+ <rect x="300" y="370" width="8" height="25" fill="#6b5d4f"/>
+ <rect x="492" y="370" width="8" height="25" fill="#6b5d4f"/>
+ <!-- Picture frame on wall -->
+ <rect x="340" y="60" width="120" height="90" rx="3" fill="#d4c8b8"/>
+ <rect x="345" y="65" width="110" height="80" rx="2" fill="#b8c8d8"/>
+ <!-- Floor lamp -->
+ <line x1="100" y1="350" x2="100" y2="200" stroke="#8b7355" stroke-width="4"/>
+ <ellipse cx="100" cy="195" rx="30" ry="15" fill="#e8d8b8" opacity="0.9"/>
+ <!-- Plant -->
+ <rect x="670" y="300" width="25" height="50" rx="2" fill="#8b7355"/>
+ <ellipse cx="682" cy="295" rx="25" ry="30" fill="#5a7a4a" opacity="0.85"/>
+ <ellipse cx="665" cy="280" rx="20" ry="25" fill="#4a6a3a" opacity="0.8"/>
+ <ellipse cx="700" cy="278" rx="22" ry="28" fill="#5a7a4a" opacity="0.75"/>
+</svg>
\ No newline at end of file
diff --git a/server.js b/server.js
index 57f5df5..b166217 100644
--- a/server.js
+++ b/server.js
@@ -1,41 +1,231 @@
-// Storefront starter — zero-dep Node http. Serves data/products.json with SERVER-SIDE sort
-// (house rule: every product grid gets sort + a density slider). Optional basic auth via BASIC_AUTH.
-const http = require('http'), fs = require('fs'), path = require('path');
+// reno-visualizer — "see it on your wall" tool for Designer Wallcoverings / Wallpaper's Back
+// Zero-dep Node http server. PORT from env; BASIC_AUTH=user:pass to gate.
+// House rules: sort + density on every grid, admin cards show created date+time.
+// Render seam: /api/render is the single function; swap body for ComfyUI/Replicate later.
+const http = require('http'), fs = require('fs'), path = require('path'), url = require('url');
const PORT = parseInt(process.env.PORT || '3900', 10);
-const BASIC_AUTH = process.env.BASIC_AUTH || ''; // "user:pass" to gate; empty = open
+const BASIC_AUTH = process.env.BASIC_AUTH || ''; // "user:pass" or empty (open)
+const ADMIN_AUTH = process.env.ADMIN_AUTH || BASIC_AUTH; // separate admin gate (falls back to BASIC_AUTH)
const DIR = __dirname;
-function load() { try { return JSON.parse(fs.readFileSync(path.join(DIR, 'data', 'products.json'), 'utf8')); } catch { return []; } }
-function authed(req) {
- if (!BASIC_AUTH) return true;
+// --- helpers ---
+function mime(f) {
+ if (f.endsWith('.html')) return 'text/html; charset=utf-8';
+ if (f.endsWith('.css')) return 'text/css';
+ if (f.endsWith('.js')) return 'application/javascript';
+ if (f.endsWith('.json')) return 'application/json';
+ if (f.endsWith('.png')) return 'image/png';
+ if (f.endsWith('.jpg') || f.endsWith('.jpeg')) return 'image/jpeg';
+ if (f.endsWith('.svg')) return 'image/svg+xml';
+ if (f.endsWith('.ico')) return 'image/x-icon';
+ return 'application/octet-stream';
+}
+function authed(req, creds) {
+ if (!creds) return true;
const m = (req.headers.authorization || '').match(/^Basic\s+(.+)$/i);
- if (!m) return false; try { return Buffer.from(m[1], 'base64').toString() === BASIC_AUTH; } catch { return false; }
+ if (!m) return false;
+ try { return Buffer.from(m[1], 'base64').toString() === creds; } catch { return false; }
+}
+function json(res, data, status) {
+ const body = JSON.stringify(data);
+ res.writeHead(status || 200, { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' });
+ res.end(body);
+}
+function bodyJson(req) {
+ return new Promise((ok, fail) => {
+ let buf = '';
+ req.on('data', c => buf += c);
+ req.on('end', () => { try { ok(JSON.parse(buf)); } catch(e) { fail(e); } });
+ req.on('error', fail);
+ });
+}
+
+// --- products ---
+function loadProducts() {
+ try { return JSON.parse(fs.readFileSync(path.join(DIR, 'data', 'products.json'), 'utf8')); }
+ catch { return []; }
+}
+function luminance(hex) {
+ if (!/^#?[0-9a-f]{6}$/i.test(hex || '')) return 999;
+ const h = hex.replace('#', '');
+ const r = parseInt(h.slice(0,2),16), g = parseInt(h.slice(2,4),16), b = parseInt(h.slice(4,6),16);
+ return 0.2126*r + 0.7152*g + 0.0722*b;
}
-// Sort modes mirror the DW sort-skill: newest, title/sku A→Z, price ↑↓, light→dark (by dominant hex).
-function luminance(hex) { if (!/^#?[0-9a-f]{6}$/i.test(hex || '')) return 999; const h = hex.replace('#', '');
- const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
- return 0.2126 * r + 0.7152 * g + 0.0722 * b; }
function sortProducts(items, mode) {
const a = items.slice();
switch (mode) {
- case 'title': return a.sort((x, y) => (x.title || '').localeCompare(y.title || ''));
- case 'sku': return a.sort((x, y) => (x.sku || x.handle || '').localeCompare(y.sku || y.handle || ''));
- case 'price-asc': return a.sort((x, y) => (x.price || 0) - (y.price || 0));
- case 'price-desc': return a.sort((x, y) => (y.price || 0) - (x.price || 0));
- case 'light': return a.sort((x, y) => luminance(y.hex) - luminance(x.hex));
- case 'dark': return a.sort((x, y) => luminance(x.hex) - luminance(y.hex));
- default: return a; // 'newest' = natural order
+ case 'title': return a.sort((x,y) => (x.title||'').localeCompare(y.title||''));
+ case 'sku': return a.sort((x,y) => (x.sku||'').localeCompare(y.sku||''));
+ case 'price-asc': return a.sort((x,y) => (x.price||0)-(y.price||0));
+ case 'price-desc': return a.sort((x,y) => (y.price||0)-(x.price||0));
+ case 'light': return a.sort((x,y) => luminance(y.hex)-luminance(x.hex));
+ case 'dark': return a.sort((x,y) => luminance(x.hex)-luminance(y.hex));
+ case 'vendor': return a.sort((x,y) => (x.vendor||'').localeCompare(y.vendor||''));
+ default: return a; // 'newest' = natural file order
}
}
+
+// --- rooms ---
+function loadRooms() {
+ try { return JSON.parse(fs.readFileSync(path.join(DIR, 'data', 'rooms.json'), 'utf8')); }
+ catch { return []; }
+}
+
+// --- leads ---
+const LEADS_FILE = path.join(DIR, 'data', 'leads.json');
+function loadLeads() {
+ try { return JSON.parse(fs.readFileSync(LEADS_FILE, 'utf8')); } catch { return []; }
+}
+function saveLead(lead) {
+ const leads = loadLeads();
+ leads.unshift(lead); // newest first
+ fs.mkdirSync(path.join(DIR, 'data'), { recursive: true });
+ fs.writeFileSync(LEADS_FILE, JSON.stringify(leads, null, 2));
+ return lead;
+}
+
+// --- render seam ---
+// PROTOTYPE-GRADE render: returns metadata only; real compositing happens client-side via Canvas.
+// To swap in a real engine (ComfyUI, Replicate), replace this function body.
+// The endpoint contract stays identical: POST /api/render → { render_url, mode, note }
+function computeRender(roomId, patternSku, wallRegion) {
+ // Prototype: client-side canvas compositing is authoritative; this just echoes params back.
+ // A real implementation would: POST to ComfyUI img2img or Replicate stable-diffusion-inpainting
+ // and return the CDN URL of the composited image.
+ return {
+ mode: 'prototype-canvas',
+ note: 'Preview render — prototype quality. Real render engine (ComfyUI/Replicate) plugs in here.',
+ room_id: roomId,
+ pattern_sku: patternSku,
+ wall_region: wallRegion,
+ render_url: null, // client canvas is used instead
+ };
+}
+
+// --- routing ---
http.createServer((req, res) => {
- if (!authed(req)) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="preview"' }); return res.end('auth required'); }
const u = new URL(req.url, 'http://x');
- if (u.pathname === '/api/products') {
- const out = sortProducts(load(), u.searchParams.get('sort') || 'newest');
- res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ count: out.length, products: out }));
- }
- const f = u.pathname === '/' ? 'index.html' : u.pathname.replace(/^\//, '');
- const fp = path.join(DIR, 'public', path.basename(f));
- if (fs.existsSync(fp)) { res.writeHead(200, { 'Content-Type': f.endsWith('.html') ? 'text/html' : 'text/plain' }); return res.end(fs.readFileSync(fp)); }
- res.writeHead(404); res.end('not found');
-}).listen(PORT, function () { console.log('[' + path.basename(DIR) + '] http://localhost:' + this.address().port); });
+ const pathname = u.pathname;
+ const method = req.method.toUpperCase();
+
+ // CORS preflight
+ if (method === 'OPTIONS') {
+ res.writeHead(204, { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS', 'Access-Control-Allow-Headers': 'Content-Type,Authorization' });
+ return res.end();
+ }
+
+ // Admin routes — gated by ADMIN_AUTH
+ if (pathname === '/admin' || pathname === '/admin/') {
+ if (ADMIN_AUTH && !authed(req, ADMIN_AUTH)) {
+ res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="reno-visualizer admin"' });
+ return res.end('Authentication required');
+ }
+ const fp = path.join(DIR, 'public', 'admin.html');
+ if (fs.existsSync(fp)) { res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); return res.end(fs.readFileSync(fp)); }
+ res.writeHead(404); return res.end('admin.html not found');
+ }
+
+ if (pathname === '/api/leads' && method === 'GET') {
+ if (ADMIN_AUTH && !authed(req, ADMIN_AUTH)) {
+ res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="reno-visualizer admin"' });
+ return res.end('Authentication required');
+ }
+ const leads = loadLeads();
+ const sortMode = u.searchParams.get('sort') || 'newest';
+ const sorted = sortMode === 'oldest' ? leads.slice().reverse() : leads;
+ return json(res, { count: sorted.length, leads: sorted });
+ }
+
+ // Main site basic-auth gate (optional)
+ if (BASIC_AUTH && !authed(req, BASIC_AUTH)) {
+ res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="reno-visualizer"' });
+ return res.end('Authentication required');
+ }
+
+ // --- API routes (open or gated above) ---
+ if (pathname === '/api/rooms' && method === 'GET') {
+ return json(res, { rooms: loadRooms() });
+ }
+
+ if (pathname === '/api/products' && method === 'GET') {
+ const sort = u.searchParams.get('sort') || 'newest';
+ const products = sortProducts(loadProducts(), sort);
+ return json(res, { count: products.length, products });
+ }
+
+ if (pathname === '/api/lead' && method === 'POST') {
+ bodyJson(req).then(body => {
+ if (!body.email) return json(res, { error: 'email required' }, 400);
+ const lead = {
+ id: Date.now().toString(36) + Math.random().toString(36).slice(2,6),
+ email: body.email,
+ pattern_sku: body.pattern_sku || null,
+ pattern_title: body.pattern_title || null,
+ pattern_vendor: body.pattern_vendor || null,
+ room_label: body.room_label || null,
+ message: body.message || null,
+ created_at: new Date().toISOString(),
+ };
+ saveLead(lead);
+ json(res, { ok: true, lead });
+ }).catch(() => json(res, { error: 'invalid JSON' }, 400));
+ return;
+ }
+
+ // Room photo upload endpoint
+ if (pathname === '/api/upload-room' && method === 'POST') {
+ const ct = req.headers['content-type'] || '';
+ if (!ct.startsWith('multipart/form-data') && !ct.startsWith('application/octet-stream') && !ct.startsWith('image/')) {
+ // Accept base64 JSON upload for simplicity (no multipart parsing needed with zero deps)
+ bodyJson(req).then(body => {
+ if (!body.data || !body.mime) return json(res, { error: 'data and mime required' }, 400);
+ const b64 = body.data.replace(/^data:[^;]+;base64,/, '');
+ const buf = Buffer.from(b64, 'base64');
+ const ext = body.mime.includes('png') ? 'png' : body.mime.includes('gif') ? 'gif' : 'jpg';
+ const fname = `upload-${Date.now()}.${ext}`;
+ const uploadsDir = path.join(DIR, 'uploads');
+ fs.mkdirSync(uploadsDir, { recursive: true });
+ fs.writeFileSync(path.join(uploadsDir, fname), buf);
+ json(res, { ok: true, url: `/uploads/${fname}` });
+ }).catch(() => json(res, { error: 'invalid body' }, 400));
+ return;
+ }
+ // Fallback: reject binary multipart (not supported in zero-dep server)
+ return json(res, { error: 'send base64 JSON: {data, mime}' }, 415);
+ }
+
+ // Render seam endpoint — prototype returns metadata; real engine replaces body
+ if (pathname === '/api/render' && method === 'POST') {
+ bodyJson(req).then(body => {
+ const result = computeRender(body.room_id, body.pattern_sku, body.wall_region);
+ json(res, result);
+ }).catch(() => json(res, { error: 'invalid JSON' }, 400));
+ return;
+ }
+
+ // Static file serving: public/ (including subdirectories like /rooms/)
+ let filePath = pathname === '/' ? 'index.html' : pathname.replace(/^\//, '');
+ // Prevent path traversal
+ const safeFile = path.normalize(filePath).replace(/^(\.\.(\/|\\|$))+/, '');
+ const fp = path.join(DIR, 'public', safeFile);
+ // Also allow serving from /uploads/ directory
+ const uploadsFile = path.join(DIR, 'uploads', safeFile.replace(/^uploads\//, ''));
+ if (fs.existsSync(fp) && fs.statSync(fp).isFile()) {
+ res.writeHead(200, { 'Content-Type': mime(fp) });
+ return res.end(fs.readFileSync(fp));
+ }
+ // Serve uploads (user-uploaded room photos)
+ if (pathname.startsWith('/uploads/') && fs.existsSync(uploadsFile) && fs.statSync(uploadsFile).isFile()) {
+ res.writeHead(200, { 'Content-Type': mime(uploadsFile) });
+ return res.end(fs.readFileSync(uploadsFile));
+ }
+
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
+ res.end('Not found: ' + pathname);
+
+}).listen(PORT, function () {
+ console.log('[reno-visualizer] http://localhost:' + this.address().port);
+ console.log(' Admin: http://localhost:' + this.address().port + '/admin');
+ if (BASIC_AUTH) console.log(' Site auth: ' + BASIC_AUTH.split(':')[0] + ':***');
+ if (ADMIN_AUTH) console.log(' Admin auth: ' + ADMIN_AUTH.split(':')[0] + ':***');
+});
← 05cf165 initial scaffold (reno-visualizer) via web-dev accelerator
·
back to Reno Visualizer
·
data: seed test lead from verification run 50ae83a →