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:
@@ -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
@@ -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');
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user