36327abed2
The site's .hero is text-align:center, so /p/<id> was the only landing page left-aligning its heading. The eyebrow, headline and subhead now centre with the rest of the site. The story body deliberately stays left-aligned — centred long-form prose is harder to read because every line starts in a different place, and that section is several paragraphs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
239 lines
14 KiB
JavaScript
239 lines
14 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;
|
|
// 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}' +
|
|
'.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>' +
|
|
'<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 };
|