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>
170 lines
11 KiB
JavaScript
170 lines
11 KiB
JavaScript
// Circle Suite — Page Builder. Ports the Branded Voice pattern that works:
|
|
// the model NEVER writes HTML. It returns labelled plain text; a deterministic
|
|
// renderer turns that into the page. That buys human-editable output, cheap
|
|
// re-renders when the design changes, and total XSS control (everything is
|
|
// escaped; nothing the model emits can become markup).
|
|
//
|
|
// One page per position, stored as its source text in the data volume and
|
|
// re-rendered on every request — same as BV re-renders from `body`.
|
|
'use strict';
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
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; }
|
|
// A member's main page is <id>.json and lives at /p/<id>. Level 6 adds extra
|
|
// named pages, stored as <id>--<slug>.json and served at /p/<id>/<slug>, so a
|
|
// leader can run a different page per angle and point different ads at each.
|
|
function slugify(s) {
|
|
return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 24);
|
|
}
|
|
function file(id, slug) {
|
|
const base = String(id).replace(/\D/g, '');
|
|
const sl = slug ? slugify(slug) : '';
|
|
return path.join(dir(), base + (sl ? '--' + sl : '') + '.json');
|
|
}
|
|
|
|
function load(id, 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 = {
|
|
pocket: { label: 'Pocket change', video: '/v/rmc-pocket-change-b403fb2f75.mp4', poster: '/v/rmc-pocket-change-poster.jpg' },
|
|
two: { label: 'You only need two', video: '/v/rmc-two-people-4102f2d719.mp4', poster: '/v/rmc-two-people-poster.jpg' },
|
|
phone: { label: 'Runs from your phone', video: '/v/rmc-phone-32ac648844.mp4', poster: '/v/rmc-phone-poster.jpg' },
|
|
graveyard: { label: 'Side-hustle graveyard', video: '/v/rmc-graveyard-290fabee9d.mp4', poster: '/v/rmc-graveyard-poster.jpg' },
|
|
overview: { label: 'General overview', video: '/v/rmc-combined-16284edbab.mp4', poster: '/v/rmc-combined-poster.jpg' }
|
|
};
|
|
|
|
// ── Generation: labelled sections, parsed deterministically ────────────────
|
|
function buildPrompt(input) {
|
|
const angle = ANGLES[input.angle] ? ANGLES[input.angle].label : 'General overview';
|
|
return (
|
|
'Write the copy for a personal invitation page. The person inviting is ' + (input.name || 'a member of our team') + '.\n\n' +
|
|
'THEIR ANGLE: ' + angle + '\n' +
|
|
'WHO IT IS FOR: ' + (input.audience || 'people they know who are open to something new') + '\n' +
|
|
'IN THEIR WORDS, why they are doing this / what they want to say:\n' + (input.story || '(they did not add anything — write something honest and general)') + '\n\n' +
|
|
'Return EXACTLY these labelled sections, nothing else:\n' +
|
|
'HEADLINE: one line, max 60 characters, plain and human — not a slogan, not clickbait\n' +
|
|
'SUBHEAD: one sentence, max 140 characters\n' +
|
|
'STORY: two or three short paragraphs in FIRST PERSON as ' + (input.name || 'the member') + ', separated by blank lines. Honest, personal, no hype.\n' +
|
|
'BULLETS: exactly three lines, each starting with "- ", each under 90 characters, describing how the team build actually works\n' +
|
|
'CLOSING: one or two sentences inviting them to watch the short video and take a look. No pressure.\n\n' +
|
|
'Do not write any HTML, markdown headers, links, or emoji. Do not invent earnings or results.'
|
|
);
|
|
}
|
|
|
|
// Locate each label wherever it appears and slice between them. Deliberately
|
|
// forgiving: the colon is optional (models drop it — "STORY" alone broke the
|
|
// first build), markdown emphasis is tolerated, order is taken from the text.
|
|
function parseSections(raw) {
|
|
const out = { headline: '', subhead: '', story: [], bullets: [], closing: '' };
|
|
const text = String(raw || '').replace(/\r/g, '');
|
|
const LABELS = ['HEADLINE', 'SUBHEAD', 'STORY', 'BULLETS', 'CLOSING'];
|
|
const found = [];
|
|
LABELS.forEach(function (L) {
|
|
const m = text.match(new RegExp('(?:^|\\n)[ \\t]*[*#>\\s]*' + L + '[ \\t]*[*#]*[ \\t]*:?[ \\t]*[*#]*[ \\t]*', 'i'));
|
|
if (m) found.push({ label: L, at: m.index, from: m.index + m[0].length });
|
|
});
|
|
if (!found.length) return out;
|
|
found.sort(function (a, b) { return a.at - b.at; });
|
|
const seg = {};
|
|
found.forEach(function (f, i) {
|
|
const end = i + 1 < found.length ? found[i + 1].at : text.length;
|
|
// strip any leftover markdown emphasis the label pattern didn't absorb
|
|
seg[f.label] = text.slice(f.from, end).replace(/^[*#\s]+/, '').replace(/[*#\s]+$/, '').trim();
|
|
});
|
|
out.headline = (seg.HEADLINE || '').split('\n')[0].replace(/^["“]|["”]$/g, '').trim().slice(0, 90);
|
|
out.subhead = (seg.SUBHEAD || '').split('\n')[0].trim().slice(0, 200);
|
|
out.story = (seg.STORY || '').split(/\n{2,}/).map(function (p) { return p.replace(/\s+/g, ' ').trim(); }).filter(Boolean).slice(0, 4);
|
|
out.bullets = (seg.BULLETS || '').split('\n').map(function (b) { return b.replace(/^[-•*]\s*/, '').trim(); }).filter(Boolean).slice(0, 4);
|
|
out.closing = (seg.CLOSING || '').replace(/\s+/g, ' ').trim().slice(0, 400);
|
|
return out;
|
|
}
|
|
|
|
async function generate(input) {
|
|
// reuse the Suite engine + compliance system prompt via a bespoke kind
|
|
const text = await suiteAI.generateRaw(buildPrompt(input));
|
|
const parsed = parseSections(text);
|
|
if (!parsed.headline || !parsed.story.length) throw new Error('The writer came back incomplete — try again.');
|
|
return parsed;
|
|
}
|
|
|
|
// ── Rendering: everything escaped, no model output ever becomes markup ─────
|
|
function esc(s) {
|
|
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
|
return ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c];
|
|
});
|
|
}
|
|
|
|
function render(rec) {
|
|
const a = ANGLES[rec.angle] || ANGLES.overview;
|
|
const link = 'https://rmcircle.team/join/' + rec.id + (rec.angle && rec.angle !== 'overview' ? '?v=' + rec.angle : '');
|
|
const who = esc(rec.name || ('Member #' + rec.id));
|
|
const c = rec.copy || {};
|
|
const story = (c.story || []).map(function (p) { return '<p>' + esc(p) + '</p>'; }).join('');
|
|
const bullets = (c.bullets || []).map(function (b) { return '<li>' + esc(b) + '</li>'; }).join('');
|
|
return '<!doctype html><html lang="en"><head><meta charset="utf-8">' +
|
|
'<meta name="viewport" content="width=device-width,initial-scale=1">' +
|
|
'<title>' + esc(c.headline || 'An invitation') + ' | ' + who + '</title>' +
|
|
'<meta name="description" content="' + esc(c.subhead || '') + '">' +
|
|
'<meta property="og:title" content="' + esc(c.headline || '') + '"><meta property="og:description" content="' + esc(c.subhead || '') + '">' +
|
|
'<meta property="og:image" content="https://rmcircle.team' + a.poster + '">' +
|
|
'<link rel="icon" type="image/png" href="/favicon.png"><link rel="stylesheet" href="/styles.css">' +
|
|
'<style>' +
|
|
'.pp{max-width:760px;margin:0 auto;padding:30px 20px 70px}' +
|
|
'.pp h1{font-size:clamp(28px,5.5vw,44px);line-height:1.1;margin:10px 0 10px}' +
|
|
'.pp .sub{color:var(--muted);font-size:18px;line-height:1.6;margin-bottom:22px}' +
|
|
'.pp .by{display:flex;align-items:center;gap:10px;color:var(--muted);font-size:14px;margin-bottom:8px}' +
|
|
'.pp .by b{color:var(--text)}' +
|
|
'.pp .vid{border-radius:14px;overflow:hidden;border:1px solid var(--line);margin:0 0 24px}' +
|
|
'.pp .vid video{display:block;width:100%;height:auto;aspect-ratio:16/9}' +
|
|
'.pp p{color:var(--muted);line-height:1.7;margin-bottom:14px;font-size:16.5px}' +
|
|
'.pp ul{margin:0 0 22px 20px;color:var(--muted)}.pp li{margin-bottom:8px;line-height:1.6}' +
|
|
'.pp .cta{border:2px solid var(--gold);border-radius:16px;padding:22px;text-align:center;margin:26px 0 0}' +
|
|
'.pp .cta p{color:var(--text);font-size:17px;margin-bottom:16px}' +
|
|
'.pp .qr{background:#fff;border-radius:12px;padding:9px;width:150px;height:150px;margin:16px auto 8px;line-height:0}' +
|
|
'.pp .qr svg{width:100%;height:100%}' +
|
|
'.pp .foot{margin-top:26px;padding-top:14px;border-top:1px solid var(--line);font-size:12.5px;color:var(--muted);line-height:1.6;text-align:center}' +
|
|
'</style></head><body>' +
|
|
'<header class="wrap nav"><a class="brand" href="/"><img class="brand-mark" src="/logo.jpg" alt="The RM Circle" width="42" height="42"><span><span>RM Circle</span><small>A personal invitation</small></span></a></header>' +
|
|
'<main class="pp">' +
|
|
'<div class="by">Shared by <b>' + who + '</b> · Member #' + esc(rec.id) + '</div>' +
|
|
'<h1>' + esc(c.headline || '') + '</h1>' +
|
|
(c.subhead ? '<p class="sub">' + esc(c.subhead) + '</p>' : '') +
|
|
'<div class="vid"><video controls preload="metadata" poster="' + a.poster + '" src="' + a.video + '"></video></div>' +
|
|
story +
|
|
(bullets ? '<ul>' + bullets + '</ul>' : '') +
|
|
'<div class="cta"><p>' + esc(c.closing || 'Take a look and see what you think.') + '</p>' +
|
|
'<a class="btn btn-primary" href="' + esc(link) + '">See how it works →</a>' +
|
|
'<div class="qr" id="ppQr"></div><div class="micro" style="color:var(--muted)">or scan · ' + esc(link.replace(/^https:\/\//, '')) + '</div></div>' +
|
|
'<div class="foot">Independent team resource shared by an individual member. Participation takes real effort and involves cryptocurrency risk, including risk of total loss. No income is guaranteed. ' +
|
|
'<a href="/disclaimer" style="color:var(--teal)">Disclaimers</a> · <a href="/contract" style="color:var(--teal)">How the contract works</a></div>' +
|
|
'</main>' +
|
|
'<script src="/qrlib.js"></script><script>(function(){try{var q=qrcode(0,"M");q.addData(' + JSON.stringify(link) + ');q.make();document.getElementById("ppQr").innerHTML=q.createSvgTag({cellSize:4,margin:2,scalable:true});}catch(e){}})();</script>' +
|
|
'<script src="/chat.js" defer></script><script src="/translate.js" defer></script>' +
|
|
'</body></html>';
|
|
}
|
|
|
|
module.exports = { init, load, save, remove, list, slugify, generate, render, ANGLES, parseSections };
|