Circle Suite: complete the ladder — every paid level now unlocks live software

Four new tools, so no tier is a placeholder any more:

L4 Voice Profile (/suite/voice) — five short answers that ride along with
every Copy Engine and Email Engine generation. It sits BEFORE the compliance
block in the prompt on purpose: a personal voice must never be able to talk
the model out of the honesty rules. suite-email builds its own prompt, so the
profile is threaded in there separately or email would keep sounding generic
while the Copy Engine sounded like the member.

L5 Split Tester (/suite/split) — 2-3 variants launched as one test with the
impressions split evenly, then click counters compared. Deliberately
conservative: it will not declare a winner until both arms have real volume
AND the leader is clearly ahead, because a 3-click gap on 400 impressions is
noise. A genuine tie is reported as a tie, which is useful information too.
A failed arm rolls back the whole test rather than leaving half of it running.
Apex is the right home for it — that is where impressions jump to 50,000.

L6 Funnel Factory (/suite/funnel) — extra named pages at /p/<id>/<slug>, so a
leader can run one page per audience and point different ads at each. Missing
slugs fall back to the member's main page, same no-dead-ends rule as /p/<id>.

L7 Leader Ops (/suite/leader) — the coaching radar already existed in chain.js
and only admin could see it, which was backwards: the leaders running those
legs need it more than anyone. Now scoped to the member's own organisation,
with a written plan built on contract-read facts only. Follows the
teach-forward rule — the digest gives the leader words to hand down, not just
numbers to act on.

Tile copy across the wall and the payout alerts now describes what actually
exists rather than what was sketched. suite-tools.js was stale in both
directions (it still had the Traffic Desk at L5 and L3 not live).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-28 10:49:36 -05:00
parent 8380696574
commit 3f8d23d66c
17 changed files with 1525 additions and 22 deletions
+76
View File
@@ -0,0 +1,76 @@
<!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="Funnel Factory — build a separate landing page for each audience you talk to, and point different ads at each one."><title>Funnel Factory | The Circle Suite</title><link rel="icon" type="image/png" href="/favicon.png"><link rel="stylesheet" href="/styles.css">
<style>
.fw{max-width:880px;margin:0 auto;padding:24px 20px 70px}
.fw h1{font-size:clamp(26px,4.5vw,36px);margin:6px 0 8px}
.fw .lede{color:var(--muted);line-height:1.6;margin-bottom:18px}
#fGate{display:none;border:2px solid var(--gold);border-radius:12px;padding:16px 20px;margin-bottom:18px;line-height:1.6}
.f-card{background:var(--card,#0d2236);border:1px solid var(--line,#24425d);border-radius:14px;padding:18px 20px;margin-bottom:16px}
label.f-lab{display:block;font-weight:700;font-size:14px;margin:14px 0 5px}
label.f-lab:first-of-type{margin-top:0}
.f-hint{color:var(--muted);font-size:12.5px;line-height:1.5;margin-bottom:7px}
.f-card input[type=text],.f-card textarea{width:100%;background:var(--bg,#0b1d2e);border:1px solid var(--line);
border-radius:10px;color:var(--text);padding:10px 12px;font:inherit;font-size:14.5px;line-height:1.55;resize:vertical}
.f-chips{display:flex;flex-wrap:wrap;gap:8px}
.f-chip{border:1px solid var(--line);background:transparent;color:var(--muted);border-radius:999px;padding:7px 14px;font-size:13.5px;font-weight:700;cursor:pointer}
.f-chip.on{border-color:var(--teal);background:rgba(78,214,203,.12);color:var(--teal)}
.f-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:16px}
#fErr{display:none;color:#f0a05a;margin-top:12px;font-size:14px;line-height:1.55}
#fOk{display:none;border:2px solid var(--teal);border-radius:12px;padding:14px 18px;margin-top:14px;line-height:1.6}
.f-page{display:flex;gap:12px;align-items:flex-start;justify-content:space-between;flex-wrap:wrap;
border:1px solid var(--line);border-radius:12px;padding:13px 15px;margin-bottom:10px}
.f-page .nm{font-weight:800;font-size:15px}
.f-page .u{font-size:12.5px;color:var(--muted);word-break:break-all}
.f-page .meta{font-size:12px;color:var(--muted);margin-top:3px}
.f-page.main{border-color:var(--gold)}
.f-acts{display:flex;gap:8px;flex-wrap:wrap}
.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}}
.f-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>Funnel Factory</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="fw">
<div class="eyebrow" style="color:var(--gold)">CIRCLE SUITE · FASTIGIUM (LEVEL 6)</div>
<h1>Funnel <span class="gold">Factory.</span></h1>
<p class="lede">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.</p>
<div id="fGate"></div>
<div id="fBody" style="display:none">
<h2 style="font-size:19px;margin:0 0 10px">Your pages</h2>
<div id="fPages"></div>
<div class="f-card" style="margin-top:18px">
<h2 style="font-size:18px;margin:0 0 12px">Build another one</h2>
<label class="f-lab" for="fName">Name it</label>
<div class="f-hint">Just for you — it also becomes the web address. "Gym friends" gives you /p/<span id="fIdA"></span>/gym-friends.</div>
<input type="text" id="fName" maxlength="80" placeholder="Gym friends">
<label class="f-lab">Angle</label>
<div class="f-hint">Sets the video and the pitch this page leads with.</div>
<div class="f-chips" id="fAngles"></div>
<label class="f-lab" for="fAud">Who is this page for?</label>
<div class="f-hint">The more specific, the better it writes. "Guys from my old job who complain about overtime" beats "everyone".</div>
<textarea id="fAud" rows="2" maxlength="400"></textarea>
<label class="f-lab" for="fStory">Anything of your own to include?</label>
<div class="f-hint">Why you started, what you were sceptical about — optional, but it makes the page sound like a person.</div>
<textarea id="fStory" rows="3" maxlength="900"></textarea>
<div class="f-row">
<button id="fGo" class="btn btn-primary">🏗️ Build this page</button>
<span class="f-hint" id="fMeter" style="margin:0"></span>
</div>
<div id="fErr"></div>
<div id="fOk"></div>
</div>
</div>
<p class="f-note"><strong style="color:var(--text)">Every page here is public and framable</strong>, 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. <br><br>Independent team resource · No income is guaranteed · Cryptocurrency involves risk.</p>
</main>
<script src="/suite-funnel.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>
+159
View File
@@ -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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 = '<div class="f-hint">You have no pages yet. Build your main one in the ' +
'<a href="/suite/page" style="color:var(--gold);font-weight:700">Page Builder</a> first, then add more here.</div>';
return;
}
state.pages.forEach(function (p) {
var row = document.createElement('div');
row.className = 'f-page' + (p.slug ? '' : ' main');
var left = document.createElement('div');
left.style.minWidth = '210px';
left.innerHTML = '<div class="nm">' + esc(p.name || 'Page') +
(p.slug ? '' : ' <span style="color:var(--gold);font-size:11px;font-weight:900">MAIN</span>') + '</div>' +
'<div class="u">rmcircle.team' + esc(p.url) + '</div>' +
'<div class="meta">' + esc((state.angles[p.angle] && state.angles[p.angle].label) || p.angle || '') +
(p.updatedAt ? ' · updated ' + esc(String(p.updatedAt).slice(0, 10)) : '') + '</div>';
row.appendChild(left);
var acts = document.createElement('div');
acts.className = 'f-acts';
var view = document.createElement('a');
view.className = 'btn btn-secondary btn-sm';
view.href = p.url; view.target = '_blank'; view.rel = 'noopener';
view.textContent = 'View';
acts.appendChild(view);
var copy = document.createElement('button');
copy.className = 'btn btn-secondary btn-sm';
copy.textContent = 'Copy link';
copy.addEventListener('click', function () {
var full = 'https://rmcircle.team' + p.url;
if (navigator.clipboard) navigator.clipboard.writeText(full);
copy.textContent = 'Copied';
setTimeout(function () { copy.textContent = 'Copy link'; }, 1400);
});
acts.appendChild(copy);
if (p.slug) {
var del = document.createElement('button');
del.className = 'btn btn-secondary btn-sm';
del.textContent = 'Delete';
del.addEventListener('click', function () { removePage(p.slug, p.name, del); });
acts.appendChild(del);
}
row.appendChild(acts);
host.appendChild(row);
});
}
async function removePage(slug, name, btn) {
if (!window.confirm('Delete "' + name + '"? Anything already pointing at that address will fall back to your main page.')) return;
btn.disabled = true;
try {
var r = await fetch('/api/public/suite-funnel', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ remove: slug })
});
var d = await r.json();
if (r.ok) { state.pages = d.pages || []; renderPages(); }
else { btn.disabled = false; }
} catch (e) { btn.disabled = false; }
}
async function build() {
$('fErr').style.display = 'none';
$('fOk').style.display = 'none';
var name = $('fName').value.trim();
if (name.length < 2) {
$('fErr').textContent = 'Give the page a short name first.';
$('fErr').style.display = 'block';
return;
}
$('fGo').disabled = true;
$('fGo').innerHTML = '<span class="spin"></span>Writing your page…';
try {
var r = await fetch('/api/public/suite-funnel', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name, angle: state.angle,
audience: $('fAud').value.trim(), story: $('fStory').value.trim()
})
});
var d = await r.json();
if (!r.ok) {
$('fErr').textContent = d.error || 'That did not build — try again.';
$('fErr').style.display = 'block';
} else {
state.pages = d.pages || [];
renderPages();
if (d.meter) $('fMeter').textContent = d.meter.remaining + ' of ' + d.meter.limit + ' page builds left this month';
$('fOk').innerHTML = '✅ <b>Built.</b> It is live at <a href="' + esc(d.url) + '" target="_blank" rel="noopener" style="color:var(--gold);font-weight:700">' + esc(d.url) + '</a>';
$('fOk').style.display = 'block';
$('fName').value = ''; $('fAud').value = ''; $('fStory').value = '';
}
} catch (e) {
$('fErr').textContent = 'Connection hiccup — try again.';
$('fErr').style.display = 'block';
}
$('fGo').disabled = false;
$('fGo').innerHTML = '🏗️ Build this page';
}
async function boot() {
try {
var r = await fetch('/api/public/suite-funnel');
if (r.status === 401) { gate('Youre 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.angles = d.angles || {};
state.pages = d.pages || [];
state.id = d.id;
$('fIdA').textContent = d.id;
if (d.meter) $('fMeter').textContent = d.meter.remaining + ' of ' + d.meter.limit + ' page builds left this month';
$('fBody').style.display = 'block';
renderAngles(); renderPages();
} catch (e) {}
}
document.addEventListener('DOMContentLoaded', function () {
boot();
$('fGo').addEventListener('click', build);
});
})();
+56
View File
@@ -0,0 +1,56 @@
<!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="Leader Ops — see which positions in your organisation need help this week, and what to say to them."><title>Leader Ops | The Circle Suite</title><link rel="icon" type="image/png" href="/favicon.png"><link rel="stylesheet" href="/styles.css">
<style>
.lw{max-width:900px;margin:0 auto;padding:24px 20px 70px}
.lw h1{font-size:clamp(26px,4.5vw,36px);margin:6px 0 8px}
.lw .lede{color:var(--muted);line-height:1.6;margin-bottom:18px}
#lGate{display:none;border:2px solid var(--gold);border-radius:12px;padding:16px 20px;margin-bottom:18px;line-height:1.6}
.l-tiles{display:grid;grid-template-columns:repeat(auto-fit,minmax(165px,1fr));gap:12px;margin-bottom:18px}
.l-tile{border:2px solid var(--line);border-radius:14px;padding:14px 16px}
.l-tile.hot{border-color:var(--gold)}
.l-tile .big{font-size:28px;font-weight:900;color:var(--gold);font-variant-numeric:tabular-nums;line-height:1}
.l-tile.cool .big{color:var(--teal)}
.l-tile .lab{font-size:11.5px;font-weight:800;letter-spacing:1.2px;text-transform:uppercase;color:var(--muted);margin-top:5px;line-height:1.35}
.l-sec{background:var(--card,#0d2236);border:1px solid var(--line,#24425d);border-radius:14px;padding:16px 18px;margin-bottom:14px}
.l-sec h2{font-size:17px;margin:0 0 4px}
.l-sec .why{color:var(--muted);font-size:13px;line-height:1.55;margin-bottom:10px}
.l-tbl{width:100%;border-collapse:collapse;font-size:14px}
.l-tbl th{text-align:left;padding:7px 9px;font-size:11px;letter-spacing:1px;text-transform:uppercase;color:var(--muted);border-bottom:2px solid var(--line)}
.l-tbl td{padding:8px 9px;border-bottom:1px solid var(--line);color:var(--muted);font-variant-numeric:tabular-nums}
.l-tbl td b{color:var(--text)}
.pill{display:inline-block;padding:2px 8px;border-radius:999px;font-size:11px;font-weight:800;letter-spacing:.4px}
.pill.q{background:rgba(240,160,90,.16);color:#f0a05a}
.pill.u{background:rgba(78,214,203,.14);color:var(--teal)}
.l-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin:16px 0 0}
#lErr{display:none;color:#f0a05a;margin-top:12px;font-size:14px;line-height:1.55}
#lDigest{display:none;border:2px solid var(--teal);border-radius:14px;padding:18px 20px;margin-top:16px;line-height:1.75;white-space:pre-wrap}
.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}}
.l-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>Leader Ops</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="lw">
<div class="eyebrow" style="color:var(--gold)">CIRCLE SUITE · VERTEX (LEVEL 7)</div>
<h1>Leader <span class="gold">Ops.</span></h1>
<p class="lede">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.</p>
<div id="lGate"></div>
<div id="lBody" style="display:none">
<div class="l-tiles" id="lTiles"></div>
<div id="lSections"></div>
<div class="l-row">
<button id="lWrite" class="btn btn-primary">🧭 Write this week's coaching plan</button>
<span class="tf-hint" style="color:var(--muted);font-size:12.5px">Uses one Copy Engine generation.</span>
</div>
<div id="lErr"></div>
<div id="lDigest"></div>
</div>
<p class="l-note"><strong style="color:var(--text)">Every number here comes off the contract</strong>, 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. <br><br>Coach people, don't chase them. Independent team resource · No income is guaranteed · Cryptocurrency involves risk.</p>
</main>
<script src="/suite-leader.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>
+128
View File
@@ -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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 '<div class="l-tile ' + (cls || '') + '"><div class="big">' + value + '</div>' +
'<div class="lab">' + label + '</div></div>';
}
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 '<tr><td><b>#' + esc(r.id) + '</b></td>' +
'<td>' + esc(r.levelName) + '</td>' +
'<td>' + (r.qualified
? '<span class="pill u">upgrade to ' + esc(r.neededLevelName || '') + '</span>'
: '<span class="pill q">' + esc(r.directCount) + ' of 2 directs</span>') + '</td>' +
'<td><b>' + n(r.atRiskPol) + '</b></td>' +
'<td>' + n(r.earnedPol) + '</td>' +
'<td>' + (r.upgradeCost ? n(r.upgradeCost) : '—') + '</td></tr>';
}).join('');
out.push('<div class="l-sec"><h2>Money forming they can\'t catch</h2>' +
'<div class="why">A position only catches an upgrade payment if it is qualified <em>and</em> 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.</div>' +
'<div class="table-wrap"><table class="l-tbl"><thead><tr><th>Position</th><th>Level</th><th>What they need</th><th>POL passing them</th><th>Earned</th><th>Upgrade cost</th></tr></thead><tbody>' +
rows + '</tbody></table></div></div>');
}
if ((scan.rollForward || []).length) {
var rf = scan.rollForward.map(function (r) {
return '<tr><td><b>#' + esc(r.id) + '</b></td><td>' + n(r.earnedPol) + '</td><td>' + n(r.ascensusCost) + '</td></tr>';
}).join('');
out.push('<div class="l-sec"><h2>Already qualified, still on Scintilla</h2>' +
'<div class="why">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.</div>' +
'<div class="table-wrap"><table class="l-tbl"><thead><tr><th>Position</th><th>Earned (POL)</th><th>Ascensus costs</th></tr></thead><tbody>' +
rf + '</tbody></table></div></div>');
}
if ((scan.oneAway || []).length) {
out.push('<div class="l-sec"><h2>One direct short</h2>' +
'<div class="why">A single placement qualifies each of these. If you have anyone coming in, this is where to put them.</div>' +
'<div style="font-size:15px;line-height:1.9">' +
scan.oneAway.map(function (r) { return '<b>#' + esc(r.id) + '</b> <span style="color:var(--muted);font-size:13px">(' + esc(r.levelName) + ')</span>'; }).join(' &nbsp;·&nbsp; ') +
'</div></div>');
}
if (!out.length) {
out.push('<div class="l-sec"><h2>Nothing needs chasing</h2>' +
'<div class="why">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.</div></div>');
}
$('lSections').innerHTML = out.join('');
}
async function writePlan(btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spin"></span>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('Youre 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();
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); });
});
})();
+87
View File
@@ -0,0 +1,87 @@
<!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="Split Tester — run two or three versions of an ad against each other and find out which one people actually click."><title>Split Tester | The Circle Suite</title><link rel="icon" type="image/png" href="/favicon.png"><link rel="stylesheet" href="/styles.css">
<style>
.sw{max-width:940px;margin:0 auto;padding:24px 20px 70px}
.sw h1{font-size:clamp(26px,4.5vw,36px);margin:6px 0 8px}
.sw .lede{color:var(--muted);line-height:1.6;margin-bottom:18px}
#sGate{display:none;border:2px solid var(--gold);border-radius:12px;padding:16px 20px;margin-bottom:18px;line-height:1.6}
.s-bal{display:flex;gap:18px;flex-wrap:wrap;align-items:center;border:2px solid var(--teal);border-radius:14px;padding:14px 18px;margin-bottom:18px}
.s-bal .big{font-size:28px;font-weight:900;color:var(--gold);font-variant-numeric:tabular-nums;line-height:1}
.s-bal .lab{font-size:11.5px;font-weight:800;letter-spacing:1.3px;text-transform:uppercase;color:var(--muted);margin-top:4px}
.s-card{background:var(--card,#0d2236);border:1px solid var(--line,#24425d);border-radius:14px;padding:18px 20px;margin-bottom:16px}
label.s-lab{display:block;font-weight:700;font-size:14px;margin:14px 0 6px}
label.s-lab:first-of-type{margin-top:0}
.s-chips{display:flex;flex-wrap:wrap;gap:8px}
.s-chip{border:1px solid var(--line);background:transparent;color:var(--muted);border-radius:999px;padding:7px 14px;font-size:13.5px;font-weight:700;cursor:pointer}
.s-chip.on{border-color:var(--teal);background:rgba(78,214,203,.12);color:var(--teal)}
.s-ads{display:grid;grid-template-columns:repeat(auto-fill,minmax(215px,1fr));gap:10px;margin-top:12px}
.s-ad{border:2px solid var(--line);border-radius:10px;padding:12px 13px;cursor:pointer;background:var(--bg,#0b1d2e);position:relative}
.s-ad.on{border-color:var(--gold);background:rgba(212,175,55,.07)}
.s-ad .s{font-weight:800;font-size:15px;color:var(--gold);line-height:1.25;margin-bottom:5px;word-break:break-word}
.s-ad .l{font-size:13px;line-height:1.5;color:var(--text);word-break:break-word}
.s-ad .tag{position:absolute;top:-9px;right:9px;background:var(--gold);color:#0b1d2e;font-size:10.5px;font-weight:900;padding:2px 7px;border-radius:999px;letter-spacing:.5px}
#sImp{width:160px;background:var(--bg,#0b1d2e);border:1px solid var(--line);border-radius:10px;color:var(--text);padding:10px 12px;font-size:15px}
.s-hint{color:var(--muted);font-size:12.5px;margin-top:6px;line-height:1.5}
.s-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:16px}
#sErr{display:none;color:#f0a05a;margin-top:12px;font-size:14px;line-height:1.55}
#sOk{display:none;border:2px solid var(--teal);border-radius:12px;padding:14px 18px;margin-top:14px;line-height:1.6}
.s-test{border:1px solid var(--line);border-radius:14px;padding:16px 18px;margin-bottom:14px}
.s-test h3{margin:0 0 3px;font-size:15px}
.s-verdict{border-radius:10px;padding:10px 13px;margin:10px 0;font-size:14px;line-height:1.6}
.s-verdict.winner{background:rgba(212,175,55,.1);border:1px solid var(--gold)}
.s-verdict.tie{background:rgba(255,255,255,.04);border:1px solid var(--line)}
.s-verdict.running{background:rgba(78,214,203,.08);border:1px solid var(--teal)}
.s-tbl{width:100%;border-collapse:collapse;font-size:13.5px}
.s-tbl th{text-align:left;padding:6px 8px;font-size:10.5px;letter-spacing:1px;text-transform:uppercase;color:var(--muted);border-bottom:2px solid var(--line)}
.s-tbl td{padding:7px 8px;border-bottom:1px solid var(--line);color:var(--muted);font-variant-numeric:tabular-nums}
.s-tbl td b{color:var(--text)}
.s-tbl tr.win td{color:var(--text)}
.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}}
.s-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>Split Tester</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="sw">
<div class="eyebrow" style="color:var(--gold)">CIRCLE SUITE · APEX (LEVEL 5)</div>
<h1>Split <span class="gold">Tester.</span></h1>
<p class="lede">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.</p>
<div id="sGate"></div>
<div class="s-bal" id="sBal" style="display:none">
<div><div class="big" id="sRemain"></div><div class="lab">impressions left this month</div></div>
<div><div class="big" id="sLimit" style="color:var(--teal)"></div><div class="lab">your monthly allowance</div></div>
</div>
<div class="s-card" id="sCard">
<label class="s-lab">What angle should the versions take?</label>
<div class="s-chips" id="sAngles"></div>
<div class="s-row">
<button id="sWrite" class="btn btn-secondary">✍️ Write me some versions</button>
<span class="s-hint" id="sWriteMeter" style="margin:0"></span>
</div>
<div id="sVariants"></div>
<label class="s-lab">Where should they send people?</label>
<div class="s-chips" id="sTargets"></div>
<label class="s-lab" for="sImp">Total impressions for the test</label>
<input type="number" id="sImp" min="1000" step="500" value="5000">
<div class="s-hint" id="sSplitHint"></div>
<div class="s-row"><button id="sGo" class="btn btn-primary">🔬 Start the test</button></div>
<div id="sErr"></div>
<div id="sOk"></div>
</div>
<div id="sTestsWrap" style="display:none">
<h2 style="font-size:20px;margin:22px 0 10px">Your tests</h2>
<div id="sTests"></div>
</div>
<p class="s-note"><strong style="color:var(--text)">Why this is deliberately slow to declare a winner:</strong> 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. <br><br>Independent team resource · No income is guaranteed · Cryptocurrency involves risk.</p>
</main>
<script src="/suite-split.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>
+289
View File
@@ -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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 × <b>' + n(per) + '</b> impressions each. ' +
(per < state.minPerArm
? '<span style="color:#f0a05a">Too thin to mean anything — use at least ' + n(need) + ' total for ' + arms + ' versions.</span>'
: 'Comes out of this month\'s allowance; stopping the test returns whatever has not served.');
}
async function writeAds(btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spin"></span>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 '<tr class="' + (best === a.arm ? 'win' : '') + '">' +
'<td><b>' + esc(a.arm) + '</b></td>' +
'<td><b>' + esc(a.subject) + '</b><br><span style="font-size:12px">' +
(a.lines || []).filter(Boolean).map(esc).join(' · ') + '</span></td>' +
'<td>' + n(a.served) + '</td>' +
'<td><b>' + n(a.clicks) + '</b></td>' +
'<td>' + (a.served > 0 ? (a.rate * 100).toFixed(3) + '%' : '—') + '</td></tr>';
}).join('');
box.innerHTML =
'<h3>Test from ' + esc(when) + ' — ' + n(t.total) + ' impressions across ' + (t.arms || []).length + ' versions' +
(t.stopped ? ' <span style="color:var(--muted);font-weight:400">(stopped, ' + n(t.refunded) + ' returned)</span>' : '') + '</h3>' +
'<div class="s-verdict ' + esc((t.verdict && t.verdict.state) || 'running') + '">' +
esc((t.verdict && t.verdict.text) || '') + '</div>' +
'<div class="table-wrap"><table class="s-tbl"><thead><tr><th></th><th>Version</th><th>Served</th><th>Clicks</th><th>Click rate</th></tr></thead><tbody>' +
rows + '</tbody></table></div>';
if (!t.stopped) {
var btn = document.createElement('button');
btn.className = 'btn btn-secondary btn-sm';
btn.style.marginTop = '10px';
btn.textContent = 'Stop this test';
btn.title = 'Stops every version and returns the unserved impressions';
btn.addEventListener('click', function () { stopTest(t.testId, btn); });
box.appendChild(btn);
}
host.appendChild(box);
});
}
async function stopTest(testId, btn) {
btn.disabled = true; btn.textContent = 'Stopping…';
try {
var r = await fetch('/api/public/suite-split', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stop: testId })
});
var d = await r.json();
if (!r.ok) {
$('sErr').textContent = d.error || 'Could not stop that test.';
$('sErr').style.display = 'block';
btn.disabled = false; btn.textContent = 'Stop this test';
return;
}
$('sOk').innerHTML = '✅ <b>Test stopped.</b> ' + n(d.stopped.refunded) + ' unserved impressions went back into your balance.';
$('sOk').style.display = 'block';
boot();
} catch (e) {
btn.disabled = false; btn.textContent = 'Stop this test';
}
}
function apply(d) {
state.id = d.id;
state.hasPage = !!d.hasPage;
state.minPerArm = d.minPerArm || 500;
state.maxArms = d.maxArms || 3;
state.angles = d.textAngles || state.angles;
if (!state.hasPage && state.target === 'page') state.target = 'join';
var tr = d.traffic || {};
state.remaining = tr.remaining || 0;
state.limit = tr.limit || 0;
$('sBal').style.display = 'flex';
$('sRemain').textContent = n(state.remaining);
$('sLimit').textContent = n(state.limit);
$('sImp').max = state.remaining;
if (Number($('sImp').value) > state.remaining) $('sImp').value = state.remaining;
if (d.textMeter) $('sWriteMeter').textContent = d.textMeter.remaining + ' of ' + d.textMeter.limit + ' batches left this month';
renderAngles(); renderTargets(); renderVariants(); splitHint();
renderTests(d.tests || []);
if (state.remaining < state.minPerArm * 2) {
$('sGo').disabled = true;
$('sGo').textContent = 'Not enough impressions left this month';
}
}
async function run() {
if (busy) return;
$('sErr').style.display = 'none';
$('sOk').style.display = 'none';
if (state.picked.length < 2) {
$('sErr').textContent = 'Pick at least two versions to compare.';
$('sErr').style.display = 'block';
return;
}
busy = true;
$('sGo').disabled = true;
$('sGo').innerHTML = '<span class="spin"></span>Launching every version…';
try {
var arms = state.picked.map(function (i) {
return { subject: state.variants[i].subject, lines: state.variants[i].lines };
});
var r = await fetch('/api/public/suite-split', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ arms: arms, impressions: Number($('sImp').value) || 0, target: state.target, angle: state.angle })
});
var d = await r.json();
if (!r.ok) {
$('sErr').textContent = d.error || 'That did not go through — try again.';
$('sErr').style.display = 'block';
} else {
$('sOk').innerHTML = '✅ <b>Test running.</b> ' + d.test.arms.length + ' versions, ' +
n(d.test.perArm) + ' impressions each. Check back in a few days — display clicks take a while to accumulate.';
$('sOk').style.display = 'block';
setTimeout(boot, 800);
}
} catch (e) {
$('sErr').textContent = 'Connection hiccup — try again.';
$('sErr').style.display = 'block';
}
busy = false;
$('sGo').disabled = false;
$('sGo').textContent = '🔬 Start the test';
}
async function boot() {
try {
var r = await fetch('/api/public/suite-split');
if (r.status === 401) { gate('Youre 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;
apply(await r.json());
} catch (e) {}
}
document.addEventListener('DOMContentLoaded', function () {
boot();
$('sGo').addEventListener('click', run);
$('sWrite').addEventListener('click', function () { writeAds(this); });
$('sImp').addEventListener('input', splitHint);
});
})();
+46
View File
@@ -0,0 +1,46 @@
<!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="Voice Profile — teach the Copy Engine and Email Engine to write the way you actually talk."><title>Voice Profile | The Circle Suite</title><link rel="icon" type="image/png" href="/favicon.png"><link rel="stylesheet" href="/styles.css">
<style>
.vw{max-width:780px;margin:0 auto;padding:24px 20px 70px}
.vw h1{font-size:clamp(26px,4.5vw,36px);margin:6px 0 8px}
.vw .lede{color:var(--muted);line-height:1.6;margin-bottom:18px}
#vGate{display:none;border:2px solid var(--gold);border-radius:12px;padding:16px 20px;margin-bottom:18px;line-height:1.6}
.v-card{background:var(--card,#0d2236);border:1px solid var(--line,#24425d);border-radius:14px;padding:18px 20px;margin-bottom:16px}
.v-f{margin-bottom:18px}
.v-f label{display:block;font-weight:700;font-size:15px;margin-bottom:4px}
.v-f .hint{color:var(--muted);font-size:12.5px;line-height:1.5;margin-bottom:7px}
.v-f textarea{width:100%;background:var(--bg,#0b1d2e);border:1px solid var(--line);border-radius:10px;
color:var(--text);padding:10px 12px;font:inherit;font-size:14.5px;line-height:1.55;resize:vertical}
.v-f textarea:focus{outline:2px solid var(--teal);outline-offset:1px}
.v-count{font-size:11.5px;color:var(--muted);text-align:right;margin-top:3px;font-variant-numeric:tabular-nums}
.v-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:8px}
#vErr{display:none;color:#f0a05a;margin-top:12px;font-size:14px;line-height:1.55}
#vOk{display:none;border:2px solid var(--teal);border-radius:12px;padding:14px 18px;margin-top:14px;line-height:1.6}
.v-state{border:2px solid var(--teal);border-radius:14px;padding:14px 18px;margin-bottom:18px;line-height:1.6}
.v-state.off{border-color:var(--line);color:var(--muted)}
.v-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>Voice Profile</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="vw">
<div class="eyebrow" style="color:var(--gold)">CIRCLE SUITE · CULMEN (LEVEL 4)</div>
<h1>Your <span class="gold">voice.</span></h1>
<p class="lede">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.</p>
<div id="vGate"></div>
<div class="v-state off" id="vState">Loading…</div>
<div class="v-card" id="vCard">
<div id="vFields"></div>
<div class="v-row">
<button id="vSave" class="btn btn-primary">Save my voice</button>
<button id="vClear" class="btn btn-secondary">Clear it</button>
</div>
<div id="vErr"></div>
<div id="vOk"></div>
</div>
<p class="v-note"><strong style="color:var(--text)">One thing this will not do:</strong> 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. <br><br>Independent team resource · No income is guaranteed · Cryptocurrency involves risk.</p>
</main>
<script src="/suite-voice.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>
+148
View File
@@ -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 = '✅ <b>Your voice profile is on.</b> 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 — <b>especially the writing sample</b>, ' +
'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
? '✅ <b>Saved.</b> Head to the <a href="/suite/copy" style="color:var(--gold);font-weight:700">Copy Engine</a> and write something — it should sound noticeably more like you.'
: '✅ <b>Saved.</b> 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('Youre 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.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);
});
})();
+5 -4
View File
@@ -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: 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: '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 teams master promo videos rendered with your personal end-card — your name, your QR, your link.', href: '/suite/video', live: true }, { lv: 3, ico: '🎥', name: 'Video Maker', desc: 'The teams 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 teams 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: 1, ico: '🚦', name: 'Traffic Desk', desc: 'Syndicated network display advertising — run banners or AI-written text ads on the teams 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: 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: 'Team radar, AI coaching digests for your legs, and cohort training rooms.', live: false }, { 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: 'Your own AI operator running your promotion, API access, and the inner circle.', live: false } { 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); }; var $ = function (id) { return document.getElementById(id); };
+120 -3
View File
@@ -25,6 +25,9 @@ const suiteEmail = require('./suite-email');
const suiteVideo = require('./suite-video'); suiteVideo.init({ dataDir: DATA_DIR, publicDir: PUBLIC_DIR }); const suiteVideo = require('./suite-video'); suiteVideo.init({ dataDir: DATA_DIR, publicDir: PUBLIC_DIR });
const suiteTraffic = require('./suite-traffic'); suiteTraffic.init({ dataDir: DATA_DIR }); const suiteTraffic = require('./suite-traffic'); suiteTraffic.init({ dataDir: DATA_DIR });
const suiteTextAds = require('./suite-textads'); 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'); const tgbot = require('./tgbot');
tgbot.init({ dataDir: DATA_DIR, chain, getConfig, messages, baseUrl: 'https://rmcircle.team' }); tgbot.init({ dataDir: DATA_DIR, chain, getConfig, messages, baseUrl: 'https://rmcircle.team' });
const SESSION_TTL = 8 * 60 * 60 * 1000; const SESSION_TTL = 8 * 60 * 60 * 1000;
@@ -669,6 +672,21 @@ async function handleApi(req,res,pathname){
} }
// ── Page Builder ───────────────────────────────────────────────────────── // ── Page Builder ─────────────────────────────────────────────────────────
// Level 6 extra pages: /p/<id>/<slug>. Falls back to the member's main page
// rather than 404ing, for the same reason /p/<id> falls back to /join/<id>.
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)){ if(req.method==='GET'&&/^\/p\/\d{1,15}$/.test(pathname)){
const pid=pathname.split('/')[2]; const pid=pathname.split('/')[2];
const rec=suitePages.load(pid); const rec=suitePages.load(pid);
@@ -736,7 +754,7 @@ async function handleApi(req,res,pathname){
} }
try{ try{
const link='https://rmcircle.team/join/'+e.d.id; 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); 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')}); 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)}); } }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)}); } }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)<suiteSplit.MIN_LEVEL)return json(res,403,{error:'The Split Tester unlocks at Apex (level 5).',minLevel:suiteSplit.MIN_LEVEL});
if(req.method==='GET'){
let r=[]; try{ r=await suiteSplit.results(e.d.id); }catch(err){}
const pg=suitePages.load(e.d.id);
return json(res,200,{tests:r,level:e.d.level,id:e.d.id,
traffic:suiteTraffic.status(e.d.id,e.d.level),hasPage:!!(pg&&pg.copy),
minPerArm:suiteSplit.MIN_PER_ARM,maxArms:suiteSplit.MAX_ARMS,
textAngles:suiteTextAds.angles(),
textMeter:suiteMeter.check(e.d.id,e.d.level,'textad')});
}
if(req.method==='POST'){
const b=await bodyJson(req)||{};
if(b.stop){
try{ return json(res,200,{stopped:await suiteSplit.stop(e.d.id,String(b.stop))}); }
catch(err){ return json(res,400,{error:String(err.message||err)}); }
}
const pg=suitePages.load(e.d.id);
try{
const t=await suiteSplit.launch({id:e.d.id,level:e.d.level,arms:b.arms,
impressions:b.impressions,target:b.target,angle:b.angle,hasPage:!!(pg&&pg.copy)});
return json(res,200,{test:t,traffic:suiteTraffic.status(e.d.id,e.d.level)});
}catch(err){ return json(res,400,{error:String(err.message||err)}); }
}
}
// -- Funnel Factory: extra pages (level 6) --------------------------------
if(pathname==='/api/public/suite-funnel'){
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)<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)<suiteLeader.MIN_LEVEL)return json(res,403,{error:'Leader Ops unlocks at Vertex (level 7).',minLevel:suiteLeader.MIN_LEVEL});
if(req.method==='GET'){
return json(res,200,{scan:suiteLeader.scan(e.d.id,12),level:e.d.level,id:e.d.id,writerReady:suiteAI.configured()});
}
if(req.method==='POST'){
const gate=suiteMeter.check(e.d.id,e.d.level,'copy');
if(!gate.allowed)return json(res,429,{error:'That is this month of generation allowance used up.',meter:gate});
try{
const r=await suiteLeader.digest(e.d.id,{maxItems:12});
suiteMeter.record(e.d.id,'copy',1);
return json(res,200,{scan:r.scan,text:r.text,meter:suiteMeter.check(e.d.id,e.d.level,'copy')});
}catch(err){ return json(res,502,{error:String(err.message||err)}); }
}
}
if(req.method==='POST'&&pathname==='/api/public/suite-traffic-stop'){ if(req.method==='POST'&&pathname==='/api/public/suite-traffic-stop'){
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500})); 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.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; const link='https://rmcircle.team/join/'+e.d.id;
try{ 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); suiteMeter.record(e.d.id,'copy',1);
return json(res,200,{text:text,meter:suiteMeter.check(e.d.id,e.d.level,'copy')}); return json(res,200,{text:text,meter:suiteMeter.check(e.d.id,e.d.level,'copy')});
}catch(err){ }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]); if((mj=pathname.match(/^\/join\/(\d{1,15})$/)))return serveMemberPage(req,res,path.join(PUBLIC_DIR,'join.html'),'join',mj[1]);
} }
let file; 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=''; 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); if(file&&staticFile(req,res,file))return;return staticFile(req,res,path.join(PUBLIC_DIR,'404.html'),404);
+4
View File
@@ -56,6 +56,10 @@ function buildMessages(kind, brief, member) {
'You write promotional copy for a member of the RM Circle team.\n\n' + 'You write promotional copy for a member of the RM Circle team.\n\n' +
'WHAT IT IS: ' + FACTS + '\n\n' + 'WHAT IT IS: ' + FACTS + '\n\n' +
'VOICE: ' + VOICE + '\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' + 'COMPLIANCE (absolute, overrides everything else): ' + COMPLIANCE + '\n\n' +
'FORMAT: ' + k.shape + '\n\n' + 'FORMAT: ' + k.shape + '\n\n' +
(link (link
+6 -1
View File
@@ -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' + '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' + (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') + (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 : '')
); );
} }
+88
View File
@@ -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 };
+32 -4
View File
@@ -14,10 +14,38 @@ const suiteAI = require('./suite-ai');
let DATA_DIR = null; let DATA_DIR = null;
function init(opts) { DATA_DIR = opts.dataDir; } 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 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 <id>.json and lives at /p/<id>. Level 6 adds extra
// named pages, stored as <id>--<slug>.json and served at /p/<id>/<slug>, 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 load(id, slug) { try { return JSON.parse(fs.readFileSync(file(id, slug), 'utf8')); } catch (e) { return null; } }
function save(id, rec) { fs.writeFileSync(file(id), JSON.stringify(rec)); return rec; } 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. // Angles reuse the team's existing hook videos + their landing variant.
const ANGLES = { const ANGLES = {
@@ -138,4 +166,4 @@ function render(rec) {
'</body></html>'; '</body></html>';
} }
module.exports = { init, load, save, generate, render, ANGLES, parseSections }; module.exports = { init, load, save, remove, list, slugify, generate, render, ANGLES, parseSections };
+177
View File
@@ -0,0 +1,177 @@
// Circle Suite — Split Tester (level 5).
//
// Level 5 (Apex) is where the Traffic Desk allowance jumps from 20,000 to
// 50,000 impressions a month — the biggest single jump in the ladder. That is
// the first point where a member has enough volume for a comparison between two
// ads to mean anything, so this is the tier where the tool belongs.
//
// What it does: launches 2-3 variants of the same ad AS ONE TEST, splitting the
// impressions evenly, then reads the click counters back and reports which one
// is winning. Nothing here is new plumbing — it creates ads through the same
// Traffic Desk path and reads the same stats — but grouping them into a test
// and doing the arithmetic is the difference between "some ads are running" and
// "this headline beats that one".
//
// The honest part matters most. Display click rates are tiny, so a 3-click
// difference on 400 impressions is noise, not a winner. This module refuses to
// declare a winner until the gap is big enough to be worth acting on, and says
// so plainly instead of showing a confident-looking number that isn't.
'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; }
function file() { return path.join(DATA_DIR, 'split-tests.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) {} }
const MIN_LEVEL = 5;
const MIN_PER_ARM = 500; // below this a comparison is meaningless
const MAX_ARMS = 3;
function tests(memberId) {
const all = readAll();
return (all[String(memberId)] || []).slice().reverse();
}
function record(memberId, test) {
const all = readAll();
const key = String(memberId);
if (!all[key]) all[key] = [];
all[key].push(test);
if (all[key].length > 40) all[key] = all[key].slice(-40);
writeAll(all);
}
function update(memberId, testId, patch) {
const all = readAll();
const rows = all[String(memberId)] || [];
const t = rows.find(function (r) { return r.testId === testId; });
if (!t) return null;
Object.keys(patch).forEach(function (k) { t[k] = patch[k]; });
writeAll(all);
return t;
}
// Launch every arm of a test. If an arm fails after others have gone live we
// stop the ones that succeeded, so a member is never left paying for half a
// test they cannot interpret.
async function launch(opts) {
const level = Number(opts.level) || 1;
if (level < MIN_LEVEL) throw new Error('The Split Tester unlocks at Apex (level 5).');
const arms = (Array.isArray(opts.arms) ? opts.arms : []).slice(0, MAX_ARMS);
if (arms.length < 2) throw new Error('A split test needs at least two versions to compare.');
const perArm = Math.floor((Number(opts.impressions) || 0) / arms.length);
if (perArm < MIN_PER_ARM) {
throw new Error('Give each version at least ' + MIN_PER_ARM.toLocaleString() +
' impressions or the result will not mean anything — that is ' +
(MIN_PER_ARM * arms.length).toLocaleString() + ' total for ' + arms.length + ' versions.');
}
const testId = 'st-' + Date.now().toString(36);
const launched = [];
try {
for (let i = 0; i < arms.length; i++) {
const a = arms[i];
const entry = await suiteTraffic.launch({
id: opts.id, level: level, kind: 'text',
subject: a.subject, lines: a.lines,
impressions: perArm, target: opts.target, angle: opts.angle,
hasPage: opts.hasPage,
name: 'RM Circle #' + opts.id + ' split ' + String.fromCharCode(65 + i)
});
launched.push({ arm: String.fromCharCode(65 + i), adId: entry.adId, subject: a.subject, lines: a.lines });
}
} catch (err) {
// Roll back whatever already went live — a half-launched test is worse
// than no test, and the member gets their impressions back either way.
for (const l of launched) {
try { await suiteTraffic.stop(opts.id, l.adId); } catch (e) {}
}
throw new Error('Could not launch the whole test, so nothing was left running: ' + (err.message || err));
}
const test = {
testId: testId, at: new Date().toISOString(), perArm: perArm,
total: perArm * arms.length, arms: launched, stopped: false
};
record(opts.id, test);
return test;
}
// Read live counters and work out where the test stands.
async function results(memberId) {
const rows = tests(memberId);
if (!rows.length) return [];
const ids = [];
rows.forEach(function (t) { t.arms.forEach(function (a) { ids.push(a.adId); }); });
let live = [];
try { live = await suiteTraffic.stats(ids); } catch (e) { live = []; }
const byId = {};
live.forEach(function (l) { byId[l.ad_id] = l; });
return rows.map(function (t) {
const arms = t.arms.map(function (a) {
const l = byId[a.adId] || {};
const served = Math.max(0, Number(l.served) || 0);
const clicks = Math.max(0, Number(l.hits) || 0);
return {
arm: a.arm, adId: a.adId, subject: a.subject, lines: a.lines,
served: served, clicks: clicks,
rate: served > 0 ? clicks / served : 0,
live: !!l.live
};
});
return Object.assign({}, t, { armResults: arms, verdict: verdict(arms) });
});
}
// Deliberately conservative. Display advertising produces very low click rates,
// so small gaps are noise. We require BOTH arms to have real volume behind them
// and the leader to be clearly ahead before we call anything.
function verdict(arms) {
const withVolume = arms.filter(function (a) { return a.served >= MIN_PER_ARM / 2; });
if (withVolume.length < 2) {
return { state: 'running', text: 'Still gathering impressions — too early to compare.' };
}
const sorted = arms.slice().sort(function (x, y) { return y.rate - x.rate; });
const top = sorted[0], next = sorted[1];
const totalClicks = arms.reduce(function (s, a) { return s + a.clicks; }, 0);
if (totalClicks < 10) {
return { state: 'running', text: 'Only ' + totalClicks + ' clicks so far across all versions — not enough to call it yet.' };
}
// Require a 30% relative edge AND at least a few clicks of absolute margin.
const edge = next.rate > 0 ? (top.rate - next.rate) / next.rate : 1;
if (edge < 0.3 || (top.clicks - next.clicks) < 3) {
return { state: 'tie', text: 'No clear winner yet — the versions are performing about the same. That is a real result too: the difference between them is not what is holding the ad back.' };
}
return {
state: 'winner', winner: top.arm,
text: 'Version ' + top.arm + ' is ahead — ' + top.clicks + ' clicks from ' +
top.served.toLocaleString() + ' impressions, against ' + next.clicks + ' from ' +
next.served.toLocaleString() + '. Run the winner on its own next time.'
};
}
// Stop every arm at once and return the unserved impressions.
async function stop(memberId, testId) {
const rows = tests(memberId);
const t = rows.find(function (r) { return r.testId === testId; });
if (!t) throw new Error('That test is not one of yours.');
if (t.stopped) throw new Error('That test is already stopped.');
let refunded = 0;
for (const a of t.arms) {
try { const r = await suiteTraffic.stop(memberId, a.adId); refunded += r.refunded || 0; }
catch (e) { /* already stopped or gone — keep going, the rest still matter */ }
}
update(memberId, testId, { stopped: true, stoppedAt: new Date().toISOString(), refunded: refunded });
return { testId: testId, refunded: refunded };
}
module.exports = { init, launch, results, stop, tests, verdict, MIN_LEVEL, MIN_PER_ARM, MAX_ARMS };
+10 -10
View File
@@ -10,23 +10,23 @@
'use strict'; 'use strict';
const LEVEL_TOOLS = { const LEVEL_TOOLS = {
1: { name: 'Scintilla', live: true, short: 'the Launch Kit — promo center, printable handouts with your QR, the Circle Method course, your dashboard and the AI coach' }, 1: { name: 'Scintilla', live: true, short: 'the Launch Kit and the Traffic Desk — promo center, printable handouts with your QR, the Circle Method course, your dashboard, the AI coach, and banners or text ads on our own ad network' },
2: { name: 'Ascensus', live: true, short: 'the Copy Engine and Page Builder — an AI copywriter for your posts and DMs, plus your own hosted invitation page' }, 2: { name: 'Ascensus', live: true, short: 'the Copy Engine and Page Builder — an AI copywriter for your posts and DMs, plus your own hosted invitation page' },
3: { name: 'Fabrica', live: false, short: 'the Email Engine and Video Maker' }, 3: { name: 'Fabrica', live: true, short: 'the Email Engine and Video Maker — full sequences in the team voice, and the promo videos rendered with your own end card' },
4: { name: 'Culmen', live: false, short: 'your own Voice Profile and multi-page funnels' }, 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: false, short: 'the Traffic Desk — syndicated network display advertising' }, 5: { name: 'Apex', live: true, short: 'the Split Tester, and your ad allowance jumps to 50,000 impressions a month' },
6: { name: 'Fastigium', live: false, short: 'the Funnel Factory and Replay Funnels' }, 6: { name: 'Fastigium', live: true, short: 'the Funnel Factory — a separate hosted landing page for every audience you talk to' },
7: { name: 'Vertex', live: false, short: 'Leader Ops — team radar and coaching digests' }, 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: false, short: 'the Founder Desk — your own AI operator' } 8: { name: 'Corona', live: true, short: 'the Founder Desk — every tool at its highest allowance, and the top of the ladder' }
}; };
// Very short form for character-limited surfaces (X/Twitter). // Very short form for character-limited surfaces (X/Twitter).
const TERSE = { const TERSE = {
1: 'the Launch Kit', 1: 'the Launch Kit + Traffic Desk',
2: 'the Copy Engine + Page Builder', 2: 'the Copy Engine + Page Builder',
3: 'the Email Engine + Video Maker', 3: 'the Email Engine + Video Maker',
4: 'Voice Profile + funnels', 4: 'your own Voice Profile',
5: 'the Traffic Desk', 5: 'the Split Tester + 50k impressions',
6: 'the Funnel Factory', 6: 'the Funnel Factory',
7: 'Leader Ops', 7: 'Leader Ops',
8: 'the Founder Desk' 8: 'the Founder Desk'
+94
View File
@@ -0,0 +1,94 @@
// Circle Suite — Voice Profile (level 4).
//
// The Copy Engine writes in the TEAM voice. That is the right default: it is
// compliant, it is consistent, and a brand-new member has no voice of their own
// to write in yet. But a member who has been at this a while sounds like
// themselves, and copy that sounds like the team instead of like them is the
// first thing their own audience notices.
//
// So this is not a second engine. It is a small profile that rides along with
// every Copy Engine and Email Engine generation and biases the output toward
// how this particular person actually talks.
//
// Deliberately kept short. Five specific answers beat a long questionnaire: the
// profile becomes prompt text, and prompt text that rambles just dilutes the
// instructions that matter (the compliance block especially).
'use strict';
const fs = require('fs');
const path = require('path');
let DATA_DIR = null;
function init(opts) {
DATA_DIR = opts.dataDir;
try { fs.mkdirSync(dir(), { recursive: true }); } catch (e) {}
}
function dir() { return path.join(DATA_DIR, 'voice'); }
function file(id) { return path.join(dir(), String(Number(id)) + '.json'); }
// field -> {label, hint, max}. Order is the order they appear in the form.
const FIELDS = [
{ key: 'background', label: 'What did you do before this?',
hint: 'A trade, a job, a business, retired, still working — one line is plenty.', max: 220 },
{ key: 'audience', label: 'Who are you usually talking to?',
hint: 'Friends and family? Other marketers? People in your church, your gym, your old industry?', max: 220 },
{ key: 'tone', label: 'How would a friend describe the way you talk?',
hint: 'Blunt. Funny. Careful. Encouraging. Skeptical. Pick a few real words.', max: 160 },
{ key: 'never', label: 'What would you never say?',
hint: 'Words or promises that would make you cringe coming out of your own mouth.', max: 220 },
{ key: 'sample', label: 'Paste something you actually wrote',
hint: 'A post, a text, an email — anything a few sentences long, in your own words. This teaches it more than the rest combined.', max: 900 }
];
function blank() {
const o = {};
FIELDS.forEach(function (f) { o[f.key] = ''; });
return o;
}
function load(id) {
try { return JSON.parse(fs.readFileSync(file(id), 'utf8')); } catch (e) { return null; }
}
function save(id, body) {
const rec = blank();
FIELDS.forEach(function (f) {
rec[f.key] = String((body && body[f.key]) || '').replace(/\s+/g, ' ').trim().slice(0, f.max);
});
rec.id = Number(id);
rec.updatedAt = new Date().toISOString();
rec.complete = complete(rec);
try { fs.writeFileSync(file(id), JSON.stringify(rec)); } catch (e) {}
return rec;
}
function clear(id) {
try { fs.unlinkSync(file(id)); } catch (e) {}
return true;
}
// "Complete enough to be worth using" — not every field, because the writing
// sample alone carries most of the signal.
function complete(rec) {
if (!rec) return false;
const filled = FIELDS.filter(function (f) { return String(rec[f.key] || '').trim().length > 2; }).length;
return filled >= 2 && String(rec.sample || rec.tone || '').trim().length > 10;
}
// Render the profile as prompt text. Returns '' when there is nothing useful,
// so callers can concatenate unconditionally.
function promptBlock(id) {
const rec = load(id);
if (!complete(rec)) return '';
const bits = [];
if (rec.background) bits.push('Their background: ' + rec.background);
if (rec.audience) bits.push('Who they are writing to: ' + rec.audience);
if (rec.tone) bits.push('How they come across: ' + rec.tone);
if (rec.never) bits.push('Things they would never say — avoid these completely: ' + rec.never);
if (rec.sample) bits.push('A sample of their own writing, for rhythm and word choice (do NOT copy its content, only its voice):\n"""\n' + rec.sample + '\n"""');
if (!bits.length) return '';
return 'WRITE AS THIS PERSON. Match their rhythm, vocabulary and level of formality. ' +
'This overrides the generic team voice above, EXCEPT for the compliance rules, which are absolute and can never be relaxed to sound more like them.\n' +
bits.join('\n');
}
module.exports = { init, FIELDS, blank, load, save, clear, complete, promptBlock };