diff --git a/public/suite-funnel.html b/public/suite-funnel.html new file mode 100644 index 0000000..9fc4de7 --- /dev/null +++ b/public/suite-funnel.html @@ -0,0 +1,76 @@ +Funnel Factory | The Circle Suite + + + +
+
CIRCLE SUITE · FASTIGIUM (LEVEL 6)
+

Funnel Factory.

+

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.

+ +
+ + + +

Every page here is public and framable, so you can drop one into an ad, a QR code, or someone's inbox and it will just work. They all carry the same honest disclaimers as the rest of the site.

Independent team resource · No income is guaranteed · Cryptocurrency involves risk.

+
+ + + diff --git a/public/suite-funnel.js b/public/suite-funnel.js new file mode 100644 index 0000000..d5aaca5 --- /dev/null +++ b/public/suite-funnel.js @@ -0,0 +1,159 @@ +// Funnel Factory client — lists the member's pages and builds new named ones. +(function () { + 'use strict'; + var $ = function (id) { return document.getElementById(id); }; + function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + var state = { angles: {}, angle: 'overview', id: null, pages: [] }; + + function gate(msg) { + $('fGate').style.display = 'block'; + $('fGate').innerHTML = msg; + } + + function renderAngles() { + var host = $('fAngles'); + host.innerHTML = ''; + Object.keys(state.angles).forEach(function (k) { + var b = document.createElement('button'); + b.type = 'button'; + b.className = 'f-chip' + (state.angle === k ? ' on' : ''); + b.textContent = state.angles[k].label || k; + b.addEventListener('click', function () { state.angle = k; renderAngles(); }); + host.appendChild(b); + }); + } + + function renderPages() { + var host = $('fPages'); + host.innerHTML = ''; + if (!state.pages.length) { + host.innerHTML = '
You have no pages yet. Build your main one in the ' + + 'Page Builder first, then add more here.
'; + 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 = '
' + esc(p.name || 'Page') + + (p.slug ? '' : ' MAIN') + '
' + + '
rmcircle.team' + esc(p.url) + '
' + + '
' + esc((state.angles[p.angle] && state.angles[p.angle].label) || p.angle || '') + + (p.updatedAt ? ' · updated ' + esc(String(p.updatedAt).slice(0, 10)) : '') + '
'; + 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 = '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 = '✅ Built. It is live at ' + esc(d.url) + ''; + $('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. Open the Suite and connect the wallet that holds your position, then come back.'); return; } + if (r.status === 403) { + var g = await r.json(); + gate((g.error || 'Not open for this position yet.') + ' See your Suite.'); + return; + } + if (!r.ok) return; + var d = await r.json(); + state.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); + }); +})(); diff --git a/public/suite-leader.html b/public/suite-leader.html new file mode 100644 index 0000000..8fc064d --- /dev/null +++ b/public/suite-leader.html @@ -0,0 +1,56 @@ +Leader Ops | The Circle Suite + + + +
+
CIRCLE SUITE · VERTEX (LEVEL 7)
+

Leader Ops.

+

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.

+ +
+ + +

Every number here comes off the contract, not an estimate — positions, levels, and the POL forming below each person. Nothing on this page is a projection or a promise of what anyone will earn.

Coach people, don't chase them. Independent team resource · No income is guaranteed · Cryptocurrency involves risk.

+
+ + + diff --git a/public/suite-leader.js b/public/suite-leader.js new file mode 100644 index 0000000..a1fe2e7 --- /dev/null +++ b/public/suite-leader.js @@ -0,0 +1,128 @@ +// Leader Ops client — renders the coaching radar for the signed-in leader's own +// organisation, then asks the engine for a written plan on request. +(function () { + 'use strict'; + var $ = function (id) { return document.getElementById(id); }; + function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + function n(v) { return Number(v || 0).toLocaleString(); } + + function gate(msg) { + $('lGate').style.display = 'block'; + $('lGate').innerHTML = msg; + } + + function tile(value, label, cls) { + return '
' + value + '
' + + '
' + label + '
'; + } + + function renderScan(scan) { + if (!scan || !scan.ready) { + gate('The on-chain index is still catching up. Give it a moment and refresh.'); + return; + } + $('lBody').style.display = 'block'; + var t = scan.totals || {}; + + $('lTiles').innerHTML = + tile(n(scan.scanned), 'positions below you', 'cool') + + tile(n(t.atRiskPol), 'POL forming that someone below cannot catch', t.atRiskPol > 0 ? 'hot' : '') + + tile(n(t.oneAwayCount), 'one direct short of qualifying', t.oneAwayCount > 0 ? 'hot' : '') + + tile(n(t.rollForwardCount), 'qualified but still on Scintilla', ''); + + var out = []; + + if ((scan.atRisk || []).length) { + var rows = scan.atRisk.map(function (r) { + return '#' + esc(r.id) + '' + + '' + esc(r.levelName) + '' + + '' + (r.qualified + ? 'upgrade to ' + esc(r.neededLevelName || '') + '' + : '' + esc(r.directCount) + ' of 2 directs') + '' + + '' + n(r.atRiskPol) + '' + + '' + n(r.earnedPol) + '' + + '' + (r.upgradeCost ? n(r.upgradeCost) : '—') + ''; + }).join(''); + out.push('

Money forming they can\'t catch

' + + '
A position only catches an upgrade payment if it is qualified and already at or above the level being bought. These people have activity underneath them that is passing them by — the fastest, kindest conversation you can have this week.
' + + '
' + + rows + '
PositionLevelWhat they needPOL passing themEarnedUpgrade cost
'); + } + + if ((scan.rollForward || []).length) { + var rf = scan.rollForward.map(function (r) { + return '#' + esc(r.id) + '' + n(r.earnedPol) + '' + n(r.ascensusCost) + ''; + }).join(''); + out.push('

Already qualified, still on Scintilla

' + + '
They have their two and their entry rewards are sitting there. Ascensus is the upgrade that starts catching their directs\' payments — for several of these, what they have already earned covers it.
' + + '
' + + rf + '
PositionEarned (POL)Ascensus costs
'); + } + + if ((scan.oneAway || []).length) { + out.push('

One direct short

' + + '
A single placement qualifies each of these. If you have anyone coming in, this is where to put them.
' + + '
' + + scan.oneAway.map(function (r) { return '#' + esc(r.id) + ' (' + esc(r.levelName) + ')'; }).join('  ·  ') + + '
'); + } + + if (!out.length) { + out.push('

Nothing needs chasing

' + + '
Nobody below you is one direct short, sitting on uncatchable money, or qualified but stuck on Scintilla. That is the quiet week you spend working on your own two.
'); + } + + $('lSections').innerHTML = out.join(''); + } + + async function writePlan(btn) { + btn.disabled = true; + btn.innerHTML = 'Reading your organisation…'; + $('lErr').style.display = 'none'; + try { + var r = await fetch('/api/public/suite-leader', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }); + var d = await r.json(); + if (!r.ok) { + $('lErr').textContent = d.error || 'Could not write the plan just now.'; + $('lErr').style.display = 'block'; + } else { + $('lDigest').textContent = d.text || ''; + $('lDigest').style.display = 'block'; + if (d.scan) renderScan(d.scan); + } + } catch (e) { + $('lErr').textContent = 'Connection hiccup — try again.'; + $('lErr').style.display = 'block'; + } + btn.disabled = false; + btn.innerHTML = '🧭 Write this week\'s coaching plan'; + } + + async function boot() { + try { + var r = await fetch('/api/public/suite-leader'); + if (r.status === 401) { gate('You’re not signed in yet. Open the Suite and connect the wallet that holds your position, then come back.'); return; } + if (r.status === 403) { + var g = await r.json(); + gate((g.error || 'Not open for this position yet.') + ' See your Suite.'); + return; + } + if (!r.ok) return; + var d = await r.json(); + renderScan(d.scan); + if (d.writerReady === false) { + $('lWrite').disabled = true; + $('lWrite').textContent = 'The writer is warming up'; + } + } catch (e) {} + } + + document.addEventListener('DOMContentLoaded', function () { + boot(); + $('lWrite').addEventListener('click', function () { writePlan(this); }); + }); +})(); diff --git a/public/suite-split.html b/public/suite-split.html new file mode 100644 index 0000000..2529b1d --- /dev/null +++ b/public/suite-split.html @@ -0,0 +1,87 @@ +Split Tester | The Circle Suite + + + +
+
CIRCLE SUITE · APEX (LEVEL 5)
+

Split Tester.

+

Run two or three versions of an ad at the same time, split evenly, and find out which wording people actually click — instead of guessing. Apex is where your monthly impressions jump to 50,000, which is the first point a comparison like this can tell you anything real.

+ +
+ + + +
+ +
+
+ + +
+
+ + +
+ + + +
+ +
+
+
+
+ + + +

Why this is deliberately slow to declare a winner: click rates on display advertising are small, so a two- or three-click gap on a few hundred impressions is noise, not a result. This tool waits until one version is clearly ahead before it calls anything — and when the versions genuinely tie, it says so, because that is useful information too: it means the wording isn't what's holding the ad back.

Independent team resource · No income is guaranteed · Cryptocurrency involves risk.

+
+ + + diff --git a/public/suite-split.js b/public/suite-split.js new file mode 100644 index 0000000..9655180 --- /dev/null +++ b/public/suite-split.js @@ -0,0 +1,289 @@ +// Split Tester client. Reuses the text-ad writer to produce candidates, then +// launches the chosen ones as one test through the Traffic Desk. +(function () { + 'use strict'; + var $ = function (id) { return document.getElementById(id); }; + function esc(s) { + return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) { + return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]; + }); + } + function n(v) { return Number(v || 0).toLocaleString(); } + + var state = { + angle: 'general', angles: [], variants: [], picked: [], target: 'join', + remaining: 0, limit: 0, hasPage: false, minPerArm: 500, maxArms: 3, id: null + }; + var busy = false; + + function chip(label, on, fn) { + var b = document.createElement('button'); + b.type = 'button'; + b.className = 's-chip' + (on ? ' on' : ''); + b.textContent = label; + b.addEventListener('click', fn); + return b; + } + + function gate(msg) { + $('sGate').style.display = 'block'; + $('sGate').innerHTML = msg; + $('sCard').style.opacity = '.55'; + $('sGo').disabled = true; + $('sWrite').disabled = true; + } + + function renderAngles() { + var host = $('sAngles'); + host.innerHTML = ''; + state.angles.forEach(function (a) { + host.appendChild(chip(a.label, state.angle === a.key, function () { + state.angle = a.key; renderAngles(); + })); + }); + } + + function renderTargets() { + var host = $('sTargets'); + host.innerHTML = ''; + host.appendChild(chip('My invite page', state.target === 'join', function () { + state.target = 'join'; renderTargets(); + })); + if (state.hasPage) { + host.appendChild(chip('My personal page', state.target === 'page', function () { + state.target = 'page'; renderTargets(); + })); + } + } + + function renderVariants() { + var host = $('sVariants'); + host.innerHTML = ''; + if (!state.variants.length) return; + var grid = document.createElement('div'); + grid.className = 's-ads'; + state.variants.forEach(function (v, i) { + var pickIdx = state.picked.indexOf(i); + var card = document.createElement('div'); + card.className = 's-ad' + (pickIdx !== -1 ? ' on' : ''); + if (pickIdx !== -1) { + var tag = document.createElement('span'); + tag.className = 'tag'; + tag.textContent = String.fromCharCode(65 + pickIdx); + card.appendChild(tag); + } + var s = document.createElement('div'); + s.className = 's'; s.textContent = v.subject; + card.appendChild(s); + v.lines.forEach(function (l) { + if (!l) return; + var d = document.createElement('div'); + d.className = 'l'; d.textContent = l; + card.appendChild(d); + }); + card.addEventListener('click', function () { + var at = state.picked.indexOf(i); + if (at !== -1) state.picked.splice(at, 1); + else if (state.picked.length < state.maxArms) state.picked.push(i); + renderVariants(); splitHint(); + }); + grid.appendChild(card); + }); + host.appendChild(grid); + var hint = document.createElement('div'); + hint.className = 's-hint'; + hint.textContent = 'Tap 2 or 3 of these to put them head to head. Tap again to unpick.'; + host.appendChild(hint); + } + + function splitHint() { + var arms = state.picked.length; + var total = Number($('sImp').value) || 0; + var el = $('sSplitHint'); + if (arms < 2) { + el.textContent = 'Pick at least two versions above first.'; + return; + } + var per = Math.floor(total / arms); + var need = state.minPerArm * arms; + el.innerHTML = arms + ' versions × ' + n(per) + ' impressions each. ' + + (per < state.minPerArm + ? 'Too thin to mean anything — use at least ' + n(need) + ' total for ' + arms + ' versions.' + : 'Comes out of this month\'s allowance; stopping the test returns whatever has not served.'); + } + + async function writeAds(btn) { + btn.disabled = true; + btn.innerHTML = 'Writing…'; + $('sErr').style.display = 'none'; + try { + var r = await fetch('/api/public/suite-textads', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ angle: state.angle }) + }); + var d = await r.json(); + if (!r.ok) { + $('sErr').textContent = d.error || 'The writer could not produce versions just now.'; + $('sErr').style.display = 'block'; + } else { + state.variants = d.variants || []; + state.picked = state.variants.length >= 2 ? [0, 1] : []; + renderVariants(); splitHint(); + if (d.meter) $('sWriteMeter').textContent = d.meter.remaining + ' of ' + d.meter.limit + ' batches left this month'; + } + } catch (e) { + $('sErr').textContent = 'Connection hiccup — try again.'; + $('sErr').style.display = 'block'; + } + btn.disabled = false; + btn.innerHTML = state.variants.length ? '✍️ Write different versions' : '✍️ Write me some versions'; + } + + function renderTests(tests) { + if (!tests || !tests.length) { $('sTestsWrap').style.display = 'none'; return; } + $('sTestsWrap').style.display = 'block'; + var host = $('sTests'); + host.innerHTML = ''; + tests.forEach(function (t) { + var box = document.createElement('div'); + box.className = 's-test'; + var when = String(t.at || '').slice(0, 10); + var best = (t.verdict && t.verdict.winner) || null; + + var rows = (t.armResults || []).map(function (a) { + return '' + + '' + esc(a.arm) + '' + + '' + esc(a.subject) + '
' + + (a.lines || []).filter(Boolean).map(esc).join(' · ') + '' + + '' + n(a.served) + '' + + '' + n(a.clicks) + '' + + '' + (a.served > 0 ? (a.rate * 100).toFixed(3) + '%' : '—') + ''; + }).join(''); + + box.innerHTML = + '

Test from ' + esc(when) + ' — ' + n(t.total) + ' impressions across ' + (t.arms || []).length + ' versions' + + (t.stopped ? ' (stopped, ' + n(t.refunded) + ' returned)' : '') + '

' + + '
' + + esc((t.verdict && t.verdict.text) || '') + '
' + + '
' + + rows + '
VersionServedClicksClick rate
'; + + 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 = '✅ Test stopped. ' + 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 = '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 = '✅ Test running. ' + 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. Open the Suite and connect the wallet that holds your position, then come back.'); return; } + if (r.status === 403) { + var g = await r.json(); + gate((g.error || 'Not open for this position yet.') + ' See your Suite.'); + return; + } + if (!r.ok) return; + 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); + }); +})(); diff --git a/public/suite-voice.html b/public/suite-voice.html new file mode 100644 index 0000000..4e2f166 --- /dev/null +++ b/public/suite-voice.html @@ -0,0 +1,46 @@ +Voice Profile | The Circle Suite + + + +
+
CIRCLE SUITE · CULMEN (LEVEL 4)
+

Your voice.

+

By default the Copy Engine writes in the team voice. That is the safe starting point — but your people know how you talk, and copy that sounds like a company instead of like you is the first thing they notice. Answer these once and everything the Suite writes for you leans toward your own words.

+ +
+
Loading…
+ +
+
+
+ + +
+
+
+
+ +

One thing this will not do: it will never loosen the compliance rules to sound more like you. If your own writing makes income claims, the engine still will not repeat them — the honesty rules sit above your voice profile on purpose, because they are what keeps every member safe.

Independent team resource · No income is guaranteed · Cryptocurrency involves risk.

+
+ + + diff --git a/public/suite-voice.js b/public/suite-voice.js new file mode 100644 index 0000000..e29411f --- /dev/null +++ b/public/suite-voice.js @@ -0,0 +1,148 @@ +// Voice Profile client. Fields come from the server so the form and the stored +// shape can never drift apart. +(function () { + 'use strict'; + var $ = function (id) { return document.getElementById(id); }; + var state = { fields: [], profile: {} }; + + function gate(msg) { + $('vGate').style.display = 'block'; + $('vGate').innerHTML = msg; + $('vCard').style.display = 'none'; + $('vState').style.display = 'none'; + } + + function renderFields() { + var host = $('vFields'); + host.innerHTML = ''; + state.fields.forEach(function (f) { + var wrap = document.createElement('div'); + wrap.className = 'v-f'; + + var lab = document.createElement('label'); + lab.setAttribute('for', 'vf_' + f.key); + lab.textContent = f.label; + wrap.appendChild(lab); + + var hint = document.createElement('div'); + hint.className = 'hint'; + hint.textContent = f.hint; + wrap.appendChild(hint); + + var ta = document.createElement('textarea'); + ta.id = 'vf_' + f.key; + ta.rows = f.max > 400 ? 6 : 2; + ta.maxLength = f.max; + ta.value = state.profile[f.key] || ''; + wrap.appendChild(ta); + + var count = document.createElement('div'); + count.className = 'v-count'; + var upd = function () { count.textContent = ta.value.length + ' / ' + f.max; }; + ta.addEventListener('input', upd); + upd(); + wrap.appendChild(count); + + host.appendChild(wrap); + }); + } + + function showState(p) { + var el = $('vState'); + el.style.display = 'block'; + var filled = state.fields.filter(function (f) { + return String((p || {})[f.key] || '').trim().length > 2; + }).length; + if (p && p.complete) { + el.className = 'v-state'; + el.innerHTML = '✅ Your voice profile is on. The Copy Engine and Email Engine are both writing with it — ' + + filled + ' of ' + state.fields.length + ' answers filled in.'; + } else { + el.className = 'v-state off'; + el.innerHTML = 'Not active yet. Fill in at least a couple of these — especially the writing sample, ' + + 'which teaches it more than the rest combined — and everything the Suite writes will start sounding like you.'; + } + } + + function collect() { + var out = {}; + state.fields.forEach(function (f) { + var el = $('vf_' + f.key); + out[f.key] = el ? el.value : ''; + }); + return out; + } + + async function save() { + $('vErr').style.display = 'none'; + $('vOk').style.display = 'none'; + $('vSave').disabled = true; + var prev = $('vSave').textContent; + $('vSave').textContent = 'Saving…'; + try { + var r = await fetch('/api/public/suite-voice', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(collect()) + }); + var d = await r.json(); + if (!r.ok) { + $('vErr').textContent = d.error || 'That did not save — try again.'; + $('vErr').style.display = 'block'; + } else { + state.profile = d.profile; + showState(d.profile); + $('vOk').innerHTML = d.profile.complete + ? '✅ Saved. Head to the Copy Engine and write something — it should sound noticeably more like you.' + : '✅ Saved. Add a bit more (the writing sample especially) and it will switch on.'; + $('vOk').style.display = 'block'; + } + } catch (e) { + $('vErr').textContent = 'Connection hiccup — try again.'; + $('vErr').style.display = 'block'; + } + $('vSave').disabled = false; + $('vSave').textContent = prev; + } + + async function clear() { + if (!window.confirm('Clear your voice profile? The Suite will go back to writing in the team voice.')) return; + try { + var r = await fetch('/api/public/suite-voice', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clear: true }) + }); + var d = await r.json(); + if (r.ok) { + state.profile = d.profile; + renderFields(); + showState(d.profile); + $('vOk').innerHTML = 'Cleared. Back to the team voice.'; + $('vOk').style.display = 'block'; + } + } catch (e) {} + } + + async function boot() { + try { + var r = await fetch('/api/public/suite-voice'); + if (r.status === 401) { gate('You’re not signed in yet. Open the Suite and connect the wallet that holds your position, then come back.'); return; } + if (r.status === 403) { + var g = await r.json(); + gate((g.error || 'Not open for this position yet.') + ' See your Suite.'); + return; + } + if (!r.ok) return; + var d = await r.json(); + state.fields = d.fields || []; + state.profile = d.profile || {}; + renderFields(); + showState(state.profile); + } catch (e) {} + } + + document.addEventListener('DOMContentLoaded', function () { + boot(); + $('vSave').addEventListener('click', save); + $('vClear').addEventListener('click', clear); + }); +})(); diff --git a/public/suite.js b/public/suite.js index 2be0bbe..83544f3 100644 --- a/public/suite.js +++ b/public/suite.js @@ -17,11 +17,12 @@ { lv: 2, ico: '🧱', name: 'Page Builder', desc: 'Answer four questions, get your own hosted invitation page — your story, your angle video, your QR.', href: '/suite/page', live: true }, { lv: 3, ico: '📧', name: 'Email Engine', desc: 'Welcome series, follow-up sequences and broadcasts in the team voice — exportable to any autoresponder.', href: '/suite/email', live: true }, { lv: 3, ico: '🎥', name: 'Video Maker', desc: 'The team’s master promo videos rendered with your personal end-card — your name, your QR, your link.', href: '/suite/video', live: true }, - { lv: 4, ico: '🗣️', name: 'Voice Profile + Funnels', desc: 'Output that sounds like YOU, plus multi-page funnels on your own team subdomain.', live: false }, + { lv: 4, ico: '🗣️', name: 'Voice Profile', desc: 'Teach the Copy Engine and Email Engine how you actually talk, so everything they write for you stops sounding like a company.', href: '/suite/voice', live: true }, + { lv: 5, ico: '🔬', name: 'Split Tester', desc: 'Run two or three versions of an ad head to head and find out which wording people actually click. Apex is where your impressions jump to 50,000 — enough for the answer to mean something.', href: '/suite/split', live: true }, { lv: 1, ico: '🚦', name: 'Traffic Desk', desc: 'Syndicated network display advertising — run banners or AI-written text ads on the team’s own ad network. Monthly impressions scale with your level: 2,500 at Scintilla up to 150,000 at Corona.', href: '/suite/traffic', live: true }, - { lv: 6, ico: '🏭', name: 'Funnel Factory', desc: 'Complete hosted funnels with A/B variants, replay funnels (your team webinar as an on-demand registration page with a timed CTA), and a lead CRM.', live: false }, - { lv: 7, ico: '🧭', name: 'Leader Ops', desc: 'Team radar, AI coaching digests for your legs, and cohort training rooms.', live: false }, - { lv: 8, ico: '👑', name: 'Founder Desk', desc: 'Your own AI operator running your promotion, API access, and the inner circle.', live: false } + { lv: 6, ico: '🏭', name: 'Funnel Factory', desc: 'A separate hosted landing page for every audience you talk to — each on its own address, each one something you can point a different ad at.', href: '/suite/funnel', live: true }, + { lv: 7, ico: '🧭', name: 'Leader Ops', desc: 'Everyone below you, read live from the contract and sorted by who needs you most — plus a written coaching plan you can teach forward to your own two.', href: '/suite/leader', live: true }, + { lv: 8, ico: '👑', name: 'Founder Desk', desc: 'Every tool in the Suite at its highest allowance, and the top of the ladder — nothing above this to unlock.', live: false } ]; var $ = function (id) { return document.getElementById(id); }; diff --git a/server.js b/server.js index e06dc1e..a3dda28 100644 --- a/server.js +++ b/server.js @@ -25,6 +25,9 @@ const suiteEmail = require('./suite-email'); const suiteVideo = require('./suite-video'); suiteVideo.init({ dataDir: DATA_DIR, publicDir: PUBLIC_DIR }); const suiteTraffic = require('./suite-traffic'); suiteTraffic.init({ dataDir: DATA_DIR }); const suiteTextAds = require('./suite-textads'); +const suiteVoice = require('./suite-voice'); suiteVoice.init({ dataDir: DATA_DIR }); +const suiteSplit = require('./suite-split'); suiteSplit.init({ dataDir: DATA_DIR }); +const suiteLeader = require('./suite-leader'); const tgbot = require('./tgbot'); tgbot.init({ dataDir: DATA_DIR, chain, getConfig, messages, baseUrl: 'https://rmcircle.team' }); const SESSION_TTL = 8 * 60 * 60 * 1000; @@ -669,6 +672,21 @@ async function handleApi(req,res,pathname){ } // ── Page Builder ───────────────────────────────────────────────────────── + // Level 6 extra pages: /p//. Falls back to the member's main page + // rather than 404ing, for the same reason /p/ falls back to /join/. + if(req.method==='GET'&&/^\/p\/\d{1,15}\/[a-z0-9-]{1,24}$/.test(pathname)){ + const parts=pathname.split('/'); + const pid=parts[2], slug=parts[3]; + const rec=suitePages.load(pid,slug); + if(!rec||!rec.copy){ + res.writeHead(302,securityHeaders({'Location':'/p/'+pid,'Cache-Control':'no-store'})); + return res.end(); + } + const html=suitePages.render(rec); + res.writeHead(200,securityHeaders({'Content-Type':'text/html; charset=utf-8','Cache-Control':'public, max-age=120'})); + return res.end(html); + } + if(req.method==='GET'&&/^\/p\/\d{1,15}$/.test(pathname)){ const pid=pathname.split('/')[2]; const rec=suitePages.load(pid); @@ -736,7 +754,7 @@ async function handleApi(req,res,pathname){ } try{ const link='https://rmcircle.team/join/'+e.d.id; - const emails=await suiteEmail.generate(kind,brief,{link:link,id:e.d.id}); + const emails=await suiteEmail.generate(kind,brief,{link:link,id:e.d.id,voice:suiteVoice.promptBlock(e.d.id)}); suiteMeter.record(e.d.id,'email',emails.length||1); return json(res,200,{emails:emails,meter:suiteMeter.check(e.d.id,e.d.level,'email')}); }catch(err){ return json(res,502,{error:String(err.message||err)}); } @@ -825,6 +843,105 @@ async function handleApi(req,res,pathname){ }catch(err){ return json(res,502,{error:String(err.message||err)}); } } + // -- Voice Profile (level 4) ---------------------------------------------- + if(pathname==='/api/public/suite-voice'){ + const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup - try again.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); + if(Number(e.d.level)<4)return json(res,403,{error:'The Voice Profile unlocks at Culmen (level 4).',minLevel:4}); + if(req.method==='GET'){ + return json(res,200,{profile:suiteVoice.load(e.d.id)||suiteVoice.blank(),fields:suiteVoice.FIELDS,level:e.d.level,id:e.d.id}); + } + if(req.method==='POST'){ + const b=await bodyJson(req)||{}; + if(b.clear){ suiteVoice.clear(e.d.id); return json(res,200,{profile:suiteVoice.blank(),cleared:true}); } + return json(res,200,{profile:suiteVoice.save(e.d.id,b),saved:true}); + } + } + + // -- Split Tester (level 5) ----------------------------------------------- + if(pathname==='/api/public/suite-split'){ + const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup - try again.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); + if(Number(e.d.level)({error:'Chain read hiccup - try again.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); + if(Number(e.d.level)<6)return json(res,403,{error:'The Funnel Factory unlocks at Fastigium (level 6).',minLevel:6}); + if(req.method==='GET'){ + return json(res,200,{pages:suitePages.list(e.d.id),angles:suitePages.ANGLES,level:e.d.level,id:e.d.id, + meter:suiteMeter.check(e.d.id,e.d.level,'page')}); + } + if(req.method==='POST'){ + const b=await bodyJson(req)||{}; + if(b.remove){ + const gone=suitePages.remove(e.d.id,String(b.remove)); + return json(res,gone?200:404,gone?{removed:b.remove,pages:suitePages.list(e.d.id)}:{error:'That page is not there.'}); + } + const slug=suitePages.slugify(b.slug||b.name||''); + if(!slug)return json(res,400,{error:'Give the page a short name.'}); + const gate=suiteMeter.check(e.d.id,e.d.level,'page'); + if(!gate.allowed)return json(res,429,{error:'You have used all '+gate.limit+' page builds this month. It resets on the 1st.',meter:gate}); + const input={id:e.d.id,name:String(b.name||'').slice(0,80),audience:String(b.audience||'').slice(0,400), + story:String(b.story||'').slice(0,900),angle:suitePages.ANGLES[b.angle]?b.angle:'overview'}; + try{ + const copy=await suitePages.generate(input); + suitePages.save(e.d.id,{id:e.d.id,slug:slug,name:input.name,angle:input.angle,audience:input.audience, + story:input.story,copy:copy,updatedAt:new Date().toISOString()},slug); + suiteMeter.record(e.d.id,'page',1); + return json(res,200,{pages:suitePages.list(e.d.id),url:'https://rmcircle.team/p/'+e.d.id+'/'+slug, + meter:suiteMeter.check(e.d.id,e.d.level,'page')}); + }catch(err){ return json(res,502,{error:String(err.message||err)}); } + } + } + + // -- Leader Ops (level 7) ------------------------------------------------- + if(pathname==='/api/public/suite-leader'){ + const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup - try again.',code:500})); + if(e.error)return json(res,e.code||500,{error:e.error}); + if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'}); + if(Number(e.d.level)({error:'Chain read hiccup — try again.',code:500})); if(e.error)return json(res,e.code||500,{error:e.error}); @@ -861,7 +978,7 @@ async function handleApi(req,res,pathname){ } const link='https://rmcircle.team/join/'+e.d.id; try{ - const text=await suiteAI.generate(kind,brief,{link:link,id:e.d.id}); + const text=await suiteAI.generate(kind,brief,{link:link,id:e.d.id,voice:suiteVoice.promptBlock(e.d.id)}); suiteMeter.record(e.d.id,'copy',1); return json(res,200,{text:text,meter:suiteMeter.check(e.d.id,e.d.level,'copy')}); }catch(err){ @@ -1169,7 +1286,7 @@ const server=http.createServer(async(req,res)=>{ if((mj=pathname.match(/^\/join\/(\d{1,15})$/)))return serveMemberPage(req,res,path.join(PUBLIC_DIR,'join.html'),'join',mj[1]); } let file; - if(pathname==='/')file=path.join(PUBLIC_DIR,'index.html');else if(pathname==='/app'||pathname==='/app/')file=path.join(PUBLIC_DIR,'app.html');else if(pathname==='/start'||pathname==='/start/')file=path.join(PUBLIC_DIR,'start.html');else if(pathname==='/training'||pathname==='/training/')file=path.join(PUBLIC_DIR,'training.html');else if(pathname==='/admin'||pathname==='/admin/')file=path.join(PUBLIC_DIR,'admin.html');else if(pathname==='/my'||pathname==='/my/'||/^\/my\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'my.html');else if(pathname==='/contract'||pathname==='/contract/')file=path.join(PUBLIC_DIR,'contract.html');else if(pathname==='/disclaimer'||pathname==='/disclaimer/')file=path.join(PUBLIC_DIR,'disclaimer.html');else if(pathname==='/privacy'||pathname==='/privacy/')file=path.join(PUBLIC_DIR,'privacy.html');else if(pathname==='/refunds'||pathname==='/refunds/')file=path.join(PUBLIC_DIR,'refunds.html');else if(pathname==='/how-pay-works'||pathname==='/how-pay-works/')file=path.join(PUBLIC_DIR,'how-pay-works.html');else if(pathname==='/tools'||pathname==='/tools/')file=path.join(PUBLIC_DIR,'tools.html');else if(pathname==='/fast-start'||pathname==='/fast-start/')file=path.join(PUBLIC_DIR,'fast-start.html');else if(pathname==='/flyers'||pathname==='/flyers/')file=path.join(PUBLIC_DIR,'flyers.html');else if(pathname==='/weekly-rhythm'||pathname==='/weekly-rhythm/')file=path.join(PUBLIC_DIR,'weekly-rhythm.html');else if(pathname==='/generation-pay'||pathname==='/generation-pay/')file=path.join(PUBLIC_DIR,'generation-pay.html');else if(pathname==='/suite'||pathname==='/suite/')file=path.join(PUBLIC_DIR,'suite.html');else if(pathname==='/suite/copy'||pathname==='/suite/copy/')file=path.join(PUBLIC_DIR,'suite-copy.html');else if(pathname==='/suite/page'||pathname==='/suite/page/')file=path.join(PUBLIC_DIR,'suite-page.html');else if(pathname==='/suite/email'||pathname==='/suite/email/')file=path.join(PUBLIC_DIR,'suite-email.html');else if(pathname==='/suite/video'||pathname==='/suite/video/')file=path.join(PUBLIC_DIR,'suite-video.html');else if(pathname==='/suite/traffic'||pathname==='/suite/traffic/')file=path.join(PUBLIC_DIR,'suite-traffic.html');else if(pathname==='/direct-join'||pathname==='/direct-join/'){if(!getSession(req)){res.writeHead(302,{Location:'/admin'});return res.end();}file=path.join(ROOT,'private','direct-join.html');}else if(pathname==='/join-now'||pathname==='/join-now/'){if(!getConfig().dappFallbackPublic){res.writeHead(302,{Location:'/start'});return res.end();}file=path.join(ROOT,'private','join-now.html');}else if(/^\/join\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'join.html');else if(pathname==='/join'||pathname==='/join/'){res.writeHead(302,{Location:'/join-now'});return res.end();}else{ + if(pathname==='/')file=path.join(PUBLIC_DIR,'index.html');else if(pathname==='/app'||pathname==='/app/')file=path.join(PUBLIC_DIR,'app.html');else if(pathname==='/start'||pathname==='/start/')file=path.join(PUBLIC_DIR,'start.html');else if(pathname==='/training'||pathname==='/training/')file=path.join(PUBLIC_DIR,'training.html');else if(pathname==='/admin'||pathname==='/admin/')file=path.join(PUBLIC_DIR,'admin.html');else if(pathname==='/my'||pathname==='/my/'||/^\/my\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'my.html');else if(pathname==='/contract'||pathname==='/contract/')file=path.join(PUBLIC_DIR,'contract.html');else if(pathname==='/disclaimer'||pathname==='/disclaimer/')file=path.join(PUBLIC_DIR,'disclaimer.html');else if(pathname==='/privacy'||pathname==='/privacy/')file=path.join(PUBLIC_DIR,'privacy.html');else if(pathname==='/refunds'||pathname==='/refunds/')file=path.join(PUBLIC_DIR,'refunds.html');else if(pathname==='/how-pay-works'||pathname==='/how-pay-works/')file=path.join(PUBLIC_DIR,'how-pay-works.html');else if(pathname==='/tools'||pathname==='/tools/')file=path.join(PUBLIC_DIR,'tools.html');else if(pathname==='/fast-start'||pathname==='/fast-start/')file=path.join(PUBLIC_DIR,'fast-start.html');else if(pathname==='/flyers'||pathname==='/flyers/')file=path.join(PUBLIC_DIR,'flyers.html');else if(pathname==='/weekly-rhythm'||pathname==='/weekly-rhythm/')file=path.join(PUBLIC_DIR,'weekly-rhythm.html');else if(pathname==='/generation-pay'||pathname==='/generation-pay/')file=path.join(PUBLIC_DIR,'generation-pay.html');else if(pathname==='/suite'||pathname==='/suite/')file=path.join(PUBLIC_DIR,'suite.html');else if(pathname==='/suite/copy'||pathname==='/suite/copy/')file=path.join(PUBLIC_DIR,'suite-copy.html');else if(pathname==='/suite/page'||pathname==='/suite/page/')file=path.join(PUBLIC_DIR,'suite-page.html');else if(pathname==='/suite/email'||pathname==='/suite/email/')file=path.join(PUBLIC_DIR,'suite-email.html');else if(pathname==='/suite/video'||pathname==='/suite/video/')file=path.join(PUBLIC_DIR,'suite-video.html');else if(pathname==='/suite/traffic'||pathname==='/suite/traffic/')file=path.join(PUBLIC_DIR,'suite-traffic.html');else if(pathname==='/suite/voice'||pathname==='/suite/voice/')file=path.join(PUBLIC_DIR,'suite-voice.html');else if(pathname==='/suite/split'||pathname==='/suite/split/')file=path.join(PUBLIC_DIR,'suite-split.html');else if(pathname==='/suite/funnel'||pathname==='/suite/funnel/')file=path.join(PUBLIC_DIR,'suite-funnel.html');else if(pathname==='/suite/leader'||pathname==='/suite/leader/')file=path.join(PUBLIC_DIR,'suite-leader.html');else if(pathname==='/direct-join'||pathname==='/direct-join/'){if(!getSession(req)){res.writeHead(302,{Location:'/admin'});return res.end();}file=path.join(ROOT,'private','direct-join.html');}else if(pathname==='/join-now'||pathname==='/join-now/'){if(!getConfig().dappFallbackPublic){res.writeHead(302,{Location:'/start'});return res.end();}file=path.join(ROOT,'private','join-now.html');}else if(/^\/join\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'join.html');else if(pathname==='/join'||pathname==='/join/'){res.writeHead(302,{Location:'/join-now'});return res.end();}else{ const safe=path.normalize(pathname).replace(/^([.][.][/\\])+/, '').replace(/^[/\\]+/,'');file=path.join(PUBLIC_DIR,safe);if(!file.startsWith(PUBLIC_DIR))file=''; } if(file&&staticFile(req,res,file))return;return staticFile(req,res,path.join(PUBLIC_DIR,'404.html'),404); diff --git a/suite-ai.js b/suite-ai.js index 5461899..46befad 100644 --- a/suite-ai.js +++ b/suite-ai.js @@ -56,6 +56,10 @@ function buildMessages(kind, brief, member) { 'You write promotional copy for a member of the RM Circle team.\n\n' + 'WHAT IT IS: ' + FACTS + '\n\n' + 'VOICE: ' + VOICE + '\n\n' + + // The member's own voice profile, when they have one (level 4+). It sits + // BEFORE compliance deliberately: compliance must be the last word in the + // prompt, so a personal voice can never talk the model out of it. + (member && member.voice ? member.voice + '\n\n' : '') + 'COMPLIANCE (absolute, overrides everything else): ' + COMPLIANCE + '\n\n' + 'FORMAT: ' + k.shape + '\n\n' + (link diff --git a/suite-email.js b/suite-email.js index ab60790..4fe035e 100644 --- a/suite-email.js +++ b/suite-email.js @@ -43,7 +43,12 @@ function buildPrompt(kind, brief, member) { 'BODY: 90-170 words, plain text, short paragraphs separated by blank lines, ending with a sign-off line\n' + (k.count > 1 ? 'EMAIL 2\nSUBJECT: ...\nBODY: ...\n(and so on)\n' : '') + '\n' + (link ? 'Where a link belongs, use exactly: ' + link + '\n' : 'Do not invent a link.\n') + - 'No HTML, no markdown headers, no emoji in subject lines, no merge tags.' + 'No HTML, no markdown headers, no emoji in subject lines, no merge tags.' + + // The member's own voice profile (level 4+), when they have one. This module + // builds its own prompt rather than going through suite-ai's message builder, + // so the profile has to be threaded in here too or email would keep writing + // in the generic team voice while the Copy Engine sounded like them. + (member && member.voice ? '\n\n' + member.voice : '') ); } diff --git a/suite-leader.js b/suite-leader.js new file mode 100644 index 0000000..b0f6740 --- /dev/null +++ b/suite-leader.js @@ -0,0 +1,88 @@ +// Circle Suite — Leader Ops (level 7). +// +// The coaching radar already existed in chain.js and only the admin panel could +// see it. That is backwards: the people who most need to know which of their +// legs is about to leak money are the leaders running those legs, not Marty. +// This exposes it scoped to the member's OWN organisation, and adds a written +// digest on top of the raw triage. +// +// The digest follows the team's teach-forward rule: every piece of coaching +// output has to leave the leader able to teach the same thing to their two, +// rather than making them dependent on the tool. So the digest is written as +// "here is what to say to this person and why", not "here is a number". +'use strict'; + +const chain = require('./chain'); +const suiteAi = require('./suite-ai'); + +const MIN_LEVEL = 7; + +// Raw triage for one leader's organisation. +function scan(memberId, maxItems) { + const s = chain.getCoachingScan(Number(memberId), Math.max(3, Math.min(25, Number(maxItems) || 12))); + if (!s || !s.ready) { + return { ready: false, reason: 'The on-chain index is still catching up — try again in a moment.' }; + } + return s; +} + +// Turn the triage into something a leader can act on this week. +// +// We hand the model FACTS ONLY — ids, levels, POL amounts already computed from +// the contract — and forbid it from inventing any others. Everything numeric in +// the digest has to come from the scan we pass in, because a coaching digest +// that misstates what someone is owed does real damage to a real relationship. +async function digest(memberId, opts) { + const s = scan(memberId, (opts && opts.maxItems) || 12); + if (!s.ready) throw new Error(s.reason); + + const nothing = !s.totals.atRiskCount && !s.totals.rollForwardCount && !s.totals.oneAwayCount; + if (nothing) { + return { + scan: s, + text: 'Nothing needs chasing in your organisation right now — nobody is one direct short, ' + + 'nobody is sitting on money they cannot catch, and nobody is qualified but still on Scintilla. ' + + 'That is the quiet week you use to work on your own two.' + }; + } + + const lines = []; + if (s.atRisk.length) { + lines.push('PEOPLE WITH MONEY FORMING BELOW THEM THEY CANNOT CATCH YET:'); + s.atRisk.slice(0, 8).forEach(function (r) { + lines.push('- Position #' + r.id + ' is ' + r.levelName + ', ' + + (r.qualified ? 'qualified' : 'NOT qualified (' + r.directCount + ' of 2 directs)') + + '. ' + r.atRiskPol + ' POL is forming below them that they cannot catch. ' + + (r.qualified + ? 'They need to upgrade to ' + r.neededLevelName + ' (costs ' + r.upgradeCost + ' POL). They have earned ' + r.earnedPol + ' POL so far.' + : 'They need their 2nd direct before they can catch anything at all.')); + }); + } + if (s.rollForward.length) { + lines.push('QUALIFIED BUT STILL ON SCINTILLA (their entry rewards already cover the next upgrade):'); + s.rollForward.slice(0, 8).forEach(function (r) { + lines.push('- Position #' + r.id + ' has earned ' + r.earnedPol + ' POL; Ascensus costs ' + r.ascensusCost + ' POL.'); + }); + } + if (s.oneAway.length) { + lines.push('ONE DIRECT SHORT OF QUALIFYING (a single placement fixes them):'); + lines.push('- ' + s.oneAway.slice(0, 10).map(function (r) { return '#' + r.id; }).join(', ')); + } + + const instruction = + 'You are writing a short weekly coaching digest for a leader in the RM Circle team build, about their own organisation.\n\n' + + 'THE FACTS — these are read live from the smart contract. Use ONLY these. Never invent an id, a level, or a POL amount:\n' + + lines.join('\n') + '\n\n' + + 'HOW THE MONEY WORKS (so your advice is correct): a member catches an upgrade payment only if they are qualified (2 personal directs) AND already at or above the level being bought. Upgrade payments travel up to the first upline who meets both tests. Entry payments go to the sponsor.\n\n' + + 'ABSOLUTE RULES:\n' + + '- Never promise, guarantee or project income. Never quote dollars — POL quantities only.\n' + + '- Never state a number that is not in the facts above.\n' + + '- Do not shame anyone. These are real people and the leader has to talk to them tomorrow.\n\n' + + 'TEACH-FORWARD RULE (this is the team doctrine and it matters most): the leader should finish reading able to TEACH this to their own two, not just to act on it themselves. So for each recommendation, give the leader the words to use and the reason behind them, so they can hand the same conversation down.\n\n' + + 'FORMAT: 200-320 words of plain prose. Open with the single most urgent thing. Then walk through what to say to whom, in priority order. Close with one sentence on what to teach their two this week. No headings, no bullet symbols, no markdown, no preamble.'; + + const text = await suiteAi.generateRaw(instruction); + return { scan: s, text: String(text || '').trim() }; +} + +module.exports = { scan, digest, MIN_LEVEL }; diff --git a/suite-pages.js b/suite-pages.js index 3fdd9d5..aed0fb2 100644 --- a/suite-pages.js +++ b/suite-pages.js @@ -14,10 +14,38 @@ const suiteAI = require('./suite-ai'); let DATA_DIR = null; function init(opts) { DATA_DIR = opts.dataDir; } function dir() { const d = path.join(DATA_DIR, 'pages'); try { fs.mkdirSync(d, { recursive: true }); } catch (e) {} return d; } -function file(id) { return path.join(dir(), String(id).replace(/\D/g, '') + '.json'); } +// A member's main page is .json and lives at /p/. Level 6 adds extra +// named pages, stored as --.json and served at /p//, so a +// leader can run a different page per angle and point different ads at each. +function slugify(s) { + return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24); +} +function file(id, slug) { + const base = String(id).replace(/\D/g, ''); + const sl = slug ? slugify(slug) : ''; + return path.join(dir(), base + (sl ? '--' + sl : '') + '.json'); +} -function load(id) { try { return JSON.parse(fs.readFileSync(file(id), 'utf8')); } catch (e) { return null; } } -function save(id, rec) { fs.writeFileSync(file(id), JSON.stringify(rec)); return rec; } +function load(id, slug) { try { return JSON.parse(fs.readFileSync(file(id, slug), 'utf8')); } catch (e) { return null; } } +function save(id, rec, slug) { fs.writeFileSync(file(id, slug), JSON.stringify(rec)); return rec; } +function remove(id, slug) { if (!slug) return false; try { fs.unlinkSync(file(id, slug)); return true; } catch (e) { return false; } } + +// Every page this member owns, main first. +function list(id) { + const base = String(id).replace(/\D/g, ''); + const out = []; + const main = load(id); + if (main && main.copy) out.push({ slug: '', url: '/p/' + base, name: main.name || 'Main page', angle: main.angle, updatedAt: main.updatedAt }); + let files = []; + try { files = fs.readdirSync(dir()); } catch (e) { files = []; } + files.forEach(function (f) { + const m = f.match(new RegExp('^' + base + '--([a-z0-9-]+)\\.json$')); + if (!m) return; + const rec = load(id, m[1]); + if (rec && rec.copy) out.push({ slug: m[1], url: '/p/' + base + '/' + m[1], name: rec.name || m[1], angle: rec.angle, updatedAt: rec.updatedAt }); + }); + return out; +} // Angles reuse the team's existing hook videos + their landing variant. const ANGLES = { @@ -138,4 +166,4 @@ function render(rec) { ''; } -module.exports = { init, load, save, generate, render, ANGLES, parseSections }; +module.exports = { init, load, save, remove, list, slugify, generate, render, ANGLES, parseSections }; diff --git a/suite-split.js b/suite-split.js new file mode 100644 index 0000000..5fd452d --- /dev/null +++ b/suite-split.js @@ -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 }; diff --git a/suite-tools.js b/suite-tools.js index 5031361..bac9951 100644 --- a/suite-tools.js +++ b/suite-tools.js @@ -10,23 +10,23 @@ 'use strict'; 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' }, - 3: { name: 'Fabrica', live: false, short: 'the Email Engine and Video Maker' }, - 4: { name: 'Culmen', live: false, short: 'your own Voice Profile and multi-page funnels' }, - 5: { name: 'Apex', live: false, short: 'the Traffic Desk — syndicated network display advertising' }, - 6: { name: 'Fastigium', live: false, short: 'the Funnel Factory and Replay Funnels' }, - 7: { name: 'Vertex', live: false, short: 'Leader Ops — team radar and coaching digests' }, - 8: { name: 'Corona', live: false, short: 'the Founder Desk — your own AI operator' } + 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: true, short: 'your Voice Profile — the Copy Engine and Email Engine start writing the way you actually talk' }, + 5: { name: 'Apex', live: true, short: 'the Split Tester, and your ad allowance jumps to 50,000 impressions a month' }, + 6: { name: 'Fastigium', live: true, short: 'the Funnel Factory — a separate hosted landing page for every audience you talk to' }, + 7: { name: 'Vertex', live: true, short: 'Leader Ops — your whole organisation triaged live from the contract, plus a coaching plan to teach forward' }, + 8: { name: 'Corona', live: true, short: 'the Founder Desk — every tool at its highest allowance, and the top of the ladder' } }; // Very short form for character-limited surfaces (X/Twitter). const TERSE = { - 1: 'the Launch Kit', + 1: 'the Launch Kit + Traffic Desk', 2: 'the Copy Engine + Page Builder', 3: 'the Email Engine + Video Maker', - 4: 'Voice Profile + funnels', - 5: 'the Traffic Desk', + 4: 'your own Voice Profile', + 5: 'the Split Tester + 50k impressions', 6: 'the Funnel Factory', 7: 'Leader Ops', 8: 'the Founder Desk' diff --git a/suite-voice.js b/suite-voice.js new file mode 100644 index 0000000..06b5978 --- /dev/null +++ b/suite-voice.js @@ -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 };