← back to Trade Portal
server.js
326 lines
// 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.
// 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);
const ADMIN_AUTH = process.env.BASIC_AUTH || ''; // "user:pass" gates /admin
const SESSION_SECRET = process.env.SESSION_SECRET || 'trade-portal-dev-secret';
const DIR = __dirname;
const DATA = p => path.join(DIR, 'data', p);
// ── Kravet-family brand set (MAP pricing rule) ──────────────────────────────
const KRAVET_VENDORS = new Set([
'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','clarke and clarke','mulberry','threads',
'baker lifestyle','andrew martin','aerin','barclay butera','thom filicia',
'nicolette mayer','nicholette mayer'
]);
function isKravetVendor(vendor) {
return KRAVET_VENDORS.has((vendor || '').toLowerCase().trim());
}
// ── 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) {
// sample_only products have no numeric price — treat as on-request
if (p.sample_only || p.price == null) {
return { retail: null, trade: null, pricing_rule: 'on-request' };
}
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: est. trade = retail × 0.75
const trade = Math.round(retail * 0.75 * 100) / 100;
return { retail, trade, pricing_rule: 'standard-est-25pct' };
}
}
// ── Session store (in-memory; ephemeral) ────────────────────────────────────
// Demo trade login: email=trade@demo.com password=trade2024
const TRADE_ACCOUNTS = {
'trade@demo.com': { password: 'trade2024', name: 'Demo Trade Account', company: 'Demo Studio' }
};
const sessions = {};
function makeSid() { return crypto.randomBytes(16).toString('hex'); }
function getSid(req) {
const cookie = req.headers.cookie || '';
const m = cookie.match(/(?:^|;\s*)tp_sid=([a-f0-9]+)/);
return m ? m[1] : null;
}
function getSession(req) { const sid = getSid(req); return sid ? (sessions[sid] || null) : null; }
function createSession(email, account) {
const sid = makeSid();
sessions[sid] = { sid, email, name: account.name, company: account.company, trade: true, created: Date.now() };
return sid;
}
// ── Data loaders ─────────────────────────────────────────────────────────────
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: p.price != null ? parseFloat(p.price) : null,
price_display: p.price_display || null,
buyable: p.buyable === true,
sample_only: p.sample_only === true,
product_url: p.product_url || null,
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 []; }
}
function saveApplications(apps) {
fs.writeFileSync(DATA('applications.json'), JSON.stringify(apps, null, 2));
}
// ── Sort ─────────────────────────────────────────────────────────────────────
function sortProducts(items, mode) {
const a = items.slice();
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) => (x.price ?? Infinity) - (y.price ?? Infinity));
case 'price-desc': return a.sort((x,y) => (y.price ?? -Infinity) - (x.price ?? -Infinity));
case 'vendor': return a.sort((x,y) => (x.vendor||'').localeCompare(y.vendor||''));
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;
const m = (req.headers.authorization || '').match(/^Basic\s+(.+)$/i);
if (!m) return false;
try { return Buffer.from(m[1], 'base64').toString() === ADMIN_AUTH; } catch { return false; }
}
// ── Static file types ─────────────────────────────────────────────────────────
function mime(f) {
if (f.endsWith('.html')) return 'text/html; charset=utf-8';
if (f.endsWith('.js')) return 'application/javascript';
if (f.endsWith('.css')) return 'text/css';
if (f.endsWith('.json')) return 'application/json';
return 'text/plain';
}
// ── Body reader ──────────────────────────────────────────────────────────────
function readBody(req) {
return new Promise((resolve, reject) => {
let body = '';
req.on('data', d => body += d);
req.on('end', () => { try { resolve(JSON.parse(body)); } catch { resolve({}); } });
req.on('error', reject);
});
}
// ── Router ────────────────────────────────────────────────────────────────────
const server = http.createServer(async (req, res) => {
const u = new URL(req.url, `http://localhost:${PORT}`);
const method = req.method.toUpperCase();
const session = getSession(req);
const isTrade = session && session.trade === true;
// ── POST /api/login ──
if (method === 'POST' && u.pathname === '/api/login') {
const body = await readBody(req);
const email = (body.email || '').toLowerCase().trim();
const pass = body.password || '';
const acct = TRADE_ACCOUNTS[email];
if (acct && acct.password === pass) {
const sid = createSession(email, acct);
res.writeHead(200, {
'Set-Cookie': `tp_sid=${sid}; HttpOnly; Path=/; SameSite=Lax; Max-Age=86400`,
'Content-Type': 'application/json'
});
return res.end(JSON.stringify({ ok: true, name: acct.name, company: acct.company }));
}
res.writeHead(401, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ ok: false, error: 'Invalid email or password' }));
}
// ── POST /api/logout ──
if (method === 'POST' && u.pathname === '/api/logout') {
const sid = getSid(req);
if (sid) delete sessions[sid];
res.writeHead(200, {
'Set-Cookie': 'tp_sid=; HttpOnly; Path=/; Max-Age=0',
'Content-Type': 'application/json'
});
return res.end(JSON.stringify({ ok: true }));
}
// ── GET /api/session ──
if (method === 'GET' && u.pathname === '/api/session') {
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ trade: isTrade, name: session?.name || null, company: session?.company || null }));
}
// ── GET /api/products ──
if (method === 'GET' && u.pathname === '/api/products') {
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);
// Build cart URL only for buyable products (sample_only never gets a cart permalink)
const cartUrl = p.buyable ? buildCartUrl(meta.checkout_domain, p.variant_id, 1, discountCode || null) : 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,
buyable: p.buyable,
sample_only: p.sample_only,
price_display: p.price_display, // pre-formatted string, never null/$null
product_url: p.product_url, // live DW product page URL
retail: pricing.retail,
// 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; null for sample_only
};
});
res.writeHead(200, { 'Content-Type': 'application/json' });
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 ──
if (method === 'POST' && u.pathname === '/api/apply') {
const body = await readBody(req);
const required = ['company_name','contact_name','email','phone','trade_cert'];
const missing = required.filter(k => !body[k] || !String(body[k]).trim());
if (missing.length) {
res.writeHead(400, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ ok: false, error: `Missing: ${missing.join(', ')}` }));
}
const apps = loadApplications();
const entry = {
id: crypto.randomBytes(6).toString('hex'),
created_at: new Date().toISOString(),
company_name: String(body.company_name).trim(),
contact_name: String(body.contact_name).trim(),
email: String(body.email).trim().toLowerCase(),
phone: String(body.phone).trim(),
trade_cert: String(body.trade_cert).trim(),
business_type: String(body.business_type || '').trim(),
website: String(body.website || '').trim(),
how_heard: String(body.how_heard || '').trim(),
notes: String(body.notes || '').trim(),
status: 'pending'
};
apps.push(entry);
saveApplications(apps);
res.writeHead(201, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ ok: true, id: entry.id }));
}
// ── GET /admin or /admin/ ── (basic-auth gated if BASIC_AUTH set)
if (method === 'GET' && (u.pathname === '/admin' || u.pathname === '/admin/')) {
if (!authedAdmin(req)) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Trade Portal Admin"', 'Content-Type': 'text/plain' });
return res.end('Admin auth required');
}
const fp = path.join(DIR, 'public', 'admin.html');
if (fs.existsSync(fp)) {
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
return res.end(fs.readFileSync(fp));
}
res.writeHead(404); return res.end('admin.html not found');
}
// ── GET /api/admin/applications ── (basic-auth gated)
if (method === 'GET' && u.pathname === '/api/admin/applications') {
if (!authedAdmin(req)) {
res.writeHead(401, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ error: 'unauthorized' }));
}
const apps = loadApplications();
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);
});
res.writeHead(200, { 'Content-Type': 'application/json' });
return res.end(JSON.stringify({ count: sorted.length, applications: sorted }));
}
// ── Static files from /public ─────────────────────────────────────────────
let fname = u.pathname === '/' ? 'index.html' : u.pathname.replace(/^\//, '');
fname = path.basename(fname);
const fp = path.join(DIR, 'public', fname);
if (fs.existsSync(fp) && fs.statSync(fp).isFile()) {
res.writeHead(200, { 'Content-Type': mime(fname) });
return res.end(fs.readFileSync(fp));
}
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('not found');
});
server.listen(PORT, '127.0.0.1', () => {
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(` 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`);
});