Files
rm-circle-team-router/suite-pages.js
T
martbost 90b03c16f9 Member pages: house heading treatment; clearer no-wallet message
Two things Marty raised.

1. Member pages did not look like the rest of the site. The byline is now the
   gold uppercase eyebrow used above every other h1, the heading is larger and
   tighter with the last beat in gold (house style is "Team <gold>Grants</gold>"),
   and the subhead steps up from 18px to a fluid 19-22px in a brighter colour.
   The headline is model-written so the gold split cannot be hand-picked —
   goldTail() takes the final two words, or one on a short line. Everything is
   escaped before the span is added; the only markup is ours.

2. The "no wallet found" error listed five wallet names and described a DApp
   tab without saying the thing that matters: you are in the wrong browser,
   and here is the address to type. It now says exactly that, names the page
   address, and warns that Trust on iPhone has no browser at all any more —
   previously those members were reading instructions they could not follow.

Diagnosed from a real case (#34): the company dApp at thermcircle.com supports
WalletConnect, so it connects from any browser; this site is injected-provider
only, so it needs the wallet's own browser. That is the whole difference, and
WalletConnect is the actual fix — this is the stopgap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 05:42:58 -05:00

234 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 ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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}' +
'.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-bottom: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;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 + ' &rarr;</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 };