← back to Estimate Instant
server.js
431 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)
const MAX_JSON_BODY_BYTES = 16 * 1024;
function validateRollSnapshot(rolls) {
const errors = [];
if (!Array.isArray(rolls) || rolls.length === 0) return { ok: false, errors: ['snapshot must be a non-empty array'] };
const canonical = new Set(), matchedAliases = new Set();
rolls.forEach((roll, index) => {
const at = `roll[${index}]`;
if (!roll || typeof roll !== 'object' || Array.isArray(roll)) { errors.push(`${at} must be an object`); return; }
const rawSku = typeof roll.sku === 'string' ? roll.sku : '';
const sku = rawSku.trim().toUpperCase();
if (!sku) errors.push(`${at}.sku is required`);
else if (rawSku !== sku) errors.push(`${at}.sku must be canonical uppercase without surrounding whitespace`);
else if (canonical.has(sku)) errors.push(`${at}.sku duplicates ${sku}`);
else canonical.add(sku);
if (typeof roll.roll_width_in !== 'number' || !Number.isFinite(roll.roll_width_in) || roll.roll_width_in <= 0) errors.push(`${at}.roll_width_in is invalid`);
if (typeof roll.roll_length_ft !== 'number' || !Number.isFinite(roll.roll_length_ft) || roll.roll_length_ft <= 0) errors.push(`${at}.roll_length_ft is invalid`);
if (typeof roll.pattern_repeat_in !== 'number' || !Number.isFinite(roll.pattern_repeat_in) || roll.pattern_repeat_in < 0) errors.push(`${at}.pattern_repeat_in is invalid`);
const match = typeof roll.match === 'string' ? roll.match : '';
if (!['random', 'straight', 'half-drop'].includes(match)) errors.push(`${at}.match is invalid or noncanonical`);
if (typeof roll.shopify_match !== 'boolean') errors.push(`${at}.shopify_match must be boolean`);
if (roll.shopify_match === true) {
const rawAlias = typeof roll.shopify_sku === 'string' ? roll.shopify_sku : '';
const alias = rawAlias.trim().toUpperCase();
if (!alias) errors.push(`${at}.shopify_sku is required for an exact match`);
else if (rawAlias !== alias) errors.push(`${at}.shopify_sku must be canonical uppercase without surrounding whitespace`);
else if (matchedAliases.has(alias)) errors.push(`${at}.shopify_sku duplicates ${alias}`);
else matchedAliases.add(alias);
if (typeof roll.shopify_price !== 'number' || !Number.isFinite(roll.shopify_price) || roll.shopify_price <= 0) errors.push(`${at}.shopify_price is invalid`);
}
});
return { ok: errors.length === 0, errors };
}
function loadRolls(file = ROLLS) {
let rolls;
try { rolls = JSON.parse(fs.readFileSync(file, 'utf8')); }
catch (error) { throw new Error(`roll snapshot unreadable: ${error.message}`); }
const validation = validateRollSnapshot(rolls);
if (!validation.ok) throw new Error(`roll snapshot invalid: ${validation.errors.slice(0, 5).join('; ')}`);
return rolls;
}
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
};
}
// Stable API contract for room-based consumers. This deliberately uses the
// checked-in roll-spec snapshot: the dw_unified mirror is not authoritative for
// sell price, and this local endpoint must never imply a live database read.
function calculateCoverage(input, rolls = loadRolls(), checkout_domain) {
const sku = typeof input?.sku === 'string' ? input.sku.trim() : '';
const roomWidthFt = input?.room_width_ft;
const roomHeightFt = input?.room_height_ft;
const numWalls = input?.num_walls;
const errors = [];
if (!sku) errors.push('sku is required.');
if (typeof roomWidthFt !== 'number' || !Number.isFinite(roomWidthFt) || roomWidthFt <= 0 || roomWidthFt > 1000) {
errors.push('room_width_ft must be a JSON number greater than 0 and at most 1000.');
}
if (typeof roomHeightFt !== 'number' || !Number.isFinite(roomHeightFt) || roomHeightFt <= 0 || roomHeightFt > 100) {
errors.push('room_height_ft must be a JSON number greater than 0 and at most 100.');
}
if (typeof numWalls !== 'number' || !Number.isInteger(numWalls) || numWalls < 1 || numWalls > 100) {
errors.push('num_walls must be a JSON integer from 1 through 100.');
}
if (errors.length) return { ok: false, errors };
const key = sku.toUpperCase();
const canonicalMatches = rolls.filter((item) => String(item.sku || '').toUpperCase() === key);
if (canonicalMatches.length > 1) {
return { ok: false, errors: [`Ambiguous local roll specification for SKU ${sku}.`] };
}
const aliasMatches = canonicalMatches.length === 0
? rolls.filter((item) => item.shopify_match === true && String(item.shopify_sku || '').toUpperCase() === key)
: [];
if (aliasMatches.length > 1) {
return { ok: false, errors: [`Ambiguous matched Shopify SKU ${sku}.`] };
}
const roll = canonicalMatches[0] || aliasMatches[0];
if (!roll) return { ok: false, errors: [`No local roll specification found for SKU ${sku}.`] };
const validPositiveSpec = (value) => typeof value === 'number' && Number.isFinite(value) && value > 0;
const validRepeat = typeof roll.pattern_repeat_in === 'number' &&
Number.isFinite(roll.pattern_repeat_in) && roll.pattern_repeat_in >= 0;
const match = String(roll.match || '').trim().toLowerCase();
if (!validPositiveSpec(roll.roll_width_in) || !validPositiveSpec(roll.roll_length_ft) ||
!validRepeat || !['random', 'straight', 'half-drop'].includes(match)) {
return { ok: false, errors: ['Local roll specification is invalid; verify width, length, repeat, and match.'] };
}
const totalWidthIn = roomWidthFt * 12 * numWalls;
const wallHeightIn = roomHeightFt * 12;
if (!Number.isFinite(totalWidthIn) || Math.abs(totalWidthIn) > Number.MAX_SAFE_INTEGER || !Number.isFinite(wallHeightIn)) {
return { ok: false, errors: ['Room dimensions exceed the safe calculation range.'] };
}
const result = estimate({
wallWidthIn: totalWidthIn,
wallHeightIn,
roll,
checkout_domain,
});
if (!result.ok) return result;
const quoteAuthoritative = roll.shopify_match === true &&
typeof roll.shopify_price === 'number' && Number.isFinite(roll.shopify_price) && roll.shopify_price > 0 &&
typeof roll.roll_width_in === 'number' && Number.isFinite(roll.roll_width_in) && roll.roll_width_in > 0 &&
typeof roll.roll_length_ft === 'number' && Number.isFinite(roll.roll_length_ft) && roll.roll_length_ft > 0 &&
typeof roll.pattern_repeat_in === 'number' && Number.isFinite(roll.pattern_repeat_in) && roll.pattern_repeat_in >= 0;
const numericOutputs = [result.rollsNeeded, result.wastePct,
result.totalStrips, result.stripsPerRoll, result.cutLengthIn];
if (quoteAuthoritative) numericOutputs.push(result.price, result.pricePerRoll);
if (numericOutputs.some((value) => typeof value !== 'number' || !Number.isFinite(value))) {
return { ok: false, errors: ['Roll specifications produced a non-finite calculation; verify the product data.'] };
}
if (![result.rollsNeeded, result.totalStrips, result.stripsPerRoll].every(Number.isSafeInteger) ||
result.rollsNeeded < 1 || result.totalStrips < 1 || result.stripsPerRoll < 1 || result.cutLengthIn <= 0) {
return { ok: false, errors: ['Roll specifications produced an invalid quantity; verify the product data.'] };
}
const notes = [
`Assumes ${numWalls} wall${numWalls === 1 ? '' : 's'} at ${roomWidthFt} ft wide by ${roomHeightFt} ft high.`,
'Calculated from the checked-in local roll-spec snapshot; confirm current specifications and price before quoting.',
];
if (!quoteAuthoritative) notes.push('This local record is not quote-authoritative; pricing is withheld until an exact matched product has valid price and roll specifications.');
return {
ok: true,
sku: result.sku,
requested_sku: sku,
rolls_needed: result.rollsNeeded,
waste_pct: result.wastePct,
total_cost: quoteAuthoritative ? result.price : null,
price_per_roll: quoteAuthoritative ? result.pricePerRoll : null,
quote_authoritative: quoteAuthoritative,
strips_needed: result.totalStrips,
strips_per_roll: result.stripsPerRoll,
cut_length_in: result.cutLengthIn,
notes,
data_source: 'local-rollspec-snapshot',
};
}
function body(req, limit = MAX_JSON_BODY_BYTES) {
return new Promise(resolve => {
let bytes = 0, tooLarge = false, ended = false, settled = false;
const chunks = [];
const finish = (result) => {
if (settled) return;
settled = true;
resolve(result);
};
req.on('data', chunk => {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
bytes += buffer.length;
if (bytes > limit) { tooLarge = true; chunks.length = 0; return; }
if (!tooLarge) chunks.push(buffer);
});
req.on('end', () => {
ended = true;
if (tooLarge) return finish({ ok: false, status: 413, error: `JSON body exceeds ${limit} bytes.` });
try {
const text = new TextDecoder('utf-8', { fatal: true }).decode(Buffer.concat(chunks));
finish({ ok: true, value: JSON.parse(text || '{}') });
} catch {
finish({ ok: false, status: 400, error: 'Malformed or invalid UTF-8 JSON body.' });
}
});
req.on('aborted', () => finish({ ok: false, status: 400, error: 'Request body was aborted.' }));
req.on('error', () => finish({ ok: false, status: 400, error: 'Unable to read request body.' }));
req.on('close', () => {
if (!ended) finish({ ok: false, status: 400, error: 'Request body closed before completion.' });
});
});
}
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';
}
async function handleRequest(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 parsed = await body(req);
if (!parsed.ok) return json(res, parsed.status, { ok: false, error: parsed.error });
const b = parsed.value;
const roll = loadRolls().find(r => r.sku === b.sku);
const { checkout_domain } = loadCatalog();
const result = estimate({ wallWidthIn: b.wallWidthIn, wallHeightIn: b.wallHeightIn, roll, checkout_domain });
return json(res, result.ok ? 200 : 400, result);
}
if (p === '/api/calculate-coverage' && req.method === 'POST') {
const parsed = await body(req);
if (!parsed.ok) return json(res, parsed.status, { ok: false, error: parsed.error });
const input = parsed.value;
const { checkout_domain } = loadCatalog();
const result = calculateCoverage(input, loadRolls(), checkout_domain);
return json(res, result.ok ? 200 : 400, result);
}
if (p === '/api/lead' && req.method === 'POST') {
const parsed = await body(req);
if (!parsed.ok) return json(res, parsed.status, { ok: false, error: parsed.error });
const b = parsed.value;
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');
}
if (require.main === module) {
loadRolls(); // fail closed before binding if the whole checked-in snapshot is invalid
http.createServer(handleRequest).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');
});
}
module.exports = { body, calculateCoverage, estimate, handleRequest, loadRolls, validateRollSnapshot, MAX_JSON_BODY_BYTES };