[object Object]

← 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 →