← 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
M .gitignoreM BRIEF.mdD data/products.jsonA data/rolls.jsonA public/admin.htmlM public/index.htmlM server.js
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=>({'&':'&','<':'<','>':'>','"':'"'}[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 & 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 →