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