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:
+106
-6
@@ -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}}',
|
||||
|
||||
Reference in New Issue
Block a user