Traffic Desk ships: signed NAS bridge client, monthly impression allowance by level (2.5k Scintilla -> 150k Corona), team-kit creative only (no member uploads = no moderation risk), local grant ledger, live serve stats; tile moved to L1 since credits are included at every paid level

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-28 09:50:03 -05:00
parent 6c73b5d9c5
commit f7276589e9
5 changed files with 421 additions and 2 deletions
+79
View File
@@ -0,0 +1,79 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><meta name="description" content="Traffic Desk — put a team banner on our own ad network, pointed at your invite link. Monthly impressions included with your position."><title>Traffic Desk | The Circle Suite</title><link rel="icon" type="image/png" href="/favicon.png"><link rel="stylesheet" href="/styles.css">
<style>
.tw{max-width:940px;margin:0 auto;padding:24px 20px 70px}
.tw h1{font-size:clamp(26px,4.5vw,36px);margin:6px 0 8px}
.tw .lede{color:var(--muted);line-height:1.6;margin-bottom:18px}
#tfGate{display:none;border:2px solid var(--gold);border-radius:12px;padding:16px 20px;margin-bottom:18px;line-height:1.6}
.tf-bal{display:flex;gap:18px;flex-wrap:wrap;align-items:center;border:2px solid var(--teal);border-radius:14px;padding:14px 18px;margin-bottom:18px}
.tf-bal .big{font-size:30px;font-weight:900;color:var(--gold);font-variant-numeric:tabular-nums;line-height:1}
.tf-bal .lab{font-size:12px;font-weight:800;letter-spacing:1.4px;text-transform:uppercase;color:var(--muted);margin-top:3px}
.tf-bal .note{color:var(--muted);font-size:13px;line-height:1.5;flex:1;min-width:220px}
.tf-card{background:var(--card,#0d2236);border:1px solid var(--line,#24425d);border-radius:14px;padding:18px 20px;margin-bottom:16px}
label.tf-lab{display:block;font-weight:700;font-size:14px;margin:14px 0 6px}
label.tf-lab:first-of-type{margin-top:0}
.tf-chips{display:flex;flex-wrap:wrap;gap:8px}
.tf-chip{border:1px solid var(--line);background:transparent;color:var(--muted);border-radius:999px;
padding:7px 14px;font-size:13.5px;font-weight:700;cursor:pointer}
.tf-chip.on{border-color:var(--teal);background:rgba(78,214,203,.12);color:var(--teal)}
.tf-creatives{display:flex;flex-wrap:wrap;gap:10px;margin-top:4px}
.tf-cre{border:2px solid var(--line);border-radius:10px;padding:6px;cursor:pointer;background:var(--bg,#0b1d2e);line-height:0}
.tf-cre.on{border-color:var(--gold)}
.tf-cre img{max-width:230px;max-height:120px;width:auto;height:auto;display:block}
#tfImp{width:150px;background:var(--bg,#0b1d2e);border:1px solid var(--line);border-radius:10px;color:var(--text);padding:10px 12px;font-size:15px}
.tf-hint{color:var(--muted);font-size:12.5px;margin-top:6px}
.tf-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:16px}
#tfErr{display:none;color:#f0a05a;margin-top:12px;font-size:14px;line-height:1.55}
#tfOk{display:none;border:2px solid var(--teal);border-radius:12px;padding:14px 18px;margin-top:14px;line-height:1.6}
.tf-live{margin-top:22px}
.tf-live table{width:100%;border-collapse:collapse;font-size:14px}
.tf-live th{text-align:left;padding:8px 10px;font-size:11px;letter-spacing:1px;text-transform:uppercase;color:var(--muted);border-bottom:2px solid var(--line)}
.tf-live td{padding:9px 10px;border-bottom:1px solid var(--line);color:var(--muted);font-variant-numeric:tabular-nums}
.tf-live td b{color:var(--text)}
.tf-note{margin-top:22px;font-size:12.5px;color:var(--muted);line-height:1.6;border-top:1px solid var(--line);padding-top:12px}
.spin{display:inline-block;width:13px;height:13px;border:2px solid rgba(255,255,255,.25);border-top-color:var(--gold);
border-radius:50%;animation:sp .8s linear infinite;vertical-align:-2px;margin-right:7px}
@keyframes sp{to{transform:rotate(360deg)}}
@media (prefers-reduced-motion:reduce){.spin{animation:none}}
</style></head>
<body>
<header class="wrap nav"><a class="brand" href="/suite"><img class="brand-mark" src="/logo.jpg" alt="The RM Circle" width="42" height="42"><span><span id="brandName">RM Circle</span><small>Traffic Desk</small></span></a><div class="nav-actions"><a class="btn btn-secondary hide-mobile" href="/suite">← The Suite</a><a class="btn btn-primary" href="/my">My Dashboard</a></div></header>
<main class="tw">
<div class="eyebrow" style="color:var(--gold)">CIRCLE SUITE · INCLUDED AT EVERY PAID LEVEL</div>
<h1>Traffic <span class="gold">Desk.</span></h1>
<p class="lede">Put a team banner on our own advertising network, pointed at your invite link. Impressions are included with your position and scale as you level up — nothing to buy, nothing to configure.</p>
<div id="tfGate"></div>
<div class="tf-bal" id="tfBal" style="display:none">
<div><div class="big" id="tfRemain">—</div><div class="lab">impressions left this month</div></div>
<div><div class="big" id="tfLimit" style="color:var(--teal)">—</div><div class="lab">your monthly allowance</div></div>
<div class="note" id="tfNote"></div>
</div>
<div class="tf-card" id="tfCard">
<label class="tf-lab">Banner size</label>
<div class="tf-chips" id="tfSizes"></div>
<label class="tf-lab">Pick a design</label>
<div class="tf-creatives" id="tfCreatives"></div>
<label class="tf-lab">Where should it send people?</label>
<div class="tf-chips" id="tfTargets"></div>
<label class="tf-lab" for="tfImp">How many impressions?</label>
<input type="number" id="tfImp" min="100" step="100" value="2500">
<div class="tf-hint">Comes out of this month's allowance. It refills on the 1st.</div>
<div class="tf-row">
<button id="tfGo" class="btn btn-primary">🚦 Launch my banner</button>
</div>
<div id="tfErr"></div>
<div id="tfOk"></div>
</div>
<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>
<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>
</main>
<script src="/suite-traffic.js"></script>
<script src="/chat.js" defer></script>
<script src="/translate.js" defer></script><script src="/tg-app.js" defer></script><script src="/nav-dash.js" defer></script></body></html>
+160
View File
@@ -0,0 +1,160 @@
// 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 = '';
[['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();
}));
});
}
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;
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>' + (l.hits != null ? l.hits : '—') + '</td>' +
'<td>' + (l.live ? '<span style="color:var(--teal)">running</span>' : 'finished') + '</td>';
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 (!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 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);
});
})();
+1 -1
View File
@@ -18,7 +18,7 @@
{ lv: 3, ico: '📧', name: 'Email Engine', desc: 'Welcome series, follow-up sequences and broadcasts in the team voice — exportable to any autoresponder.', href: '/suite/email', live: true },
{ lv: 3, ico: '🎥', name: 'Video Maker', desc: 'The team’s master promo videos rendered with your personal end-card — your name, your QR, your link.', href: '/suite/video', live: true },
{ lv: 4, ico: '🗣️', name: 'Voice Profile + Funnels', desc: 'Output that sounds like YOU, plus multi-page funnels on your own team subdomain.', live: false },
{ lv: 5, ico: '🚦', name: 'Traffic Desk', desc: 'Syndicated network display advertising: monthly ad credits on the team’s own network, banner and text placements, rotator priority, and AI campaign packs.', live: false },
{ lv: 1, ico: '🚦', name: 'Traffic Desk', desc: 'Syndicated network display advertising — put a banner on the team’s own ad network. Monthly impressions scale with your level: 2,500 at Scintilla up to 150,000 at Corona.', href: '/suite/traffic', live: true },
{ lv: 6, ico: '🏭', name: 'Funnel Factory', desc: 'Complete hosted funnels with A/B variants, replay funnels (your team webinar as an on-demand registration page with a timed CTA), and a lead CRM.', live: false },
{ lv: 7, ico: '🧭', name: 'Leader Ops', desc: 'Team radar, AI coaching digests for your legs, and cohort training rooms.', live: false },
{ lv: 8, ico: '👑', name: 'Founder Desk', desc: 'Your own AI operator running your promotion, API access, and the inner circle.', live: false }
+28 -1
View File
@@ -23,6 +23,7 @@ const suitePages = require('./suite-pages'); suitePages.init({ dataDir: DATA_DIR
const suiteTools = require('./suite-tools');
const suiteEmail = require('./suite-email');
const suiteVideo = require('./suite-video'); suiteVideo.init({ dataDir: DATA_DIR, publicDir: PUBLIC_DIR });
const suiteTraffic = require('./suite-traffic'); suiteTraffic.init({ dataDir: DATA_DIR });
const tgbot = require('./tgbot');
tgbot.init({ dataDir: DATA_DIR, chain, getConfig, messages, baseUrl: 'https://rmcircle.team' });
const SESSION_TTL = 8 * 60 * 60 * 1000;
@@ -765,6 +766,32 @@ async function handleApi(req,res,pathname){
}catch(err){ return json(res,502,{error:String(err.message||err)}); }
}
// ── Traffic Desk ─────────────────────────────────────────────────────────
if(req.method==='GET'&&pathname==='/api/public/suite-traffic'){
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup.',code:500}));
if(e.error)return json(res,e.code||500,{error:e.error});
if(!e.inOrg||!e.allowed)return json(res,403,{error:'Not available for this position yet.'});
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});
}
if(req.method==='POST'&&pathname==='/api/public/suite-traffic'){
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.'});
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 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,
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==='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});
@@ -1098,7 +1125,7 @@ const server=http.createServer(async(req,res)=>{
if((mj=pathname.match(/^\/join\/(\d{1,15})$/)))return serveMemberPage(req,res,path.join(PUBLIC_DIR,'join.html'),'join',mj[1]);
}
let file;
if(pathname==='/')file=path.join(PUBLIC_DIR,'index.html');else if(pathname==='/app'||pathname==='/app/')file=path.join(PUBLIC_DIR,'app.html');else if(pathname==='/start'||pathname==='/start/')file=path.join(PUBLIC_DIR,'start.html');else if(pathname==='/training'||pathname==='/training/')file=path.join(PUBLIC_DIR,'training.html');else if(pathname==='/admin'||pathname==='/admin/')file=path.join(PUBLIC_DIR,'admin.html');else if(pathname==='/my'||pathname==='/my/'||/^\/my\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'my.html');else if(pathname==='/contract'||pathname==='/contract/')file=path.join(PUBLIC_DIR,'contract.html');else if(pathname==='/disclaimer'||pathname==='/disclaimer/')file=path.join(PUBLIC_DIR,'disclaimer.html');else if(pathname==='/privacy'||pathname==='/privacy/')file=path.join(PUBLIC_DIR,'privacy.html');else if(pathname==='/refunds'||pathname==='/refunds/')file=path.join(PUBLIC_DIR,'refunds.html');else if(pathname==='/how-pay-works'||pathname==='/how-pay-works/')file=path.join(PUBLIC_DIR,'how-pay-works.html');else if(pathname==='/tools'||pathname==='/tools/')file=path.join(PUBLIC_DIR,'tools.html');else if(pathname==='/fast-start'||pathname==='/fast-start/')file=path.join(PUBLIC_DIR,'fast-start.html');else if(pathname==='/flyers'||pathname==='/flyers/')file=path.join(PUBLIC_DIR,'flyers.html');else if(pathname==='/weekly-rhythm'||pathname==='/weekly-rhythm/')file=path.join(PUBLIC_DIR,'weekly-rhythm.html');else if(pathname==='/generation-pay'||pathname==='/generation-pay/')file=path.join(PUBLIC_DIR,'generation-pay.html');else if(pathname==='/suite'||pathname==='/suite/')file=path.join(PUBLIC_DIR,'suite.html');else if(pathname==='/suite/copy'||pathname==='/suite/copy/')file=path.join(PUBLIC_DIR,'suite-copy.html');else if(pathname==='/suite/page'||pathname==='/suite/page/')file=path.join(PUBLIC_DIR,'suite-page.html');else if(pathname==='/suite/email'||pathname==='/suite/email/')file=path.join(PUBLIC_DIR,'suite-email.html');else if(pathname==='/suite/video'||pathname==='/suite/video/')file=path.join(PUBLIC_DIR,'suite-video.html');else if(pathname==='/direct-join'||pathname==='/direct-join/'){if(!getSession(req)){res.writeHead(302,{Location:'/admin'});return res.end();}file=path.join(ROOT,'private','direct-join.html');}else if(pathname==='/join-now'||pathname==='/join-now/'){if(!getConfig().dappFallbackPublic){res.writeHead(302,{Location:'/start'});return res.end();}file=path.join(ROOT,'private','join-now.html');}else if(/^\/join\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'join.html');else if(pathname==='/join'||pathname==='/join/'){res.writeHead(302,{Location:'/join-now'});return res.end();}else{
if(pathname==='/')file=path.join(PUBLIC_DIR,'index.html');else if(pathname==='/app'||pathname==='/app/')file=path.join(PUBLIC_DIR,'app.html');else if(pathname==='/start'||pathname==='/start/')file=path.join(PUBLIC_DIR,'start.html');else if(pathname==='/training'||pathname==='/training/')file=path.join(PUBLIC_DIR,'training.html');else if(pathname==='/admin'||pathname==='/admin/')file=path.join(PUBLIC_DIR,'admin.html');else if(pathname==='/my'||pathname==='/my/'||/^\/my\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'my.html');else if(pathname==='/contract'||pathname==='/contract/')file=path.join(PUBLIC_DIR,'contract.html');else if(pathname==='/disclaimer'||pathname==='/disclaimer/')file=path.join(PUBLIC_DIR,'disclaimer.html');else if(pathname==='/privacy'||pathname==='/privacy/')file=path.join(PUBLIC_DIR,'privacy.html');else if(pathname==='/refunds'||pathname==='/refunds/')file=path.join(PUBLIC_DIR,'refunds.html');else if(pathname==='/how-pay-works'||pathname==='/how-pay-works/')file=path.join(PUBLIC_DIR,'how-pay-works.html');else if(pathname==='/tools'||pathname==='/tools/')file=path.join(PUBLIC_DIR,'tools.html');else if(pathname==='/fast-start'||pathname==='/fast-start/')file=path.join(PUBLIC_DIR,'fast-start.html');else if(pathname==='/flyers'||pathname==='/flyers/')file=path.join(PUBLIC_DIR,'flyers.html');else if(pathname==='/weekly-rhythm'||pathname==='/weekly-rhythm/')file=path.join(PUBLIC_DIR,'weekly-rhythm.html');else if(pathname==='/generation-pay'||pathname==='/generation-pay/')file=path.join(PUBLIC_DIR,'generation-pay.html');else if(pathname==='/suite'||pathname==='/suite/')file=path.join(PUBLIC_DIR,'suite.html');else if(pathname==='/suite/copy'||pathname==='/suite/copy/')file=path.join(PUBLIC_DIR,'suite-copy.html');else if(pathname==='/suite/page'||pathname==='/suite/page/')file=path.join(PUBLIC_DIR,'suite-page.html');else if(pathname==='/suite/email'||pathname==='/suite/email/')file=path.join(PUBLIC_DIR,'suite-email.html');else if(pathname==='/suite/video'||pathname==='/suite/video/')file=path.join(PUBLIC_DIR,'suite-video.html');else if(pathname==='/suite/traffic'||pathname==='/suite/traffic/')file=path.join(PUBLIC_DIR,'suite-traffic.html');else if(pathname==='/direct-join'||pathname==='/direct-join/'){if(!getSession(req)){res.writeHead(302,{Location:'/admin'});return res.end();}file=path.join(ROOT,'private','direct-join.html');}else if(pathname==='/join-now'||pathname==='/join-now/'){if(!getConfig().dappFallbackPublic){res.writeHead(302,{Location:'/start'});return res.end();}file=path.join(ROOT,'private','join-now.html');}else if(/^\/join\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'join.html');else if(pathname==='/join'||pathname==='/join/'){res.writeHead(302,{Location:'/join-now'});return res.end();}else{
const safe=path.normalize(pathname).replace(/^([.][.][/\\])+/, '').replace(/^[/\\]+/,'');file=path.join(PUBLIC_DIR,safe);if(!file.startsWith(PUBLIC_DIR))file='';
}
if(file&&staticFile(req,res,file))return;return staticFile(req,res,path.join(PUBLIC_DIR,'404.html'),404);
+153
View File
@@ -0,0 +1,153 @@
// Circle Suite — Traffic Desk. Places member banner ads on the team's own
// NetworkAdSpace network through the signed bridge API (rmc-api on that host).
//
// Two deliberate constraints:
// 1. Creative comes from the TEAM banner kit only. Members never upload art.
// That removes moderation risk entirely — every ad on the network carries
// approved branding — and it means a member can launch in two clicks.
// 2. Allowances scale with contract level and are counted per calendar month
// against a local ledger, so a member cannot spend more than their level.
'use strict';
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
let DATA_DIR = null;
function init(opts) { DATA_DIR = opts.dataDir; }
function credsFile() { return path.join(DATA_DIR, 'nas-api.json'); }
function creds() { try { return JSON.parse(fs.readFileSync(credsFile(), 'utf8')); } catch (e) { return null; } }
function configured() { const c = creds(); return !!(c && c.url && c.secret); }
// Monthly impressions by contract level (index = level-1). Marty-approved.
const ALLOWANCE = [2500, 5000, 10000, 20000, 50000, 75000, 100000, 150000];
function allowanceFor(level) {
const n = Math.max(1, Math.min(8, Number(level) || 1));
return ALLOWANCE[n - 1];
}
// Team banner kit — every ad uses approved creative, so nothing needs review.
const CREATIVES = {
'468x60': [ 'rmc-468x60-v1.png', 'rmc-468x60-v2.png', 'rmc-468x60-v3.png', 'rmc-468x60-v4.png',
'rmc-468x60-v5.png', 'rmc-468x60-v6.png', 'rmc-468x60-v7.png', 'rmc-growing-468x60-bluegreen.png' ],
'728x90': [ 'rmc-728x90-v1.png', 'rmc-728x90-v2.png', 'rmc-728x90-v3.png' ],
'300x250': [ 'rmc-300x250-v1.png', 'rmc-300x250-v3.png', 'rmc-300x250-v4.png' ],
'160x600': [ 'rmc-160x600-v1.png', 'rmc-160x600-v2.png', 'rmc-160x600-v3.png' ],
'120x600': [ 'rmc-120x600-v1.png', 'rmc-120x600-v2.png' ],
'125x125': [ 'rmc-banner-125x125.png' ]
};
function sizes() { return Object.keys(CREATIVES); }
// ── local ledger: what each position has been granted this month ────────────
function ledgerFile() { return path.join(DATA_DIR, 'traffic-grants.json'); }
function readLedger() { try { return JSON.parse(fs.readFileSync(ledgerFile(), 'utf8')); } catch (e) { return {}; } }
function writeLedger(v) { try { fs.writeFileSync(ledgerFile(), JSON.stringify(v)); } catch (e) {} }
function monthKey() { const d = new Date(); return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0'); }
function usage(memberId) {
const all = readLedger();
const rows = ((all[monthKey()] || {})[String(memberId)]) || [];
const used = rows.reduce(function (s, r) { return s + (Number(r.impressions) || 0); }, 0);
return { used: used, campaigns: rows };
}
function status(memberId, level) {
const limit = allowanceFor(level);
const u = usage(memberId);
return {
limit: limit, used: u.used, remaining: Math.max(0, limit - u.used),
campaigns: u.campaigns, resets: monthKey(), sizes: sizes(), creatives: CREATIVES
};
}
function record(memberId, entry) {
const all = readLedger();
const mk = monthKey();
if (!all[mk]) all[mk] = {};
const id = String(memberId);
if (!all[mk][id]) all[mk][id] = [];
all[mk][id].push(entry);
// keep three months of history; the file stays small forever
const keep = Object.keys(all).sort().slice(-3);
const trimmed = {};
keep.forEach(function (k) { trimmed[k] = all[k]; });
writeLedger(trimmed);
}
// ── signed call to the NAS bridge ───────────────────────────────────────────
function callNas(payload) {
return new Promise(function (resolve, reject) {
const c = creds();
if (!c) return reject(new Error('The ad network bridge is not configured yet.'));
const body = JSON.stringify(payload);
const ts = String(Math.floor(Date.now() / 1000));
const sig = crypto.createHmac('sha256', c.secret).update(ts + '.' + body).digest('hex');
const u = new URL(c.url);
const lib = u.protocol === 'https:' ? require('https') : require('http');
const req = lib.request({
hostname: u.hostname, port: u.port || (u.protocol === 'https:' ? 443 : 80),
path: u.pathname + (u.search || ''), method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(body),
'X-RMC-Signature': sig,
'X-RMC-Timestamp': ts,
'User-Agent': 'RMCircleSuite/1.0'
}
}, function (res) {
let data = '';
res.on('data', function (d) { data += d; });
res.on('end', function () {
let j = null;
try { j = JSON.parse(data); } catch (e) { return reject(new Error('The ad network returned something unexpected.')); }
if (!j.ok) return reject(new Error(j.error || 'The ad network rejected that.'));
resolve(j);
});
});
req.on('error', function (e) { reject(new Error('Could not reach the ad network: ' + e.message)); });
req.setTimeout(45000, function () { req.destroy(new Error('The ad network took too long.')); });
req.write(body); req.end();
});
}
async function launch(opts) {
const level = Number(opts.level) || 1;
const size = String(opts.size || '');
if (!CREATIVES[size]) throw new Error('Pick one of the available banner sizes.');
const file = String(opts.creative || '');
if (CREATIVES[size].indexOf(file) === -1) throw new Error('Pick one of the team banner designs.');
const impressions = Math.max(100, Math.min(allowanceFor(level), Number(opts.impressions) || 0));
const st = status(opts.id, level);
if (impressions > st.remaining) {
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 : '');
const idem = 'rmc-' + opts.id + '-' + monthKey() + '-' + crypto.randomBytes(6).toString('hex');
const res = await callNas({
action: 'create', member_id: Number(opts.id), idem_key: idem, kind: 'banner',
size: size, impressions: impressions, days: 365,
target_url: target,
banner_url: 'https://rmcircle.team/banners/' + file,
advertiser_name: (opts.name || 'RM Circle member #' + opts.id).slice(0, 60),
advertiser_email: '', catid: 5
});
const entry = {
adId: res.ad_id, impressions: impressions, size: size, creative: file,
target: target, at: new Date().toISOString()
};
record(opts.id, entry);
return entry;
}
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 };