diff --git a/public/suite-traffic.html b/public/suite-traffic.html
new file mode 100644
index 0000000..dde09d3
--- /dev/null
+++ b/public/suite-traffic.html
@@ -0,0 +1,79 @@
+
Traffic Desk | The Circle Suite
+
+
+
+
+ CIRCLE SUITE · INCLUDED AT EVERY PAID LEVEL
+ Traffic Desk.
+ 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.
+
+
+
+
+
—
impressions left this month
+
+
+
+
+
+
Banner size
+
+
Pick a design
+
+
Where should it send people?
+
+
How many impressions?
+
+
Comes out of this month's allowance. It refills on the 1st.
+
+ 🚦 Launch my banner
+
+
+
+
+
+
+
Your running banners
+
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
new file mode 100644
index 0000000..b3074f5
--- /dev/null
+++ b/public/suite-traffic.js
@@ -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 = '' + 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() : '—') + ' ' +
+ '' + (l.hits != null ? l.hits : '—') + ' ' +
+ '' + (l.live ? 'running ' : 'finished') + ' ';
+ 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 ' +
+ Number(st.limit).toLocaleString() + ' 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. Open the Suite 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. See your Suite .'); 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 = ' 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 = '✅ Your banner is live on the network. ' +
+ 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);
+ });
+})();
diff --git a/public/suite.js b/public/suite.js
index e4d411a..a8c26e4 100644
--- a/public/suite.js
+++ b/public/suite.js
@@ -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 }
diff --git a/server.js b/server.js
index a6ec70d..eac5547 100644
--- a/server.js
+++ b/server.js
@@ -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);
diff --git a/suite-traffic.js b/suite-traffic.js
new file mode 100644
index 0000000..3046fbc
--- /dev/null
+++ b/suite-traffic.js
@@ -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 };