Featured links: Extend run (book more days) replaces Buy more views; visit packs top up by visits; campaign rows say paid up front and runs through

Hugh and Michael both added credits to a featured run expecting more days. Featured is a flat
daily buy, so the credits sat unspent until the run ended and their balance read 0 meanwhile.
Now: /api/my/campaigns/:id/extend books extra days after the current run (or from today if it
ended) and charges them at once; top-up refuses featured; a visits top-up buys more visits at the
pack rate, charged now; rows show paid up front + day count + runs through <date> / run ended;
explainer under the campaign table; chatbot canned answer + AI prompt updated; my.js v tag bumped.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-16 07:38:37 -05:00
parent b1a1cbff5e
commit 28bce75913
5 changed files with 41 additions and 7 deletions
+29 -4
View File
@@ -819,19 +819,43 @@
+ '<td class="num" data-l="Network views">' + (['banner', 'text'].includes(c.type) ? (c.impsNas || 0).toLocaleString() : '<span class="muted small" title="only banner and text ads syndicate to the network">n/a</span>') + '</td>'
+ '<td class="num" data-l="Clicks">' + c.clicks + (r.clickSources && r.clickSources[c.id] ? '<div class="muted small clk-src" title="where the clicks happened">' + Object.entries(r.clickSources[c.id]).sort((a, b) => b[1] - a[1]).map(([k, v]) => '<span>' + esc(k) + ' ' + v + '</span>').join('') + (c.impsNas ? ' · network: see Network views' : '') + '</div>' : '') + '</td>'
+ (c.type === 'visits' ? '<td class="num" data-l="Spent"><span class="muted small" title="visit packs are paid in full when created; nothing more is charged as visits arrive">paid up front</span></td><td class="num" data-l="Delivered"><b>' + c.imps.toLocaleString() + '</b> of ' + Number(c.body || 0).toLocaleString() + ' visits</td>'
: c.type === 'featured' ? '<td class="num" data-l="Spent"><span class="muted small" title="featured runs are paid in full when booked (' + (r.rates.featuredPerDay || 40) + ' credits a day); nothing more is charged while the run is live">paid up front</span></td><td class="num" data-l="Run"><b>' + Number(c.body || 0) + '</b> day' + (Number(c.body || 0) === 1 ? '' : 's') + '</td>'
: '<td class="num" data-l="Spent">' + c.spent + '</td><td class="num" data-l="Budget">' + c.budget + '</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">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>'
: c.status === 'paused' ? '<button class="btn small sec" data-camp="' + c.id + '" data-act="resume">Resume</button>' : '')
+ ' <button class="btn small sec" data-topup="' + c.id + '">Buy more views</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>'
: ' <button class="btn small sec" data-topup="' + c.id + '">Buy more views</button>')
+ '</td></tr>').join('') + '</tbody></table>';
el.appendChild(tbl);
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);
el.querySelectorAll('button[data-extend]').forEach(b => b.addEventListener('click', async () => {
const rr = r.rates || {}; const durs = rr.featuredDurations || [1, 2, 7]; const per = rr.featuredPerDay || 40;
const d = await IAP.ask({ title: 'Extend featured run', text: 'How many more days? Choose ' + durs.join(', ') + '. ' + per + ' credits a day, charged now. The days are added after the current run, or start today if it has ended.', type: 'number', placeholder: 'e.g. ' + durs[durs.length - 1], ok: 'Extend' });
if (!d) return;
try { const x = await api('/api/my/campaigns/' + b.dataset.extend + '/extend', { days: Number(d) });
IAP.status('Extended ' + x.days + ' day' + (x.days === 1 ? '' : 's') + ' for ' + x.cost + ' credits' + (x.resumed ? '. The run is live again.' : '.'), 'ok');
await loadCampaigns(); loadDashboard();
} catch (e) { IAP.status(e.message, 'bad'); }
}));
el.querySelectorAll('button[data-morevisits]').forEach(b => b.addEventListener('click', async () => {
const per = (r.rates && r.rates.visitCostPerVisit) || 3;
const n = await IAP.ask({ title: 'Buy more visits', text: 'How many more verified visits? ' + per + ' credits each, charged now like the original pack.', type: 'number', placeholder: 'e.g. 50', ok: 'Buy visits' });
if (!n) return;
try { const x = await api('/api/my/campaigns/' + b.dataset.morevisits + '/topup', { credits: Math.floor(Number(n)) * per });
IAP.status('Added ' + x.visits + ' visits (' + x.added + ' credits)' + (x.reactivated ? '. The pack is open again.' : '.'), 'ok');
await loadCampaigns(); loadDashboard();
} catch (e) { IAP.status(e.message, 'bad'); }
}));
el.querySelectorAll('button[data-camp]').forEach(b => b.addEventListener('click', async () => {
try { await api('/api/my/campaigns/' + b.dataset.camp + '/' + b.dataset.act); await loadCampaigns(); }
catch (e) { IAP.status(e.message, 'bad'); }
}));
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.', 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;
try { const r = await api('/api/my/campaigns/' + b.dataset.topup + '/topup', { credits: Number(n) });
IAP.status('Added ' + r.added + ' credits' + (r.reactivated ? ' — campaign is live again.' : '.'), 'ok');
@@ -1307,7 +1331,8 @@
function schedChips(c) {
const now = Date.now(); let s = '';
if (c.starts && c.starts > now) s += ' <span class="muted small">starts ' + fmtWhen(c.starts) + '</span>';
if (c.expires && c.type !== 'featured') s += ' <span class="muted small">' + (c.expires > now ? 'ends ' : 'ended ') + fmtWhen(c.expires) + '</span>';
if (c.expires && c.type === 'featured') { const day = new Date(c.expires - 1).toLocaleDateString([], { month: 'short', day: 'numeric' }); s += ' <span class="muted small">' + (c.expires > now ? 'runs through ' : 'ran through ') + day + '</span>'; }
else if (c.expires) s += ' <span class="muted small">' + (c.expires > now ? 'ends ' : 'ended ') + fmtWhen(c.expires) + '</span>';
return s;
}
function geoLine(rows) {