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:
martbost
2026-08-29 06:02:06 -05:00
parent 3620a72338
commit c8ae4d55b0
3 changed files with 160 additions and 11 deletions
+47 -3
View File
@@ -736,13 +736,28 @@ async function handleApi(req,res,pathname){
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});
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'){
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)||{};
// 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={
name:String(b.name||'').trim().slice(0,60),
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'
};
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');
if(!gate.allowed){
return json(res,429,{error:gate.reason==='locked'
@@ -758,9 +788,23 @@ async function handleApi(req,res,pathname){
}
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()});
// 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);
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)}); }
}