← back to Estimate Instant
shopify: wire server to static catalog — estimate returns cart_url + /api/catalog-meta
4349f95d372fe9d0eb8cedfd88395b3d8f689448 · 2026-07-16 07:38:55 -0700 · Steve Abrams
- loadCatalog() reads shopify-catalog.json (no token, static pull)
- estimate() now accepts checkout_domain, builds cart permalink
https://<checkout_domain>/cart/<variant_id>:<rolls_needed>
- estimate response includes shopify_variant_id, shopify_match, cart_url, checkout_domain
- pricePerRoll uses shopify_price from rolls.json (falls back to price_per_roll)
- New public /api/catalog-meta endpoint exposes checkout_domain for embed consumers
- No Shopify token ever lives in this server
Files touched
Diff
commit 4349f95d372fe9d0eb8cedfd88395b3d8f689448
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Thu Jul 16 07:38:55 2026 -0700
shopify: wire server to static catalog — estimate returns cart_url + /api/catalog-meta
- loadCatalog() reads shopify-catalog.json (no token, static pull)
- estimate() now accepts checkout_domain, builds cart permalink
https://<checkout_domain>/cart/<variant_id>:<rolls_needed>
- estimate response includes shopify_variant_id, shopify_match, cart_url, checkout_domain
- pricePerRoll uses shopify_price from rolls.json (falls back to price_per_roll)
- New public /api/catalog-meta endpoint exposes checkout_domain for embed consumers
- No Shopify token ever lives in this server
---
server.js | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
1 file changed, 62 insertions(+), 5 deletions(-)
diff --git a/server.js b/server.js
index baf4267..f769a54 100644
--- a/server.js
+++ b/server.js
@@ -8,6 +8,18 @@
//
// 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.
//
@@ -22,11 +34,25 @@ const BASIC_AUTH = process.env.BASIC_AUTH || ''; // "user:pass" to gate admin; e
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) {
if (!BASIC_AUTH) return true;
const m = (req.headers.authorization || '').match(/^Basic\s+(.+)$/i);
@@ -42,7 +68,11 @@ function authed(req) {
// 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 }) {
+//
+// 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.');
@@ -76,7 +106,10 @@ function estimate({ wallWidthIn, wallHeightIn, roll }) {
const totalStrips = Math.ceil(W / rollWidth);
const rollsNeeded = Math.ceil(totalStrips / stripsPerRoll);
- const price = +(rollsNeeded * Number(roll.price_per_roll)).toFixed(2);
+ // 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).
@@ -84,13 +117,29 @@ function estimate({ wallWidthIn, wallHeightIn, roll }) {
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: Number(roll.price_per_roll),
+ pricePerRoll,
price, wastePct,
- match: roll.match, sku: roll.sku, pattern: roll.pattern
+ 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
};
}
@@ -136,10 +185,18 @@ http.createServer(async (req, res) => {
// ── 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);
- return json(res, 200, estimate({ wallWidthIn: b.wallWidthIn, wallHeightIn: b.wallHeightIn, roll }));
+ 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') {
← 1abb45d shopify: enrich rolls.json with real Shopify variant_ids + p
·
back to Estimate Instant
·
shopify: add Buy N rolls CTA + TEST banner to calculator UI 646c3eb →