Onsite solo ads: inbox delivery with read rewards, composer, homepage format live

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-05 15:25:00 -05:00
parent 5c945a1b09
commit f6a3befe09
9 changed files with 373 additions and 21 deletions
+106 -6
View File
@@ -126,6 +126,7 @@
}
const tc = $('dbTeamChip'), wk = (d.referrals || []).filter(r => Date.now() - new Date(r.joined) < 6048e5).length;
if (tc && wk) { tc.hidden = false; tc.textContent = '+' + wk + ' this week'; }
setInboxBadge(d.inboxUnread || 0);
loadCharts(d);
$('nextMove').textContent = nextMove(d);
$('qualFill').style.width = Math.min(100, (d.buyerCount || 0) * 20) + '%';
@@ -182,9 +183,10 @@
}
// ── back-office menu: hash-routed panes ───────────────
const PANES = ['overview', 'line', 'buy', 'campaigns', 'earn', 'earnings', 'promo', 'wallet', 'profile'];
const PANES = ['overview', 'line', 'buy', 'campaigns', 'inbox', 'earn', 'earnings', 'promo', 'wallet', 'profile'];
const TITLES = { overview: 'Overview', line: 'My line', buy: 'Buy packages', campaigns: 'Campaigns',
earn: 'Earn credits', earnings: 'Earnings', promo: 'Promo tools', wallet: 'Wallet & account', profile: 'Profile' };
inbox: 'Inbox', earn: 'Earn credits', earnings: 'Earnings', promo: 'Promo tools',
wallet: 'Wallet & account', profile: 'Profile' };
function setPane(name) {
if (!PANES.includes(name)) name = 'overview';
for (const p of PANES) {
@@ -194,6 +196,7 @@
document.querySelectorAll('.bo-menu [data-pane]').forEach(b =>
b.classList.toggle('on', b.dataset.pane === name));
if ($('boTitle')) $('boTitle').textContent = TITLES[name];
if (name === 'inbox') loadInbox();
document.getElementById('memberArea').classList.remove('side-open'); // close mobile drawer
if (location.hash !== '#' + name) history.replaceState(null, '', '#' + name);
}
@@ -288,11 +291,14 @@
try {
const r = await (await fetch('/api/my/campaigns')).json();
if (r.error) return;
lastRates = r.rates;
soloHint();
$('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';
+ ' views, login ' + r.rates.loginCreditsPerDay + 'cr/day, solo '
+ (r.rates.soloCostPerRecipient || 5) + 'cr/delivery';
const el = $('campList');
el.innerHTML = '';
if (!r.campaigns.length) { el.innerHTML = '<p class="muted small">No campaigns yet. Launch your first below.</p>'; return; }
@@ -314,16 +320,32 @@
}));
} catch (e) {}
}
let lastRates = null;
function soloHint() {
if (!lastRates || $('cType').value !== 'solo') return;
const cost = lastRates.soloCostPerRecipient || 5;
const n = Math.floor((Number($('cBudget').value) || 0) / cost);
$('cSoloHint').textContent = cost + ' credits per guaranteed inbox delivery'
+ (n ? ' — this budget reaches ' + n + ' members' : '')
+ '. Readers earn ' + (lastRates.soloReadCredits || 2) + ' credits for a real read, so your message gets opened.';
}
$('cBudget').addEventListener('input', soloHint);
$('cType').addEventListener('change', () => {
const t = $('cType').value;
$('cImageRow').hidden = t === 'text';
$('cTitleRow').hidden = t !== 'text';
$('cImageRow').hidden = t === 'text' || t === 'solo';
$('cTitleRow').hidden = t !== 'text' && t !== 'solo';
$('cBodyRow').hidden = t !== 'text';
$('cSoloRow').hidden = t !== 'solo';
$('cSoloHint').hidden = t !== 'solo';
$('cTitle').placeholder = t === 'solo' ? 'Subject line (max 80)' : 'Headline (max 60)';
soloHint();
});
$('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) });
title: $('cTitle').value,
body: $('cType').value === 'solo' ? $('cSoloBody').value : $('cBody').value,
budget: Number($('cBudget').value) });
IAP.status('Campaign is live. It starts serving right away.', 'ok');
$('cName').value = ''; $('cBudget').value = '';
await loadCampaigns();
@@ -331,6 +353,84 @@
// defers the busy() lookup to click time (busy is declared below)
function busy2(btn, fn) { return (...a) => busy(btn, fn)(...a); }
// ── solo-ads inbox: list, read view, dwell-gated read reward ──
let ibTimer = null;
function setInboxBadge(n) {
const b = $('inboxBadge');
if (b) { b.hidden = !n; b.textContent = n; }
}
async function loadInbox() {
try {
const r = await (await fetch('/api/my/inbox')).json();
if (r.error) return;
$('ibRewardNote').textContent = '+' + r.readCredits + ' credits per real read (up to '
+ r.readCap + ' rewarded reads a day)';
const el = $('ibList');
$('inboxReadCard').hidden = true;
$('inboxListCard').hidden = false;
setInboxBadge(r.unread);
if (!r.items.length) {
el.innerHTML = '<p class="muted small">No solo ads yet. When a member sends one, it lands here — and reading it pays.</p>';
return;
}
el.innerHTML = '';
for (const i of r.items) {
const d = document.createElement('div');
d.className = 'ib-row' + (i.read ? '' : ' unread');
d.innerHTML = '<span class="sub"></span><span class="from"></span>'
+ (i.rewarded ? '<span class="badge">claimed</span>' : i.read ? '' : '<span class="badge amber">new</span>')
+ '<span class="when">' + new Date(i.delivered).toLocaleDateString() + '</span>';
d.querySelector('.sub').textContent = i.subject || '(no subject)';
d.querySelector('.from').textContent = 'from ' + (i.fromName || 'a member');
d.addEventListener('click', () => openInboxItem(i.id));
el.appendChild(d);
}
} catch (e) {}
}
async function openInboxItem(id) {
try {
const r = await (await fetch('/api/my/inbox/' + id)).json();
if (r.error) { IAP.status(r.error, 'bad'); return; }
$('inboxListCard').hidden = true;
$('inboxReadCard').hidden = false;
$('ibSubject').textContent = r.subject || '(no subject)';
$('ibMeta').textContent = 'from ' + (r.fromName || 'a member') + ' · ' + new Date(r.delivered).toLocaleString();
$('ibBody').textContent = r.body || '';
$('ibVisit').href = r.url;
const btn = $('ibClaimBtn');
clearInterval(ibTimer);
if (r.rewarded) {
btn.hidden = true;
$('ibHint').textContent = 'Read reward already claimed for this one.';
return;
}
btn.hidden = false;
btn.disabled = true;
let left = r.dwell;
btn.textContent = 'Read it — claim in ' + left + 's';
$('ibHint').textContent = 'Stay on this tab while you read; the claim unlocks when the timer is done.';
// countdown pauses off-tab; the server separately enforces the dwell on its own clock
ibTimer = setInterval(() => {
if (document.visibilityState !== 'visible' || !document.hasFocus()) return;
left -= 1;
if (left > 0) { btn.textContent = 'Read it — claim in ' + left + 's'; return; }
clearInterval(ibTimer);
btn.disabled = false;
btn.textContent = 'Claim +' + r.reward + ' credits';
}, 1000);
btn.onclick = async () => {
try {
const c = await api('/api/my/inbox/' + id + '/claim');
IAP.status('+' + c.credited + ' credits for reading. They spend like any earned credits.', 'ok');
btn.hidden = true;
$('ibHint').textContent = 'Claimed. Head back for the next one.';
loadDashboard();
} catch (e2) { IAP.status(e2.message, 'bad'); }
};
} catch (e) {}
}
$('ibBack').addEventListener('click', ev => { ev.preventDefault(); clearInterval(ibTimer); loadInbox(); });
// ── promo tools: Branded Voice copy, personalized with the member link ──
const PROMO_POSTS = [
'A membership site where money is handled by code, not people. Every purchase splits instantly to sponsor wallets on the Polygon blockchain. Nothing to withdraw. The money just lands in your wallet. Plus you earn ad credits for viewing ads while you\'re there. {{LINK}}',