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:
@@ -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>
|
||||
@@ -0,0 +1,159 @@
|
||||
// Funnel Factory client — lists the member's pages and builds new named ones.
|
||||
(function () {
|
||||
'use strict';
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
var state = { angles: {}, angle: 'overview', id: null, pages: [] };
|
||||
|
||||
function gate(msg) {
|
||||
$('fGate').style.display = 'block';
|
||||
$('fGate').innerHTML = msg;
|
||||
}
|
||||
|
||||
function renderAngles() {
|
||||
var host = $('fAngles');
|
||||
host.innerHTML = '';
|
||||
Object.keys(state.angles).forEach(function (k) {
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'f-chip' + (state.angle === k ? ' on' : '');
|
||||
b.textContent = state.angles[k].label || k;
|
||||
b.addEventListener('click', function () { state.angle = k; renderAngles(); });
|
||||
host.appendChild(b);
|
||||
});
|
||||
}
|
||||
|
||||
function renderPages() {
|
||||
var host = $('fPages');
|
||||
host.innerHTML = '';
|
||||
if (!state.pages.length) {
|
||||
host.innerHTML = '<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('You’re not signed in yet. <a href="/suite" style="color:var(--gold);font-weight:700">Open the Suite</a> and connect the wallet that holds your position, then come back.'); return; }
|
||||
if (r.status === 403) {
|
||||
var g = await r.json();
|
||||
gate((g.error || 'Not open for this position yet.') + ' <a href="/suite" style="color:var(--gold);font-weight:700">See your Suite</a>.');
|
||||
return;
|
||||
}
|
||||
if (!r.ok) return;
|
||||
var d = await r.json();
|
||||
state.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);
|
||||
});
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -0,0 +1,128 @@
|
||||
// Leader Ops client — renders the coaching radar for the signed-in leader's own
|
||||
// organisation, then asks the engine for a written plan on request.
|
||||
(function () {
|
||||
'use strict';
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
function n(v) { return Number(v || 0).toLocaleString(); }
|
||||
|
||||
function gate(msg) {
|
||||
$('lGate').style.display = 'block';
|
||||
$('lGate').innerHTML = msg;
|
||||
}
|
||||
|
||||
function tile(value, label, cls) {
|
||||
return '<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(' · ') +
|
||||
'</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('You’re not signed in yet. <a href="/suite" style="color:var(--gold);font-weight:700">Open the Suite</a> and connect the wallet that holds your position, then come back.'); return; }
|
||||
if (r.status === 403) {
|
||||
var g = await r.json();
|
||||
gate((g.error || 'Not open for this position yet.') + ' <a href="/suite" style="color:var(--gold);font-weight:700">See your Suite</a>.');
|
||||
return;
|
||||
}
|
||||
if (!r.ok) return;
|
||||
var d = await r.json();
|
||||
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); });
|
||||
});
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -0,0 +1,289 @@
|
||||
// Split Tester client. Reuses the text-ad writer to produce candidates, then
|
||||
// launches the chosen ones as one test through the Traffic Desk.
|
||||
(function () {
|
||||
'use strict';
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
function n(v) { return Number(v || 0).toLocaleString(); }
|
||||
|
||||
var state = {
|
||||
angle: 'general', angles: [], variants: [], picked: [], target: 'join',
|
||||
remaining: 0, limit: 0, hasPage: false, minPerArm: 500, maxArms: 3, id: null
|
||||
};
|
||||
var busy = false;
|
||||
|
||||
function chip(label, on, fn) {
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 's-chip' + (on ? ' on' : '');
|
||||
b.textContent = label;
|
||||
b.addEventListener('click', fn);
|
||||
return b;
|
||||
}
|
||||
|
||||
function gate(msg) {
|
||||
$('sGate').style.display = 'block';
|
||||
$('sGate').innerHTML = msg;
|
||||
$('sCard').style.opacity = '.55';
|
||||
$('sGo').disabled = true;
|
||||
$('sWrite').disabled = true;
|
||||
}
|
||||
|
||||
function renderAngles() {
|
||||
var host = $('sAngles');
|
||||
host.innerHTML = '';
|
||||
state.angles.forEach(function (a) {
|
||||
host.appendChild(chip(a.label, state.angle === a.key, function () {
|
||||
state.angle = a.key; renderAngles();
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
function renderTargets() {
|
||||
var host = $('sTargets');
|
||||
host.innerHTML = '';
|
||||
host.appendChild(chip('My invite page', state.target === 'join', function () {
|
||||
state.target = 'join'; renderTargets();
|
||||
}));
|
||||
if (state.hasPage) {
|
||||
host.appendChild(chip('My personal page', state.target === 'page', function () {
|
||||
state.target = 'page'; renderTargets();
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function renderVariants() {
|
||||
var host = $('sVariants');
|
||||
host.innerHTML = '';
|
||||
if (!state.variants.length) return;
|
||||
var grid = document.createElement('div');
|
||||
grid.className = 's-ads';
|
||||
state.variants.forEach(function (v, i) {
|
||||
var pickIdx = state.picked.indexOf(i);
|
||||
var card = document.createElement('div');
|
||||
card.className = 's-ad' + (pickIdx !== -1 ? ' on' : '');
|
||||
if (pickIdx !== -1) {
|
||||
var tag = document.createElement('span');
|
||||
tag.className = 'tag';
|
||||
tag.textContent = String.fromCharCode(65 + pickIdx);
|
||||
card.appendChild(tag);
|
||||
}
|
||||
var s = document.createElement('div');
|
||||
s.className = 's'; s.textContent = v.subject;
|
||||
card.appendChild(s);
|
||||
v.lines.forEach(function (l) {
|
||||
if (!l) return;
|
||||
var d = document.createElement('div');
|
||||
d.className = 'l'; d.textContent = l;
|
||||
card.appendChild(d);
|
||||
});
|
||||
card.addEventListener('click', function () {
|
||||
var at = state.picked.indexOf(i);
|
||||
if (at !== -1) state.picked.splice(at, 1);
|
||||
else if (state.picked.length < state.maxArms) state.picked.push(i);
|
||||
renderVariants(); splitHint();
|
||||
});
|
||||
grid.appendChild(card);
|
||||
});
|
||||
host.appendChild(grid);
|
||||
var hint = document.createElement('div');
|
||||
hint.className = 's-hint';
|
||||
hint.textContent = 'Tap 2 or 3 of these to put them head to head. Tap again to unpick.';
|
||||
host.appendChild(hint);
|
||||
}
|
||||
|
||||
function splitHint() {
|
||||
var arms = state.picked.length;
|
||||
var total = Number($('sImp').value) || 0;
|
||||
var el = $('sSplitHint');
|
||||
if (arms < 2) {
|
||||
el.textContent = 'Pick at least two versions above first.';
|
||||
return;
|
||||
}
|
||||
var per = Math.floor(total / arms);
|
||||
var need = state.minPerArm * arms;
|
||||
el.innerHTML = arms + ' versions × <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('You’re not signed in yet. <a href="/suite" style="color:var(--gold);font-weight:700">Open the Suite</a> and connect the wallet that holds your position, then come back.'); return; }
|
||||
if (r.status === 403) {
|
||||
var g = await r.json();
|
||||
gate((g.error || 'Not open for this position yet.') + ' <a href="/suite" style="color:var(--gold);font-weight:700">See your Suite</a>.');
|
||||
return;
|
||||
}
|
||||
if (!r.ok) return;
|
||||
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);
|
||||
});
|
||||
})();
|
||||
@@ -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>
|
||||
@@ -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('You’re not signed in yet. <a href="/suite" style="color:var(--gold);font-weight:700">Open the Suite</a> and connect the wallet that holds your position, then come back.'); return; }
|
||||
if (r.status === 403) {
|
||||
var g = await r.json();
|
||||
gate((g.error || 'Not open for this position yet.') + ' <a href="/suite" style="color:var(--gold);font-weight:700">See your Suite</a>.');
|
||||
return;
|
||||
}
|
||||
if (!r.ok) return;
|
||||
var d = await r.json();
|
||||
state.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
@@ -17,11 +17,12 @@
|
||||
{ lv: 2, ico: '🧱', name: 'Page Builder', desc: 'Answer four questions, get your own hosted invitation page — your story, your angle video, your QR.', href: '/suite/page', live: true },
|
||||
{ lv: 3, ico: '📧', name: 'Email Engine', desc: 'Welcome series, follow-up sequences and broadcasts in the team voice — exportable to any autoresponder.', href: '/suite/email', live: true },
|
||||
{ lv: 3, ico: '🎥', name: 'Video Maker', desc: 'The team’s master promo videos rendered with your personal end-card — your name, your QR, your link.', href: '/suite/video', live: true },
|
||||
{ lv: 4, ico: '🗣️', name: 'Voice Profile + Funnels', desc: 'Output that sounds like YOU, plus multi-page funnels on your own team subdomain.', live: false },
|
||||
{ lv: 4, ico: '🗣️', name: 'Voice Profile', desc: 'Teach the Copy Engine and Email Engine how you actually talk, so everything they write for you stops sounding like a company.', href: '/suite/voice', live: true },
|
||||
{ lv: 5, ico: '🔬', name: 'Split Tester', desc: 'Run two or three versions of an ad head to head and find out which wording people actually click. Apex is where your impressions jump to 50,000 — enough for the answer to mean something.', href: '/suite/split', live: true },
|
||||
{ lv: 1, ico: '🚦', name: 'Traffic Desk', desc: 'Syndicated network display advertising — run banners or AI-written text ads on the team’s own ad network. Monthly impressions scale with your level: 2,500 at Scintilla up to 150,000 at Corona.', href: '/suite/traffic', live: true },
|
||||
{ lv: 6, ico: '🏭', name: 'Funnel Factory', desc: 'Complete hosted funnels with A/B variants, replay funnels (your team webinar as an on-demand registration page with a timed CTA), and a lead CRM.', live: false },
|
||||
{ lv: 7, ico: '🧭', name: 'Leader Ops', desc: 'Team radar, AI coaching digests for your legs, and cohort training rooms.', live: false },
|
||||
{ lv: 8, ico: '👑', name: 'Founder Desk', desc: 'Your own AI operator running your promotion, API access, and the inner circle.', live: false }
|
||||
{ lv: 6, ico: '🏭', name: 'Funnel Factory', desc: 'A separate hosted landing page for every audience you talk to — each on its own address, each one something you can point a different ad at.', href: '/suite/funnel', live: true },
|
||||
{ lv: 7, ico: '🧭', name: 'Leader Ops', desc: 'Everyone below you, read live from the contract and sorted by who needs you most — plus a written coaching plan you can teach forward to your own two.', href: '/suite/leader', live: true },
|
||||
{ lv: 8, ico: '👑', name: 'Founder Desk', desc: 'Every tool in the Suite at its highest allowance, and the top of the ladder — nothing above this to unlock.', live: false }
|
||||
];
|
||||
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
|
||||
Reference in New Issue
Block a user