Files
rm-circle-team-router/suite-pages.js
T

130 lines
8.8 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; }
function file(id) { return path.join(dir(), String(id).replace(/\D/g, '') + '.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; }
// 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.'
);
}
function parseSections(raw) {
const out = { headline: '', subhead: '', story: [], bullets: [], closing: '' };
const text = String(raw || '').replace(/\r/g, '');
const grab = function (label, next) {
const re = new RegExp(label + ':\\s*([\\s\\S]*?)(?=\\n(?:' + next + '):|$)', 'i');
const m = text.match(re);
return m ? m[1].trim() : '';
};
out.headline = grab('HEADLINE', 'SUBHEAD|STORY|BULLETS|CLOSING').split('\n')[0].trim().slice(0, 90);
out.subhead = grab('SUBHEAD', 'STORY|BULLETS|CLOSING').split('\n')[0].trim().slice(0, 200);
out.story = grab('STORY', 'BULLETS|CLOSING').split(/\n{2,}/).map(function (p) { return p.replace(/\s+/g, ' ').trim(); }).filter(Boolean).slice(0, 4);
out.bullets = grab('BULLETS', 'CLOSING').split('\n').map(function (b) { return b.replace(/^[-•*]\s*/, '').trim(); }).filter(Boolean).slice(0, 4);
out.closing = grab('CLOSING', 'ZZZZ').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 ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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, generate, render, ANGLES, parseSections };