Founder Desk (L8): weekly campaign pack + Suite API

Completes the ladder — no tier is a placeholder now.

Every tier below builds one thing at a time, which is right for someone
still finding their words. At the top the constraint is different: these are
people running organisations who don't have an afternoon to spend clicking.
So one pass produces five social posts across five different angles, three
outreach messages (first approach, follow-up, pyramid-scheme answer), an
email, and three text ads — all carrying their link, all in the member's own
voice profile if they have one.

Partial failures are reported rather than hidden: if the engine can't finish
a section the pack says which, instead of quietly handing over a short week.

API access is real, not a label: a personal key over
GET /me, GET /team, POST /generate. Keys are stored hashed and shown exactly
once, compared in constant time, and every call goes through the same meters
and compliance rules as the web tools.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-08-28 11:09:48 -05:00
parent 85261f2719
commit f05d5a2746
7 changed files with 514 additions and 4 deletions
+203
View File
@@ -0,0 +1,203 @@
// Founder Desk client — builds the weekly pack and manages the API key.
(function () {
'use strict';
var $ = function (id) { return document.getElementById(id); };
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c];
});
}
function gate(msg) {
$('dGate').style.display = 'block';
$('dGate').innerHTML = msg;
}
function copyBtn(getText) {
var b = document.createElement('button');
b.className = 'btn btn-secondary btn-sm d-copy';
b.textContent = 'Copy';
b.addEventListener('click', function () {
if (navigator.clipboard) navigator.clipboard.writeText(getText());
b.textContent = 'Copied';
setTimeout(function () { b.textContent = 'Copy'; }, 1400);
});
return b;
}
function item(tag, subject, body) {
var d = document.createElement('div');
d.className = 'd-item';
var html = '<div class="tag">' + esc(tag) + '</div>';
if (subject) html += '<div class="subj">' + esc(subject) + '</div>';
html += '<div class="body">' + esc(body) + '</div>';
d.innerHTML = html;
d.appendChild(copyBtn(function () { return (subject ? subject + '\n\n' : '') + body; }));
return d;
}
function section(title, why) {
var s = document.createElement('div');
s.className = 'd-sec';
s.innerHTML = '<h3>' + esc(title) + '</h3><div class="why">' + esc(why) + '</div>';
return s;
}
function renderPack(pack) {
var host = $('dPack');
host.innerHTML = '';
if (!pack) return;
if ((pack.posts || []).length) {
var s1 = section('Social posts — one per angle',
'Post one a day. They deliberately take different angles so the week does not read as the same message five times.');
pack.posts.forEach(function (p) { s1.appendChild(item(p.angle, '', p.text)); });
host.appendChild(s1);
}
if ((pack.messages || []).length) {
var s2 = section('Outreach messages',
'For the conversations that actually build the team — a first approach, a follow-up for someone gone quiet, and the pyramid-scheme answer.');
pack.messages.forEach(function (mm) { s2.appendChild(item(mm.label, '', mm.text)); });
host.appendChild(s2);
}
if ((pack.emails || []).length) {
var s3 = section('Email for your list', 'Drop straight into your autoresponder.');
pack.emails.forEach(function (em) { s3.appendChild(item('Email', em.subject, em.body)); });
host.appendChild(s3);
}
if ((pack.textAds || []).length) {
var s4 = section('Text ads', 'Ready to run from the Traffic Desk.');
var grid = document.createElement('div');
grid.className = 'd-ads';
pack.textAds.forEach(function (a) {
var c = document.createElement('div');
c.className = 'd-ad';
var h = '<div class="s">' + esc(a.subject) + '</div>';
(a.lines || []).forEach(function (l) { if (l) h += '<div class="l">' + esc(l) + '</div>'; });
c.innerHTML = h;
grid.appendChild(c);
});
s4.appendChild(grid);
host.appendChild(s4);
}
// Be straight about anything the engine could not produce, rather than
// quietly handing over a short pack.
if ((pack.problems || []).length) {
var warn = document.createElement('div');
warn.className = 'd-item';
warn.style.borderColor = '#f0a05a';
warn.innerHTML = '<div class="tag" style="color:#f0a05a">Incomplete</div><div class="body">' +
'The engine could not finish: ' + esc(pack.problems.join(', ')) +
'. Everything else above is fine — build again to retry the missing pieces.</div>';
host.appendChild(warn);
}
}
function renderKey(info, freshKey) {
var host = $('dKeyState');
if (freshKey) {
host.innerHTML = '<div class="k-meta"><b style="color:var(--gold)">Copy this now — it is shown once and never again.</b></div>' +
'<div class="k-key" id="dFresh">' + esc(freshKey) + '</div>';
var b = copyBtn(function () { return freshKey; });
b.style.position = 'static';
b.style.marginTop = '8px';
host.appendChild(b);
$('dIssue').textContent = 'Replace this key';
$('dRevoke').style.display = '';
return;
}
if (info && info.exists) {
host.innerHTML = '<div class="k-meta">A key is active (<b>' + esc(info.hint) + '</b>), created ' +
esc(String(info.createdAt || '').slice(0, 10)) +
(info.lastUsedAt ? ', last used ' + esc(String(info.lastUsedAt).slice(0, 10)) : ', never used yet') +
'. The key itself is stored hashed, so it cannot be shown again — create a new one if you have lost it.</div>';
$('dIssue').textContent = 'Replace this key';
$('dRevoke').style.display = '';
} else {
host.innerHTML = '<div class="k-meta">No key yet.</div>';
$('dIssue').textContent = 'Create a key';
$('dRevoke').style.display = 'none';
}
}
async function build(btn) {
btn.disabled = true;
btn.innerHTML = '<span class="spin"></span>Building your week — this takes a minute…';
$('dErr').style.display = 'none';
try {
var r = await fetch('/api/public/suite-founder', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}'
});
var d = await r.json();
if (!r.ok) {
$('dErr').textContent = d.error || 'That did not build — try again.';
$('dErr').style.display = 'block';
} else {
renderPack(d.pack);
if (d.meter) $('dMeter').textContent = d.meter.remaining + ' of ' + d.meter.limit + ' packs left this month';
}
} catch (e) {
$('dErr').textContent = 'Connection hiccup — try again.';
$('dErr').style.display = 'block';
}
btn.disabled = false;
btn.innerHTML = '👑 Build my week';
}
async function issue() {
if (!window.confirm('Create a new API key? Any existing key stops working immediately.')) return;
try {
var r = await fetch('/api/public/suite-founder', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ issueKey: true })
});
var d = await r.json();
if (r.ok) renderKey(d.info, d.key);
} catch (e) {}
}
async function revoke() {
if (!window.confirm('Revoke your API key? Anything using it stops working.')) return;
try {
var r = await fetch('/api/public/suite-founder', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ revokeKey: true })
});
var d = await r.json();
if (r.ok) renderKey(d.info, null);
} catch (e) {}
}
async function boot() {
try {
var r = await fetch('/api/public/suite-founder');
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.'); return; }
if (r.status === 403) {
var g = await r.json();
gate((g.error || 'Not 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();
$('dBody').style.display = 'block';
renderPack(d.pack);
renderKey(d.key, null);
if (d.meter) $('dMeter').textContent = d.meter.remaining + ' of ' + d.meter.limit + ' packs left this month';
if (d.writerReady === false) {
$('dGo').disabled = true;
$('dGo').textContent = 'The writer is warming up';
}
} catch (e) {}
}
document.addEventListener('DOMContentLoaded', function () {
boot();
$('dGo').addEventListener('click', function () { build(this); });
$('dIssue').addEventListener('click', issue);
$('dRevoke').addEventListener('click', revoke);
});
})();