Campaigns: archive a finished one, get the credits back, keep the numbers

A member asked for a delete button. Deleting would orphan the credit ledger,
the hourly view rows and the P&L, and throw away the figures they actually
want, so a campaign is archived instead: out of the working table, listed in
full underneath with its views, clicks and spend, and restorable.

The credits are the real point. Reserved budget is computed from campaigns
that are active or paused, so a paused campaign quietly holds credits the
member cannot spend anywhere else. Archiving releases them the moment the
status changes, with nothing to refund and nothing that can double-refund.

Only a paused or finished campaign can be archived, so pause stays the
deliberate first step, and restoring brings it back paused rather than live.
The syndication rails are stopped on the way in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-23 08:40:17 -05:00
parent 891eac1626
commit f0a6a93f8b
4 changed files with 56 additions and 5 deletions
BIN
View File
Binary file not shown.
+40
View File
@@ -858,6 +858,30 @@
} }
// credit activity ledger under the campaign table (2026-09-16): every earn and spend with its reason // credit activity ledger under the campaign table (2026-09-16): every earn and spend with its reason
// Archived campaigns: everything they did, none of the clutter and none of the reservation.
function renderArchived(el, r) {
const rows = r.archived || [];
if (!rows.length) return;
const box = document.createElement('div');
box.className = 'card';
box.style.marginTop = '18px';
box.innerHTML = '<div class="card-head"><h3>Archived</h3><span class="sub">' + rows.length + ' finished campaign' + (rows.length === 1 ? '' : 's') + ' · credits released</span></div>'
+ '<div class="tablewrap"><table class="adm-table"><thead><tr><th>Name</th><th>Type</th><th class="num">Views here</th><th class="num">Clicks</th><th class="num">Spent</th><th>Ran until</th><th></th></tr></thead><tbody>'
+ rows.map(c => '<tr><td>' + esc(c.name || ('#' + c.id)) + '</td><td>' + esc(c.type) + '</td>'
+ '<td class="num">' + Number(c.imps || 0).toLocaleString() + '</td>'
+ '<td class="num">' + Number(c.clicks || 0).toLocaleString() + '</td>'
+ '<td class="num">' + Number(c.spent || 0).toLocaleString() + '</td>'
+ '<td class="when muted small">' + (c.created ? new Date(c.created).toLocaleDateString() : '') + '</td>'
+ '<td class="act"><button class="btn small sec" data-unarch="' + c.id + '">Restore</button></td></tr>').join('')
+ '</tbody></table></div>'
+ '<p class="muted small" style="margin:10px 0 0">Archived campaigns keep every number they earned. They serve nothing and reserve nothing, so their unspent credits are back in your Available balance. Restore brings one back, paused.</p>';
el.appendChild(box);
box.querySelectorAll('[data-unarch]').forEach(b => b.addEventListener('click', async () => {
b.disabled = true;
try { await api('/api/my/campaigns/' + b.dataset.unarch + '/unarchive'); IAP.status('Restored, and paused. Press Resume when you want it running.', 'ok'); await loadCampaigns(); loadDashboard(); }
catch (e) { IAP.status(e.message, 'bad'); b.disabled = false; }
}));
}
async function loadCreditActivity(el) { async function loadCreditActivity(el) {
try { try {
const r = await (await fetch('/api/my/credits/activity')).json(); const r = await (await fetch('/api/my/credits/activity')).json();
@@ -918,12 +942,14 @@
+ '<td data-l="Status">' + (c.status === 'out' ? (c.type === 'visits' ? '<span class="badge">pack delivered</span>' : '<span class="badge amber">budget spent</span>') : c.status === 'done' ? '<span class="muted">' + (c.type === 'featured' ? 'run ended' : 'ended') + '</span>' : c.scheduled ? '<span class="badge">scheduled</span>' : c.status) + '</td>' + '<td data-l="Status">' + (c.status === 'out' ? (c.type === 'visits' ? '<span class="badge">pack delivered</span>' : '<span class="badge amber">budget spent</span>') : c.status === 'done' ? '<span class="muted">' + (c.type === 'featured' ? 'run ended' : 'ended') + '</span>' : c.scheduled ? '<span class="badge">scheduled</span>' : c.status) + '</td>'
+ '<td class="act">' + (c.status === 'active' ? '<button class="btn small sec" data-camp="' + c.id + '" data-act="pause">Pause</button>' + '<td class="act">' + (c.status === 'active' ? '<button class="btn small sec" data-camp="' + c.id + '" data-act="pause">Pause</button>'
: c.status === 'paused' ? '<button class="btn small sec" data-camp="' + c.id + '" data-act="resume">Resume</button>' : '') : c.status === 'paused' ? '<button class="btn small sec" data-camp="' + c.id + '" data-act="resume">Resume</button>' : '')
+ (['paused', 'out', 'done'].includes(c.status) ? ' <button class="btn small ghost" data-arch="' + c.id + '" data-archname="' + esc(c.name || ('#' + c.id)) + '" data-archleft="' + Math.max(0, (c.budget || 0) - (c.spent || 0)) + '" title="move it out of this list, keep its numbers">Archive</button>' : '')
+ (c.type === 'featured' ? ' <button class="btn small sec" data-extend="' + c.id + '" title="book more days at the daily rate">Extend run</button>' + (c.type === 'featured' ? ' <button class="btn small sec" data-extend="' + c.id + '" title="book more days at the daily rate">Extend run</button>'
: c.type === 'visits' ? ' <button class="btn small sec" data-morevisits="' + c.id + '">Buy more visits</button>' : c.type === 'visits' ? ' <button class="btn small sec" data-morevisits="' + c.id + '">Buy more visits</button>'
: ' <button class="btn small sec" data-topup="' + c.id + '">Buy more views</button>') : ' <button class="btn small sec" data-topup="' + c.id + '">Buy more views</button>')
+ '</td></tr>').join('') + '</tbody></table>'; + '</td></tr>').join('') + '</tbody></table>';
el.appendChild(tbl); el.appendChild(tbl);
pageList('camps', tbl, 'tbody tr'); pageList('camps', tbl, 'tbody tr');
renderArchived(el, r);
const note = document.createElement('p'); note.className = 'muted small'; const note = document.createElement('p'); note.className = 'muted small';
note.textContent = 'Credits you put into a campaign are set aside the moment you start it, so your Available number drops right away and the campaign spends from its own budget. Featured links and verified-visit packs are paid in full up front; a featured run is sold by the day, so use Extend run to keep it going. Whatever a campaign has not spent comes back to Available when it ends.'; note.textContent = 'Credits you put into a campaign are set aside the moment you start it, so your Available number drops right away and the campaign spends from its own budget. Featured links and verified-visit packs are paid in full up front; a featured run is sold by the day, so use Extend run to keep it going. Whatever a campaign has not spent comes back to Available when it ends.';
el.appendChild(note); el.appendChild(note);
@@ -950,6 +976,20 @@
try { await api('/api/my/campaigns/' + b.dataset.camp + '/' + b.dataset.act); await loadCampaigns(); } try { await api('/api/my/campaigns/' + b.dataset.camp + '/' + b.dataset.act); await loadCampaigns(); }
catch (e) { IAP.status(e.message, 'bad'); } catch (e) { IAP.status(e.message, 'bad'); }
})); }));
el.querySelectorAll('[data-arch]').forEach(b => b.addEventListener('click', async () => {
const left = Number(b.dataset.archleft) || 0;
const ok = await IAP.confirmBox(
'It stops appearing in this list but keeps every number it earned, in the Archived section below.'
+ (left ? ' Its ' + left.toLocaleString() + ' unspent credits go straight back to your Available balance.' : '')
+ ' You can restore it at any time.',
{ title: 'Archive ' + b.dataset.archname, ok: 'Archive it', cancel: 'Keep it here' });
if (!ok) return;
b.disabled = true;
try { const x = await api('/api/my/campaigns/' + b.dataset.arch + '/archive');
IAP.status('Archived.' + (x.freed ? ' ' + Number(x.freed).toLocaleString() + ' credits released.' : ''), 'ok');
await loadCampaigns(); loadDashboard(); }
catch (e) { IAP.status(e.message, 'bad'); b.disabled = false; }
}));
el.querySelectorAll('button[data-topup]').forEach(b => b.addEventListener('click', async () => { el.querySelectorAll('button[data-topup]').forEach(b => b.addEventListener('click', async () => {
const n = await IAP.ask({ title: 'Add credits', text: 'How many credits to add to this campaign? More credits buy more views. They are set aside now and spend as views are delivered; anything unspent comes back when the campaign ends.', type: 'number', placeholder: 'e.g. 100', ok: 'Add credits' }); const n = await IAP.ask({ title: 'Add credits', text: 'How many credits to add to this campaign? More credits buy more views. They are set aside now and spend as views are delivered; anything unspent comes back when the campaign ends.', type: 'number', placeholder: 'e.g. 100', ok: 'Add credits' });
if (!n) return; if (!n) return;
+1 -1
View File
@@ -1073,7 +1073,7 @@
<script src="/assets/common.js?v=20260923b"></script> <script src="/assets/common.js?v=20260923b"></script>
<script src="/assets/wallet.js?v=20260911a"></script> <script src="/assets/wallet.js?v=20260911a"></script>
<script src="/assets/promo.js?v=20260921d"></script> <script src="/assets/promo.js?v=20260921d"></script>
<script src="/assets/my.js?v=20260922v"></script> <script src="/assets/my.js?v=20260923c"></script>
<script src="/assets/chat.js?v=20260907l"></script> <script src="/assets/chat.js?v=20260907l"></script>
</body> </body>
</html> </html>
+15 -4
View File
@@ -2848,10 +2848,14 @@ const server = http.createServer(async (req, res) => {
const s = await auth.fromRequest(req); const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const memberId = await auth.refreshMemberId(s); const memberId = await auth.refreshMemberId(s);
const out = { campaigns: await ads.listCampaigns(s.email), rates: ads.rates(), bannerSizes: ads.bannerSizes() }; const all = await ads.listCampaigns(s.email);
out.clickSources = await coach.clickSources(out.campaigns.map(c => c.id)); // archived ones keep their full stats but leave the working list (Marty, 2026-09-23)
out.hours = await ads.hoursFor(out.campaigns.map(c => c.id)); // on-site views per UTC hour, last 7 days const out = { campaigns: all.filter(c => c.status !== 'archived'), archived: all.filter(c => c.status === 'archived'),
out.geo = await ads.geoFor(out.campaigns.map(c => c.id)); // on-site serves per viewer country rates: ads.rates(), bannerSizes: ads.bannerSizes() };
const ids = all.map(c => c.id); // archived included: their numbers are the whole point of keeping them
out.clickSources = await coach.clickSources(ids);
out.hours = await ads.hoursFor(ids); // on-site views per UTC hour, last 7 days
out.geo = await ads.geoFor(ids); // on-site serves per viewer country
{ const t = geo.tierLists(siteConfig()); out.tiers = { t1: [...t.t1], t2: [...t.t2] }; out.geoReady = geo.status().loaded; } { const t = geo.tierLists(siteConfig()); out.tiers = { t1: [...t.t1], t2: [...t.t2] }; out.geoReady = geo.status().loaded; }
const pool = await ads.balances((await myMemberIds(s)).ids, s.email); const pool = await ads.balances((await myMemberIds(s)).ids, s.email);
out.purchasedCredits = pool.total; out.purchasedCredits = pool.total;
@@ -2905,6 +2909,13 @@ const server = http.createServer(async (req, res) => {
const r = await ads.extendFeatured(s.email, memberId, m[1], b.days); const r = await ads.extendFeatured(s.email, memberId, m[1], b.days);
return json(res, r.error ? 400 : 200, r); return json(res, r.error ? 400 : 200, r);
} }
m = /^\/api\/my\/campaigns\/(\d+)\/(archive|unarchive)$/.exec(p);
if (m && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const r = m[2] === 'archive' ? await ads.archive(s.email, m[1]) : await ads.unarchive(s.email, m[1]);
return json(res, r.error ? 400 : 200, r);
}
m = /^\/api\/my\/campaigns\/(\d+)\/(pause|resume)$/.exec(p); m = /^\/api\/my\/campaigns\/(\d+)\/(pause|resume)$/.exec(p);
if (m && req.method === 'POST') { if (m && req.method === 'POST') {
const s = await auth.fromRequest(req); const s = await auth.fromRequest(req);