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:
@@ -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>
|
||||
@@ -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
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user