From 860aedabf6e7c810b81be3e66bab3ab6f77a3c84 Mon Sep 17 00:00:00 2001 From: martbost Date: Sun, 6 Sep 2026 08:03:01 -0500 Subject: [PATCH] Video ads (watch-to-earn): duration-tiered pricing, escape-proof player, per-view charge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New 'video' campaign type; MP4/WebM upload or direct https link + required-watch tier (10/30/60s → 3/7/12 cr per view; viewer earns 1/2/4) - Composer: video fields, tier dropdown, live price hint - Earn credits > Watch videos sub-tab: no-seek player, server-clock watch floor, daily cap, self-exclusion, single-use tokens - media-src CSP for direct-link/hosted video; video skips frame-check Co-Authored-By: Claude Fable 5 --- ads.js | Bin 35660 -> 39501 bytes chatbot.js | 2 +- db.js | 1 + public/assets/my.js | 109 +++++++++++++++++++++++++++++++++++++++++-- public/contract.html | 8 ++-- public/index.html | 10 ++-- public/ledger.html | 8 ++-- public/my.html | 45 ++++++++++++++++-- public/tx.html | 6 +-- public/view.html | 4 +- public/wall.html | 6 +-- server.js | 33 ++++++++++++- 12 files changed, 199 insertions(+), 33 deletions(-) diff --git a/ads.js b/ads.js index 0f79f88c99ea191da9f41a25cbadc653456c894e..73f07e4612b79f5a913e18626f884f8c38bd056c 100644 GIT binary patch delta 1936 zcmah~-%lJ>6i#U?cAJ=LO9CQr7L=V~XNRTHgzPe1DA1S|loo+VOJSI~3uBj=>CD~T z64*<-?|kPw=l=A0 z&(GiWJk34not%Umeejd>FE?Wr)fm`N^IR4f!2EI$ISlGHclIIhL7qq8R08LLks?oL z-;VYe@O|=fYb@d(naID~qlFX1974G`2rG;;SH&{GiJ0qgX<74FolFSBWG+>%`VRN# zDhc5o3UVWew3s=u4o46%UX6UvBAc^V)3GozNto$OFG8c^D`C3pA_>hQzZ4J>pP+EC zP6l?&-M~H6;rN)Mi%12S${Tb~-Hu#ZL!(H}$+bt|(Vk;^uA!o=MU_HIY#i8oY%M}# zZ1AkZZTxFAjbA;0A<5gSjLJ|nY$ zww#e{i`9 z#?deKZ|)dymp$6jMRx7{qM#m|73S~peJH6~CnVCzye5wC=C^bm3gA~O72(^UpQ1Jl zf(o&F6MxZPd$k34e4N-(h{3Wf=thl~CI)WQg(dkJJ6Pu60Br=oph&pE^&?zH~O(q&Yu}2x`%OP`rNprj~|B2 zYUOeY`J;Hd(1$;U19)rV3N~k7$o!Aair|$&OLI)mU&b%2k&E0EEkcx1-G8-S$MWVY zeQJ+dgoM_rPy;`_Ka9UyY5Zd|ck?t00K0BeM=h99P^ny}HX$;FQlq5E4Pcd+k<68y zyK6qwMsaq&5C6Q@Tg(Jio`H=w7w;^R%B|8U2_yl~B3;&8R=joB$r$HE4E*$7e{WJ| z{N~=^a95yit#PFu9Cppm-n%p?m{tl=QkR$|R}TKLHE=^e0irFs|LW%!&;M}4XYcmo z$JqjKZ5I>ro#Pi!td?Tm#(CPQvL)uL4kvLKH;OJg)q1};jEZMl!tqm{Nr(%mYeI+s0&Pn!m2 zjtgt8P!V&K(Y%Z;`m{9(+H+Fcl7j8fAQ)v{fMuKSn+NtIji##rpHmm(L}U1Q8q+cb z%DIHiJ6G_+tJyAQ%87(5dR|WMs8N)KDSY-moF%m1!V@jEY!Jtt`Auobw{Ts&I%2dW cx2bZ9!S4i-8mBT@gC0zG(T;q26X}up8>(%4*8l(j delta 23 fcmX@Rh3U*RrVYA{lfO+C*!;Dhn~O!E)|v|dhO7ys diff --git a/chatbot.js b/chatbot.js index d454138..c614023 100644 --- a/chatbot.js +++ b/chatbot.js @@ -53,7 +53,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 — unlocked by the WELCOME TOUR on first sign-in: they visit their upline's line-banner sites (up to 3, 10 seconds each — the same 3 levels the contract pays), then claim the credits. Members with no upline banners get the credits instantly. - LINE BANNER (free, set in Profile): every member can set a destination URL (must allow framing) plus an optional banner image. It is shown to their next THREE levels of new members during welcome tours (position 1 for directs, 2, 3 below), and on their public BANNER WALL at /wall/ — a shareable page showing their line ladder with their join link. Free viral traffic that compounds as the team grows; no credits spent. - 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), full-screen LOGIN ADS (per day: right after a member signs in they land on a sponsor interstitial — they click "Open Ad", the advertiser's page opens in a NEW tab, a countdown runs on the interstitial, and at zero a "Go to dashboard" button appears. Just a CTA link is enough; an optional banner image can be the clickable creative. No framing requirement since it opens in its own tab), and solo ads. Coming: featured rotation with disclosed rotation size, verified-visit packs. +- Live formats: display banners (per impression), text ads (per impression), full-screen LOGIN ADS (per day: right after a member signs in they land on a sponsor interstitial — they click "Open Ad", the advertiser's page opens in a NEW tab, a countdown runs on the interstitial, and at zero a "Go to dashboard" button appears. Just a CTA link is enough; an optional banner image can be the clickable creative. No framing requirement since it opens in its own tab), WATCH-TO-EARN VIDEO ADS (advertiser uploads an MP4/WebM or gives a direct https .mp4/.webm link and picks a required watch length — 10s/30s/60s — which sets the per-view price; viewers watch in an escape-proof player under Earn credits > Watch videos, the watch time is enforced on the server clock, and they earn credits per completed watch; you never see your own videos), and solo ads. Banner ads also require a size (standard IAB sizes like 728x90, 300x250). Coming: 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 2000 characters of formatted text + your link) delivered into members' on-site Inbox (Members > Inbox). The composer in Campaigns > Solo ad has a rich-text editor (bold, headings, lists, links), lets you ATTACH one image (PNG/JPG/WebP/GIF, up to 3MB) or one video (MP4/WebM, up to 25MB), and adds a call-to-action button with a custom label that opens the target URL. 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). diff --git a/db.js b/db.js index a0adb21..9791057 100644 --- a/db.js +++ b/db.js @@ -89,6 +89,7 @@ async function bootstrap() { last_ts BIGINT NOT NULL DEFAULT 0, PRIMARY KEY (email, day) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`); + await alterSafe('ALTER TABLE daily_views ADD COLUMN video_count INT NOT NULL DEFAULT 0'); // watch-to-earn videos/day await q(`CREATE TABLE IF NOT EXISTS solo_inbox ( id INT AUTO_INCREMENT PRIMARY KEY, campaign_id INT NOT NULL, diff --git a/public/assets/my.js b/public/assets/my.js index cee9f3a..fb6e8bd 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -414,28 +414,127 @@ $('cBodyRow').hidden = t !== 'text'; $('cSoloRow').hidden = t !== 'solo'; $('cSoloHint').hidden = t !== 'solo'; + $('cVideoRow').hidden = t !== 'video'; $('cTitle').placeholder = t === 'solo' ? 'Subject line (max 80)' : 'Headline (max 60)'; soloHint(); + if (t === 'video') videoHint(); + }); + // video composer: tier dropdown + upload + price hint + function videoHint() { + if (!lastRates || !lastRates.videoTiers) return; + if ($('cWatchSecs') && !$('cWatchSecs').options.length) + $('cWatchSecs').innerHTML = lastRates.videoTiers.map(t => + '').join(''); + const tier = lastRates.videoTiers.find(t => t.secs === Number($('cWatchSecs').value)) || lastRates.videoTiers[0]; + const n = tier ? Math.floor((Number($('cBudget').value) || 0) / tier.cost) : 0; + $('cVideoHint').textContent = tier ? (tier.cost + ' credits per completed ' + tier.secs + 's view' + + (n ? ' — this budget buys ' + n + ' views' : '') + '. Viewers earn ' + tier.reward + ' credits each, so they watch.') : ''; + } + document.addEventListener('change', e => { if (e.target && e.target.id === 'cWatchSecs') videoHint(); }); + $('cBudget').addEventListener('input', () => { if ($('cType').value === 'video') videoHint(); }); + $('cVideoUploadBtn').addEventListener('click', () => $('cVideoFile').click()); + $('cVideoFile').addEventListener('change', async () => { + const f = $('cVideoFile').files[0]; + if (!f) return; + $('cVideoInfo').textContent = 'Uploading ' + f.name + '… (large files take a moment)'; + try { + const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json(); + if (r.error) { $('cVideoInfo').textContent = r.error; $('cVideoFile').value = ''; return; } + $('cVideoUrl').value = r.url; + $('cVideoInfo').textContent = f.name + ' uploaded'; + $('cVideoPrev').hidden = false; + $('cVideoPrev').innerHTML = ''; + } catch (e) { $('cVideoInfo').textContent = 'Upload failed. Try again.'; } + $('cVideoFile').value = ''; }); $('createCampBtn').addEventListener('click', busy2($('createCampBtn'), async () => { // in raw mode the source of truth is the textarea; sync it back first let soloBody = $('cSoloEd').innerHTML; if ($('cSoloRaw') && !$('cSoloRaw').hidden) soloBody = $('cSoloRaw').value; + const isVideo = $('cType').value === 'video'; await api('/api/my/campaigns', { type: $('cType').value, name: $('cName').value, targetUrl: $('cTarget').value, imageUrl: $('cImage').value, size: $('cSize').value, - title: $('cTitle').value, + title: isVideo ? $('cVideoTitle').value : $('cTitle').value, body: $('cType').value === 'solo' ? soloBody : $('cBody').value, - ctaLabel: $('cCtaLabel').value, + videoUrl: $('cVideoUrl').value, watchSecs: Number($('cWatchSecs').value), + ctaLabel: isVideo ? $('cVideoCta').value : $('cCtaLabel').value, budget: Number($('cBudget').value) }); IAP.status('Campaign is live. It starts serving right away.', 'ok'); $('cName').value = ''; $('cBudget').value = ''; $('cSoloEd').innerHTML = ''; $('cSoloRaw').value = ''; $('cCtaLabel').value = ''; + $('cVideoUrl').value = ''; $('cVideoTitle').value = ''; $('cVideoCta').value = ''; + $('cVideoInfo').textContent = ''; $('cVideoPrev').hidden = true; $('cVideoPrev').innerHTML = ''; $('edMediaInfo').textContent = ''; await loadCampaigns(); })); // defers the busy() lookup to click time (busy is declared below) function busy2(btn, fn) { return (...a) => busy(btn, fn)(...a); } + // ── watch-to-earn videos: escape-proof player, server-clock reward ── + const vidState = { token: null, secs: 0, maxSeen: 0, done: false, credited: false }; + async function loadVideoStatus() { + try { + const st = await (await fetch('/api/my/videos')).json(); + if (st.error) return; + $('vidProgress').textContent = 'today: ' + (st.status.count || 0) + ' / ' + st.status.cap + ' videos watched'; + if (st.status.left <= 0) { + $('vidBox').innerHTML = 'That is today\'s video set. Come back tomorrow.'; + $('vidStartBtn').hidden = true; + return; + } + $('vidStartBtn').hidden = false; + } catch (e) {} + } + async function loadVideoAd() { + let r = null; + try { r = await (await fetch('/api/my/videos')).json(); } catch (e) {} + if (!r || !r.ad) { + $('vidBox').innerHTML = '' + (r && r.status && r.status.left <= 0 + ? 'That is today\'s video set. Come back tomorrow.' + : 'No member videos are live right now. Check back when a campaign is running.') + ''; + $('vidWrap').hidden = true; + return; + } + const ad = r.ad; + vidState.token = r.token; vidState.secs = ad.watchSecs; vidState.maxSeen = 0; vidState.done = false; vidState.credited = false; + $('vidBox').hidden = true; + $('vidWrap').hidden = false; + $('vidCta').hidden = true; + $('vidHint').textContent = ad.title ? 'Now playing: ' + ad.title : ''; + const p = $('vidPlayer'); + p.src = ad.videoUrl; + p.currentTime = 0; + // escape-proof: no seeking past what's been watched; track max reached + p.onseeking = () => { if (p.currentTime > vidState.maxSeen + 0.5) p.currentTime = vidState.maxSeen; }; + p.ontimeupdate = () => { + if (p.currentTime > vidState.maxSeen) vidState.maxSeen = p.currentTime; + const left = Math.max(0, Math.ceil(vidState.secs - vidState.maxSeen)); + $('vidTimer').textContent = left > 0 ? 'Watch ' + left + 's more to earn' : 'Watch time met — finishing…'; + if (!vidState.done && vidState.maxSeen >= vidState.secs) { vidState.done = true; completeVideo(ad); } + }; + $('vidStartBtn').textContent = 'Playing…'; + $('vidStartBtn').disabled = true; + try { await p.play(); } catch (e) { $('vidStartBtn').disabled = false; $('vidStartBtn').textContent = 'Tap to play'; } + $('vidCta').href = ad.ctaUrl; + $('vidCta').textContent = ad.ctaLabel || 'Learn more'; + $('vidCta').hidden = false; + } + async function completeVideo(ad) { + if (vidState.credited) return; + vidState.credited = true; + try { + const r = await (await fetch('/api/my/videowatch', { method: 'POST', + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: vidState.token }) })).json(); + if (r.error) { IAP.status(r.error, 'bad'); $('vidHint').textContent = r.error; } + else if (r.credited) { IAP.status('+' + r.credited + ' credits earned for watching.', 'ok'); $('vidHint').textContent = '+' + r.credited + ' credits earned. Load the next one.'; loadDashboard(); } + else $('vidHint').textContent = 'That video just ran out of budget — load another.'; + $('vidProgress').textContent = 'today: ' + ((r.status && r.status.count) || 0) + ' / ' + ((r.status && r.status.cap) || 0) + ' videos watched'; + } catch (e) { IAP.status('Could not confirm that watch. Try the next one.', 'bad'); } + $('vidStartBtn').disabled = false; + $('vidStartBtn').textContent = 'Next video'; + } + $('vidStartBtn').addEventListener('click', () => loadVideoAd()); + // ── solo-ads inbox: list, read view, dwell-gated read reward ── let ibTimer = null; function setInboxBadge(n) { @@ -447,13 +546,15 @@ // Earn credits sub-tabs: Watch ads | Inbox let earnSub = 'watch'; function setEarnSub(which) { - earnSub = which === 'inbox' ? 'inbox' : 'watch'; - const w = $('earn-watch'), i = $('earn-inbox'); + earnSub = ['inbox', 'videos'].includes(which) ? which : 'watch'; + const w = $('earn-watch'), i = $('earn-inbox'), v = $('earn-videos'); if (w) w.hidden = earnSub !== 'watch'; if (i) i.hidden = earnSub !== 'inbox'; + if (v) v.hidden = earnSub !== 'videos'; document.querySelectorAll('.subtabs [data-earn]').forEach(b => b.classList.toggle('on', b.dataset.earn === earnSub)); if (earnSub === 'inbox') loadInbox(); + else if (earnSub === 'videos') loadVideoStatus(); else earnRefresh(); } document.querySelectorAll('.subtabs [data-earn]').forEach(b => diff --git a/public/contract.html b/public/contract.html index 911a3fa..4c788e5 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 b996ea1..ecc45d4 100644 --- a/public/index.html +++ b/public/index.html @@ -5,7 +5,7 @@ InstantAdPay: advertise and earn, locked in code - + @@ -410,9 +410,9 @@ - - - - + + + + diff --git a/public/ledger.html b/public/ledger.html index cc51b11..d01099a 100644 --- a/public/ledger.html +++ b/public/ledger.html @@ -5,7 +5,7 @@ Live ledger | InstantAdPay - +
@@ -25,8 +25,8 @@
- - - + + + diff --git a/public/my.html b/public/my.html index 3b1afe8..ab79ecc 100644 --- a/public/my.html +++ b/public/my.html @@ -4,7 +4,7 @@ Member area | InstantAdPay - + @@ -245,6 +245,7 @@ +

@@ -287,6 +288,17 @@ placeholder="Call-to-action button label (default: Learn more) — the button opens your target URL">

+ @@ -294,6 +306,7 @@ + + - - - - + + + + diff --git a/public/tx.html b/public/tx.html index 1a7de7b..3dbca13 100644 --- a/public/tx.html +++ b/public/tx.html @@ -4,7 +4,7 @@ Transaction | InstantAdPay - + @@ -33,7 +33,7 @@

← Back to the live ledger · Read the contract review

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