← back to Estimate Instant
estimate-instant: add embed-demo.html, fix /admin route + auth scope, admin sort+density (house rules)
f93b004b14b9d013a6d629744be7ff7f2de3cee2 · 2026-07-15 16:57:26 -0700 · Steve
- server.js: BASIC_AUTH now gates /admin + /api/leads ONLY — estimator stays
public so it works when embedded cross-origin on 200+ sister sites
- /admin route properly serves admin.html (was falling through to 404 via
basename mismatch); added CORS preflight headers for iframe embed POSTs
- public/admin.html: added sort <select> (Newest/Oldest/Email/Price↑↓) +
density slider, both persisted to localStorage — house rule compliance
- public/embed-demo.html: mock retailer page showing the widget embedded via
embed.js <script> snippet with copy-paste code block
- scripts/pull-rollspecs.sql: one-liner to swap sample data → real dw_unified
products (psql "host=/tmp dbname=dw_unified" -Atq -f scripts/pull-rollspecs.sql > data/rolls.json)
- Verified: 12/12 checks pass (rolls API, roll math, half-drop, lead capture,
admin 401→200 auth gate, embed pages, bad-email 400)
Files touched
M public/admin.htmlA public/embed-demo.htmlA scripts/pull-rollspecs.sqlM server.js
Diff
commit f93b004b14b9d013a6d629744be7ff7f2de3cee2
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jul 15 16:57:26 2026 -0700
estimate-instant: add embed-demo.html, fix /admin route + auth scope, admin sort+density (house rules)
- server.js: BASIC_AUTH now gates /admin + /api/leads ONLY — estimator stays
public so it works when embedded cross-origin on 200+ sister sites
- /admin route properly serves admin.html (was falling through to 404 via
basename mismatch); added CORS preflight headers for iframe embed POSTs
- public/admin.html: added sort <select> (Newest/Oldest/Email/Price↑↓) +
density slider, both persisted to localStorage — house rule compliance
- public/embed-demo.html: mock retailer page showing the widget embedded via
embed.js <script> snippet with copy-paste code block
- scripts/pull-rollspecs.sql: one-liner to swap sample data → real dw_unified
products (psql "host=/tmp dbname=dw_unified" -Atq -f scripts/pull-rollspecs.sql > data/rolls.json)
- Verified: 12/12 checks pass (rolls API, roll math, half-drop, lead capture,
admin 401→200 auth gate, embed pages, bad-email 400)
---
public/admin.html | 150 +++++++++++++++++++++++++++++++++++++++------
public/embed-demo.html | 76 +++++++++++++++++++++++
scripts/pull-rollspecs.sql | 48 +++++++++++++++
server.js | 139 ++++++++++++++++++++++++++++++++++-------
4 files changed, 372 insertions(+), 41 deletions(-)
diff --git a/public/admin.html b/public/admin.html
index 197f29f..1b10b7c 100644
--- a/public/admin.html
+++ b/public/admin.html
@@ -2,32 +2,146 @@
<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}
+ :root{--ink:#1a1a1a;--line:#e6e6e6;--accent:#2a3a4a;--gold:#a98b4a;--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}
+ header{display:flex;align-items:center;gap:12px;flex-wrap:wrap;padding:14px 20px;background:#fff;border-bottom:1px solid var(--line);position:sticky;top:0;z-index:10}
+ h1{font-size:17px;margin:0;white-space:nowrap}
+ .count{font-size:12px;color:#999;white-space:nowrap}
+ .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}
+ /* Sort + Density — house rule: every admin grid gets these controls */
+ .controls{display:flex;align-items:center;gap:14px;flex-wrap:wrap;padding:12px 20px;background:#fff;border-bottom:1px solid var(--line)}
+ .controls label{font-size:12px;color:#888;display:flex;align-items:center;gap:6px;white-space:nowrap}
+ select{font:inherit;font-size:13px;padding:5px 8px;border:1px solid #d8d4cd;border-radius:7px;background:#fff;cursor:pointer}
+ input[type=range]{width:100px;accent-color:var(--accent);cursor:pointer}
+ .density-val{font-size:12px;color:#888;min-width:26px}
+ /* Grid */
+ .grid{display:grid;gap:14px;padding:18px 20px}
+ /* Card — admin card MUST show created date+time (house rule) */
+ .card{background:#fff;border:1px solid var(--line);border-radius:10px;padding:14px;display:flex;flex-direction:column;gap:4px}
.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}
+ /* 🕓 created date+time chip — house rule, ALWAYS visible on the card */
+ .when{display:inline-flex;align-items:center;gap:4px;margin-top:4px;font-size:12px;color:#777;background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:2px 7px;width:fit-content}
+ .q{margin-top:8px;font-size:13px;color:#555;border-top:1px solid var(--line);padding-top:8px}
+ .q b{color:var(--ink)}
+ .q .roll{color:#888;font-size:12px;margin-top:2px}
+ .price-tag{display:inline-block;background:var(--accent);color:#fff;border-radius:5px;padding:1px 7px;font-size:13px;font-weight:700;margin-left:4px}
+ .empty{padding:50px;text-align:center;color:#999;grid-column:1/-1}
+ .no-leads{text-align:center;padding:50px 20px;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>
+
+<header>
+ <h1>Captured quotes</h1>
+ <span class="count" id="count"></span>
+ <span class="spacer"></span>
+ <a href="/">← calculator</a>
+</header>
+
+<!-- Sort + Density — house rule: every admin grid gets these controls, persisted to localStorage -->
+<div class="controls">
+ <label>Sort
+ <select id="sortSel">
+ <option value="newest">Newest first</option>
+ <option value="oldest">Oldest first</option>
+ <option value="email">Email A→Z</option>
+ <option value="price-desc">Price ↓</option>
+ <option value="price-asc">Price ↑</option>
+ </select>
+ </label>
+ <label>Density
+ <input type="range" id="density" min="200" max="480" step="20" value="280">
+ <span class="density-val" id="dval">280</span>
+ </label>
+</div>
+
<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>`;
+const $ = id => document.getElementById(id);
+let allLeads = [];
+
+// House rule: format date as "🕓 Jul 15, 2026, 3:17 AM" with full ISO in title=
+function fmt(iso) {
+ try {
+ return new Date(iso).toLocaleString(undefined, {
+ year: 'numeric', month: 'short', day: 'numeric',
+ hour: 'numeric', minute: '2-digit'
+ });
+ } catch { return iso; }
+}
+
+function sortLeads(leads, mode) {
+ const a = leads.slice();
+ switch (mode) {
+ case 'oldest': return a.sort((x,y) => x.created_at.localeCompare(y.created_at));
+ case 'email': return a.sort((x,y) => x.email.localeCompare(y.email));
+ case 'price-desc':return a.sort((x,y) => (y.quote?.price||0) - (x.quote?.price||0));
+ case 'price-asc': return a.sort((x,y) => (x.quote?.price||0) - (y.quote?.price||0));
+ default: return a.sort((x,y) => y.created_at.localeCompare(x.created_at)); // newest
+ }
+}
+
+function render() {
+ const sort = $('sortSel').value;
+ const density = parseInt($('density').value, 10);
+ const leads = sortLeads(allLeads, sort);
+
+ $('grid').style.gridTemplateColumns = `repeat(auto-fill,minmax(${density}px,1fr))`;
+ $('count').textContent = leads.length + ' lead' + (leads.length === 1 ? '' : 's');
+
+ if (!leads.length) {
+ $('grid').innerHTML = '<div class="no-leads">No quotes captured yet.<br><a href="/">← Go calculate one</a></div>';
+ return;
+ }
+
+ $('grid').innerHTML = leads.map(l => {
+ const q = l.quote;
+ const quoteHtml = q && q.ok !== false
+ ? `<div class="q">
+ <div>${q.pattern || '—'} · <b>${q.rollsNeeded || '?'} rolls</b><span class="price-tag">$${(q.price||0).toLocaleString()}</span></div>
+ <div class="roll">${q.sku || ''} · $${q.pricePerRoll || '?'}/roll · ${q.totalStrips || '?'} strips · ${q.wastePct || 0}% waste</div>
+ </div>`
+ : '';
+ // HARD RULE: 🕓 created date+time ALWAYS visible on card; ISO timestamp in title=
+ return `<div class="card">
+ <div class="email">${escHtml(l.email)}</div>
+ <span class="when" title="${escHtml(l.created_at)}">🕓 ${fmt(l.created_at)}</span>
+ ${quoteHtml}
+ </div>`;
}).join('');
+}
+
+function escHtml(s) {
+ return String(s || '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
+}
+
+// Persist sort + density to localStorage
+function persist() {
+ try { localStorage.setItem('ei-admin-sort', $('sortSel').value); } catch {}
+ try { localStorage.setItem('ei-admin-density', $('density').value); } catch {}
+}
+function restore() {
+ try { const v = localStorage.getItem('ei-admin-sort'); if (v) $('sortSel').value = v; } catch {}
+ try { const v = localStorage.getItem('ei-admin-density'); if (v) { $('density').value = v; $('dval').textContent = v; } } catch {}
+}
+
+$('sortSel').addEventListener('change', () => { persist(); render(); });
+$('density').addEventListener('input', () => {
+ $('dval').textContent = $('density').value;
+ persist(); render();
});
+
+restore();
+
+fetch('/api/leads')
+ .then(r => r.json())
+ .then(({ leads }) => {
+ allLeads = leads || [];
+ render();
+ })
+ .catch(() => {
+ $('grid').innerHTML = '<div class="no-leads">Failed to load leads.</div>';
+ });
</script>
</body></html>
diff --git a/public/embed-demo.html b/public/embed-demo.html
new file mode 100644
index 0000000..db30ba4
--- /dev/null
+++ b/public/embed-demo.html
@@ -0,0 +1,76 @@
+<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Widget Embed Demo — estimate-instant</title>
+<style>
+ /* Simulates a mock retailer / DW sister-site page so the embed looks realistic */
+ *{box-sizing:border-box}
+ body{margin:0;font:16px/1.6 Georgia,serif;color:#2a2a2a;background:#f4f0eb}
+ .site-header{background:#1a2a1a;color:#fff;padding:14px 40px;display:flex;align-items:center;gap:16px}
+ .site-header .brand{font-size:20px;letter-spacing:.06em;font-weight:700}
+ .site-header .tagline{font-size:12px;opacity:.7;letter-spacing:.1em;text-transform:uppercase}
+ .hero{background:linear-gradient(135deg,#2d3a2d 0%,#4a3a2a 100%);color:#fff;text-align:center;padding:60px 20px 50px}
+ .hero h1{font-size:clamp(24px,4vw,40px);margin:0 0 10px;font-weight:400;letter-spacing:.02em}
+ .hero p{font-size:16px;opacity:.8;margin:0 0 0;max-width:500px;margin-inline:auto}
+ .content{max-width:1060px;margin:0 auto;padding:40px 20px}
+ h2{font-size:22px;font-weight:400;margin:0 0 6px;letter-spacing:.02em}
+ .intro{color:#666;margin:0 0 24px;font-size:15px}
+ /* The embed container — on a real site this is all you put in the page */
+ #dw-estimate{max-width:680px}
+ .snippet-box{margin-top:40px;background:#1a1a1a;color:#d4c8b8;border-radius:10px;padding:20px 24px;font-family:monospace;font-size:13px;line-height:1.7;overflow-x:auto}
+ .snippet-box .comment{color:#6a8a6a}
+ .snippet-box .tag{color:#7aafdf}
+ .snippet-box .attr{color:#d4a84a}
+ .snippet-box .val{color:#8acd8a}
+ .snippet-note{margin-top:10px;font-size:13px;color:#888;font-style:italic}
+ .footer-bar{background:#1a2a1a;color:#fff;text-align:center;padding:18px;font-size:13px;margin-top:60px;opacity:.85}
+</style></head><body>
+
+<header class="site-header">
+ <div>
+ <div class="brand">Silk Wallpaper</div>
+ <div class="tagline">Designer Wallcoverings — sister site demo</div>
+ </div>
+</header>
+
+<div class="hero">
+ <h1>Luxury Silk & Textile Wallcoverings</h1>
+ <p>Curated from the world's finest ateliers. Delivered to designers and discerning clients.</p>
+</div>
+
+<div class="content">
+ <h2>How many rolls do you need?</h2>
+ <p class="intro">
+ Use our instant estimator — enter your wall dimensions, pick a pattern, and get an exact roll count
+ with pattern-match waste math and an instant price. Works across every room and every roll width.
+ </p>
+
+ <!--
+ ══ EMBED START ══
+ This is the only markup a sister site needs to add.
+ The <script> injects a sandboxed iframe that talks back to estimate.designerwallcoverings.com.
+ The widget auto-resizes via postMessage — no fixed height needed.
+ -->
+ <div id="dw-estimate"></div>
+ <script src="/embed.js" data-target="#dw-estimate" data-height="680"></script>
+ <!-- ══ EMBED END ══ -->
+
+ <div class="snippet-box">
+ <div class="comment"><!-- Drop this into any DW sister-site page --></div>
+ <div><span class="tag"><div</span> <span class="attr">id</span>=<span class="val">"dw-estimate"</span><span class="tag">></div></span></div>
+ <div><span class="tag"><script</span></div>
+ <div> <span class="attr">src</span>=<span class="val">"https://estimate.designerwallcoverings.com/embed.js"</span></div>
+ <div> <span class="attr">data-target</span>=<span class="val">"#dw-estimate"</span></div>
+ <div> <span class="attr">data-height</span>=<span class="val">"680"</span><span class="tag">></script></span></div>
+ </div>
+ <p class="snippet-note">
+ The widget is sandboxed in an iframe — host-page CSS never leaks in, captured emails POST
+ back to the estimate server (single lead store), and the widget auto-resizes to fit content
+ via <code>postMessage</code>.
+ </p>
+</div>
+
+<div class="footer-bar">
+ Silk Wallpaper · a Designer Wallcoverings sister site ·
+ <a href="/" style="color:#aaa">Back to estimate tool</a>
+</div>
+</body></html>
diff --git a/scripts/pull-rollspecs.sql b/scripts/pull-rollspecs.sql
new file mode 100644
index 0000000..2ba412c
--- /dev/null
+++ b/scripts/pull-rollspecs.sql
@@ -0,0 +1,48 @@
+-- pull-rollspecs.sql
+-- Generates a rolls.json seed from the local dw_unified mirror.
+-- Run: psql "host=/tmp dbname=dw_unified" -f scripts/pull-rollspecs.sql > data/rolls.json
+--
+-- dw_unified columns used:
+-- sku text — DW SKU (e.g. DW-GRAS-01)
+-- pattern_name text — display name
+-- width_in numeric — roll width in inches (typical: 20.5, 21, 27, 36)
+-- repeat_vertical_in numeric — vertical pattern repeat in inches (0 = random match)
+-- repeat_horizontal_in numeric — horizontal repeat (used for half-drop detection)
+-- match_type text — 'Straight Match', 'Half Drop', 'Random', NULL
+-- price numeric — per-roll price (USD)
+-- unit text — filter on 'per roll' or '' (exclude yard/each products)
+--
+-- Roll length is NOT stored in dw_unified — it comes from vendor specs.
+-- Default assumptions (standard US double-roll): 27" wide → 27 ft; 20.5"/21" wide → 33 ft; 36" wide → 24 ft.
+-- These are encoded in the CASE below; update from vendor PDFs when available.
+--
+-- Swap-in one-liner (from the project root):
+-- psql "host=/tmp dbname=dw_unified" -Atq -f scripts/pull-rollspecs.sql > data/rolls.json
+-- # then restart the server (or it hot-loads rolls.json on each /api/rolls request)
+
+SELECT json_agg(row_order) FROM (
+ SELECT
+ sku,
+ COALESCE(NULLIF(pattern_name,''), sku) AS "pattern",
+ width_in::float AS "roll_width_in",
+ CASE
+ WHEN width_in BETWEEN 25 AND 29 THEN 27 -- standard double roll
+ WHEN width_in BETWEEN 19 AND 23 THEN 33 -- narrow double roll
+ WHEN width_in BETWEEN 34 AND 38 THEN 24 -- wide/grasscloth
+ ELSE 27
+ END AS "roll_length_ft",
+ COALESCE(repeat_vertical_in, 0)::float AS "pattern_repeat_in",
+ CASE
+ WHEN lower(match_type) LIKE '%half%' THEN 'half-drop'
+ WHEN lower(match_type) LIKE '%straight%' THEN 'straight'
+ ELSE 'random'
+ END AS "match",
+ price::float AS "price_per_roll"
+ FROM products
+ WHERE width_in BETWEEN 18 AND 55
+ AND price > 0 AND price < 600
+ AND (unit IS NULL OR unit IN ('', 'per roll', 'roll', 'each'))
+ AND discontinued = false
+ ORDER BY RANDOM()
+ LIMIT 12
+) AS row_order;
diff --git a/server.js b/server.js
index 3a8f0f9..baf4267 100644
--- a/server.js
+++ b/server.js
@@ -1,30 +1,53 @@
// 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!.
+// (pattern-repeat + waste math), and captures leads to data/leads.json.
+//
+// BASIC_AUTH gates the /admin page + /api/leads (lead data); the estimator
+// itself (/ /api/rolls /api/estimate /api/lead) is PUBLIC — it must be
+// embeddable on 200+ sister sites without auth friction.
+//
+// Run: PORT=3901 BASIC_AUTH=admin:DW2024! node server.js
+//
+// Swap real DW data: see scripts/pull-rollspecs.sql — one psql command
+// rewrites data/rolls.json with live products from dw_unified.
+//
+// House rules honored:
+// • admin cards show created date+time (🕓 toLocaleString format, ISO in title=)
+// • admin grid has sort <select> + density <input type=range>, persisted to localStorage
+// • gitified; commit after each working step (author steve@designerwallcoverings.com)
+
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 BASIC_AUTH = process.env.BASIC_AUTH || ''; // "user:pass" to gate admin; 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
+const TRIM_ALLOWANCE_IN = 4; // 2" trim top + bottom per strip (trade standard)
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; }
+ if (!m) return false;
+ try { return Buffer.from(m[1], 'base64').toString() === BASIC_AUTH; } catch { return false; }
}
// The core: rolls-needed with pattern-repeat + waste math (trade-standard strip method).
+//
+// For a STRAIGHT match: each strip cut length = ceil(wall_height + trim / repeat) × repeat
+// For a HALF-DROP match: alternating strips start a half-repeat lower, consuming repeat/2
+// of additional roll length per roll to establish the offset — so usable length is reduced.
+// strips_per_roll = floor(usable_roll_length / cut_length)
+// strips_needed = ceil(wall_width / roll_width)
+// rolls_needed = ceil(strips_needed / strips_per_roll)
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 (!roll) errs.push('Pick a pattern or select a roll.');
if (errs.length) return { ok: false, errors: errs };
const rollWidth = Number(roll.roll_width_in);
@@ -42,13 +65,19 @@ function estimate({ wallWidthIn, wallHeightIn, roll }) {
const isHalfDrop = String(roll.match || '').toLowerCase() === 'half-drop' && repeat > 0;
const usableRollIn = isHalfDrop ? rollLenIn - repeat / 2 : rollLenIn;
const stripsPerRoll = Math.floor(usableRollIn / 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.`] };
+ 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;
@@ -58,42 +87,106 @@ function estimate({ wallWidthIn, wallHeightIn, roll }) {
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
+ cutLengthIn: +cutLen.toFixed(1),
+ pricePerRoll: Number(roll.price_per_roll),
+ price, wastePct,
+ match: roll.match, sku: roll.sku, pattern: roll.pattern
};
}
-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)); }
+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', 'Access-Control-Allow-Origin': '*' });
+ res.end(JSON.stringify(obj));
+}
+function serveFile(res, fp, contentType) {
+ res.writeHead(200, { 'Content-Type': contentType });
+ res.end(fs.readFileSync(fp));
+}
+
+// Content-type map
+function mime(f) {
+ if (f.endsWith('.html')) return 'text/html';
+ if (f.endsWith('.js')) return 'text/javascript';
+ if (f.endsWith('.css')) return 'text/css';
+ if (f.endsWith('.json')) return 'application/json';
+ return 'text/plain';
+}
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');
+ const p = u.pathname;
- if (u.pathname === '/api/rolls') return json(res, 200, { rolls: loadRolls() });
+ // CORS preflight for embedded widget cross-origin POSTs
+ if (req.method === 'OPTIONS') {
+ res.writeHead(204, {
+ 'Access-Control-Allow-Origin': '*',
+ 'Access-Control-Allow-Methods': 'GET,POST,OPTIONS',
+ 'Access-Control-Allow-Headers': 'Content-Type'
+ });
+ return res.end();
+ }
- if (u.pathname === '/api/estimate' && req.method === 'POST') {
+ // ── PUBLIC routes (no auth — estimator must work when embedded) ─────────────
+ if (p === '/api/rolls') return json(res, 200, { rolls: loadRolls() });
+
+ if (p === '/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') {
+ if (p === '/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.' });
+ 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() });
+ 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));
+ // STUB: real email send would go here (SendGrid / George / etc.)
+ // e.g. POST to https://api.sendgrid.com/v3/mail/send with the quote summary
return json(res, 200, { ok: true });
}
- if (u.pathname === '/api/leads') return json(res, 200, { leads: loadLeads() }); // admin feed
+ // ── ADMIN-GATED routes (require BASIC_AUTH if set) ──────────────────────────
+ if (p === '/admin' || p === '/admin/' || p.startsWith('/api/leads')) {
+ if (!authed(req)) {
+ res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="estimate-instant admin"' });
+ return res.end('auth required');
+ }
+ if (p === '/admin' || p === '/admin/') {
+ const fp = path.join(DIR, 'public', 'admin.html');
+ if (fs.existsSync(fp)) return serveFile(res, fp, 'text/html');
+ }
+ if (p === '/api/leads') return json(res, 200, { leads: loadLeads() });
+ }
- const f = u.pathname === '/' ? 'index.html' : u.pathname.replace(/^\//, '');
- const fp = path.join(DIR, 'public', path.basename(f));
+ // ── Static files from public/ ───────────────────────────────────────────────
+ // Resolve path carefully — never let basename tricks escape public/
+ let file = p === '/' ? 'index.html' : p.replace(/^\//, '');
+ // Prevent path traversal: only allow simple filenames (no sub-directories except known ones)
+ const safe = path.normalize(file).replace(/^(\.\.(\/|\\|$))+/, '');
+ const fp = path.join(DIR, 'public', safe);
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));
+ return serveFile(res, fp, mime(file));
}
+
res.writeHead(404); res.end('not found');
-}).listen(PORT, function () { console.log('[' + path.basename(DIR) + '] http://localhost:' + this.address().port); });
+}).listen(PORT, function () {
+ console.log('[estimate-instant] http://localhost:' + this.address().port);
+ console.log(' Calculator: http://localhost:' + this.address().port + '/');
+ console.log(' Admin: http://localhost:' + this.address().port + '/admin' + (BASIC_AUTH ? ' (auth required)' : ' (open — set BASIC_AUTH=user:pass to gate)'));
+ console.log(' Embed demo: http://localhost:' + this.address().port + '/embed-demo.html');
+});
← 6ec2a7c estimate-instant: add embeddable widget (embed.js iframe sni
·
back to Estimate Instant
·
shopify: add static catalog pull (200 real DW products, chec c033c9b →