Ad engine v1: banner/text/login campaigns against on-chain credits

Spec 8b types 1-3. Spend accrues per campaign in batches; burns queue for
the engine signer (admin runs consume() on-chain, /api/admin/burns). Rates
are volume config (adrates.json), rehearsal placeholders until Marty sets
the real card. Public slots serve on the ledger page; campaign manager in
the members area.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-04 13:04:47 -05:00
parent a90d5eee78
commit bee13ba4b8
7 changed files with 334 additions and 1 deletions
+18 -1
View File
@@ -84,5 +84,22 @@ window.IAP = (function () {
+ '<span class="tx"><a target="_blank" rel="noopener" href="' + c.explorer + '/tx/' + ev.tx + '">verify ↗</a></span>';
return div;
}
return { getConfig, fmtPol, fmtUsd, status, renderNav, refreshNavWallet, describeEvent, feedRow, $ };
// Render one served ad into #<elId>. Silent if no inventory.
async function adSlot(type, elId) {
try {
const { ad } = await (await fetch('/api/ads/slot?type=' + type)).json();
const el = $(elId);
if (!ad || !el) return;
if (ad.imageUrl) {
el.innerHTML = '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow">'
+ '<img src="' + ad.imageUrl + '" alt="advertisement" style="max-width:100%;border-radius:8px"></a>'
+ '<div class="small muted">member ad</div>';
} else {
el.innerHTML = '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow"><b>' + ad.title + '</b>'
+ (ad.body ? ' · ' + ad.body : '') + '</a> <span class="small muted">member ad</span>';
}
el.hidden = false;
} catch (e) {}
}
return { getConfig, fmtPol, fmtUsd, status, renderNav, refreshNavWallet, describeEvent, feedRow, adSlot, $ };
})();
+3
View File
@@ -15,6 +15,9 @@
IAP.$('statLine').textContent = stats.onchainMembers + ' on-chain member(s)';
} catch (e) {}
IAP.adSlot('banner', 'adSlotBanner');
IAP.adSlot('text', 'adSlotText');
const es = new EventSource('/api/feed/live');
es.onopen = () => { const b = IAP.$('liveBadge'); b.textContent = '● live'; };
es.onerror = () => { const b = IAP.$('liveBadge'); b.textContent = 'reconnecting…'; };
+48
View File
@@ -31,6 +31,8 @@
$('linkCard').hidden = !!me.address;
$('activateCard').hidden = !(me.address && !me.memberId);
$('activityArea').hidden = !me.memberId;
$('campaignCard').hidden = !me.memberId;
if (me.memberId) loadCampaigns();
if (me.memberId) {
const bc = me.buyerCount || 0;
@@ -66,6 +68,52 @@
} catch (e) {}
}
async function loadCampaigns() {
try {
const r = await (await fetch('/api/my/campaigns')).json();
if (r.error) return;
$('rateLine').textContent = 'Available to spend: ' + r.availableCredits.toLocaleString()
+ ' 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';
const el = $('campList');
el.innerHTML = '';
if (!r.campaigns.length) { el.innerHTML = '<p class="muted small">No campaigns yet. Launch your first below.</p>'; return; }
const tbl = document.createElement('div');
tbl.className = 'tablewrap';
tbl.innerHTML = '<table><thead><tr><th>Name</th><th>Type</th><th class="num">Views</th><th class="num">Clicks</th>'
+ '<th class="num">Spent</th><th class="num">Budget</th><th>Status</th><th></th></tr></thead><tbody>'
+ r.campaigns.map(c => '<tr><td><b>' + c.name + '</b></td><td>' + c.type + '</td>'
+ '<td class="num">' + c.imps.toLocaleString() + '</td><td class="num">' + c.clicks + '</td>'
+ '<td class="num">' + c.spent + '</td><td class="num">' + c.budget + '</td>'
+ '<td>' + (c.status === 'out' ? '<span class="badge amber">budget spent</span>' : c.status) + '</td>'
+ '<td>' + (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>' : '')
+ '</td></tr>').join('') + '</tbody></table>';
el.appendChild(tbl);
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'); }
}));
} catch (e) {}
}
$('cType').addEventListener('change', () => {
const t = $('cType').value;
$('cImageRow').hidden = t === 'text';
$('cTitleRow').hidden = t !== 'text';
$('cBodyRow').hidden = t !== 'text';
});
$('createCampBtn').addEventListener('click', busy2($('createCampBtn'), async () => {
await api('/api/my/campaigns', { type: $('cType').value, name: $('cName').value,
targetUrl: $('cTarget').value, imageUrl: $('cImage').value,
title: $('cTitle').value, body: $('cBody').value, budget: Number($('cBudget').value) });
IAP.status('Campaign is live. It starts serving right away.', 'ok');
$('cName').value = ''; $('cBudget').value = '';
await loadCampaigns();
}));
// defers the busy() lookup to click time (busy is declared below)
function busy2(btn, fn) { return (...a) => busy(btn, fn)(...a); }
const busy = (btn, fn) => async () => {
try { btn.disabled = true; await fn(); }
catch (e) { IAP.status((e && e.message) || String(e), 'bad'); }
+2
View File
@@ -15,9 +15,11 @@
<p><span class="badge" id="liveBadge">connecting…</span>
<span class="small muted" id="statLine"></span></p>
</section>
<div class="card" id="adSlotBanner" hidden></div>
<div class="card" style="padding:0">
<div class="feed" id="feed"><div class="row muted">Loading recent history…</div></div>
</div>
<div class="card small" id="adSlotText" hidden></div>
<footer>
<div>InstantAdPay · <a href="/">how it works</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">contract source ↗</a></div>
</footer>
+21
View File
@@ -73,6 +73,27 @@
Purchases from your linked wallet automatically credit this account.</p>
</div>
<div class="card" id="campaignCard" hidden>
<h3>Your ad campaigns</h3>
<p class="muted small" id="rateLine">…</p>
<div id="campList"></div>
<h3 style="margin-top:18px">New campaign</h3>
<div class="grid c3">
<p><select id="cType" style="width:100%">
<option value="banner">Banner (per impression)</option>
<option value="text">Text ad (per impression)</option>
<option value="login">Login ad (per day)</option>
</select></p>
<p><input id="cName" placeholder="Campaign name" style="width:100%"></p>
<p><input id="cBudget" type="number" placeholder="Budget (credits)" min="10" style="width:100%"></p>
</div>
<p><input id="cTarget" placeholder="Target URL (https://…)" style="width:100%"></p>
<p id="cImageRow"><input id="cImage" placeholder="Image URL (banner/login ads)" style="width:100%"></p>
<p id="cTitleRow" hidden><input id="cTitle" placeholder="Headline (max 60)" style="width:100%"></p>
<p id="cBodyRow" hidden><input id="cBody" placeholder="Ad text (max 140)" style="width:100%"></p>
<button class="btn" id="createCampBtn">Launch campaign</button>
</div>
<div id="activityArea" hidden>
<h2>Your activity, straight from the chain</h2>
<div class="grid c2">