From f0a6a93f8bef04034d1ac3526f877008ae7756fd Mon Sep 17 00:00:00 2001 From: martbost Date: Wed, 23 Sep 2026 08:40:17 -0500 Subject: [PATCH] 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 --- ads.js | Bin 110721 -> 113595 bytes public/assets/my.js | 40 ++++++++++++++++++++++++++++++++++++++++ public/my.html | 2 +- server.js | 19 +++++++++++++++---- 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/ads.js b/ads.js index 05dc4a6a2d79f965b20555dc8a76cfece232fade..a3ae0340dc47ea4b55ad1adf8417ec1c2dd9a00b 100644 GIT binary patch delta 2265 zcmZ`)O>7la6wZy|p@o+K;it4n4=B9%$aMN>4Z+p|g!Pf1~n zTzig`AN{cQt9|K@17v-2<3NA1FnT0e+HWPlk6uYC2ev1d$HtNe)&AsZeM9oi*wvEG zxmbR*;jOBb+*5-Gn6`5FzOc(Z65n>xese_5w&Ots>Q$&%=Hxb%xyB!wamK~kA>d> zFNg$_(&Hfr-j+Z^tcO?5?mcOle|#>Pc@xGOlGM)~Q-C@uratCq(@<%Uvy4R+NDj1z z4?Ik1nnloFC>5h!cN}=EfHzFqHW#y+2;Y+mFDuXYbwe(l@tG;(h0ut7KSLwf9AfbB z%+`k{;6krM2@Ve!%ur_*upvb13E=I}hI33ZF^3pt;x3Y)G_GT3PM!Vm!trTn2p;qh zOK7)r%TiGlON=Xe_2w9I3K5MZ=BZ+(z*7nzM-clQI~wb}vSvZ3F`^!B?C4{Yogyfo zh7Pl!iv$JSh`+*{ z8M@*$S1nB|<`k?EqpGN^sq#r!9JQ#zwnK1vjD!wnamcH~Q7D)?ckI0r7pIP%JPwm^ zwUYH+v7mzDRjy5x`!K7cv~hB9XBt(f2J`97KC=15Y2%ivW6u~~U||93H5i88`>dZA zUQ@>rF|uY3Y7?-SF1xrwCXXW4 zbwOLH({3@rCh%hJJsvV>QJg0>Aq1b1h(YfIEmbS*^B;M3HW~L-urw8*U{#)T)UrTMfOfVu7ox zAUEXsmcyA0**Fwl$AM$64Hs}yaNXf5pvJkHa~$L-?hWe7EM*HvP2v?JXU=THYsP|U z`UH!(&=<|V!9mk^^TnO5I@eC%=l+3wSC4~0Ub4NNTQQ+OZR?N2oc2`&zQo>Ja(nOk zkG)emMEbi$Y7GtB?rGVxCfcGvni;8BRZ^jb&6e5WA`U~`8;F%*Eur)d!Gshfi>ROq z7Z`qvJp2;iGnj8jX8W{!hwQt!+VF4)>Tno}%>m=t>c~_qgv*4)FoEauz-Pxn;R3LF z1E*O_uiqv^L;q(q%@cqb(vx=%hUZ_1^&a8=}w%VS@Y)35vsB~JDa delta 171 zcmdn}ovra9+Xj8M$p&l@lRMd5C!c4N5$93>f}H&H%)H>7{1TVM%3>=8V*{PZEbJwd zE7^lKpJAV>HF=?~_~a@h@5wt1wI_cxikvKGBr!SJ*m&}B1KG)2jZ;{FhSYABH__19 z?2(XhX!=Ga#<=MM%8WMCeU%xNrxz+SHcU5|#+WjFp&Db!bVDUZ1+b3opHvtRS50Q0 X$v=6*Th{H@Rx!5nZqMJr=)(*E+a^0t diff --git a/public/assets/my.js b/public/assets/my.js index 1368645..a221739 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -858,6 +858,30 @@ } // 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 = '

Archived

' + rows.length + ' finished campaign' + (rows.length === 1 ? '' : 's') + ' ยท credits released
' + + '
' + + rows.map(c => '' + + '' + + '' + + '' + + '' + + '').join('') + + '
NameTypeViews hereClicksSpentRan until
' + esc(c.name || ('#' + c.id)) + '' + esc(c.type) + '' + Number(c.imps || 0).toLocaleString() + '' + Number(c.clicks || 0).toLocaleString() + '' + Number(c.spent || 0).toLocaleString() + '' + (c.created ? new Date(c.created).toLocaleDateString() : '') + '
' + + '

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.

'; + 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) { try { const r = await (await fetch('/api/my/credits/activity')).json(); @@ -918,12 +942,14 @@ + '' + (c.status === 'out' ? (c.type === 'visits' ? 'pack delivered' : 'budget spent') : c.status === 'done' ? '' + (c.type === 'featured' ? 'run ended' : 'ended') + '' : c.scheduled ? 'scheduled' : c.status) + '' + '' + (c.status === 'active' ? '' : c.status === 'paused' ? '' : '') + + (['paused', 'out', 'done'].includes(c.status) ? ' ' : '') + (c.type === 'featured' ? ' ' : c.type === 'visits' ? ' ' : ' ') + '').join('') + ''; el.appendChild(tbl); pageList('camps', tbl, 'tbody tr'); + renderArchived(el, r); 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.'; el.appendChild(note); @@ -950,6 +976,20 @@ try { await api('/api/my/campaigns/' + b.dataset.camp + '/' + b.dataset.act); await loadCampaigns(); } 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 () => { 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; diff --git a/public/my.html b/public/my.html index aadded7..303dc29 100644 --- a/public/my.html +++ b/public/my.html @@ -1073,7 +1073,7 @@ - + diff --git a/server.js b/server.js index 022d216..91c6df0 100644 --- a/server.js +++ b/server.js @@ -2848,10 +2848,14 @@ const server = http.createServer(async (req, res) => { const s = await auth.fromRequest(req); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); const memberId = await auth.refreshMemberId(s); - const out = { campaigns: await ads.listCampaigns(s.email), rates: ads.rates(), bannerSizes: ads.bannerSizes() }; - out.clickSources = await coach.clickSources(out.campaigns.map(c => c.id)); - out.hours = await ads.hoursFor(out.campaigns.map(c => c.id)); // on-site views per UTC hour, last 7 days - out.geo = await ads.geoFor(out.campaigns.map(c => c.id)); // on-site serves per viewer country + const all = await ads.listCampaigns(s.email); + // archived ones keep their full stats but leave the working list (Marty, 2026-09-23) + const out = { campaigns: all.filter(c => c.status !== 'archived'), archived: all.filter(c => c.status === 'archived'), + 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 pool = await ads.balances((await myMemberIds(s)).ids, s.email); 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); 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); if (m && req.method === 'POST') { const s = await auth.fromRequest(req);