← 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');
});