From 15b036b940a302ad128d5aeb232f03d8a75d4814 Mon Sep 17 00:00:00 2001 From: martbost Date: Sat, 5 Sep 2026 07:23:30 -0500 Subject: [PATCH] Earn viewer v2: full-screen URL visits with countdown, human check, explicit return Co-Authored-By: Claude Fable 5 --- ads.js | 11 ++++- chatbot.js | 4 +- public/assets/my.js | 46 +++++++----------- public/assets/view.js | 106 ++++++++++++++++++++++++++++++++++++++++++ public/my.html | 24 +++++----- public/view.html | 48 +++++++++++++++++++ server.js | 46 ++++++++++++++++-- 7 files changed, 238 insertions(+), 47 deletions(-) create mode 100644 public/assets/view.js create mode 100644 public/view.html diff --git a/ads.js b/ads.js index c2cac93..ce3f8d6 100644 --- a/ads.js +++ b/ads.js @@ -139,6 +139,10 @@ const J = { this.save(); return c.targetUrl; }, + async targetOf(id) { // resolve without counting a click (earn-view frames the real url) + const c = this.db.campaigns.find(x => x.id === Number(id)); + return c ? c.targetUrl : null; + }, async dailySweep() { const r = rates(); const today = new Date().toISOString().slice(0, 10); @@ -242,6 +246,10 @@ const D = { await db.q('UPDATE campaigns SET clicks=clicks+1 WHERE id=?', [Number(id)]); return rows[0].target_url; }, + async targetOf(id) { // resolve without counting a click (earn-view frames the real url) + const rows = await db.q('SELECT target_url FROM campaigns WHERE id=?', [Number(id)]); + return rows.length ? rows[0].target_url : null; + }, async dailySweep() { const r = rates(); const today = new Date().toISOString().slice(0, 10); @@ -415,10 +423,11 @@ async function setStatus(owner, id, status) { } async function serve(type, opts) { return TYPES.includes(type) ? impl().serve(type, opts) : null; } async function click(id) { return impl().click(id); } +async function targetOf(id) { return impl().targetOf(id); } async function dailySweep() { return impl().dailySweep(); } 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, + serve, click, targetOf, dailySweep, availableCredits, earnedBalance, grantWelcome, viewStatus, recordView, claimDaily, pendingBurns, markBurned }; diff --git a/chatbot.js b/chatbot.js index f90d4e4..b51069f 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: /((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, a: 'Packages mint ad credits on-chain, and every new member also gets a small batch of welcome credits just for joining. One credit is one cent of ad delivery: banners, text ads, and login ads across the network, managed from the campaign manager in Members. Only your campaigns can spend your credits.' }, { re: /(price|cost|package|how much is)/i, @@ -49,7 +51,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. +- 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. - 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/public/assets/my.js b/public/assets/my.js index f928a3e..f69a8eb 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -426,47 +426,33 @@ } catch (e) { return null; } } async function earnShowAd() { + // each view happens full screen in its own tab: /view/ frames the + // advertiser's site, runs the countdown, then a human check credits it const type = earnState.types[earnState.i++ % earnState.types.length]; let r = null; try { r = await (await fetch('/api/my/earnview?type=' + type)).json(); } catch (e) {} const box = $('earnAdBox'); - if (!r || !r.ad) { + if (!r || !r.ad || !r.viewUrl) { await earnRefresh(); box.innerHTML = '' + (r && r.status && r.status.views >= r.status.target ? 'Set complete for today.' : 'No member ads are live in rotation right now. Views resume the moment a campaign is running.') + ''; return; } - const ad = r.ad; - box.innerHTML = ad.imageUrl - ? 'member ad' - : '' + ad.title + '' + (ad.body ? '
' + ad.body + '' : '') + '
'; - const btn = $('earnNextBtn'); - btn.hidden = false; - btn.disabled = true; - // the countdown only runs while this tab is visible AND focused; the - // server separately enforces the full dwell on its own clock - let left = r.status.dwell; - btn.textContent = 'Next ad (' + left + 's)'; - clearInterval(earnState.timer); - earnState.timer = setInterval(async () => { - if (document.visibilityState !== 'visible' || !document.hasFocus()) { - btn.textContent = 'Paused: stay on this tab (' + left + 's)'; - return; - } - left -= 1; - if (left > 0) { btn.textContent = 'Next ad (' + left + 's)'; return; } - clearInterval(earnState.timer); - btn.textContent = 'Next ad'; - const v = await (await fetch('/api/my/adview', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: r.token }) })).json(); - if (v.error) { IAP.status(v.error, 'bad'); btn.disabled = false; return; } - btn.disabled = false; - await earnRefresh(); - }, 1000); + const w = window.open(r.viewUrl, '_blank'); + box.innerHTML = 'Ad open in its own tab. Watch the countdown, pass the quick ' + + 'check, and the view credits itself — this page updates the moment it does.' + + (w ? '' : '
Pop-up blocked — open the ad here.'); + $('earnStartBtn').textContent = 'View next ad'; } - $('earnStartBtn').addEventListener('click', () => { $('earnStartBtn').hidden = true; earnShowAd(); }); - $('earnNextBtn').addEventListener('click', earnShowAd); + $('earnStartBtn').addEventListener('click', () => earnShowAd()); + // the viewer tab pings localStorage when a view credits; refresh instantly + window.addEventListener('storage', e => { + if (e.key === 'iap-view-done') { earnRefresh(); loadDashboard(); } + }); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible' && $('pane-earn') && !$('pane-earn').hidden) earnRefresh(); + }); $('earnClaimBtn').addEventListener('click', async () => { try { const r = await api('/api/my/claim'); diff --git a/public/assets/view.js b/public/assets/view.js new file mode 100644 index 0000000..334bb91 --- /dev/null +++ b/public/assets/view.js @@ -0,0 +1,106 @@ +// Full-screen ad viewer: the advertiser URL fills the tab, a countdown runs in +// the top bar (paused whenever this tab loses focus), and once the dwell is +// done the server hands out a human check. Solve it and the view credits. +// The dwell floor is enforced on the SERVER clock; this UI cannot cheat it. +(() => { + const $ = id => document.getElementById(id); + const token = location.pathname.split('/').pop(); + let left = 5, timer = null, credited = false, asking = false; + const setMsg = t => { $('vMsg').textContent = t; }; + async function j(url, body) { + const r = await fetch(url, body + ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } + : undefined); + return r.json(); + } + function tick() { + if (credited || asking) return; + if (document.visibilityState !== 'visible' || !document.hasFocus()) { + $('vTimer').classList.add('paused'); + $('vTimer').textContent = 'paused'; + setMsg('Come back to this tab to keep the countdown moving.'); + return; + } + $('vTimer').classList.remove('paused'); + left = Math.max(0, left - 0.25); + $('vTimer').textContent = Math.ceil(left) + 's left'; + if (left <= 0) { clearInterval(timer); askChallenge(); } + } + async function askChallenge() { + asking = true; + $('vTimer').textContent = 'check'; + $('vTimer').classList.add('done'); + setMsg('One quick check to count the view:'); + let c = await j('/api/my/viewchallenge?token=' + token); + if (c.early) { // server clock says not quite yet: wait it out and re-ask + await new Promise(r => setTimeout(r, (c.wait || 1) * 1000 + 300)); + c = await j('/api/my/viewchallenge?token=' + token); + } + if (c.error) return fail(c.error); + renderChallenge(c); + } + function renderChallenge(c) { + $('vPrompt').textContent = 'Click the ' + c.prompt + ':'; + const w = $('vOpts'); + w.innerHTML = ''; + c.options.forEach((em, i) => { + const b = document.createElement('button'); + b.type = 'button'; + b.textContent = em; + b.addEventListener('click', () => answer(i)); + w.appendChild(b); + }); + $('vCheck').classList.add('on'); + } + async function answer(i) { + const r = await j('/api/my/adview', { token, answer: i }); + if (r.error) { + if (r.retry) { + setMsg('Not that one — try again.'); + const c = await j('/api/my/viewchallenge?token=' + token); + if (!c.error) return renderChallenge(c); + return fail(c.error); + } + return fail(r.error); + } + credited = true; + $('vCheck').classList.remove('on'); + $('vTimer').textContent = '✓ credited'; + const st = r.status || r; + setMsg('View ' + st.views + ' of ' + st.target + ' counted for today.' + + (st.views >= st.target && !st.claimed ? ' Head back and claim your credits.' : '')); + $('vDone').classList.add('on'); + try { localStorage.setItem('iap-view-done', String(Date.now())); } catch (e) {} + } + function fail(msg) { + credited = true; // stop the loop; this view is over either way + if (timer) clearInterval(timer); + $('vCheck').classList.remove('on'); + $('vTimer').textContent = '—'; + setMsg(msg); + $('vDone').classList.add('on'); + } + $('vClose').addEventListener('click', () => { + window.close(); + setMsg('You can close this tab now.'); + }); + (async () => { + const info = await j('/api/my/viewinfo?token=' + token); + if (info.error) { + $('vFrame').remove(); + const d = document.createElement('div'); + d.className = 'vfail'; + d.textContent = info.error; + document.querySelector('.vw').appendChild(d); + $('vDone').classList.add('on'); + $('vTimer').textContent = '—'; + setMsg(''); + return; + } + left = info.dwell || 5; + $('vFrame').src = info.targetUrl; + setMsg('Watching: ' + (info.adName || 'member ad') + ' — stay on this tab.'); + $('vTimer').textContent = left + 's left'; + timer = setInterval(tick, 250); + })(); +})(); diff --git a/public/my.html b/public/my.html index aa401ed..efc4186 100644 --- a/public/my.html +++ b/public/my.html @@ -4,7 +4,7 @@ Member area | InstantAdPay - + @@ -225,17 +225,17 @@