Files
rm-circle-team-router/public/suite-traffic.js
T
martbost 931eb4336b Traffic Desk: stop-and-refund, plus never let a banner dead-end
Three fixes from live testing of the Traffic Desk:

- /p/<id> with no page built now 302s to /join/<id> instead of returning
  raw JSON 404. Any /p/ link already on a banner, flyer, or in a DM must
  always land somewhere useful.
- The personal-page ad destination is only offered (client) and only
  accepted (server) once a page actually exists; otherwise the member is
  pointed at the Page Builder.
- Members can stop a running banner and get the unserved impressions back
  in their monthly balance. Counters are read before deactivation, since
  deactivating zeroes `remaining` and would look fully served.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 10:03:38 -05:00

212 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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); };
var state = { size: null, creative: null, target: 'join', creatives: {}, remaining: 0, id: null };
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 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;
var statusCell = c.stopped
? 'stopped <span style="opacity:.7">(' + Number(c.refunded || 0).toLocaleString() + ' returned)</span>'
: (l.live ? '<span style="color:var(--teal)">running</span>' : 'finished');
tr.innerHTML = '<td><b>' + c.size + '</b></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) : served).toLocaleString() + '</b></td>' +
'<td>' + (c.stopped ? '—' : (l.remaining != null ? Number(l.remaining).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;
renderSizes(st.sizes || []); renderCreatives(); renderTargets();
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…';
try {
var r = await fetch('/api/public/suite-traffic', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
size: state.size, creative: state.creative, target: state.target,
impressions: Number($('tfImp').value) || 0
})
});
var d = await r.json();
if (!r.ok) {
$('tfErr').textContent = d.error || 'That didn’t go through — try again.';
$('tfErr').style.display = 'block';
} else {
$('tfOk').innerHTML = '✅ <b>Your banner is live on the network.</b><br>' +
Number(d.campaign.impressions).toLocaleString() + ' impressions of ' + 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; $('tfGo').textContent = '🚦 Launch my banner'; }
}
document.addEventListener('DOMContentLoaded', function () {
boot();
$('tfGo').addEventListener('click', run);
});
})();