diff --git a/ads.js b/ads.js index c0a4560..45d6b3a 100644 --- a/ads.js +++ b/ads.js @@ -21,7 +21,10 @@ function rates() { textBatch: 10, textCreditsPerBatch: 1, loginCreditsPerDay: 100, burnBatchMin: 50, - welcomeCredits: 25 // PLACEHOLDER until Marty sets the rate card (spec §8b earned pool) + welcomeCredits: 25, + dailyViewTarget: 5, // ads to view for the daily claim (spec §8b attention-gated claim) + dailyClaimCredits: 5, + viewDwellSeconds: 5 }, saved); } function setRates(patch) { @@ -110,10 +113,17 @@ const J = { c.batchImps += 1; if (c.batchImps >= b.n) { c.batchImps = 0; - c.accrued = (c.accrued || 0) + b.cr; - if (c.accrued >= r.burnBatchMin) { - this.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; + // earned pool pays first (spec §8b); only the remainder burns on-chain + if (await spendEarned(c.owner, b.cr)) { + c.spent += b.cr; + } else if (!c.memberId) { + c.status = 'out'; // earned-only advertiser ran dry: no chain pool to fall to + } else { + c.accrued = (c.accrued || 0) + b.cr; + if (c.accrued >= r.burnBatchMin) { + this.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; + } } if (c.spent + c.accrued >= c.budget) c.status = 'out'; } @@ -193,9 +203,20 @@ const D = { const b = batchFor(type, r); if (b) { // atomic batch rollover: only one worker wins the WHERE guard - await db.q('UPDATE campaigns SET batch_imps=batch_imps-?, accrued=accrued+? WHERE id=? AND batch_imps>=?', - [b.n, b.cr, c.id, b.n]); - await this.rollBurn(c.id, r); + const won = await db.q('UPDATE campaigns SET batch_imps=batch_imps-? WHERE id=? AND batch_imps>=?', + [b.n, c.id, b.n]); + if (won.affectedRows) { + // earned pool pays first (spec §8b); only the remainder burns on-chain + if (await spendEarned(c.owner, b.cr)) { + await db.q('UPDATE campaigns SET spent=spent+? WHERE id=?', [b.cr, c.id]); + await db.q('UPDATE campaigns SET status=\'out\' WHERE id=? AND status=\'active\' AND spent+accrued>=budget', [c.id]); + } else if (!c.memberId) { + await db.q('UPDATE campaigns SET status=\'out\' WHERE id=?', [c.id]); // earned-only ran dry + } else { + await db.q('UPDATE campaigns SET accrued=accrued+? WHERE id=?', [b.cr, c.id]); + await this.rollBurn(c.id, r); + } + } } return served(c); }, @@ -278,6 +299,96 @@ async function grantWelcome(email) { return (EJ.db[e] && EJ.db[e].balance) || 0; } +// spend from the earned pool first (banner/text only); true = fully covered +async function spendEarned(email, amount) { + const e = String(email || '').toLowerCase(); + if (!e || !amount) return false; + if (db.enabled()) { + const r = await db.q('UPDATE earned_credits SET balance=balance-? WHERE email=? AND balance>=?', [amount, e, amount]); + return !!r.affectedRows; + } + if (!EJ.db) EJ.load(); + const rec = EJ.db[e]; + if (!rec || rec.balance < amount) return false; + rec.balance -= amount; + EJ.save(); + return true; +} +function addEarned(email, amount) { + const e = String(email || '').toLowerCase(); + if (db.enabled()) { + return db.q(`INSERT INTO earned_credits (email,balance,granted_welcome,updated) VALUES (?,?,0,?) + ON DUPLICATE KEY UPDATE balance=balance+VALUES(balance), updated=VALUES(updated)`, [e, amount, Date.now()]); + } + if (!EJ.db) EJ.load(); + EJ.db[e] = EJ.db[e] || { balance: 0 }; + EJ.db[e].balance += amount; + EJ.save(); +} + +// ---- attention-gated daily claim (view N real ads -> claim earned credits) ---- +const VJ = { + db: null, + FILE: () => path.join(DATA_DIR, 'dailyviews.json'), + load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = {}; } }, + save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} } +}; +const today = () => new Date().toISOString().slice(0, 10); +async function viewStatus(email) { + const e = String(email || '').toLowerCase(); + const r = rates(); + let views = 0, claimed = false; + if (db.enabled()) { + const rows = await db.q('SELECT views, claimed FROM daily_views WHERE email=? AND day=?', [e, today()]); + if (rows.length) { views = rows[0].views; claimed = !!rows[0].claimed; } + } else { + if (!VJ.db) VJ.load(); + const rec = VJ.db[e]; + if (rec && rec.day === today()) { views = rec.views; claimed = !!rec.claimed; } + } + return { views, target: r.dailyViewTarget, claimCredits: r.dailyClaimCredits, + dwell: r.viewDwellSeconds, claimed, earned: await earnedBalance(e) }; +} +async function recordView(email) { + const e = String(email || '').toLowerCase(); + const r = rates(); + const minGap = Math.max(2, r.viewDwellSeconds - 1) * 1000; + const now = Date.now(); + if (db.enabled()) { + await db.q(`INSERT INTO daily_views (email,day,views,claimed,last_ts) VALUES (?,?,0,0,0) + ON DUPLICATE KEY UPDATE email=email`, [e, today()]); + const upd = await db.q(`UPDATE daily_views SET views=views+1, last_ts=? WHERE email=? AND day=? AND views= r.dailyViewTarget || now - rec.last < minGap) return Object.assign(await viewStatus(e), { tooFast: true }); + rec.views += 1; rec.last = now; + VJ.save(); + } + return viewStatus(e); +} +async function claimDaily(email) { + const e = String(email || '').toLowerCase(); + const r = rates(); + if (db.enabled()) { + const upd = await db.q('UPDATE daily_views SET claimed=1 WHERE email=? AND day=? AND claimed=0 AND views>=?', + [e, today(), r.dailyViewTarget]); + if (!upd.affectedRows) return { error: 'View today’s ads first, then claim.' }; + } else { + if (!VJ.db) VJ.load(); + const rec = VJ.db[e]; + if (!rec || rec.day !== today() || rec.claimed || rec.views < r.dailyViewTarget) + return { error: 'View today’s ads first, then claim.' }; + rec.claimed = true; + VJ.save(); + } + await addEarned(e, r.dailyClaimCredits); + return { ok: true, credited: r.dailyClaimCredits, status: await viewStatus(e) }; +} + const impl = () => db.enabled() ? D : J; function init(opts) { DATA_DIR = opts.dataDir; chain = opts.chain; J.load(); } @@ -288,8 +399,11 @@ async function availableCredits(memberId) { async function createCampaign(owner, memberId, input) { const v = validate(input); if (v.error) return v; - const avail = await availableCredits(memberId); - if (v.c.budget > avail) return { error: 'Budget exceeds your available credits (' + avail + ').' }; + const purchased = await availableCredits(memberId); + const earned = v.c.type !== 'login' ? await earnedBalance(owner) : 0; // earned pool: baseline formats only + const avail = purchased + earned; + if (v.c.budget > avail) return { error: 'Budget exceeds your available credits (' + avail + + (earned ? ', including ' + earned + ' earned' : '') + ').' }; return { ok: true, campaign: await impl().create(owner, memberId, v.c) }; } async function listCampaigns(owner) { return impl().list(owner); } @@ -304,4 +418,5 @@ async function pendingBurns() { return impl().pendingBurns(); } async function markBurned(id, tx) { return impl().markBurned(id, tx); } module.exports = { init, rates, setRates, createCampaign, listCampaigns, setStatus, - serve, click, dailySweep, availableCredits, earnedBalance, grantWelcome, pendingBurns, markBurned }; + serve, click, dailySweep, availableCredits, earnedBalance, grantWelcome, + viewStatus, recordView, claimDaily, pendingBurns, markBurned }; diff --git a/chatbot.js b/chatbot.js index b4aec18..f90d4e4 100644 --- a/chatbot.js +++ b/chatbot.js @@ -49,6 +49,7 @@ FACTS: - Free to join with email only (6-digit code sign-in, no passwords). Wallet appears only at purchase or payout activation. Every new member gets a small welcome batch of ad credits at signup (engine-side bonus credits, separate from on-chain purchased credits). - 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: view the daily set of member ads (a few seconds each) in the Earn credits section of Members, then 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. - 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. - Every member gets a share link immediately (site code, /join/); the contract locks a buyer to their sponsor at the buyer's FIRST purchase, so members should switch on payouts before their referrals buy. diff --git a/db.js b/db.js index 5d5be3f..edcbbb2 100644 --- a/db.js +++ b/db.js @@ -70,6 +70,14 @@ async function bootstrap() { granted_welcome TINYINT NOT NULL DEFAULT 0, updated BIGINT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await q(`CREATE TABLE IF NOT EXISTS daily_views ( + email VARCHAR(190) NOT NULL, + day CHAR(10) NOT NULL, + views INT NOT NULL DEFAULT 0, + claimed TINYINT NOT NULL DEFAULT 0, + last_ts BIGINT NOT NULL DEFAULT 0, + PRIMARY KEY (email, day) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); await q(`CREATE TABLE IF NOT EXISTS burns ( id VARCHAR(32) PRIMARY KEY, member_id INT NOT NULL, diff --git a/public/assets/common.js b/public/assets/common.js index 3fe6f99..d32a0ec 100644 --- a/public/assets/common.js +++ b/public/assets/common.js @@ -85,7 +85,9 @@ window.IAP = (function () { const div = document.createElement('div'); div.className = 'row t-' + ev.type; div.innerHTML = '' + describeEvent(ev, c) + '' - + 'verify ↗'; + + (c.explorer + ? 'verify ↗' + : '' + ev.tx.slice(0, 12) + '…'); return div; } // Render one served ad into #. Silent if no inventory. diff --git a/public/assets/contract.js b/public/assets/contract.js index 4a46cd0..2993aac 100644 --- a/public/assets/contract.js +++ b/public/assets/contract.js @@ -6,8 +6,16 @@ $('cAddr').textContent = c.contract.slice(0, 10) + '…' + c.contract.slice(-6); $('cChain').textContent = c.chainName; $('mockAddr').textContent = c.contract.slice(0, 18) + '…'; - $('lnkExplorer').href = c.explorer + '/address/' + c.contract + '#code'; - const src = 'https://repo.sourcify.dev/contracts/full_match/' + c.chainId + '/' + c.contract + '/'; - $('lnkSource').href = src; - $('lnkSource2').href = src; + if (c.explorer) { + $('lnkExplorer').href = c.explorer + '/address/' + c.contract + '#code'; + const src = 'https://repo.sourcify.dev/contracts/full_match/' + c.chainId + '/' + c.contract + '/'; + $('lnkSource').href = src; + $('lnkSource2').href = src; + } else { + // private rehearsal chain: point at the audited Amoy verification instead + const src = 'https://repo.sourcify.dev/contracts/full_match/80002/0xaF47EC401A9B0D570a553b1A1c95D6079083636e/'; + $('lnkExplorer').href = src; + $('lnkSource').href = src; + $('lnkSource2').href = src; + } })(); diff --git a/public/assets/my.js b/public/assets/my.js index 373b4e0..b7ed121 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -68,9 +68,9 @@ } // ── back-office menu: hash-routed panes ─────────────── - const PANES = ['overview', 'line', 'campaigns', 'earnings', 'wallet']; + const PANES = ['overview', 'line', 'campaigns', 'earn', 'earnings', 'wallet']; const TITLES = { overview: 'Overview', line: 'My line', campaigns: 'Campaigns', - earnings: 'Earnings', wallet: 'Wallet & account' }; + earn: 'Earn credits', earnings: 'Earnings', wallet: 'Wallet & account' }; function setPane(name) { if (!PANES.includes(name)) name = 'overview'; for (const p of PANES) { @@ -113,8 +113,9 @@ $('linkCard').hidden = !!me.address; $('activateCard').hidden = !(me.address && !me.memberId); $('activityArea').hidden = !me.memberId; - $('campaignCard').hidden = !me.memberId; - if (me.memberId) loadCampaigns(); + $('campGate').hidden = true; // earned credits fund campaigns for everyone + $('campaignCard').hidden = false; + loadCampaigns(); // the share link works from day one; codes resolve to your chain id later if (me.refCode || me.memberId) { @@ -159,6 +160,7 @@ const r = await (await fetch('/api/my/campaigns')).json(); if (r.error) return; $('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'; @@ -200,6 +202,63 @@ // defers the busy() lookup to click time (busy is declared below) function busy2(btn, fn) { return (...a) => busy(btn, fn)(...a); } + // ── earn-by-viewing: daily ad set with dwell, then claim ── + const earnState = { types: ['banner', 'text'], i: 0, timer: null }; + async function earnRefresh() { + try { + const st = await (await fetch('/api/my/earn')).json(); + if (st.error) return null; + $('earnProgress').textContent = 'today: ' + st.views + ' / ' + st.target + ' ads viewed'; + $('earnBalance').textContent = st.earned + ' earned credits'; + const done = st.views >= st.target; + $('earnClaimBtn').hidden = !(done && !st.claimed); + if (st.claimed) $('earnHint').textContent = 'Claimed for today. Come back tomorrow, or put those credits to work in Campaigns.'; + else if (done) $('earnHint').textContent = 'Set complete. Claim your ' + st.claimCredits + ' credits.'; + return st; + } catch (e) { return null; } + } + async function earnShowAd() { + const st = await earnRefresh(); + if (!st || st.views >= st.target) return; + const type = earnState.types[earnState.i++ % earnState.types.length]; + let ad = null; + try { ad = (await (await fetch('/api/ads/slot?type=' + type)).json()).ad; } catch (e) {} + const box = $('earnAdBox'); + if (!ad) { + box.innerHTML = 'No member ads are live in rotation right now. ' + + 'Views resume the moment a campaign is running.'; + return; + } + box.innerHTML = ad.imageUrl + ? 'member ad' + : '' + ad.title + '' + (ad.body ? '
' + ad.body + '' : '') + '
'; + const btn = $('earnNextBtn'); + btn.hidden = false; + btn.disabled = true; + let left = st.dwell; + btn.textContent = 'Next ad (' + left + 's)'; + clearInterval(earnState.timer); + earnState.timer = setInterval(async () => { + left -= 1; + if (left > 0) { btn.textContent = 'Next ad (' + left + 's)'; return; } + clearInterval(earnState.timer); + btn.textContent = 'Next ad'; + btn.disabled = false; + await fetch('/api/my/adview', { method: 'POST' }); // dwell served: count it + await earnRefresh(); + }, 1000); + } + $('earnStartBtn').addEventListener('click', () => { $('earnStartBtn').hidden = true; earnShowAd(); }); + $('earnNextBtn').addEventListener('click', earnShowAd); + $('earnClaimBtn').addEventListener('click', async () => { + try { + const r = await api('/api/my/claim'); + IAP.status('+' + r.credited + ' credits earned. Spend them in Campaigns.', 'ok'); + await earnRefresh(); + loadDashboard(); + } catch (e) { IAP.status(e.message, 'bad'); } + }); + const busy = (btn, fn) => async () => { try { btn.disabled = true; await fn(); } catch (e) { IAP.status((e && e.message) || String(e), 'bad'); } diff --git a/public/contract.html b/public/contract.html index 4d665ec..a2a84eb 100644 --- a/public/contract.html +++ b/public/contract.html @@ -5,7 +5,7 @@ The contract | InstantAdPay - +
@@ -129,8 +129,8 @@
Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.
- - - + + + diff --git a/public/index.html b/public/index.html index e531014..8a85673 100644 --- a/public/index.html +++ b/public/index.html @@ -5,7 +5,7 @@ InstantAdPay: advertise and earn, locked in code - + @@ -399,9 +399,9 @@ - - - - + + + + diff --git a/public/ledger.html b/public/ledger.html index a1fe9cf..ddbf9ca 100644 --- a/public/ledger.html +++ b/public/ledger.html @@ -5,7 +5,7 @@ Live ledger | InstantAdPay - +
@@ -25,8 +25,8 @@
InstantAdPay · how it works · contract source ↗
- - - + + + diff --git a/public/my.html b/public/my.html index 7790503..2632fa7 100644 --- a/public/my.html +++ b/public/my.html @@ -4,7 +4,7 @@ Member area | InstantAdPay - + @@ -55,6 +55,7 @@ + @@ -155,6 +156,27 @@ + +