726bc24e09
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
369 lines
15 KiB
JavaScript
369 lines
15 KiB
JavaScript
// Traffic Desk client — pick size, pick approved creative, pick destination,
|
||
// launch. Balance and running campaigns come from the server.
|
||
(function () {
|
||
'use strict';
|
||
var $ = function (id) { return document.getElementById(id); };
|
||
function esc(s) {
|
||
return String(s == null ? '' : s).replace(/[&<>"']/g, function (ch) {
|
||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch];
|
||
});
|
||
}
|
||
var state = { size: null, creative: null, target: 'join', creatives: {}, remaining: 0, id: null,
|
||
format: 'banner', angle: 'general', angles: [], variants: [], variant: null, hasPage: false };
|
||
var busy = false;
|
||
|
||
function chip(label, on, fn) {
|
||
var b = document.createElement('button');
|
||
b.type = 'button';
|
||
b.className = 'tf-chip' + (on ? ' on' : '');
|
||
b.textContent = label;
|
||
b.addEventListener('click', fn);
|
||
return b;
|
||
}
|
||
|
||
function renderSizes(sizes) {
|
||
var host = $('tfSizes');
|
||
host.innerHTML = '';
|
||
sizes.forEach(function (s) {
|
||
host.appendChild(chip(s, s === state.size, function () {
|
||
state.size = s;
|
||
state.creative = (state.creatives[s] || [])[0] || null;
|
||
renderSizes(sizes); renderCreatives();
|
||
}));
|
||
});
|
||
}
|
||
|
||
function renderCreatives() {
|
||
var host = $('tfCreatives');
|
||
host.innerHTML = '';
|
||
(state.creatives[state.size] || []).forEach(function (f) {
|
||
var d = document.createElement('div');
|
||
d.className = 'tf-cre' + (f === state.creative ? ' on' : '');
|
||
var img = document.createElement('img');
|
||
img.src = '/banners/' + f;
|
||
img.alt = 'Team banner ' + state.size;
|
||
img.loading = 'lazy';
|
||
d.appendChild(img);
|
||
d.addEventListener('click', function () { state.creative = f; renderCreatives(); });
|
||
host.appendChild(d);
|
||
});
|
||
}
|
||
|
||
function renderTargets() {
|
||
var host = $('tfTargets');
|
||
host.innerHTML = '';
|
||
host.appendChild(chip('My invite page', state.target === 'join', function () {
|
||
state.target = 'join'; renderTargets();
|
||
}));
|
||
if (state.hasPage) {
|
||
host.appendChild(chip('My personal page', state.target === 'page', function () {
|
||
state.target = 'page'; renderTargets();
|
||
}));
|
||
} else {
|
||
// Don't let anyone point an ad at a page that doesn't exist yet.
|
||
var d = document.createElement('span');
|
||
d.className = 'tf-hint';
|
||
d.style.cssText = 'margin:0;align-self:center';
|
||
d.innerHTML = 'Want to send traffic to your own page instead? <a href="/suite/page" style="color:var(--gold);font-weight:700">Build it first →</a>';
|
||
host.appendChild(d);
|
||
}
|
||
}
|
||
|
||
function renderFormat() {
|
||
var host = $('tfFormat');
|
||
host.innerHTML = '';
|
||
[['banner', 'Banner image'], ['text', 'Text ad']].forEach(function (f) {
|
||
host.appendChild(chip(f[1], state.format === f[0], function () {
|
||
state.format = f[0];
|
||
renderFormat();
|
||
goLabel();
|
||
$('tfBannerPane').style.display = f[0] === 'banner' ? '' : 'none';
|
||
$('tfTextPane').style.display = f[0] === 'text' ? '' : 'none';
|
||
}));
|
||
});
|
||
}
|
||
|
||
function renderAngles() {
|
||
var host = $('tfAngles');
|
||
host.innerHTML = '';
|
||
state.angles.forEach(function (a) {
|
||
host.appendChild(chip(a.label, state.angle === a.key, function () {
|
||
state.angle = a.key; renderAngles();
|
||
}));
|
||
});
|
||
}
|
||
|
||
function renderVariants() {
|
||
var host = $('tfVariants');
|
||
host.innerHTML = '';
|
||
if (!state.variants.length) return;
|
||
var grid = document.createElement('div');
|
||
grid.className = 'tf-ads';
|
||
state.variants.forEach(function (v, i) {
|
||
var card = document.createElement('div');
|
||
card.className = 'tf-ad' + (state.variant === i ? ' on' : '');
|
||
var s = document.createElement('div');
|
||
s.className = 's'; s.textContent = v.subject;
|
||
card.appendChild(s);
|
||
v.lines.forEach(function (l) {
|
||
if (!l) return;
|
||
var d = document.createElement('div');
|
||
d.className = 'l'; d.textContent = l;
|
||
card.appendChild(d);
|
||
});
|
||
card.addEventListener('click', function () { state.variant = i; renderVariants(); });
|
||
grid.appendChild(card);
|
||
});
|
||
host.appendChild(grid);
|
||
var hint = document.createElement('div');
|
||
hint.className = 'tf-hint';
|
||
hint.textContent = 'Pick the one you want to run, then set your impressions below and launch it.';
|
||
host.appendChild(hint);
|
||
}
|
||
|
||
async function writeAds(btn) {
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spin"></span>Writing…';
|
||
$('tfErr').style.display = 'none';
|
||
try {
|
||
var r = await fetch('/api/public/suite-textads', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ angle: state.angle })
|
||
});
|
||
var d = await r.json();
|
||
if (!r.ok) {
|
||
$('tfErr').textContent = d.error || 'The writer could not produce ads just now.';
|
||
$('tfErr').style.display = 'block';
|
||
} else {
|
||
state.variants = d.variants || [];
|
||
state.variant = state.variants.length ? 0 : null;
|
||
renderVariants();
|
||
if (d.meter) showWriteMeter(d.meter);
|
||
}
|
||
} catch (e) {
|
||
$('tfErr').textContent = 'Connection hiccup — try again.';
|
||
$('tfErr').style.display = 'block';
|
||
}
|
||
btn.disabled = false;
|
||
btn.innerHTML = state.variants.length ? '✍️ Write 5 more' : '✍️ Write me 5 ads';
|
||
}
|
||
|
||
function showWriteMeter(m) {
|
||
if (!m) return;
|
||
$('tfWriteMeter').textContent = m.remaining + ' of ' + m.limit + ' batches left this month';
|
||
}
|
||
|
||
function goLabel() {
|
||
if ($('tfGo').disabled && state.remaining <= 0) return;
|
||
$('tfGo').textContent = state.format === 'text' ? '🚦 Launch my text ad' : '🚦 Launch my banner';
|
||
}
|
||
|
||
function gate(msg) {
|
||
$('tfGate').style.display = 'block';
|
||
$('tfGate').innerHTML = msg;
|
||
$('tfCard').style.opacity = '.55';
|
||
$('tfGo').disabled = true;
|
||
}
|
||
|
||
function renderLive(campaigns, live) {
|
||
if (!campaigns.length) { $('tfLiveWrap').style.display = 'none'; return; }
|
||
var byId = {};
|
||
(live || []).forEach(function (l) { byId[l.ad_id] = l; });
|
||
var tb = $('tfLive');
|
||
tb.innerHTML = '';
|
||
campaigns.slice().reverse().forEach(function (c) {
|
||
var l = byId[c.adId] || {};
|
||
var tr = document.createElement('tr');
|
||
// The bridge computes served for us (assigned − remaining; `remaining`
|
||
// counts DOWN for BOTH ad kinds — the old "banners count up" theory made
|
||
// every fresh banner render as delivered-in-full the moment it launched).
|
||
// The network's counters also drift ABOVE what we bought (3,521 observed
|
||
// on a 2,500 purchase; cause is inside the ionCube-encoded ad app), so
|
||
// clamp to what the member bought: served + left = bought, always. Any
|
||
// genuine over-delivery is a bonus to them and needs no explanation here.
|
||
var bought = Number(c.bought != null ? c.bought : c.impressions) || 0;
|
||
var servedShown, left;
|
||
if (c.completed || c.stopped) {
|
||
// Once a campaign is closed, trust what we recorded at the time —
|
||
// deactivating an ad ZEROES `remaining` on the network, so re-deriving
|
||
// from live counters would misreport a finished campaign.
|
||
servedShown = Number(c.served != null ? c.served : bought) || 0;
|
||
left = Math.max(0, bought - servedShown);
|
||
} else if (l.served != null) {
|
||
servedShown = Math.max(0, Math.min(Number(l.served) || 0, bought));
|
||
left = bought - servedShown;
|
||
} else if (l.remaining != null) {
|
||
left = Math.max(0, Math.min(Number(l.remaining) || 0, bought));
|
||
servedShown = bought - left;
|
||
} else { servedShown = 0; left = null; }
|
||
// "running" with 0 left reads as broken. It is not: banners have no cap on
|
||
// this network — the counter runs past the purchase and the ad keeps
|
||
// serving until its expiry, so a fully-delivered banner is genuinely
|
||
// still going. Say that plainly, and keep Stop available so a member can
|
||
// actually end it rather than wonder why it never finishes.
|
||
var delivered = servedShown >= bought && bought > 0;
|
||
var statusCell = c.completed
|
||
? '<span style="color:var(--muted)">delivered in full</span>'
|
||
: c.stopped
|
||
? 'stopped <span style="opacity:.7">(' + Number(c.refunded || 0).toLocaleString() + ' returned)</span>'
|
||
: !l.live ? 'finished'
|
||
: delivered
|
||
? '<span style="color:var(--teal)">delivered</span>' +
|
||
'<br><span style="font-size:11.5px;color:var(--muted)">served in full — closing out</span>'
|
||
: '<span style="color:var(--teal)">running</span>';
|
||
var label = c.kind === 'text'
|
||
? '<b>Text</b><br><span style="font-size:12px">' + esc(c.subject || '') + '</span>'
|
||
: '<b>' + c.size + '</b>';
|
||
tr.innerHTML = '<td>' + label + '</td>' +
|
||
'<td>' + (c.target.indexOf('/p/') !== -1 ? 'personal page' : 'invite page') + '</td>' +
|
||
'<td>' + Number(c.bought != null ? c.bought : c.impressions).toLocaleString() + '</td>' +
|
||
'<td><b>' + Number(servedShown).toLocaleString() + '</b></td>' +
|
||
'<td>' + (left != null ? left.toLocaleString() : '—') + '</td>' +
|
||
'<td>' + (l.hits != null ? l.hits : '—') + '</td>' +
|
||
'<td>' + statusCell + '</td>';
|
||
var act = document.createElement('td');
|
||
if (!c.stopped && l.live) {
|
||
var btn = document.createElement('button');
|
||
btn.className = 'btn btn-secondary btn-sm';
|
||
btn.textContent = 'Stop';
|
||
btn.title = 'Stop this banner and return the unserved impressions to your balance';
|
||
btn.addEventListener('click', function () { stopCampaign(c.adId, btn); });
|
||
act.appendChild(btn);
|
||
}
|
||
tr.appendChild(act);
|
||
tb.appendChild(tr);
|
||
});
|
||
$('tfLiveWrap').style.display = 'block';
|
||
}
|
||
|
||
function applyStatus(d) {
|
||
var st = d.status;
|
||
state.creatives = st.creatives || {};
|
||
state.id = d.id;
|
||
state.remaining = st.remaining;
|
||
if (typeof d.hasPage === 'boolean') state.hasPage = d.hasPage;
|
||
if (!state.hasPage && state.target === 'page') state.target = 'join';
|
||
if (!state.size) state.size = (st.sizes || [])[0] || null;
|
||
if (!state.creative) state.creative = (state.creatives[state.size] || [])[0] || null;
|
||
$('tfBal').style.display = 'flex';
|
||
$('tfRemain').textContent = Number(st.remaining).toLocaleString();
|
||
$('tfLimit').textContent = Number(st.limit).toLocaleString();
|
||
$('tfNote').innerHTML = 'Level ' + d.level + ' includes <b style="color:var(--text)">' +
|
||
Number(st.limit).toLocaleString() + '</b> impressions a month on the network. Each upgrade raises it — Apex is where it jumps to 50,000.';
|
||
$('tfImp').max = st.remaining;
|
||
$('tfImp').value = Math.min(Number($('tfImp').value) || st.remaining, st.remaining) || 100;
|
||
state.angles = d.textAngles || state.angles;
|
||
renderFormat(); renderAngles(); renderVariants();
|
||
showWriteMeter(d.textMeter);
|
||
if (d.writerReady === false) {
|
||
$('tfWrite').disabled = true;
|
||
$('tfWriteMeter').textContent = 'The writer is warming up.';
|
||
}
|
||
renderSizes(st.sizes || []); renderCreatives(); renderTargets(); goLabel();
|
||
renderLive(st.campaigns || [], d.live || []);
|
||
if (st.remaining <= 0) {
|
||
$('tfGo').disabled = true;
|
||
$('tfGo').textContent = 'Allowance used — refills on the 1st';
|
||
}
|
||
}
|
||
|
||
async function stopCampaign(adId, btn) {
|
||
if (btn) { btn.disabled = true; btn.textContent = 'Stopping…'; }
|
||
try {
|
||
var r = await fetch('/api/public/suite-traffic-stop', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ad_id: adId })
|
||
});
|
||
var d = await r.json();
|
||
if (!r.ok) {
|
||
$('tfErr').textContent = d.error || 'Could not stop that banner.';
|
||
$('tfErr').style.display = 'block';
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Stop'; }
|
||
return;
|
||
}
|
||
$('tfOk').innerHTML = '✅ <b>Banner stopped.</b> ' +
|
||
Number(d.stopped.refunded).toLocaleString() + ' unserved impressions went back into your balance' +
|
||
(d.stopped.served ? ' — it had served ' + Number(d.stopped.served).toLocaleString() + '.' : '.');
|
||
$('tfOk').style.display = 'block';
|
||
boot();
|
||
} catch (e) {
|
||
$('tfErr').textContent = 'Connection hiccup — try again.';
|
||
$('tfErr').style.display = 'block';
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Stop'; }
|
||
}
|
||
}
|
||
|
||
async function boot() {
|
||
try {
|
||
var r = await fetch('/api/public/suite-traffic');
|
||
if (r.status === 401) { gate('You’re not signed in yet. <a href="/suite" style="color:var(--gold);font-weight:700">Open the Suite</a> and connect the wallet that holds your position, then come back.'); return; }
|
||
if (r.status === 403) { gate('The Traffic Desk isn’t open for this position yet. <a href="/suite" style="color:var(--gold);font-weight:700">See your Suite</a>.'); return; }
|
||
if (!r.ok) return;
|
||
var d = await r.json();
|
||
if (!d.configured) { gate('The ad network bridge is being finalized — this opens shortly.'); return; }
|
||
applyStatus(d);
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function run() {
|
||
if (busy) return;
|
||
busy = true;
|
||
$('tfErr').style.display = 'none';
|
||
$('tfOk').style.display = 'none';
|
||
$('tfGo').disabled = true;
|
||
$('tfGo').innerHTML = '<span class="spin"></span>Launching on the network…';
|
||
|
||
var payload = { target: state.target, impressions: Number($('tfImp').value) || 0 };
|
||
if (state.format === 'text') {
|
||
var v = state.variants[state.variant];
|
||
if (!v) {
|
||
$('tfErr').textContent = 'Write some ads first, then pick the one you want to run.';
|
||
$('tfErr').style.display = 'block';
|
||
busy = false; $('tfGo').disabled = false; goLabel();
|
||
return;
|
||
}
|
||
payload.kind = 'text';
|
||
payload.subject = v.subject;
|
||
payload.lines = v.lines;
|
||
} else {
|
||
payload.kind = 'banner';
|
||
payload.size = state.size;
|
||
payload.creative = state.creative;
|
||
}
|
||
|
||
try {
|
||
var r = await fetch('/api/public/suite-traffic', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
var d = await r.json();
|
||
if (!r.ok) {
|
||
$('tfErr').textContent = d.error || 'That didn’t go through — try again.';
|
||
$('tfErr').style.display = 'block';
|
||
} else {
|
||
var what = d.campaign.kind === 'text' ? 'text ad' : 'banner';
|
||
$('tfOk').innerHTML = '✅ <b>Your ' + what + ' is live on the network.</b><br>' +
|
||
Number(d.campaign.impressions).toLocaleString() + ' impressions of ' +
|
||
(d.campaign.kind === 'text' ? 'your text ad' : d.campaign.size) +
|
||
' pointed at your ' + (d.campaign.target.indexOf('/p/') !== -1 ? 'personal page' : 'invite page') +
|
||
'. It starts rotating immediately — check back here to watch it serve.';
|
||
$('tfOk').style.display = 'block';
|
||
applyStatus({ status: d.status, level: 0, id: state.id, live: [], configured: true });
|
||
// refresh from the server so the running-banners table picks it up
|
||
setTimeout(boot, 800);
|
||
}
|
||
} catch (e) {
|
||
$('tfErr').textContent = 'Connection hiccup — try again.';
|
||
$('tfErr').style.display = 'block';
|
||
}
|
||
busy = false;
|
||
if (state.remaining > 0) { $('tfGo').disabled = false; goLabel(); }
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
boot();
|
||
$('tfGo').addEventListener('click', run);
|
||
$('tfWrite').addEventListener('click', function () { writeAds(this); });
|
||
});
|
||
})();
|