diff --git a/ads.js b/ads.js index 05dc4a6..a3ae034 100644 Binary files a/ads.js and b/ads.js differ 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);