[object Object]

← back to Trade Portal

Switch product source to shopify-catalog.json; add cart permalink builder

20df63d162efa5553865873f545a92275858a226 · 2026-07-16 07:37:04 -0700 · steve@designerwallcoverings.com

- loadCatalog() reads data/shopify-catalog.json (real DW Shopify store, read-only)
- computePricing() driven by catalog price field (Shopify retail = MAP for Kravet)
- buildCartUrl(): https://checkout_domain/cart/<variant_id>:<qty>[?discount=CODE]
- /api/products returns variant_id + cart_url per product; never exposes cost
- Non-Kravet trade = est. retail x0.75; labeled 'standard-est-25pct' (clearly est.)
- Catalog cached in-memory after first load; no Shopify Admin token in app

Files touched

Diff

commit 20df63d162efa5553865873f545a92275858a226
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date:   Thu Jul 16 07:37:04 2026 -0700

    Switch product source to shopify-catalog.json; add cart permalink builder
    
    - loadCatalog() reads data/shopify-catalog.json (real DW Shopify store, read-only)
    - computePricing() driven by catalog price field (Shopify retail = MAP for Kravet)
    - buildCartUrl(): https://checkout_domain/cart/<variant_id>:<qty>[?discount=CODE]
    - /api/products returns variant_id + cart_url per product; never exposes cost
    - Non-Kravet trade = est. retail x0.75; labeled 'standard-est-25pct' (clearly est.)
    - Catalog cached in-memory after first load; no Shopify Admin token in app
---
 server.js | 126 +++++++++++++++++++++++++++++++++++++++++++++-----------------
 1 file changed, 91 insertions(+), 35 deletions(-)

diff --git a/server.js b/server.js
index e3eb494..a770b42 100644
--- a/server.js
+++ b/server.js
@@ -1,8 +1,11 @@
-// trade-portal — To the Trade B2B storefront (Phase 1)
+// trade-portal — To the Trade B2B storefront (Phase 1 + Shopify cart integration)
 // Zero-dep Node http. PORT, BASIC_AUTH (admin gate), SESSION_SECRET from env.
-// Pricing: Kravet-family → MAP = cost × 1.5 (never cost/0.65/0.85).
-//          Non-Kravet   → retail = cost/0.65/0.85; trade = retail × 0.75 (25% off).
+// Product catalog sourced from data/shopify-catalog.json (real DW Shopify store, read-only).
+// Cart permalink: https://www.designerwallcoverings.com/cart/<variant_id>:<qty>
+// Pricing: Kravet-family → MAP = price (store price IS MAP for Kravet lines).
+//          Non-Kravet   → retail = store price; trade = retail × 0.75 (est. 25% off, labeled est.).
 // Cost/wholesale NEVER exposed on any public API response.
+// No Shopify Admin token in this app — static catalog + public permalinks only.
 const http = require('http'), fs = require('fs'), path = require('path'), crypto = require('crypto');
 
 const PORT   = parseInt(process.env.PORT || '3900', 10);
@@ -13,30 +16,32 @@ const DATA   = p => path.join(DIR, 'data', p);
 
 // ── Kravet-family brand set (MAP pricing rule) ──────────────────────────────
 const KRAVET_VENDORS = new Set([
-  'kravet','lee jofa','lee jofa modern','groundworks','brunschwig & fils',
+  'kravet','kravet couture','kravet design','kravet contract','kravet basics',
+  'lee jofa','lee jofa modern','groundworks','brunschwig & fils',
   'cole & son','gp & j baker','g p & j baker','colefax and fowler',
-  'colefax & fowler','clarke & clarke','mulberry','threads','baker lifestyle',
-  'andrew martin','aerin','barclay butera','thom filicia','nicholette mayer'
+  'colefax & fowler','clarke & clarke','clarke and clarke','mulberry','threads',
+  'baker lifestyle','andrew martin','aerin','barclay butera','thom filicia',
+  'nicolette mayer','nicholette mayer'
 ]);
-function isKravet(vendor_family, vendor) {
-  if (vendor_family === 'kravet') return true;
+function isKravetVendor(vendor) {
   return KRAVET_VENDORS.has((vendor || '').toLowerCase().trim());
 }
 
-// ── Pricing ─────────────────────────────────────────────────────────────────
-// Returns { retail, trade, pricing_rule } — never exposes cost.
+// ── Pricing from Shopify catalog ────────────────────────────────────────────
+// Shopify catalog has: price (store retail), variant_id, sku, handle, vendor
+// Kravet-family: store price IS MAP; trade price = MAP (Kravet enforces MAP)
+// Non-Kravet: retail = store price; trade = est. retail × 0.75 (25% off, labeled est.)
+// NEVER exposes cost/wholesale.
 function computePricing(p) {
-  const cost = parseFloat(p.cost) || 0;
-  if (cost <= 0) return { retail: null, trade: null, pricing_rule: 'no-cost' };
-  if (isKravet(p.vendor_family, p.vendor)) {
-    const map = Math.round(cost * 1.5 * 100) / 100;
-    // Kravet-family: trade price = MAP (they sell at MAP; no further discount)
-    return { retail: map, trade: map, pricing_rule: 'kravet-map' };
+  const retail = parseFloat(p.price) || 0;
+  if (retail <= 0) return { retail: null, trade: null, pricing_rule: 'no-price' };
+  if (isKravetVendor(p.vendor)) {
+    // Kravet: store price = MAP; no further trade discount (MAP enforcement)
+    return { retail, trade: retail, pricing_rule: 'kravet-map' };
   } else {
-    // Non-Kravet: retail = cost / 0.65 / 0.85; trade = retail × 0.75 (25% off)
-    const retail = Math.round((cost / 0.65 / 0.85) * 100) / 100;
-    const trade  = Math.round(retail * 0.75 * 100) / 100;
-    return { retail, trade, pricing_rule: 'standard-25pct-off' };
+    // Non-Kravet: est. trade = retail × 0.75
+    const trade = Math.round(retail * 0.75 * 100) / 100;
+    return { retail, trade, pricing_rule: 'standard-est-25pct' };
   }
 }
 
@@ -60,9 +65,36 @@ function createSession(email, account) {
 }
 
 // ── Data loaders ─────────────────────────────────────────────────────────────
-function loadProducts() {
-  try { return JSON.parse(fs.readFileSync(DATA('products.json'), 'utf8')); } catch { return []; }
+let _catalogCache = null;
+function loadCatalog() {
+  if (_catalogCache) return _catalogCache;
+  try {
+    const raw = JSON.parse(fs.readFileSync(DATA('shopify-catalog.json'), 'utf8'));
+    // Extract metadata + normalize products
+    const meta = {
+      checkout_domain: raw.checkout_domain || 'www.designerwallcoverings.com',
+      shop_domain:     raw.shop_domain || '',
+      pulled_at:       raw.pulled_at || '',
+    };
+    const products = (raw.products || []).map(p => ({
+      id:           p.id,
+      handle:       p.handle || '',
+      title:        p.title || '',
+      vendor:       p.vendor || '',
+      product_type: p.type || '',
+      image:        p.image || '',
+      sku:          p.sku || '',
+      price:        parseFloat(p.price) || 0,
+      variant_id:   p.variant_id || null,
+    }));
+    _catalogCache = { meta, products };
+    return _catalogCache;
+  } catch (e) {
+    console.error('[catalog] load error:', e.message);
+    return { meta: { checkout_domain: 'www.designerwallcoverings.com' }, products: [] };
+  }
 }
+
 function loadApplications() {
   try { return JSON.parse(fs.readFileSync(DATA('applications.json'), 'utf8')); } catch { return []; }
 }
@@ -76,13 +108,24 @@ function sortProducts(items, mode) {
   switch (mode) {
     case 'title':      return a.sort((x,y) => (x.title||'').localeCompare(y.title||''));
     case 'sku':        return a.sort((x,y) => (x.sku||'').localeCompare(y.sku||''));
-    case 'price-asc':  return a.sort((x,y) => { const pa=computePricing(x).retail||0, pb=computePricing(y).retail||0; return pa-pb; });
-    case 'price-desc': return a.sort((x,y) => { const pa=computePricing(x).retail||0, pb=computePricing(y).retail||0; return pb-pa; });
+    case 'price-asc':  return a.sort((x,y) => (x.price||0) - (y.price||0));
+    case 'price-desc': return a.sort((x,y) => (y.price||0) - (x.price||0));
     case 'vendor':     return a.sort((x,y) => (x.vendor||'').localeCompare(y.vendor||''));
-    default:           return a; // newest = natural order
+    case 'color':      return a.sort((x,y) => (x.title||'').localeCompare(y.title||''));   // approx
+    default:           return a; // newest = catalog order
   }
 }
 
+// ── Cart permalink builder ────────────────────────────────────────────────────
+// https://www.designerwallcoverings.com/cart/<variant_id>:<qty>[,<variant_id>:<qty>...]
+// Optional ?discount=<CODE> placeholder for Phase-2 discount codes.
+function buildCartUrl(checkoutDomain, variantId, qty, discountCode) {
+  if (!variantId) return null;
+  let url = `https://${checkoutDomain}/cart/${variantId}:${qty || 1}`;
+  if (discountCode) url += `?discount=${encodeURIComponent(discountCode)}`;
+  return url;
+}
+
 // ── Basic-auth helper (for /admin) ───────────────────────────────────────────
 function authedAdmin(req) {
   if (!ADMIN_AUTH) return true;
@@ -154,24 +197,37 @@ const server = http.createServer(async (req, res) => {
 
   // ── GET /api/products ──
   if (method === 'GET' && u.pathname === '/api/products') {
-    const raw   = loadProducts();
-    const sorted = sortProducts(raw, u.searchParams.get('sort') || 'newest');
+    const { meta, products } = loadCatalog();
+    const sortMode = u.searchParams.get('sort') || 'newest';
+    const sorted = sortProducts(products, sortMode);
+    const discountCode = u.searchParams.get('discount') || '';
     const out = sorted.map(p => {
       const pricing = computePricing(p);
-      // NEVER expose cost or vendor_family internals
+      // Build cart URL (no cost, no wholesale exposed)
+      const cartUrl = buildCartUrl(meta.checkout_domain, p.variant_id, 1, discountCode || null);
       return {
         sku:          p.sku,
+        handle:       p.handle,
         title:        p.title,
         vendor:       p.vendor,
         product_type: p.product_type,
         image:        p.image,
+        variant_id:   p.variant_id,
         retail:       pricing.retail,
-        trade:        isTrade ? pricing.trade : null,   // only if trade session
-        pricing_rule: isTrade ? pricing.pricing_rule : null
+        // trade price only if logged in as trade — never expose to public
+        trade:        isTrade ? pricing.trade : null,
+        pricing_rule: isTrade ? pricing.pricing_rule : null,
+        cart_url:     cartUrl,  // safe: just a URL with public variant_id
       };
     });
     res.writeHead(200, { 'Content-Type': 'application/json' });
-    return res.end(JSON.stringify({ count: out.length, trade: isTrade, products: out }));
+    return res.end(JSON.stringify({
+      count: out.length,
+      trade: isTrade,
+      checkout_domain: meta.checkout_domain,
+      catalog_pulled_at: meta.pulled_at,
+      products: out
+    }));
   }
 
   // ── POST /api/apply ──
@@ -210,7 +266,6 @@ const server = http.createServer(async (req, res) => {
       res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Trade Portal Admin"', 'Content-Type': 'text/plain' });
       return res.end('Admin auth required');
     }
-    // Serve admin.html
     const fp = path.join(DIR, 'public', 'admin.html');
     if (fs.existsSync(fp)) {
       res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
@@ -229,7 +284,7 @@ const server = http.createServer(async (req, res) => {
     const sort  = u.searchParams.get('sort') || 'newest';
     const sorted = apps.slice().sort((a, b) => {
       if (sort === 'oldest') return new Date(a.created_at) - new Date(b.created_at);
-      return new Date(b.created_at) - new Date(a.created_at); // newest default
+      return new Date(b.created_at) - new Date(a.created_at);
     });
     res.writeHead(200, { 'Content-Type': 'application/json' });
     return res.end(JSON.stringify({ count: sorted.length, applications: sorted }));
@@ -237,7 +292,6 @@ const server = http.createServer(async (req, res) => {
 
   // ── Static files from /public ─────────────────────────────────────────────
   let fname = u.pathname === '/' ? 'index.html' : u.pathname.replace(/^\//, '');
-  // prevent directory traversal
   fname = path.basename(fname);
   const fp = path.join(DIR, 'public', fname);
   if (fs.existsSync(fp) && fs.statSync(fp).isFile()) {
@@ -253,5 +307,7 @@ server.listen(PORT, () => {
   console.log(`[trade-portal] http://localhost:${server.address().port}`);
   console.log(`  Demo trade login: trade@demo.com / trade2024`);
   console.log(`  Admin: /admin${ADMIN_AUTH ? ' (BASIC_AUTH set)' : ' (open — set BASIC_AUTH=user:pass to gate)'}`);
-  console.log(`  Pricing: Kravet MAP=cost×1.5; Non-Kravet retail=cost/0.65/0.85, trade=retail×0.75`);
+  console.log(`  Catalog: data/shopify-catalog.json (real DW Shopify store, read-only)`);
+  console.log(`  Cart: https://www.designerwallcoverings.com/cart/<variant_id>:<qty>`);
+  console.log(`  Pricing: Kravet MAP=store price; Non-Kravet trade=est. retail×0.75`);
 });

← 2eb6579 Add real Shopify catalog data source (shopify-catalog.json,  ·  back to Trade Portal  ·  Add Buy on Shopify cart button, test-mode banner, trade pric 7904fa5 →