Page Builder ships: labelled-section AI output parsed deterministically, escaped renderer, one page per position at public /p/<id> with angle video + QR, rebuild keeps the same URL; L2 tile live
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="robots" content="noindex"><meta name="description" content="Page Builder — your own personal invitation page, written for you and hosted on the team site."><title>Page Builder | The Circle Suite</title><link rel="icon" type="image/png" href="/favicon.png"><link rel="stylesheet" href="/styles.css">
|
||||
<style>
|
||||
.pw{max-width:880px;margin:0 auto;padding:24px 20px 70px}
|
||||
.pw h1{font-size:clamp(26px,4.5vw,36px);margin:6px 0 8px}
|
||||
.pw .lede{color:var(--muted);line-height:1.6;margin-bottom:18px}
|
||||
#pgGate{display:none;border:2px solid var(--gold);border-radius:12px;padding:16px 20px;margin-bottom:18px;line-height:1.6}
|
||||
#pgGate b{color:var(--gold)}
|
||||
.pg-card{background:var(--card,#0d2236);border:1px solid var(--line,#24425d);border-radius:14px;padding:18px 20px;margin-bottom:16px}
|
||||
label.pg-lab{display:block;font-weight:700;font-size:14px;margin:14px 0 6px}
|
||||
label.pg-lab:first-of-type{margin-top:0}
|
||||
.pg-card input[type=text],.pg-card textarea{width:100%;background:var(--bg,#0b1d2e);border:1px solid var(--line);
|
||||
border-radius:10px;color:var(--text);padding:11px 13px;font-size:15px;font-family:inherit;line-height:1.5}
|
||||
.pg-card textarea{min-height:88px;resize:vertical}
|
||||
.pg-hint{color:var(--muted);font-size:12.5px;margin-top:6px}
|
||||
.pg-angles{display:flex;flex-wrap:wrap;gap:8px}
|
||||
.pg-angle{border:1px solid var(--line);background:transparent;color:var(--muted);border-radius:999px;
|
||||
padding:7px 14px;font-size:13.5px;font-weight:700;cursor:pointer}
|
||||
.pg-angle.on{border-color:var(--teal);background:rgba(78,214,203,.12);color:var(--teal)}
|
||||
.pg-row{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin-top:16px}
|
||||
#pgMeter{font-size:12.5px;color:var(--muted);margin-left:auto}
|
||||
#pgMeter b{color:var(--text)}
|
||||
#pgErr{display:none;color:#f0a05a;margin-top:12px;font-size:14px;line-height:1.55}
|
||||
#pgLive{display:none;border:2px solid var(--teal);border-radius:14px;padding:16px 20px;margin-bottom:16px}
|
||||
#pgLive .u{font-family:ui-monospace,monospace;font-size:15px;color:var(--gold);font-weight:700;word-break:break-all}
|
||||
#pgPrev{display:none;margin-top:16px}
|
||||
#pgFrame{width:100%;height:560px;border:1px solid var(--line);border-radius:12px;background:#0b1d2e}
|
||||
.pg-note{margin-top:20px;font-size:12.5px;color:var(--muted);line-height:1.6;border-top:1px solid var(--line);padding-top:12px}
|
||||
.spin{display:inline-block;width:13px;height:13px;border:2px solid rgba(255,255,255,.25);border-top-color:var(--gold);
|
||||
border-radius:50%;animation:sp .8s linear infinite;vertical-align:-2px;margin-right:7px}
|
||||
@keyframes sp{to{transform:rotate(360deg)}}
|
||||
@media (prefers-reduced-motion:reduce){.spin{animation:none}}
|
||||
</style></head>
|
||||
<body>
|
||||
<header class="wrap nav"><a class="brand" href="/suite"><img class="brand-mark" src="/logo.jpg" alt="The RM Circle" width="42" height="42"><span><span id="brandName">RM Circle</span><small>Page Builder</small></span></a><div class="nav-actions"><a class="btn btn-secondary hide-mobile" href="/suite">← The Suite</a><a class="btn btn-primary" href="/suite/copy">Copy Engine</a></div></header>
|
||||
<main class="pw">
|
||||
<div class="eyebrow" style="color:var(--gold)">CIRCLE SUITE · LEVEL 2 · ASCENSUS</div>
|
||||
<h1>Page <span class="gold">Builder.</span></h1>
|
||||
<p class="lede">Answer four questions and you get your own invitation page — your name, your story, your angle video, your QR code — hosted on the team site at a link you can send anywhere.</p>
|
||||
|
||||
<div id="pgGate"></div>
|
||||
|
||||
<div id="pgLive">
|
||||
<div style="font-weight:700;margin-bottom:6px">✅ Your page is live</div>
|
||||
<div class="u" id="pgUrl"></div>
|
||||
<div class="pg-row"><a class="btn btn-teal btn-sm" id="pgOpen" target="_blank" rel="noopener">Open it →</a><button class="btn btn-secondary btn-sm" id="pgCopyUrl">📋 Copy link</button><span class="pg-hint" id="pgCopied" style="display:none;color:var(--teal)">Copied.</span></div>
|
||||
</div>
|
||||
|
||||
<div class="pg-card" id="pgCard">
|
||||
<label class="pg-lab" for="pgName">What should people call you?</label>
|
||||
<input type="text" id="pgName" maxlength="60" placeholder="e.g. Marty B.">
|
||||
<label class="pg-lab">Which angle fits you best?</label>
|
||||
<div class="pg-angles" id="pgAngles"></div>
|
||||
<div class="pg-hint">This picks the short video that plays on your page.</div>
|
||||
<label class="pg-lab" for="pgAudience">Who are you talking to?</label>
|
||||
<input type="text" id="pgAudience" maxlength="300" placeholder="e.g. friends who work too many hours and are curious but skeptical about crypto">
|
||||
<label class="pg-lab" for="pgStory">In your own words — why are you doing this, and what do you want them to know?</label>
|
||||
<textarea id="pgStory" maxlength="1200" placeholder="Doesn't have to be polished. A few honest sentences is plenty — the writer turns it into the page."></textarea>
|
||||
<div class="pg-hint">Write it messy. Real beats perfect, and it gets rewritten in your voice either way.</div>
|
||||
<div class="pg-row">
|
||||
<button id="pgGo" class="btn btn-primary">🧱 Build my page</button>
|
||||
<span id="pgMeter"></span>
|
||||
</div>
|
||||
<div id="pgErr"></div>
|
||||
</div>
|
||||
|
||||
<div id="pgPrev">
|
||||
<div style="font-weight:700;margin-bottom:8px">Preview</div>
|
||||
<iframe id="pgFrame" title="Your page preview"></iframe>
|
||||
</div>
|
||||
|
||||
<p class="pg-note"><strong style="color:var(--text)">Teach it forward:</strong> when your two join, walk them through building theirs — a personal page beats a bare link every time. <br>Rebuilding replaces your page at the same link, so anything you've already shared keeps working. Independent team resource · No income is guaranteed · Cryptocurrency involves risk.</p>
|
||||
</main>
|
||||
<script src="/suite-page.js"></script>
|
||||
<script src="/chat.js" defer></script>
|
||||
<script src="/translate.js" defer></script><script src="/tg-app.js" defer></script><script src="/nav-dash.js" defer></script></body></html>
|
||||
@@ -0,0 +1,118 @@
|
||||
// Page Builder client. Loads the member's existing page (if any), takes a
|
||||
// four-question brief, and rebuilds at the same /p/<id> URL so shared links
|
||||
// never break.
|
||||
(function () {
|
||||
'use strict';
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
var angle = 'overview', busy = false, myId = null;
|
||||
|
||||
function renderAngles(angles) {
|
||||
var host = $('pgAngles');
|
||||
host.innerHTML = '';
|
||||
Object.keys(angles).forEach(function (k) {
|
||||
var b = document.createElement('button');
|
||||
b.type = 'button';
|
||||
b.className = 'pg-angle' + (k === angle ? ' on' : '');
|
||||
b.textContent = angles[k].label;
|
||||
b.addEventListener('click', function () { angle = k; renderAngles(angles); });
|
||||
host.appendChild(b);
|
||||
});
|
||||
}
|
||||
|
||||
function showMeter(m) {
|
||||
if (!m || !m.limit) { $('pgMeter').textContent = ''; return; }
|
||||
$('pgMeter').innerHTML = '<b>' + m.remaining + '</b> of ' + m.limit + ' page builds left this month';
|
||||
}
|
||||
|
||||
function showLive(url) {
|
||||
$('pgUrl').textContent = url.replace(/^https:\/\//, '');
|
||||
$('pgOpen').href = url;
|
||||
$('pgLive').style.display = 'block';
|
||||
$('pgFrame').src = url + '?preview=1';
|
||||
$('pgPrev').style.display = 'block';
|
||||
$('pgGo').textContent = '🔁 Rebuild my page';
|
||||
}
|
||||
|
||||
function gate(msg) {
|
||||
var g = $('pgGate');
|
||||
g.style.display = 'block';
|
||||
g.innerHTML = msg;
|
||||
$('pgCard').style.opacity = '.55';
|
||||
$('pgGo').disabled = true;
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
try {
|
||||
var r = await fetch('/api/public/suite-page');
|
||||
if (r.status === 401) {
|
||||
gate('You’re not signed in yet. <a href="/suite" style="color:var(--gold);font-weight:700">Open the Suite</a> and connect the wallet that holds your position, then come back.');
|
||||
renderAngles({ overview: { label: 'General overview' } });
|
||||
return;
|
||||
}
|
||||
if (r.status === 403) { gate('The Page Builder isn’t open for this position yet. <a href="/suite" style="color:var(--gold);font-weight:700">See your Suite</a>.'); return; }
|
||||
if (!r.ok) return;
|
||||
var d = await r.json();
|
||||
myId = d.id;
|
||||
renderAngles(d.angles || {});
|
||||
if (d.meter && d.meter.reason === 'locked') {
|
||||
gate('The Page Builder unlocks at <b>Ascensus (level 2)</b>. You’re at level ' + d.level + ' — your next upgrade opens it, along with the Copy Engine. <a href="/how-pay-works#exact-costs" style="color:var(--gold);font-weight:700">See what an upgrade costs →</a>');
|
||||
return;
|
||||
}
|
||||
showMeter(d.meter);
|
||||
if (d.page) {
|
||||
if (d.page.name) $('pgName').value = d.page.name;
|
||||
if (d.page.audience) $('pgAudience').value = d.page.audience;
|
||||
if (d.page.story) $('pgStory').value = d.page.story;
|
||||
if (d.page.angle) { angle = d.page.angle; renderAngles(d.angles || {}); }
|
||||
showLive('https://rmcircle.team/p/' + d.id);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
$('pgErr').style.display = 'none';
|
||||
$('pgGo').disabled = true;
|
||||
var label = $('pgGo').textContent;
|
||||
$('pgGo').innerHTML = '<span class="spin"></span>Writing your page… (up to a minute)';
|
||||
try {
|
||||
var r = await fetch('/api/public/suite-page', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: $('pgName').value.trim(),
|
||||
audience: $('pgAudience').value.trim(),
|
||||
story: $('pgStory').value.trim(),
|
||||
angle: angle
|
||||
})
|
||||
});
|
||||
var d = await r.json();
|
||||
if (!r.ok) {
|
||||
$('pgErr').textContent = d.error || 'That didn’t go through — try again.';
|
||||
$('pgErr').style.display = 'block';
|
||||
if (d.meter) showMeter(d.meter);
|
||||
} else {
|
||||
showMeter(d.meter);
|
||||
showLive(d.url);
|
||||
$('pgLive').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
} catch (e) {
|
||||
$('pgErr').textContent = 'Connection hiccup — try again.';
|
||||
$('pgErr').style.display = 'block';
|
||||
}
|
||||
busy = false;
|
||||
$('pgGo').disabled = false;
|
||||
$('pgGo').textContent = label.indexOf('Rebuild') !== -1 ? '🔁 Rebuild my page' : '🧱 Build my page';
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
boot();
|
||||
$('pgGo').addEventListener('click', run);
|
||||
$('pgCopyUrl').addEventListener('click', function () {
|
||||
navigator.clipboard.writeText($('pgOpen').href).then(function () {
|
||||
$('pgCopied').style.display = '';
|
||||
setTimeout(function () { $('pgCopied').style.display = 'none'; }, 2500);
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
{ lv: 1, ico: '📈', name: 'Live Dashboard', desc: 'Your pipeline, organization bar, coach panel and wallet-verified team messages.', href: '/my', live: true },
|
||||
{ lv: 1, ico: '🤖', name: 'AI Coach', desc: '24/7 answers about the system, in 21+ languages — the chat bubble on every page of this site.', href: '#', live: true, coach: true },
|
||||
{ lv: 2, ico: '✍️', name: 'Copy Engine', desc: 'An AI copywriter tuned to this business: posts, DMs, follow-ups and objection replies with your link filled in.', href: '/suite/copy', live: true },
|
||||
{ lv: 2, ico: '🧱', name: 'Page Builder', desc: 'Answer five questions, get your own hosted bridge page — your story, your angle video, your QR.', live: false },
|
||||
{ lv: 2, ico: '🧱', name: 'Page Builder', desc: 'Answer four questions, get your own hosted invitation page — your story, your angle video, your QR.', href: '/suite/page', live: true },
|
||||
{ lv: 3, ico: '📧', name: 'Email Engine', desc: 'Follow-up sequences and broadcasts in the team voice, exportable to any autoresponder.', live: false },
|
||||
{ lv: 3, ico: '🎥', name: 'Video Maker', desc: 'The team’s master promo videos rendered with your personal end-card — your name, your QR, your link.', live: false },
|
||||
{ lv: 4, ico: '🗣️', name: 'Voice Profile + Funnels', desc: 'Output that sounds like YOU, plus multi-page funnels on your own team subdomain.', live: false },
|
||||
|
||||
@@ -19,6 +19,7 @@ const IS_PROD = process.env.NODE_ENV === 'production';
|
||||
messages.init({ dataDir: DATA_DIR, chain, isProd: IS_PROD });
|
||||
const suiteMeter = require('./suite-meter'); suiteMeter.init({ dataDir: DATA_DIR });
|
||||
const suiteAI = require('./suite-ai'); suiteAI.init({ dataDir: DATA_DIR });
|
||||
const suitePages = require('./suite-pages'); suitePages.init({ dataDir: DATA_DIR });
|
||||
const tgbot = require('./tgbot');
|
||||
tgbot.init({ dataDir: DATA_DIR, chain, getConfig, messages, baseUrl: 'https://rmcircle.team' });
|
||||
const SESSION_TTL = 8 * 60 * 60 * 1000;
|
||||
@@ -658,6 +659,48 @@ async function handleApi(req,res,pathname){
|
||||
return {d,inOrg,beta,allowed};
|
||||
}
|
||||
|
||||
// ── Page Builder ─────────────────────────────────────────────────────────
|
||||
if(req.method==='GET'&&/^\/p\/\d{1,15}$/.test(pathname)){
|
||||
const pid=pathname.split('/')[2];
|
||||
const rec=suitePages.load(pid);
|
||||
if(!rec||!rec.copy)return json(res,404,{error:'No page here yet.'});
|
||||
const html=suitePages.render(rec);
|
||||
res.writeHead(200,{'Content-Type':'text/html; charset=utf-8','Cache-Control':'public, max-age=120'});
|
||||
return res.end(html);
|
||||
}
|
||||
if(req.method==='GET'&&pathname==='/api/public/suite-page'){
|
||||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup.',code:500}));
|
||||
if(e.error)return json(res,e.code||500,{error:e.error});
|
||||
if(!e.inOrg||!e.allowed)return json(res,403,{error:'Not available for this position yet.'});
|
||||
const gate=suiteMeter.check(e.d.id,e.d.level,'page');
|
||||
return json(res,200,{page:suitePages.load(e.d.id),meter:gate,angles:suitePages.ANGLES,level:e.d.level,id:e.d.id});
|
||||
}
|
||||
if(req.method==='POST'&&pathname==='/api/public/suite-page'){
|
||||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500}));
|
||||
if(e.error)return json(res,e.code||500,{error:e.error});
|
||||
if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'});
|
||||
const b=await bodyJson(req)||{};
|
||||
const input={
|
||||
name:String(b.name||'').trim().slice(0,60),
|
||||
audience:String(b.audience||'').trim().slice(0,300),
|
||||
story:String(b.story||'').trim().slice(0,1200),
|
||||
angle:suitePages.ANGLES[b.angle]?b.angle:'overview'
|
||||
};
|
||||
if(!suiteAI.configured())return json(res,503,{error:'The Page Builder is warming up — try again shortly.'});
|
||||
const gate=suiteMeter.check(e.d.id,e.d.level,'page');
|
||||
if(!gate.allowed){
|
||||
return json(res,429,{error:gate.reason==='locked'
|
||||
? 'The Page Builder unlocks at Ascensus (level 2). Your next upgrade opens it.'
|
||||
: 'You have used all '+gate.limit+' page builds this month. It resets on the 1st — or a level upgrade raises your allowance.',meter:gate});
|
||||
}
|
||||
try{
|
||||
const copy=await suitePages.generate(input);
|
||||
const rec=suitePages.save(e.d.id,{id:e.d.id,name:input.name,angle:input.angle,audience:input.audience,story:input.story,copy:copy,updatedAt:new Date().toISOString()});
|
||||
suiteMeter.record(e.d.id,'page',1);
|
||||
return json(res,200,{page:rec,url:'https://rmcircle.team/p/'+e.d.id,meter:suiteMeter.check(e.d.id,e.d.level,'page')});
|
||||
}catch(err){ return json(res,502,{error:String(err.message||err)}); }
|
||||
}
|
||||
|
||||
if(req.method==='GET'&&pathname==='/api/public/suite-meters'){
|
||||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup.',code:500}));
|
||||
if(e.error)return json(res,e.code||500,{error:e.error});
|
||||
@@ -989,7 +1032,7 @@ const server=http.createServer(async(req,res)=>{
|
||||
if((mj=pathname.match(/^\/join\/(\d{1,15})$/)))return serveMemberPage(req,res,path.join(PUBLIC_DIR,'join.html'),'join',mj[1]);
|
||||
}
|
||||
let file;
|
||||
if(pathname==='/')file=path.join(PUBLIC_DIR,'index.html');else if(pathname==='/app'||pathname==='/app/')file=path.join(PUBLIC_DIR,'app.html');else if(pathname==='/start'||pathname==='/start/')file=path.join(PUBLIC_DIR,'start.html');else if(pathname==='/training'||pathname==='/training/')file=path.join(PUBLIC_DIR,'training.html');else if(pathname==='/admin'||pathname==='/admin/')file=path.join(PUBLIC_DIR,'admin.html');else if(pathname==='/my'||pathname==='/my/'||/^\/my\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'my.html');else if(pathname==='/contract'||pathname==='/contract/')file=path.join(PUBLIC_DIR,'contract.html');else if(pathname==='/disclaimer'||pathname==='/disclaimer/')file=path.join(PUBLIC_DIR,'disclaimer.html');else if(pathname==='/how-pay-works'||pathname==='/how-pay-works/')file=path.join(PUBLIC_DIR,'how-pay-works.html');else if(pathname==='/tools'||pathname==='/tools/')file=path.join(PUBLIC_DIR,'tools.html');else if(pathname==='/fast-start'||pathname==='/fast-start/')file=path.join(PUBLIC_DIR,'fast-start.html');else if(pathname==='/flyers'||pathname==='/flyers/')file=path.join(PUBLIC_DIR,'flyers.html');else if(pathname==='/weekly-rhythm'||pathname==='/weekly-rhythm/')file=path.join(PUBLIC_DIR,'weekly-rhythm.html');else if(pathname==='/generation-pay'||pathname==='/generation-pay/')file=path.join(PUBLIC_DIR,'generation-pay.html');else if(pathname==='/suite'||pathname==='/suite/')file=path.join(PUBLIC_DIR,'suite.html');else if(pathname==='/suite/copy'||pathname==='/suite/copy/')file=path.join(PUBLIC_DIR,'suite-copy.html');else if(pathname==='/direct-join'||pathname==='/direct-join/'){if(!getSession(req)){res.writeHead(302,{Location:'/admin'});return res.end();}file=path.join(ROOT,'private','direct-join.html');}else if(pathname==='/join-now'||pathname==='/join-now/'){if(!getConfig().dappFallbackPublic){res.writeHead(302,{Location:'/start'});return res.end();}file=path.join(ROOT,'private','join-now.html');}else if(/^\/join\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'join.html');else if(pathname==='/join'||pathname==='/join/'){res.writeHead(302,{Location:'/join-now'});return res.end();}else{
|
||||
if(pathname==='/')file=path.join(PUBLIC_DIR,'index.html');else if(pathname==='/app'||pathname==='/app/')file=path.join(PUBLIC_DIR,'app.html');else if(pathname==='/start'||pathname==='/start/')file=path.join(PUBLIC_DIR,'start.html');else if(pathname==='/training'||pathname==='/training/')file=path.join(PUBLIC_DIR,'training.html');else if(pathname==='/admin'||pathname==='/admin/')file=path.join(PUBLIC_DIR,'admin.html');else if(pathname==='/my'||pathname==='/my/'||/^\/my\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'my.html');else if(pathname==='/contract'||pathname==='/contract/')file=path.join(PUBLIC_DIR,'contract.html');else if(pathname==='/disclaimer'||pathname==='/disclaimer/')file=path.join(PUBLIC_DIR,'disclaimer.html');else if(pathname==='/how-pay-works'||pathname==='/how-pay-works/')file=path.join(PUBLIC_DIR,'how-pay-works.html');else if(pathname==='/tools'||pathname==='/tools/')file=path.join(PUBLIC_DIR,'tools.html');else if(pathname==='/fast-start'||pathname==='/fast-start/')file=path.join(PUBLIC_DIR,'fast-start.html');else if(pathname==='/flyers'||pathname==='/flyers/')file=path.join(PUBLIC_DIR,'flyers.html');else if(pathname==='/weekly-rhythm'||pathname==='/weekly-rhythm/')file=path.join(PUBLIC_DIR,'weekly-rhythm.html');else if(pathname==='/generation-pay'||pathname==='/generation-pay/')file=path.join(PUBLIC_DIR,'generation-pay.html');else if(pathname==='/suite'||pathname==='/suite/')file=path.join(PUBLIC_DIR,'suite.html');else if(pathname==='/suite/copy'||pathname==='/suite/copy/')file=path.join(PUBLIC_DIR,'suite-copy.html');else if(pathname==='/suite/page'||pathname==='/suite/page/')file=path.join(PUBLIC_DIR,'suite-page.html');else if(pathname==='/direct-join'||pathname==='/direct-join/'){if(!getSession(req)){res.writeHead(302,{Location:'/admin'});return res.end();}file=path.join(ROOT,'private','direct-join.html');}else if(pathname==='/join-now'||pathname==='/join-now/'){if(!getConfig().dappFallbackPublic){res.writeHead(302,{Location:'/start'});return res.end();}file=path.join(ROOT,'private','join-now.html');}else if(/^\/join\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'join.html');else if(pathname==='/join'||pathname==='/join/'){res.writeHead(302,{Location:'/join-now'});return res.end();}else{
|
||||
const safe=path.normalize(pathname).replace(/^([.][.][/\\])+/, '').replace(/^[/\\]+/,'');file=path.join(PUBLIC_DIR,safe);if(!file.startsWith(PUBLIC_DIR))file='';
|
||||
}
|
||||
if(file&&staticFile(req,res,file))return;return staticFile(req,res,path.join(PUBLIC_DIR,'404.html'),404);
|
||||
|
||||
+22
-6
@@ -68,10 +68,14 @@ function buildMessages(kind, brief, member) {
|
||||
|
||||
// Engine call. Long timeout: the agent narrates internally before answering.
|
||||
function generate(kind, brief, member) {
|
||||
return call(buildMessages(kind, brief, member)).then(function (t) { return cleanup(t, member); });
|
||||
}
|
||||
|
||||
function call(messages) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
const c = creds();
|
||||
if (!c) return reject(new Error('The Copy Engine is not configured yet.'));
|
||||
const body = JSON.stringify({ model: c.model || 'hermes-agent', messages: buildMessages(kind, brief, member) });
|
||||
if (!c) return reject(new Error('The engine is not configured yet.'));
|
||||
const body = JSON.stringify({ model: c.model || 'hermes-agent', messages: messages });
|
||||
const u = new URL(c.url.replace(/\/$/, '') + '/chat/completions');
|
||||
const lib = u.protocol === 'https:' ? require('https') : require('http');
|
||||
const req = lib.request({
|
||||
@@ -84,14 +88,13 @@ function generate(kind, brief, member) {
|
||||
if (res.statusCode !== 200) return reject(new Error('Engine returned ' + res.statusCode));
|
||||
try {
|
||||
const j = JSON.parse(data);
|
||||
let text = (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content || '').trim();
|
||||
text = cleanup(text, member);
|
||||
const text = (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content || '').trim();
|
||||
if (!text) return reject(new Error('The engine came back empty — try again.'));
|
||||
resolve(text);
|
||||
} catch (e) { reject(new Error('Could not read the engine response.')); }
|
||||
});
|
||||
});
|
||||
req.on('error', function (e) { reject(new Error('Could not reach the Copy Engine: ' + e.message)); });
|
||||
req.on('error', function (e) { reject(new Error('Could not reach the engine: ' + e.message)); });
|
||||
req.setTimeout(240000, function () { req.destroy(new Error('The engine took too long — try again.')); });
|
||||
req.write(body); req.end();
|
||||
});
|
||||
@@ -114,4 +117,17 @@ function cleanup(text, member) {
|
||||
return t.trim();
|
||||
}
|
||||
|
||||
module.exports = { init, configured, generate, KINDS };
|
||||
// Raw generation for tools that supply their own instruction (Page Builder).
|
||||
// Still carries the facts + compliance + voice; only FORMAT is the caller's.
|
||||
function generateRaw(instruction) {
|
||||
return call([
|
||||
{ role: 'system', content:
|
||||
'You write for a member of the RM Circle team.\n\nWHAT IT IS: ' + FACTS +
|
||||
'\n\nVOICE: ' + VOICE +
|
||||
'\n\nCOMPLIANCE (absolute): ' + COMPLIANCE +
|
||||
'\n\nFollow the requested output format EXACTLY. No preamble, no explanation, no markdown fences.' },
|
||||
{ role: 'user', content: instruction }
|
||||
]);
|
||||
}
|
||||
|
||||
module.exports = { init, configured, generate, generateRaw, KINDS };
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
// 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 ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 };
|
||||
Reference in New Issue
Block a user