← 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`);
});