[object Object]

← back to Trade Portal

Phase-1 trade-portal: trade application form, login stub, MAP/net pricing, admin card grid

ebc041b7e663a36ac285a622e66a0fafd6e00c75 · 2026-07-15 16:59:31 -0700 · Steve

Files touched

Diff

commit ebc041b7e663a36ac285a622e66a0fafd6e00c75
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jul 15 16:59:31 2026 -0700

    Phase-1 trade-portal: trade application form, login stub, MAP/net pricing, admin card grid
---
 data/applications.json |   1 +
 data/products.json     | 166 ++++++++++++++++++++-
 public/admin.html      | 147 ++++++++++++++++++
 public/index.html      | 395 ++++++++++++++++++++++++++++++++++++++++++-------
 server.js              | 290 +++++++++++++++++++++++++++++++-----
 5 files changed, 907 insertions(+), 92 deletions(-)

diff --git a/data/applications.json b/data/applications.json
new file mode 100644
index 0000000..fe51488
--- /dev/null
+++ b/data/applications.json
@@ -0,0 +1 @@
+[]
diff --git a/data/products.json b/data/products.json
index 8eee5f2..7c03e88 100644
--- a/data/products.json
+++ b/data/products.json
@@ -1,6 +1,164 @@
 [
-  { "title": "Sample One", "sku": "SMP-001", "price": 129.00, "hex": "#2b3a55", "image": "" },
-  { "title": "Sample Two", "sku": "SMP-002", "price": 89.00, "hex": "#d8c3a5", "image": "" },
-  { "title": "Sample Three", "sku": "SMP-003", "price": 210.00, "hex": "#8e8d8a", "image": "" },
-  { "title": "Sample Four", "sku": "SMP-004", "price": 54.00, "hex": "#e98074", "image": "" }
+  {
+    "sku": "DWKK-102097",
+    "title": "Satoru - Antique Gold",
+    "vendor": "Brunschwig & Fils",
+    "vendor_family": "kravet",
+    "product_type": "Wallcovering",
+    "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/P8015123_44_942b3f2a-c5bb-48c4-a40c-aa76ba87fb73.jpg?v=1753290654",
+    "cost": 341.25
+  },
+  {
+    "sku": "DWKK-102096",
+    "title": "Satoru - Gold/Copper Gold",
+    "vendor": "Brunschwig & Fils",
+    "vendor_family": "kravet",
+    "product_type": "Wallcovering",
+    "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/P8015123_424_1976c99c-9a18-4a68-95df-17c62e754155.jpg?v=1753290656",
+    "cost": 341.25
+  },
+  {
+    "sku": "DWKK-102091",
+    "title": "Satoru - Pewter Silver",
+    "vendor": "Brunschwig & Fils",
+    "vendor_family": "kravet",
+    "product_type": "Wallcovering",
+    "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/P8015123_1111_50c7d46e-86b6-4b44-ac39-a9c209cf72b9.jpg?v=1753290667",
+    "cost": 341.25
+  },
+  {
+    "sku": "DWKK-102076",
+    "title": "Kiko - Bronze Gold",
+    "vendor": "Brunschwig & Fils",
+    "vendor_family": "kravet",
+    "product_type": "Wallcovering",
+    "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/P8015120_46_749b3805-625a-4a3f-bb77-00811ec629c5.jpg?v=1753290704",
+    "cost": 312.90
+  },
+  {
+    "sku": "DWKK-102073",
+    "title": "Kiko - Gold",
+    "vendor": "Brunschwig & Fils",
+    "vendor_family": "kravet",
+    "product_type": "Wallcovering",
+    "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/P8015120_4_ae6c2b22-8f61-44af-81c2-8e78210e7af1.jpg?v=1753290710",
+    "cost": 312.90
+  },
+  {
+    "sku": "DWKK-140649",
+    "title": "Hydrangea Bird - Rose/Biscuit",
+    "vendor": "GP & J Baker",
+    "vendor_family": "kravet",
+    "product_type": "Fabric",
+    "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R1355_3_8fc9988a-abd2-4f14-9041-006305517990.jpg?v=1726601926",
+    "cost": 156.45
+  },
+  {
+    "sku": "DWKK-140648",
+    "title": "Hydrangea Bird - Mustard/Mauve",
+    "vendor": "GP & J Baker",
+    "vendor_family": "kravet",
+    "product_type": "Fabric",
+    "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R1355_1_f825edcd-0186-4c06-a495-1b323eeee50a.jpg?v=1726601924",
+    "cost": 156.45
+  },
+  {
+    "sku": "DWKK-140647",
+    "title": "Magnolia - Cream/Blue Multi",
+    "vendor": "GP & J Baker",
+    "vendor_family": "kravet",
+    "product_type": "Fabric",
+    "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R1351_4_8579ec30-7389-446a-ba5f-0d273e57a6c4.jpg?v=1726601922",
+    "cost": 203.70
+  },
+  {
+    "sku": "DWKK-140641",
+    "title": "Nympheus Linen - Teal/Green",
+    "vendor": "GP & J Baker",
+    "vendor_family": "kravet",
+    "product_type": "Fabric",
+    "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R1206_7_69d1c5b8-0843-4f2a-b69f-a7133fee9857.jpg?v=1726601911",
+    "cost": 165.90
+  },
+  {
+    "sku": "DWKK-140639",
+    "title": "Nympheus Linen - Biscuit/Taupe",
+    "vendor": "GP & J Baker",
+    "vendor_family": "kravet",
+    "product_type": "Fabric",
+    "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R1206_2_d3d489fe-080c-487c-bcf7-f61b9cc78697.jpg?v=1726601905",
+    "cost": 165.90
+  },
+  {
+    "sku": "DWTT-81027",
+    "title": "Kyoto Leaves Fuchsia",
+    "vendor": "Anna French",
+    "vendor_family": "non-kravet",
+    "product_type": "Wallcovering",
+    "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/room-living_room-dwtt-81027-designer-wallcoverings-los-angeles.jpg?v=1775071159",
+    "cost": 67.50
+  },
+  {
+    "sku": "DWTT-81026",
+    "title": "Kyoto Leaves Eggplant",
+    "vendor": "Anna French",
+    "vendor_family": "non-kravet",
+    "product_type": "Wallcovering",
+    "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/room-living_room-dwtt-81026-designer-wallcoverings-los-angeles.jpg?v=1775071122",
+    "cost": 67.50
+  },
+  {
+    "sku": "DWTT-81010",
+    "title": "Kimono Navy",
+    "vendor": "Anna French",
+    "vendor_family": "non-kravet",
+    "product_type": "Wallcovering",
+    "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/Nara-Kimino-06-FAB.jpg?v=1770345334",
+    "cost": 85.50
+  },
+  {
+    "sku": "DWTT-80984",
+    "title": "Kyoto Robin's Egg",
+    "vendor": "Anna French",
+    "vendor_family": "non-kravet",
+    "product_type": "Wallcovering",
+    "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/Nara-Kyoto-10-WP-UpstreamSheer.jpg?v=1770345411",
+    "cost": 72.90
+  },
+  {
+    "sku": "DWWC-500630",
+    "title": "Boreas Midnight",
+    "vendor": "Graham & Brown",
+    "vendor_family": "non-kravet",
+    "product_type": "Wallcovering",
+    "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/boreas-midnight-wallpaper.jpg?v=1761005216",
+    "cost": 108.00
+  },
+  {
+    "sku": "DWIN-15040",
+    "title": "Rangoon Silk",
+    "vendor": "Innovations USA",
+    "vendor_family": "non-kravet",
+    "product_type": "Wallcovering",
+    "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/products/RS8505.jpg?v=1736198871",
+    "cost": 31.96
+  },
+  {
+    "sku": "DWIN-15042",
+    "title": "Rangoon Silk - Slate",
+    "vendor": "Innovations USA",
+    "vendor_family": "non-kravet",
+    "product_type": "Wallcovering",
+    "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/products/RS8503.jpg?v=1736198874",
+    "cost": 31.96
+  },
+  {
+    "sku": "DWIN-15037",
+    "title": "Sanctuary",
+    "vendor": "Innovations USA",
+    "vendor_family": "non-kravet",
+    "product_type": "Wallcovering",
+    "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/products/SAC-003.jpg?v=1736198867",
+    "cost": 31.96
+  }
 ]
diff --git a/public/admin.html b/public/admin.html
new file mode 100644
index 0000000..b955e53
--- /dev/null
+++ b/public/admin.html
@@ -0,0 +1,147 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Admin — Trade Portal Applications</title>
+<style>
+  :root{--cols:3;--accent:#1a3a5c;--gold:#b8962e}
+  *{box-sizing:border-box}
+  body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;color:#1a1a1a;background:#f0ede8}
+  .topbar{background:var(--accent);color:#fff;padding:10px 24px;display:flex;align-items:center;gap:14px}
+  .topbar h1{margin:0;font-size:14px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;flex:1}
+  .topbar a{color:#cce0ff;font-size:13px;text-decoration:none}
+  .controls{display:flex;gap:12px;align-items:center;flex-wrap:wrap;padding:12px 24px;border-bottom:1px solid #ddd;background:#fff;position:sticky;top:0;z-index:10}
+  .controls h2{margin:0;font-size:13px;font-weight:600;color:#666;flex:1}
+  label{font-size:12px;color:#666;display:flex;gap:6px;align-items:center}
+  select,input[type=range]{font:inherit}
+  .count{font-size:12px;color:#999}
+  .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:14px;padding:20px}
+  /* ── Application card ── */
+  .card{background:#fff;border:1px solid #e0dbd4;border-radius:8px;padding:14px;position:relative}
+  .card-header{display:flex;justify-content:space-between;align-items:flex-start;gap:8px;margin-bottom:10px}
+  .card-company{font-size:14px;font-weight:700;color:var(--accent);margin:0}
+  .card-contact{font-size:12px;color:#555;margin:2px 0 0}
+  .status-badge{font-size:10px;font-weight:700;padding:2px 7px;border-radius:3px;text-transform:uppercase;letter-spacing:.04em;white-space:nowrap;flex-shrink:0}
+  .status-pending{background:#fff3cd;color:#856404}
+  .status-approved{background:#d1e7dd;color:#0a3622}
+  .status-rejected{background:#f8d7da;color:#58151c}
+  /* ── HARD RULE: created date+time chip ── */
+  .when-chip{font-size:11px;color:#888;display:flex;align-items:center;gap:4px;margin-bottom:8px}
+  .when-chip svg{opacity:.5}
+  .divider{border:none;border-top:1px solid #f0ede8;margin:10px 0}
+  .field-row{display:flex;gap:6px;margin-bottom:5px;font-size:12px}
+  .field-label{color:#999;min-width:80px;flex-shrink:0}
+  .field-val{color:#333;word-break:break-word}
+  .field-val a{color:var(--accent);text-decoration:none}
+  .field-val a:hover{text-decoration:underline}
+  .card-notes{font-size:12px;color:#666;font-style:italic;margin-top:8px;padding:8px;background:#f9f8f7;border-radius:4px}
+  .card-id{font-size:10px;color:#ccc;margin-top:10px}
+  .empty{padding:60px;text-align:center;color:#999}
+</style>
+</head>
+<body>
+
+<div class="topbar">
+  <h1>Trade Portal &mdash; Applications</h1>
+  <a href="/">&larr; Storefront</a>
+</div>
+
+<div class="controls">
+  <h2 id="page-heading">Trade Account Applications</h2>
+  <label>Sort
+    <select id="sort">
+      <option value="newest">Newest First</option>
+      <option value="oldest">Oldest First</option>
+    </select>
+  </label>
+  <label>Density <input id="density" type="range" min="1" max="4" value="3"></label>
+  <span class="count" id="count"></span>
+</div>
+
+<div class="grid" id="grid"><div class="empty">Loading&hellip;</div></div>
+
+<script>
+const esc = s => (s||'').replace(/[&<>"']/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+const $id = id => document.getElementById(id);
+
+// Persist sort + density (house rule)
+const sortSel = $id('sort'), densInp = $id('density');
+sortSel.value = localStorage.getItem('admin-sort') || 'newest';
+densInp.value = localStorage.getItem('admin-cols') || '3';
+document.documentElement.style.setProperty('--cols', densInp.value);
+sortSel.onchange = () => { localStorage.setItem('admin-sort', sortSel.value); load(); };
+densInp.oninput  = () => {
+  document.documentElement.style.setProperty('--cols', densInp.value);
+  localStorage.setItem('admin-cols', densInp.value);
+};
+
+// HARD RULE: format date+time for admin cards
+// Uses toLocaleString with full year/month/day + hour/minute in local timezone.
+// Full ISO in title attribute for precision.
+function fmtDate(iso) {
+  if (!iso) return '—';
+  const d = new Date(iso);
+  return d.toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
+}
+
+function statusBadge(status) {
+  const map = { pending:'status-pending', approved:'status-approved', rejected:'status-rejected' };
+  const cls = map[status] || 'status-pending';
+  return `<span class="status-badge ${cls}">${esc(status||'pending')}</span>`;
+}
+
+function cardHTML(a) {
+  const createdIso  = a.created_at || '';
+  const createdDisp = fmtDate(createdIso);
+  return `<div class="card">
+    <div class="card-header">
+      <div>
+        <div class="card-company">${esc(a.company_name||'—')}</div>
+        <div class="card-contact">${esc(a.contact_name||'')}</div>
+      </div>
+      ${statusBadge(a.status)}
+    </div>
+    <!-- HARD RULE: created date+time visible on card; full ISO in title -->
+    <div class="when-chip" title="${esc(createdIso)}">
+      <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
+        <circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/>
+      </svg>
+      ${esc(createdDisp)}
+    </div>
+    <hr class="divider">
+    <div class="field-row"><span class="field-label">Email</span>
+      <span class="field-val"><a href="mailto:${esc(a.email||'')}">${esc(a.email||'—')}</a></span></div>
+    <div class="field-row"><span class="field-label">Phone</span>
+      <span class="field-val">${esc(a.phone||'—')}</span></div>
+    <div class="field-row"><span class="field-label">Trade Cert</span>
+      <span class="field-val">${esc(a.trade_cert||'—')}</span></div>
+    <div class="field-row"><span class="field-label">Biz Type</span>
+      <span class="field-val">${esc(a.business_type||'—')}</span></div>
+    ${a.website ? `<div class="field-row"><span class="field-label">Website</span>
+      <span class="field-val"><a href="${esc(a.website)}" target="_blank" rel="noopener">${esc(a.website)}</a></span></div>` : ''}
+    ${a.how_heard ? `<div class="field-row"><span class="field-label">How heard</span>
+      <span class="field-val">${esc(a.how_heard)}</span></div>` : ''}
+    ${a.notes ? `<div class="card-notes">${esc(a.notes)}</div>` : ''}
+    <div class="card-id">ID: ${esc(a.id||'—')}</div>
+  </div>`;
+}
+
+async function load() {
+  const r = await fetch('/api/admin/applications?sort=' + encodeURIComponent(sortSel.value));
+  if (r.status === 401) {
+    $id('grid').innerHTML = '<div class="empty">Not authorized. Reload and enter admin credentials.</div>';
+    return;
+  }
+  const d = await r.json();
+  $id('count').textContent = d.count + ' application' + (d.count !== 1 ? 's' : '');
+  $id('page-heading').textContent = 'Trade Account Applications (' + d.count + ')';
+  $id('grid').innerHTML = d.applications.length
+    ? d.applications.map(cardHTML).join('')
+    : '<div class="empty">No applications yet. They appear here after someone submits the trade form.</div>';
+}
+
+load();
+</script>
+</body>
+</html>
diff --git a/public/index.html b/public/index.html
index ceb7ab1..997277b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,55 +1,348 @@
-<!doctype html><html lang="en"><head><meta charset="utf-8">
-<meta name="viewport" content="width=device-width,initial-scale=1"><title>Storefront starter</title>
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>To the Trade — Designer Wallcoverings</title>
 <style>
-  :root{--cols:4}
-  *{box-sizing:border-box} body{margin:0;font:15px/1.4 -apple-system,Segoe UI,Roboto,sans-serif;color:#1a1a1a;background:#fafafa}
-  header{display:flex;gap:14px;align-items:center;flex-wrap:wrap;padding:14px 20px;border-bottom:1px solid #e6e6e6;position:sticky;top:0;background:#fff;z-index:5}
-  h1{font-size:17px;margin:0;font-weight:700} .spacer{flex:1}
+  :root{--cols:4;--accent:#1a3a5c;--gold:#b8962e}
+  *{box-sizing:border-box}
+  body{margin:0;font:15px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;color:#1a1a1a;background:#f7f5f2}
+  .topbar{background:var(--accent);color:#fff;padding:10px 24px;display:flex;align-items:center;gap:16px;flex-wrap:wrap}
+  .topbar h1{margin:0;font-size:15px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;flex:1}
+  .topbar a{color:#cce0ff;font-size:13px;text-decoration:none;cursor:pointer}
+  .topbar a:hover{color:#fff}
+  .trade-badge{background:var(--gold);color:#fff;font-size:11px;font-weight:700;padding:2px 8px;border-radius:3px;letter-spacing:.05em;text-transform:uppercase}
+  #session-info{font-size:13px;color:#cce0ff;display:flex;align-items:center;gap:10px}
+  .controls{display:flex;gap:12px;align-items:center;flex-wrap:wrap;padding:12px 24px;border-bottom:1px solid #e2ddd7;background:#fff;position:sticky;top:0;z-index:10}
+  .controls h2{margin:0;font-size:13px;font-weight:600;color:#666;flex:1}
   label{font-size:12px;color:#666;display:flex;gap:6px;align-items:center}
-  select,input[type=range]{font:inherit} .count{font-size:12px;color:#999}
-  .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:14px;padding:20px}
-  .card{background:#fff;border:1px solid #e6e6e6;border-radius:8px;overflow:hidden}
-  .card img{width:100%;aspect-ratio:1/1;object-fit:cover;display:block;background:#f0f0f0}
-  .meta{padding:8px 10px} .t{font-size:13px;font-weight:600;margin:0 0 2px} .s{font-size:11px;color:#888}
-  .p{font-size:13px;margin-top:4px} .empty{padding:40px;color:#999;text-align:center}
-</style></head><body>
-<header>
-  <h1>Storefront starter</h1>
-  <label>Sort
-    <select id="sort">
-      <option value="newest">Newest</option>
-      <option value="title">Title A→Z</option>
-      <option value="sku">SKU A→Z</option>
-      <option value="price-asc">Price ↑</option>
-      <option value="price-desc">Price ↓</option>
-      <option value="light">Light → Dark</option>
-      <option value="dark">Dark → Light</option>
-    </select>
-  </label>
-  <label>Density <input id="density" type="range" min="2" max="8" value="4"></label>
-  <span class="spacer"></span><span class="count" id="count"></span>
-</header>
-<div class="grid" id="grid"><div class="empty">Loading…</div></div>
+  select,input[type=range]{font:inherit}
+  .count{font-size:12px;color:#999}
+  .nav-links{display:flex;gap:14px}
+  .nav-links a{font-size:13px;color:var(--accent);text-decoration:none;font-weight:500;cursor:pointer}
+  .nav-links a:hover{text-decoration:underline}
+  .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:16px;padding:24px}
+  .card{background:#fff;border:1px solid #e2ddd7;border-radius:8px;overflow:hidden;transition:box-shadow .15s}
+  .card:hover{box-shadow:0 4px 16px rgba(0,0,0,.1)}
+  .card img{width:100%;aspect-ratio:4/3;object-fit:cover;display:block;background:#eee}
+  .card-body{padding:10px 12px}
+  .card-title{font-size:13px;font-weight:600;margin:0 0 2px;line-height:1.3}
+  .card-vendor{font-size:11px;color:#888;margin:0 0 4px}
+  .card-type{font-size:11px;color:#aaa}
+  .card-sku{font-size:10px;color:#bbb;margin-top:2px}
+  .price-row{margin-top:8px;display:flex;flex-direction:column;gap:3px}
+  .price-retail{font-size:13px;color:#444}
+  .price-retail .plabel{font-size:10px;color:#999;text-transform:uppercase;letter-spacing:.04em}
+  .price-trade-wrap{margin-top:4px}
+  .price-trade{font-size:14px;font-weight:700;color:var(--accent);background:#eaf2ff;padding:3px 7px;border-radius:4px;display:inline-block}
+  .price-trade-label{font-size:10px;color:var(--gold);font-weight:700;text-transform:uppercase;letter-spacing:.04em}
+  .trade-locked{font-size:12px;color:#bbb;font-style:italic;margin-top:4px}
+  .kravet-badge{font-size:10px;background:#fef3e2;color:var(--gold);border:1px solid #f5d68a;padding:1px 5px;border-radius:3px;display:inline-block;margin-top:3px}
+  .empty{padding:60px;text-align:center;color:#999}
+  #trade-cta{background:linear-gradient(135deg,#1a3a5c,#0d2440);color:#fff;padding:20px 24px;display:flex;align-items:center;gap:20px;flex-wrap:wrap}
+  #trade-cta h3{margin:0 0 4px;font-size:15px}
+  #trade-cta p{margin:0;font-size:13px;color:#bcd6f5}
+  #trade-cta .cta-actions{display:flex;gap:10px;flex-wrap:wrap;margin-left:auto}
+  .btn{padding:9px 18px;border-radius:5px;font-size:13px;font-weight:600;cursor:pointer;border:none;text-decoration:none;display:inline-block}
+  .btn-primary{background:var(--gold);color:#fff}
+  .btn-primary:hover{background:#9a7a22}
+  .btn-outline{background:transparent;color:#fff;border:1px solid rgba(255,255,255,.4)}
+  .btn-outline:hover{background:rgba(255,255,255,.1)}
+  .btn-sm{padding:6px 14px;font-size:12px}
+  .modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:100;display:none;align-items:center;justify-content:center}
+  .modal-overlay.open{display:flex}
+  .modal{background:#fff;border-radius:10px;width:100%;max-width:400px;padding:28px;position:relative}
+  .modal h2{margin:0 0 16px;font-size:18px;color:var(--accent)}
+  .modal-close{position:absolute;top:14px;right:16px;background:none;border:none;font-size:20px;cursor:pointer;color:#999}
+  .field{margin-bottom:14px}
+  .field label{display:block;font-size:12px;font-weight:600;color:#666;margin-bottom:4px;text-transform:uppercase;letter-spacing:.04em}
+  .field input,.field select,.field textarea{width:100%;padding:9px 10px;border:1px solid #ddd;border-radius:5px;font:inherit;font-size:14px}
+  .field input:focus,.field select:focus,.field textarea:focus{outline:2px solid var(--accent);border-color:transparent}
+  .field textarea{resize:vertical;min-height:70px}
+  .login-hint{font-size:12px;color:#999;margin-top:16px;padding:10px;background:#f5f5f5;border-radius:5px}
+  .login-hint code{background:#e8e8e8;padding:1px 4px;border-radius:3px;font-size:11px}
+  .error-msg{color:#c0392b;font-size:13px;margin-top:8px;display:none}
+  #apply-page{display:none;padding:32px 24px;max-width:680px;margin:0 auto}
+  #apply-page h2{color:var(--accent);margin:0 0 6px}
+  #apply-page .subtitle{color:#666;font-size:14px;margin:0 0 24px}
+  .form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
+  .form-grid .full{grid-column:1/-1}
+  .apply-success{display:none;padding:24px;background:#eafaf1;border:1px solid #a9dfbf;border-radius:8px;color:#196f3d;margin-top:16px}
+  .back-link{color:var(--accent);cursor:pointer;font-size:13px;margin-bottom:20px;display:inline-block;background:none;border:none;padding:0;font:inherit}
+</style>
+</head>
+<body>
+
+<div class="topbar">
+  <h1>Designer Wallcoverings &mdash; To the Trade</h1>
+  <div id="session-info">
+    <span id="session-name" style="display:none"></span>
+    <span id="trade-badge-top" class="trade-badge" style="display:none">Trade Account</span>
+    <a id="btn-login" onclick="openLogin()">Trade Login</a>
+    <a id="btn-logout" onclick="doLogout()" style="display:none">Log Out</a>
+    <a href="/admin" style="color:#5a8ab5;font-size:11px;margin-left:6px">Admin</a>
+  </div>
+</div>
+
+<div id="trade-cta">
+  <div>
+    <h3>Access Net / Trade Pricing</h3>
+    <p>Interior designers, architects &amp; decorators unlock trade pricing on all lines.</p>
+  </div>
+  <div class="cta-actions">
+    <button class="btn btn-outline btn-sm" onclick="openLogin()">Trade Login</button>
+    <button class="btn btn-primary btn-sm" onclick="showApply()">Apply for Trade Access</button>
+  </div>
+</div>
+
+<div id="grid-page">
+  <div class="controls">
+    <h2 id="page-heading">All Products</h2>
+    <div class="nav-links">
+      <a onclick="showApply()">Apply for Trade Account</a>
+    </div>
+    <label>Sort
+      <select id="sort">
+        <option value="newest">Newest</option>
+        <option value="title">Title A&#x2192;Z</option>
+        <option value="sku">SKU A&#x2192;Z</option>
+        <option value="price-asc">Price &#x2191;</option>
+        <option value="price-desc">Price &#x2193;</option>
+        <option value="vendor">Vendor</option>
+      </select>
+    </label>
+    <label>Density <input id="density" type="range" min="2" max="6" value="4"></label>
+    <span class="count" id="count"></span>
+  </div>
+  <div class="grid" id="grid"><div class="empty">Loading&hellip;</div></div>
+</div>
+
+<div id="apply-page">
+  <button class="back-link" onclick="showGrid()">&larr; Back to catalog</button>
+  <h2>Apply for Trade Access</h2>
+  <p class="subtitle">Complete the form below. We review applications within 1&ndash;2 business days.</p>
+  <form id="apply-form">
+    <div class="form-grid">
+      <div class="field">
+        <label>Company Name *</label>
+        <input name="company_name" required placeholder="Studio Name or Firm">
+      </div>
+      <div class="field">
+        <label>Contact Name *</label>
+        <input name="contact_name" required placeholder="Your full name">
+      </div>
+      <div class="field">
+        <label>Business Email *</label>
+        <input name="email" type="email" required placeholder="you@studio.com">
+      </div>
+      <div class="field">
+        <label>Phone *</label>
+        <input name="phone" type="tel" required placeholder="(555) 000-0000">
+      </div>
+      <div class="field">
+        <label>Resale / Trade Cert # *</label>
+        <input name="trade_cert" required placeholder="CA-1234567 or equivalent">
+      </div>
+      <div class="field">
+        <label>Business Type</label>
+        <select name="business_type">
+          <option value="">Select&hellip;</option>
+          <option>Interior Designer</option>
+          <option>Architect</option>
+          <option>Decorator</option>
+          <option>Contractor / Installer</option>
+          <option>Home Stager</option>
+          <option>Other</option>
+        </select>
+      </div>
+      <div class="field full">
+        <label>Website / Portfolio</label>
+        <input name="website" type="url" placeholder="https://yourstudio.com">
+      </div>
+      <div class="field full">
+        <label>How did you hear about us?</label>
+        <input name="how_heard" placeholder="Referral, Instagram, trade show&hellip;">
+      </div>
+      <div class="field full">
+        <label>Additional Notes</label>
+        <textarea name="notes" placeholder="Anything else you'd like us to know"></textarea>
+      </div>
+    </div>
+    <div class="error-msg" id="apply-error"></div>
+    <button class="btn btn-primary" type="submit" style="margin-top:16px;width:100%;padding:12px">Submit Trade Application</button>
+  </form>
+  <div class="apply-success" id="apply-success">
+    <strong>Application received!</strong> We'll review your trade credentials and follow up at your email within 1&ndash;2 business days.
+    <br><br><button class="back-link" onclick="showGrid()">&larr; Return to catalog</button>
+  </div>
+</div>
+
+<!-- Login modal -->
+<div class="modal-overlay" id="login-modal">
+  <div class="modal">
+    <button class="modal-close" onclick="closeLogin()">&times;</button>
+    <h2>Trade Account Login</h2>
+    <form id="login-form">
+      <div class="field">
+        <label>Email</label>
+        <input id="login-email" type="email" required placeholder="trade@yourstudio.com" autocomplete="email">
+      </div>
+      <div class="field">
+        <label>Password</label>
+        <input id="login-pass" type="password" required placeholder="Password" autocomplete="current-password">
+      </div>
+      <div class="error-msg" id="login-error"></div>
+      <button class="btn btn-primary" type="submit" style="width:100%;margin-top:4px">Log In</button>
+    </form>
+    <div class="login-hint">
+      <strong>Demo credentials:</strong><br>
+      Email: <code>trade@demo.com</code> &nbsp; Password: <code>trade2024</code>
+    </div>
+    <div style="margin-top:14px;font-size:12px;color:#999;text-align:center">
+      No account? <a href="#" onclick="closeLogin();showApply();return false" style="color:var(--accent)">Apply for trade access</a>
+    </div>
+  </div>
+</div>
+
 <script>
-// House rule: sort + density both persist in localStorage so the choice survives reloads.
-const $=s=>document.querySelector(s), esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
-const sortSel=$('#sort'), dens=$('#density');
-sortSel.value=localStorage.getItem('sort')||'newest'; dens.value=localStorage.getItem('cols')||'4';
-function applyCols(){document.documentElement.style.setProperty('--cols',dens.value);localStorage.setItem('cols',dens.value);}
-applyCols();
-async function load(){
-  const r=await fetch('/api/products?sort='+encodeURIComponent(sortSel.value)); const d=await r.json();
-  $('#count').textContent=d.count+' items';
-  $('#grid').innerHTML = d.products.length ? d.products.map(p=>`
-    <div class="card">
-      <img src="${esc(p.image||'')}" alt="${esc(p.title||'')}" loading="lazy">
-      <div class="meta"><p class="t">${esc(p.title||'Untitled')}</p>
-        <div class="s">${esc(p.sku||p.handle||'')}</div>
-        ${p.price?`<div class="p">$${Number(p.price).toFixed(2)}</div>`:''}
-      </div>
-    </div>`).join('') : '<div class="empty">No products yet — fill data/products.json.</div>';
+const esc = s => (s||'').replace(/[&<>"']/g, c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
+const $id = id => document.getElementById(id);
+
+// Sort + density: persist in localStorage (house rule)
+const sortSel = $id('sort'), densInp = $id('density');
+sortSel.value = localStorage.getItem('tp-sort') || 'newest';
+densInp.value = localStorage.getItem('tp-cols') || '4';
+document.documentElement.style.setProperty('--cols', densInp.value);
+sortSel.onchange = () => { localStorage.setItem('tp-sort', sortSel.value); loadProducts(); };
+densInp.oninput  = () => {
+  document.documentElement.style.setProperty('--cols', densInp.value);
+  localStorage.setItem('tp-cols', densInp.value);
+};
+
+// Session state
+let tradeSession = false;
+async function refreshSession() {
+  const r = await fetch('/api/session');
+  const d = await r.json();
+  tradeSession = d.trade;
+  if (d.trade) {
+    $id('session-name').textContent = `${d.name} (${d.company})`;
+    $id('session-name').style.display = 'inline';
+    $id('trade-badge-top').style.display = 'inline';
+    $id('btn-login').style.display = 'none';
+    $id('btn-logout').style.display = 'inline';
+    $id('trade-cta').style.display = 'none';
+  } else {
+    $id('session-name').style.display = 'none';
+    $id('trade-badge-top').style.display = 'none';
+    $id('btn-login').style.display = 'inline';
+    $id('btn-logout').style.display = 'none';
+    $id('trade-cta').style.display = 'flex';
+  }
+  return d;
+}
+
+// Products
+async function loadProducts() {
+  const r = await fetch('/api/products?sort=' + encodeURIComponent(sortSel.value));
+  const d = await r.json();
+  $id('count').textContent = d.count + ' products';
+  $id('page-heading').textContent = d.trade ? 'All Products — Trade Pricing Unlocked' : 'All Products';
+  $id('grid').innerHTML = d.products.length
+    ? d.products.map(p => cardHTML(p, d.trade)).join('')
+    : '<div class="empty">No products found.</div>';
+}
+
+function cardHTML(p, isTrade) {
+  const retailFmt = p.retail != null ? `$${p.retail.toFixed(2)}` : '—';
+  const isKravet  = p.pricing_rule === 'kravet-map';
+  let priceHTML = '';
+  if (isTrade && p.trade != null) {
+    priceHTML = `<div class="price-row">
+      <div class="price-retail"><span class="plabel">Retail </span>${esc(retailFmt)}</div>
+      <div class="price-trade-wrap">
+        <div class="price-trade-label">Your Trade Price</div>
+        <div class="price-trade">$${p.trade.toFixed(2)}</div>
+        ${isKravet ? '<div class="kravet-badge">MAP Pricing (Kravet family)</div>' : ''}
+      </div>
+    </div>`;
+  } else {
+    priceHTML = `<div class="price-row">
+      <div class="price-retail"><span class="plabel">Retail </span>${esc(retailFmt)}</div>
+      <div class="trade-locked">🔒 Trade price — login to reveal</div>
+    </div>`;
+  }
+  return `<div class="card">
+    <img src="${esc(p.image||'')}" alt="${esc(p.title||'')}" loading="lazy">
+    <div class="card-body">
+      <div class="card-title">${esc(p.title||'Untitled')}</div>
+      <div class="card-vendor">${esc(p.vendor||'')}</div>
+      <div class="card-type">${esc(p.product_type||'')}</div>
+      ${priceHTML}
+      <div class="card-sku">${esc(p.sku||'')}</div>
+    </div>
+  </div>`;
+}
+
+// Login
+function openLogin() { $id('login-modal').classList.add('open'); $id('login-error').style.display='none'; }
+function closeLogin() { $id('login-modal').classList.remove('open'); }
+$id('login-modal').onclick = e => { if (e.target === $id('login-modal')) closeLogin(); };
+
+$id('login-form').onsubmit = async e => {
+  e.preventDefault();
+  const err = $id('login-error');
+  err.style.display = 'none';
+  try {
+    const r = await fetch('/api/login', {
+      method: 'POST',
+      headers: {'Content-Type':'application/json'},
+      body: JSON.stringify({ email: $id('login-email').value, password: $id('login-pass').value })
+    });
+    const d = await r.json();
+    if (d.ok) { closeLogin(); await refreshSession(); loadProducts(); }
+    else { err.textContent = d.error || 'Login failed'; err.style.display = 'block'; }
+  } catch { err.textContent = 'Network error'; err.style.display = 'block'; }
+};
+
+async function doLogout() {
+  await fetch('/api/logout', { method: 'POST' });
+  await refreshSession();
+  loadProducts();
+}
+
+// Apply form
+function showApply() {
+  $id('grid-page').style.display = 'none';
+  $id('apply-page').style.display = 'block';
+  $id('apply-success').style.display = 'none';
+  $id('apply-form').style.display = 'block';
+}
+function showGrid() {
+  $id('apply-page').style.display = 'none';
+  $id('grid-page').style.display = 'block';
 }
-sortSel.onchange=()=>{localStorage.setItem('sort',sortSel.value);load();};
-dens.oninput=applyCols;
-load();
-</script></body></html>
+
+$id('apply-form').onsubmit = async e => {
+  e.preventDefault();
+  const err = $id('apply-error');
+  err.style.display = 'none';
+  const body = Object.fromEntries(new FormData(e.target).entries());
+  try {
+    const r = await fetch('/api/apply', {
+      method: 'POST',
+      headers: {'Content-Type':'application/json'},
+      body: JSON.stringify(body)
+    });
+    const d = await r.json();
+    if (d.ok) { $id('apply-form').style.display = 'none'; $id('apply-success').style.display = 'block'; }
+    else { err.textContent = d.error || 'Submission error'; err.style.display = 'block'; }
+  } catch { err.textContent = 'Network error'; err.style.display = 'block'; }
+};
+
+// Init
+(async () => { await refreshSession(); await loadProducts(); })();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index 57f5df5..e3eb494 100644
--- a/server.js
+++ b/server.js
@@ -1,41 +1,257 @@
-// Storefront starter — zero-dep Node http. Serves data/products.json with SERVER-SIDE sort
-// (house rule: every product grid gets sort + a density slider). Optional basic auth via BASIC_AUTH.
-const http = require('http'), fs = require('fs'), path = require('path');
-const PORT = parseInt(process.env.PORT || '3900', 10);
-const BASIC_AUTH = process.env.BASIC_AUTH || ''; // "user:pass" to gate; empty = open
-const DIR = __dirname;
-
-function load() { try { return JSON.parse(fs.readFileSync(path.join(DIR, 'data', 'products.json'), 'utf8')); } catch { return []; } }
-function authed(req) {
-  if (!BASIC_AUTH) return true;
-  const m = (req.headers.authorization || '').match(/^Basic\s+(.+)$/i);
-  if (!m) return false; try { return Buffer.from(m[1], 'base64').toString() === BASIC_AUTH; } catch { return false; }
+// trade-portal — To the Trade B2B storefront (Phase 1)
+// 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).
+// Cost/wholesale NEVER exposed on any public API response.
+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','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'
+]);
+function isKravet(vendor_family, vendor) {
+  if (vendor_family === 'kravet') return true;
+  return KRAVET_VENDORS.has((vendor || '').toLowerCase().trim());
+}
+
+// ── Pricing ─────────────────────────────────────────────────────────────────
+// Returns { retail, trade, pricing_rule } — never exposes cost.
+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' };
+  } 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' };
+  }
+}
+
+// ── 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 ─────────────────────────────────────────────────────────────
+function loadProducts() {
+  try { return JSON.parse(fs.readFileSync(DATA('products.json'), 'utf8')); } catch { return []; }
 }
-// Sort modes mirror the DW sort-skill: newest, title/sku A→Z, price ↑↓, light→dark (by dominant hex).
-function luminance(hex) { if (!/^#?[0-9a-f]{6}$/i.test(hex || '')) return 999; const h = hex.replace('#', '');
-  const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16);
-  return 0.2126 * r + 0.7152 * g + 0.0722 * b; }
+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 || x.handle || '').localeCompare(y.sku || y.handle || ''));
-    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 'light': return a.sort((x, y) => luminance(y.hex) - luminance(x.hex));
-    case 'dark': return a.sort((x, y) => luminance(x.hex) - luminance(y.hex));
-    default: return a; // 'newest' = natural order
-  }
-}
-http.createServer((req, res) => {
-  if (!authed(req)) { res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="preview"' }); return res.end('auth required'); }
-  const u = new URL(req.url, 'http://x');
-  if (u.pathname === '/api/products') {
-    const out = sortProducts(load(), u.searchParams.get('sort') || 'newest');
-    res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ count: out.length, products: out }));
-  }
-  const f = u.pathname === '/' ? 'index.html' : u.pathname.replace(/^\//, '');
-  const fp = path.join(DIR, 'public', path.basename(f));
-  if (fs.existsSync(fp)) { res.writeHead(200, { 'Content-Type': f.endsWith('.html') ? 'text/html' : 'text/plain' }); return res.end(fs.readFileSync(fp)); }
-  res.writeHead(404); res.end('not found');
-}).listen(PORT, function () { console.log('[' + path.basename(DIR) + '] http://localhost:' + this.address().port); });
+    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 'vendor':     return a.sort((x,y) => (x.vendor||'').localeCompare(y.vendor||''));
+    default:           return a; // newest = natural order
+  }
+}
+
+// ── 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 raw   = loadProducts();
+    const sorted = sortProducts(raw, u.searchParams.get('sort') || 'newest');
+    const out = sorted.map(p => {
+      const pricing = computePricing(p);
+      // NEVER expose cost or vendor_family internals
+      return {
+        sku:          p.sku,
+        title:        p.title,
+        vendor:       p.vendor,
+        product_type: p.product_type,
+        image:        p.image,
+        retail:       pricing.retail,
+        trade:        isTrade ? pricing.trade : null,   // only if trade session
+        pricing_rule: isTrade ? pricing.pricing_rule : null
+      };
+    });
+    res.writeHead(200, { 'Content-Type': 'application/json' });
+    return res.end(JSON.stringify({ count: out.length, trade: isTrade, 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');
+    }
+    // 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' });
+      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); // newest default
+    });
+    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(/^\//, '');
+  // prevent directory traversal
+  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, () => {
+  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`);
+});

← ee4bba5 initial scaffold (trade-portal) via web-dev accelerator  ·  back to Trade Portal  ·  Add real Shopify catalog data source (shopify-catalog.json, 2eb6579 →