diff --git a/public/suite-traffic.html b/public/suite-traffic.html
index dde09d3..33a789b 100644
--- a/public/suite-traffic.html
+++ b/public/suite-traffic.html
@@ -69,7 +69,7 @@
Your running banners
-
| Size | Sends to | Bought | Served | Left | Clicks | Status |
|---|
+
| Size | Sends to | Bought | Served | Left | Clicks | Status | |
|---|
What this is, honestly: display advertising is awareness volume — 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.
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.
diff --git a/public/suite-traffic.js b/public/suite-traffic.js
index b3074f5..e491b8b 100644
--- a/public/suite-traffic.js
+++ b/public/suite-traffic.js
@@ -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? Build it first →';
+ 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 (' + Number(c.refunded || 0).toLocaleString() + ' returned)'
+ : (l.live ? 'running' : 'finished');
tr.innerHTML = '' + c.size + ' | ' +
'' + (c.target.indexOf('/p/') !== -1 ? 'personal page' : 'invite page') + ' | ' +
- '' + Number(c.impressions).toLocaleString() + ' | ' +
- '' + Number(served).toLocaleString() + ' | ' +
- '' + (l.remaining != null ? Number(l.remaining).toLocaleString() : '—') + ' | ' +
+ '' + Number(c.bought != null ? c.bought : c.impressions).toLocaleString() + ' | ' +
+ '' + Number(c.stopped ? (c.served || 0) : served).toLocaleString() + ' | ' +
+ '' + (c.stopped ? '—' : (l.remaining != null ? Number(l.remaining).toLocaleString() : '—')) + ' | ' +
'' + (l.hits != null ? l.hits : '—') + ' | ' +
- '' + (l.live ? 'running' : 'finished') + ' | ';
+ '' + statusCell + ' | ';
+ 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 = '✅ Banner stopped. ' +
+ 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');
diff --git a/server.js b/server.js
index eac5547..19e22fc 100644
--- a/server.js
+++ b/server.js
@@ -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/ 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});
diff --git a/suite-traffic.js b/suite-traffic.js
index 3046fbc..a5f0f17 100644
--- a/suite-traffic.js
+++ b/suite-traffic.js
@@ -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/ also redirects to /join/ 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 };