Page Builder: members can hold several pages, scaled by level
The quota and the capability used to contradict each other. An Ascensus member was allowed 3 page builds a month, but every build overwrote the same file — so they could build three times and never have more than one page. The slug plumbing to do better already existed and was only exposed at level 6, which made the Funnel Factory's differentiator a COUNT rather than a KIND, the same flaw the upper tiers had. Now: hold caps by level (2 at Ascensus rising to 50 at Corona), separate from the monthly build quota. Rebuilding spends a build but needs no hold room; adding a new page needs both. The Page Builder is a list of your pages with view / copy link / rebuild / delete, plus a form that ADDS one instead of silently replacing what you had. Two things kept deliberately fixed. The first page a member builds stays at /p/<id> forever and cannot be deleted — that address ends up on banners, printed flyers and in people's DMs, so it must never move. Extra pages take a slug from the headline the writer produced, de-duplicated, so the address describes the page without anyone having to invent one. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -29,6 +29,13 @@
|
|||||||
border-radius:50%;animation:sp .8s linear infinite;vertical-align:-2px;margin-right:7px}
|
border-radius:50%;animation:sp .8s linear infinite;vertical-align:-2px;margin-right:7px}
|
||||||
@keyframes sp{to{transform:rotate(360deg)}}
|
@keyframes sp{to{transform:rotate(360deg)}}
|
||||||
@media (prefers-reduced-motion:reduce){.spin{animation:none}}
|
@media (prefers-reduced-motion:reduce){.spin{animation:none}}
|
||||||
|
.pg-page{display:flex;gap:12px;align-items:flex-start;justify-content:space-between;flex-wrap:wrap;
|
||||||
|
border:1px solid var(--line);border-radius:12px;padding:12px 14px;margin-bottom:9px}
|
||||||
|
.pg-page.main{border-color:var(--gold)}
|
||||||
|
.pg-page .nm{font-weight:800;font-size:15px}
|
||||||
|
.pg-page .u{font-size:12.5px;color:var(--muted);word-break:break-all;margin-top:2px}
|
||||||
|
.pg-page .meta{font-size:12px;color:var(--muted);margin-top:3px}
|
||||||
|
.pg-acts{display:flex;gap:7px;flex-wrap:wrap}
|
||||||
</style></head>
|
</style></head>
|
||||||
<body>
|
<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>
|
<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>
|
||||||
@@ -45,6 +52,12 @@
|
|||||||
<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 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>
|
||||||
|
|
||||||
|
<div id="pgPagesWrap" style="display:none">
|
||||||
|
<h2 style="font-size:19px;margin:0 0 4px">Your pages</h2>
|
||||||
|
<div class="pg-hint" id="pgCapNote" style="margin-bottom:10px"></div>
|
||||||
|
<div id="pgPages"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="pg-card" id="pgCard">
|
<div class="pg-card" id="pgCard">
|
||||||
<label class="pg-lab" for="pgName">What should people call you?</label>
|
<label class="pg-lab" for="pgName">What should people call you?</label>
|
||||||
<input type="text" id="pgName" maxlength="60" placeholder="e.g. Marty B.">
|
<input type="text" id="pgName" maxlength="60" placeholder="e.g. Marty B.">
|
||||||
|
|||||||
+100
-8
@@ -5,6 +5,8 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
var $ = function (id) { return document.getElementById(id); };
|
var $ = function (id) { return document.getElementById(id); };
|
||||||
var angle = 'overview', busy = false, myId = null;
|
var angle = 'overview', busy = false, myId = null;
|
||||||
|
// null = building a NEW page; '' = rebuilding the main one; 'slug' = that page
|
||||||
|
var replacing = null, pages = [], cap = 0;
|
||||||
|
|
||||||
function renderAngles(angles) {
|
function renderAngles(angles) {
|
||||||
var host = $('pgAngles');
|
var host = $('pgAngles');
|
||||||
@@ -19,6 +21,93 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||||||
|
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMode(mode, label) {
|
||||||
|
replacing = mode;
|
||||||
|
$('pgGo').textContent = mode === null ? '🧱 Build a new page' : '🔁 Rebuild ' + (label || 'this page');
|
||||||
|
if (mode !== null) $('pgCard').scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPages() {
|
||||||
|
var wrap = $('pgPagesWrap'), host = $('pgPages');
|
||||||
|
if (!pages.length) { wrap.style.display = 'none'; return; }
|
||||||
|
wrap.style.display = 'block';
|
||||||
|
host.innerHTML = '';
|
||||||
|
pages.forEach(function (p) {
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'pg-page' + (p.slug ? '' : ' main');
|
||||||
|
var left = document.createElement('div');
|
||||||
|
left.style.minWidth = '210px';
|
||||||
|
left.innerHTML = '<div class="nm">' + esc(p.name || 'Your page') +
|
||||||
|
(p.slug ? '' : ' <span style="color:var(--gold);font-size:11px;font-weight:900">MAIN</span>') + '</div>' +
|
||||||
|
'<div class="u">rmcircle.team' + esc(p.url) + '</div>' +
|
||||||
|
'<div class="meta">' + esc(p.angle || '') +
|
||||||
|
(p.updatedAt ? ' · updated ' + esc(String(p.updatedAt).slice(0, 10)) : '') + '</div>';
|
||||||
|
row.appendChild(left);
|
||||||
|
|
||||||
|
var acts = document.createElement('div');
|
||||||
|
acts.className = 'pg-acts';
|
||||||
|
var view = document.createElement('a');
|
||||||
|
view.className = 'btn btn-secondary btn-sm';
|
||||||
|
view.href = p.url; view.target = '_blank'; view.rel = 'noopener';
|
||||||
|
view.textContent = 'View';
|
||||||
|
acts.appendChild(view);
|
||||||
|
|
||||||
|
var copy = document.createElement('button');
|
||||||
|
copy.className = 'btn btn-secondary btn-sm';
|
||||||
|
copy.textContent = 'Copy link';
|
||||||
|
copy.addEventListener('click', function () {
|
||||||
|
if (navigator.clipboard) navigator.clipboard.writeText('https://rmcircle.team' + p.url);
|
||||||
|
copy.textContent = 'Copied';
|
||||||
|
setTimeout(function () { copy.textContent = 'Copy link'; }, 1400);
|
||||||
|
});
|
||||||
|
acts.appendChild(copy);
|
||||||
|
|
||||||
|
var re = document.createElement('button');
|
||||||
|
re.className = 'btn btn-secondary btn-sm';
|
||||||
|
re.textContent = 'Rebuild';
|
||||||
|
re.addEventListener('click', function () { setMode(p.slug || '', p.name || 'this page'); });
|
||||||
|
acts.appendChild(re);
|
||||||
|
|
||||||
|
// The main page keeps its address forever — it is on banners and printed
|
||||||
|
// flyers — so only the extra pages can be removed.
|
||||||
|
if (p.slug) {
|
||||||
|
var del = document.createElement('button');
|
||||||
|
del.className = 'btn btn-secondary btn-sm';
|
||||||
|
del.textContent = 'Delete';
|
||||||
|
del.addEventListener('click', function () { removePage(p.slug, p.name, del); });
|
||||||
|
acts.appendChild(del);
|
||||||
|
}
|
||||||
|
row.appendChild(acts);
|
||||||
|
host.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
var left = Math.max(0, cap - pages.length);
|
||||||
|
$('pgCapNote').innerHTML = 'Holding <b>' + pages.length + '</b> of <b>' + cap + '</b> pages your level allows' +
|
||||||
|
(left ? ' — room for ' + left + ' more.' : ' — delete one to add another, or upgrade to hold more.');
|
||||||
|
if (!left && replacing === null) setMode(pages.length ? (pages[0].slug || '') : null, pages[0] && pages[0].name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removePage(slug, name, btn) {
|
||||||
|
if (!window.confirm('Delete "' + (name || slug) + '"? Anything pointing at that address will fall back to your main page.')) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
var r = await fetch('/api/public/suite-page', {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ remove: slug })
|
||||||
|
});
|
||||||
|
var d = await r.json();
|
||||||
|
if (r.ok) { pages = d.pages || []; cap = d.cap || cap; renderPages(); }
|
||||||
|
else btn.disabled = false;
|
||||||
|
} catch (e) { btn.disabled = false; }
|
||||||
|
}
|
||||||
|
|
||||||
function showMeter(m) {
|
function showMeter(m) {
|
||||||
if (!m || !m.limit) { $('pgMeter').textContent = ''; return; }
|
if (!m || !m.limit) { $('pgMeter').textContent = ''; return; }
|
||||||
$('pgMeter').innerHTML = '<b>' + m.remaining + '</b> of ' + m.limit + ' page builds left this month';
|
$('pgMeter').innerHTML = '<b>' + m.remaining + '</b> of ' + m.limit + ' page builds left this month';
|
||||||
@@ -34,7 +123,6 @@
|
|||||||
// cache-bust so a rebuild always shows the NEW page, not the old one
|
// cache-bust so a rebuild always shows the NEW page, not the old one
|
||||||
$('pgFrame').src = url + '?preview=' + Date.now();
|
$('pgFrame').src = url + '?preview=' + Date.now();
|
||||||
$('pgPrev').style.display = 'block';
|
$('pgPrev').style.display = 'block';
|
||||||
$('pgGo').textContent = '🔁 Rebuild my page';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Live status under the button. A silent 40-second wait reads as "frozen",
|
// Live status under the button. A silent 40-second wait reads as "frozen",
|
||||||
@@ -90,6 +178,13 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showMeter(d.meter);
|
showMeter(d.meter);
|
||||||
|
pages = d.pages || [];
|
||||||
|
cap = d.cap || 0;
|
||||||
|
// First-time member: no pages yet, so the button builds their main one.
|
||||||
|
// Anyone with pages starts in "add another" mode; renderPages flips that
|
||||||
|
// to rebuild if they are already at their level's limit.
|
||||||
|
setMode(pages.length ? null : '', null);
|
||||||
|
renderPages();
|
||||||
if (d.page) {
|
if (d.page) {
|
||||||
if (d.page.name) $('pgName').value = d.page.name;
|
if (d.page.name) $('pgName').value = d.page.name;
|
||||||
if (d.page.audience) $('pgAudience').value = d.page.audience;
|
if (d.page.audience) $('pgAudience').value = d.page.audience;
|
||||||
@@ -114,12 +209,7 @@
|
|||||||
try {
|
try {
|
||||||
var r = await fetch('/api/public/suite-page', {
|
var r = await fetch('/api/public/suite-page', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({ name: $('pgName').value.trim(), audience: $('pgAudience').value.trim(), story: $('pgStory').value.trim(), angle: angle, replace: replacing === null ? undefined : replacing })
|
||||||
name: $('pgName').value.trim(),
|
|
||||||
audience: $('pgAudience').value.trim(),
|
|
||||||
story: $('pgStory').value.trim(),
|
|
||||||
angle: angle
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
var d = await r.json();
|
var d = await r.json();
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
@@ -129,6 +219,8 @@
|
|||||||
} else {
|
} else {
|
||||||
showMeter(d.meter);
|
showMeter(d.meter);
|
||||||
showLive(d.url);
|
showLive(d.url);
|
||||||
|
if (d.pages) { pages = d.pages; cap = d.cap || cap; renderPages(); }
|
||||||
|
setMode(d.canAddMore ? null : (d.slug || ''), null);
|
||||||
$('pgLive').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
$('pgLive').scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -138,7 +230,7 @@
|
|||||||
stopProgress();
|
stopProgress();
|
||||||
busy = false;
|
busy = false;
|
||||||
$('pgGo').disabled = false;
|
$('pgGo').disabled = false;
|
||||||
$('pgGo').textContent = built ? '🔁 Rebuild my page' : '🧱 Build my page';
|
setMode(replacing, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
|||||||
@@ -736,13 +736,28 @@ async function handleApi(req,res,pathname){
|
|||||||
if(e.error)return json(res,e.code||500,{error:e.error});
|
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.'});
|
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');
|
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});
|
const pages=suitePages.list(e.d.id);
|
||||||
|
const cap=suitePages.pageLimit(e.d.level);
|
||||||
|
return json(res,200,{pages:pages,page:suitePages.load(e.d.id),meter:gate,
|
||||||
|
angles:suitePages.ANGLES,level:e.d.level,id:e.d.id,
|
||||||
|
cap:cap,held:pages.length,canAddMore:pages.length<cap});
|
||||||
}
|
}
|
||||||
if(req.method==='POST'&&pathname==='/api/public/suite-page'){
|
if(req.method==='POST'&&pathname==='/api/public/suite-page'){
|
||||||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500}));
|
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.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.'});
|
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 b=await bodyJson(req)||{};
|
||||||
|
|
||||||
|
// Deleting one of their own pages.
|
||||||
|
if(b.remove!==undefined){
|
||||||
|
const slug=String(b.remove||'');
|
||||||
|
if(!slug)return json(res,400,{error:'The main page cannot be deleted — rebuild it instead.'});
|
||||||
|
const gone=suitePages.remove(e.d.id,slug);
|
||||||
|
return json(res,gone?200:404,gone
|
||||||
|
?{removed:slug,pages:suitePages.list(e.d.id),held:suitePages.list(e.d.id).length,cap:suitePages.pageLimit(e.d.level)}
|
||||||
|
:{error:'That page is not there.'});
|
||||||
|
}
|
||||||
|
|
||||||
const input={
|
const input={
|
||||||
name:String(b.name||'').trim().slice(0,60),
|
name:String(b.name||'').trim().slice(0,60),
|
||||||
audience:String(b.audience||'').trim().slice(0,300),
|
audience:String(b.audience||'').trim().slice(0,300),
|
||||||
@@ -750,6 +765,21 @@ async function handleApi(req,res,pathname){
|
|||||||
angle:suitePages.ANGLES[b.angle]?b.angle:'overview'
|
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.'});
|
if(!suiteAI.configured())return json(res,503,{error:'The Page Builder is warming up — try again shortly.'});
|
||||||
|
|
||||||
|
// Two different limits, and they used to contradict each other: the monthly
|
||||||
|
// BUILD quota is how often the writer may run, the HOLD cap is how many
|
||||||
|
// pages the level lets them keep. Rebuilding an existing page spends a
|
||||||
|
// build but does not need hold room; adding a new one needs both.
|
||||||
|
const existing=suitePages.list(e.d.id);
|
||||||
|
const cap=suitePages.pageLimit(e.d.level);
|
||||||
|
const replacing=b.replace!==undefined?String(b.replace||''):null;
|
||||||
|
const isNew=replacing===null;
|
||||||
|
if(isNew&&existing.length>=cap){
|
||||||
|
return json(res,429,{error:cap<=1
|
||||||
|
? 'Your level allows one page. Rebuild the one you have, or upgrade to hold more.'
|
||||||
|
: 'You are holding all '+cap+' pages your level allows. Delete one, or upgrade to hold more.',
|
||||||
|
cap:cap,held:existing.length});
|
||||||
|
}
|
||||||
const gate=suiteMeter.check(e.d.id,e.d.level,'page');
|
const gate=suiteMeter.check(e.d.id,e.d.level,'page');
|
||||||
if(!gate.allowed){
|
if(!gate.allowed){
|
||||||
return json(res,429,{error:gate.reason==='locked'
|
return json(res,429,{error:gate.reason==='locked'
|
||||||
@@ -758,9 +788,23 @@ async function handleApi(req,res,pathname){
|
|||||||
}
|
}
|
||||||
try{
|
try{
|
||||||
const copy=await suitePages.generate(input);
|
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()});
|
// The first page a member builds stays at /p/<id> — that address is on
|
||||||
|
// banners, flyers and in people's DMs, so it must never move. Extra pages
|
||||||
|
// get a slug from the headline the writer produced, so the address
|
||||||
|
// describes the page without asking anyone to invent one.
|
||||||
|
const hasMain=existing.some(function(x){return !x.slug});
|
||||||
|
let slug;
|
||||||
|
if(replacing!==null) slug=replacing;
|
||||||
|
else if(!hasMain) slug='';
|
||||||
|
else slug=suitePages.slugFromTitle(e.d.id,copy.headline,input.angle);
|
||||||
|
|
||||||
|
const rec=suitePages.save(e.d.id,{id:e.d.id,slug:slug||undefined,name:input.name,angle:input.angle,
|
||||||
|
audience:input.audience,story:input.story,copy:copy,updatedAt:new Date().toISOString()},slug||undefined);
|
||||||
suiteMeter.record(e.d.id,'page',1);
|
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')});
|
const url='https://rmcircle.team/p/'+e.d.id+(slug?'/'+slug:'');
|
||||||
|
const after=suitePages.list(e.d.id);
|
||||||
|
return json(res,200,{page:rec,url:url,slug:slug||'',pages:after,held:after.length,cap:cap,
|
||||||
|
canAddMore:after.length<cap,meter:suiteMeter.check(e.d.id,e.d.level,'page')});
|
||||||
}catch(err){ return json(res,502,{error:String(err.message||err)}); }
|
}catch(err){ return json(res,502,{error:String(err.message||err)}); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user