← back to Estimate Instant
server.js
250 lines
// 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 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
//
// Shopify integration (read-only, no token required):
// • data/shopify-catalog.json — static pull from LIVE DW Shopify store (200 products).
// Contains variant_id + price per SKU and checkout_domain for cart permalinks.
// • Cart permalink: https://<checkout_domain>/cart/<variant_id>:<qty>
// • data/rolls.json rolls are enriched with shopify_variant_id + shopify_price.
// shopify_match=true means the roll SKU matched a catalog SKU exactly.
// shopify_match=false means a clearly-labeled stand-in variant is used (see _note fields).
// • NO Shopify token lives in this server — only public catalog data + public cart URLs.
// • PRODUCTION TODO: roll_width_in / roll_length_ft / pattern_repeat_in should be pulled
// from Shopify metafields keyed by variant_id (not stored here). The _note field on each
// rolls.json entry documents this seam.
//
// 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 || ''; // optional override for the admin credential
// Admin surfaces (/admin + /api/leads = captured lead PII) are ALWAYS gated — even when the
// public calculator runs open on an embed host. Default admin/DW2024!; override via env.
const ADMIN_CRED = process.env.BASIC_AUTH || process.env.ADMIN_AUTH || 'admin:DW2024!';
const DIR = __dirname;
const ROLLS = path.join(DIR, 'data', 'rolls.json');
const LEADS = path.join(DIR, 'data', 'leads.json');
const CATALOG_PATH = path.join(DIR, 'data', 'shopify-catalog.json');
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 []; } }
// Load Shopify catalog (static, read-only — no token needed).
// Returns { checkout_domain, products_by_sku } for cart permalink construction.
function loadCatalog() {
try {
const c = JSON.parse(fs.readFileSync(CATALOG_PATH, 'utf8'));
const bySkuUpper = {};
(c.products || []).forEach(p => { bySkuUpper[String(p.sku).toUpperCase()] = p; });
return { checkout_domain: c.checkout_domain || 'www.designerwallcoverings.com', bySkuUpper };
} catch {
return { checkout_domain: 'www.designerwallcoverings.com', bySkuUpper: {} };
}
}
function authed(req) {
const m = (req.headers.authorization || '').match(/^Basic\s+(.+)$/i);
if (!m) return false;
try { return Buffer.from(m[1], 'base64').toString() === ADMIN_CRED; } 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)
//
// Shopify checkout: when roll has shopify_variant_id, we build the cart permalink
// https://<checkout_domain>/cart/<variant_id>:<rolls_needed>
// shopify_match=false means the variant is a stand-in (prototype SKU not in catalog).
function estimate({ wallWidthIn, wallHeightIn, roll, checkout_domain }) {
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 or select a roll.');
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, rounded UP to a full pattern repeat so
// strips align side-to-side. Half-drop match: adjacent strips are offset by half a
// repeat, so each strip needs an extra half-repeat of length to land at either offset
// — added BEFORE the round-up. Conservative by design: it rounds up and never
// under-counts (an under-count leaves the installer short mid-wall).
const isHalfDrop = String(roll.match || '').toLowerCase() === 'half-drop' && repeat > 0;
const rawCut = H + TRIM_ALLOWANCE_IN + (isHalfDrop ? repeat / 2 : 0);
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);
// Price: use the real Shopify price if available (shopify_price), else fall back to
// the local price_per_roll placeholder.
const pricePerRoll = Number(roll.shopify_price || roll.price_per_roll);
const price = +(rollsNeeded * pricePerRoll).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;
// Shopify hosted checkout cart permalink — no API key needed, public URL.
// Format: https://<checkout_domain>/cart/<variant_id>:<qty>
// shopify_match=false means stand-in variant (prototype SKU not in live catalog).
const variantId = roll.shopify_variant_id || null;
const domain = checkout_domain || 'www.designerwallcoverings.com';
const cartUrl = variantId
? `https://${domain}/cart/${variantId}:${rollsNeeded}`
: null;
const shopifyMatch = roll.shopify_match !== undefined ? roll.shopify_match : false;
return {
ok: true,
rollsNeeded, totalStrips, stripsPerRoll,
cutLengthIn: +cutLen.toFixed(1),
pricePerRoll,
price, wastePct,
match: roll.match, sku: roll.sku, pattern: roll.pattern,
// Shopify checkout fields — used by the frontend CTA
shopify_variant_id: variantId,
shopify_sku: roll.shopify_sku || null,
shopify_match: shopifyMatch,
cart_url: cartUrl,
checkout_domain: domain
};
}
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) => {
const u = new URL(req.url, 'http://x');
const p = u.pathname;
// 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();
}
// ── PUBLIC routes (no auth — estimator must work when embedded) ─────────────
if (p === '/api/rolls') return json(res, 200, { rolls: loadRolls() });
// Expose checkout_domain so the frontend can build cart permalinks without
// the server having to inject it into every page (also used by embed.js).
if (p === '/api/catalog-meta') {
const { checkout_domain } = loadCatalog();
return json(res, 200, { checkout_domain });
}
if (p === '/api/estimate' && req.method === 'POST') {
const b = await body(req);
const roll = loadRolls().find(r => r.sku === b.sku);
const { checkout_domain } = loadCatalog();
return json(res, 200, estimate({ wallWidthIn: b.wallWidthIn, wallHeightIn: b.wallHeightIn, roll, checkout_domain }));
}
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.' });
}
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));
// 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 });
}
// ── 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() });
}
// ── 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()) {
return serveFile(res, fp, mime(file));
}
res.writeHead(404); res.end('not found');
}).listen(PORT, '127.0.0.1', 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 (auth required — ADMIN_CRED)');
console.log(' Embed demo: http://localhost:' + this.address().port + '/embed-demo.html');
});