[object Object]

← 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

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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
+}
+
+// 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 &amp; 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">&lt;!-- Drop this into any DW sister-site page --&gt;</div>
+    <div><span class="tag">&lt;div</span> <span class="attr">id</span>=<span class="val">"dw-estimate"</span><span class="tag">&gt;&lt;/div&gt;</span></div>
+    <div><span class="tag">&lt;script</span></div>
+    <div>&nbsp;&nbsp;<span class="attr">src</span>=<span class="val">"https://estimate.designerwallcoverings.com/embed.js"</span></div>
+    <div>&nbsp;&nbsp;<span class="attr">data-target</span>=<span class="val">"#dw-estimate"</span></div>
+    <div>&nbsp;&nbsp;<span class="attr">data-height</span>=<span class="val">"680"</span><span class="tag">&gt;&lt;/script&gt;</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 →