← 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 &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>