[object Object]

← back to Estimate Instant

estimate-instant v0: rolls-needed + instant-price calculator (server-side math), email capture, admin quotes view

25a1126d6a6882b99112ef987dd4684fa8549b4b · 2026-07-14 10:10:04 -0700 · Steve

Files touched

Diff

commit 25a1126d6a6882b99112ef987dd4684fa8549b4b
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jul 14 10:10:04 2026 -0700

    estimate-instant v0: rolls-needed + instant-price calculator (server-side math), email capture, admin quotes view
---
 .gitignore         |   1 +
 BRIEF.md           |  19 +++++--
 data/products.json |   6 ---
 data/rolls.json    |  10 ++++
 public/admin.html  |  33 ++++++++++++
 public/index.html  | 156 ++++++++++++++++++++++++++++++++++++-----------------
 server.js          |  97 +++++++++++++++++++++++++--------
 7 files changed, 240 insertions(+), 82 deletions(-)

diff --git a/.gitignore b/.gitignore
index 1924158..d315d0a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ tmp/
 dist/
 build/
 .next/
+data/leads.json
diff --git a/BRIEF.md b/BRIEF.md
index 35d44f8..0eacd78 100644
--- a/BRIEF.md
+++ b/BRIEF.md
@@ -1,5 +1,16 @@
 # estimate-instant — brief
-- Client:
-- Outcome / "high value":
-- Constraints (brand, domain, deadline):
-- Reference / competitor URLs:
+- **Client:** Designer Wallcoverings (DW) + every sister microsite; the calculator is also an embeddable widget across the 200+ site fleet.
+- **Outcome / "high value":** Remove the #1 pre-purchase bounce point — "how many rolls do I need?" Answer it instantly (rolls-needed with pattern-repeat + waste math) + show an instant price, and capture the shopper's email + intent on every use. Small AOV lift × huge traffic base; becomes the lead-capture layer for the other accelerator ideas.
+- **Constraints (brand, domain, deadline):** DW house look (gold kicker / ink accent). House rules: admin cards show created date+time; preview gated behind `admin / DW2024!`. `$0-local`, zero-dependency Node. No deploy/DNS/publish without Steve's explicit go.
+- **Reference / competitor URLs:** internal — mirrors the DW sort-skill / dw-site-build conventions.
+
+## Build (v0 — approach A, committed by DTD)
+- **Data:** `data/rolls.json` — representative DW roll specs (width, length, pattern-repeat, match, price). Real `dw_unified` roll-spec wiring is the planned NEXT increment.
+- **Core logic (server-side, `server.js` `estimate()`):** trade-standard strip method — strips = ceil(wallWidth / rollWidth); cut length per strip = (wallHeight + 4" trim) rounded up to the next full pattern repeat; strips/roll = floor(rollLength / cutLength); rolls = ceil(strips / strips-per-roll); price = rolls × price/roll; plus est. waste %.
+- **Surfaces:** `/` calculator (ft/in toggle, pattern picker, instant result + email capture) · `/admin.html` captured-quotes cards (created date+time) · `/api/rolls` · `POST /api/estimate` · `POST /api/lead` · `/api/leads`.
+
+## Run
+```bash
+PORT=3900 node server.js                              # open
+BASIC_AUTH="admin:DW2024!" PORT=3900 node server.js   # gated preview
+```
diff --git a/data/products.json b/data/products.json
deleted file mode 100644
index 8eee5f2..0000000
--- a/data/products.json
+++ /dev/null
@@ -1,6 +0,0 @@
-[
-  { "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": "" }
-]
diff --git a/data/rolls.json b/data/rolls.json
new file mode 100644
index 0000000..05fbef8
--- /dev/null
+++ b/data/rolls.json
@@ -0,0 +1,10 @@
+[
+  { "sku": "DW-GRAS-01", "pattern": "Grasscloth Natural", "roll_width_in": 36, "roll_length_ft": 24, "pattern_repeat_in": 0, "match": "random", "price_per_roll": 128, "hex": "#c8b89a" },
+  { "sku": "DW-DAMA-14", "pattern": "Heirloom Damask", "roll_width_in": 27, "roll_length_ft": 27, "pattern_repeat_in": 25, "match": "straight", "price_per_roll": 96, "hex": "#3a4a5a" },
+  { "sku": "DW-FLOR-22", "pattern": "Garden Chintz", "roll_width_in": 20.5, "roll_length_ft": 33, "pattern_repeat_in": 21, "match": "half-drop", "price_per_roll": 84, "hex": "#7a9a6a" },
+  { "sku": "DW-GEOM-07", "pattern": "Deco Fan", "roll_width_in": 27, "roll_length_ft": 27, "pattern_repeat_in": 12.5, "match": "straight", "price_per_roll": 110, "hex": "#d4c04a" },
+  { "sku": "DW-STRP-03", "pattern": "Regency Stripe", "roll_width_in": 21, "roll_length_ft": 33, "pattern_repeat_in": 0, "match": "random", "price_per_roll": 72, "hex": "#8a2a2a" },
+  { "sku": "DW-TOIL-09", "pattern": "Provence Toile", "roll_width_in": 27, "roll_length_ft": 27, "pattern_repeat_in": 18.75, "match": "straight", "price_per_roll": 118, "hex": "#2a3a4a" },
+  { "sku": "DW-CORK-01", "pattern": "Phillipe Romano Cork", "roll_width_in": 36, "roll_length_ft": 24, "pattern_repeat_in": 0, "match": "random", "price_per_roll": 178.5, "hex": "#b89a6a" },
+  { "sku": "DW-SILK-05", "pattern": "Shantung Silk", "roll_width_in": 36, "roll_length_ft": 24, "pattern_repeat_in": 0, "match": "random", "price_per_roll": 156, "hex": "#e0d4c0" }
+]
diff --git a/public/admin.html b/public/admin.html
new file mode 100644
index 0000000..197f29f
--- /dev/null
+++ b/public/admin.html
@@ -0,0 +1,33 @@
+<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>estimate-instant — captured quotes (admin)</title>
+<style>
+  :root{--ink:#1a1a1a;--line:#e6e6e6;--accent:#2a3a4a;--bg:#f7f5f2}
+  *{box-sizing:border-box}
+  body{margin:0;font:15px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;color:var(--ink);background:var(--bg)}
+  header{display:flex;align-items:baseline;gap:12px;padding:16px 22px;background:#fff;border-bottom:1px solid var(--line);position:sticky;top:0}
+  h1{font-size:17px;margin:0} .count{font-size:12px;color:#999} .spacer{flex:1}
+  a{color:#888;font-size:13px}
+  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:14px;padding:20px}
+  .card{background:#fff;border:1px solid var(--line);border-radius:10px;padding:14px}
+  .email{font-weight:700;font-size:15px;word-break:break-all}
+  .when{display:inline-block;margin-top:6px;font-size:12px;color:#777;background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:2px 7px}
+  .q{margin-top:10px;font-size:13px;color:#555;border-top:1px solid var(--line);padding-top:8px}
+  .q b{color:var(--ink)} .empty{padding:50px;text-align:center;color:#999}
+</style></head><body>
+<header><h1>Captured quotes</h1><span class="count" id="count"></span><span class="spacer"></span><a href="/">← calculator</a></header>
+<div class="grid" id="grid"></div>
+<script>
+const $=id=>document.getElementById(id);
+function fmt(iso){ try{ return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }catch{ return iso; } }
+fetch('/api/leads').then(r=>r.json()).then(({leads})=>{
+  leads=(leads||[]).slice().reverse();
+  $('count').textContent=leads.length+' lead'+(leads.length===1?'':'s');
+  if(!leads.length){ $('grid').innerHTML='<div class="empty">No quotes captured yet.</div>'; return; }
+  $('grid').innerHTML=leads.map(l=>{
+    const q=l.quote; const line=q&&q.ok?`<div class="q">${q.pattern} · <b>${q.rollsNeeded} rolls</b> · <b>$${(q.price||0).toLocaleString()}</b> <span style="color:#999">(${q.sku})</span></div>`:'';
+    return `<div class="card"><div class="email">${l.email}</div><span class="when" title="${l.created_at}">🕓 ${fmt(l.created_at)}</span>${line}</div>`;
+  }).join('');
+});
+</script>
+</body></html>
diff --git a/public/index.html b/public/index.html
index ceb7ab1..63bcec1 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,55 +1,111 @@
 <!doctype html><html lang="en"><head><meta charset="utf-8">
-<meta name="viewport" content="width=device-width,initial-scale=1"><title>Storefront starter</title>
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>How Many Rolls? — Instant Wallpaper Estimate</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}
+  :root{--ink:#1a1a1a;--line:#e6e6e6;--accent:#2a3a4a;--gold:#a98b4a;--bg:#f7f5f2}
+  *{box-sizing:border-box}
+  body{margin:0;font:16px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;color:var(--ink);background:var(--bg)}
+  .wrap{max-width:640px;margin:0 auto;padding:32px 20px 60px}
+  .kicker{font-size:12px;letter-spacing:.18em;text-transform:uppercase;color:var(--gold);font-weight:600;margin:0 0 6px}
+  h1{font-size:30px;line-height:1.15;margin:0 0 6px;font-weight:700}
+  .sub{color:#666;margin:0 0 26px;font-size:15px}
+  .card{background:#fff;border:1px solid var(--line);border-radius:12px;padding:22px}
+  label{display:block;font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:#888;font-weight:600;margin:0 0 6px}
+  .row{display:flex;gap:14px;flex-wrap:wrap}
+  .fld{flex:1;min-width:150px;margin-bottom:16px}
+  input,select{width:100%;font:inherit;padding:11px 12px;border:1px solid #d8d4cd;border-radius:8px;background:#fff}
+  input:focus,select:focus{outline:none;border-color:var(--accent)}
+  .units{display:flex;gap:8px;margin-bottom:16px}
+  .units button{flex:1;font:inherit;font-size:13px;padding:8px;border:1px solid #d8d4cd;background:#fff;border-radius:8px;cursor:pointer;color:#666}
+  .units button.on{background:var(--accent);color:#fff;border-color:var(--accent)}
+  .go{width:100%;font:inherit;font-weight:700;font-size:16px;padding:14px;border:0;border-radius:9px;background:var(--accent);color:#fff;cursor:pointer;margin-top:4px}
+  .go:hover{background:#1d2a36}
+  .result{margin-top:22px;border-top:1px solid var(--line);padding-top:22px;display:none}
+  .result.show{display:block}
+  .big{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap}
+  .rolls{font-size:44px;font-weight:800;line-height:1;color:var(--accent)}
+  .rolls small{font-size:16px;font-weight:600;color:#888}
+  .price{font-size:22px;font-weight:700;margin-left:auto}
+  .detail{margin:16px 0 0;font-size:14px;color:#555;display:grid;grid-template-columns:1fr 1fr;gap:6px 18px}
+  .detail b{color:var(--ink);font-weight:600}
+  .errs{margin-top:16px;background:#fff4f4;border:1px solid #f3c9c9;color:#9a2a2a;border-radius:8px;padding:12px 14px;font-size:14px}
+  .capture{margin-top:20px;background:var(--bg);border:1px dashed #d0cabf;border-radius:10px;padding:16px}
+  .capture .row2{display:flex;gap:10px;flex-wrap:wrap;margin-top:8px}
+  .capture input{flex:1;min-width:180px}
+  .capture button{font:inherit;font-weight:600;padding:11px 16px;border:0;border-radius:8px;background:var(--gold);color:#fff;cursor:pointer}
+  .done{color:#2a6a3a;font-weight:600;font-size:14px;margin-top:8px}
+  .foot{margin-top:20px;font-size:12px;color:#aaa;text-align:center}
+  .foot a{color:#888}
 </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>
-<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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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>`:''}
+<div class="wrap">
+  <p class="kicker">Designer Wallcoverings</p>
+  <h1>How many rolls do I need?</h1>
+  <p class="sub">Enter your wall, pick a pattern — get the exact roll count (with pattern-match waste) and an instant price.</p>
+
+  <div class="card">
+    <div class="units" id="units">
+      <button data-u="ft" class="on">Feet</button>
+      <button data-u="in">Inches</button>
+    </div>
+    <div class="row">
+      <div class="fld"><label>Wall width <span id="uw">(ft)</span></label><input id="w" type="number" min="0" step="0.1" placeholder="e.g. 12"></div>
+      <div class="fld"><label>Wall height <span id="uh">(ft)</span></label><input id="h" type="number" min="0" step="0.1" placeholder="e.g. 9"></div>
+    </div>
+    <div class="fld"><label>Pattern</label><select id="pat"></select></div>
+    <button class="go" id="go">Calculate rolls &amp; price</button>
+
+    <div class="errs" id="errs" style="display:none"></div>
+
+    <div class="result" id="result">
+      <div class="big">
+        <div class="rolls"><span id="rn">0</span> <small>rolls</small></div>
+        <div class="price" id="pr">$0</div>
+      </div>
+      <div class="detail" id="detail"></div>
+      <div class="capture">
+        <label>Email me this quote</label>
+        <div class="row2">
+          <input id="email" type="email" placeholder="you@email.com">
+          <button id="send">Send quote</button>
+        </div>
+        <div class="done" id="done" style="display:none">✓ Sent — we'll email your quote and can help you order.</div>
       </div>
-    </div>`).join('') : '<div class="empty">No products yet — fill data/products.json.</div>';
+    </div>
+  </div>
+  <p class="foot">Estimates include a standard 4" trim + pattern-repeat allowance. Confirm with your DW rep before ordering. · <a href="/admin.html">admin</a></p>
+</div>
+<script>
+let unit='ft', rolls=[], last=null;
+const $=id=>document.getElementById(id);
+function inches(v){ return unit==='ft' ? Number(v)*12 : Number(v); }
+async function loadRolls(){
+  const r=await fetch('/api/rolls').then(r=>r.json()); rolls=r.rolls||[];
+  $('pat').innerHTML=rolls.map(x=>`<option value="${x.sku}">${x.pattern} — ${x.roll_width_in}" wide${x.pattern_repeat_in?`, ${x.pattern_repeat_in}" repeat`:', random match'}</option>`).join('');
+}
+$('units').addEventListener('click',e=>{ const b=e.target.closest('button'); if(!b)return;
+  [...$('units').children].forEach(x=>x.classList.remove('on')); b.classList.add('on');
+  unit=b.dataset.u; $('uw').textContent=$('uh').textContent=`(${unit})`; });
+async function calc(){
+  $('errs').style.display='none'; $('result').classList.remove('show');
+  const payload={ sku:$('pat').value, wallWidthIn:inches($('w').value), wallHeightIn:inches($('h').value) };
+  const r=await fetch('/api/estimate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}).then(r=>r.json());
+  if(!r.ok){ $('errs').innerHTML=(r.errors||['Something went wrong.']).join('<br>'); $('errs').style.display='block'; return; }
+  last=r;
+  $('rn').textContent=r.rollsNeeded; $('pr').textContent='$'+r.price.toLocaleString();
+  $('detail').innerHTML=[
+    ['Pattern',r.pattern],['SKU',r.sku],
+    ['Strips needed',r.totalStrips],['Strips per roll',r.stripsPerRoll],
+    ['Cut length / strip',r.cutLengthIn+'"'],['Match',r.match],
+    ['Price per roll','$'+r.pricePerRoll],['Est. waste',r.wastePct+'%']
+  ].map(([k,v])=>`<div>${k}: <b>${v}</b></div>`).join('');
+  $('result').classList.add('show'); $('done').style.display='none';
 }
-sortSel.onchange=()=>{localStorage.setItem('sort',sortSel.value);load();};
-dens.oninput=applyCols;
-load();
-</script></body></html>
+$('go').addEventListener('click',calc);
+$('send').addEventListener('click',async()=>{
+  const email=$('email').value.trim();
+  const r=await fetch('/api/lead',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email,quote:last})}).then(r=>r.json());
+  if(r.ok){ $('done').style.display='block'; $('email').value=''; } else { alert(r.error||'Enter a valid email.'); }
+});
+loadRolls();
+</script>
+</body></html>
diff --git a/server.js b/server.js
index 57f5df5..171c307 100644
--- a/server.js
+++ b/server.js
@@ -1,41 +1,94 @@
-// 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.
+// estimate-instant — wallpaper "how many rolls + instant price" calculator.
+// Zero-dep Node http. Serves the calculator, computes rolls-needed server-side
+// (pattern-repeat + waste math), and captures leads. Optional basic auth via BASIC_AUTH.
+// House rules honored: admin cards show created date+time; preview gated behind admin/DW2024!.
 const http = require('http'), fs = require('fs'), path = require('path');
 const PORT = parseInt(process.env.PORT || '3900', 10);
 const BASIC_AUTH = process.env.BASIC_AUTH || ''; // "user:pass" to gate; empty = open
 const DIR = __dirname;
+const ROLLS = path.join(DIR, 'data', 'rolls.json');
+const LEADS = path.join(DIR, 'data', 'leads.json');
+const TRIM_ALLOWANCE_IN = 4; // 2" trim top + bottom per strip
 
-function load() { try { return JSON.parse(fs.readFileSync(path.join(DIR, 'data', 'products.json'), 'utf8')); } catch { return []; } }
+function loadRolls() { try { return JSON.parse(fs.readFileSync(ROLLS, 'utf8')); } catch { return []; } }
+function loadLeads() { try { return JSON.parse(fs.readFileSync(LEADS, 'utf8')); } catch { return []; } }
 function authed(req) {
   if (!BASIC_AUTH) 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; }
 }
-// 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
+
+// The core: rolls-needed with pattern-repeat + waste math (trade-standard strip method).
+function estimate({ wallWidthIn, wallHeightIn, roll }) {
+  const errs = [];
+  const W = Number(wallWidthIn), H = Number(wallHeightIn);
+  if (!(W > 0)) errs.push('Enter a wall width greater than 0.');
+  if (!(H > 0)) errs.push('Enter a wall height greater than 0.');
+  if (!roll) errs.push('Pick a pattern.');
+  if (errs.length) return { ok: false, errors: errs };
+
+  const rollWidth = Number(roll.roll_width_in);
+  const rollLenIn = Number(roll.roll_length_ft) * 12;
+  const repeat = Number(roll.pattern_repeat_in) || 0;
+
+  // Each strip must clear wall height + trim; for a patterned match, round the cut
+  // length UP to the next full pattern repeat so strips align side-to-side.
+  const rawCut = H + TRIM_ALLOWANCE_IN;
+  const cutLen = repeat > 0 ? Math.ceil(rawCut / repeat) * repeat : rawCut;
+
+  const stripsPerRoll = Math.floor(rollLenIn / cutLen);
+  if (stripsPerRoll < 1) {
+    return { ok: false, errors: [`Wall is too tall for this roll — one drop (${cutLen}") exceeds the ${rollLenIn}" roll length. Pick a longer roll or split the wall.`] };
   }
+  const totalStrips = Math.ceil(W / rollWidth);
+  const rollsNeeded = Math.ceil(totalStrips / stripsPerRoll);
+
+  const price = +(rollsNeeded * Number(roll.price_per_roll)).toFixed(2);
+  // True material waste: length actually landing on the wall vs total length bought
+  // (captures roll-end offcuts + per-strip trim + pattern-repeat allowance).
+  const usedIn = totalStrips * H;
+  const boughtIn = rollsNeeded * rollLenIn;
+  const wastePct = boughtIn > 0 ? Math.round((1 - usedIn / boughtIn) * 100) : 0;
+
+  return {
+    ok: true,
+    rollsNeeded, totalStrips, stripsPerRoll,
+    cutLengthIn: +cutLen.toFixed(1), pricePerRoll: Number(roll.price_per_roll), price,
+    wastePct, match: roll.match, sku: roll.sku, pattern: roll.pattern
+  };
 }
-http.createServer((req, res) => {
+
+function body(req) { return new Promise(r => { let d = ''; req.on('data', c => d += c); req.on('end', () => { try { r(JSON.parse(d || '{}')); } catch { r({}); } }); }); }
+function json(res, code, obj) { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); }
+
+http.createServer(async (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 }));
+
+  if (u.pathname === '/api/rolls') return json(res, 200, { rolls: loadRolls() });
+
+  if (u.pathname === '/api/estimate' && req.method === 'POST') {
+    const b = await body(req);
+    const roll = loadRolls().find(r => r.sku === b.sku);
+    return json(res, 200, estimate({ wallWidthIn: b.wallWidthIn, wallHeightIn: b.wallHeightIn, roll }));
+  }
+
+  if (u.pathname === '/api/lead' && req.method === 'POST') {
+    const b = await body(req);
+    if (!b.email || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(b.email)) return json(res, 400, { ok: false, error: 'A valid email is required.' });
+    const leads = loadLeads();
+    leads.push({ id: leads.length + 1, email: String(b.email).slice(0, 200), quote: b.quote || null, created_at: new Date().toISOString() });
+    fs.writeFileSync(LEADS, JSON.stringify(leads, null, 2));
+    return json(res, 200, { ok: true });
   }
+
+  if (u.pathname === '/api/leads') return json(res, 200, { leads: loadLeads() }); // admin feed
+
   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)); }
+  if (fs.existsSync(fp) && fs.statSync(fp).isFile()) {
+    const type = f.endsWith('.html') ? 'text/html' : f.endsWith('.js') ? 'text/javascript' : 'text/plain';
+    res.writeHead(200, { 'Content-Type': type }); 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); });

← 6ef11db initial scaffold (estimate-instant) via web-dev accelerator  ·  back to Estimate Instant  ·  estimate-instant: fix half-drop math (consume match), honest 324b6db →