[object Object]

← back to Wpb Sales Dashboard

initial scaffold: WPB sales & products dashboard (:9812)

1b8b8306d8818f789b0b5e7bf1036f92273bbe9a · 2026-07-08 12:05:55 -0700 · Steve

Files touched

Diff

commit 1b8b8306d8818f789b0b5e7bf1036f92273bbe9a
Author: Steve <steve@designerwallcoverings.com>
Date:   Wed Jul 8 12:05:55 2026 -0700

    initial scaffold: WPB sales & products dashboard (:9812)
---
 .deploy.conf             |   7 ++
 .gitignore               |   8 ++
 README.md                |  51 +++++++++
 config/marketplaces.json |  77 +++++++++++++
 data/sales-ledger.jsonl  |   0
 package.json             |  12 ++
 public/index.html        | 289 +++++++++++++++++++++++++++++++++++++++++++++++
 server.js                | 254 +++++++++++++++++++++++++++++++++++++++++
 8 files changed, 698 insertions(+)

diff --git a/.deploy.conf b/.deploy.conf
new file mode 100644
index 0000000..15c15bb
--- /dev/null
+++ b/.deploy.conf
@@ -0,0 +1,7 @@
+# wpb-sales-dashboard deploy config (LOCAL-ONLY until Steve gates a deploy)
+APP_NAME=wpb-sales-dashboard
+PORT=9812
+HEALTH_URL=http://admin:DW2024!@127.0.0.1:9812/healthz
+ENTRY=server.js
+# No remote target set — deploy is Steve-gated. This dashboard tracks the
+# WPB marketplace selling operation and reads pattern-vault data locally.
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..44ddfdc
--- /dev/null
+++ b/README.md
@@ -0,0 +1,51 @@
+# WPB Sales & Products Dashboard
+
+Tracks the **Wallpaper's Back design-marketplace selling operation** in one place:
+
+```
+Designs (inventory)  ->  Listings (design on a marketplace)  ->  Sales (orders)
+```
+
+- **Port:** 9812 · Basic auth `admin:DW2024!` (the 401 is the healthy gate) · `/healthz` is auth-exempt.
+- **Zero dependencies** — plain Node HTTP.
+- **Cost:** $0 (local) — reads pattern-vault data files + a local append-only sales ledger, no paid API.
+
+## Data sources (read live per request)
+
+| Data | Source |
+|------|--------|
+| Designs (pattern inventory) | `~/Projects/pattern-vault/data/catalog.json` |
+| Spoonflower listings (LIVE, currently private) | `~/Projects/pattern-vault/data/spoonflower-uploads.json` |
+| Etsy listings (PLANNED) | `~/Projects/pattern-vault/data/etsy-listings-plan.json` |
+| Marketplace accounts | `config/marketplaces.json` (this repo) |
+| Sales | `data/sales-ledger.jsonl` (this repo, append-only) |
+
+## Sales ledger
+
+Starts **empty on purpose** — everything is pre-launch / PRIVATE. Record the first
+real order via the dashboard's "Record a sale" form, or POST directly:
+
+```sh
+curl -s -u admin:DW2024! -X POST http://127.0.0.1:9812/api/sales \
+  -H 'Content-Type: application/json' \
+  -d '{"marketplace":"spoonflower","title":"Fern Wink — Rainbows","units":1,"gross":8.50,"fee":2.10}'
+```
+
+A marketplace webhook can POST the same shape later — the manual form and the
+webhook write identical rows. Fields: `marketplace` (key from marketplaces.json),
+`title`/`design`, `units`, `gross`, `fee`, optional `net`/`date`/`orderId`.
+
+## Run
+
+```sh
+node server.js          # http://localhost:9812
+```
+
+## Standing-rule compliance
+
+- **Sort + density slider** on the listings grid (persisted in `localStorage`).
+- **Created date + time chip** on every listing card (admin surface).
+- **Brand identity per shop** encoded in `config/marketplaces.json` — WPB everywhere
+  except Spoonflower + Patternbank (permanent `info@dw` exceptions).
+
+Deploy is **Steve-gated** — local-only until then.
diff --git a/config/marketplaces.json b/config/marketplaces.json
new file mode 100644
index 0000000..986c642
--- /dev/null
+++ b/config/marketplaces.json
@@ -0,0 +1,77 @@
+{
+  "_note": "Account registry for the WPB design-selling operation. brand/email follow the pattern-marketplace-email rule: WPB everywhere EXCEPT Spoonflower + Patternbank (permanent info@dw exceptions). status: live=account exists & usable, pending=awaiting confirmation, planned=not yet created. Sales/units are filled from data/sales-ledger.jsonl at runtime — never hand-edit those here.",
+  "marketplaces": [
+    {
+      "key": "spoonflower",
+      "name": "Spoonflower",
+      "shop": "Fernwick Studio",
+      "brand": "Designer Wallcoverings (permanent exception)",
+      "email": "info@designerwallcoverings.com",
+      "status": "live",
+      "url": "https://www.spoonflower.com/profiles/fernwick",
+      "model": "POD royalty per sale",
+      "notes": "3 designs uploaded (PRIVATE / pre-launch). For-sale switch gated on Steve's payout+tax setup.",
+      "color": "#2ec4b6"
+    },
+    {
+      "key": "etsy",
+      "name": "Etsy",
+      "shop": "SteveAbramsStudios",
+      "brand": "Wallpaper's Back (move from DW when resolved)",
+      "email": "info@wallpapersback.com",
+      "status": "live",
+      "url": "https://www.etsy.com/shop/SteveAbramsStudios",
+      "model": "Digital download, you set price ($149)",
+      "notes": "38 listings planned in etsy-listings-plan.json; not yet pushed to draft via API.",
+      "color": "#f4a261"
+    },
+    {
+      "key": "creativemarket",
+      "name": "Creative Market",
+      "shop": "SteveAbramsDW",
+      "brand": "Wallpaper's Back (move from DW when resolved)",
+      "email": "info@wallpapersback.com",
+      "status": "live",
+      "url": "https://creativemarket.com/SteveAbramsDW",
+      "model": "Digital pattern packs, ~40-50% commission",
+      "notes": "Created via Google SSO under DW. Best AI-friendly fit. 0 listings yet.",
+      "color": "#e76f51"
+    },
+    {
+      "key": "redbubble",
+      "name": "Redbubble",
+      "shop": "wallpapersback",
+      "brand": "Wallpaper's Back",
+      "email": "info@wallpapersback.com",
+      "status": "live",
+      "url": "https://www.redbubble.com/people/wallpapersback/shop",
+      "model": "POD margin, you set markup",
+      "notes": "Account created 2026-07-07. AI-hostile platform — anti-bot cadence critical. 0 listings yet.",
+      "color": "#e63946"
+    },
+    {
+      "key": "patternbank",
+      "name": "Patternbank",
+      "shop": "Wallpaper's Back",
+      "brand": "Designer Wallcoverings (permanent exception)",
+      "email": "info@designerwallcoverings.com",
+      "status": "pending",
+      "url": "https://patternbank.com",
+      "model": "Curated marketplace, revenue share",
+      "notes": "Signup under info@dw; awaiting confirmation email. 0 listings yet.",
+      "color": "#8338ec"
+    },
+    {
+      "key": "society6",
+      "name": "Society6",
+      "shop": "Wallpaper's Back",
+      "brand": "Wallpaper's Back",
+      "email": "info@wallpapersback.com",
+      "status": "pending",
+      "url": "https://society6.com",
+      "model": "POD margin",
+      "notes": "Signup in progress; awaiting confirmation. 0 listings yet.",
+      "color": "#3a86ff"
+    }
+  ]
+}
diff --git a/data/sales-ledger.jsonl b/data/sales-ledger.jsonl
new file mode 100644
index 0000000..e69de29
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..84ee6ea
--- /dev/null
+++ b/package.json
@@ -0,0 +1,12 @@
+{
+  "name": "wpb-sales-dashboard",
+  "version": "1.0.0",
+  "description": "Wallpaper's Back design-marketplace sales & products dashboard — Designs → Listings → Sales across Spoonflower/Etsy/Creative Market/Redbubble/Patternbank/Society6. Zero-dependency Node.",
+  "main": "server.js",
+  "scripts": {
+    "start": "node server.js"
+  },
+  "author": "steve@designerwallcoverings.com",
+  "license": "UNLICENSED",
+  "private": true
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..45ff932
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,289 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>WPB · Sales & Products</title>
+<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Crect width='100' height='100' rx='20' fill='%23111'/%3E%3Ctext x='50' y='68' font-size='54' text-anchor='middle' fill='%23f4a261' font-family='Georgia'%3EW%3C/text%3E%3C/svg%3E">
+<style>
+  :root{
+    --bg:#0d0e12; --panel:#15171e; --panel2:#1b1e27; --line:#262a35;
+    --ink:#f2f2f5; --dim:#9aa0ad; --dimmer:#6b7280;
+    --accent:#f4a261; --good:#2ec4b6; --live:#2ec4b6; --private:#f4a261;
+    --planned:#5b8def; --pending:#8a8f9c;
+  }
+  *{box-sizing:border-box}
+  body{margin:0;background:var(--bg);color:var(--ink);
+    font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
+    -webkit-font-smoothing:antialiased}
+  a{color:inherit}
+  .wrap{max-width:1240px;margin:0 auto;padding:22px 20px 80px}
+  header.top{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:6px}
+  .brand{font:700 22px/1 Georgia,serif;letter-spacing:.3px}
+  .brand b{color:var(--accent)}
+  .sub{color:var(--dim);font-size:13px}
+  .fresh{margin-left:auto;color:var(--dimmer);font-size:12px}
+  .fresh .dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--good);margin-right:5px;vertical-align:middle}
+  h2{font:600 13px/1 -apple-system,sans-serif;text-transform:uppercase;letter-spacing:.12em;
+    color:var(--dim);margin:34px 0 14px}
+
+  /* KPI row */
+  .kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:12px;margin-top:16px}
+  .kpi{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:16px 16px 14px}
+  .kpi .v{font:700 30px/1 Georgia,serif}
+  .kpi .l{color:var(--dim);font-size:12px;margin-top:7px;letter-spacing:.02em}
+  .kpi .sub{font-size:11px;color:var(--dimmer);margin-top:3px}
+  .kpi.hero .v{color:var(--accent)}
+  .kpi.good .v{color:var(--good)}
+
+  /* two-col */
+  .cols{display:grid;grid-template-columns:1.4fr 1fr;gap:20px;align-items:start}
+  @media(max-width:920px){.cols{grid-template-columns:1fr}}
+
+  .card{background:var(--panel);border:1px solid var(--line);border-radius:16px;padding:18px}
+  .card h3{margin:0 0 14px;font-size:14px;letter-spacing:.02em}
+
+  /* sales graph */
+  .graph{width:100%;height:150px;display:block}
+  .graph .bar{fill:var(--accent);opacity:.9}
+  .graph .base{stroke:var(--line);stroke-width:1}
+  .graph text{fill:var(--dimmer);font-size:9px}
+  .prelaunch{color:var(--dim);font-size:12.5px;background:var(--panel2);border:1px dashed var(--line);
+    border-radius:10px;padding:12px 14px;margin-top:12px}
+
+  /* marketplace list */
+  .mkt{display:flex;align-items:center;gap:12px;padding:12px 0;border-top:1px solid var(--line)}
+  .mkt:first-child{border-top:0}
+  .mkt .swatch{width:10px;height:34px;border-radius:4px;flex-shrink:0}
+  .mkt .nm{font-weight:600}
+  .mkt .meta{color:var(--dim);font-size:12px}
+  .mkt .badge{margin-left:auto;text-align:right;font-size:12px;color:var(--dim)}
+  .pill{display:inline-block;font-size:10.5px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;
+    padding:2px 7px;border-radius:999px;vertical-align:middle}
+  .pill.live{background:rgba(46,196,182,.16);color:var(--live)}
+  .pill.pending{background:rgba(138,143,156,.16);color:var(--pending)}
+  .pill.private{background:rgba(244,162,97,.16);color:var(--private)}
+  .pill.planned{background:rgba(91,141,239,.16);color:var(--planned)}
+
+  /* controls */
+  .controls{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:14px}
+  .controls label{font-size:12px;color:var(--dim);display:flex;align-items:center;gap:7px}
+  select,input[type=range]{accent-color:var(--accent)}
+  select{background:var(--panel2);color:var(--ink);border:1px solid var(--line);border-radius:8px;
+    padding:6px 9px;font-size:13px}
+  .count{margin-left:auto;color:var(--dimmer);font-size:12px}
+
+  /* listings grid */
+  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--cols,240px),1fr));gap:12px}
+  .listing{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:13px 13px 12px;
+    display:flex;flex-direction:column;gap:8px}
+  .listing .t{font-weight:600;font-size:13.5px;line-height:1.35}
+  .listing .row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
+  .listing .mp{font-size:11.5px;color:var(--dim)}
+  .listing .price{margin-left:auto;font-weight:700;font-size:13px}
+  .listing .when{font-size:11px;color:var(--dimmer);margin-top:2px}
+  .listing .brand{font-size:10.5px;color:var(--dimmer)}
+
+  /* record-a-sale */
+  form.rec{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px;align-items:end}
+  form.rec .f{display:flex;flex-direction:column;gap:4px}
+  form.rec label{font-size:11px;color:var(--dim)}
+  form.rec input,form.rec select{background:var(--panel2);color:var(--ink);border:1px solid var(--line);
+    border-radius:8px;padding:7px 9px;font-size:13px}
+  .btn{background:var(--accent);color:#141414;border:0;border-radius:9px;padding:9px 16px;
+    font-weight:700;font-size:13px;cursor:pointer}
+  .btn:hover{filter:brightness(1.06)}
+  .foot{color:var(--dimmer);font-size:11.5px;margin-top:26px;text-align:center}
+  .empty{color:var(--dim);padding:24px;text-align:center;border:1px dashed var(--line);border-radius:12px}
+</style>
+</head>
+<body>
+<div class="wrap">
+  <header class="top">
+    <div class="brand"><b>Wallpaper's Back</b> · Sales &amp; Products</div>
+    <div class="sub">design-marketplace tracker</div>
+    <div class="fresh" id="fresh"></div>
+  </header>
+
+  <div class="kpis" id="kpis"></div>
+
+  <div class="cols" style="margin-top:34px">
+    <div class="card">
+      <h3>Revenue — last 14 days</h3>
+      <svg class="graph" id="graph" viewBox="0 0 700 150" preserveAspectRatio="none"></svg>
+      <div id="salesNote"></div>
+    </div>
+    <div class="card">
+      <h3>Marketplace accounts</h3>
+      <div id="markets"></div>
+    </div>
+  </div>
+
+  <h2>Record a sale</h2>
+  <div class="card">
+    <form class="rec" id="recForm">
+      <div class="f"><label>Marketplace</label><select name="marketplace" id="recMkt" required></select></div>
+      <div class="f"><label>Design / title</label><input name="title" placeholder="e.g. Fern Wink — Rainbows"></div>
+      <div class="f"><label>Units</label><input name="units" type="number" min="1" value="1"></div>
+      <div class="f"><label>Gross $</label><input name="gross" type="number" step="0.01" placeholder="149.00" required></div>
+      <div class="f"><label>Fee $</label><input name="fee" type="number" step="0.01" placeholder="0"></div>
+      <div class="f"><button class="btn" type="submit">Log sale</button></div>
+    </form>
+    <div id="recMsg" style="margin-top:10px;font-size:12.5px;color:var(--dim)"></div>
+  </div>
+
+  <h2>Listings across marketplaces</h2>
+  <div class="controls">
+    <label>Sort
+      <select id="sort">
+        <option value="newest">Newest</option>
+        <option value="title">Title A→Z</option>
+        <option value="marketplace">Marketplace</option>
+        <option value="status">Status</option>
+        <option value="price">Price ↓</option>
+      </select>
+    </label>
+    <label>Density
+      <input id="density" type="range" min="170" max="360" step="10" value="240">
+    </label>
+    <span class="count" id="count"></span>
+  </div>
+  <div class="grid" id="grid"></div>
+
+  <div class="foot">Compute cost: <b>$0 (local)</b> — reads pattern-vault files + local ledger, no paid API. · Auth admin:•••• · :9812</div>
+</div>
+
+<script>
+const $ = s => document.querySelector(s);
+const fmtMoney = v => '$' + (v||0).toLocaleString(undefined,{minimumFractionDigits:0,maximumFractionDigits:2});
+const fmtWhen = iso => { if(!iso) return '—'; const d=new Date(iso); if(isNaN(d)) return '—';
+  return d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); };
+const statusPill = s => `<span class="pill ${s}">${s}</span>`;
+
+let DATA = null;
+
+function renderKpis(k){
+  const cards = [
+    {v:fmtMoney(k.salesGross), l:'Gross sales', sub:`${k.ordersCount} orders · ${k.unitsSold} units`, cls:'hero'},
+    {v:fmtMoney(k.salesNet), l:'Net (after fees)', cls:'good'},
+    {v:k.listingsTotal, l:'Listings', sub:`${k.listingsLive} live · ${k.listingsPrivate} private · ${k.listingsPlanned} planned`},
+    {v:k.designsTotal, l:'Designs in inventory'},
+    {v:`${k.marketplacesLive}/${k.marketplacesTotal}`, l:'Marketplaces live'},
+  ];
+  $('#kpis').innerHTML = cards.map(c=>`
+    <div class="kpi ${c.cls||''}">
+      <div class="v">${c.v}</div>
+      <div class="l">${c.l}</div>
+      ${c.sub?`<div class="sub">${c.sub}</div>`:''}
+    </div>`).join('');
+}
+
+function renderGraph(series){
+  const days = series.sales14d || [];
+  const max = Math.max(1, ...days.map(d=>d.gross));
+  const W=700,H=150,pad=18, bw=(W-pad*2)/days.length*0.7, gap=(W-pad*2)/days.length;
+  let svg = `<line class="base" x1="0" y1="${H-16}" x2="${W}" y2="${H-16}"/>`;
+  days.forEach((d,i)=>{
+    const x = pad + i*gap + (gap-bw)/2;
+    const h = d.gross>0 ? Math.max(2,(d.gross/max)*(H-40)) : 0;
+    svg += `<rect class="bar" x="${x.toFixed(1)}" y="${(H-16-h).toFixed(1)}" width="${bw.toFixed(1)}" height="${h.toFixed(1)}" rx="2"><title>${d.date}: ${fmtMoney(d.gross)}</title></rect>`;
+    if(i%2===0) svg += `<text x="${(x+bw/2).toFixed(1)}" y="${H-4}" text-anchor="middle">${d.date.slice(5)}</text>`;
+  });
+  $('#graph').innerHTML = svg;
+  const total = days.reduce((a,d)=>a+d.gross,0);
+  $('#salesNote').innerHTML = total>0
+    ? `<div class="prelaunch">14-day gross: <b style="color:var(--accent)">${fmtMoney(total)}</b></div>`
+    : `<div class="prelaunch">🌱 <b>Pre-launch</b> — no sales recorded yet. Designs are uploaded PRIVATE while the for-sale switch waits on Steve's payout + tax setup. This chart fills the moment the first order is logged.</div>`;
+}
+
+function renderMarkets(markets){
+  $('#markets').innerHTML = markets.map(m=>`
+    <div class="mkt">
+      <div class="swatch" style="background:${m.color||'#555'}"></div>
+      <div>
+        <div class="nm">${m.name} ${statusPill(m.status)}</div>
+        <div class="meta">${m.shop} · ${m.brand.split(' (')[0]}</div>
+      </div>
+      <div class="badge">
+        <div><b style="color:var(--ink)">${m.listings}</b> listings</div>
+        <div>${m.orders>0?fmtMoney(m.salesGross)+' · '+m.orders+' orders':'—'}</div>
+      </div>
+    </div>`).join('');
+}
+
+function renderMktOptions(markets){
+  $('#recMkt').innerHTML = markets.map(m=>`<option value="${m.key}">${m.name}</option>`).join('');
+}
+
+function renderGrid(){
+  const sort = $('#sort').value;
+  let items = DATA.listings.slice();
+  const statusOrder = {live:0,private:1,planned:2,draft:3};
+  items.sort((a,b)=>{
+    if(sort==='title') return (a.title||'').localeCompare(b.title||'');
+    if(sort==='marketplace') return (a.marketplaceName||'').localeCompare(b.marketplaceName||'');
+    if(sort==='status') return (statusOrder[a.status]??9)-(statusOrder[b.status]??9);
+    if(sort==='price') return (b.price||0)-(a.price||0);
+    // newest: createdAt desc, nulls last
+    const ta=a.createdAt?Date.parse(a.createdAt):-Infinity, tb=b.createdAt?Date.parse(b.createdAt):-Infinity;
+    return tb-ta;
+  });
+  $('#count').textContent = `${items.length} listings`;
+  $('#grid').innerHTML = items.length ? items.map(l=>`
+    <div class="listing">
+      <div class="t">${l.title||'(untitled)'}</div>
+      <div class="row">
+        <span class="mp">${l.marketplaceName}</span>
+        ${statusPill(l.status)}
+        ${l.price?`<span class="price">$${l.price}</span>`:''}
+      </div>
+      <div class="brand">${l.brand||''}</div>
+      <div class="when" title="${l.createdAt||''}">🕓 ${fmtWhen(l.createdAt)}</div>
+      ${l.url?`<a class="mp" href="${l.url}" target="_blank" rel="noopener">open ↗</a>`:''}
+    </div>`).join('')
+    : `<div class="empty">No listings yet.</div>`;
+}
+
+async function load(){
+  const r = await fetch('/api/data'); DATA = await r.json();
+  renderKpis(DATA.kpis);
+  renderGraph(DATA.series);
+  renderMarkets(DATA.markets);
+  renderMktOptions(DATA.markets);
+  renderGrid();
+  const d = new Date(DATA.generatedAt);
+  $('#fresh').innerHTML = `<span class="dot"></span>updated ${d.toLocaleTimeString(undefined,{hour:'numeric',minute:'2-digit'})}`;
+}
+
+// controls + persistence
+function applyDensity(v){ document.documentElement.style.setProperty('--cols', v+'px'); }
+$('#density').addEventListener('input', e=>{ applyDensity(e.target.value); localStorage.setItem('wpb_density', e.target.value); });
+$('#sort').addEventListener('change', ()=>{ localStorage.setItem('wpb_sort', $('#sort').value); renderGrid(); });
+(function initPrefs(){
+  const d = localStorage.getItem('wpb_density'); if(d){ $('#density').value=d; }
+  applyDensity($('#density').value);
+  const s = localStorage.getItem('wpb_sort'); if(s){ $('#sort').value=s; }
+})();
+
+// record a sale
+$('#recForm').addEventListener('submit', async e=>{
+  e.preventDefault();
+  const f = new FormData(e.target);
+  const body = {
+    marketplace: f.get('marketplace'),
+    title: f.get('title')||null,
+    units: Number(f.get('units')||1),
+    gross: Number(f.get('gross')||0),
+    fee: Number(f.get('fee')||0),
+  };
+  const r = await fetch('/api/sales',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
+  const j = await r.json();
+  $('#recMsg').textContent = j.ok ? `✓ Logged ${fmtMoney(body.gross)} on ${body.marketplace}` : `✗ ${j.error||'failed'}`;
+  if(j.ok){ e.target.reset(); $('#recMkt').selectedIndex=0; load(); }
+});
+
+load();
+setInterval(load, 60000);
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..df6afd6
--- /dev/null
+++ b/server.js
@@ -0,0 +1,254 @@
+#!/usr/bin/env node
+/*
+ * WPB Sales & Products Dashboard  —  zero-dependency Node HTTP server.
+ *
+ * Tracks the Wallpaper's Back design-selling operation across marketplaces:
+ *   Designs (inventory)  ->  Listings (design placed on a marketplace)  ->  Sales (orders).
+ *
+ * Data model / sources (read live on every request, so edits to the underlying
+ * pattern-vault files show up on refresh — no build step, no cache to bust):
+ *   - Designs   : pattern-vault/data/catalog.json          (the pattern inventory)
+ *   - Spoonflower listings (LIVE) : pattern-vault/data/spoonflower-uploads.json
+ *   - Etsy listings (PLANNED)     : pattern-vault/data/etsy-listings-plan.json
+ *   - Marketplace accounts        : ./config/marketplaces.json  (this repo)
+ *   - Sales                       : ./data/sales-ledger.jsonl   (append-only, this repo)
+ *
+ * The sales ledger starts EMPTY on purpose — everything is pre-launch / PRIVATE.
+ * The dashboard renders honestly at $0 and is wired to receive the first real order
+ * via POST /api/sales (manual entry now; marketplace webhooks can POST the same shape later).
+ */
+'use strict';
+
+const http = require('http');
+const fs   = require('fs');
+const path = require('path');
+const os   = require('os');
+
+const PORT = process.env.PORT || 9812;
+const ROOT = __dirname;
+const HOME = os.homedir();
+
+// ---- source-of-truth paths (pattern-vault owns the product data) --------------
+const PV = path.join(HOME, 'Projects', 'pattern-vault', 'data');
+const SRC = {
+  catalog:    path.join(PV, 'catalog.json'),
+  spoonflower:path.join(PV, 'spoonflower-uploads.json'),
+  etsyPlan:   path.join(PV, 'etsy-listings-plan.json'),
+};
+const CFG_MARKETS = path.join(ROOT, 'config', 'marketplaces.json');
+const LEDGER      = path.join(ROOT, 'data', 'sales-ledger.jsonl');
+
+// ---- basic auth (fleet convention: admin / DW2024!, the 401 IS the healthy gate) ---
+const AUTH_USER = process.env.DASH_USER || 'admin';
+const AUTH_PASS = process.env.DASH_PASS || 'DW2024!';
+
+// ---- tiny helpers -------------------------------------------------------------
+function readJSON(file, fallback) {
+  try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
+  catch (e) { return fallback; }
+}
+function readLedger() {
+  try {
+    return fs.readFileSync(LEDGER, 'utf8')
+      .split('\n').map(l => l.trim()).filter(Boolean)
+      .map(l => { try { return JSON.parse(l); } catch { return null; } })
+      .filter(Boolean);
+  } catch { return []; }
+}
+function n(x) { const v = Number(x); return Number.isFinite(v) ? v : 0; }
+
+// ---- aggregation --------------------------------------------------------------
+function buildSnapshot() {
+  const marketsCfg = readJSON(CFG_MARKETS, { marketplaces: [] }).marketplaces || [];
+  const marketByKey = Object.fromEntries(marketsCfg.map(m => [m.key, m]));
+
+  // Designs = the pattern inventory
+  const catalog = readJSON(SRC.catalog, []);
+  const designs = (Array.isArray(catalog) ? catalog : []).map(d => ({
+    id: d.id,
+    title: d.title,
+    category: d.category || d.kind || 'design',
+    createdAt: d.createdAt || null,
+    img: d.img || null,
+    pricingStatus: d.pricingStatus || null,
+    tiers: Array.isArray(d.licenseTiers) ? d.licenseTiers.map(t => t.priceUsd) : [],
+  }));
+
+  // Listings = a design placed on a marketplace
+  const listings = [];
+
+  // Spoonflower (LIVE uploads, currently private)
+  const sf = readJSON(SRC.spoonflower, []);
+  (Array.isArray(sf) ? sf : []).forEach(u => listings.push({
+    id: `sf-${u.id}`,
+    designId: u.id,
+    title: u.title,
+    marketplace: 'spoonflower',
+    marketplaceName: 'Spoonflower',
+    status: u.private ? 'private' : 'live',
+    price: null,               // POD royalty — price set by Spoonflower per substrate
+    url: u.url || null,
+    image: null,
+    brand: 'Designer Wallcoverings',
+    createdAt: u.uploadedAt || null,
+  }));
+
+  // Etsy (PLANNED — in the listing plan, not yet pushed as drafts)
+  const etsy = readJSON(SRC.etsyPlan, {});
+  const etsyPrice = etsy && etsy.fixed ? n(etsy.fixed.price) : null;
+  (etsy && Array.isArray(etsy.plan) ? etsy.plan : []).forEach((p, i) => {
+    const title = (p.body && p.body.title) || `Etsy listing ${i + 1}`;
+    listings.push({
+      id: `etsy-plan-${i}`,
+      designId: (p.imageFile || '').replace(/^_images\//, '').replace(/\.\w+$/, '') || null,
+      title,
+      marketplace: 'etsy',
+      marketplaceName: 'Etsy',
+      status: 'planned',
+      price: etsyPrice,
+      url: null,
+      image: null,
+      brand: "Wallpaper's Back",
+      createdAt: null,
+    });
+  });
+
+  // Sales ledger
+  const sales = readLedger().map(s => ({
+    date: s.date || null,
+    marketplace: s.marketplace || 'unknown',
+    marketplaceName: (marketByKey[s.marketplace] || {}).name || s.marketplace || 'Unknown',
+    design: s.design || s.title || null,
+    title: s.title || s.design || null,
+    units: n(s.units || 1),
+    gross: n(s.gross),
+    fee: n(s.fee),
+    net: s.net != null ? n(s.net) : (n(s.gross) - n(s.fee)),
+    currency: s.currency || 'USD',
+    orderId: s.orderId || null,
+  }));
+
+  // Per-marketplace rollup (listings + sales)
+  const markets = marketsCfg.map(m => {
+    const mListings = listings.filter(l => l.marketplace === m.key);
+    const mSales = sales.filter(s => s.marketplace === m.key);
+    return {
+      ...m,
+      listings: mListings.length,
+      listingsLive: mListings.filter(l => l.status === 'live').length,
+      listingsPrivate: mListings.filter(l => l.status === 'private').length,
+      listingsPlanned: mListings.filter(l => l.status === 'planned').length,
+      salesGross: mSales.reduce((a, s) => a + s.gross, 0),
+      salesNet: mSales.reduce((a, s) => a + s.net, 0),
+      unitsSold: mSales.reduce((a, s) => a + s.units, 0),
+      orders: mSales.length,
+    };
+  });
+
+  // KPIs
+  const kpis = {
+    designsTotal: designs.length,
+    listingsTotal: listings.length,
+    listingsLive: listings.filter(l => l.status === 'live').length,
+    listingsPrivate: listings.filter(l => l.status === 'private').length,
+    listingsPlanned: listings.filter(l => l.status === 'planned').length,
+    marketplacesTotal: marketsCfg.length,
+    marketplacesLive: marketsCfg.filter(m => m.status === 'live').length,
+    salesGross: sales.reduce((a, s) => a + s.gross, 0),
+    salesNet: sales.reduce((a, s) => a + s.net, 0),
+    unitsSold: sales.reduce((a, s) => a + s.units, 0),
+    ordersCount: sales.length,
+  };
+
+  // 14-day sales series (gross by day) — honest zeros when pre-launch
+  const days = [];
+  const today = new Date();
+  for (let i = 13; i >= 0; i--) {
+    const d = new Date(Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate() - i));
+    const key = d.toISOString().slice(0, 10);
+    const dayGross = sales.filter(s => (s.date || '').slice(0, 10) === key).reduce((a, s) => a + s.gross, 0);
+    days.push({ date: key, gross: dayGross });
+  }
+
+  return {
+    generatedAt: new Date().toISOString(),
+    kpis,
+    markets,
+    listings,
+    designs,
+    sales: sales.slice().reverse(), // newest first
+    series: { sales14d: days },
+    launched: kpis.ordersCount > 0,
+  };
+}
+
+// ---- request handling ---------------------------------------------------------
+function unauthorized(res) {
+  res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="WPB Sales"', 'Content-Type': 'text/plain' });
+  res.end('Authentication required');
+}
+function checkAuth(req) {
+  const h = req.headers.authorization || '';
+  if (!h.startsWith('Basic ')) return false;
+  const [u, p] = Buffer.from(h.slice(6), 'base64').toString('utf8').split(':');
+  return u === AUTH_USER && p === AUTH_PASS;
+}
+function send(res, code, body, type) {
+  res.writeHead(code, { 'Content-Type': type || 'application/json', 'Cache-Control': 'no-store' });
+  res.end(typeof body === 'string' ? body : JSON.stringify(body));
+}
+function readBody(req) {
+  return new Promise((resolve) => {
+    let b = '';
+    req.on('data', c => { b += c; if (b.length > 1e6) req.destroy(); });
+    req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch { resolve({}); } });
+  });
+}
+
+const MIME = { '.html': 'text/html; charset=utf-8', '.css': 'text/css', '.js': 'text/javascript', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon' };
+
+const server = http.createServer(async (req, res) => {
+  const url = new URL(req.url, `http://localhost:${PORT}`);
+  const p = url.pathname;
+
+  // health check is auth-exempt (so pm2/monitors get a clean 200)
+  if (p === '/healthz') return send(res, 200, { ok: true, ts: Date.now() });
+
+  if (!checkAuth(req)) return unauthorized(res);
+
+  if (p === '/api/data') return send(res, 200, buildSnapshot());
+
+  // Record a sale (manual entry now; a marketplace webhook can POST the same shape later)
+  if (p === '/api/sales' && req.method === 'POST') {
+    const b = await readBody(req);
+    if (!b.marketplace || (b.gross == null && b.units == null)) {
+      return send(res, 400, { ok: false, error: 'need at least { marketplace, gross }' });
+    }
+    const row = {
+      date: b.date || new Date().toISOString(),
+      marketplace: String(b.marketplace),
+      title: b.title || b.design || null,
+      design: b.design || b.title || null,
+      units: n(b.units || 1),
+      gross: n(b.gross),
+      fee: n(b.fee),
+      net: b.net != null ? n(b.net) : (n(b.gross) - n(b.fee)),
+      currency: b.currency || 'USD',
+      orderId: b.orderId || null,
+      recordedAt: new Date().toISOString(),
+    };
+    fs.appendFileSync(LEDGER, JSON.stringify(row) + '\n');
+    return send(res, 200, { ok: true, row });
+  }
+
+  // static
+  let file = p === '/' ? '/index.html' : p;
+  file = path.normalize(file).replace(/^(\.\.[/\\])+/, '');
+  const abs = path.join(ROOT, 'public', file);
+  if (abs.startsWith(path.join(ROOT, 'public')) && fs.existsSync(abs) && fs.statSync(abs).isFile()) {
+    return send(res, 200, fs.readFileSync(abs), MIME[path.extname(abs)] || 'application/octet-stream');
+  }
+  return send(res, 404, { error: 'not found' });
+});
+
+server.listen(PORT, () => console.log(`WPB Sales & Products dashboard on http://localhost:${PORT}  (auth ${AUTH_USER}:****)`));

(oldest)  ·  back to Wpb Sales Dashboard  ·  fix: static send() JSON-stringified Buffers → serve raw byte 3a526d7 →