1d52597c1c
The QR on /p/<id> and /p/<id>/<slug> was drawn by an inline <script>, but the site sends `script-src 'self'` with no 'unsafe-inline'. The browser silently refused to run it, so the QR box rendered as an empty white square on every member page, not just Funnel Factory ones. Moved the bootstrap to /page-qr.js and pass the link via a data attribute, so no page data is interpolated into executable script and the CSP stays as strict as it is. Loosening script-src to fix this would have traded a site-wide security property for one widget. This matters more than it looks: these pages go on printed flyers and ad destinations, where an unscannable QR is discovered by the person holding the paper. 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" 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, generate, render, ANGLES, parseSections };
|