Give the top of the ladder a real prize: Team Grants + Network Intelligence
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 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
<!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="Team Grants — hand Suite capacity down into your own organisation."><title>Team Grants | The Circle Suite</title><link rel="icon" type="image/png" href="/favicon.png"><link rel="stylesheet" href="/styles.css">
|
||||
<style>
|
||||
.gw{max-width:860px;margin:0 auto;padding:24px 20px 70px}
|
||||
.gw h1{font-size:clamp(26px,4.5vw,36px);margin:6px 0 8px}
|
||||
.gw .lede{color:var(--muted);line-height:1.6;margin-bottom:18px}
|
||||
#gGate{display:none;border:2px solid var(--gold);border-radius:12px;padding:16px 20px;margin-bottom:18px;line-height:1.6}
|
||||
.g-card{background:var(--card,#0d2236);border:1px solid var(--line,#24425d);border-radius:14px;padding:18px 20px;margin-bottom:16px}
|
||||
.g-pools{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px;margin-bottom:6px}
|
||||
.g-pool{border:2px solid var(--line);border-radius:12px;padding:12px 14px;cursor:pointer;transition:border-color .12s}
|
||||
.g-pool.on{border-color:var(--gold);background:rgba(212,175,55,.07)}
|
||||
.g-pool .big{font-size:24px;font-weight:900;color:var(--gold);font-variant-numeric:tabular-nums;line-height:1}
|
||||
.g-pool .lab{font-size:11.5px;font-weight:800;letter-spacing:1.1px;text-transform:uppercase;color:var(--muted);margin-top:5px;line-height:1.35}
|
||||
.g-pool .of{font-size:12px;color:var(--muted);margin-top:5px}
|
||||
label.g-lab{display:block;font-weight:700;font-size:14px;margin:16px 0 5px}
|
||||
.g-hint{color:var(--muted);font-size:12.5px;line-height:1.5;margin-bottom:7px}
|
||||
.g-card input[type=number]{width:150px;background:var(--bg,#0b1d2e);border:1px solid var(--line);border-radius:10px;color:var(--text);padding:10px 12px;font-size:15px}
|
||||
.g-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:16px}
|
||||
#gErr{display:none;color:#f0a05a;margin-top:12px;font-size:14px;line-height:1.55}
|
||||
#gOk{display:none;border:2px solid var(--teal);border-radius:12px;padding:14px 18px;margin-top:14px;line-height:1.6}
|
||||
.g-tbl{width:100%;border-collapse:collapse;font-size:14px}
|
||||
.g-tbl th{text-align:left;padding:7px 9px;font-size:10.5px;letter-spacing:1px;text-transform:uppercase;color:var(--muted);border-bottom:2px solid var(--line)}
|
||||
.g-tbl td{padding:8px 9px;border-bottom:1px solid var(--line);color:var(--muted);font-variant-numeric:tabular-nums}
|
||||
.g-tbl td b{color:var(--text)}
|
||||
.g-got{border:2px solid var(--teal);border-radius:14px;padding:14px 18px;margin-bottom:18px;line-height:1.65}
|
||||
.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}}
|
||||
.g-note{margin-top:22px;font-size:12.5px;color:var(--muted);line-height:1.6;border-top:1px solid var(--line);padding-top:12px}
|
||||
</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>Team Grants</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="gw">
|
||||
<div class="eyebrow" style="color:var(--gold)">CIRCLE SUITE · VERTEX (LEVEL 7)</div>
|
||||
<h1>Team <span class="gold">Grants.</span></h1>
|
||||
<p class="lede">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.</p>
|
||||
|
||||
<div id="gGate"></div>
|
||||
<div id="gGot"></div>
|
||||
|
||||
<div id="gBody" style="display:none">
|
||||
<div class="g-card">
|
||||
<label class="g-lab" style="margin-top:0">What do you want to give?</label>
|
||||
<div class="g-pools" id="gPools"></div>
|
||||
|
||||
<label class="g-lab" for="gTo">Who needs it?</label>
|
||||
<div class="g-hint">Their position number. It has to be someone in your own organisation — check <a href="/suite/leader" style="color:var(--gold);font-weight:700">Leader Ops</a> if you are not sure who is stuck.</div>
|
||||
<input type="number" id="gTo" min="1" step="1" placeholder="46">
|
||||
|
||||
<label class="g-lab" for="gN">How much?</label>
|
||||
<input type="number" id="gN" min="1" step="1">
|
||||
<div class="g-hint" id="gNHint"></div>
|
||||
|
||||
<div class="g-row"><button id="gGo" class="btn btn-primary">🎁 Give it to them</button></div>
|
||||
<div id="gErr"></div>
|
||||
<div id="gOk"></div>
|
||||
</div>
|
||||
|
||||
<div id="gHistWrap" style="display:none">
|
||||
<h2 style="font-size:19px;margin:22px 0 10px">What you've given this month</h2>
|
||||
<div class="table-wrap"><table class="g-tbl"><thead><tr><th>To</th><th>What</th><th>How much</th><th>When</th></tr></thead><tbody id="gHist"></tbody></table></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="g-note"><strong style="color:var(--text)">Grants come out of a separate monthly pool that your level gives you</strong> — 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. <br><br>Independent team resource · No income is guaranteed · Cryptocurrency involves risk.</p>
|
||||
</main>
|
||||
<script src="/suite-grants.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 @@
|
||||
// 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 '<div>· <b style="color:var(--text)">' + n(r.total) + ' ' + esc(t.unit || k) +
|
||||
'</b> of ' + esc((t.label || k).toLowerCase()) + ', from ' + uniq.join(', ') + '</div>';
|
||||
});
|
||||
$('gGot').innerHTML = '<div class="g-got"><b style="color:var(--teal)">Someone above you topped you up this month.</b>' +
|
||||
parts.join('') + '<div style="color:var(--muted);font-size:12.5px;margin-top:6px">' +
|
||||
'It is already added to your allowances — nothing to claim.</div></div>';
|
||||
}
|
||||
|
||||
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 = '<div class="big">' + n(t.remaining) + '</div>' +
|
||||
'<div class="lab">' + esc(t.label) + '</div>' +
|
||||
'<div class="of">left to give of ' + n(t.pool) + '</div>';
|
||||
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 = '<td><b>#' + esc(h.to) + '</b></td>' +
|
||||
'<td>' + esc(t.label || h.tool) + '</td>' +
|
||||
'<td><b>' + n(h.n) + '</b> ' + esc(t.unit || '') + '</td>' +
|
||||
'<td>' + esc(String(h.at || '').slice(0, 10)) + '</td>';
|
||||
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 = '<span class="spin"></span>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 = '✅ <b>Sent.</b> #' + 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. <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) {
|
||||
var g = await r.json();
|
||||
gate((g.error || 'Not 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();
|
||||
state.status = d.status;
|
||||
state.level = d.level;
|
||||
state.minLevel = d.minLevel;
|
||||
|
||||
renderReceived(d.status.received);
|
||||
|
||||
if (!d.status.canGrant) {
|
||||
gate('<b>Team Grants unlock at Vertex (level 7).</b> At that point you can hand your own Suite capacity ' +
|
||||
'down to anyone in your organisation who needs it. Anything granted <em>to</em> 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);
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,50 @@
|
||||
<!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="Network Intelligence — what is actually working across every ad the whole team runs."><title>Network Intelligence | The Circle Suite</title><link rel="icon" type="image/png" href="/favicon.png"><link rel="stylesheet" href="/styles.css">
|
||||
<style>
|
||||
.iw{max-width:940px;margin:0 auto;padding:24px 20px 70px}
|
||||
.iw h1{font-size:clamp(26px,4.5vw,36px);margin:6px 0 8px}
|
||||
.iw .lede{color:var(--muted);line-height:1.6;margin-bottom:18px}
|
||||
#iGate{display:none;border:2px solid var(--gold);border-radius:12px;padding:16px 20px;margin-bottom:18px;line-height:1.6}
|
||||
.i-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:12px;margin-bottom:16px}
|
||||
.i-tile{border:2px solid var(--line);border-radius:14px;padding:14px 16px}
|
||||
.i-tile .big{font-size:26px;font-weight:900;color:var(--gold);font-variant-numeric:tabular-nums;line-height:1}
|
||||
.i-tile.cool .big{color:var(--teal)}
|
||||
.i-tile .lab{font-size:11px;font-weight:800;letter-spacing:1.2px;text-transform:uppercase;color:var(--muted);margin-top:5px;line-height:1.35}
|
||||
#iReadout{border:2px solid var(--teal);border-radius:14px;padding:16px 19px;margin-bottom:18px;line-height:1.75}
|
||||
#iReadout div{margin-bottom:7px}
|
||||
#iReadout div:last-child{margin-bottom:0}
|
||||
.i-sec{background:var(--card,#0d2236);border:1px solid var(--line,#24425d);border-radius:14px;padding:16px 18px;margin-bottom:14px}
|
||||
.i-sec h2{font-size:17px;margin:0 0 3px}
|
||||
.i-sec .why{color:var(--muted);font-size:12.5px;line-height:1.55;margin-bottom:10px}
|
||||
.i-tbl{width:100%;border-collapse:collapse;font-size:14px}
|
||||
.i-tbl th{text-align:left;padding:7px 9px;font-size:10.5px;letter-spacing:1px;text-transform:uppercase;color:var(--muted);border-bottom:2px solid var(--line)}
|
||||
.i-tbl td{padding:8px 9px;border-bottom:1px solid var(--line);color:var(--muted);font-variant-numeric:tabular-nums}
|
||||
.i-tbl tr:first-child td{color:var(--text)}
|
||||
.i-tbl tr:first-child td b{color:var(--gold)}
|
||||
.i-bar{height:6px;border-radius:3px;background:var(--gold);min-width:2px}
|
||||
.i-thin{color:var(--muted);font-size:12px;margin-top:9px;font-style:italic}
|
||||
.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}}
|
||||
.i-note{margin-top:22px;font-size:12.5px;color:var(--muted);line-height:1.6;border-top:1px solid var(--line);padding-top:12px}
|
||||
</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>Network Intelligence</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="iw">
|
||||
<div class="eyebrow" style="color:var(--gold)">CIRCLE SUITE · CORONA (LEVEL 8)</div>
|
||||
<h1>Network <span class="gold">Intelligence.</span></h1>
|
||||
<p class="lede">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.</p>
|
||||
|
||||
<div id="iGate"></div>
|
||||
<div id="iLoading" style="color:var(--muted)"><span class="spin"></span>Reading the network…</div>
|
||||
|
||||
<div id="iBody" style="display:none">
|
||||
<div class="i-tiles" id="iTiles"></div>
|
||||
<div id="iReadout"></div>
|
||||
<div id="iSections"></div>
|
||||
</div>
|
||||
|
||||
<p class="i-note"><strong style="color:var(--text)">Nothing here identifies anybody.</strong> 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. <br><br>Independent team resource · No income is guaranteed · Cryptocurrency involves risk.</p>
|
||||
</main>
|
||||
<script src="/suite-intel.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,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 '<div class="i-tile ' + (cls || '') + '"><div class="big">' + v + '</div><div class="lab">' + label + '</div></div>';
|
||||
}
|
||||
|
||||
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 '<tr><td><b>' + esc(r.label) + '</b></td>' +
|
||||
'<td>' + n(r.served) + '</td>' +
|
||||
'<td>' + n(r.clicks) + '</td>' +
|
||||
'<td><b>' + pct(r.rate) + '</b></td>' +
|
||||
'<td style="width:110px"><div class="i-bar" style="width:' + w + '%"></div></td></tr>';
|
||||
}).join('');
|
||||
var thin = group.thin
|
||||
? '<div class="i-thin">' + group.thin + ' more not shown — under the evidence threshold (' +
|
||||
n(group.thinServed) + ' impressions between them).</div>'
|
||||
: '';
|
||||
return '<div class="i-sec"><h2>' + esc(title) + '</h2><div class="why">' + esc(why) + '</div>' +
|
||||
'<div class="table-wrap"><table class="i-tbl"><thead><tr><th>' + esc(unitLabel) +
|
||||
'</th><th>Served</th><th>Clicks</th><th>Rate</th><th></th></tr></thead><tbody>' +
|
||||
rows + '</tbody></table></div>' + thin + '</div>';
|
||||
}
|
||||
|
||||
function render(d) {
|
||||
var rep = d.report;
|
||||
$('iLoading').style.display = 'none';
|
||||
if (!rep || !rep.ready) {
|
||||
gate('<b>Not enough data yet.</b> ' + 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 '<div>' + esc(line) + '</div>';
|
||||
}).join('') || '<div>Not enough evidence yet to draw conclusions.</div>';
|
||||
|
||||
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 || '<div class="i-sec"><div class="why">Nothing has crossed the evidence threshold yet. Keep the ads running.</div></div>';
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
try {
|
||||
var r = await fetch('/api/public/suite-intel');
|
||||
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) {
|
||||
var g = await r.json();
|
||||
gate((g.error || 'Not open for this position yet.') + ' <a href="/suite" style="color:var(--gold);font-weight:700">See your Suite</a>.');
|
||||
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);
|
||||
})();
|
||||
@@ -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 }
|
||||
];
|
||||
|
||||
@@ -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)<suiteGrants.MIN_LEVEL)return json(res,403,{error:'Team Grants unlock at Vertex (level 7).',minLevel:suiteGrants.MIN_LEVEL});
|
||||
const b=await bodyJson(req)||{};
|
||||
const to=Number(b.to)||0;
|
||||
if(!to)return json(res,400,{error:'Enter the position number you want to help.'});
|
||||
// Only into your OWN organisation, checked against the contract.
|
||||
let inTeam=false;
|
||||
try{ inTeam=chain.isInTeam(to,Number(e.d.id)); }catch(err){}
|
||||
if(!inTeam)return json(res,400,{error:'Position #'+to+' is not in your organisation. You can only grant to people below you.'});
|
||||
try{
|
||||
const rec=suiteGrants.grant({by:e.d.id,to:to,tool:String(b.tool||''),n:b.n,level:e.d.level});
|
||||
return json(res,200,{granted:rec,status:suiteGrants.status(e.d.id,e.d.level)});
|
||||
}catch(err){ return json(res,400,{error:String(err.message||err)}); }
|
||||
}
|
||||
}
|
||||
|
||||
// -- Network Intelligence (level 8) ---------------------------------------
|
||||
if(req.method==='GET'&&pathname==='/api/public/suite-intel'){
|
||||
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(Number(e.d.level)<suiteIntel.MIN_LEVEL)return json(res,403,{error:'Network Intelligence unlocks at Corona (level 8).',minLevel:suiteIntel.MIN_LEVEL});
|
||||
try{
|
||||
const rep=await suiteIntel.report();
|
||||
return json(res,200,{report:rep,readout:suiteIntel.readout(rep)});
|
||||
}catch(err){ return json(res,502,{error:String(err.message||err)}); }
|
||||
}
|
||||
|
||||
if(req.method==='POST'&&pathname==='/api/public/suite-traffic-stop'){
|
||||
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});
|
||||
@@ -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);
|
||||
|
||||
+143
@@ -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 };
|
||||
+179
@@ -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 };
|
||||
+14
-3
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+4
-4
@@ -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) {
|
||||
|
||||
+9
-3
@@ -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.');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user