← 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
A data/applications.jsonM data/products.jsonA public/admin.htmlM public/index.htmlM server.js
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 — Applications</h1>
+ <a href="/">← 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…</div></div>
+
+<script>
+const esc = s => (s||'').replace(/[&<>"']/g, c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 — 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 & 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→Z</option>
+ <option value="sku">SKU A→Z</option>
+ <option value="price-asc">Price ↑</option>
+ <option value="price-desc">Price ↓</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…</div></div>
+</div>
+
+<div id="apply-page">
+ <button class="back-link" onclick="showGrid()">← Back to catalog</button>
+ <h2>Apply for Trade Access</h2>
+ <p class="subtitle">Complete the form below. We review applications within 1–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…</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…">
+ </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–2 business days.
+ <br><br><button class="back-link" onclick="showGrid()">← Return to catalog</button>
+ </div>
+</div>
+
+<!-- Login modal -->
+<div class="modal-overlay" id="login-modal">
+ <div class="modal">
+ <button class="modal-close" onclick="closeLogin()">×</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> 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=>({'&':'&','<':'<','>':'>','"':'"'}[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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 →