From f7276589e9b9eef47215631c99574d124462003a Mon Sep 17 00:00:00 2001 From: martbost Date: Fri, 28 Aug 2026 09:50:03 -0500 Subject: [PATCH] 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 --- public/suite-traffic.html | 79 +++++++++++++++++++ public/suite-traffic.js | 160 ++++++++++++++++++++++++++++++++++++++ public/suite.js | 2 +- server.js | 29 ++++++- suite-traffic.js | 153 ++++++++++++++++++++++++++++++++++++ 5 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 public/suite-traffic.html create mode 100644 public/suite-traffic.js create mode 100644 suite-traffic.js 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.

+ +
+ + + +
+ +
+ +
+ +
+ + +
Comes out of this month's allowance. It refills on the 1st.
+
+ +
+
+
+
+ + + +

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