← back to Sample Box
sample-box v1: vibe-picker funnel + swatch grid + TEST checkout + admin dashboard
ac3f15d64014d9b4e269aa8c4cd1dabb4bb01d34 · 2026-07-15 17:02:13 -0700 · Steve
- 6-vibe picker (Modern/Traditional/Floral/Grasscloth/Bold Color/Geometric)
- /api/products filtered by vibe with server-side sort + density slider (localStorage-persisted)
- /api/orders: POST places test order to data/orders.json with credit note
- Stripe STUBBED (TEST_STUB, payment_stub:true) — wire with STRIPE_SECRET_KEY + checkout.sessions.create()
- Admin /admin: order cards with HARD RULE created date+time (toLocaleString), sort+density, stats bar
- BASIC_AUTH gates /admin + /api/admin/* only; public funnel stays open
- 20 real DW products from dw_unified (ACTIVE, tagged by style vibe) + 2 seeds
- $15 box price, credited to first roll order (ref: SB-<id>)
Files touched
M BRIEF.mdA data/orders.jsonM data/products.jsonA public/admin.htmlM public/index.htmlM server.js
Diff
commit ac3f15d64014d9b4e269aa8c4cd1dabb4bb01d34
Author: Steve <steve@designerwallcoverings.com>
Date: Wed Jul 15 17:02:13 2026 -0700
sample-box v1: vibe-picker funnel + swatch grid + TEST checkout + admin dashboard
- 6-vibe picker (Modern/Traditional/Floral/Grasscloth/Bold Color/Geometric)
- /api/products filtered by vibe with server-side sort + density slider (localStorage-persisted)
- /api/orders: POST places test order to data/orders.json with credit note
- Stripe STUBBED (TEST_STUB, payment_stub:true) — wire with STRIPE_SECRET_KEY + checkout.sessions.create()
- Admin /admin: order cards with HARD RULE created date+time (toLocaleString), sort+density, stats bar
- BASIC_AUTH gates /admin + /api/admin/* only; public funnel stays open
- 20 real DW products from dw_unified (ACTIVE, tagged by style vibe) + 2 seeds
- $15 box price, credited to first roll order (ref: SB-<id>)
---
BRIEF.md | 15 +-
data/orders.json | 38 +++++
data/products.json | 204 +++++++++++++++++++++++++-
public/admin.html | 208 ++++++++++++++++++++++++++
public/index.html | 422 ++++++++++++++++++++++++++++++++++++++++++++++-------
server.js | 222 +++++++++++++++++++++++-----
6 files changed, 1015 insertions(+), 94 deletions(-)
diff --git a/BRIEF.md b/BRIEF.md
index 2107538..afacac7 100644
--- a/BRIEF.md
+++ b/BRIEF.md
@@ -1,5 +1,12 @@
# sample-box — brief
-- Client:
-- Outcome / "high value":
-- Constraints (brand, domain, deadline):
-- Reference / competitor URLs:
+
+- **Client:** Designer Wallcoverings (internal product)
+- **Outcome / "high value":** Paid curated swatch-box funnel that monetizes sample friction — the #1 wallcovering conversion killer. A $15 box (credited to first roll order) captures warm leads + shipping addresses + style preference data while sending real material. Converts "I'm not sure" browsers into warm, addressed leads.
+- **Shape:** (a) vibe/style picker — 6 vibes (Modern, Traditional, Floral, Grasscloth, Bold Color, Geometric) → (b) curated swatch grid (sort + density) filtered to chosen vibe, select up to 8 swatches → (c) checkout capturing shipping + email → (d) confirmation with credit note.
+- **Payment:** Stripe TEST MODE stub (no live keys used). Structurally mirrors Stripe — order total, fake card step. One-liner to wire real Stripe: set STRIPE_SECRET_KEY, call stripe.checkout.sessions.create() in POST /api/checkout.
+- **Credit logic:** Box price ($15) credited to first roll order. Credit note stored on every order: "$15.00 credited toward your first roll order (ref: SB-XXXXX)"
+- **Order store:** data/orders.json — vibe, swatches, address, email, price, credit note, payment_method, created_at.
+- **Admin:** /admin (gate with BASIC_AUTH=user:pass) — fulfillment card grid with sort (Newest/Oldest) + density slider. HARD RULE: every card shows created date+time (toLocaleString).
+- **Data:** 20 real DW products pulled from dw_unified (ACTIVE, image_url, style-tagged) + 2 representative seeds for gaps. SQL pull: `SELECT sku,title,vendor,image_url,tags,COALESCE(price,retail_price) FROM shopify_products WHERE status='ACTIVE' AND image_url!='' AND tags ILIKE ANY('{%modern%,%floral%,%grasscloth%,%geometric%,...}') ORDER BY RANDOM() LIMIT 25`
+- **Constraints:** Zero-dep Node http. $0 local. No deploy/DNS/publish. PORT=3903. Build one switch from live and stop.
+- **Start command:** `PORT=3903 node server.js` | `BASIC_AUTH=admin:DW2024! PORT=3903 node server.js`
diff --git a/data/orders.json b/data/orders.json
new file mode 100644
index 0000000..4de0b06
--- /dev/null
+++ b/data/orders.json
@@ -0,0 +1,38 @@
+[
+ {
+ "id": "SB-MRMQU6LL",
+ "vibe": "modern",
+ "swatches": [
+ {
+ "sku": "DWK-30209",
+ "title": "Esplanade Concrete — Light Gray",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/ESPLANADE_CONCRETE_SG01-05__24X24_KOROSEAL.jpg",
+ "price": 45
+ },
+ {
+ "sku": "EUR-80333-Sample",
+ "title": "Belle Ile Stripe — Celadon Green",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/nina_crop_7513504514099.jpg",
+ "price": 107
+ }
+ ],
+ "swatch_count": 2,
+ "box_price": 15,
+ "credit_note": "$15.00 credited toward your first roll order (ref: SB-MRMQU6LL)",
+ "email": "test@designerwallcoverings.com",
+ "shipping": {
+ "name": "Test Customer",
+ "address1": "123 Main St",
+ "address2": "",
+ "city": "Los Angeles",
+ "state": "CA",
+ "zip": "90001",
+ "country": "US"
+ },
+ "payment_method": "TEST_STUB",
+ "payment_status": "paid_test",
+ "stripe_session_id": null,
+ "status": "pending_fulfillment",
+ "created_at": "2026-07-16T00:01:07.305Z"
+ }
+]
\ No newline at end of file
diff --git a/data/products.json b/data/products.json
index 8eee5f2..1de70c4 100644
--- a/data/products.json
+++ b/data/products.json
@@ -1,6 +1,202 @@
[
- { "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": "DWJS-13415",
+ "title": "Hove Cream Wallcovering",
+ "vendor": "Jeffrey Stevens",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/SO2472.jpg?v=1747077223",
+ "price": 60.00,
+ "product_type": "Wallcovering",
+ "vibes": ["traditional", "floral"],
+ "tags": ["Floral", "Traditional", "Watercolor", "Cream", "Beige"]
+ },
+ {
+ "sku": "MTG-70383",
+ "title": "Jardin Sauvage — Green, Grey, Brown",
+ "vendor": "Mind the Gap",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/Screen_Shot_2024-09-30_at_12.46.26_PM.png?v=1748367939",
+ "price": 259.00,
+ "product_type": "Wallcovering",
+ "vibes": ["floral", "bold-color"],
+ "tags": ["Botanical", "Scenic", "Chinoiserie", "Green", "Traditional"]
+ },
+ {
+ "sku": "DWK-30209",
+ "title": "Esplanade Concrete — Light Gray",
+ "vendor": "Koroseal",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/ESPLANADE_CONCRETE_SG01-05__24X24_KOROSEAL.jpg?v=1751952415",
+ "price": 45.00,
+ "product_type": "Wallcovering",
+ "vibes": ["modern", "geometric"],
+ "tags": ["Texture", "Concrete", "Commercial", "Gray", "Minimalist"]
+ },
+ {
+ "sku": "DWGB-700383",
+ "title": "Linen Beige Wallcovering",
+ "vendor": "Graham & Brown",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/105854_TILE_LINEN_2520BEIGE_01__83876.1769008768.jpg?v=1771175548",
+ "price": 170.00,
+ "product_type": "Wallcovering",
+ "vibes": ["traditional", "grasscloth"],
+ "tags": ["Linen", "Texture", "Beige", "Traditional", "Woven"]
+ },
+ {
+ "sku": "EUR-90317-Sample",
+ "title": "Kashgar Damask — Platinum",
+ "vendor": "Designers Guild",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/31411.webp?v=1738200522",
+ "price": 95.00,
+ "product_type": "Wallcovering",
+ "vibes": ["traditional", "floral"],
+ "tags": ["Damask", "Floral", "Traditional", "Gray", "Platinum", "Luxury"]
+ },
+ {
+ "sku": "EUR-80333-Sample",
+ "title": "Belle Ile Stripe — Celadon Green",
+ "vendor": "Nina Campbell",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/nina_crop_7513504514099.jpg?v=1775523439",
+ "price": 107.00,
+ "product_type": "Wallcovering",
+ "vibes": ["modern", "geometric"],
+ "tags": ["Stripe", "Geometric", "Green", "Contemporary", "Organic Modern"]
+ },
+ {
+ "sku": "DWKK-102255",
+ "title": "Chinese Leopard Toile — Porcelain Blue",
+ "vendor": "Brunschwig & Fils",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/P8019106_5_11fb0e57-ed86-4379-9a6f-375bb256491d.jpg?v=1725922554",
+ "price": 120.00,
+ "product_type": "Wallcovering",
+ "vibes": ["traditional", "bold-color"],
+ "tags": ["Toile", "Chinoiserie", "Blue", "Traditional", "Animal"]
+ },
+ {
+ "sku": "EUR-70009-Sample",
+ "title": "Matsu Grasscloth — Cobalt",
+ "vendor": "Designers Guild",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/tile-crop_4ce3b815-c84a-4050-b6fc-f6d6aa977697.jpg?v=1775493972",
+ "price": 185.00,
+ "product_type": "Wallcovering",
+ "vibes": ["grasscloth", "bold-color"],
+ "tags": ["Grasscloth", "Cobalt Blue", "Botanical", "Organic", "Luxury"]
+ },
+ {
+ "sku": "DWKK-102434",
+ "title": "Marcel Toile — Red & White",
+ "vendor": "Brunschwig & Fils",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/P8023106_19_92322aa6-588d-4f6a-b408-fd204bc5481d.jpg?v=1725922985",
+ "price": 145.00,
+ "product_type": "Wallcovering",
+ "vibes": ["traditional", "bold-color", "floral"],
+ "tags": ["Toile", "Chinoiserie", "Red", "Traditional", "Scenic"]
+ },
+ {
+ "sku": "DWK-31040",
+ "title": "Grande Canyon Trail — Brown",
+ "vendor": "Koroseal",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/5021-58_a1427c13-f198-4595-9227-25538457c543.jpg?v=1751950584",
+ "price": 55.00,
+ "product_type": "Wallcovering",
+ "vibes": ["modern", "grasscloth"],
+ "tags": ["Texture", "Brown", "Abstract", "Industrial", "Organic Modern"]
+ },
+ {
+ "sku": "DWJS-13625",
+ "title": "Vivianne Vertical Woven — Wheat",
+ "vendor": "Jeffrey Stevens",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/CL1879.jpg?v=1740534393",
+ "price": 70.00,
+ "product_type": "Wallcovering",
+ "vibes": ["grasscloth", "modern"],
+ "tags": ["Woven", "Texture", "Beige", "Minimalist", "Contemporary"]
+ },
+ {
+ "sku": "LXPR-8421",
+ "title": "Luxuria Golden Brown — Phillipe Romano",
+ "vendor": "Phillipe Romano",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/products/8421_quadrat_e39798c8-96e9-4df2-865a-9a9954ca47fb.jpg?v=1576169731",
+ "price": 85.00,
+ "product_type": "Wallcovering",
+ "vibes": ["traditional", "bold-color"],
+ "tags": ["Damask", "Gold", "Traditional", "Victorian", "Textile"]
+ },
+ {
+ "sku": "DWQW-56968",
+ "title": "Indie Linen Embossed Vinyl — Hale Blue",
+ "vendor": "Malibu Wallpaper",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/RY31732.jpg?v=1748380576",
+ "price": 65.00,
+ "product_type": "Wallcovering",
+ "vibes": ["modern", "grasscloth"],
+ "tags": ["Linen", "Embossed", "Blue", "Texture", "Bohemian"]
+ },
+ {
+ "sku": "DWRW-72204",
+ "title": "Bellewood Black Toile Mural",
+ "vendor": "Rebel Walls",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/products/R13053_image2.jpg?v=1756329358",
+ "price": 76.40,
+ "product_type": "Wallcovering",
+ "vibes": ["bold-color", "floral"],
+ "tags": ["Botanical", "Toile", "Black", "Scandinavian", "Scenic"]
+ },
+ {
+ "sku": "SUR-21832",
+ "title": "Piaza Faux Metal — Silver",
+ "vendor": "Surface Stick",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/products/b7d080e21ab4bbd76bd19a657aac6b3f.jpg?v=1572309652",
+ "price": 42.00,
+ "product_type": "Wallcovering",
+ "vibes": ["modern", "geometric"],
+ "tags": ["Faux", "Metal", "Contemporary", "Solid", "Texture"]
+ },
+ {
+ "sku": "DWDC-330208",
+ "title": "Coordonné Durable Vinyl — Charcoal Dot",
+ "vendor": "Coordonné",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/6474998.jpg?v=1739303422",
+ "price": 88.00,
+ "product_type": "Wallcovering",
+ "vibes": ["modern", "geometric"],
+ "tags": ["Geometric", "Dot", "Gray", "Contemporary", "Minimalist"]
+ },
+ {
+ "sku": "DWKK-100639",
+ "title": "Bromo Velvet — Seafoam Turquoise",
+ "vendor": "Brunschwig & Fils",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/8018115_13_0a9938d5-33b6-420f-a42f-5fd85d9f5a69.jpg?v=1725919104",
+ "price": 185.00,
+ "product_type": "Fabric",
+ "vibes": ["bold-color", "modern"],
+ "tags": ["Velvet", "Stripe", "Teal", "Turquoise", "Upholstery"]
+ },
+ {
+ "sku": "DWQW-60952",
+ "title": "Madrone Grove — Dune Vista Chevron",
+ "vendor": "Malibu Wallpaper",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/0066863_throw-knit-caramel-latte_650.jpg?v=1776905775",
+ "price": 75.00,
+ "product_type": "Wallcovering",
+ "vibes": ["geometric", "grasscloth"],
+ "tags": ["Chevron", "Beige", "Coastal", "Traditional", "Woven"]
+ },
+ {
+ "sku": "DWKK-119235",
+ "title": "Impression — Blush Pink Velvet",
+ "vendor": "Clarke & Clarke",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/F1526_01_CAC_9a3777b4-96a9-44c1-88ab-b31f8a6b9f3a.jpg?v=1725873754",
+ "price": 98.00,
+ "product_type": "Fabric",
+ "vibes": ["bold-color", "traditional"],
+ "tags": ["Velvet", "Blush", "Pink", "Modern", "Upholstery"]
+ },
+ {
+ "sku": "DWRW-360277",
+ "title": "Backyard — Steel Gray Industrial Mural",
+ "vendor": "Rebel Walls",
+ "image": "https://cdn.shopify.com/s/files/1/0015/4117/7456/files/R15241_interior1.jpg?v=1780557625",
+ "price": 82.00,
+ "product_type": "Wallcovering",
+ "vibes": ["modern", "geometric"],
+ "tags": ["Industrial", "Metal", "Gray", "Urban", "Mural"]
+ }
]
diff --git a/public/admin.html b/public/admin.html
new file mode 100644
index 0000000..410be3f
--- /dev/null
+++ b/public/admin.html
@@ -0,0 +1,208 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Sample Box Admin — Designer Wallcoverings</title>
+<style>
+*{box-sizing:border-box;margin:0;padding:0}
+:root{--accent:#1a1a2e;--gold:#c9a84c;--bg:#f5f3ef;--card:#fff;--border:#e0dbd0;--muted:#888;--radius:10px;--cols:3}
+body{font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;color:#1a1a1a;background:var(--bg);min-height:100vh}
+
+.admin-header{background:var(--accent);color:#fff;padding:16px 28px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100}
+.admin-header h1{font-size:18px;font-weight:700}
+.admin-header .sub{font-size:12px;opacity:.65;margin-top:2px}
+.badge{background:var(--gold);color:#fff;font-size:10px;font-weight:700;padding:2px 7px;border-radius:3px}
+.badge-red{background:#e74c3c}
+
+/* stats bar */
+.stats{display:flex;gap:20px;padding:18px 28px;background:#fff;border-bottom:2px solid var(--border);flex-wrap:wrap}
+.stat{text-align:center}
+.stat .n{font-size:28px;font-weight:700;color:var(--accent)}
+.stat .lbl{font-size:11px;color:var(--muted);margin-top:2px}
+
+/* toolbar */
+.toolbar{display:flex;gap:12px;align-items:center;flex-wrap:wrap;padding:16px 28px;background:#fff;border-bottom:1px solid var(--border)}
+.toolbar h2{font-size:15px;font-weight:700;flex:1}
+label.ctrl{font-size:12px;color:var(--muted);display:flex;gap:6px;align-items:center}
+select,input[type=range]{font:inherit}
+.item-count{font-size:12px;color:var(--muted)}
+
+/* order cards grid */
+.orders-grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:16px;padding:24px 28px}
+.order-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);padding:18px;display:flex;flex-direction:column;gap:10px;position:relative}
+.order-card:hover{box-shadow:0 4px 14px rgba(0,0,0,.08)}
+
+/* HARD RULE: every admin card shows created date+time */
+.card-when{
+ font-size:11px;color:var(--muted);
+ display:flex;align-items:center;gap:5px;
+}
+.card-when .clock{opacity:.7}
+
+.card-header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}
+.order-id{font-family:monospace;font-size:12px;font-weight:700;color:var(--accent)}
+.status-badge{font-size:10px;font-weight:700;letter-spacing:.05em;padding:2px 8px;border-radius:3px;background:#fffbf0;color:var(--gold);border:1px solid var(--gold)}
+.status-badge.paid{background:#f0faf4;color:#27ae60;border-color:#27ae60}
+
+.card-vibe{font-size:12px;font-weight:600;background:var(--bg);padding:4px 10px;border-radius:20px;display:inline-block;color:var(--accent)}
+
+.card-swatches{display:flex;gap:6px;flex-wrap:wrap}
+.swatch-thumb{width:36px;height:36px;border-radius:5px;object-fit:cover;border:1px solid var(--border);background:#f0ede6}
+.swatch-overflow{width:36px;height:36px;border-radius:5px;background:var(--bg);display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;color:var(--muted);border:1px solid var(--border)}
+
+.card-addr{font-size:12px;color:#444;line-height:1.4}
+.card-addr .name{font-weight:600;color:var(--accent)}
+.card-email{font-size:11px;color:var(--muted)}
+
+.card-price{font-size:14px;font-weight:700;color:var(--accent)}
+.credit-line{font-size:11px;color:#27ae60;margin-top:2px}
+
+.empty{padding:60px;text-align:center;color:var(--muted);font-size:14px;grid-column:1/-1}
+.loading{padding:40px;text-align:center;color:var(--muted);grid-column:1/-1}
+
+@media(max-width:900px){:root{--cols:2}}
+@media(max-width:600px){:root{--cols:1}.orders-grid{padding:16px}}
+</style>
+</head>
+<body>
+
+<header class="admin-header">
+ <div>
+ <h1>Sample Box Admin</h1>
+ <div class="sub">Fulfillment dashboard — order cards with sort + density</div>
+ </div>
+ <div style="display:flex;gap:8px;align-items:center">
+ <span class="badge badge-red">TEST MODE</span>
+ <a href="/" style="color:#fff;font-size:12px;opacity:.7;text-decoration:none">← Storefront</a>
+ </div>
+</header>
+
+<!-- Stats bar -->
+<div class="stats" id="stats-bar">
+ <div class="stat"><div class="n" id="stat-orders">—</div><div class="lbl">Total Orders</div></div>
+ <div class="stat"><div class="n" id="stat-revenue">—</div><div class="lbl">Test Revenue</div></div>
+ <div class="stat"><div class="n" id="stat-swatches">—</div><div class="lbl">Swatches Sent</div></div>
+</div>
+
+<!-- Toolbar — sort + density (house rule) -->
+<div class="toolbar">
+ <h2>Orders</h2>
+ <label class="ctrl">Sort
+ <select id="sort">
+ <option value="newest">Newest</option>
+ <option value="oldest">Oldest</option>
+ </select>
+ </label>
+ <label class="ctrl">Density <input id="density" type="range" min="1" max="5" value="3"></label>
+ <span class="item-count" id="item-count"></span>
+</div>
+
+<div class="orders-grid" id="orders-grid">
+ <div class="loading">Loading orders…</div>
+</div>
+
+<script>
+const $=s=>document.querySelector(s);
+const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
+
+// sort + density — persist per house rule
+const sortSel=$('#sort'),dens=$('#density');
+sortSel.value=localStorage.getItem('admin-sort')||'newest';
+dens.value=localStorage.getItem('admin-cols')||'3';
+function applyCols(){
+ document.documentElement.style.setProperty('--cols',dens.value);
+ localStorage.setItem('admin-cols',dens.value);
+}
+applyCols();
+dens.oninput=applyCols;
+sortSel.onchange=()=>{localStorage.setItem('admin-sort',sortSel.value);loadOrders();};
+
+// HARD RULE: format created date+time for every admin card
+// toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})
+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'});
+}
+
+async function loadStats(){
+ try{
+ const r=await fetch('/api/admin/stats'),d=await r.json();
+ $('#stat-orders').textContent=d.total_orders;
+ $('#stat-revenue').textContent='$'+Number(d.total_revenue).toFixed(2);
+ $('#stat-swatches').textContent=d.total_swatches;
+ }catch(e){console.warn('stats failed',e);}
+}
+
+async function loadOrders(){
+ const grid=$('#orders-grid');
+ grid.innerHTML='<div class="loading">Loading…</div>';
+ try{
+ const r=await fetch(`/api/admin/orders?sort=${encodeURIComponent(sortSel.value)}`);
+ const d=await r.json();
+ $('#item-count').textContent=d.count+' order'+(d.count!==1?'s':'');
+ if(!d.orders.length){grid.innerHTML='<div class="empty">No orders yet. Place a test order from the storefront.</div>';return;}
+ grid.innerHTML=d.orders.map(o=>renderCard(o)).join('');
+ }catch(e){grid.innerHTML=`<div class="empty">Error loading orders: ${esc(e.message)}</div>`;}
+}
+
+function renderCard(o){
+ // HARD RULE: created date+time on every admin card
+ const whenFmt=fmtDate(o.created_at);
+ const whenISO=o.created_at||'';
+
+ // swatch thumbnails (show up to 5, then overflow badge)
+ const MAX_THUMBS=5;
+ const swatches=o.swatches||[];
+ const thumbs=swatches.slice(0,MAX_THUMBS).map(s=>
+ `<img class="swatch-thumb" src="${esc(s.image||'')}" alt="${esc(s.title||'')}" loading="lazy"
+ onerror="this.style.background='#ece8e1';this.removeAttribute('src')" title="${esc(s.title)}">`
+ ).join('');
+ const overflow=swatches.length>MAX_THUMBS
+ ? `<div class="swatch-overflow">+${swatches.length-MAX_THUMBS}</div>` : '';
+
+ const addr=o.shipping||{};
+ const addrLine=`${esc(addr.address1||'')}${addr.address2?' '+esc(addr.address2):''}, ${esc(addr.city||'')}, ${esc(addr.state||'')} ${esc(addr.zip||'')}`;
+
+ const vibeLabel={modern:'Modern & Minimal',traditional:'Traditional',floral:'Floral & Botanical',
+ grasscloth:'Grasscloth & Natural','bold-color':'Bold Color',geometric:'Geometric'};
+
+ return `<div class="order-card">
+ <div class="card-header">
+ <span class="order-id">${esc(o.id)}</span>
+ <span class="status-badge paid">${esc(o.payment_status||'')}</span>
+ </div>
+
+ <!-- HARD RULE: created date + time, full ISO in title= -->
+ <div class="card-when">
+ <span class="clock">🕓</span>
+ <time datetime="${esc(whenISO)}" title="${esc(whenISO)}">${esc(whenFmt)}</time>
+ </div>
+
+ <span class="card-vibe">${esc(vibeLabel[o.vibe]||o.vibe||'All')}</span>
+
+ <div class="card-swatches" title="${swatches.length} swatches">
+ ${thumbs}${overflow}
+ </div>
+
+ <div class="card-addr">
+ <div class="name">${esc(addr.name||'')}</div>
+ <div>${addrLine}</div>
+ <div class="card-email">${esc(o.email||'')}</div>
+ </div>
+
+ <div>
+ <div class="card-price">$${Number(o.box_price||0).toFixed(2)} — ${esc(o.payment_method||'')}</div>
+ <div class="credit-line">✅ ${esc(o.credit_note||'')}</div>
+ </div>
+ </div>`;
+}
+
+loadStats();
+loadOrders();
+// auto-refresh every 30s while tab is visible
+document.addEventListener('visibilitychange',()=>{if(!document.hidden){loadStats();loadOrders();}});
+</script>
+</body>
+</html>
diff --git a/public/index.html b/public/index.html
index ceb7ab1..a2bba97 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,55 +1,377 @@
-<!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>Sample Box — 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}
- 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>
+*{box-sizing:border-box;margin:0;padding:0}
+:root{--cols:4;--accent:#1a1a2e;--gold:#c9a84c;--bg:#fafaf8;--card-bg:#fff;--border:#e8e4dc;--text:#1a1a1a;--muted:#888;--radius:10px}
+body{font:15px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;color:var(--text);background:var(--bg);min-height:100vh}
+
+.site-header{background:var(--accent);color:#fff;padding:18px 32px;display:flex;align-items:center;justify-content:space-between;position:sticky;top:0;z-index:100}
+.site-header h1{font-size:20px;font-weight:700;letter-spacing:.03em}
+.site-header .tagline{font-size:12px;opacity:.7;margin-top:2px}
+.badge-test{background:#e74c3c;color:#fff;font-size:10px;font-weight:700;padding:2px 7px;border-radius:3px;letter-spacing:.05em}
+
+.steps{display:flex;gap:0;border-bottom:2px solid var(--border);background:#fff}
+.step{flex:1;padding:14px 16px;font-size:13px;font-weight:600;color:var(--muted);text-align:center;border-bottom:3px solid transparent;cursor:default;transition:.2s}
+.step.active{color:var(--accent);border-bottom-color:var(--accent)}
+.step.done{color:var(--gold);border-bottom-color:var(--gold)}
+
+.panel{display:none;padding:32px}
+.panel.active{display:block}
+
+/* vibe */
+.vibe-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:16px;max-width:900px;margin:0 auto}
+.vibe-card{background:var(--card-bg);border:2px solid var(--border);border-radius:var(--radius);padding:28px 20px;text-align:center;cursor:pointer;transition:.15s}
+.vibe-card:hover{border-color:var(--accent);transform:translateY(-2px);box-shadow:0 4px 16px rgba(0,0,0,.1)}
+.vibe-card.selected{border-color:var(--gold);background:#fffbf0;box-shadow:0 0 0 3px rgba(201,168,76,.25)}
+.vibe-card .emoji{font-size:40px;display:block;margin-bottom:10px}
+.vibe-card .vibe-label{font-size:15px;font-weight:600}
+
+/* swatch grid */
+.grid-toolbar{display:flex;gap:12px;align-items:center;flex-wrap:wrap;padding:0 0 18px;border-bottom:1px solid var(--border);margin-bottom:20px}
+.grid-toolbar h2{font-size:17px;font-weight:700;flex:1}
+label.ctrl{font-size:12px;color:var(--muted);display:flex;gap:6px;align-items:center}
+select,input[type=range]{font:inherit}
+.sel-count{font-size:12px;color:var(--muted);margin-left:auto}
+
+.swatch-grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:14px}
+.swatch-card{background:var(--card-bg);border:2px solid var(--border);border-radius:var(--radius);overflow:hidden;cursor:pointer;transition:.15s;position:relative}
+.swatch-card:hover{transform:translateY(-2px);box-shadow:0 4px 14px rgba(0,0,0,.1)}
+.swatch-card.selected{border-color:var(--gold);box-shadow:0 0 0 3px rgba(201,168,76,.3)}
+.swatch-card img{width:100%;aspect-ratio:1/1;object-fit:cover;display:block;background:#f0f0f0}
+.swatch-meta{padding:9px 10px}
+.swatch-title{font-size:13px;font-weight:600;margin-bottom:2px}
+.swatch-vendor{font-size:11px;color:var(--muted)}
+.swatch-price{font-size:12px;margin-top:4px;color:var(--accent)}
+.check-badge{position:absolute;top:8px;right:8px;background:var(--gold);color:#fff;width:24px;height:24px;border-radius:50%;display:none;align-items:center;justify-content:center;font-size:14px;font-weight:700;box-shadow:0 2px 6px rgba(0,0,0,.2)}
+.swatch-card.selected .check-badge{display:flex}
+
+/* checkout */
+.checkout-wrap{max-width:560px;margin:0 auto}
+.order-summary{background:var(--card-bg);border:1px solid var(--border);border-radius:var(--radius);padding:20px;margin-bottom:24px}
+.order-summary h3{font-size:15px;font-weight:700;margin-bottom:12px}
+.order-line{display:flex;justify-content:space-between;font-size:13px;padding:4px 0;border-bottom:1px solid var(--border)}
+.order-line:last-child{border-bottom:none;font-weight:700;font-size:14px;padding-top:10px}
+.credit-note{font-size:12px;color:#27ae60;margin-top:8px;padding:8px 10px;background:#f0faf4;border-radius:6px;border-left:3px solid #27ae60}
+.form-group{margin-bottom:16px}
+.form-group label{display:block;font-size:12px;font-weight:600;margin-bottom:5px;color:var(--muted)}
+.form-group input,.form-group select{width:100%;padding:10px 12px;border:1px solid var(--border);border-radius:6px;font:inherit;font-size:14px;background:#fff;transition:.15s}
+.form-group input:focus,.form-group select:focus{outline:none;border-color:var(--accent)}
+.form-row{display:grid;grid-template-columns:1fr 1fr;gap:12px}
+
+.payment-stub{background:#fffbf0;border:2px dashed var(--gold);border-radius:var(--radius);padding:20px;margin:20px 0;text-align:center}
+.payment-stub .stub-label{font-size:11px;font-weight:700;letter-spacing:.08em;color:var(--gold);margin-bottom:8px}
+.payment-stub .stub-amount{font-size:28px;font-weight:700;color:var(--accent)}
+.payment-stub .stub-card{font-size:13px;color:var(--muted);margin:8px 0;font-family:monospace}
+.payment-stub .stub-note{font-size:11px;color:var(--muted);margin-top:6px}
+
+.btn{display:inline-flex;align-items:center;gap:8px;padding:13px 28px;border-radius:7px;font:600 15px/1 inherit;cursor:pointer;transition:.15s;border:none}
+.btn-primary{background:var(--accent);color:#fff}
+.btn-primary:hover{background:#2d2d50}
+.btn-primary:disabled{opacity:.5;cursor:not-allowed}
+.btn-gold{background:var(--gold);color:#fff}
+.btn-gold:hover{background:#b8974a}
+.btn-gold:disabled{opacity:.5;cursor:not-allowed}
+.btn-outline{background:transparent;color:var(--accent);border:2px solid var(--accent)}
+.btn-outline:hover{background:var(--accent);color:#fff}
+.btns{display:flex;gap:12px;margin-top:24px;flex-wrap:wrap}
+
+.confirm-box{max-width:540px;margin:0 auto;text-align:center;padding:40px 20px}
+.confirm-box .tick{font-size:72px;margin-bottom:16px}
+.confirm-box h2{font-size:24px;font-weight:700;margin-bottom:8px;color:var(--accent)}
+.confirm-box .order-id{font-family:monospace;font-size:14px;color:var(--muted);margin-bottom:20px}
+.confirm-detail{background:var(--card-bg);border:1px solid var(--border);border-radius:var(--radius);padding:20px;text-align:left;margin-top:20px}
+.confirm-detail p{font-size:13px;padding:5px 0;border-bottom:1px solid var(--border)}
+.confirm-detail p:last-child{border-bottom:none}
+.credit-chip{display:inline-block;background:#f0faf4;color:#27ae60;border:1px solid #27ae60;border-radius:5px;padding:6px 12px;font-size:13px;font-weight:600;margin-top:12px}
+.empty{padding:60px 20px;text-align:center;color:var(--muted)}
+
+@media(max-width:640px){
+ .swatch-grid{--cols:2!important}
+ .vibe-grid{grid-template-columns:repeat(2,1fr)}
+ .form-row{grid-template-columns:1fr}
+ .panel{padding:20px}
+}
+</style>
+</head>
+<body>
+
+<header class="site-header">
+ <div>
+ <h1>Designer Wallcoverings — Sample Box</h1>
+ <div class="tagline">Touch before you commit. $15, credited to your first roll order.</div>
+ </div>
+ <span class="badge-test">TEST MODE</span>
</header>
-<div class="grid" id="grid"><div class="empty">Loading…</div></div>
+
+<nav class="steps">
+ <div class="step active" id="step-vibe">1 · Choose Your Vibe</div>
+ <div class="step" id="step-swatches">2 · Pick Swatches</div>
+ <div class="step" id="step-checkout">3 · Checkout</div>
+ <div class="step" id="step-confirm">4 · Confirmed</div>
+</nav>
+
+<!-- PANEL 1: VIBE PICKER -->
+<div class="panel active" id="panel-vibe">
+ <h2 style="text-align:center;margin-bottom:8px;font-size:22px">What's your style vibe?</h2>
+ <p style="text-align:center;color:var(--muted);margin-bottom:28px;font-size:14px">
+ We'll curate a box of hand-picked real samples from our collection.
+ </p>
+ <div class="vibe-grid" id="vibe-grid"><div class="empty">Loading vibes…</div></div>
+ <div class="btns" style="justify-content:center;margin-top:32px">
+ <button class="btn btn-primary" id="btn-vibe-next" disabled>See My Swatches →</button>
+ </div>
+</div>
+
+<!-- PANEL 2: SWATCH GRID -->
+<div class="panel" id="panel-swatches">
+ <div class="grid-toolbar">
+ <h2 id="swatch-heading">Swatches</h2>
+ <label class="ctrl">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>
+ </select>
+ </label>
+ <label class="ctrl">Density <input id="density" type="range" min="2" max="6" value="4"></label>
+ <span class="sel-count" id="sel-count">0 selected</span>
+ </div>
+ <div class="swatch-grid" id="swatch-grid"><div class="empty">Loading…</div></div>
+ <div class="btns">
+ <button class="btn btn-outline" id="btn-back-vibe">← Back</button>
+ <button class="btn btn-primary" id="btn-swatches-next" disabled>Add to My Box →</button>
+ </div>
+</div>
+
+<!-- PANEL 3: CHECKOUT -->
+<div class="panel" id="panel-checkout">
+ <div class="checkout-wrap">
+ <div class="order-summary" id="order-summary">
+ <h3>Your Sample Box</h3>
+ </div>
+ <h3 style="margin-bottom:16px">Shipping Address</h3>
+ <form id="checkout-form" autocomplete="on">
+ <div class="form-group">
+ <label>Full Name *</label>
+ <input name="name" type="text" required placeholder="Jane Smith">
+ </div>
+ <div class="form-group">
+ <label>Email *</label>
+ <input name="email" type="email" required placeholder="jane@example.com">
+ </div>
+ <div class="form-group">
+ <label>Address Line 1 *</label>
+ <input name="address1" type="text" required placeholder="123 Main St">
+ </div>
+ <div class="form-group">
+ <label>Address Line 2</label>
+ <input name="address2" type="text" placeholder="Apt / Suite">
+ </div>
+ <div class="form-row">
+ <div class="form-group">
+ <label>City *</label>
+ <input name="city" type="text" required placeholder="Los Angeles">
+ </div>
+ <div class="form-group">
+ <label>State *</label>
+ <input name="state" type="text" required placeholder="CA" maxlength="2">
+ </div>
+ </div>
+ <div class="form-row">
+ <div class="form-group">
+ <label>ZIP *</label>
+ <input name="zip" type="text" required placeholder="90210" maxlength="10">
+ </div>
+ <div class="form-group">
+ <label>Country</label>
+ <select name="country">
+ <option value="US" selected>United States</option>
+ <option value="CA">Canada</option>
+ </select>
+ </div>
+ </div>
+ <div class="payment-stub">
+ <div class="stub-label">TEST CHECKOUT — NO REAL CHARGE</div>
+ <div class="stub-amount" id="stub-amount">$15.00</div>
+ <div class="stub-card">Card: 4242 4242 4242 4242 | 12/99 | CVC 123</div>
+ <div class="stub-note">Stripe test mode stub. To wire real Stripe: set STRIPE_SECRET_KEY in .env,<br>call stripe.checkout.sessions.create() in server.js POST /api/checkout.</div>
+ </div>
+ </form>
+ <div class="btns">
+ <button class="btn btn-outline" id="btn-back-swatches">← Back</button>
+ <button class="btn btn-gold" id="btn-place-order">Place Test Order — $15.00</button>
+ </div>
+ <p id="order-error" style="color:#e74c3c;font-size:13px;margin-top:12px;display:none"></p>
+ </div>
+</div>
+
+<!-- PANEL 4: CONFIRMATION -->
+<div class="panel" id="panel-confirm">
+ <div class="confirm-box">
+ <div class="tick">✅</div>
+ <h2>Your Sample Box is Ordered!</h2>
+ <div class="order-id" id="confirm-order-id"></div>
+ <p style="font-size:14px;color:var(--muted)">We'll ship your curated swatch box within 3-5 business days.</p>
+ <span class="credit-chip" id="confirm-credit"></span>
+ <div class="confirm-detail" id="confirm-detail"></div>
+ <div class="btns" style="justify-content:center;margin-top:24px">
+ <button class="btn btn-outline" onclick="location.reload()">Order Another Box</button>
+ </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);}
+const state={vibe:null,selected:[],order:null};
+const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
+const $=s=>document.querySelector(s);
+const MAX=8,PRICE=15.00;
+
+// sort+density — house rule: persist to localStorage
+const sortSel=$('#sort'),dens=$('#density');
+sortSel.value=localStorage.getItem('sb-sort')||'newest';
+dens.value=localStorage.getItem('sb-cols')||'4';
+function applyCols(){
+ const v=dens.value;
+ $('#swatch-grid').style.setProperty('--cols',v);
+ localStorage.setItem('sb-cols',v);
+}
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>';
-}
-sortSel.onchange=()=>{localStorage.setItem('sort',sortSel.value);load();};
dens.oninput=applyCols;
-load();
-</script></body></html>
+sortSel.onchange=()=>{localStorage.setItem('sb-sort',sortSel.value);loadSwatches();};
+
+// step management
+function setStep(n){
+ ['panel-vibe','panel-swatches','panel-checkout','panel-confirm'].forEach((id,i)=>{
+ document.getElementById(id).classList.toggle('active',i+1===n);
+ });
+ ['step-vibe','step-swatches','step-checkout','step-confirm'].forEach((id,i)=>{
+ const el=document.getElementById(id);
+ el.classList.toggle('active',i+1===n);
+ el.classList.toggle('done',i+1<n);
+ });
+ window.scrollTo(0,0);
+}
+
+// panel 1: vibes
+async function loadVibes(){
+ const r=await fetch('/api/vibes'),d=await r.json();
+ const grid=$('#vibe-grid');
+ if(!d.vibes.length){grid.innerHTML='<div class="empty">No vibes found.</div>';return;}
+ grid.innerHTML=d.vibes.map(v=>`
+ <div class="vibe-card" data-id="${esc(v.id)}" onclick="selectVibe(this,'${esc(v.id)}')">
+ <span class="emoji">${v.emoji}</span>
+ <div class="vibe-label">${esc(v.label)}</div>
+ </div>`).join('');
+}
+function selectVibe(el,id){
+ document.querySelectorAll('.vibe-card').forEach(c=>c.classList.remove('selected'));
+ el.classList.add('selected');
+ state.vibe=id;
+ $('#btn-vibe-next').disabled=false;
+}
+$('#btn-vibe-next').onclick=()=>{setStep(2);loadSwatches();};
+
+// panel 2: swatches
+window._products=[];
+async function loadSwatches(){
+ const grid=$('#swatch-grid');
+ grid.innerHTML='<div class="empty">Loading…</div>';
+ const sort=sortSel.value,vibe=state.vibe||'all';
+ const r=await fetch(`/api/products?sort=${encodeURIComponent(sort)}&vibe=${encodeURIComponent(vibe)}`);
+ const d=await r.json();
+ const labels={modern:'Modern & Minimal',traditional:'Traditional',floral:'Floral & Botanical',
+ grasscloth:'Grasscloth & Natural','bold-color':'Bold Color',geometric:'Geometric'};
+ $('#swatch-heading').textContent=`${labels[vibe]||'All'} — ${d.count} swatch${d.count!==1?'es':''}`;
+ window._products=d.products;
+ if(!d.products.length){grid.innerHTML='<div class="empty">No swatches found for this vibe.</div>';return;}
+ grid.innerHTML=d.products.map(p=>{
+ const sel=state.selected.some(s=>s.sku===p.sku);
+ return `<div class="swatch-card${sel?' selected':''}" data-sku="${esc(p.sku)}" onclick="toggleSwatch(this)">
+ <img src="${esc(p.image||'')}" alt="${esc(p.title||'')}" loading="lazy"
+ onerror="this.style.background='#f5f0ea'">
+ <div class="swatch-meta">
+ <div class="swatch-title">${esc(p.title||'Untitled')}</div>
+ <div class="swatch-vendor">${esc(p.vendor||'')}</div>
+ ${p.price?`<div class="swatch-price">$${Number(p.price).toFixed(0)}/roll</div>`:''}
+ </div>
+ <div class="check-badge">✓</div>
+ </div>`;
+ }).join('');
+ updateSelCount();
+}
+function toggleSwatch(el){
+ const sku=el.dataset.sku,p=window._products.find(x=>x.sku===sku);
+ if(!p)return;
+ if(el.classList.contains('selected')){
+ el.classList.remove('selected');
+ state.selected=state.selected.filter(s=>s.sku!==sku);
+ }else{
+ if(state.selected.length>=MAX){alert(`Max ${MAX} swatches per box. Deselect one to add another.`);return;}
+ el.classList.add('selected');
+ state.selected.push(p);
+ }
+ updateSelCount();
+}
+function updateSelCount(){
+ const n=state.selected.length;
+ $('#sel-count').textContent=`${n} selected (max ${MAX})`;
+ $('#btn-swatches-next').disabled=n===0;
+}
+$('#btn-back-vibe').onclick=()=>setStep(1);
+$('#btn-swatches-next').onclick=()=>{buildOrderSummary();setStep(3);};
+
+// panel 3: checkout
+function buildOrderSummary(){
+ const wrap=$('#order-summary');
+ const rows=state.selected.map(p=>`<div class="order-line"><span>${esc(p.title)}</span><span style="color:var(--muted);font-size:11px">swatch</span></div>`).join('');
+ wrap.innerHTML=`<h3>Your Sample Box (${state.selected.length} swatches)</h3>${rows}
+ <div class="order-line"><span>Box Price</span><span>$${PRICE.toFixed(2)}</span></div>
+ <div class="credit-note">✅ $${PRICE.toFixed(2)} fully credited toward your first roll order</div>`;
+}
+$('#btn-back-swatches').onclick=()=>setStep(2);
+$('#btn-place-order').onclick=async()=>{
+ const form=$('#checkout-form');
+ if(!form.checkValidity()){form.reportValidity();return;}
+ const fd=new FormData(form);
+ const shipping={name:fd.get('name'),address1:fd.get('address1'),address2:fd.get('address2')||'',
+ city:fd.get('city'),state:fd.get('state').toUpperCase(),zip:fd.get('zip'),country:fd.get('country')};
+ const btn=$('#btn-place-order');
+ btn.disabled=true;btn.textContent='Placing order…';
+ try{
+ const r=await fetch('/api/orders',{method:'POST',headers:{'Content-Type':'application/json'},
+ body:JSON.stringify({vibe:state.vibe,swatches:state.selected.map(p=>({sku:p.sku,title:p.title,image:p.image,price:p.price})),
+ shipping,email:fd.get('email'),payment_stub:true})});
+ const d=await r.json();
+ if(!d.success)throw new Error(d.error||'Order failed');
+ showConfirmation(d.order);
+ }catch(e){
+ const err=$('#order-error');err.textContent='Error: '+e.message;err.style.display='';
+ btn.disabled=false;btn.textContent='Place Test Order — $15.00';
+ }
+};
+
+// panel 4: confirmation
+function showConfirmation(order){
+ setStep(4);
+ $('#confirm-order-id').textContent='Order '+order.id;
+ $('#confirm-credit').textContent=order.credit_note;
+ const addr=order.shipping;
+ $('#confirm-detail').innerHTML=order.swatches.map(s=>`<p><strong>${esc(s.title)}</strong></p>`).join('')+
+ `<p style="padding-top:10px"><strong>Ship to:</strong> ${esc(addr.name)}</p>
+ <p>${esc(addr.address1)}${addr.address2?' '+esc(addr.address2):''}</p>
+ <p>${esc(addr.city)}, ${esc(addr.state)} ${esc(addr.zip)} ${esc(addr.country)}</p>
+ <p><strong>Email:</strong> ${esc(order.email)}</p>
+ <p><strong>Payment:</strong> ${esc(order.payment_method)} · ${esc(order.payment_status)}</p>
+ <p><strong>Box price:</strong> $${Number(order.box_price).toFixed(2)} (TEST — no charge)</p>`;
+}
+
+loadVibes();
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index 57f5df5..5285ab5 100644
--- a/server.js
+++ b/server.js
@@ -1,41 +1,191 @@
-// 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; }
+// Sample Box — paid curated swatch-box funnel for Designer Wallcoverings
+// Zero-dep Node http. Extends the web-dev accelerator template.
+// PORT=3903 node server.js | BASIC_AUTH=admin:DW2024! gates /admin
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+
+const PORT = parseInt(process.env.PORT || '3903', 10);
+const BASIC_AUTH = process.env.BASIC_AUTH || ''; // "user:pass" gates admin
+const DIR = __dirname;
+const DATA_DIR = path.join(DIR, 'data');
+const ORDERS_FILE = path.join(DATA_DIR, 'orders.json');
+const BOX_PRICE = 15.00; // $15 sample box, fully credited to first roll order
+
+// ── Data helpers ──────────────────────────────────────────────────────────────
+function loadProducts() {
+ try { return JSON.parse(fs.readFileSync(path.join(DATA_DIR, '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 loadOrders() {
+ try { return JSON.parse(fs.readFileSync(ORDERS_FILE, 'utf8')); }
+ catch { return []; }
+}
+function saveOrder(order) {
+ const orders = loadOrders();
+ orders.push(order);
+ fs.writeFileSync(ORDERS_FILE, JSON.stringify(orders, 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)); }
+ case 'title': return a.sort((x, y) => (x.title||'').localeCompare(y.title||''));
+ case 'sku': return a.sort((x, y) => (x.sku||'').localeCompare(y.sku||''));
+ case 'price-asc': return a.sort((x, y) => (x.price||0) - (y.price||0));
+ case 'price-desc': return a.sort((x, y) => (y.price||0) - (x.price||0));
+ default: return a; // newest = natural order
+ }
+}
+function sortOrders(items, mode) {
+ const a = items.slice();
+ if (mode === 'oldest') return a.sort((x,y) => new Date(x.created_at) - new Date(y.created_at));
+ return a.sort((x,y) => new Date(y.created_at) - new Date(x.created_at));
+}
+
+// ── Auth (admin only) ─────────────────────────────────────────────────────────
+function isAdmin(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; }
+}
+
+// ── Body parser ───────────────────────────────────────────────────────────────
+function readBody(req) {
+ return new Promise((resolve, reject) => {
+ let d = '';
+ req.on('data', c => { d += c; if (d.length > 1e6) req.destroy(); });
+ req.on('end', () => { try { resolve(JSON.parse(d)); } catch { resolve({}); }});
+ req.on('error', reject);
+ });
+}
+
+// ── MIME ──────────────────────────────────────────────────────────────────────
+const MIME = { '.html':'text/html; charset=utf-8', '.js':'application/javascript',
+ '.css':'text/css', '.json':'application/json', '.png':'image/png',
+ '.jpg':'image/jpeg', '.webp':'image/webp', '.svg':'image/svg+xml', '.ico':'image/x-icon' };
+
+function serveFile(res, fp) {
+ if (!fs.existsSync(fp)) { res.writeHead(404); return res.end('not found'); }
+ const ext = path.extname(fp);
+ res.writeHead(200, { 'Content-Type': MIME[ext] || 'text/plain' });
+ res.end(fs.readFileSync(fp));
+}
+
+// ── Server ────────────────────────────────────────────────────────────────────
+http.createServer(async (req, res) => {
+ const u = new URL(req.url, `http://localhost:${PORT}`);
+ const mt = req.method.toUpperCase();
+
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ if (mt === 'OPTIONS') { res.writeHead(204); return res.end(); }
+
+ // ── Public API ──────────────────────────────────────────────────────────────
+
+ // GET /api/products?sort=newest&vibe=modern
+ if (u.pathname === '/api/products' && mt === 'GET') {
+ let products = loadProducts();
+ const vibe = u.searchParams.get('vibe');
+ if (vibe && vibe !== 'all') {
+ products = products.filter(p => Array.isArray(p.vibes) && p.vibes.includes(vibe));
+ }
+ products = sortProducts(products, u.searchParams.get('sort') || 'newest');
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ count: products.length, products }));
+ }
+
+ // GET /api/vibes
+ if (u.pathname === '/api/vibes' && mt === 'GET') {
+ const products = loadProducts();
+ const vibeSet = new Set();
+ products.forEach(p => (p.vibes||[]).forEach(v => vibeSet.add(v)));
+ const ALL_VIBES = [
+ { id:'modern', label:'Modern & Minimal', emoji:'⬜' },
+ { id:'traditional', label:'Traditional', emoji:'🏛' },
+ { id:'floral', label:'Floral & Botanical', emoji:'🌿' },
+ { id:'grasscloth', label:'Grasscloth & Natural', emoji:'🌾' },
+ { id:'bold-color', label:'Bold Color', emoji:'🎨' },
+ { id:'geometric', label:'Geometric', emoji:'🔷' },
+ ];
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ vibes: ALL_VIBES.filter(v => vibeSet.has(v.id)) }));
+ }
+
+ // POST /api/orders — place a TEST order (no real charge)
+ if (u.pathname === '/api/orders' && mt === 'POST') {
+ const body = await readBody(req);
+ const { vibe, swatches, shipping, email, payment_stub } = body;
+
+ if (!email || !shipping || !Array.isArray(swatches) || !swatches.length) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ error: 'Missing: email, shipping, swatches' }));
+ }
+
+ const orderId = 'SB-' + Date.now().toString(36).toUpperCase();
+ const order = {
+ id: orderId,
+ vibe: vibe || 'all',
+ swatches, // [{sku,title,image,price}]
+ swatch_count: swatches.length,
+ box_price: BOX_PRICE,
+ credit_note: `$${BOX_PRICE.toFixed(2)} credited toward your first roll order (ref: ${orderId})`,
+ email,
+ shipping, // {name,address1,address2,city,state,zip,country}
+ payment_method: payment_stub ? 'TEST_STUB' : 'STRIPE_TEST',
+ payment_status: 'paid_test',
+ stripe_session_id: payment_stub ? null : ('cs_test_' + crypto.randomBytes(12).toString('hex')),
+ status: 'pending_fulfillment',
+ created_at: new Date().toISOString(),
+ };
+
+ saveOrder(order);
+ res.writeHead(201, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ success: true, order_id: orderId, order }));
+ }
+
+ // ── Admin (gated) ───────────────────────────────────────────────────────────
+
+ if (u.pathname.startsWith('/api/admin') || u.pathname === '/admin' || u.pathname === '/admin/') {
+ if (!isAdmin(req)) {
+ res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="Sample Box Admin"' });
+ return res.end('Admin auth required');
+ }
+ }
+
+ // GET /api/admin/orders?sort=newest
+ if (u.pathname === '/api/admin/orders' && mt === 'GET') {
+ let orders = loadOrders();
+ orders = sortOrders(orders, u.searchParams.get('sort') || 'newest');
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ count: orders.length, orders }));
+ }
+
+ // GET /api/admin/stats
+ if (u.pathname === '/api/admin/stats' && mt === 'GET') {
+ const orders = loadOrders();
+ const revenue = orders.reduce((s,o) => s + (o.box_price||0), 0);
+ const swatches = orders.reduce((s,o) => s + (o.swatch_count||0), 0);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ total_orders: orders.length, total_revenue: revenue, total_swatches: swatches }));
+ }
+
+ // ── Static ──────────────────────────────────────────────────────────────────
+
+ if (u.pathname === '/admin' || u.pathname === '/admin/') {
+ return serveFile(res, path.join(DIR, 'public', 'admin.html'));
+ }
+
+ const fname = u.pathname === '/' ? 'index.html' : u.pathname.replace(/^\//, '');
+ const fp = path.join(DIR, 'public', path.basename(fname));
+ if (fs.existsSync(fp)) return serveFile(res, fp);
+
res.writeHead(404); res.end('not found');
-}).listen(PORT, function () { console.log('[' + path.basename(DIR) + '] http://localhost:' + this.address().port); });
+
+}).listen(PORT, function () {
+ console.log(`[sample-box] http://localhost:${this.address().port}`);
+ console.log(`[sample-box] /admin — set BASIC_AUTH=user:pass to gate`);
+ console.log(`[sample-box] BOX PRICE $${BOX_PRICE.toFixed(2)} — TEST MODE, no real charges`);
+});
← 6cfcf4e initial scaffold (sample-box) via web-dev accelerator
·
back to Sample Box
·
step 1: copy real Shopify catalog (200 products, checkout_do 5e7cf3b →