3f8d23d66c
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>
290 lines
11 KiB
JavaScript
290 lines
11 KiB
JavaScript
// 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);
|
||
});
|
||
})();
|