Earn-and-spend credit economy + private rehearsal chain cutover

Members earn credits by attention (spec 8b): daily 5-ad set with dwell
timing and a too-fast guard, then a claimable daily batch. Earned credits
now FUND campaigns: banner/text budgets draw earned-first (free members can
advertise on welcome credits alone), purchased credits and the on-chain
burn queue only cover the remainder; earned-only campaigns pause when the
pool runs dry. New Earn credits section in the member menu with the viewer.
Explorer links degrade gracefully for the private chain (contract page
points at the audited Amoy verification). Site flipped to the anvil
rehearsal chain at rpc.instantadpay.com: unlimited test POL, no more
faucets. E2E: welcome->views->claim->earned-funded campaign->charged
serving, all green. Assets v=20260905d.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-05 05:59:42 -05:00
parent d5bb74db7a
commit cdeb59ed7f
11 changed files with 274 additions and 41 deletions
+3 -1
View File
@@ -85,7 +85,9 @@ window.IAP = (function () {
const div = document.createElement('div');
div.className = 'row t-' + ev.type;
div.innerHTML = '<span>' + describeEvent(ev, c) + '</span>'
+ '<span class="tx"><a target="_blank" rel="noopener" href="' + c.explorer + '/tx/' + ev.tx + '">verify ↗</a></span>';
+ (c.explorer
? '<span class="tx"><a target="_blank" rel="noopener" href="' + c.explorer + '/tx/' + ev.tx + '">verify ↗</a></span>'
: '<span class="tx mono">' + ev.tx.slice(0, 12) + '…</span>');
return div;
}
// Render one served ad into #<elId>. Silent if no inventory.
+12 -4
View File
@@ -6,8 +6,16 @@
$('cAddr').textContent = c.contract.slice(0, 10) + '…' + c.contract.slice(-6);
$('cChain').textContent = c.chainName;
$('mockAddr').textContent = c.contract.slice(0, 18) + '…';
$('lnkExplorer').href = c.explorer + '/address/' + c.contract + '#code';
const src = 'https://repo.sourcify.dev/contracts/full_match/' + c.chainId + '/' + c.contract + '/';
$('lnkSource').href = src;
$('lnkSource2').href = src;
if (c.explorer) {
$('lnkExplorer').href = c.explorer + '/address/' + c.contract + '#code';
const src = 'https://repo.sourcify.dev/contracts/full_match/' + c.chainId + '/' + c.contract + '/';
$('lnkSource').href = src;
$('lnkSource2').href = src;
} else {
// private rehearsal chain: point at the audited Amoy verification instead
const src = 'https://repo.sourcify.dev/contracts/full_match/80002/0xaF47EC401A9B0D570a553b1A1c95D6079083636e/';
$('lnkExplorer').href = src;
$('lnkSource').href = src;
$('lnkSource2').href = src;
}
})();
+63 -4
View File
@@ -68,9 +68,9 @@
}
// ── back-office menu: hash-routed panes ───────────────
const PANES = ['overview', 'line', 'campaigns', 'earnings', 'wallet'];
const PANES = ['overview', 'line', 'campaigns', 'earn', 'earnings', 'wallet'];
const TITLES = { overview: 'Overview', line: 'My line', campaigns: 'Campaigns',
earnings: 'Earnings', wallet: 'Wallet & account' };
earn: 'Earn credits', earnings: 'Earnings', wallet: 'Wallet & account' };
function setPane(name) {
if (!PANES.includes(name)) name = 'overview';
for (const p of PANES) {
@@ -113,8 +113,9 @@
$('linkCard').hidden = !!me.address;
$('activateCard').hidden = !(me.address && !me.memberId);
$('activityArea').hidden = !me.memberId;
$('campaignCard').hidden = !me.memberId;
if (me.memberId) loadCampaigns();
$('campGate').hidden = true; // earned credits fund campaigns for everyone
$('campaignCard').hidden = false;
loadCampaigns();
// the share link works from day one; codes resolve to your chain id later
if (me.refCode || me.memberId) {
@@ -159,6 +160,7 @@
const r = await (await fetch('/api/my/campaigns')).json();
if (r.error) return;
$('rateLine').textContent = 'Available to spend: ' + r.availableCredits.toLocaleString()
+ (r.earnedCredits ? ' (' + r.purchasedCredits.toLocaleString() + ' purchased + ' + r.earnedCredits + ' earned)' : '')
+ ' credits · rates: banner ' + r.rates.bannerCreditsPerBatch + 'cr/' + r.rates.bannerBatch
+ ' views, text ' + r.rates.textCreditsPerBatch + 'cr/' + r.rates.textBatch
+ ' views, login ' + r.rates.loginCreditsPerDay + 'cr/day';
@@ -200,6 +202,63 @@
// defers the busy() lookup to click time (busy is declared below)
function busy2(btn, fn) { return (...a) => busy(btn, fn)(...a); }
// ── earn-by-viewing: daily ad set with dwell, then claim ──
const earnState = { types: ['banner', 'text'], i: 0, timer: null };
async function earnRefresh() {
try {
const st = await (await fetch('/api/my/earn')).json();
if (st.error) return null;
$('earnProgress').textContent = 'today: ' + st.views + ' / ' + st.target + ' ads viewed';
$('earnBalance').textContent = st.earned + ' earned credits';
const done = st.views >= st.target;
$('earnClaimBtn').hidden = !(done && !st.claimed);
if (st.claimed) $('earnHint').textContent = 'Claimed for today. Come back tomorrow, or put those credits to work in Campaigns.';
else if (done) $('earnHint').textContent = 'Set complete. Claim your ' + st.claimCredits + ' credits.';
return st;
} catch (e) { return null; }
}
async function earnShowAd() {
const st = await earnRefresh();
if (!st || st.views >= st.target) return;
const type = earnState.types[earnState.i++ % earnState.types.length];
let ad = null;
try { ad = (await (await fetch('/api/ads/slot?type=' + type)).json()).ad; } catch (e) {}
const box = $('earnAdBox');
if (!ad) {
box.innerHTML = '<span class="muted small">No member ads are live in rotation right now. '
+ 'Views resume the moment a campaign is running.</span>';
return;
}
box.innerHTML = ad.imageUrl
? '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow"><img src="' + ad.imageUrl + '" alt="member ad" style="max-height:200px"></a>'
: '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow" style="font-size:17px"><b>' + ad.title + '</b>' + (ad.body ? '<br><span class="muted">' + ad.body + '</span>' : '') + '</a>';
const btn = $('earnNextBtn');
btn.hidden = false;
btn.disabled = true;
let left = st.dwell;
btn.textContent = 'Next ad (' + left + 's)';
clearInterval(earnState.timer);
earnState.timer = setInterval(async () => {
left -= 1;
if (left > 0) { btn.textContent = 'Next ad (' + left + 's)'; return; }
clearInterval(earnState.timer);
btn.textContent = 'Next ad';
btn.disabled = false;
await fetch('/api/my/adview', { method: 'POST' }); // dwell served: count it
await earnRefresh();
}, 1000);
}
$('earnStartBtn').addEventListener('click', () => { $('earnStartBtn').hidden = true; earnShowAd(); });
$('earnNextBtn').addEventListener('click', earnShowAd);
$('earnClaimBtn').addEventListener('click', async () => {
try {
const r = await api('/api/my/claim');
IAP.status('+' + r.credited + ' credits earned. Spend them in Campaigns.', 'ok');
await earnRefresh();
loadDashboard();
} catch (e) { IAP.status(e.message, 'bad'); }
});
const busy = (btn, fn) => async () => {
try { btn.disabled = true; await fn(); }
catch (e) { IAP.status((e && e.message) || String(e), 'bad'); }