The Page Builder gives you one page. This gives you as many as you need — a different page for each audience you talk to, each on its own address, each one something you can point a separate ad or a separate conversation at.
+
+
+
+
+
Your pages
+
+
+
+
Build another one
+
+
+
Just for you — it also becomes the web address. "Gym friends" gives you /p/…/gym-friends.
+
+
+
+
Sets the video and the pitch this page leads with.
+
+
+
+
The more specific, the better it writes. "Guys from my old job who complain about overtime" beats "everyone".
+
+
+
+
Why you started, what you were sceptical about — optional, but it makes the page sound like a person.
+
+
+
+
+
+
+
+
+
+
+
+
Every page here is public and framable, so you can drop one into an ad, a QR code, or someone's inbox and it will just work. They all carry the same honest disclaimers as the rest of the site.
Independent team resource · No income is guaranteed · Cryptocurrency involves risk.
+
+
+
+
diff --git a/public/suite-funnel.js b/public/suite-funnel.js
new file mode 100644
index 0000000..d5aaca5
--- /dev/null
+++ b/public/suite-funnel.js
@@ -0,0 +1,159 @@
+// Funnel Factory client — lists the member's pages and builds new named ones.
+(function () {
+ 'use strict';
+ var $ = function (id) { return document.getElementById(id); };
+ function esc(s) {
+ return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
+ return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
+ });
+ }
+ var state = { angles: {}, angle: 'overview', id: null, pages: [] };
+
+ function gate(msg) {
+ $('fGate').style.display = 'block';
+ $('fGate').innerHTML = msg;
+ }
+
+ function renderAngles() {
+ var host = $('fAngles');
+ host.innerHTML = '';
+ Object.keys(state.angles).forEach(function (k) {
+ var b = document.createElement('button');
+ b.type = 'button';
+ b.className = 'f-chip' + (state.angle === k ? ' on' : '');
+ b.textContent = state.angles[k].label || k;
+ b.addEventListener('click', function () { state.angle = k; renderAngles(); });
+ host.appendChild(b);
+ });
+ }
+
+ function renderPages() {
+ var host = $('fPages');
+ host.innerHTML = '';
+ if (!state.pages.length) {
+ host.innerHTML = '
You have no pages yet. Build your main one in the ' +
+ 'Page Builder first, then add more here.
Everyone below you, read live from the contract and sorted by who needs you most. Not a report — a list of conversations to have this week, and the words to have them with.
+
+
+
+
+
+
+
+
+ Uses one Copy Engine generation.
+
+
+
+
+
+
Every number here comes off the contract, not an estimate — positions, levels, and the POL forming below each person. Nothing on this page is a projection or a promise of what anyone will earn.
Coach people, don't chase them. Independent team resource · No income is guaranteed · Cryptocurrency involves risk.
+
+
+
+
diff --git a/public/suite-leader.js b/public/suite-leader.js
new file mode 100644
index 0000000..a1fe2e7
--- /dev/null
+++ b/public/suite-leader.js
@@ -0,0 +1,128 @@
+// Leader Ops client — renders the coaching radar for the signed-in leader's own
+// organisation, then asks the engine for a written plan on request.
+(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 gate(msg) {
+ $('lGate').style.display = 'block';
+ $('lGate').innerHTML = msg;
+ }
+
+ function tile(value, label, cls) {
+ return '
' + value + '
' +
+ '
' + label + '
';
+ }
+
+ function renderScan(scan) {
+ if (!scan || !scan.ready) {
+ gate('The on-chain index is still catching up. Give it a moment and refresh.');
+ return;
+ }
+ $('lBody').style.display = 'block';
+ var t = scan.totals || {};
+
+ $('lTiles').innerHTML =
+ tile(n(scan.scanned), 'positions below you', 'cool') +
+ tile(n(t.atRiskPol), 'POL forming that someone below cannot catch', t.atRiskPol > 0 ? 'hot' : '') +
+ tile(n(t.oneAwayCount), 'one direct short of qualifying', t.oneAwayCount > 0 ? 'hot' : '') +
+ tile(n(t.rollForwardCount), 'qualified but still on Scintilla', '');
+
+ var out = [];
+
+ if ((scan.atRisk || []).length) {
+ var rows = scan.atRisk.map(function (r) {
+ return '
A position only catches an upgrade payment if it is qualified and already at or above the level being bought. These people have activity underneath them that is passing them by — the fastest, kindest conversation you can have this week.
' +
+ '
Position
Level
What they need
POL passing them
Earned
Upgrade cost
' +
+ rows + '
');
+ }
+
+ if ((scan.rollForward || []).length) {
+ var rf = scan.rollForward.map(function (r) {
+ return '
#' + esc(r.id) + '
' + n(r.earnedPol) + '
' + n(r.ascensusCost) + '
';
+ }).join('');
+ out.push('
Already qualified, still on Scintilla
' +
+ '
They have their two and their entry rewards are sitting there. Ascensus is the upgrade that starts catching their directs\' payments — for several of these, what they have already earned covers it.
Nobody below you is one direct short, sitting on uncatchable money, or qualified but stuck on Scintilla. That is the quiet week you spend working on your own two.
');
+ }
+
+ $('lSections').innerHTML = out.join('');
+ }
+
+ async function writePlan(btn) {
+ btn.disabled = true;
+ btn.innerHTML = 'Reading your organisation…';
+ $('lErr').style.display = 'none';
+ try {
+ var r = await fetch('/api/public/suite-leader', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
+ var d = await r.json();
+ if (!r.ok) {
+ $('lErr').textContent = d.error || 'Could not write the plan just now.';
+ $('lErr').style.display = 'block';
+ } else {
+ $('lDigest').textContent = d.text || '';
+ $('lDigest').style.display = 'block';
+ if (d.scan) renderScan(d.scan);
+ }
+ } catch (e) {
+ $('lErr').textContent = 'Connection hiccup — try again.';
+ $('lErr').style.display = 'block';
+ }
+ btn.disabled = false;
+ btn.innerHTML = '🧭 Write this week\'s coaching plan';
+ }
+
+ async function boot() {
+ try {
+ var r = await fetch('/api/public/suite-leader');
+ 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();
+ renderScan(d.scan);
+ if (d.writerReady === false) {
+ $('lWrite').disabled = true;
+ $('lWrite').textContent = 'The writer is warming up';
+ }
+ } catch (e) {}
+ }
+
+ document.addEventListener('DOMContentLoaded', function () {
+ boot();
+ $('lWrite').addEventListener('click', function () { writePlan(this); });
+ });
+})();
diff --git a/public/suite-split.html b/public/suite-split.html
new file mode 100644
index 0000000..2529b1d
--- /dev/null
+++ b/public/suite-split.html
@@ -0,0 +1,87 @@
+Split Tester | The Circle Suite
+
+
+RM CircleSplit Tester
Run two or three versions of an ad at the same time, split evenly, and find out which wording people actually click — instead of guessing. Apex is where your monthly impressions jump to 50,000, which is the first point a comparison like this can tell you anything real.
+
+
+
+
+
—
impressions left this month
+
—
your monthly allowance
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Your tests
+
+
+
+
Why this is deliberately slow to declare a winner: click rates on display advertising are small, so a two- or three-click gap on a few hundred impressions is noise, not a result. This tool waits until one version is clearly ahead before it calls anything — and when the versions genuinely tie, it says so, because that is useful information too: it means the wording isn't what's holding the ad back.
Independent team resource · No income is guaranteed · Cryptocurrency involves risk.
+
+
+
+
diff --git a/public/suite-split.js b/public/suite-split.js
new file mode 100644
index 0000000..9655180
--- /dev/null
+++ b/public/suite-split.js
@@ -0,0 +1,289 @@
+// Split Tester client. Reuses the text-ad writer to produce candidates, then
+// launches the chosen ones as one test through the Traffic Desk.
+(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 = {
+ angle: 'general', angles: [], variants: [], picked: [], target: 'join',
+ remaining: 0, limit: 0, hasPage: false, minPerArm: 500, maxArms: 3, id: null
+ };
+ var busy = false;
+
+ function chip(label, on, fn) {
+ var b = document.createElement('button');
+ b.type = 'button';
+ b.className = 's-chip' + (on ? ' on' : '');
+ b.textContent = label;
+ b.addEventListener('click', fn);
+ return b;
+ }
+
+ function gate(msg) {
+ $('sGate').style.display = 'block';
+ $('sGate').innerHTML = msg;
+ $('sCard').style.opacity = '.55';
+ $('sGo').disabled = true;
+ $('sWrite').disabled = true;
+ }
+
+ function renderAngles() {
+ var host = $('sAngles');
+ host.innerHTML = '';
+ state.angles.forEach(function (a) {
+ host.appendChild(chip(a.label, state.angle === a.key, function () {
+ state.angle = a.key; renderAngles();
+ }));
+ });
+ }
+
+ function renderTargets() {
+ var host = $('sTargets');
+ host.innerHTML = '';
+ host.appendChild(chip('My invite page', state.target === 'join', function () {
+ state.target = 'join'; renderTargets();
+ }));
+ if (state.hasPage) {
+ host.appendChild(chip('My personal page', state.target === 'page', function () {
+ state.target = 'page'; renderTargets();
+ }));
+ }
+ }
+
+ function renderVariants() {
+ var host = $('sVariants');
+ host.innerHTML = '';
+ if (!state.variants.length) return;
+ var grid = document.createElement('div');
+ grid.className = 's-ads';
+ state.variants.forEach(function (v, i) {
+ var pickIdx = state.picked.indexOf(i);
+ var card = document.createElement('div');
+ card.className = 's-ad' + (pickIdx !== -1 ? ' on' : '');
+ if (pickIdx !== -1) {
+ var tag = document.createElement('span');
+ tag.className = 'tag';
+ tag.textContent = String.fromCharCode(65 + pickIdx);
+ card.appendChild(tag);
+ }
+ var s = document.createElement('div');
+ s.className = 's'; s.textContent = v.subject;
+ card.appendChild(s);
+ v.lines.forEach(function (l) {
+ if (!l) return;
+ var d = document.createElement('div');
+ d.className = 'l'; d.textContent = l;
+ card.appendChild(d);
+ });
+ card.addEventListener('click', function () {
+ var at = state.picked.indexOf(i);
+ if (at !== -1) state.picked.splice(at, 1);
+ else if (state.picked.length < state.maxArms) state.picked.push(i);
+ renderVariants(); splitHint();
+ });
+ grid.appendChild(card);
+ });
+ host.appendChild(grid);
+ var hint = document.createElement('div');
+ hint.className = 's-hint';
+ hint.textContent = 'Tap 2 or 3 of these to put them head to head. Tap again to unpick.';
+ host.appendChild(hint);
+ }
+
+ function splitHint() {
+ var arms = state.picked.length;
+ var total = Number($('sImp').value) || 0;
+ var el = $('sSplitHint');
+ if (arms < 2) {
+ el.textContent = 'Pick at least two versions above first.';
+ return;
+ }
+ var per = Math.floor(total / arms);
+ var need = state.minPerArm * arms;
+ el.innerHTML = arms + ' versions × ' + n(per) + ' impressions each. ' +
+ (per < state.minPerArm
+ ? 'Too thin to mean anything — use at least ' + n(need) + ' total for ' + arms + ' versions.'
+ : 'Comes out of this month\'s allowance; stopping the test returns whatever has not served.');
+ }
+
+ async function writeAds(btn) {
+ btn.disabled = true;
+ btn.innerHTML = 'Writing…';
+ $('sErr').style.display = 'none';
+ try {
+ var r = await fetch('/api/public/suite-textads', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ angle: state.angle })
+ });
+ var d = await r.json();
+ if (!r.ok) {
+ $('sErr').textContent = d.error || 'The writer could not produce versions just now.';
+ $('sErr').style.display = 'block';
+ } else {
+ state.variants = d.variants || [];
+ state.picked = state.variants.length >= 2 ? [0, 1] : [];
+ renderVariants(); splitHint();
+ if (d.meter) $('sWriteMeter').textContent = d.meter.remaining + ' of ' + d.meter.limit + ' batches left this month';
+ }
+ } catch (e) {
+ $('sErr').textContent = 'Connection hiccup — try again.';
+ $('sErr').style.display = 'block';
+ }
+ btn.disabled = false;
+ btn.innerHTML = state.variants.length ? '✍️ Write different versions' : '✍️ Write me some versions';
+ }
+
+ function renderTests(tests) {
+ if (!tests || !tests.length) { $('sTestsWrap').style.display = 'none'; return; }
+ $('sTestsWrap').style.display = 'block';
+ var host = $('sTests');
+ host.innerHTML = '';
+ tests.forEach(function (t) {
+ var box = document.createElement('div');
+ box.className = 's-test';
+ var when = String(t.at || '').slice(0, 10);
+ var best = (t.verdict && t.verdict.winner) || null;
+
+ var rows = (t.armResults || []).map(function (a) {
+ return '
By default the Copy Engine writes in the team voice. That is the safe starting point — but your people know how you talk, and copy that sounds like a company instead of like you is the first thing they notice. Answer these once and everything the Suite writes for you leans toward your own words.
+
+
+
Loading…
+
+
+
+
+
+
+
+
+
+
+
+
One thing this will not do: it will never loosen the compliance rules to sound more like you. If your own writing makes income claims, the engine still will not repeat them — the honesty rules sit above your voice profile on purpose, because they are what keeps every member safe.
Independent team resource · No income is guaranteed · Cryptocurrency involves risk.
+
+
+
+
diff --git a/public/suite-voice.js b/public/suite-voice.js
new file mode 100644
index 0000000..e29411f
--- /dev/null
+++ b/public/suite-voice.js
@@ -0,0 +1,148 @@
+// Voice Profile client. Fields come from the server so the form and the stored
+// shape can never drift apart.
+(function () {
+ 'use strict';
+ var $ = function (id) { return document.getElementById(id); };
+ var state = { fields: [], profile: {} };
+
+ function gate(msg) {
+ $('vGate').style.display = 'block';
+ $('vGate').innerHTML = msg;
+ $('vCard').style.display = 'none';
+ $('vState').style.display = 'none';
+ }
+
+ function renderFields() {
+ var host = $('vFields');
+ host.innerHTML = '';
+ state.fields.forEach(function (f) {
+ var wrap = document.createElement('div');
+ wrap.className = 'v-f';
+
+ var lab = document.createElement('label');
+ lab.setAttribute('for', 'vf_' + f.key);
+ lab.textContent = f.label;
+ wrap.appendChild(lab);
+
+ var hint = document.createElement('div');
+ hint.className = 'hint';
+ hint.textContent = f.hint;
+ wrap.appendChild(hint);
+
+ var ta = document.createElement('textarea');
+ ta.id = 'vf_' + f.key;
+ ta.rows = f.max > 400 ? 6 : 2;
+ ta.maxLength = f.max;
+ ta.value = state.profile[f.key] || '';
+ wrap.appendChild(ta);
+
+ var count = document.createElement('div');
+ count.className = 'v-count';
+ var upd = function () { count.textContent = ta.value.length + ' / ' + f.max; };
+ ta.addEventListener('input', upd);
+ upd();
+ wrap.appendChild(count);
+
+ host.appendChild(wrap);
+ });
+ }
+
+ function showState(p) {
+ var el = $('vState');
+ el.style.display = 'block';
+ var filled = state.fields.filter(function (f) {
+ return String((p || {})[f.key] || '').trim().length > 2;
+ }).length;
+ if (p && p.complete) {
+ el.className = 'v-state';
+ el.innerHTML = '✅ Your voice profile is on. The Copy Engine and Email Engine are both writing with it — ' +
+ filled + ' of ' + state.fields.length + ' answers filled in.';
+ } else {
+ el.className = 'v-state off';
+ el.innerHTML = 'Not active yet. Fill in at least a couple of these — especially the writing sample, ' +
+ 'which teaches it more than the rest combined — and everything the Suite writes will start sounding like you.';
+ }
+ }
+
+ function collect() {
+ var out = {};
+ state.fields.forEach(function (f) {
+ var el = $('vf_' + f.key);
+ out[f.key] = el ? el.value : '';
+ });
+ return out;
+ }
+
+ async function save() {
+ $('vErr').style.display = 'none';
+ $('vOk').style.display = 'none';
+ $('vSave').disabled = true;
+ var prev = $('vSave').textContent;
+ $('vSave').textContent = 'Saving…';
+ try {
+ var r = await fetch('/api/public/suite-voice', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(collect())
+ });
+ var d = await r.json();
+ if (!r.ok) {
+ $('vErr').textContent = d.error || 'That did not save — try again.';
+ $('vErr').style.display = 'block';
+ } else {
+ state.profile = d.profile;
+ showState(d.profile);
+ $('vOk').innerHTML = d.profile.complete
+ ? '✅ Saved. Head to the Copy Engine and write something — it should sound noticeably more like you.'
+ : '✅ Saved. Add a bit more (the writing sample especially) and it will switch on.';
+ $('vOk').style.display = 'block';
+ }
+ } catch (e) {
+ $('vErr').textContent = 'Connection hiccup — try again.';
+ $('vErr').style.display = 'block';
+ }
+ $('vSave').disabled = false;
+ $('vSave').textContent = prev;
+ }
+
+ async function clear() {
+ if (!window.confirm('Clear your voice profile? The Suite will go back to writing in the team voice.')) return;
+ try {
+ var r = await fetch('/api/public/suite-voice', {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ clear: true })
+ });
+ var d = await r.json();
+ if (r.ok) {
+ state.profile = d.profile;
+ renderFields();
+ showState(d.profile);
+ $('vOk').innerHTML = 'Cleared. Back to the team voice.';
+ $('vOk').style.display = 'block';
+ }
+ } catch (e) {}
+ }
+
+ async function boot() {
+ try {
+ var r = await fetch('/api/public/suite-voice');
+ 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.fields = d.fields || [];
+ state.profile = d.profile || {};
+ renderFields();
+ showState(state.profile);
+ } catch (e) {}
+ }
+
+ document.addEventListener('DOMContentLoaded', function () {
+ boot();
+ $('vSave').addEventListener('click', save);
+ $('vClear').addEventListener('click', clear);
+ });
+})();
diff --git a/public/suite.js b/public/suite.js
index 2be0bbe..83544f3 100644
--- a/public/suite.js
+++ b/public/suite.js
@@ -17,11 +17,12 @@
{ lv: 2, ico: '🧱', name: 'Page Builder', desc: 'Answer four questions, get your own hosted invitation page — your story, your angle video, your QR.', href: '/suite/page', live: true },
{ lv: 3, ico: '📧', name: 'Email Engine', desc: 'Welcome series, follow-up sequences and broadcasts in the team voice — exportable to any autoresponder.', href: '/suite/email', live: true },
{ lv: 3, ico: '🎥', name: 'Video Maker', desc: 'The team’s master promo videos rendered with your personal end-card — your name, your QR, your link.', href: '/suite/video', live: true },
- { lv: 4, ico: '🗣️', name: 'Voice Profile + Funnels', desc: 'Output that sounds like YOU, plus multi-page funnels on your own team subdomain.', live: false },
+ { lv: 4, ico: '🗣️', name: 'Voice Profile', desc: 'Teach the Copy Engine and Email Engine how you actually talk, so everything they write for you stops sounding like a company.', href: '/suite/voice', live: true },
+ { 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: 'Complete hosted funnels with A/B variants, replay funnels (your team webinar as an on-demand registration page with a timed CTA), and a lead CRM.', live: false },
- { lv: 7, ico: '🧭', name: 'Leader Ops', desc: 'Team radar, AI coaching digests for your legs, and cohort training rooms.', live: false },
- { lv: 8, ico: '👑', name: 'Founder Desk', desc: 'Your own AI operator running your promotion, API access, and the inner circle.', live: false }
+ { 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: '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: 'Every tool in the Suite at its highest allowance, and the top of the ladder — nothing above this to unlock.', live: false }
];
var $ = function (id) { return document.getElementById(id); };
diff --git a/server.js b/server.js
index e06dc1e..a3dda28 100644
--- a/server.js
+++ b/server.js
@@ -25,6 +25,9 @@ const suiteEmail = require('./suite-email');
const suiteVideo = require('./suite-video'); suiteVideo.init({ dataDir: DATA_DIR, publicDir: PUBLIC_DIR });
const suiteTraffic = require('./suite-traffic'); suiteTraffic.init({ dataDir: DATA_DIR });
const suiteTextAds = require('./suite-textads');
+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 tgbot = require('./tgbot');
tgbot.init({ dataDir: DATA_DIR, chain, getConfig, messages, baseUrl: 'https://rmcircle.team' });
const SESSION_TTL = 8 * 60 * 60 * 1000;
@@ -669,6 +672,21 @@ async function handleApi(req,res,pathname){
}
// ── Page Builder ─────────────────────────────────────────────────────────
+ // Level 6 extra pages: /p//. Falls back to the member's main page
+ // rather than 404ing, for the same reason /p/ falls back to /join/.
+ if(req.method==='GET'&&/^\/p\/\d{1,15}\/[a-z0-9-]{1,24}$/.test(pathname)){
+ const parts=pathname.split('/');
+ const pid=parts[2], slug=parts[3];
+ const rec=suitePages.load(pid,slug);
+ if(!rec||!rec.copy){
+ res.writeHead(302,securityHeaders({'Location':'/p/'+pid,'Cache-Control':'no-store'}));
+ return res.end();
+ }
+ const html=suitePages.render(rec);
+ res.writeHead(200,securityHeaders({'Content-Type':'text/html; charset=utf-8','Cache-Control':'public, max-age=120'}));
+ return res.end(html);
+ }
+
if(req.method==='GET'&&/^\/p\/\d{1,15}$/.test(pathname)){
const pid=pathname.split('/')[2];
const rec=suitePages.load(pid);
@@ -736,7 +754,7 @@ async function handleApi(req,res,pathname){
}
try{
const link='https://rmcircle.team/join/'+e.d.id;
- const emails=await suiteEmail.generate(kind,brief,{link:link,id:e.d.id});
+ const emails=await suiteEmail.generate(kind,brief,{link:link,id:e.d.id,voice:suiteVoice.promptBlock(e.d.id)});
suiteMeter.record(e.d.id,'email',emails.length||1);
return json(res,200,{emails:emails,meter:suiteMeter.check(e.d.id,e.d.level,'email')});
}catch(err){ return json(res,502,{error:String(err.message||err)}); }
@@ -825,6 +843,105 @@ async function handleApi(req,res,pathname){
}catch(err){ return json(res,502,{error:String(err.message||err)}); }
}
+ // -- Voice Profile (level 4) ----------------------------------------------
+ if(pathname==='/api/public/suite-voice'){
+ 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)<4)return json(res,403,{error:'The Voice Profile unlocks at Culmen (level 4).',minLevel:4});
+ if(req.method==='GET'){
+ return json(res,200,{profile:suiteVoice.load(e.d.id)||suiteVoice.blank(),fields:suiteVoice.FIELDS,level:e.d.level,id:e.d.id});
+ }
+ if(req.method==='POST'){
+ const b=await bodyJson(req)||{};
+ if(b.clear){ suiteVoice.clear(e.d.id); return json(res,200,{profile:suiteVoice.blank(),cleared:true}); }
+ return json(res,200,{profile:suiteVoice.save(e.d.id,b),saved:true});
+ }
+ }
+
+ // -- Split Tester (level 5) -----------------------------------------------
+ if(pathname==='/api/public/suite-split'){
+ 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)({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)<6)return json(res,403,{error:'The Funnel Factory unlocks at Fastigium (level 6).',minLevel:6});
+ if(req.method==='GET'){
+ return json(res,200,{pages:suitePages.list(e.d.id),angles:suitePages.ANGLES,level:e.d.level,id:e.d.id,
+ meter:suiteMeter.check(e.d.id,e.d.level,'page')});
+ }
+ if(req.method==='POST'){
+ const b=await bodyJson(req)||{};
+ if(b.remove){
+ const gone=suitePages.remove(e.d.id,String(b.remove));
+ return json(res,gone?200:404,gone?{removed:b.remove,pages:suitePages.list(e.d.id)}:{error:'That page is not there.'});
+ }
+ const slug=suitePages.slugify(b.slug||b.name||'');
+ if(!slug)return json(res,400,{error:'Give the page a short name.'});
+ const gate=suiteMeter.check(e.d.id,e.d.level,'page');
+ if(!gate.allowed)return json(res,429,{error:'You have used all '+gate.limit+' page builds this month. It resets on the 1st.',meter:gate});
+ const input={id:e.d.id,name:String(b.name||'').slice(0,80),audience:String(b.audience||'').slice(0,400),
+ story:String(b.story||'').slice(0,900),angle:suitePages.ANGLES[b.angle]?b.angle:'overview'};
+ try{
+ const copy=await suitePages.generate(input);
+ suitePages.save(e.d.id,{id:e.d.id,slug:slug,name:input.name,angle:input.angle,audience:input.audience,
+ story:input.story,copy:copy,updatedAt:new Date().toISOString()},slug);
+ suiteMeter.record(e.d.id,'page',1);
+ return json(res,200,{pages:suitePages.list(e.d.id),url:'https://rmcircle.team/p/'+e.d.id+'/'+slug,
+ meter:suiteMeter.check(e.d.id,e.d.level,'page')});
+ }catch(err){ return json(res,502,{error:String(err.message||err)}); }
+ }
+ }
+
+ // -- Leader Ops (level 7) -------------------------------------------------
+ if(pathname==='/api/public/suite-leader'){
+ 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)({error:'Chain read hiccup — try again.',code:500}));
if(e.error)return json(res,e.code||500,{error:e.error});
@@ -861,7 +978,7 @@ async function handleApi(req,res,pathname){
}
const link='https://rmcircle.team/join/'+e.d.id;
try{
- const text=await suiteAI.generate(kind,brief,{link:link,id:e.d.id});
+ const text=await suiteAI.generate(kind,brief,{link:link,id:e.d.id,voice:suiteVoice.promptBlock(e.d.id)});
suiteMeter.record(e.d.id,'copy',1);
return json(res,200,{text:text,meter:suiteMeter.check(e.d.id,e.d.level,'copy')});
}catch(err){
@@ -1169,7 +1286,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==='/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==='/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-ai.js b/suite-ai.js
index 5461899..46befad 100644
--- a/suite-ai.js
+++ b/suite-ai.js
@@ -56,6 +56,10 @@ function buildMessages(kind, brief, member) {
'You write promotional copy for a member of the RM Circle team.\n\n' +
'WHAT IT IS: ' + FACTS + '\n\n' +
'VOICE: ' + VOICE + '\n\n' +
+ // The member's own voice profile, when they have one (level 4+). It sits
+ // BEFORE compliance deliberately: compliance must be the last word in the
+ // prompt, so a personal voice can never talk the model out of it.
+ (member && member.voice ? member.voice + '\n\n' : '') +
'COMPLIANCE (absolute, overrides everything else): ' + COMPLIANCE + '\n\n' +
'FORMAT: ' + k.shape + '\n\n' +
(link
diff --git a/suite-email.js b/suite-email.js
index ab60790..4fe035e 100644
--- a/suite-email.js
+++ b/suite-email.js
@@ -43,7 +43,12 @@ function buildPrompt(kind, brief, member) {
'BODY: 90-170 words, plain text, short paragraphs separated by blank lines, ending with a sign-off line\n' +
(k.count > 1 ? 'EMAIL 2\nSUBJECT: ...\nBODY: ...\n(and so on)\n' : '') + '\n' +
(link ? 'Where a link belongs, use exactly: ' + link + '\n' : 'Do not invent a link.\n') +
- 'No HTML, no markdown headers, no emoji in subject lines, no merge tags.'
+ 'No HTML, no markdown headers, no emoji in subject lines, no merge tags.' +
+ // The member's own voice profile (level 4+), when they have one. This module
+ // builds its own prompt rather than going through suite-ai's message builder,
+ // so the profile has to be threaded in here too or email would keep writing
+ // in the generic team voice while the Copy Engine sounded like them.
+ (member && member.voice ? '\n\n' + member.voice : '')
);
}
diff --git a/suite-leader.js b/suite-leader.js
new file mode 100644
index 0000000..b0f6740
--- /dev/null
+++ b/suite-leader.js
@@ -0,0 +1,88 @@
+// Circle Suite — Leader Ops (level 7).
+//
+// The coaching radar already existed in chain.js and only the admin panel could
+// see it. That is backwards: the people who most need to know which of their
+// legs is about to leak money are the leaders running those legs, not Marty.
+// This exposes it scoped to the member's OWN organisation, and adds a written
+// digest on top of the raw triage.
+//
+// The digest follows the team's teach-forward rule: every piece of coaching
+// output has to leave the leader able to teach the same thing to their two,
+// rather than making them dependent on the tool. So the digest is written as
+// "here is what to say to this person and why", not "here is a number".
+'use strict';
+
+const chain = require('./chain');
+const suiteAi = require('./suite-ai');
+
+const MIN_LEVEL = 7;
+
+// Raw triage for one leader's organisation.
+function scan(memberId, maxItems) {
+ const s = chain.getCoachingScan(Number(memberId), Math.max(3, Math.min(25, Number(maxItems) || 12)));
+ if (!s || !s.ready) {
+ return { ready: false, reason: 'The on-chain index is still catching up — try again in a moment.' };
+ }
+ return s;
+}
+
+// Turn the triage into something a leader can act on this week.
+//
+// We hand the model FACTS ONLY — ids, levels, POL amounts already computed from
+// the contract — and forbid it from inventing any others. Everything numeric in
+// the digest has to come from the scan we pass in, because a coaching digest
+// that misstates what someone is owed does real damage to a real relationship.
+async function digest(memberId, opts) {
+ const s = scan(memberId, (opts && opts.maxItems) || 12);
+ if (!s.ready) throw new Error(s.reason);
+
+ const nothing = !s.totals.atRiskCount && !s.totals.rollForwardCount && !s.totals.oneAwayCount;
+ if (nothing) {
+ return {
+ scan: s,
+ text: 'Nothing needs chasing in your organisation right now — nobody is one direct short, ' +
+ 'nobody is sitting on money they cannot catch, and nobody is qualified but still on Scintilla. ' +
+ 'That is the quiet week you use to work on your own two.'
+ };
+ }
+
+ const lines = [];
+ if (s.atRisk.length) {
+ lines.push('PEOPLE WITH MONEY FORMING BELOW THEM THEY CANNOT CATCH YET:');
+ s.atRisk.slice(0, 8).forEach(function (r) {
+ lines.push('- Position #' + r.id + ' is ' + r.levelName + ', ' +
+ (r.qualified ? 'qualified' : 'NOT qualified (' + r.directCount + ' of 2 directs)') +
+ '. ' + r.atRiskPol + ' POL is forming below them that they cannot catch. ' +
+ (r.qualified
+ ? 'They need to upgrade to ' + r.neededLevelName + ' (costs ' + r.upgradeCost + ' POL). They have earned ' + r.earnedPol + ' POL so far.'
+ : 'They need their 2nd direct before they can catch anything at all.'));
+ });
+ }
+ if (s.rollForward.length) {
+ lines.push('QUALIFIED BUT STILL ON SCINTILLA (their entry rewards already cover the next upgrade):');
+ s.rollForward.slice(0, 8).forEach(function (r) {
+ lines.push('- Position #' + r.id + ' has earned ' + r.earnedPol + ' POL; Ascensus costs ' + r.ascensusCost + ' POL.');
+ });
+ }
+ if (s.oneAway.length) {
+ lines.push('ONE DIRECT SHORT OF QUALIFYING (a single placement fixes them):');
+ lines.push('- ' + s.oneAway.slice(0, 10).map(function (r) { return '#' + r.id; }).join(', '));
+ }
+
+ const instruction =
+ 'You are writing a short weekly coaching digest for a leader in the RM Circle team build, about their own organisation.\n\n' +
+ 'THE FACTS — these are read live from the smart contract. Use ONLY these. Never invent an id, a level, or a POL amount:\n' +
+ lines.join('\n') + '\n\n' +
+ 'HOW THE MONEY WORKS (so your advice is correct): a member catches an upgrade payment only if they are qualified (2 personal directs) AND already at or above the level being bought. Upgrade payments travel up to the first upline who meets both tests. Entry payments go to the sponsor.\n\n' +
+ 'ABSOLUTE RULES:\n' +
+ '- Never promise, guarantee or project income. Never quote dollars — POL quantities only.\n' +
+ '- Never state a number that is not in the facts above.\n' +
+ '- Do not shame anyone. These are real people and the leader has to talk to them tomorrow.\n\n' +
+ 'TEACH-FORWARD RULE (this is the team doctrine and it matters most): the leader should finish reading able to TEACH this to their own two, not just to act on it themselves. So for each recommendation, give the leader the words to use and the reason behind them, so they can hand the same conversation down.\n\n' +
+ 'FORMAT: 200-320 words of plain prose. Open with the single most urgent thing. Then walk through what to say to whom, in priority order. Close with one sentence on what to teach their two this week. No headings, no bullet symbols, no markdown, no preamble.';
+
+ const text = await suiteAi.generateRaw(instruction);
+ return { scan: s, text: String(text || '').trim() };
+}
+
+module.exports = { scan, digest, MIN_LEVEL };
diff --git a/suite-pages.js b/suite-pages.js
index 3fdd9d5..aed0fb2 100644
--- a/suite-pages.js
+++ b/suite-pages.js
@@ -14,10 +14,38 @@ const suiteAI = require('./suite-ai');
let DATA_DIR = null;
function init(opts) { DATA_DIR = opts.dataDir; }
function dir() { const d = path.join(DATA_DIR, 'pages'); try { fs.mkdirSync(d, { recursive: true }); } catch (e) {} return d; }
-function file(id) { return path.join(dir(), String(id).replace(/\D/g, '') + '.json'); }
+// A member's main page is .json and lives at /p/. Level 6 adds extra
+// named pages, stored as --.json and served at /p//, so a
+// leader can run a different page per angle and point different ads at each.
+function slugify(s) {
+ return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24);
+}
+function file(id, slug) {
+ const base = String(id).replace(/\D/g, '');
+ const sl = slug ? slugify(slug) : '';
+ return path.join(dir(), base + (sl ? '--' + sl : '') + '.json');
+}
-function load(id) { try { return JSON.parse(fs.readFileSync(file(id), 'utf8')); } catch (e) { return null; } }
-function save(id, rec) { fs.writeFileSync(file(id), JSON.stringify(rec)); return rec; }
+function load(id, slug) { try { return JSON.parse(fs.readFileSync(file(id, slug), 'utf8')); } catch (e) { return null; } }
+function save(id, rec, slug) { fs.writeFileSync(file(id, slug), JSON.stringify(rec)); return rec; }
+function remove(id, slug) { if (!slug) return false; try { fs.unlinkSync(file(id, slug)); return true; } catch (e) { return false; } }
+
+// Every page this member owns, main first.
+function list(id) {
+ const base = String(id).replace(/\D/g, '');
+ const out = [];
+ const main = load(id);
+ if (main && main.copy) out.push({ slug: '', url: '/p/' + base, name: main.name || 'Main page', angle: main.angle, updatedAt: main.updatedAt });
+ let files = [];
+ try { files = fs.readdirSync(dir()); } catch (e) { files = []; }
+ files.forEach(function (f) {
+ const m = f.match(new RegExp('^' + base + '--([a-z0-9-]+)\\.json$'));
+ if (!m) return;
+ const rec = load(id, m[1]);
+ if (rec && rec.copy) out.push({ slug: m[1], url: '/p/' + base + '/' + m[1], name: rec.name || m[1], angle: rec.angle, updatedAt: rec.updatedAt });
+ });
+ return out;
+}
// Angles reuse the team's existing hook videos + their landing variant.
const ANGLES = {
@@ -138,4 +166,4 @@ function render(rec) {
'