← back to Ads Dashboard

public/index.html

266 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>Ad-Ops</title>
<!-- href-drill:exempt reason="KPI/ad-ops dashboard — aggregate metrics + charts, no repeated drillable data rows" (Steve 2026-08-23, exclusion-marker verdict) -->
<style>
  :root { --accent:#555; --bg:#0f1115; --card:#171a21; --line:#262b36; --fg:#e7eaf0; --mut:#8b94a7; }
  @media (prefers-color-scheme: light){ :root{ --bg:#f5f6f8; --card:#fff; --line:#e4e7ec; --fg:#1a1d24; --mut:#68707f; } }
  *{ box-sizing:border-box; }
  body{ margin:0; font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; background:var(--bg); color:var(--fg); }
  header{ display:flex; align-items:center; gap:14px; padding:16px 22px; border-bottom:1px solid var(--line); position:sticky; top:0; background:var(--bg); z-index:5; }
  header .dot{ width:12px; height:12px; border-radius:50%; background:var(--accent); flex:0 0 auto; }
  header h1{ font-size:17px; margin:0; font-weight:650; letter-spacing:-.2px; }
  header .sub{ color:var(--mut); font-size:13px; }
  header .spacer{ flex:1; }
  header a.host{ color:var(--mut); font-size:12px; text-decoration:none; }
  main{ padding:22px; max-width:1200px; margin:0 auto; }
  .grid{ display:grid; gap:16px; grid-template-columns:repeat(auto-fill,minmax(var(--min,240px),1fr)); }
  .card{ background:var(--card); border:1px solid var(--line); border-radius:12px; padding:16px 18px; }
  .card h2{ font-size:12px; text-transform:uppercase; letter-spacing:.6px; color:var(--mut); margin:0 0 10px; }
  .kpi{ font-size:30px; font-weight:680; letter-spacing:-.5px; }
  .kpi a{ color:inherit; text-decoration:none; border-bottom:2px solid transparent; }
  .kpi a:hover{ border-color:var(--accent); }
  .when{ display:inline-block; margin-top:8px; font-size:11px; color:var(--mut); }
  .controls{ display:flex; gap:12px; align-items:center; margin:22px 0 12px; flex-wrap:wrap; }
  .controls label{ font-size:12px; color:var(--mut); }
  select,input[type=range]{ accent-color:var(--accent); }
  select{ background:var(--card); color:var(--fg); border:1px solid var(--line); border-radius:8px; padding:5px 8px; }
  table{ width:100%; border-collapse:collapse; font-size:13px; }
  th,td{ text-align:left; padding:8px 10px; border-bottom:1px solid var(--line); }
  th{ color:var(--mut); font-weight:600; cursor:pointer; user-select:none; }
  td a{ color:var(--fg); text-decoration:none; border-bottom:1px dotted var(--mut); }
  td a:hover{ border-color:var(--accent); }
  .badge{ display:inline-block; padding:2px 8px; border-radius:99px; font-size:11px; border:1px solid var(--line); }
  .empty{ color:var(--mut); font-size:13px; padding:16px 0; }
  .section-h{ font-size:13px; text-transform:uppercase; letter-spacing:.6px; color:var(--mut); margin:28px 0 6px; }
</style>
  <link rel="stylesheet" href="/nav-agent/nav-agent.css"><!-- nav-agent -->
</head>
<body>
<header>
  <span class="dot" id="dot"></span>
  <div>
    <h1 id="brandName">Ad-Ops</h1>
    <div class="sub" id="brandSub">loading…</div>
  </div>
  <span class="spacer"></span>
  <a class="host" id="hostChip" href="/healthz">/healthz</a>
</header>
<main>
  <div class="grid" id="kpis"></div>

  <div class="section-h">Revenue by stream <span style="text-transform:none;font-weight:400">(trailing 30d, net)</span></div>
  <div class="card">
    <table id="revTable"><thead><tr><th>Stream</th><th>Revenue</th><th>Orders</th><th>Status</th></tr></thead>
    <tbody id="revBody"></tbody></table>
    <div class="when" id="revNote" style="margin-top:8px"></div>
  </div>

  <div class="section-h">Daily orders <span style="text-transform:none;font-weight:400">(net, last 30d)</span></div>
  <div class="card">
    <div id="trend" style="display:flex;align-items:flex-end;gap:3px;height:120px;overflow-x:auto"></div>
    <div class="when" id="trendCap" style="margin-top:8px"></div>
    <div class="empty" id="trendEmpty" hidden></div>
  </div>

  <div class="section-h">Campaigns</div>
  <div class="controls">
    <label>Sort <select id="sort">
      <option value="newest">Newest</option>
      <option value="name">Name A→Z</option>
      <option value="status">Status</option>
      <option value="spend">Spend ↓</option>
    </select></label>
    <label>Density <input type="range" id="density" min="220" max="420" value="240"></label>
  </div>
  <div class="card">
    <table id="campTable"><thead><tr>
      <th data-k="name">Campaign</th><th data-k="domain">Domain</th>
      <th data-k="status">Status</th><th data-k="spend">Spend</th><th data-k="created_at">Created</th>
    </tr></thead><tbody id="campBody"></tbody></table>
    <div class="empty" id="campEmpty" hidden>No campaigns wired yet — /yoloforever will populate this. Data source: /api/campaigns</div>
  </div>

  <div class="section-h">Acquisition — where orders come from <span style="text-transform:none;font-weight:400">(real, from Shopify order referrers)</span></div>
  <div class="card">
    <table id="acqTable"><thead><tr>
      <th>Channel</th><th>Orders</th><th>Revenue</th>
    </tr></thead><tbody id="acqBody"></tbody></table>
    <div class="empty" id="acqEmpty" hidden></div>
  </div>

  <div class="section-h">Top campaigns <span style="text-transform:none;font-weight:400">(UTM attribution from converting orders — not paid-ad spend)</span></div>
  <div class="card">
    <table id="campUtmTable"><thead><tr>
      <th>Campaign</th><th>Source</th><th>Medium</th><th>Orders</th><th>Revenue</th>
    </tr></thead><tbody id="campUtmBody"></tbody></table>
    <div class="empty" id="campUtmEmpty" hidden></div>
  </div>

  <div class="section-h">Competitor Ad Signals</div>
  <div class="card">
    <div id="signals"></div>
  </div>
</main>

<script>
const fmtDate = (iso) => { try { return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); } catch { return iso||'—'; } };
const $ = (id)=>document.getElementById(id);

function renderRevenue(rev){
  if (!rev || !rev.present){ $('revBody').innerHTML='<tr><td colspan="4" class="empty">Revenue unavailable (source: /api/revenue)</td></tr>'; return; }
  const fmt$ = n => '$'+Number(n||0).toLocaleString();
  const rows = (rev.streams||[]).map(s=>{
    let status;
    if (!s.present) status = '<span class="badge">'+(s.reason||'not connected')+'</span>';
    else if (s.stale) status = '<span class="badge" style="border-color:#c0392b;color:#c0392b">⚠ stale '+s.age_hours+'h — as of '+fmtDate(s.as_of)+'</span>';
    else status = '<span class="badge">live'+(s.as_of?' · '+fmtDate(s.as_of):'')+'</span>';
    return `<tr${s.present?'':' style="opacity:.55"'}>
      <td><a href="/api/revenue">${s.stream}</a></td>
      <td>${s.present?fmt$(s.total):'—'}</td>
      <td>${s.orders!=null?s.orders:'—'}</td>
      <td>${status}</td></tr>`;
  }).join('');
  $('revBody').innerHTML = rows + `<tr style="font-weight:680"><td>Combined</td><td>${fmt$(rev.combined_total)}</td><td></td><td></td></tr>`;
  $('revNote').textContent = rev.note || '';
}

let campaigns = [];
async function boot(){
  const brand = await fetch('/api/brand').then(r=>r.json());
  document.documentElement.style.setProperty('--accent', brand.accent || '#555');
  $('brandName').textContent = brand.name + ' — Ad-Ops';
  $('brandSub').textContent = (brand.domains||[]).join(' · ') || 'internal ad-operations command center';
  $('hostChip').textContent = location.host;
  document.title = brand.short || 'Ad-Ops';

  const [acq, camps, rev] = await Promise.all([
    fetch('/api/acquisition').then(r=>r.json()).catch(()=>({present:false})),
    fetch('/api/campaigns').then(r=>r.json()),
    fetch('/api/revenue').then(r=>r.json()).catch(()=>({present:false})),
  ]);
  renderRevenue(rev);

  // KPIs — driven by REAL acquisition data (30d Shopify orders), every number hrefs to its source
  const topCh = (acq.channels||[])[0];
  const tagPct = acq.present && acq.totals.orders ? Math.round((acq.totals.utm_tagged||0)/acq.totals.orders*100) : 0;
  const shopS = (rev.streams||[]).find(s=>s.stream==='Shopify');
  const fmS = (rev.streams||[]).find(s=>s.stream==='FMPro invoicing');
  const kpis = acq.present ? [
    { h:'Revenue (30d, all streams)', v: rev.present ? '$'+rev.combined_total.toLocaleString() : '$'+acq.totals.revenue.toLocaleString(), href:'/api/revenue', when: rev.present ? 'Shopify + FMPro'+((rev.streams||[]).some(s=>s.stream==='PayPal'&&s.present)?' + PayPal':' (PayPal pending)') : 'Shopify only' },
    { h:'Shopify (30d)', v: '$'+(shopS?shopS.total:acq.totals.revenue).toLocaleString(), href:'/api/acquisition', when: acq.totals.orders+' orders' },
    { h:'FMPro invoicing (30d)', v: fmS&&fmS.present ? '$'+fmS.total.toLocaleString() : '—', href:'/api/revenue', when: fmS&&fmS.present ? fmS.orders+' booked'+(fmS.stale?' · ⚠ stale':'') : (fmS?fmS.reason:'') },
    { h:'Top channel', v: topCh ? topCh.channel : '—', href:'/api/acquisition', when: topCh ? topCh.orders+' orders' : '' },
  ] : [
    { h:'Revenue', v:'—', href:'/api/revenue', when:'unavailable' },
  ];
  $('kpis').innerHTML = kpis.map(k=>`<div class="card"><h2>${k.h}</h2>
     <div class="kpi"><a href="${k.href}">${k.v}</a></div>
     <span class="when" title="${new Date().toISOString()}">🕓 ${fmtDate(new Date().toISOString())}${k.when?' · '+k.when:''}</span></div>`).join('');

  campaigns = camps.campaigns || [];
  renderCampaigns();

  // signals
  const sig = await fetch('/api/signals').then(r=>r.json());
  const items = sig.signals || [];
  $('signals').innerHTML = items.length
    ? items.map(s=>`<div><a href="${s.url||'#'}">${s.advertiser||s.title||'signal'}</a> <span class="badge">${s.platform||'—'}</span> <span class="when">🕓 ${fmtDate(s.seen_at||s.created_at)}</span></div>`).join('')
    : `<div class="empty">No competitor ad signals captured yet — feeds from the advertising-signals skill. Source: /api/signals</div>`;

  // acquisition — real Shopify order referrers (acq already fetched up top for KPIs)
  if (acq.present && (acq.channels||[]).length) {
    $('acqEmpty').hidden = true;
    $('acqBody').innerHTML = acq.channels.map(c=>`<tr>
      <td><a href="/api/acquisition">${c.channel}</a></td>
      <td>${c.orders}</td>
      <td>$${c.revenue.toLocaleString()}</td>
    </tr>`).join('') + `<tr style="font-weight:650"><td>Total (${acq.window})</td><td>${acq.totals.orders}</td><td>$${acq.totals.revenue.toLocaleString()}</td></tr>`;
  } else {
    $('acqBody').innerHTML = '';
    $('acqEmpty').hidden = false;
    $('acqEmpty').textContent = 'Acquisition data unavailable: ' + (acq.reason||'no data') + ' (source: /api/acquisition)';
  }

  // daily trend bars
  const tr = (acq.trend||[]);
  if (tr.length) {
    $('trendEmpty').hidden = true;
    const max = Math.max(...tr.map(d=>d.orders), 1);
    $('trend').innerHTML = tr.map(d=>`<a href="/api/acquisition" title="${d.date}: ${d.orders} orders, $${d.revenue.toLocaleString()} — view source"
      style="flex:1;min-width:8px;height:${Math.round(d.orders/max*100)}%;background:var(--accent);border-radius:3px 3px 0 0;opacity:.85;text-decoration:none;display:block"></a>`).join('');
    $('trendCap').innerHTML = 'Revenue net of refunds, attributed to order date (shop-local, America/Los_Angeles).'
      + (acq.capped ? ' <span class="badge" style="border-color:#c0392b;color:#c0392b">⚠ hit 250-order fetch cap — earliest days may under-report</span>' : '');
  } else { $('trend').innerHTML=''; $('trendEmpty').hidden=false; $('trendEmpty').textContent='No daily data (source: /api/acquisition).'; }

  // UTM campaigns (attribution)
  const utmCamps = (acq.campaigns||[]);
  if (utmCamps.length) {
    $('campUtmEmpty').hidden = true;
    $('campUtmBody').innerHTML = utmCamps.map(c=>`<tr>
      <td><a href="/api/acquisition">${c.campaign}</a>${c.automated?' <span class="badge" title="automated feed traffic (product_sync / shop), not a marketing campaign">auto</span>':''}</td>
      <td>${c.source}</td><td>${c.medium}</td>
      <td>${c.orders}</td><td>$${c.revenue.toLocaleString()}</td>
    </tr>`).join('') + `<tr style="font-weight:650"><td colspan="3">${acq.totals.utm_tagged||0} of ${acq.totals.orders} orders UTM-tagged · ${acq.window} · ${acq.totals.refunded_netted||0} refunds netted</td><td colspan="2"></td></tr>`;
  } else {
    $('campUtmBody').innerHTML='';
    $('campUtmEmpty').hidden = false;
    $('campUtmEmpty').textContent = acq.present ? 'No UTM-tagged campaigns in the current window.' : 'Unavailable (source: /api/acquisition).';
  }
}

let sortKey='newest';
function renderCampaigns(){
  const b=$('campBody'), sorters={
    newest:(a,c)=> (Date.parse(c.created_at||0))-(Date.parse(a.created_at||0)),
    name:(a,c)=> (a.name||'').localeCompare(c.name||''),
    status:(a,c)=> (a.status||'').localeCompare(c.status||''),
    spend:(a,c)=> (c.spend||0)-(a.spend||0),
  };
  const rows=[...campaigns].sort(sorters[sortKey]||sorters.newest);
  $('campEmpty').hidden = rows.length>0;
  b.innerHTML = rows.map(c=>`<tr>
    <td><a href="/api/campaigns">${c.name||'—'}</a></td>
    <td><a href="https://${c.domain||''}">${c.domain||'—'}</a></td>
    <td><span class="badge">${c.status||'—'}</span></td>
    <td>${c.spend!=null?('$'+c.spend):'—'}</td>
    <td><span class="when" title="${c.created_at||''}">🕓 ${fmtDate(c.created_at)}</span></td>
  </tr>`).join('');
}
$('sort').onchange = e=>{ sortKey=e.target.value; renderCampaigns(); };
$('density').oninput = e=>document.documentElement.style.setProperty('--min', e.target.value+'px');
document.querySelectorAll('#campTable th').forEach(th=>th.onclick=()=>{ const m={name:'name',status:'status',spend:'spend',created_at:'newest'}; sortKey=m[th.dataset.k]||'newest'; $('sort').value=sortKey; renderCampaigns(); });

boot().catch(e=>{ $('brandSub').textContent='error: '+e.message; });
</script>
  <script src="/nav-agent/nav-agent.js" defer></script><!-- nav-agent -->
</body>
</html>