diff --git a/public/suite-page.html b/public/suite-page.html new file mode 100644 index 0000000..197a048 --- /dev/null +++ b/public/suite-page.html @@ -0,0 +1,75 @@ +Page Builder | The Circle Suite + + + +
+
CIRCLE SUITE · LEVEL 2 · ASCENSUS
+

Page Builder.

+

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.

+ +
+ +
+
✅ Your page is live
+
+
Open it →
+
+ +
+ + + +
+
This picks the short video that plays on your page.
+ + + + +
Write it messy. Real beats perfect, and it gets rewritten in your voice either way.
+
+ + +
+
+
+ +
+
Preview
+ +
+ +

Teach it forward: when your two join, walk them through building theirs — a personal page beats a bare link every time.
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.

+
+ + + diff --git a/public/suite-page.js b/public/suite-page.js new file mode 100644 index 0000000..8142a1c --- /dev/null +++ b/public/suite-page.js @@ -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/ 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 = '' + m.remaining + ' 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. Open the Suite 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. See your Suite.'); 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 Ascensus (level 2). You’re at level ' + d.level + ' — your next upgrade opens it, along with the Copy Engine. See what an upgrade costs →'); + 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 = '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); + }); + }); + }); +})(); diff --git a/public/suite.js b/public/suite.js index 07a0c19..ce10f83 100644 --- a/public/suite.js +++ b/public/suite.js @@ -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 }, diff --git a/server.js b/server.js index 2fabf80..2bc21eb 100644 --- a/server.js +++ b/server.js @@ -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); diff --git a/suite-ai.js b/suite-ai.js index fbe7d1d..5461899 100644 --- a/suite-ai.js +++ b/suite-ai.js @@ -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 }; diff --git a/suite-pages.js b/suite-pages.js new file mode 100644 index 0000000..f34734d --- /dev/null +++ b/suite-pages.js @@ -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 '

' + esc(p) + '

'; }).join(''); + const bullets = (c.bullets || []).map(function (b) { return '
  • ' + esc(b) + '
  • '; }).join(''); + return '' + + '' + + '' + esc(c.headline || 'An invitation') + ' | ' + who + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
    ' + + '
    Shared by ' + who + ' · Member #' + esc(rec.id) + '
    ' + + '

    ' + esc(c.headline || '') + '

    ' + + (c.subhead ? '

    ' + esc(c.subhead) + '

    ' : '') + + '
    ' + + story + + (bullets ? '' : '') + + '

    ' + esc(c.closing || 'Take a look and see what you think.') + '

    ' + + 'See how it works →' + + '
    or scan · ' + esc(link.replace(/^https:\/\//, '')) + '
    ' + + '
    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. ' + + 'Disclaimers · How the contract works
    ' + + '
    ' + + '' + + '' + + ''; +} + +module.exports = { init, load, save, generate, render, ANGLES, parseSections };