a57af32f4a
Once every member from Ascensus up could hold several pages, the Funnel Factory's differentiator became a count — the same quantity-not-kind flaw the upper tiers had. L6 now unlocks identity instead: a member's own name, monogram and accent colour on every page they build. Below Fastigium you have a page on the team site; at Fastigium it becomes yours. Three deliberate limits. NO IMAGE UPLOADS. A logo file uploaded by a member and served from rmcircle.team is an abuse surface — whatever they upload lives on our domain. The mark is a monogram generated from their brand name instead: real identity, nothing to moderate. Same reasoning that keeps Traffic Desk creative team-only. ACCENTS ARE A CURATED SET, not a colour picker. Every option is checked against the dark ground, so nobody can pick something that makes their own page unreadable and then wonder why it converts badly. THE DISCLAIMERS ARE NOT THEIRS TO BRAND. Branding replaces the header identity only; the footer — independent team resource, no income guaranteed, cryptocurrency risk — is rendered by us and cannot be removed or restyled. Co-branding, not white-labelling. Verified in the renderer. Branding is resolved against the LIVE contract level at render time, so a position that drops below Fastigium reverts to team identity on its own rather than keeping something it no longer holds. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
254 lines
16 KiB
JavaScript
254 lines
16 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;
|
|
}
|
|
|
|
// How many pages a position may HOLD at once, by level (index = level - 1).
|
|
// This is a different thing from the monthly BUILD quota in suite-meter: builds
|
|
// are how often you may run the writer, this is how many pages you may keep.
|
|
// They used to contradict each other — an Ascensus member was allowed 3 builds
|
|
// a month but every build overwrote the same page, so they could never have
|
|
// more than one. Anyone who can build a page can now keep several, because the
|
|
// moment somebody thinks about two different audiences they want two pages.
|
|
const PAGE_LIMIT = [0, 2, 3, 5, 8, 15, 25, 50];
|
|
function pageLimit(level) {
|
|
const lv = Math.max(1, Math.min(8, Number(level) || 1));
|
|
return PAGE_LIMIT[lv - 1] || 0;
|
|
}
|
|
|
|
// Slug from the headline the writer produced, so the address describes the page
|
|
// without asking the member to invent one. Falls back to the angle, then to a
|
|
// short random suffix, and always de-duplicates against what they already hold.
|
|
function slugFromTitle(id, title, angle) {
|
|
let base = slugify(title || '');
|
|
if (base.length < 3) base = slugify(angle || 'page');
|
|
if (!base) base = 'page';
|
|
const taken = {};
|
|
list(id).forEach(function (p) { if (p.slug) taken[p.slug] = 1; });
|
|
if (!taken[base]) return base;
|
|
for (let i = 2; i < 40; i++) {
|
|
const c = slugify(base.slice(0, 20) + '-' + i);
|
|
if (!taken[c]) return c;
|
|
}
|
|
return slugify(base.slice(0, 16) + '-' + Math.floor(Date.now() / 1000).toString(36));
|
|
}
|
|
|
|
// 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];
|
|
});
|
|
}
|
|
|
|
// House style puts the last beat of a heading in gold ("Team <gold>Grants.</gold>").
|
|
// These headlines are written by the model, so we cannot hand-pick the split —
|
|
// take the final two words, or the last one when the line is short. Everything
|
|
// is escaped first; the only markup added is our own span.
|
|
function goldTail(text) {
|
|
const t = String(text || '').trim();
|
|
if (!t) return '';
|
|
const words = t.split(/\s+/);
|
|
if (words.length < 3) return esc(t);
|
|
const take = words.length >= 6 ? 2 : 1;
|
|
const head = words.slice(0, words.length - take).join(' ');
|
|
const tail = words.slice(words.length - take).join(' ');
|
|
return esc(head) + ' <span class="gold">' + esc(tail) + '</span>';
|
|
}
|
|
|
|
function render(rec) {
|
|
const a = ANGLES[rec.angle] || ANGLES.overview;
|
|
// Co-branding is resolved by the caller and passed on the record, so this
|
|
// module stays free of level logic. Absent = team identity, which is also
|
|
// what a member who drops below L6 gets back automatically.
|
|
const b = rec.brand || null;
|
|
// The button and the QR go to DIFFERENT places on purpose.
|
|
//
|
|
// `?v=<angle>` is not a normal landing page — it is a SQUEEZE step that hides
|
|
// everything except the hook video and one button. A reader of this page has
|
|
// just watched that exact video, so sending them there replayed it before
|
|
// they could act. Dropping the `?v=` lands them on the full invite page
|
|
// instead: payout feed, proof, sponsor card, join button. That is the proof
|
|
// layer, and skipping straight past it to the payment screen would be too big
|
|
// a jump for someone who arrived cold from an ad.
|
|
//
|
|
// The QR keeps the angle, because a scanned code often reaches somebody who
|
|
// never saw this page — off a printout or a screenshot — and the hook video
|
|
// is exactly what they need first.
|
|
const joinLink = 'https://rmcircle.team/join/' + rec.id;
|
|
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}' +
|
|
// The rest of the site centres its hero block (.hero is text-align:center),
|
|
// so this page was the odd one out. Centre the eyebrow, heading and
|
|
// subhead — but NOT the story below, because centred long-form prose is
|
|
// measurably harder to read: every line starts in a different place.
|
|
'.pp .by,.pp h1,.pp .sub{text-align:center}' +
|
|
// Co-branding (L6). The accent overrides --gold for THIS page only, so
|
|
// every gold detail follows the member's colour without touching the
|
|
// site stylesheet. The footer disclaimers are rendered below and keep
|
|
// their own muted colour — branding never restyles the risk wording.
|
|
(b ? ':root{--gold:' + b.accent + '}'
|
|
+ '.brand .mono{display:inline-flex;align-items:center;justify-content:center;width:42px;height:42px;'
|
|
+ 'border-radius:50%;background:' + b.accentSoft + ';border:2px solid ' + b.accent + ';'
|
|
+ 'color:' + b.accent + ';font-weight:900;font-size:15px;letter-spacing:.5px}' : '') +
|
|
'.pp h1{font-size:clamp(30px,6vw,48px);line-height:1.06;letter-spacing:-.8px;margin:8px 0 12px;text-wrap:balance}' +
|
|
'.pp h1 .gold{color:var(--gold)}' +
|
|
'.pp .sub{color:#dbe6ef;font-size:clamp(19px,2.4vw,22px);line-height:1.55;margin:0 auto 24px;max-width:46ch}' +
|
|
// The byline becomes the gold eyebrow the rest of the site uses above
|
|
// an h1, so a member page reads as part of the same family instead of
|
|
// a stray document.
|
|
'.pp .by{display:flex;align-items:center;justify-content:center;gap:8px;color:var(--gold);font-size:12px;font-weight:700;' +
|
|
'letter-spacing:1.6px;text-transform:uppercase;margin-bottom:6px}' +
|
|
'.pp .by b{color:var(--gold);font-weight:800}' +
|
|
'.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>' +
|
|
(b
|
|
? '<header class="wrap nav"><span class="brand"><span class="mono">' + esc(b.monogram) + '</span>'
|
|
+ '<span><span>' + esc(b.brandName) + '</span><small>' + esc(b.tagline || 'A personal invitation') + '</small></span></span></header>'
|
|
: '<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>' + goldTail(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(joinLink) + '">Join ' + who + ' →</a>' +
|
|
'<div class="qr" id="ppQr" data-link="' + esc(link) + '"></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 src="/page-qr.js"></script>' +
|
|
'<script src="/chat.js" defer></script><script src="/translate.js" defer></script>' +
|
|
'</body></html>';
|
|
}
|
|
|
|
module.exports = { init, load, save, remove, list, slugify, slugFromTitle, pageLimit, PAGE_LIMIT, generate, render, ANGLES, parseSections };
|