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
+13
View File
@@ -29,6 +29,13 @@
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}}
.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>
<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>
@@ -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>
<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">
<label class="pg-lab" for="pgName">What should people call you?</label>
<input type="text" id="pgName" maxlength="60" placeholder="e.g. Marty B.">
+100 -8
View File
@@ -5,6 +5,8 @@
'use strict';
var $ = function (id) { return document.getElementById(id); };
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) {
var host = $('pgAngles');
@@ -19,6 +21,93 @@
});
}
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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) {
if (!m || !m.limit) { $('pgMeter').textContent = ''; return; }
$('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
$('pgFrame').src = url + '?preview=' + Date.now();
$('pgPrev').style.display = 'block';
$('pgGo').textContent = '🔁 Rebuild my page';
}
// Live status under the button. A silent 40-second wait reads as "frozen",
@@ -90,6 +178,13 @@
return;
}
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.name) $('pgName').value = d.page.name;
if (d.page.audience) $('pgAudience').value = d.page.audience;
@@ -114,12 +209,7 @@
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
})
body: JSON.stringify({ name: $('pgName').value.trim(), audience: $('pgAudience').value.trim(), story: $('pgStory').value.trim(), angle: angle, replace: replacing === null ? undefined : replacing })
});
var d = await r.json();
if (!r.ok) {
@@ -129,6 +219,8 @@
} else {
showMeter(d.meter);
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' });
}
} catch (e) {
@@ -138,7 +230,7 @@
stopProgress();
busy = false;
$('pgGo').disabled = false;
$('pgGo').textContent = built ? '🔁 Rebuild my page' : '🧱 Build my page';
setMode(replacing, null);
}
document.addEventListener('DOMContentLoaded', function () {