From 6a7c5161ffccb7b867770f3adcfbca6dd3a8c095 Mon Sep 17 00:00:00 2001 From: martbost Date: Fri, 28 Aug 2026 12:26:39 -0500 Subject: [PATCH] Give the top of the ladder a real prize: Team Grants + Network Intelligence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Marty's read was right — the upper tiers felt flat, and the reason is that they scaled QUANTITY, not KIND. L1->L2 was a category change (you couldn't write, now you can). Everything above L4 was a multiplier on a capability already owned at L2: sound more like you, measure it, more pages, five at once. Meanwhile the price doubles each rung — Corona costs ~17x Culmen. No batch button is worth 17x a voice profile. The fix is a change of axis. Every tool from L1 to L8 was a "me" tool, but past the first levels this business isn't about you: get two, help your two, teach them to teach. So the top now operates on the ORGANISATION. L7 Team Grants — hand Suite capacity down to anyone in your own org. Deliberately from a separate pool that the level grants, NOT out of the leader's own allowance: making someone choose between equipping their team and using their own tools means nobody ever grants anything. Recipients are verified in-org against the contract. Grants can lift a tool the recipient's level hasn't unlocked — that's the point, not a loophole. Being helped is made visible to the recipient, since half the value is knowing an upline backed you. L8 Network Intelligence — every team ad pooled and anonymised: which angles, headlines, creative and formats actually earn their impressions. No member alone has enough traffic to learn anything from display; together they do. It can't be bought, and it compounds monthly with no further work. Holds two lines: nothing identifies anyone, and nothing under 2,000 impressions gets a reported rate — under-evidenced rows are counted and disclosed rather than silently dropped. The written readout is hand-authored, not generated: these are conclusions about money, and a model paraphrasing a table is exactly where an invented number slips in. Co-Authored-By: Claude Fable 5 --- public/suite-grants.html | 68 +++++++++++++++ public/suite-grants.js | 160 ++++++++++++++++++++++++++++++++++ public/suite-intel.html | 50 +++++++++++ public/suite-intel.js | 89 +++++++++++++++++++ public/suite.js | 2 + server.js | 43 +++++++++- suite-grants.js | 143 +++++++++++++++++++++++++++++++ suite-intel.js | 179 +++++++++++++++++++++++++++++++++++++++ suite-meter.js | 17 +++- suite-tools.js | 8 +- suite-traffic.js | 12 ++- 11 files changed, 760 insertions(+), 11 deletions(-) create mode 100644 public/suite-grants.html create mode 100644 public/suite-grants.js create mode 100644 public/suite-intel.html create mode 100644 public/suite-intel.js create mode 100644 suite-grants.js create mode 100644 suite-intel.js diff --git a/public/suite-grants.html b/public/suite-grants.html new file mode 100644 index 0000000..80b6472 --- /dev/null +++ b/public/suite-grants.html @@ -0,0 +1,68 @@ +Team Grants | The Circle Suite + + + +
+
CIRCLE SUITE · VERTEX (LEVEL 7)
+

Team Grants.

+

Every other tool in the Suite helps you. This one helps the people under you. Hand your own capacity — ad impressions, copy generations, page builds — to anyone in your organisation who is stuck without it.

+ +
+
+ + + +

Grants come out of a separate monthly pool that your level gives you — giving to your team never eats into your own allowances, because nobody should have to choose between equipping their people and using their own tools. Pools reset on the 1st along with everything else, so a grant is for unblocking somebody now rather than building a balance.

Independent team resource · No income is guaranteed · Cryptocurrency involves risk.

+
+ + + diff --git a/public/suite-grants.js b/public/suite-grants.js new file mode 100644 index 0000000..d8d7c00 --- /dev/null +++ b/public/suite-grants.js @@ -0,0 +1,160 @@ +// Team Grants client. +(function () { + 'use strict'; + var $ = function (id) { return document.getElementById(id); }; + function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + function n(v) { return Number(v || 0).toLocaleString(); } + + var state = { tool: null, status: null, level: 0, minLevel: 7 }; + + function gate(msg) { + $('gGate').style.display = 'block'; + $('gGate').innerHTML = msg; + } + + // Shown to everyone, including people below level 7 — being helped should be + // visible, not silent. + function renderReceived(received) { + var keys = Object.keys(received || {}); + if (!keys.length) { $('gGot').innerHTML = ''; return; } + var parts = keys.map(function (k) { + var r = received[k]; + var who = r.from.map(function (f) { return '#' + f.by; }); + var uniq = who.filter(function (v, i) { return who.indexOf(v) === i; }); + var t = (state.status && state.status.tools[k]) || {}; + return '
· ' + n(r.total) + ' ' + esc(t.unit || k) + + ' of ' + esc((t.label || k).toLowerCase()) + ', from ' + uniq.join(', ') + '
'; + }); + $('gGot').innerHTML = '
Someone above you topped you up this month.' + + parts.join('') + '
' + + 'It is already added to your allowances — nothing to claim.
'; + } + + function renderPools() { + var host = $('gPools'); + host.innerHTML = ''; + var tools = state.status.tools; + Object.keys(tools).forEach(function (k) { + var t = tools[k]; + var d = document.createElement('div'); + d.className = 'g-pool' + (state.tool === k ? ' on' : ''); + d.innerHTML = '
' + n(t.remaining) + '
' + + '
' + esc(t.label) + '
' + + '
left to give of ' + n(t.pool) + '
'; + d.addEventListener('click', function () { + state.tool = k; + renderPools(); + var el = $('gN'); + el.max = Math.min(t.max, t.remaining); + el.step = t.step; + el.value = Math.min(t.step, t.remaining) || ''; + $('gNHint').textContent = 'Up to ' + n(Math.min(t.max, t.remaining)) + ' ' + t.unit + + ' in one go. You have ' + n(t.remaining) + ' left to give this month.'; + }); + host.appendChild(d); + }); + } + + function renderHistory(hist) { + if (!hist || !hist.length) { $('gHistWrap').style.display = 'none'; return; } + $('gHistWrap').style.display = 'block'; + var tb = $('gHist'); + tb.innerHTML = ''; + hist.forEach(function (h) { + var t = (state.status.tools[h.tool]) || {}; + var tr = document.createElement('tr'); + tr.innerHTML = '#' + esc(h.to) + '' + + '' + esc(t.label || h.tool) + '' + + '' + n(h.n) + ' ' + esc(t.unit || '') + '' + + '' + esc(String(h.at || '').slice(0, 10)) + ''; + tb.appendChild(tr); + }); + } + + async function give() { + $('gErr').style.display = 'none'; + $('gOk').style.display = 'none'; + if (!state.tool) { + $('gErr').textContent = 'Pick what you want to give first.'; + $('gErr').style.display = 'block'; + return; + } + var to = Number($('gTo').value) || 0; + var amount = Number($('gN').value) || 0; + if (!to) { + $('gErr').textContent = 'Enter the position number you want to help.'; + $('gErr').style.display = 'block'; + return; + } + $('gGo').disabled = true; + $('gGo').innerHTML = 'Sending…'; + try { + var r = await fetch('/api/public/suite-grants', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ to: to, tool: state.tool, n: amount }) + }); + var d = await r.json(); + if (!r.ok) { + $('gErr').textContent = d.error || 'That did not go through.'; + $('gErr').style.display = 'block'; + } else { + state.status = d.status; + var t = state.status.tools[d.granted.tool] || {}; + $('gOk').innerHTML = '✅ Sent. #' + esc(d.granted.to) + ' now has ' + n(d.granted.n) + ' more ' + + esc(t.unit || '') + ' this month. Worth telling them — it will just appear in their allowance otherwise.'; + $('gOk').style.display = 'block'; + renderPools(); + renderHistory(state.status.history); + } + } catch (e) { + $('gErr').textContent = 'Connection hiccup — try again.'; + $('gErr').style.display = 'block'; + } + $('gGo').disabled = false; + $('gGo').innerHTML = '🎁 Give it to them'; + } + + async function boot() { + try { + var r = await fetch('/api/public/suite-grants'); + 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) { + var g = await r.json(); + gate((g.error || 'Not open for this position yet.') + ' See your Suite.'); + return; + } + if (!r.ok) return; + var d = await r.json(); + state.status = d.status; + state.level = d.level; + state.minLevel = d.minLevel; + + renderReceived(d.status.received); + + if (!d.status.canGrant) { + gate('Team Grants unlock at Vertex (level 7). At that point you can hand your own Suite capacity ' + + 'down to anyone in your organisation who needs it. Anything granted to you shows above and is already in your allowances.'); + return; + } + $('gBody').style.display = 'block'; + var first = Object.keys(d.status.tools)[0]; + state.tool = first; + renderPools(); + var t = d.status.tools[first]; + $('gN').max = Math.min(t.max, t.remaining); + $('gN').step = t.step; + $('gN').value = Math.min(t.step, t.remaining) || ''; + $('gNHint').textContent = 'Up to ' + n(Math.min(t.max, t.remaining)) + ' ' + t.unit + ' in one go.'; + renderHistory(d.status.history); + } catch (e) {} + } + + document.addEventListener('DOMContentLoaded', function () { + boot(); + $('gGo').addEventListener('click', give); + }); +})(); diff --git a/public/suite-intel.html b/public/suite-intel.html new file mode 100644 index 0000000..918ac52 --- /dev/null +++ b/public/suite-intel.html @@ -0,0 +1,50 @@ +Network Intelligence | The Circle Suite + + + +
+
CIRCLE SUITE · CORONA (LEVEL 8)
+

Network Intelligence.

+

Every member running ads is answering the same question: which words make a stranger click. On their own, none of them has enough traffic to know. Pooled across the whole team, it becomes an answer — and one nobody outside this team can buy.

+ +
+
Reading the network…
+ + + +

Nothing here identifies anybody. No positions, no names, no per-person numbers — this is a picture of what works, not a leaderboard of who is working. Groupings with too little traffic behind them are held back and counted rather than shown, because a click rate built on a few hundred impressions is noise dressed up as insight.

Independent team resource · No income is guaranteed · Cryptocurrency involves risk.

+
+ + + diff --git a/public/suite-intel.js b/public/suite-intel.js new file mode 100644 index 0000000..6be2566 --- /dev/null +++ b/public/suite-intel.js @@ -0,0 +1,89 @@ +// Network Intelligence client — renders the pooled, anonymised ad results. +(function () { + 'use strict'; + var $ = function (id) { return document.getElementById(id); }; + function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + function n(v) { return Number(v || 0).toLocaleString(); } + function pct(r) { return (Number(r || 0) * 100).toFixed(3) + '%'; } + + function gate(msg) { + $('iLoading').style.display = 'none'; + $('iGate').style.display = 'block'; + $('iGate').innerHTML = msg; + } + + function tile(v, label, cls) { + return '
' + v + '
' + label + '
'; + } + + function section(title, why, group, unitLabel) { + if (!group || !group.rows.length) return ''; + var top = group.rows[0].rate || 1; + var rows = group.rows.map(function (r) { + var w = Math.max(2, Math.round((r.rate / top) * 100)); + return '' + esc(r.label) + '' + + '' + n(r.served) + '' + + '' + n(r.clicks) + '' + + '' + pct(r.rate) + '' + + '
'; + }).join(''); + var thin = group.thin + ? '
' + group.thin + ' more not shown — under the evidence threshold (' + + n(group.thinServed) + ' impressions between them).
' + : ''; + return '

' + esc(title) + '

' + esc(why) + '
' + + '
' + + rows + '
' + esc(unitLabel) + + 'ServedClicksRate
' + thin + '
'; + } + + function render(d) { + var rep = d.report; + $('iLoading').style.display = 'none'; + if (!rep || !rep.ready) { + gate('Not enough data yet. ' + esc((rep && rep.reason) || 'This fills in as the team advertises.')); + return; + } + $('iBody').style.display = 'block'; + + $('iTiles').innerHTML = + tile(n(rep.totals.ads), 'team ads measured', 'cool') + + tile(n(rep.totals.served), 'impressions served') + + tile(n(rep.totals.clicks), 'clicks', 'cool') + + tile(pct(rep.totals.rate), 'network click rate'); + + $('iReadout').innerHTML = (d.readout || []).map(function (line) { + return '
' + esc(line) + '
'; + }).join('') || '
Not enough evidence yet to draw conclusions.
'; + + var html = ''; + html += section('Format', 'Text placements against banner placements, across everything the team has run.', rep.format, 'Format'); + html += section('Angle', 'Which hook the destination link carried. This is the one worth acting on — it tells you what to lead with everywhere, not just in ads.', rep.angle, 'Angle'); + html += section('Banner creative', 'Which designs from the team kit are actually earning their impressions.', rep.creative, 'Design'); + html += section('Banner size', 'Placement size, pooled across every design.', rep.size, 'Size'); + html += section('Text ad headlines', 'Individual headlines that have run enough to be judged.', rep.headline, 'Headline'); + $('iSections').innerHTML = html || '
Nothing has crossed the evidence threshold yet. Keep the ads running.
'; + } + + async function boot() { + try { + var r = await fetch('/api/public/suite-intel'); + 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) { + var g = await r.json(); + gate((g.error || 'Not open for this position yet.') + ' See your Suite.'); + return; + } + if (!r.ok) { gate('Could not read the network just now — try again shortly.'); return; } + render(await r.json()); + } catch (e) { + gate('Connection hiccup — reload to try again.'); + } + } + + document.addEventListener('DOMContentLoaded', boot); +})(); diff --git a/public/suite.js b/public/suite.js index e4a8de8..683c85a 100644 --- a/public/suite.js +++ b/public/suite.js @@ -21,6 +21,8 @@ { lv: 5, ico: '🔬', name: 'Split Tester', desc: 'Run two or three versions of an ad head to head and find out which wording people actually click. Apex is where your impressions jump to 50,000 — enough for the answer to mean something.', href: '/suite/split', live: true }, { lv: 1, ico: '🚦', name: 'Traffic Desk', desc: 'Syndicated network display advertising — run banners or AI-written text ads 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: 'A separate hosted landing page for every audience you talk to — each on its own address, each one something you can point a different ad at.', href: '/suite/funnel', live: true }, + { lv: 7, ico: '🎁', name: 'Team Grants', desc: 'Hand Suite capacity down into your own organisation — impressions, generations, page builds. Your level stops being something you bought for yourself and becomes something you give your team.', href: '/suite/grants', live: true }, + { lv: 8, ico: '🛰️', name: 'Network Intelligence', desc: 'What is actually working across every ad the whole team runs — best angles, headlines and creative, pooled and anonymised. Nobody can buy this; it is the team\'s own accumulated result, and it compounds every month.', href: '/suite/intel', live: true }, { lv: 7, ico: '🧭', name: 'Leader Ops', desc: 'Everyone below you, read live from the contract and sorted by who needs you most — plus a written coaching plan you can teach forward to your own two.', href: '/suite/leader', live: true }, { lv: 8, ico: '👑', name: 'Founder Desk', desc: 'A whole week of promotion built in one pass — posts, outreach messages, an email and text ads — plus an API key to pull your position, your organisation and the writer into anything you already run.', href: '/suite/founder', live: true } ]; diff --git a/server.js b/server.js index 16c5d81..31afef5 100644 --- a/server.js +++ b/server.js @@ -29,6 +29,8 @@ const suiteVoice = require('./suite-voice'); suiteVoice.init({ dataDir: DATA_DIR const suiteSplit = require('./suite-split'); suiteSplit.init({ dataDir: DATA_DIR }); const suiteLeader = require('./suite-leader'); const suiteFounder = require('./suite-founder'); suiteFounder.init({ dataDir: DATA_DIR }); +const suiteGrants = require('./suite-grants'); suiteGrants.init({ dataDir: DATA_DIR }); +const suiteIntel = require('./suite-intel'); suiteIntel.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; @@ -1021,6 +1023,45 @@ async function handleApi(req,res,pathname){ return json(res,404,{error:'Unknown endpoint. Available: GET /api/suite/v1/me, GET /api/suite/v1/team, POST /api/suite/v1/generate'}); } + // -- Team Grants (level 7) ------------------------------------------------- + if(pathname==='/api/public/suite-grants'){ + 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(req.method==='GET'){ + // Recipients see what they were given even below level 7 - that is the + // half of the feature that makes being helped visible. + return json(res,200,{status:suiteGrants.status(e.d.id,e.d.level),tools:suiteGrants.tools(), + minLevel:suiteGrants.MIN_LEVEL,level:e.d.level,id:e.d.id}); + } + if(req.method==='POST'){ + if(Number(e.d.level)({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(Number(e.d.level)({error:'Chain read hiccup — try again.',code:500})); if(e.error)return json(res,e.code||500,{error:e.error}); @@ -1348,7 +1389,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==='/suite/traffic'||pathname==='/suite/traffic/')file=path.join(PUBLIC_DIR,'suite-traffic.html');else if(pathname==='/suite/voice'||pathname==='/suite/voice/')file=path.join(PUBLIC_DIR,'suite-voice.html');else if(pathname==='/suite/split'||pathname==='/suite/split/')file=path.join(PUBLIC_DIR,'suite-split.html');else if(pathname==='/suite/funnel'||pathname==='/suite/funnel/')file=path.join(PUBLIC_DIR,'suite-funnel.html');else if(pathname==='/suite/leader'||pathname==='/suite/leader/')file=path.join(PUBLIC_DIR,'suite-leader.html');else if(pathname==='/suite/founder'||pathname==='/suite/founder/')file=path.join(PUBLIC_DIR,'suite-founder.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==='/suite/voice'||pathname==='/suite/voice/')file=path.join(PUBLIC_DIR,'suite-voice.html');else if(pathname==='/suite/split'||pathname==='/suite/split/')file=path.join(PUBLIC_DIR,'suite-split.html');else if(pathname==='/suite/funnel'||pathname==='/suite/funnel/')file=path.join(PUBLIC_DIR,'suite-funnel.html');else if(pathname==='/suite/leader'||pathname==='/suite/leader/')file=path.join(PUBLIC_DIR,'suite-leader.html');else if(pathname==='/suite/founder'||pathname==='/suite/founder/')file=path.join(PUBLIC_DIR,'suite-founder.html');else if(pathname==='/suite/grants'||pathname==='/suite/grants/')file=path.join(PUBLIC_DIR,'suite-grants.html');else if(pathname==='/suite/intel'||pathname==='/suite/intel/')file=path.join(PUBLIC_DIR,'suite-intel.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-grants.js b/suite-grants.js new file mode 100644 index 0000000..03ecd97 --- /dev/null +++ b/suite-grants.js @@ -0,0 +1,143 @@ +// Circle Suite — Team Grants (Vertex L7, Corona L8). +// +// The problem this fixes: every tool from L1 to L8 was a "me" tool — help ME +// write, help ME post, help ME measure. But past the first couple of levels the +// business is not about you. It is get two, help your two, teach them to teach. +// So the top of the ladder was scaling the wrong axis: more of your own +// capability, at double the price each rung. +// +// A grant lets a leader hand Suite capacity DOWN into their own organisation — +// impressions, generations, page builds, ad batches. Three things make it work: +// +// 1. It comes from a POOL that is a perk of the level, not a tax on the +// leader's own allowance. Making leaders choose between equipping their +// team and using their own tools would just mean nobody grants anything. +// 2. It can only go to someone inside the grantor's own organisation, checked +// against the contract. This is not a way to hand capacity to strangers. +// 3. It expires with the month, like every other allowance here. Grants are +// for unblocking someone now, not for building up a balance. +'use strict'; +const fs = require('fs'); +const path = require('path'); + +let DATA_DIR = null; +function init(opts) { DATA_DIR = opts.dataDir; } +function file() { return path.join(DATA_DIR, 'team-grants.json'); } +function readAll() { try { return JSON.parse(fs.readFileSync(file(), 'utf8')); } catch (e) { return {}; } } +function writeAll(v) { try { fs.writeFileSync(file(), JSON.stringify(v)); } catch (e) {} } +function monthKey() { const d = new Date(); return d.getUTCFullYear() + '-' + String(d.getUTCMonth() + 1).padStart(2, '0'); } + +const MIN_LEVEL = 7; + +// What a leader can give away each month, by level (index = level - 1). +// Zero below Vertex — this is the thing the top of the ladder buys. +const POOLS = { + traffic: { label: 'Ad impressions', unit: 'impressions', step: 1000, max: 50000, + pool: [0, 0, 0, 0, 0, 0, 60000, 200000] }, + copy: { label: 'Copy Engine generations', unit: 'generations', step: 25, max: 300, + pool: [0, 0, 0, 0, 0, 0, 300, 1000] }, + page: { label: 'Page builds', unit: 'pages', step: 1, max: 20, + pool: [0, 0, 0, 0, 0, 0, 15, 50] }, + textad: { label: 'Text ad batches', unit: 'batches', step: 5, max: 60, + pool: [0, 0, 0, 0, 0, 0, 60, 200] } +}; +function tools() { + return Object.keys(POOLS).map(function (k) { + return { key: k, label: POOLS[k].label, unit: POOLS[k].unit, step: POOLS[k].step, max: POOLS[k].max }; + }); +} + +function poolFor(tool, level) { + const t = POOLS[tool]; + if (!t) return 0; + const lv = Math.max(1, Math.min(8, Number(level) || 1)); + return t.pool[lv - 1] || 0; +} + +function rows(mk) { + const all = readAll(); + return all[mk || monthKey()] || []; +} + +// How much of `tool` this position has RECEIVED this month. +function receivedBy(memberId, tool) { + return rows().reduce(function (s, r) { + return s + ((Number(r.to) === Number(memberId) && r.tool === tool) ? Number(r.n) || 0 : 0); + }, 0); +} + +// How much of `tool` this position has GIVEN AWAY this month. +function givenBy(memberId, tool) { + return rows().reduce(function (s, r) { + return s + ((Number(r.by) === Number(memberId) && r.tool === tool) ? Number(r.n) || 0 : 0); + }, 0); +} + +// Who gave it to them — so a recipient can see it came from their upline and +// not from nowhere. That is half the point: it should feel like being helped. +function receivedDetail(memberId) { + const out = {}; + rows().forEach(function (r) { + if (Number(r.to) !== Number(memberId)) return; + if (!out[r.tool]) out[r.tool] = { total: 0, from: [] }; + out[r.tool].total += Number(r.n) || 0; + out[r.tool].from.push({ by: Number(r.by), n: Number(r.n), at: r.at }); + }); + return out; +} + +function status(memberId, level) { + const t = {}; + Object.keys(POOLS).forEach(function (k) { + const pool = poolFor(k, level); + const given = givenBy(memberId, k); + t[k] = { + label: POOLS[k].label, unit: POOLS[k].unit, step: POOLS[k].step, max: POOLS[k].max, + pool: pool, given: given, remaining: Math.max(0, pool - given) + }; + }); + return { + level: Number(level) || 0, canGrant: Number(level) >= MIN_LEVEL, + tools: t, resets: monthKey(), + history: rows().filter(function (r) { return Number(r.by) === Number(memberId); }).slice(-40).reverse(), + received: receivedDetail(memberId) + }; +} + +// Hand capacity to someone. The caller is responsible for having verified that +// `to` is inside `by`'s organisation — that check needs the chain and lives in +// the route, not here. +function grant(opts) { + const by = Number(opts.by), to = Number(opts.to); + const tool = String(opts.tool || ''); + const n = Math.floor(Number(opts.n) || 0); + const level = Number(opts.level) || 0; + + if (!POOLS[tool]) throw new Error('That is not something you can grant.'); + if (level < MIN_LEVEL) throw new Error('Team Grants unlock at Vertex (level 7).'); + if (!to || to === by) throw new Error('Pick someone in your team other than yourself.'); + if (n <= 0) throw new Error('Choose how much to give.'); + if (n > POOLS[tool].max) throw new Error('The most you can give one person at a time is ' + POOLS[tool].max.toLocaleString() + '.'); + + const pool = poolFor(tool, level); + const given = givenBy(by, tool); + if (given + n > pool) { + throw new Error('That is more than you have left to give this month — ' + + Math.max(0, pool - given).toLocaleString() + ' ' + POOLS[tool].unit + ' remaining.'); + } + + const all = readAll(); + const mk = monthKey(); + if (!all[mk]) all[mk] = []; + const rec = { by: by, to: to, tool: tool, n: n, at: new Date().toISOString() }; + all[mk].push(rec); + + // Keep three months, same as every other ledger here. + const keep = Object.keys(all).sort().slice(-3); + const trimmed = {}; + keep.forEach(function (k) { trimmed[k] = all[k]; }); + writeAll(trimmed); + return rec; +} + +module.exports = { init, MIN_LEVEL, POOLS, tools, poolFor, status, grant, receivedBy, givenBy, receivedDetail }; diff --git a/suite-intel.js b/suite-intel.js new file mode 100644 index 0000000..28a4a77 --- /dev/null +++ b/suite-intel.js @@ -0,0 +1,179 @@ +// Circle Suite — Network Intelligence (Corona, L8). +// +// Every member running ads is generating an answer to the same question: which +// wording actually makes a stranger click. Until now each of them only saw +// their own slice, which on display inventory is far too small to learn from — +// a member with 5,000 impressions and 4 clicks knows nothing. +// +// Pooled across the whole team it becomes a real asset, and one that cannot be +// bought or copied: it is the team's own accumulated result. It also gets +// better every month without anyone building anything, which is exactly what +// the top of a ladder should feel like. +// +// Two rules it holds to: +// 1. NOTHING identifies a member. No ids, no names, no per-person numbers. +// This is a picture of what works, not a leaderboard of who is working. +// 2. It refuses to report anything that has not earned the right to be +// reported. Display click rates are tiny, so a row with 300 impressions +// behind it is noise. Under-evidenced rows are counted and disclosed, not +// quietly dropped, so the reader knows what was left out. +'use strict'; +const fs = require('fs'); +const path = require('path'); +const suiteTraffic = require('./suite-traffic'); + +let DATA_DIR = null; +function init(opts) { DATA_DIR = opts.dataDir; } + +const MIN_LEVEL = 8; +const MIN_SERVED = 2000; // per row, before we will report a rate +const MIN_ROWS = 2; // before we will compare rows at all + +function readJson(f) { try { return JSON.parse(fs.readFileSync(path.join(DATA_DIR, f), 'utf8')); } catch (e) { return {}; } } + +// Pull every campaign the team has run, from every month we still hold. +function allCampaigns() { + const led = readJson('traffic-grants.json'); + const out = []; + Object.keys(led).forEach(function (mk) { + const byMember = led[mk] || {}; + Object.keys(byMember).forEach(function (mid) { + (byMember[mid] || []).forEach(function (c) { + out.push({ + adId: Number(c.adId), kind: c.kind || 'banner', size: c.size || '', + creative: c.creative || '', subject: c.subject || '', + target: c.target || '', month: mk + }); + }); + }); + }); + return out; +} + +// The angle is encoded in the destination (?v=pocket etc). Personal pages carry +// no angle, so they group separately rather than being lumped into "general". +function angleOf(target) { + const m = String(target || '').match(/[?&]v=([a-z]+)/i); + if (m) return m[1].toLowerCase(); + if (String(target).indexOf('/p/') !== -1) return 'personal page'; + return 'general'; +} + +function blankRow(label) { + return { label: label, served: 0, clicks: 0, ads: 0 }; +} + +function finish(map) { + const rows = Object.keys(map).map(function (k) { + const r = map[k]; + r.rate = r.served > 0 ? r.clicks / r.served : 0; + r.enough = r.served >= MIN_SERVED; + return r; + }); + const solid = rows.filter(function (r) { return r.enough; }).sort(function (a, b) { return b.rate - a.rate; }); + const thin = rows.filter(function (r) { return !r.enough; }); + return { + rows: solid, + thin: thin.length, + thinServed: thin.reduce(function (s, r) { return s + r.served; }, 0), + comparable: solid.length >= MIN_ROWS + }; +} + +async function report() { + const camps = allCampaigns(); + if (!camps.length) return { ready: false, reason: 'No campaigns have run yet — this fills in as the team advertises.' }; + + // NAS caps a stats call, so batch. + const ids = camps.map(function (c) { return c.adId; }).filter(Boolean); + const stats = {}; + for (let i = 0; i < ids.length; i += 150) { + try { + const chunk = await suiteTraffic.stats(ids.slice(i, i + 150)); + chunk.forEach(function (s) { stats[s.ad_id] = s; }); + } catch (e) { /* partial data still beats none */ } + } + if (!Object.keys(stats).length) { + return { ready: false, reason: 'The ad network did not return counters just now — try again shortly.' }; + } + + const byFormat = {}, bySize = {}, byAngle = {}, byCreative = {}, byHeadline = {}; + let totalServed = 0, totalClicks = 0, counted = 0; + + camps.forEach(function (c) { + const s = stats[c.adId]; + if (!s) return; + const served = Math.max(0, Number(s.served) || 0); + const clicks = Math.max(0, Number(s.hits) || 0); + if (served <= 0) return; + counted++; + totalServed += served; totalClicks += clicks; + + const add = function (map, key, label) { + if (!key) return; + if (!map[key]) map[key] = blankRow(label || key); + map[key].served += served; map[key].clicks += clicks; map[key].ads++; + }; + + add(byFormat, c.kind === 'text' ? 'text' : 'banner', c.kind === 'text' ? 'Text ads' : 'Banner ads'); + add(byAngle, angleOf(c.target)); + if (c.kind === 'text') { + if (c.subject) add(byHeadline, c.subject.toLowerCase(), c.subject); + } else { + if (c.size) add(bySize, c.size); + if (c.creative) add(byCreative, c.creative); + } + }); + + return { + ready: true, + totals: { + ads: counted, served: totalServed, clicks: totalClicks, + rate: totalServed > 0 ? totalClicks / totalServed : 0, + months: Object.keys(readJson('traffic-grants.json')).length + }, + format: finish(byFormat), + angle: finish(byAngle), + size: finish(bySize), + creative: finish(byCreative), + headline: finish(byHeadline), + minServed: MIN_SERVED + }; +} + +// One plain-English read of the numbers. Deliberately written here rather than +// generated: these are conclusions about money, and a model paraphrasing a +// table is exactly where an invented number would slip in. +function readout(rep) { + if (!rep || !rep.ready) return []; + const out = []; + const pct = function (r) { return (r * 100).toFixed(3) + '%'; }; + + out.push('Across ' + rep.totals.ads.toLocaleString() + ' team ads: ' + + rep.totals.served.toLocaleString() + ' impressions served, ' + + rep.totals.clicks.toLocaleString() + ' clicks — ' + pct(rep.totals.rate) + ' overall.'); + + if (rep.format.comparable) { + const top = rep.format.rows[0], next = rep.format.rows[1]; + const edge = next.rate > 0 ? ((top.rate - next.rate) / next.rate) * 100 : 0; + out.push(top.label + ' are pulling ' + pct(top.rate) + ' against ' + pct(next.rate) + ' for ' + + next.label.toLowerCase() + (edge >= 20 ? ' — a ' + Math.round(edge) + '% edge worth acting on.' : ' — close enough to call it a tie.')); + } + if (rep.angle.comparable) { + const a = rep.angle.rows[0]; + out.push('The "' + a.label + '" angle is the strongest performer at ' + pct(a.rate) + + ' across ' + a.served.toLocaleString() + ' impressions.'); + } + if (rep.creative.comparable) { + const c = rep.creative.rows[0]; + out.push('Best banner: ' + c.label + ' at ' + pct(c.rate) + '.'); + } + const thinTotal = rep.format.thin + rep.angle.thin + rep.size.thin + rep.creative.thin + rep.headline.thin; + if (thinTotal) { + out.push(thinTotal + ' grouping(s) are held back for now — under ' + rep.minServed.toLocaleString() + + ' impressions each, which is not enough to read anything into.'); + } + return out; +} + +module.exports = { init, report, readout, MIN_LEVEL, MIN_SERVED }; diff --git a/suite-meter.js b/suite-meter.js index b996975..df9e800 100644 --- a/suite-meter.js +++ b/suite-meter.js @@ -9,6 +9,7 @@ 'use strict'; const fs = require('fs'); const path = require('path'); +const suiteGrants = require('./suite-grants'); let DATA_DIR = null; function init(opts) { DATA_DIR = opts.dataDir; } @@ -38,6 +39,14 @@ function quotaFor(tool, level) { return t.quota[lv - 1] || 0; } +// Capacity handed down by an upline this month. It is added on TOP of the +// level quota and, deliberately, can lift a tool the member's own level has +// not unlocked yet — a leader giving page builds to someone on Scintilla is +// exactly the point of the feature, not a loophole in it. +function grantedFor(memberId, tool) { + try { return suiteGrants.receivedBy(memberId, tool) || 0; } catch (e) { return 0; } +} + function used(memberId, tool) { const all = readAll(); const m = all[monthKey()] || {}; @@ -50,16 +59,18 @@ function used(memberId, tool) { function check(memberId, level, tool) { const t = TOOLS[tool]; if (!t) return { allowed: false, reason: 'unknown-tool', used: 0, limit: 0, remaining: 0 }; - const limit = quotaFor(tool, level); + const base = quotaFor(tool, level); + const granted = grantedFor(memberId, tool); + const limit = base + granted; const u = used(memberId, tool); if (limit <= 0) { - return { allowed: false, reason: 'locked', minLevel: t.minLevel, used: u, limit: 0, remaining: 0, label: t.label }; + return { allowed: false, reason: 'locked', minLevel: t.minLevel, used: u, limit: 0, remaining: 0, granted: 0, label: t.label }; } const remaining = Math.max(0, limit - u); return { allowed: remaining > 0, reason: remaining > 0 ? 'ok' : 'quota', - used: u, limit, remaining, label: t.label, resets: monthKey() + used: u, limit, base, granted, remaining, label: t.label, resets: monthKey() }; } diff --git a/suite-tools.js b/suite-tools.js index e0fc9df..0663a40 100644 --- a/suite-tools.js +++ b/suite-tools.js @@ -16,8 +16,8 @@ const LEVEL_TOOLS = { 4: { name: 'Culmen', live: true, short: 'your Voice Profile — the Copy Engine and Email Engine start writing the way you actually talk' }, 5: { name: 'Apex', live: true, short: 'the Split Tester, and your ad allowance jumps to 50,000 impressions a month' }, 6: { name: 'Fastigium', live: true, short: 'the Funnel Factory — a separate hosted landing page for every audience you talk to' }, - 7: { name: 'Vertex', live: true, short: 'Leader Ops — your whole organisation triaged live from the contract, plus a coaching plan to teach forward' }, - 8: { name: 'Corona', live: true, short: 'the Founder Desk — a whole week of promotion built in one pass, plus API access to the Suite' } + 7: { name: 'Vertex', live: true, short: 'Leader Ops and Team Grants — your whole organisation triaged live from the contract, plus the ability to hand Suite capacity down to your own people' }, + 8: { name: 'Corona', live: true, short: 'the Founder Desk and Network Intelligence — a whole week of promotion in one pass, API access, and what is actually working across every ad the team runs' } }; // Very short form for character-limited surfaces (X/Twitter). @@ -28,8 +28,8 @@ const TERSE = { 4: 'your own Voice Profile', 5: 'the Split Tester + 50k impressions', 6: 'the Funnel Factory', - 7: 'Leader Ops', - 8: 'the Founder Desk' + 7: 'Leader Ops + Team Grants', + 8: 'the Founder Desk + Network Intelligence' }; function levelNumber(level, levelName) { diff --git a/suite-traffic.js b/suite-traffic.js index 60b7cd5..125cc75 100644 --- a/suite-traffic.js +++ b/suite-traffic.js @@ -11,6 +11,7 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); +const suiteGrants = require('./suite-grants'); let DATA_DIR = null; function init(opts) { DATA_DIR = opts.dataDir; } @@ -51,10 +52,15 @@ function usage(memberId) { } function status(memberId, level) { - const limit = allowanceFor(level); + const base = allowanceFor(level); + // Impressions an upline granted this month sit on top of the level allowance. + let granted = 0; + try { granted = suiteGrants.receivedBy(memberId, 'traffic') || 0; } catch (e) {} + const limit = base + granted; const u = usage(memberId); return { - limit: limit, used: u.used, remaining: Math.max(0, limit - u.used), + limit: limit, base: base, granted: granted, + used: u.used, remaining: Math.max(0, limit - u.used), campaigns: u.campaigns, resets: monthKey(), sizes: sizes(), creatives: CREATIVES }; } @@ -132,8 +138,8 @@ async function launch(opts) { 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); + const impressions = Math.max(100, Math.min(st.limit, Number(opts.impressions) || 0)); if (impressions > st.remaining) { throw new Error('That is more than your remaining ' + st.remaining.toLocaleString() + ' impressions this month.'); }