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
+174 -3
View File
@@ -24,7 +24,13 @@ function rates() {
welcomeCredits: 25, welcomeCredits: 25,
dailyViewTarget: 5, // ads to view for the daily claim (spec §8b attention-gated claim) dailyViewTarget: 5, // ads to view for the daily claim (spec §8b attention-gated claim)
dailyClaimCredits: 5, dailyClaimCredits: 5,
viewDwellSeconds: 5 viewDwellSeconds: 5,
// onsite solo ads: full-message inbox delivery, charged per guaranteed recipient
soloCostPerRecipient: 5,
soloMinRecipients: 10,
soloReadCredits: 2, // earned by the reader per rewarded read
soloReadCapPerDay: 5,
soloReadDwellSeconds: 10
}, saved); }, saved);
} }
function setRates(patch) { function setRates(patch) {
@@ -32,7 +38,7 @@ function setRates(patch) {
return rates(); return rates();
} }
const TYPES = ['banner', 'text', 'login']; const TYPES = ['banner', 'text', 'login', 'solo'];
const URL_RE = /^https?:\/\/[^\s]+$/i; const URL_RE = /^https?:\/\/[^\s]+$/i;
const bid = () => crypto.randomBytes(8).toString('hex'); const bid = () => crypto.randomBytes(8).toString('hex');
function batchFor(type, r) { function batchFor(type, r) {
@@ -58,6 +64,15 @@ function validate(input) {
out.body = String(input.body || '').trim().slice(0, 140); out.body = String(input.body || '').trim().slice(0, 140);
if (!out.title) return { error: 'Text ads need a headline.' }; if (!out.title) return { error: 'Text ads need a headline.' };
} }
if (type === 'solo') {
const r = rates();
out.title = String(input.title || '').trim().slice(0, 80);
out.body = String(input.body || '').trim().slice(0, 1000);
if (!out.title) return { error: 'Solo ads need a subject line.' };
if (out.body.length < 40) return { error: 'Write the message — at least 40 characters.' };
const min = (r.soloCostPerRecipient || 5) * (r.soloMinRecipients || 10);
if (budget < min) return { error: 'Solo ads start at ' + min + ' credits (' + (r.soloMinRecipients || 10) + ' guaranteed deliveries).' };
}
return { ok: true, c: out }; return { ok: true, c: out };
} }
const pubC = c => ({ id: c.id, type: c.type, name: c.name, targetUrl: c.targetUrl, imageUrl: c.imageUrl || null, const pubC = c => ({ id: c.id, type: c.type, name: c.name, targetUrl: c.targetUrl, imageUrl: c.imageUrl || null,
@@ -336,6 +351,161 @@ function addEarned(email, amount) {
EJ.save(); EJ.save();
} }
// ---- onsite solo ads: full-message ads delivered into member inboxes,
// charged per guaranteed delivery; readers earn credits for dwelled reads ----
const SJ = {
db: null,
FILE: () => path.join(DATA_DIR, 'inbox.json'),
load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = { nextId: 1, items: [] }; } },
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }
};
// lazy guaranteed delivery: whenever a member touches their inbox (or the
// dashboard asks for their unread count), pending solos fill toward their
// recipient guarantee — never the sender's own, never twice to one member,
// and the advertiser is charged per delivery through the same earned-first
// then burn-accrual path every other format uses
async function deliverSolos(email, max = 3) {
const e = String(email || '').toLowerCase();
if (!e) return 0;
const r = rates();
const cost = r.soloCostPerRecipient || 5;
let n = 0;
if (db.enabled()) {
const rows = await db.q(`SELECT c.* FROM campaigns c
WHERE c.type='solo' AND c.status='active' AND c.owner_email<>?
AND c.budget - c.spent - c.accrued >= ?
AND NOT EXISTS (SELECT 1 FROM solo_inbox s WHERE s.campaign_id=c.id AND s.email=?)
ORDER BY c.created LIMIT ?`, [e, cost, e, max]);
for (const row of rows) {
try { await db.q('INSERT INTO solo_inbox (campaign_id,email,delivered) VALUES (?,?,?)', [row.id, e, Date.now()]); }
catch (er) { continue; } // unique key lost a race: already delivered
if (await spendEarned(row.owner_email, cost)) {
await db.q('UPDATE campaigns SET spent=spent+?, imps=imps+1 WHERE id=?', [cost, row.id]);
} else if (row.member_id) {
await db.q('UPDATE campaigns SET accrued=accrued+?, imps=imps+1 WHERE id=?', [cost, row.id]);
await D.rollBurn(row.id, r);
} else { // earned-only advertiser ran dry: undo the delivery, close the campaign
await db.q('DELETE FROM solo_inbox WHERE campaign_id=? AND email=?', [row.id, e]);
await db.q('UPDATE campaigns SET status=\'out\' WHERE id=?', [row.id]);
continue;
}
await db.q('UPDATE campaigns SET status=\'out\' WHERE id=? AND status=\'active\' AND spent+accrued>=budget', [row.id]);
n++;
}
} else {
if (!SJ.db) SJ.load();
const have = new Set(SJ.db.items.filter(i => i.email === e).map(i => i.cid));
for (const c of J.db.campaigns) {
if (n >= max) break;
if (c.type !== 'solo' || c.status !== 'active' || c.owner === e || have.has(c.id)) continue;
if (c.budget - c.spent - (c.accrued || 0) < cost) continue;
if (await spendEarned(c.owner, cost)) c.spent += cost;
else if (c.memberId) {
c.accrued = (c.accrued || 0) + cost;
if (c.accrued >= r.burnBatchMin) {
J.db.burnsPending.push({ id: bid(), memberId: c.memberId, amount: c.accrued, ref: 'campaign-' + c.id, ts: Date.now() });
c.spent += c.accrued; c.accrued = 0;
}
} else { c.status = 'out'; continue; } // earned-only ran dry
c.imps += 1;
if (c.spent + (c.accrued || 0) >= c.budget) c.status = 'out';
SJ.db.items.push({ id: SJ.db.nextId++, cid: c.id, email: e, delivered: Date.now(), readTs: 0, rewarded: 0, rewardedDay: null });
n++;
}
if (n) { J.save(); SJ.save(); }
}
return n;
}
async function inboxList(email) {
const e = String(email || '').toLowerCase();
await deliverSolos(e);
const r = rates();
let items = [];
if (db.enabled()) {
const rows = await db.q(`SELECT s.id, s.campaign_id cid, s.delivered, s.read_ts, s.rewarded,
c.title, c.member_id mid FROM solo_inbox s JOIN campaigns c ON c.id = s.campaign_id
WHERE s.email=? ORDER BY s.delivered DESC LIMIT 100`, [e]);
items = rows.map(x => ({ id: x.id, cid: x.cid, subject: x.title, fromMemberId: x.mid,
delivered: Number(x.delivered), read: !!x.read_ts, rewarded: !!x.rewarded }));
} else {
if (!SJ.db) SJ.load();
items = SJ.db.items.filter(i => i.email === e).sort((a, b) => b.delivered - a.delivered).slice(0, 100)
.map(i => {
const c = J.db.campaigns.find(x => x.id === i.cid) || {};
return { id: i.id, cid: i.cid, subject: c.title || c.name, fromMemberId: c.memberId || 0,
delivered: i.delivered, read: !!i.readTs, rewarded: !!i.rewarded };
});
}
return { items, unread: items.filter(i => !i.read).length,
readCredits: r.soloReadCredits || 2, readDwell: r.soloReadDwellSeconds || 10, readCap: r.soloReadCapPerDay || 5 };
}
async function inboxOpen(email, id) {
const e = String(email || '').toLowerCase();
const r = rates();
if (db.enabled()) {
const rows = await db.q(`SELECT s.*, c.title, c.body, c.member_id mid FROM solo_inbox s
JOIN campaigns c ON c.id = s.campaign_id WHERE s.id=? AND s.email=?`, [Number(id), e]);
if (!rows.length) return { error: 'No such message.' };
const x = rows[0];
if (!x.read_ts) await db.q('UPDATE solo_inbox SET read_ts=? WHERE id=? AND read_ts IS NULL', [Date.now(), x.id]);
return { id: x.id, cid: x.campaign_id, subject: x.title, body: x.body || '', fromMemberId: x.mid,
url: '/api/ads/click/' + x.campaign_id, delivered: Number(x.delivered),
rewarded: !!x.rewarded, dwell: r.soloReadDwellSeconds || 10, reward: r.soloReadCredits || 2 };
}
if (!SJ.db) SJ.load();
const i = SJ.db.items.find(x => x.id === Number(id) && x.email === e);
if (!i) return { error: 'No such message.' };
if (!i.readTs) { i.readTs = Date.now(); SJ.save(); }
const c = J.db.campaigns.find(x => x.id === i.cid) || {};
return { id: i.id, cid: i.cid, subject: c.title || c.name, body: c.body || '', fromMemberId: c.memberId || 0,
url: '/api/ads/click/' + i.cid, delivered: i.delivered,
rewarded: !!i.rewarded, dwell: r.soloReadDwellSeconds || 10, reward: r.soloReadCredits || 2 };
}
async function claimSoloRead(email, id) {
const e = String(email || '').toLowerCase();
const r = rates();
const dwellMs = (r.soloReadDwellSeconds || 10) * 1000;
const cap = r.soloReadCapPerDay || 5;
const reward = r.soloReadCredits || 2;
const day = today();
if (db.enabled()) {
const rows = await db.q('SELECT * FROM solo_inbox WHERE id=? AND email=?', [Number(id), e]);
if (!rows.length) return { error: 'No such message.' };
const x = rows[0];
if (x.rewarded) return { error: 'Already claimed for this one.' };
if (!x.read_ts || Date.now() - Number(x.read_ts) < dwellMs - 400) return { error: 'Give it a real read first.' };
const cnt = await db.q('SELECT COUNT(*) n FROM solo_inbox WHERE email=? AND rewarded=1 AND rewarded_day=?', [e, day]);
if (cnt[0].n >= cap) return { error: 'Daily read-reward cap reached (' + cap + '). Reading still works; rewards resume tomorrow.' };
const upd = await db.q('UPDATE solo_inbox SET rewarded=1, rewarded_day=? WHERE id=? AND rewarded=0', [day, x.id]);
if (!upd.affectedRows) return { error: 'Already claimed for this one.' };
await addEarned(e, reward);
return { ok: true, credited: reward };
}
if (!SJ.db) SJ.load();
const i = SJ.db.items.find(x => x.id === Number(id) && x.email === e);
if (!i) return { error: 'No such message.' };
if (i.rewarded) return { error: 'Already claimed for this one.' };
if (!i.readTs || Date.now() - i.readTs < dwellMs - 400) return { error: 'Give it a real read first.' };
const nToday = SJ.db.items.filter(x => x.email === e && x.rewarded && x.rewardedDay === day).length;
if (nToday >= cap) return { error: 'Daily read-reward cap reached (' + cap + '). Reading still works; rewards resume tomorrow.' };
i.rewarded = 1;
i.rewardedDay = day;
SJ.save();
addEarned(e, reward);
return { ok: true, credited: reward };
}
async function unreadCount(email) {
const e = String(email || '').toLowerCase();
if (!e) return 0;
await deliverSolos(e);
if (db.enabled()) {
const r = await db.q('SELECT COUNT(*) n FROM solo_inbox WHERE email=? AND read_ts IS NULL', [e]);
return r[0].n;
}
if (!SJ.db) SJ.load();
return SJ.db.items.filter(i => i.email === e && !i.readTs).length;
}
// ---- attention-gated daily claim (view N real ads -> claim earned credits) ---- // ---- attention-gated daily claim (view N real ads -> claim earned credits) ----
const VJ = { const VJ = {
db: null, db: null,
@@ -430,4 +600,5 @@ async function markBurned(id, tx) { return impl().markBurned(id, tx); }
module.exports = { init, rates, setRates, createCampaign, listCampaigns, setStatus, module.exports = { init, rates, setRates, createCampaign, listCampaigns, setStatus,
serve, click, targetOf, dailySweep, availableCredits, earnedBalance, grantWelcome, serve, click, targetOf, dailySweep, availableCredits, earnedBalance, grantWelcome,
viewStatus, recordView, claimDaily, pendingBurns, markBurned }; viewStatus, recordView, claimDaily, pendingBurns, markBurned,
inboxList, inboxOpen, claimSoloRead, unreadCount };
+3
View File
@@ -33,6 +33,8 @@ const CANNED = [
a: 'Join free with just your email at https://instantadpay.com/my, no wallet and no password needed. Your wallet only comes out when you buy a package or switch on payouts, and the site walks you through it.' }, a: 'Join free with just your email at https://instantadpay.com/my, no wallet and no password needed. Your wallet only comes out when you buy a package or switch on payouts, and the site walks you through it.' },
{ re: /(referral link|invite link|share link|refer)/i, { re: /(referral link|invite link|share link|refer)/i,
a: 'You get your share link the moment you sign in, free members included. One tip: switch on payouts (one free wallet step in Members) before your people start buying, because the contract locks each buyer to their sponsor at their first purchase.' }, a: 'You get your share link the moment you sign in, free members included. One tip: switch on payouts (one free wallet step in Members) before your people start buying, because the contract locks each buyer to their sponsor at their first purchase.' },
{ re: /(solo ad|inbox ad|inbox)/i,
a: 'Solo ads are full-message ads delivered straight into member inboxes on-site. Compose one under Campaigns (pick "Solo ad"): subject, up to 1000 characters, your link. You pay 5 credits per guaranteed delivery, 10 deliveries minimum. On the reading side, your Inbox section collects solos from other members — give one a real read (10 seconds on the open message) and claim 2 credits, up to 5 rewarded reads a day. You never receive your own solo.' },
{ re: /((view|watch|see).{0,12}ads?|earn.{0,12}credits?|daily (set|ads|views))/i, { re: /((view|watch|see).{0,12}ads?|earn.{0,12}credits?|daily (set|ads|views))/i,
a: 'In the Earn credits section of Members, each ad in the daily set opens full screen in its own tab, showing the advertiser\'s real site. A countdown runs while you watch (it pauses if you leave the tab), then you pass a quick click-the-icon check and the view counts. Finish the set, claim your daily credits, and spend them on your own banner or text campaigns. You never see your own ads, and viewer rewards are credits, never cash.' }, a: 'In the Earn credits section of Members, each ad in the daily set opens full screen in its own tab, showing the advertiser\'s real site. A countdown runs while you watch (it pauses if you leave the tab), then you pass a quick click-the-icon check and the view counts. Finish the set, claim your daily credits, and spend them on your own banner or text campaigns. You never see your own ads, and viewer rewards are credits, never cash.' },
{ re: /(credit|impression|cpm|what do i get|what am i buying)/i, { re: /(credit|impression|cpm|what do i get|what am i buying)/i,
@@ -52,6 +54,7 @@ FACTS:
- Ad packages: Micro $5/500 credits, Activation $20/2,000, Builder $50/5,500, Growth $100/12,000, Leader $250/32,500. Dollar-priced, settled in POL (Polygon) at the live Chainlink rate. 1 credit = 1 cent of ad delivery. - Ad packages: Micro $5/500 credits, Activation $20/2,000, Builder $50/5,500, Growth $100/12,000, Leader $250/32,500. Dollar-priced, settled in POL (Polygon) at the live Chainlink rate. 1 credit = 1 cent of ad delivery.
- Live formats: display banners (per impression), text ads (per impression), login ads (per day). Coming: inbox ads, featured rotation with disclosed rotation size, verified-visit packs. - Live formats: display banners (per impression), text ads (per impression), login ads (per day). Coming: inbox ads, featured rotation with disclosed rotation size, verified-visit packs.
- Members EARN credits by attention: in the Earn credits section of Members, each ad in the daily set opens FULL SCREEN in its own tab, showing the advertiser's real website. A countdown runs while you watch (it pauses if you leave the tab), then a quick human check (click the named icon) must be passed before the view counts. Finish the daily set, claim a small daily credit batch. Earned credits spend on banner and text campaigns; attention earns advertising, referrals earn money, and viewer rewards are never cash. Advertisers get real, verified visits to their site. - Members EARN credits by attention: in the Earn credits section of Members, each ad in the daily set opens FULL SCREEN in its own tab, showing the advertiser's real website. A countdown runs while you watch (it pauses if you leave the tab), then a quick human check (click the named icon) must be passed before the view counts. Finish the daily set, claim a small daily credit batch. Earned credits spend on banner and text campaigns; attention earns advertising, referrals earn money, and viewer rewards are never cash. Advertisers get real, verified visits to their site.
- Onsite SOLO ADS are live: a solo ad is a full message (subject + up to 1000 characters + your link) delivered into members' on-site Inbox (Members > Inbox). Cost 5 credits per guaranteed delivery, minimum 10 deliveries (50 credits). Each member receives a given solo at most once, and never the sender's own. Readers earn 2 credits per real read (10-second dwell on the open message, up to 5 rewarded reads/day) — claimed right from the message. Compose one in Campaigns > Solo ad.
- Campaign target URLs are checked the moment they are submitted: the page must be reachable and must ALLOW framing (no X-Frame-Options deny/sameorigin, no blocking CSP frame-ancestors), because surf views show the real site full screen. Frame-blocking or dead URLs are rejected with the exact reason; the fix is a landing page that allows framing. Login-ad targets skip the frame check (they are click-through only). - Campaign target URLs are checked the moment they are submitted: the page must be reachable and must ALLOW framing (no X-Frame-Options deny/sameorigin, no blocking CSP frame-ancestors), because surf views show the real site full screen. Frame-blocking or dead URLs are rejected with the exact reason; the fix is a landing page that allows framing. Login-ad targets skip the frame check (they are click-through only).
- Every purchase is split by an immutable smart contract in the same transaction: 50% direct sponsor, 20% level 2, 10% level 3, 20% platform. No withdrawals exist; money lands in members' own wallets instantly. - Every purchase is split by an immutable smart contract in the same transaction: 50% direct sponsor, 20% level 2, 10% level 3, 20% platform. No withdrawals exist; money lands in members' own wallets instantly.
- Qualification: level 1 open to all; 2 buyers of $20+ unlock level 2; 5 unlock level 3. Unqualified shares pass up the sponsor line, checking up to 25 positions, else the platform receives them. Qualification cannot be bought and never expires. - Qualification: level 1 open to all; 2 buyers of $20+ unlock level 2; 5 unlock level 3. Unqualified shares pass up the sponsor line, checking up to 25 positions, else the platform receives them. Qualification cannot be bought and never expires.
+13
View File
@@ -87,6 +87,19 @@ async function bootstrap() {
last_ts BIGINT NOT NULL DEFAULT 0, last_ts BIGINT NOT NULL DEFAULT 0,
PRIMARY KEY (email, day) PRIMARY KEY (email, day)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await q(`CREATE TABLE IF NOT EXISTS solo_inbox (
id INT AUTO_INCREMENT PRIMARY KEY,
campaign_id INT NOT NULL,
email VARCHAR(190) NOT NULL,
delivered BIGINT NOT NULL,
read_ts BIGINT NULL,
rewarded TINYINT NOT NULL DEFAULT 0,
rewarded_day CHAR(10) NULL,
UNIQUE KEY uq_solo (campaign_id, email),
INDEX (email), INDEX (email, rewarded, rewarded_day)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
// solo message bodies run long; widen the shared campaigns.body column
await alterSafe('ALTER TABLE campaigns MODIFY body VARCHAR(1200) NULL');
await q(`CREATE TABLE IF NOT EXISTS burns ( await q(`CREATE TABLE IF NOT EXISTS burns (
id VARCHAR(32) PRIMARY KEY, id VARCHAR(32) PRIMARY KEY,
member_id INT NOT NULL, member_id INT NOT NULL,
+106 -6
View File
@@ -126,6 +126,7 @@
} }
const tc = $('dbTeamChip'), wk = (d.referrals || []).filter(r => Date.now() - new Date(r.joined) < 6048e5).length; 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'; } if (tc && wk) { tc.hidden = false; tc.textContent = '+' + wk + ' this week'; }
setInboxBadge(d.inboxUnread || 0);
loadCharts(d); loadCharts(d);
$('nextMove').textContent = nextMove(d); $('nextMove').textContent = nextMove(d);
$('qualFill').style.width = Math.min(100, (d.buyerCount || 0) * 20) + '%'; $('qualFill').style.width = Math.min(100, (d.buyerCount || 0) * 20) + '%';
@@ -182,9 +183,10 @@
} }
// ── back-office menu: hash-routed panes ─────────────── // ── 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', 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) { function setPane(name) {
if (!PANES.includes(name)) name = 'overview'; if (!PANES.includes(name)) name = 'overview';
for (const p of PANES) { for (const p of PANES) {
@@ -194,6 +196,7 @@
document.querySelectorAll('.bo-menu [data-pane]').forEach(b => document.querySelectorAll('.bo-menu [data-pane]').forEach(b =>
b.classList.toggle('on', b.dataset.pane === name)); b.classList.toggle('on', b.dataset.pane === name));
if ($('boTitle')) $('boTitle').textContent = TITLES[name]; if ($('boTitle')) $('boTitle').textContent = TITLES[name];
if (name === 'inbox') loadInbox();
document.getElementById('memberArea').classList.remove('side-open'); // close mobile drawer document.getElementById('memberArea').classList.remove('side-open'); // close mobile drawer
if (location.hash !== '#' + name) history.replaceState(null, '', '#' + name); if (location.hash !== '#' + name) history.replaceState(null, '', '#' + name);
} }
@@ -288,11 +291,14 @@
try { try {
const r = await (await fetch('/api/my/campaigns')).json(); const r = await (await fetch('/api/my/campaigns')).json();
if (r.error) return; if (r.error) return;
lastRates = r.rates;
soloHint();
$('rateLine').textContent = 'Available to spend: ' + r.availableCredits.toLocaleString() $('rateLine').textContent = 'Available to spend: ' + r.availableCredits.toLocaleString()
+ (r.earnedCredits ? ' (' + r.purchasedCredits.toLocaleString() + ' purchased + ' + r.earnedCredits + ' earned)' : '') + (r.earnedCredits ? ' (' + r.purchasedCredits.toLocaleString() + ' purchased + ' + r.earnedCredits + ' earned)' : '')
+ ' credits · rates: banner ' + r.rates.bannerCreditsPerBatch + 'cr/' + r.rates.bannerBatch + ' credits · rates: banner ' + r.rates.bannerCreditsPerBatch + 'cr/' + r.rates.bannerBatch
+ ' views, text ' + r.rates.textCreditsPerBatch + 'cr/' + r.rates.textBatch + ' 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'); const el = $('campList');
el.innerHTML = ''; el.innerHTML = '';
if (!r.campaigns.length) { el.innerHTML = '<p class="muted small">No campaigns yet. Launch your first below.</p>'; return; } 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) {} } 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', () => { $('cType').addEventListener('change', () => {
const t = $('cType').value; const t = $('cType').value;
$('cImageRow').hidden = t === 'text'; $('cImageRow').hidden = t === 'text' || t === 'solo';
$('cTitleRow').hidden = t !== 'text'; $('cTitleRow').hidden = t !== 'text' && t !== 'solo';
$('cBodyRow').hidden = t !== 'text'; $('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 () => { $('createCampBtn').addEventListener('click', busy2($('createCampBtn'), async () => {
await api('/api/my/campaigns', { type: $('cType').value, name: $('cName').value, await api('/api/my/campaigns', { type: $('cType').value, name: $('cName').value,
targetUrl: $('cTarget').value, imageUrl: $('cImage').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'); IAP.status('Campaign is live. It starts serving right away.', 'ok');
$('cName').value = ''; $('cBudget').value = ''; $('cName').value = ''; $('cBudget').value = '';
await loadCampaigns(); await loadCampaigns();
@@ -331,6 +353,84 @@
// defers the busy() lookup to click time (busy is declared below) // defers the busy() lookup to click time (busy is declared below)
function busy2(btn, fn) { return (...a) => busy(btn, fn)(...a); } 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 ── // ── promo tools: Branded Voice copy, personalized with the member link ──
const PROMO_POSTS = [ 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}}', '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}}',
+12 -2
View File
@@ -217,10 +217,11 @@ footer{border-top:1px solid var(--line);margin-top:90px;padding:34px 0 0;color:v
box-shadow:0 12px 40px rgba(0,0,0,.55)} box-shadow:0 12px 40px rgba(0,0,0,.55)}
#status.ok{border-color:var(--mint)} #status.ok{border-color:var(--mint)}
#status.bad{border-color:var(--bad)} #status.bad{border-color:var(--bad)}
input,select{background:rgba(4,8,7,.65);border:1px solid var(--line-strong);color:var(--ink);border-radius:11px; input,select,textarea{background:rgba(4,8,7,.65);border:1px solid var(--line-strong);color:var(--ink);border-radius:11px;
padding:11px 14px;font-size:14.5px;font-family:inherit} padding:11px 14px;font-size:14.5px;font-family:inherit}
input[type=range]{padding:0;border:0;background:transparent;accent-color:var(--mint);height:28px;vertical-align:middle} input[type=range]{padding:0;border:0;background:transparent;accent-color:var(--mint);height:28px;vertical-align:middle}
input:focus,select:focus{border-color:var(--mint)} input:focus,select:focus,textarea:focus{border-color:var(--mint)}
textarea{resize:vertical;font:inherit}
:focus-visible{outline:2px solid var(--mint);outline-offset:2px} :focus-visible{outline:2px solid var(--mint);outline-offset:2px}
.hero-note{font-family:var(--mono);font-size:12px;color:var(--muted);margin-top:26px} .hero-note{font-family:var(--mono);font-size:12px;color:var(--muted);margin-top:26px}
/* ── member back-office shell ─────────────────────────── */ /* ── member back-office shell ─────────────────────────── */
@@ -294,6 +295,15 @@ input:focus,select:focus{border-color:var(--mint)}
.donut-legend{display:flex;flex-direction:column;gap:8px;font-size:13px} .donut-legend{display:flex;flex-direction:column;gap:8px;font-size:13px}
.donut-legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:8px} .donut-legend i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:8px}
.donut-center{font-family:var(--disp);font-weight:800} .donut-center{font-family:var(--disp);font-weight:800}
/* ── solo-ads inbox ── */
.bo-menu .pill{margin-left:auto;background:var(--amber);color:#1a1206;font-size:11px;font-weight:800;
border-radius:999px;padding:1px 8px;line-height:1.5}
.ib-row{display:flex;gap:10px;align-items:baseline;padding:11px 6px;border-bottom:1px solid var(--line);
cursor:pointer;flex-wrap:wrap}
.ib-row:hover{background:rgba(67,232,195,.05)}
.ib-row .sub{font-weight:700;flex:1;min-width:160px;overflow-wrap:anywhere}
.ib-row.unread .sub{color:var(--mint)}
.ib-row .from,.ib-row .when{font-size:12px;color:var(--muted);white-space:nowrap}
/* ── back-office accent family: green leads, cyan/violet/amber season the cards ── */ /* ── back-office accent family: green leads, cyan/violet/amber season the cards ── */
.bo .stats .stat:nth-child(2) .n{color:var(--cyan)} .bo .stats .stat:nth-child(2) .n{color:var(--cyan)}
.bo .stats .stat:nth-child(2)::before{background:linear-gradient(90deg,transparent,var(--cyan),transparent)} .bo .stats .stat:nth-child(2)::before{background:linear-gradient(90deg,transparent,var(--cyan),transparent)}
+4 -2
View File
@@ -225,8 +225,10 @@
</div> </div>
<div class="platecard"> <div class="platecard">
<div class="plate"><svg viewBox="0 0 24 24"><path d="M3 7l9 6 9-6"/><rect x="3" y="5" width="18" height="14" rx="2"/></svg></div> <div class="plate"><svg viewBox="0 0 24 24"><path d="M3 7l9 6 9-6"/><rect x="3" y="5" width="18" height="14" rx="2"/></svg></div>
<h3>Inbox ads <span class="badge amber">soon</span></h3> <h3>Solo ads</h3>
<p>Delivered on-site, where members earn credits just for reading them.</p> <p>Your full message, subject line to signature, delivered straight into member inboxes on-site.
Priced per guaranteed delivery, and readers earn credits for a real read — so your message
gets opened, not skimmed past.</p>
</div> </div>
<div class="platecard"> <div class="platecard">
<div class="plate"><svg viewBox="0 0 24 24"><path d="M12 3v4M12 17v4M3 12h4M17 12h4"/><circle cx="12" cy="12" r="4.5"/></svg></div> <div class="plate"><svg viewBox="0 0 24 24"><path d="M12 3v4M12 17v4M3 12h4M17 12h4"/><circle cx="12" cy="12" r="4.5"/></svg></div>
+28 -5
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"> <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Member area | InstantAdPay</title> <title>Member area | InstantAdPay</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
<link rel="stylesheet" href="/assets/site.css?v=20260905l"> <link rel="stylesheet" href="/assets/site.css?v=20260905n">
</head> </head>
<body class="bo-body"> <body class="bo-body">
@@ -57,6 +57,7 @@
<button data-pane="line" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="7" r="3.2"/><circle cx="5" cy="17" r="2.6"/><circle cx="19" cy="17" r="2.6"/><path d="M12 10v3M12 13l-5 2M12 13l5 2"/></svg>My line</button> <button data-pane="line" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="7" r="3.2"/><circle cx="5" cy="17" r="2.6"/><circle cx="19" cy="17" r="2.6"/><path d="M12 10v3M12 13l-5 2M12 13l5 2"/></svg>My line</button>
<button data-pane="buy" type="button"><svg viewBox="0 0 24 24"><circle cx="9" cy="20" r="1.6"/><circle cx="17" cy="20" r="1.6"/><path d="M3 4h2l2.4 11.2A2 2 0 0 0 9.4 17h8.2a2 2 0 0 0 2-1.6L21 8H6"/></svg>Buy packages</button> <button data-pane="buy" type="button"><svg viewBox="0 0 24 24"><circle cx="9" cy="20" r="1.6"/><circle cx="17" cy="20" r="1.6"/><path d="M3 4h2l2.4 11.2A2 2 0 0 0 9.4 17h8.2a2 2 0 0 0 2-1.6L21 8H6"/></svg>Buy packages</button>
<button data-pane="campaigns" type="button"><svg viewBox="0 0 24 24"><path d="M3 11l14-5v12L3 13v-2z"/><path d="M17 8a4 4 0 0 1 0 8M7 13v5a2 2 0 0 0 4 0v-3"/></svg>Campaigns</button> <button data-pane="campaigns" type="button"><svg viewBox="0 0 24 24"><path d="M3 11l14-5v12L3 13v-2z"/><path d="M17 8a4 4 0 0 1 0 8M7 13v5a2 2 0 0 0 4 0v-3"/></svg>Campaigns</button>
<button data-pane="inbox" type="button"><svg viewBox="0 0 24 24"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="M3 8l9 6 9-6"/></svg>Inbox<span class="pill" id="inboxBadge" hidden></span></button>
<button data-pane="earn" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5v9M9 10c0-1.1 1.3-1.8 3-1.8s3 .7 3 1.8-1.3 1.6-3 1.8-3 .7-3 1.8 1.3 1.8 3 1.8 3-.7 3-1.8"/></svg>Earn credits</button> <button data-pane="earn" type="button"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.5v9M9 10c0-1.1 1.3-1.8 3-1.8s3 .7 3 1.8-1.3 1.6-3 1.8-3 .7-3 1.8 1.3 1.8 3 1.8 3-.7 3-1.8"/></svg>Earn credits</button>
<button data-pane="earnings" type="button"><svg viewBox="0 0 24 24"><path d="M4 17l5-5 4 3 7-8"/><path d="M14 7h6v6"/></svg>Earnings</button> <button data-pane="earnings" type="button"><svg viewBox="0 0 24 24"><path d="M4 17l5-5 4 3 7-8"/><path d="M14 7h6v6"/></svg>Earnings</button>
<button data-pane="promo" type="button"><svg viewBox="0 0 24 24"><path d="M7 10s5-1 9-5v14c-4-4-9-5-9-5H5a2 2 0 0 1-2-2v0a2 2 0 0 1 2-2h2z"/><path d="M8 15l1 5h2l-1-5"/></svg>Promo tools</button> <button data-pane="promo" type="button"><svg viewBox="0 0 24 24"><path d="M7 10s5-1 9-5v14c-4-4-9-5-9-5H5a2 2 0 0 1-2-2v0a2 2 0 0 1 2-2h2z"/><path d="M8 15l1 5h2l-1-5"/></svg>Promo tools</button>
@@ -210,6 +211,7 @@
<option value="banner">Banner (per impression)</option> <option value="banner">Banner (per impression)</option>
<option value="text">Text ad (per impression)</option> <option value="text">Text ad (per impression)</option>
<option value="login">Login ad (per day)</option> <option value="login">Login ad (per day)</option>
<option value="solo">Solo ad (inbox delivery)</option>
</select></p> </select></p>
<p><input id="cName" placeholder="Campaign name" style="width:100%"></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> <p><input id="cBudget" type="number" placeholder="Budget (credits)" min="10" style="width:100%"></p>
@@ -220,10 +222,31 @@
<p id="cImageRow"><input id="cImage" placeholder="Image URL (banner/login ads)" 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="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> <p id="cBodyRow" hidden><input id="cBody" placeholder="Ad text (max 140)" style="width:100%"></p>
<p id="cSoloRow" hidden><textarea id="cSoloBody" placeholder="Your message (40–1000 characters). Write it like an email worth reading." rows="7" style="width:100%"></textarea></p>
<p class="small muted" id="cSoloHint" hidden></p>
<button class="btn" id="createCampBtn">Launch campaign</button> <button class="btn" id="createCampBtn">Launch campaign</button>
</div> </div>
</div> </div>
<div class="pane" id="pane-inbox" hidden>
<div class="card" id="inboxListCard">
<h3>Solo ads inbox</h3>
<p class="muted small">Full-message ads from members land here — each one delivered to you at
most once, never your own. Give one a real read and claim <span id="ibRewardNote">…</span>.</p>
<div id="ibList"><p class="muted small">Loading…</p></div>
</div>
<div class="card" id="inboxReadCard" hidden>
<p><a href="#" id="ibBack">← Back to inbox</a></p>
<h3 id="ibSubject"></h3>
<p class="small muted" id="ibMeta"></p>
<div id="ibBody" class="promo-block" style="white-space:pre-wrap"></div>
<p style="margin-top:14px">
<a class="btn" id="ibVisit" target="_blank" rel="noopener nofollow">Visit the advertiser</a>
<button class="btn sec" id="ibClaimBtn" type="button" hidden>…</button></p>
<p class="small muted" id="ibHint"></p>
</div>
</div>
<div class="pane" id="pane-earn" hidden> <div class="pane" id="pane-earn" hidden>
<div class="card"> <div class="card">
<h3>Earn credits by viewing ads</h3> <h3>Earn credits by viewing ads</h3>
@@ -332,9 +355,9 @@
</div> </div>
</div> </div>
<script src="/assets/common.js?v=20260905l"></script> <script src="/assets/common.js?v=20260905n"></script>
<script src="/assets/wallet.js?v=20260905l"></script> <script src="/assets/wallet.js?v=20260905n"></script>
<script src="/assets/my.js?v=20260905l"></script> <script src="/assets/my.js?v=20260905n"></script>
<script src="/assets/chat.js?v=20260905l"></script> <script src="/assets/chat.js?v=20260905n"></script>
</body> </body>
</html> </html>
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
<title>Viewing ad — InstantAdPay</title> <title>Viewing ad — InstantAdPay</title>
<link rel="stylesheet" href="/assets/site.css?v=20260905l"> <link rel="stylesheet" href="/assets/site.css?v=20260905n">
<style> <style>
html,body{height:100%;margin:0;overflow:hidden} html,body{height:100%;margin:0;overflow:hidden}
.vw{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg,#04110c);color:var(--ink,#e8fff7)} .vw{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg,#04110c);color:var(--ink,#e8fff7)}
@@ -43,6 +43,6 @@
</div> </div>
<iframe class="vframe" id="vFrame" sandbox="allow-scripts allow-same-origin allow-forms allow-popups" referrerpolicy="no-referrer" title="Advertiser site"></iframe> <iframe class="vframe" id="vFrame" sandbox="allow-scripts allow-same-origin allow-forms allow-popups" referrerpolicy="no-referrer" title="Advertiser site"></iframe>
</div> </div>
<script src="/assets/view.js?v=20260905l"></script> <script src="/assets/view.js?v=20260905n"></script>
</body> </body>
</html> </html>
+31 -1
View File
@@ -392,6 +392,7 @@ const server = http.createServer(async (req, res) => {
refCode: (acct && acct.code) || null, credits: 0, buyerCount: 0, refCode: (acct && acct.code) || null, credits: 0, buyerCount: 0,
earnedWei: '0', earnCount: 0, referrals: [], welcomeCredits: 0 }; earnedWei: '0', earnCount: 0, referrals: [], welcomeCredits: 0 };
if (out.email) out.welcomeCredits = await ads.grantWelcome(out.email); // idempotent lazy grant if (out.email) out.welcomeCredits = await ads.grantWelcome(out.email); // idempotent lazy grant
if (out.email) out.inboxUnread = await ads.unreadCount(out.email); // delivers pending solos too
if (memberId) { if (memberId) {
try { try {
const mm = await chain.member(memberId); const mm = await chain.member(memberId);
@@ -504,6 +505,35 @@ const server = http.createServer(async (req, res) => {
const r = await ads.claimDaily(s.email); const r = await ads.claimDaily(s.email);
return json(res, r.error ? 400 : 200, r); return json(res, r.error ? 400 : 200, r);
} }
// -- onsite solo ads: member inbox with read rewards
if (p === '/api/my/inbox' && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const r = await ads.inboxList(s.email);
const names = await accounts.namesForMembers([...new Set(r.items.map(i => i.fromMemberId).filter(Boolean))]);
for (const i of r.items) i.fromName = (i.fromMemberId && names[i.fromMemberId]) ? '@' + names[i.fromMemberId]
: i.fromMemberId ? 'member #' + i.fromMemberId : 'a member';
return json(res, 200, r);
}
m = /^\/api\/my\/inbox\/(\d+)$/.exec(p);
if (m && req.method === 'GET') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const r = await ads.inboxOpen(s.email, m[1]);
if (!r.error) {
const names = r.fromMemberId ? await accounts.namesForMembers([r.fromMemberId]) : {};
r.fromName = (r.fromMemberId && names[r.fromMemberId]) ? '@' + names[r.fromMemberId]
: r.fromMemberId ? 'member #' + r.fromMemberId : 'a member';
}
return json(res, r.error ? 404 : 200, r);
}
m = /^\/api\/my\/inbox\/(\d+)\/claim$/.exec(p);
if (m && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const r = await ads.claimSoloRead(s.email, m[1]);
return json(res, r.error ? 400 : 200, r);
}
if (p === '/api/my/activity' && req.method === 'GET') { if (p === '/api/my/activity' && req.method === 'GET') {
const s = await auth.fromRequest(req); const s = await auth.fromRequest(req);
if (!s) return json(res, 401, { error: 'Sign in first.' }); if (!s) return json(res, 401, { error: 'Sign in first.' });
@@ -545,7 +575,7 @@ const server = http.createServer(async (req, res) => {
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' }); if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });
const memberId = await auth.refreshMemberId(s); // 0 is fine: earned credits fund banner/text const memberId = await auth.refreshMemberId(s); // 0 is fine: earned credits fund banner/text
const b = await readBody(req); const b = await readBody(req);
if (b.type !== 'login') { // surf views frame the target: catch frame-breakers at the door if (!['login', 'solo'].includes(String(b.type || ''))) { // surf views frame the target: catch frame-breakers at the door (login + solo are click-through only)
const fc = await frameCheck(b.targetUrl); const fc = await frameCheck(b.targetUrl);
if (!fc.ok) return json(res, 400, { error: fc.reason }); if (!fc.ok) return json(res, 400, { error: fc.reason });
} }