26f2c91684
Marty: "if it's just gonna keep running, why would anyone use impressions to place the ad at all?" He is right, and my previous answer — calling the over-delivery a bonus — papered over a real flaw. Banner placements on this network have no cap. Left alone, 2,500 impressions buys an ad that runs until expiry, which makes the allowance decorative and the whole level ladder (2,500 at Scintilla to 150,000 at Corona) worth nothing. Nobody would ever spend the larger allowance for an identical outcome. Two changes so the impressions are genuinely the thing being spent: sweepCompleted() deactivates any campaign that has served its purchased amount. Runs 90s after boot and every 15 minutes, batches its stat reads, and only touches campaigns that are live, un-stopped and provably at or past what was bought. Nothing is refunded — they delivered in full. It re-reads the ledger after the network calls, since deactivating takes real time and another member may have launched in that window. New campaigns get days:30 instead of days:365. The allowance is monthly, so a campaign outliving the month it was paid from is the same bug by another route. Belt and braces — whichever ends it first. Copy corrected too: "still serving as a bonus" became "served in full — closing out", and the footnote now states plainly that a campaign ends when it has served what was bought. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
363 lines
15 KiB
JavaScript
363 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');
|
||
var served = l.served != null ? l.served : 0;
|
||
// The ad network's own counters sometimes drift ABOVE what we bought —
|
||
// its remaining has been observed at 3,521 on a 2,500 purchase. The cause
|
||
// is inside the ionCube-encoded ad app, so we cannot fix it at source; we
|
||
// can stop it rendering as nonsense. Clamp to what the member actually
|
||
// bought so the row always reconciles: served + left = bought. Any genuine
|
||
// over-delivery is a bonus to them and needs no explanation on this table.
|
||
// The network counts `remaining` DOWN for text ads and UP for banners —
|
||
// measured, not documented. Reading both the same way showed every banner
|
||
// as 0 served while it was quietly delivering thousands.
|
||
var bought = Number(c.bought != null ? c.bought : c.impressions) || 0;
|
||
var rawRem = l.remaining != null ? Number(l.remaining) : null;
|
||
var servedShown, left;
|
||
if (rawRem == null) { servedShown = Number(served) || 0; left = null; }
|
||
else if (c.kind === 'text') { left = Math.max(0, Math.min(rawRem, bought)); servedShown = bought - left; }
|
||
else { servedShown = Math.max(0, Math.min(rawRem, bought)); left = bought - servedShown; }
|
||
// "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(c.stopped ? (c.served || 0) : servedShown).toLocaleString() + '</b></td>' +
|
||
'<td>' + (c.stopped ? '—' : (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); });
|
||
});
|
||
})();
|