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>
This commit is contained in:
martbost
2026-08-28 10:03:38 -05:00
parent f7276589e9
commit 931eb4336b
4 changed files with 130 additions and 15 deletions
+1 -1
View File
@@ -69,7 +69,7 @@
<div class="tf-live" id="tfLiveWrap" style="display:none">
<h2 style="font-size:20px;margin:0 0 8px">Your running banners</h2>
<div class="table-wrap"><table><thead><tr><th>Size</th><th>Sends to</th><th>Bought</th><th>Served</th><th>Left</th><th>Clicks</th><th>Status</th></tr></thead><tbody id="tfLive"></tbody></table></div>
<div class="table-wrap"><table><thead><tr><th>Size</th><th>Sends to</th><th>Bought</th><th>Served</th><th>Left</th><th>Clicks</th><th>Status</th><th></th></tr></thead><tbody id="tfLive"></tbody></table></div>
</div>
<p class="tf-note"><strong style="color:var(--text)">What this is, honestly:</strong> display advertising is <strong style="color:var(--text)">awareness volume</strong> — it puts your link in front of people browsing the network. It is not a lead list, and click rates on display inventory are low by nature. The conversations you have with people you know are still what actually builds a team; this runs quietly in the background while you do that. <br><br>All banners use approved team creative, so every ad on the network stays on-brand. Independent team resource · No income is guaranteed · Cryptocurrency involves risk.</p>
+59 -8
View File
@@ -46,11 +46,21 @@
function renderTargets() {
var host = $('tfTargets');
host.innerHTML = '';
[['join', 'My invite page'], ['page', 'My personal page (/p/' + state.id + ')']].forEach(function (t) {
host.appendChild(chip(t[1], state.target === t[0], function () {
state.target = t[0]; renderTargets();
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) {
@@ -70,13 +80,26 @@
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.impressions).toLocaleString() + '</td>' +
'<td><b>' + Number(served).toLocaleString() + '</b></td>' +
'<td>' + (l.remaining != null ? Number(l.remaining).toLocaleString() : '—') + '</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>' + (l.live ? '<span style="color:var(--teal)">running</span>' : 'finished') + '</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';
@@ -87,6 +110,8 @@
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';
@@ -104,6 +129,32 @@
}
}
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');
+25 -2
View File
@@ -671,7 +671,13 @@ async function handleApi(req,res,pathname){
if(req.method==='GET'&&/^\/p\/\d{1,15}$/.test(pathname)){
const pid=pathname.split('/')[2];
const rec=suitePages.load(pid);
if(!rec||!rec.copy)return json(res,404,{error:'No page here yet.'});
// No custom page built yet? NEVER dead-end — especially not with JSON. Any
// /p/<id> link may already be on a banner, a flyer, or in someone's DMs, so
// it must always land somewhere useful: the member's own invite page.
if(!rec||!rec.copy){
res.writeHead(302,securityHeaders({'Location':'/join/'+pid,'Cache-Control':'no-store'}));
return res.end();
}
const html=suitePages.render(rec);
// Member pages must ALWAYS be viewable inside a frame — the builder previews
// them, and members share them into contexts that embed. Never send
@@ -774,7 +780,11 @@ async function handleApi(req,res,pathname){
const st=suiteTraffic.status(e.d.id,e.d.level);
let live=[];
try{ live=await suiteTraffic.stats(st.campaigns.map(c=>c.adId)); }catch(err){}
return json(res,200,{level:e.d.level,id:e.d.id,configured:suiteTraffic.configured(),status:st,live:live});
// Tell the UI whether a personal page actually exists, so it can't be
// offered as an ad destination before it's been built.
const pg=suitePages.load(e.d.id);
const hasPage=!!(pg&&pg.copy);
return json(res,200,{level:e.d.level,id:e.d.id,configured:suiteTraffic.configured(),status:st,live:live,hasPage:hasPage});
}
if(req.method==='POST'&&pathname==='/api/public/suite-traffic'){
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500}));
@@ -783,15 +793,28 @@ async function handleApi(req,res,pathname){
if(!suiteTraffic.configured())return json(res,503,{error:'The ad network bridge is warming up — try again shortly.'});
const b=await bodyJson(req)||{};
try{
const pgRec=suitePages.load(e.d.id);
const entry=await suiteTraffic.launch({
id:e.d.id, level:e.d.level, size:b.size, creative:b.creative,
impressions:b.impressions, target:b.target, angle:b.angle,
hasPage:!!(pgRec&&pgRec.copy),
name:String(b.name||'').slice(0,60)
});
return json(res,200,{campaign:entry,status:suiteTraffic.status(e.d.id,e.d.level)});
}catch(err){ return json(res,400,{error:String(err.message||err)}); }
}
if(req.method==='POST'&&pathname==='/api/public/suite-traffic-stop'){
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500}));
if(e.error)return json(res,e.code||500,{error:e.error});
if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'});
const b=await bodyJson(req)||{};
try{
const r=await suiteTraffic.stop(e.d.id,Number(b.ad_id));
return json(res,200,{stopped:r,status:suiteTraffic.status(e.d.id,e.d.level)});
}catch(err){ return json(res,400,{error:String(err.message||err)}); }
}
if(req.method==='GET'&&pathname==='/api/public/suite-meters'){
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup.',code:500}));
if(e.error)return json(res,e.code||500,{error:e.error});
+45 -4
View File
@@ -122,9 +122,14 @@ async function launch(opts) {
throw new Error('That is more than your remaining ' + st.remaining.toLocaleString() + ' impressions this month.');
}
const target = opts.target === 'page'
? 'https://rmcircle.team/p/' + opts.id
: 'https://rmcircle.team/join/' + opts.id + (opts.angle ? '?v=' + opts.angle : '');
// Server-side guard: only allow the personal page as a destination when one
// actually exists. /p/<id> also redirects to /join/<id> when empty, so a live
// banner can never dead-end — but we shouldn't create that situation at all.
let target = 'https://rmcircle.team/join/' + opts.id + (opts.angle ? '?v=' + opts.angle : '');
if (opts.target === 'page') {
if (!opts.hasPage) throw new Error('Build your personal page first, then you can point ads at it.');
target = 'https://rmcircle.team/p/' + opts.id;
}
const idem = 'rmc-' + opts.id + '-' + monthKey() + '-' + crypto.randomBytes(6).toString('hex');
const res = await callNas({
@@ -144,10 +149,46 @@ async function launch(opts) {
return entry;
}
// Stop a running banner and return the UNSERVED impressions to the member's
// monthly balance. Order matters: read the counters BEFORE deactivating,
// because deactivation zeroes `remaining` and would make it look fully served.
async function stop(memberId, adId) {
const all = readLedger();
const mk = monthKey();
const rows = ((all[mk] || {})[String(memberId)]) || [];
const row = rows.find(function (r) { return Number(r.adId) === Number(adId); });
if (!row) throw new Error('That banner is not one of yours from this month.');
if (row.stopped) throw new Error('That banner is already stopped.');
let served = 0, unserved = 0;
try {
const s = await callNas({ action: 'stats', ad_ids: [Number(adId)] });
const st = (s.stats || [])[0];
if (st) {
served = Math.max(0, Number(st.served) || 0);
unserved = Math.max(0, Number(st.remaining) || 0);
}
} catch (e) { /* if stats are unavailable, refund nothing rather than guess */ }
await callNas({ action: 'deactivate', ad_id: Number(adId) });
// Charge only what actually served; the rest returns to the allowance.
// `bought` preserves the original order size so the member's history still
// shows what they launched, not just what it ended up costing them.
if (row.bought == null) row.bought = row.impressions;
row.stopped = true;
row.stoppedAt = new Date().toISOString();
row.served = served;
row.refunded = unserved;
row.impressions = served; // what this campaign counts against the month
writeLedger(all);
return { adId: Number(adId), served: served, refunded: unserved };
}
async function stats(adIds) {
if (!adIds || !adIds.length) return [];
const r = await callNas({ action: 'stats', ad_ids: adIds.slice(0, 200) });
return r.stats || [];
}
module.exports = { init, configured, status, launch, stats, allowanceFor, sizes, CREATIVES, ALLOWANCE };
module.exports = { init, configured, status, launch, stop, stats, allowanceFor, sizes, CREATIVES, ALLOWANCE };