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