← back to Wpb Sales Dashboard

public/index.html

369 lines

<!DOCTYPE html>
<html lang="en">
<head>
<script>
// Credential-safe fetch guard (fleet drop-in) — if this page is opened with
// credentials in the URL (a saved bookmark / Chrome-remembered basic-auth:
// https://user:pass@host/…), the browser poisons document.baseURI, so any
// relative or root-relative fetch('/api/…') throws "Request cannot be constructed
// from a URL that includes credentials" and callers silently fall to an empty
// state. Resolve every non-absolute request URL against the credential-free
// location instead of baseURI. Placed first so it wraps window.fetch before any
// app script runs. Ref: creds-in-url-fetch-guard-fleet-pattern.
(function () {
  var _fetch = window.fetch.bind(window);
  var cleanBase = function () { return location.origin + location.pathname; };
  window.fetch = function (input, init) {
    try {
      if (typeof input === 'string' && !/^[a-z]+:\/\//i.test(input) && input.indexOf('//') !== 0) {
        input = new URL(input, cleanBase()).href;
      } else if (input instanceof Request && !/^[a-z]+:\/\//i.test(input.url)) {
        input = new Request(new URL(input.url, cleanBase()).href, input);
      }
    } catch (_) { /* fall through to native */ }
    return _fetch(input, init);
  };
})();
</script>
<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>
  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</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="activef" id="activef" style="display:none"></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>

<style>.listing .drill{color:inherit;text-decoration:none;cursor:pointer;border-bottom:1px dotted transparent}.listing .drill:hover{border-bottom-color:currentColor;opacity:.85}
.activef{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}
.activef a{font-size:11px;text-decoration:none;color:var(--ink);background:var(--panel2);border:1px solid var(--line);border-radius:999px;padding:3px 10px}
.activef a:hover{border-color:var(--accent);color:var(--accent)}</style>
<script src="/drill.js"></script>
<script>
const $ = s => document.querySelector(s);
const fmtMoney = v => '$' + (v||0).toLocaleString(undefined,{minimumFractionDigits:0,maximumFractionDigits:2});
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
// ── href-to-deeper-data (HARD rule) — VIEWER model over the client-side listings array ──
// Every displayed listing data point (marketplace / status / brand) becomes a .drill atom
// whose href IS the filtered view for that value; filter state mirrors into the URL so any
// filtered listings view is a shareable, deep-linkable, back-button-safe href. drill.js is
// loaded for the fleet atom contract + the enforcer canary; the render logic below is local.
const FIELDS = [['marketplaceName','Marketplace'],['status','Status'],['brand','Brand']];
const FIL = {}; FIELDS.forEach(([k])=>FIL[k]='');
function lqstr(){
  const u=new URLSearchParams();
  for(const [k] of FIELDS){ if(FIL[k]) u.set(k,FIL[k]); }
  const s=u.toString(); return s?('?'+s):location.pathname;
}
function lwriteURL(push){ try{ history[push?'pushState':'replaceState']({},'',lqstr()); }catch(_){} }
function lreadURL(){ const u=new URLSearchParams(location.search); for(const [k] of FIELDS) FIL[k]=u.get(k)||''; }
// drill atom: an <a> whose href IS the filtered view for that field=value (delegated click filters in place)
function ldrill(field,val,label){
  if(val==null||val==='') return esc(label==null?'':label);
  const u=new URLSearchParams(location.search); u.set(field,String(val));
  return '<a class="drill" data-filter="'+field+'" data-v="'+esc(val)+'" href="?'+esc(u.toString())+'" title="Filter to '+esc(val)+'">'+esc(label!=null?label:val)+'</a>';
}
function lsetF(k,v){ if(FIL[k]===String(v)) return; FIL[k]=String(v); renderGrid(); lwriteURL(true); }
function lclearF(k){ FIL[k]=''; renderGrid(); lwriteURL(true); }
// delegated: plain click on a drill atom filters in place; modifier/middle keeps the real href
document.addEventListener('click',function(e){
  const c=e.target.closest('#activef a[data-clear]');
  if(c){ e.preventDefault(); lclearF(c.dataset.clear); return; }
  const d=e.target.closest('.listing a.drill[data-filter]'); if(!d) return;
  if(e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||e.button===1) return;
  e.preventDefault(); e.stopPropagation(); lsetF(d.dataset.filter,d.dataset.v);
});
addEventListener('popstate',function(){ lreadURL(); if(DATA) renderGrid(); });
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('');
}

// status pill, but the status TEXT is a drill atom to the filtered view
function statusPillDrill(s){ return `<span class="pill ${s}">${ldrill('status',s,s)}</span>`; }
function renderActiveFilters(){
  const box=$('#activef'); if(!box) return;
  const chips=FIELDS.filter(([k])=>FIL[k]).map(([k,lbl])=>
    `<a href="#" data-clear="${k}" title="Remove ${lbl} filter">${lbl}: ${esc(FIL[k])} ✕</a>`).join('');
  box.innerHTML=chips; box.style.display=chips?'flex':'none';
}
function renderGrid(){
  const sort = $('#sort').value;
  // apply drill filters (VIEWER model: partition the client array by displayed data points)
  let items = DATA.listings.filter(l=>{
    for(const [k] of FIELDS){ if(FIL[k] && String(l[k]??'')!==FIL[k]) return false; }
    return true;
  });
  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;
  });
  renderActiveFilters();
  const totalTxt = (FIELDS.some(([k])=>FIL[k])) ? `${items.length} of ${DATA.listings.length} listings` : `${items.length} listings`;
  $('#count').textContent = totalTxt;
  $('#grid').innerHTML = items.length ? items.map(l=>`
    <div class="listing">
      <div class="t">${esc(l.title||'(untitled)')}</div>
      <div class="row">
        <span class="mp">${ldrill('marketplaceName',l.marketplaceName,l.marketplaceName)}</span>
        ${statusPillDrill(l.status)}
        ${l.price?`<span class="price">$${esc(l.price)}</span>`:''}
      </div>
      <div class="brand">${l.brand?ldrill('brand',l.brand,l.brand):''}</div>
      <div class="when" title="${esc(l.createdAt||'')}">🕓 ${fmtWhen(l.createdAt)}</div>
      ${l.url?`<a class="mp" href="${esc(l.url)}" target="_blank" rel="noopener">open ↗</a>`:''}
    </div>`).join('')
    : `<div class="empty">No listings match this filter.</div>`;
}

async function load(){
  const r = await fetch('/api/data'); DATA = await r.json();
  lreadURL();   // deep-link: hydrate listing filters from the URL before first render
  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>
  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body>
</html>