From f6a3befe09f7b551c57ee84ca9006c70fc32d6b5 Mon Sep 17 00:00:00 2001 From: martbost Date: Sat, 5 Sep 2026 15:25:00 -0500 Subject: [PATCH] Onsite solo ads: inbox delivery with read rewards, composer, homepage format live Co-Authored-By: Claude Fable 5 --- ads.js | 177 ++++++++++++++++++++++++++++++++++++++++- chatbot.js | 3 + db.js | 13 +++ public/assets/my.js | 112 ++++++++++++++++++++++++-- public/assets/site.css | 14 +++- public/index.html | 6 +- public/my.html | 33 ++++++-- public/view.html | 4 +- server.js | 32 +++++++- 9 files changed, 373 insertions(+), 21 deletions(-) diff --git a/ads.js b/ads.js index ce3f8d6..6c2d74e 100644 --- a/ads.js +++ b/ads.js @@ -24,7 +24,13 @@ function rates() { welcomeCredits: 25, dailyViewTarget: 5, // ads to view for the daily claim (spec §8b attention-gated claim) 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); } function setRates(patch) { @@ -32,7 +38,7 @@ function setRates(patch) { return rates(); } -const TYPES = ['banner', 'text', 'login']; +const TYPES = ['banner', 'text', 'login', 'solo']; const URL_RE = /^https?:\/\/[^\s]+$/i; const bid = () => crypto.randomBytes(8).toString('hex'); function batchFor(type, r) { @@ -58,6 +64,15 @@ function validate(input) { out.body = String(input.body || '').trim().slice(0, 140); 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 }; } 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(); } +// ---- 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) ---- const VJ = { db: null, @@ -430,4 +600,5 @@ async function markBurned(id, tx) { return impl().markBurned(id, tx); } module.exports = { init, rates, setRates, createCampaign, listCampaigns, setStatus, serve, click, targetOf, dailySweep, availableCredits, earnedBalance, grantWelcome, - viewStatus, recordView, claimDaily, pendingBurns, markBurned }; + viewStatus, recordView, claimDaily, pendingBurns, markBurned, + inboxList, inboxOpen, claimSoloRead, unreadCount }; diff --git a/chatbot.js b/chatbot.js index 2e4fd00..fff61f7 100644 --- a/chatbot.js +++ b/chatbot.js @@ -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.' }, { 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.' }, + { 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, 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, @@ -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. - 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. +- 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). - 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. diff --git a/db.js b/db.js index a88c4c4..7ca1e65 100644 --- a/db.js +++ b/db.js @@ -87,6 +87,19 @@ async function bootstrap() { last_ts BIGINT NOT NULL DEFAULT 0, PRIMARY KEY (email, day) ) 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 ( id VARCHAR(32) PRIMARY KEY, member_id INT NOT NULL, diff --git a/public/assets/my.js b/public/assets/my.js index f69a8eb..77a7f5e 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -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 = '

No campaigns yet. Launch your first below.

'; 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 = '

No solo ads yet. When a member sends one, it lands here — and reading it pays.

'; + return; + } + el.innerHTML = ''; + for (const i of r.items) { + const d = document.createElement('div'); + d.className = 'ib-row' + (i.read ? '' : ' unread'); + d.innerHTML = '' + + (i.rewarded ? 'claimed' : i.read ? '' : 'new') + + '' + new Date(i.delivered).toLocaleDateString() + ''; + 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}}', diff --git a/public/assets/site.css b/public/assets/site.css index 83943b4..aa3f183 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -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)} #status.ok{border-color:var(--mint)} #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} 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} .hero-note{font-family:var(--mono);font-size:12px;color:var(--muted);margin-top:26px} /* ── 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 i{display:inline-block;width:10px;height:10px;border-radius:3px;margin-right:8px} .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 ── */ .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)} diff --git a/public/index.html b/public/index.html index ce943d6..f3d71f3 100644 --- a/public/index.html +++ b/public/index.html @@ -225,8 +225,10 @@
-

Inbox ads soon

-

Delivered on-site, where members earn credits just for reading them.

+

Solo ads

+

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.

diff --git a/public/my.html b/public/my.html index 6e99086..0aa1b77 100644 --- a/public/my.html +++ b/public/my.html @@ -4,7 +4,7 @@ Member area | InstantAdPay - + @@ -57,6 +57,7 @@ + @@ -210,6 +211,7 @@ +

@@ -220,10 +222,31 @@

+ +
+ + - - - - + + + + diff --git a/public/view.html b/public/view.html index d9ff966..e9fe57b 100644 --- a/public/view.html +++ b/public/view.html @@ -4,7 +4,7 @@ Viewing ad — InstantAdPay - +