← back to Wpb Sales Dashboard
public/index.html
290 lines
<!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>