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 @@

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 };