← back to Wpb Sales Dashboard
initial scaffold: WPB sales & products dashboard (:9812)
1b8b8306d8818f789b0b5e7bf1036f92273bbe9a · 2026-07-08 12:05:55 -0700 · Steve
Files touched
A .deploy.confA .gitignoreA README.mdA config/marketplaces.jsonA data/sales-ledger.jsonlA package.jsonA public/index.htmlA server.js
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 & 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 →