From 2503b1e1a297263e04a69fdca93be08dcf794213 Mon Sep 17 00:00:00 2001 From: martbost Date: Wed, 23 Sep 2026 11:41:54 -0500 Subject: [PATCH] The board says the one condition out loud The card read "when someone new opens PolHunter" and listed everyone a hunter had brought as hunting or earning, including members who joined InstantAdPay before the mission existed and can never pay a bounty. So the board promised an earning the payout code would refuse. It now says what actually qualifies: someone who has never had an InstantAdPay account. People who were already members are listed as exactly that, the match window is not claimed for them, and the counts show how many of the brought are genuinely new. Co-Authored-By: Claude Opus 5 --- lib/referrals.js | 13 +- public/app.html | 104 ++++++------- public/app.js | 373 ++++++++++++++++++++++++----------------------- public/style.css | 4 + 4 files changed, 254 insertions(+), 240 deletions(-) diff --git a/lib/referrals.js b/lib/referrals.js index 7c1205e..c10d755 100644 --- a/lib/referrals.js +++ b/lib/referrals.js @@ -162,17 +162,25 @@ function state(member) { const me = hunter(id) || { memberId: id }; const rows = refRows().filter(p => Number(p.memberId) === id); const today = ctDay(); + // the same floor onFind pays by, so the card can never promise an earning it will not make + const floor = Date.parse(String(c.refNewAfter || '')); + const gateOk = Number.isFinite(floor) && floor > 0; const mine = broughtBy(id).map(x => { const theirFinds = store.read('payouts', []).filter(p => Number(p.memberId) === Number(x.memberId) && !p.ref && !p.prize && p.status !== 'failed'); const paid = bountyPaidFor(x.memberId); + const isNew = !!(gateOk && x.joinedAt && Number(x.joinedAt) >= floor); return { name: x.username || ('#' + x.memberId), // when they actually joined InstantAdPay, not when this directory first noticed them: // the backfill stamped firstSeen at seeding time and made everyone look brought-in today joinedAt: x.joinedAt || x.firstSeen || null, finds: theirFinds.length, - state: paid ? 'earning' : theirFinds.length ? 'hunting' : 'signed up, no find yet', - stillNew: x.joinedAt ? (Date.now() - Number(x.joinedAt)) / 86400000 <= Number(c.refMatchDays) : false, + // an account that predates the mission was already a member: say so plainly rather than + // show them as 'hunting' next to a bounty that will never arrive + isNew, + state: !isNew ? 'already a member before the mission, earns nothing' + : paid ? 'earning' : theirFinds.length ? 'hunting' : 'signed up, no find yet', + stillNew: isNew && (Date.now() - Number(x.joinedAt)) / 86400000 <= Number(c.refMatchDays), }; }); return { @@ -182,6 +190,7 @@ function state(member) { matchPct: Number(c.refMatchPct), matchDays: Number(c.refMatchDays), brought: mine.length, + broughtNew: mine.filter(x => x.isNew).length, // the only ones that can ever pay broughtToday: mine.filter(x => x.joinedAt && ctDay(x.joinedAt) === today).length, earnedPol: round4(rows.reduce((n, p) => n + p.pol, 0)), earnedTodayPol: round4(rows.filter(p => p.day === today).reduce((n, p) => n + p.pol, 0)), diff --git a/public/app.html b/public/app.html index bc2bbfa..866300d 100644 --- a/public/app.html +++ b/public/app.html @@ -1,52 +1,52 @@ - - - - - -Your board · PolHunter - - - - -
-
- PolHunter - -
-
- -
-

Your missions

-
Each find pays a random drip. Your codes are yours alone.
-
-

Loading your board…

-
- -
-

Your badges

-
Earned from your finds. Share one and the card carries your name and your link.
-
-
- - - -
-

Your drips

-
-
- -
-

Share your hunt

-
Your link puts sign-ups on your InstantAdPay line. Post a find, or just the link.
-
- -
-

Banners, swipes and the teaser video are on the promo tools page, already carrying your link.

-
- -
Rewards are for completed missions, not income. The daily pool is limited; when it is spent, claims close until midnight Central. Cryptocurrency involves risk of loss.
-
- - - - + + + + + +Your board · PolHunter + + + + +
+
+ PolHunter + +
+
+ +
+

Your missions

+
Each find pays a random drip. Your codes are yours alone.
+
+

Loading your board…

+
+ +
+

Your badges

+
Earned from your finds. Share one and the card carries your name and your link.
+
+
+ + + +
+

Your drips

+
+
+ +
+

Share your hunt

+
Your link puts sign-ups on your InstantAdPay line. Post a find, or just the link.
+
+ +
+

Banners, swipes and the teaser video are on the promo tools page, already carrying your link.

+
+ +
Rewards are for completed missions, not income. The daily pool is limited; when it is spent, claims close until midnight Central. Cryptocurrency involves risk of loss.
+
+ + + + diff --git a/public/app.js b/public/app.js index c37c6a6..dfa91d0 100644 --- a/public/app.js +++ b/public/app.js @@ -1,186 +1,187 @@ -// The hunter's board: missions in their own order, a live timer while they are on the site, a -// code box when they are back. One open mission at a time. -(() => { - const $ = id => document.getElementById(id); - const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); - const api = async (path, body) => { const r = await fetch(path, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}); const j = await r.json().catch(() => ({})); if (r.status === 401) location.href = '/?signin=1'; return j; }; - let board = null, open = null; // open: { missionId, token, url, dwell, started } - // toasts: small, stacked, gone in a few seconds - function toast(msg, kind, link) { - let host = $('toasts'); if (!host) { host = document.createElement('div'); host.id = 'toasts'; document.body.appendChild(host); } - const t = document.createElement('div'); t.className = 'toast ' + (kind || ''); t.innerHTML = msg + (link ? ' ' + esc(link.text) + '' : ''); - host.appendChild(t); requestAnimationFrame(() => t.classList.add('in')); - setTimeout(() => { t.classList.remove('in'); setTimeout(() => t.remove(), 400); }, kind === 'big' ? 9000 : 6000); - } - const seenPaid = new Set(); let seenLedger = null, paidPrimed = false; - // a drip that flips to paid while the board is open gets a toast with its proof. The first pass only - // records what is already paid: history is on the ledger, not in the toasts (Marty, 2026-09-21). - function watchPaid() { - for (const d of (board.drips || [])) if (d.status === 'paid' && d.tx) { - if (paidPrimed && !seenPaid.has(d.id)) toast('\u{1F4B0} ' + d.pol + ' POL landed in your wallet.', 'ok', { href: explorer + '/tx/' + d.tx, text: 'verify \u2197' }); - seenPaid.add(d.id); - } - paidPrimed = true; - } - // other hunters' finds, as they happen - async function watchLedger() { - try { const r = await (await fetch('/api/ledger')).json(); const rows = r.recent || []; - if (seenLedger) { const fresh = rows.filter(x => !seenLedger.has(x.tx)).slice(0, 2); for (const x of fresh) if (!board || !board.me || x.who !== (board.me.username ? '@' + board.me.username : '#' + board.me.memberId)) toast('\u{1F3AF} ' + esc(x.who) + ' just found ' + x.pol + ' POL on ' + esc(x.site) + '.', ''); } - seenLedger = new Set(rows.map(x => x.tx)); - } catch (e) {} - } - setInterval(watchLedger, 45000); setInterval(async () => { if (board && (board.drips || []).some(d => d.status === 'due' || d.status === 'sent')) { await load(); } }, 30000); - - function card(m) { - const isOpen = open && open.missionId === m.id; - const r = m.reward || {}; - return '
' - + '' + (m.done ? 'Done today' : esc(m.site)) + '' - + '

' + esc(m.name) + '

' + esc(m.brief) + '

' - + '

' + r.minPol + ' to ' + r.maxPol + ' POL · ' + m.dwell + 's on the site

' - + (m.done ? '' : (board.pool && board.pool.spent && !isOpen) ? '
Closed until midnight Central
' : (board.daily && !board.daily.left && !isOpen) ? '
Back tomorrow
' : isOpen - ? '
Your code appears on the site after ' + m.dwell + 's. Keep that tab open.
' - + '
Six characters. On the site, tap the code to copy it, then paste it here.
' - + '
' - + '

Open the site again ↗

' - : '
') - + '
'; - } - // The best-paid thing on the board, and the reason it sits first: sharing used to live on a page - // nobody opened, so nobody shared (Marty, 2026-09-23). - function referralCard() { - const rf = board.referral; if (!rf || !rf.on) return ''; - const link = (board.share && board.share.link) || ''; - const today = rf.broughtToday, total = rf.brought; - const people = (rf.people || []).slice(0, 5).map(p => - '
' + esc(p.name) + '' + esc(p.state) + '
').join(''); - return '
' - + 'Best paid' - + '

🤝 Bring a hunter

' - + '

Send your link. When someone new opens PolHunter and finds their first code, you get ' + rf.bountyPol + ' POL. ' - + 'After that you earn ' + rf.matchPct + '% on top of everything they find for their first ' + rf.matchDays + ' days. ' - + 'It comes out of our pool, never theirs.

' - + (rf.firstBonusPol ? '

\u{1F947} ' + rf.firstBonusPol + ' POL bonus, still unclaimed. It goes to the first hunter who brings someone all the way through. Nobody has yet.

' : '') - + '
' - + '

' + today + ' brought today ' - + '· ' + total + ' all time · ' + rf.earnedPol + ' POL earned

' - + (people ? '
' + people + '
' : '

Nobody yet. One message is all it takes.

') - + '
' - + '
'; - } - // the explainer plays in place, the way the teaser does on the home page - function wireRefVideo() { - const b = $('refPlay'); if (!b) return; - b.addEventListener('click', () => { - const host = b.parentElement; - const v = document.createElement('video'); - v.controls = true; v.autoplay = true; v.playsInline = true; v.className = 'ref-video'; - v.poster = 'https://coolify-saasytop.nyc3.digitaloceanspaces.com/training/polhunter-bring-a-hunter.jpg?v=20260923a'; - v.src = 'https://coolify-saasytop.nyc3.digitaloceanspaces.com/training/polhunter-bring-a-hunter.mp4?v=20260923a'; - host.innerHTML = ''; host.appendChild(v); - v.play().catch(() => {}); - }); - } - const IAP = 'https://instantadpay.com'; - function until(ms) { const s = Math.max(0, Math.round((ms - Date.now()) / 60000)); const h = Math.floor(s / 60), m = s % 60; return h ? h + 'h ' + m + 'm' : m + 'm'; } - // no wallet on the InstantAdPay account yet: the three steps, in place of the missions - function onboarding() { - return '
One thing before your first hunt. Drips are paid to the wallet on your InstantAdPay account, and yours has none yet.
' - + '
' - + '
Create your free InstantAdPay account. Already done: you are signed in as ' + esc(board.me.username ? '@' + board.me.username : '#' + board.me.memberId) + '.
' - + '
Link your wallet. In your InstantAdPay dashboard open Wallet, press connect wallet and approve. Any Polygon wallet works (MetaMask, Trust, Phantom with Polygon on).
' - + '
Come back through the door. Return to your dashboard and press Open my board again. Your missions unlock the moment a wallet is on the account.
' - + '
'; - } - // badge cards: art, lock state, share - function badgeStrip() { - const el = $('badgeStrip'); if (!el || !board.badgeCards) return; - el.innerHTML = board.badgeCards.map(b => '
' + esc(b.name) + '
' + b.icon + ' ' + esc(b.name) + '
' + esc(b.why) + '
' + (b.earned ? '' : '
\u{1F512} locked
') + '
').join(''); - el.querySelectorAll('[data-share]').forEach(x => x.addEventListener('click', () => shareBadge(x.dataset.share, false))); - } - function shareBadge(id, unlocked) { - const b = (board.badgeCards || []).find(x => x.id === id); if (!b || !b.earned) return; - const text = (unlocked ? 'Achievement unlocked on PolHunter: ' : 'I earned ') + b.name + ' (' + b.why + ') on PolHunter. Visit a site, find your code, get a drip of POL to your wallet. ' + b.page; - const T = encodeURIComponent(text), U = encodeURIComponent(b.page); - $('shareBody').innerHTML = '' - + '' - + '' - + '

The card carries your name; the link carries your referral.

'; - $('shareModal').hidden = false; - $('shareCopy').onclick = async () => { try { await navigator.clipboard.writeText(b.page); } catch (e) {} $('shareCopy').textContent = 'Copied'; setTimeout(() => { $('shareCopy').textContent = 'Copy link'; }, 1200); }; - } - document.addEventListener('click', e => { if (e.target.id === 'shareClose' || e.target.id === 'shareModal') $('shareModal').hidden = true; }); - function render() { - badgeStrip(); - $('who').innerHTML = ' ' + esc(board.me.username ? '@' + board.me.username : '#' + board.me.memberId) + (board.me.wallet ? '' : ' · no wallet linked'); - const r = board.missions[0] && board.missions[0].reward; - if (r) $('rangeLine').textContent = 'Each find pays a random drip between ' + r.minPol + ' and ' + r.maxPol + ' POL. Your codes are yours alone.'; - const pool = board.pool || {}; - if (!board.me.wallet) $('missions').innerHTML = onboarding(); - else $('missions').innerHTML = (pool.spent ? '
Today\u2019s POL pool is spent. No more claims today. Claims reopen at midnight Central' + (pool.resetsAt ? ', in ' + until(pool.resetsAt) : '') + '.
' : '') - + (board.missions.length ? board.missions.map(card).join('') : '

No missions are open right now. Check back soon.

'); - if ($('refSlot')) $('refSlot').innerHTML = board.me.wallet ? referralCard() : ''; - wireRefVideo(); - const rc = $('refCopy'); - if (rc) rc.addEventListener('click', async () => { - const v = $('refLink').value; - try { await navigator.clipboard.writeText(v); } catch (e) { $('refLink').select(); document.execCommand('copy'); } - rc.textContent = 'Copied'; setTimeout(() => { rc.textContent = 'Copy'; }, 1600); - }); - // standing: badges and rank - const rk = board.rank || {}; const line = []; - if (rk.week) line.push('This week #' + rk.week.rank + ' of ' + rk.week.of); if (rk.all) line.push('All time #' + rk.all.rank + ' of ' + rk.all.of); - $('standing').innerHTML = (board.badges && board.badges.length ? '' + board.badges.map(b => '' + b.icon + ' ' + esc(b.name) + '').join('') + '' : 'No badges yet. Your first find earns one.') - + (line.length ? '' + line.join(' \u00b7 ') + ' \u00b7 leaderboard' : 'leaderboard'); - // share panel - if (board.share) { - const link = board.share.link; $('mylink').value = link; - const best = (board.drips || []).filter(d => d.status === 'paid').sort((a, b) => b.pol - a.pol)[0]; // brag about the biggest find - const text = best ? 'I just found ' + best.pol + ' POL on ' + best.site + ' with PolHunter. Visit a site, find your code, get a drip of POL to your wallet, on chain. ' + link - : 'PolHunter: visit one of our sites, find your code on the page, and a random drip of POL lands in your wallet. On chain, with a link to prove it. ' + link; - if (!$('sharetext').dataset.touched) $('sharetext').value = text; - const L = encodeURIComponent(link), T = () => encodeURIComponent($('sharetext').value); - $('sharebtns').innerHTML = 'XTelegramFacebookWhatsApp'; - const wire = () => { $('sharebtns').querySelector('[data-x]').href = 'https://twitter.com/intent/tweet?text=' + T(); $('sharebtns').querySelector('[data-tg]').href = 'https://t.me/share/url?url=' + L + '&text=' + encodeURIComponent($('sharetext').value.replace(link, '').trim()); $('sharebtns').querySelector('[data-wa]').href = 'https://wa.me/?text=' + T(); }; - wire(); $('sharetext').oninput = () => { $('sharetext').dataset.touched = '1'; wire(); }; - const copy = async (el, btn) => { try { await navigator.clipboard.writeText(el.value); } catch (e) { el.select(); document.execCommand('copy'); } const t = btn.textContent; btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = t; }, 1200); }; - $('copylink').onclick = () => copy($('mylink'), $('copylink')); $('copytext').onclick = () => copy($('sharetext'), $('copytext')); - } - watchPaid(); if (seenLedger === null) watchLedger(); - const dl = board.daily; if (dl && $('rangeLine')) $('rangeLine').innerHTML = ($('rangeLine').textContent.replace(/\s*·\s*You have.*$/, '')) + ' \u00b7 ' + (dl.left ? 'You have ' + dl.left + ' of ' + dl.limit + ' missions left today.' : 'That is your ' + dl.limit + ' for today. Fresh missions at midnight Central.'); - $('mine').innerHTML = board.drips.length ? board.drips.map(d => '
' + esc(d.site) + '' + ({ paid: 'paid', sent: 'sending', due: 'paying next', queued: 'queued for the next pool', failed: 'held, being looked at' }[d.status] || d.status) + '+' + d.pol + ' POL' + (d.tx ? 'verify ↗' : '') + '
').join('') - : '
Nothing yet. Your first find goes here.
'; - document.querySelectorAll('[data-start]').forEach(b => b.addEventListener('click', () => start(b.dataset.start))); - const s = $('submit'); if (s) { s.addEventListener('click', submit); $('code').addEventListener('keydown', e => { if (e.key === 'Enter') submit(); }); tick(); } - } - let explorer = 'https://polygonscan.com'; - async function load() { board = await api('/api/my/board'); render(); } - async function start(id) { - const r = await api('/api/my/start', { missionId: id }); - if (r.error) { toast(esc(r.error), 'bad'); if (r.spent || r.dailyDone) await load(); return; } - open = { missionId: id, token: r.token, url: r.url, dwell: r.dwell, started: Date.now(), expires: r.expires }; - window.open(r.url, '_blank', 'noopener'); - render(); - } - function tick() { - const el = $('timer'); if (!el || !open) return; - const left = Math.max(0, open.dwell - Math.floor((Date.now() - open.started) / 1000)); - el.innerHTML = left > 0 ? 'Your code appears on the site in about ' + left + 's. Stay on the site tab until it shows (switching away pauses it), then come back and paste it here.' : 'Your code should be showing on the site now. If it is not, switch to the site tab and give it a few seconds. Then paste it here.'; - if (open.expires && Date.now() > open.expires) { el.innerHTML = 'This attempt timed out. Start the mission again.'; return; } - setTimeout(tick, 1000); - } - async function submit() { - const code = $('code').value.trim(); if (!code) return; - $('submit').disabled = true; - const r = await api('/api/my/submit', { token: open.token, code }); - const m = $('msg'); m.className = 'msg ' + (r.error ? 'bad' : 'ok'); m.textContent = r.error || r.message; - if (r.error) toast(esc(r.error), 'bad'); else toast('\u{1F389} Found it! ' + r.pol + ' POL is on its way to your wallet.', 'big'); - if (!r.error) { const a = document.createElement('a'); a.href = '#share'; a.textContent = ' Share this find \u2197'; a.style.marginLeft = '6px'; m.appendChild(a); delete $('sharetext').dataset.touched; } - // an achievement unlocked by this find: celebrate it once the board has reloaded - if (!r.error && r.unlocked && r.unlocked.length) { const id = r.unlocked[0]; toast('\u{1F3C6} Achievement unlocked!', 'big'); setTimeout(async () => { await load(); shareBadge(id, true); }, 1200); } - $('submit').disabled = false; - if (!r.error || r.spent) { open = null; setTimeout(load, 900); } - } - fetch('/api/config').then(r => r.json()).then(c => { explorer = c.explorer || explorer; }).catch(() => {}); - load(); -})(); +// The hunter's board: missions in their own order, a live timer while they are on the site, a +// code box when they are back. One open mission at a time. +(() => { + const $ = id => document.getElementById(id); + const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); + const api = async (path, body) => { const r = await fetch(path, body ? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) } : {}); const j = await r.json().catch(() => ({})); if (r.status === 401) location.href = '/?signin=1'; return j; }; + let board = null, open = null; // open: { missionId, token, url, dwell, started } + // toasts: small, stacked, gone in a few seconds + function toast(msg, kind, link) { + let host = $('toasts'); if (!host) { host = document.createElement('div'); host.id = 'toasts'; document.body.appendChild(host); } + const t = document.createElement('div'); t.className = 'toast ' + (kind || ''); t.innerHTML = msg + (link ? ' ' + esc(link.text) + '' : ''); + host.appendChild(t); requestAnimationFrame(() => t.classList.add('in')); + setTimeout(() => { t.classList.remove('in'); setTimeout(() => t.remove(), 400); }, kind === 'big' ? 9000 : 6000); + } + const seenPaid = new Set(); let seenLedger = null, paidPrimed = false; + // a drip that flips to paid while the board is open gets a toast with its proof. The first pass only + // records what is already paid: history is on the ledger, not in the toasts (Marty, 2026-09-21). + function watchPaid() { + for (const d of (board.drips || [])) if (d.status === 'paid' && d.tx) { + if (paidPrimed && !seenPaid.has(d.id)) toast('\u{1F4B0} ' + d.pol + ' POL landed in your wallet.', 'ok', { href: explorer + '/tx/' + d.tx, text: 'verify \u2197' }); + seenPaid.add(d.id); + } + paidPrimed = true; + } + // other hunters' finds, as they happen + async function watchLedger() { + try { const r = await (await fetch('/api/ledger')).json(); const rows = r.recent || []; + if (seenLedger) { const fresh = rows.filter(x => !seenLedger.has(x.tx)).slice(0, 2); for (const x of fresh) if (!board || !board.me || x.who !== (board.me.username ? '@' + board.me.username : '#' + board.me.memberId)) toast('\u{1F3AF} ' + esc(x.who) + ' just found ' + x.pol + ' POL on ' + esc(x.site) + '.', ''); } + seenLedger = new Set(rows.map(x => x.tx)); + } catch (e) {} + } + setInterval(watchLedger, 45000); setInterval(async () => { if (board && (board.drips || []).some(d => d.status === 'due' || d.status === 'sent')) { await load(); } }, 30000); + + function card(m) { + const isOpen = open && open.missionId === m.id; + const r = m.reward || {}; + return '
' + + '' + (m.done ? 'Done today' : esc(m.site)) + '' + + '

' + esc(m.name) + '

' + esc(m.brief) + '

' + + '

' + r.minPol + ' to ' + r.maxPol + ' POL · ' + m.dwell + 's on the site

' + + (m.done ? '' : (board.pool && board.pool.spent && !isOpen) ? '
Closed until midnight Central
' : (board.daily && !board.daily.left && !isOpen) ? '
Back tomorrow
' : isOpen + ? '
Your code appears on the site after ' + m.dwell + 's. Keep that tab open.
' + + '
Six characters. On the site, tap the code to copy it, then paste it here.
' + + '
' + + '

Open the site again ↗

' + : '
') + + '
'; + } + // The best-paid thing on the board, and the reason it sits first: sharing used to live on a page + // nobody opened, so nobody shared (Marty, 2026-09-23). + function referralCard() { + const rf = board.referral; if (!rf || !rf.on) return ''; + const link = (board.share && board.share.link) || ''; + const today = rf.broughtToday, total = rf.brought; + const people = (rf.people || []).slice(0, 5).map(p => + '
' + esc(p.name) + '' + esc(p.state) + '
').join(''); + return '
' + + 'Best paid' + + '

🤝 Bring a hunter

' + + '

Send your link. When someone who has never had an InstantAdPay account joins on it, opens PolHunter and finds their first code, you get ' + rf.bountyPol + ' POL. ' + + 'After that you earn ' + rf.matchPct + '% on top of everything they find for their first ' + rf.matchDays + ' days. ' + + 'It comes out of our pool, never theirs.

' + + '

This pays for new people only. Someone who was already a member before this mission started earns nothing, however much they hunt.

' + + (rf.firstBonusPol ? '

\u{1F947} ' + rf.firstBonusPol + ' POL bonus, still unclaimed. It goes to the first hunter who brings someone all the way through. Nobody has yet.

' : '') + + '
' + + '

' + today + ' brought today ' + + '· ' + total + ' all time, ' + (rf.broughtNew || 0) + ' of them new · ' + rf.earnedPol + ' POL earned

' + + (people ? '
' + people + '
' : '

Nobody yet. One message is all it takes.

') + + '
' + + '
'; + } + // the explainer plays in place, the way the teaser does on the home page + function wireRefVideo() { + const b = $('refPlay'); if (!b) return; + b.addEventListener('click', () => { + const host = b.parentElement; + const v = document.createElement('video'); + v.controls = true; v.autoplay = true; v.playsInline = true; v.className = 'ref-video'; + v.poster = 'https://coolify-saasytop.nyc3.digitaloceanspaces.com/training/polhunter-bring-a-hunter.jpg?v=20260923a'; + v.src = 'https://coolify-saasytop.nyc3.digitaloceanspaces.com/training/polhunter-bring-a-hunter.mp4?v=20260923a'; + host.innerHTML = ''; host.appendChild(v); + v.play().catch(() => {}); + }); + } + const IAP = 'https://instantadpay.com'; + function until(ms) { const s = Math.max(0, Math.round((ms - Date.now()) / 60000)); const h = Math.floor(s / 60), m = s % 60; return h ? h + 'h ' + m + 'm' : m + 'm'; } + // no wallet on the InstantAdPay account yet: the three steps, in place of the missions + function onboarding() { + return '
One thing before your first hunt. Drips are paid to the wallet on your InstantAdPay account, and yours has none yet.
' + + '
' + + '
Create your free InstantAdPay account. Already done: you are signed in as ' + esc(board.me.username ? '@' + board.me.username : '#' + board.me.memberId) + '.
' + + '
Link your wallet. In your InstantAdPay dashboard open Wallet, press connect wallet and approve. Any Polygon wallet works (MetaMask, Trust, Phantom with Polygon on).
' + + '
Come back through the door. Return to your dashboard and press Open my board again. Your missions unlock the moment a wallet is on the account.
' + + '
'; + } + // badge cards: art, lock state, share + function badgeStrip() { + const el = $('badgeStrip'); if (!el || !board.badgeCards) return; + el.innerHTML = board.badgeCards.map(b => '
' + esc(b.name) + '
' + b.icon + ' ' + esc(b.name) + '
' + esc(b.why) + '
' + (b.earned ? '' : '
\u{1F512} locked
') + '
').join(''); + el.querySelectorAll('[data-share]').forEach(x => x.addEventListener('click', () => shareBadge(x.dataset.share, false))); + } + function shareBadge(id, unlocked) { + const b = (board.badgeCards || []).find(x => x.id === id); if (!b || !b.earned) return; + const text = (unlocked ? 'Achievement unlocked on PolHunter: ' : 'I earned ') + b.name + ' (' + b.why + ') on PolHunter. Visit a site, find your code, get a drip of POL to your wallet. ' + b.page; + const T = encodeURIComponent(text), U = encodeURIComponent(b.page); + $('shareBody').innerHTML = '' + + '' + + '' + + '

The card carries your name; the link carries your referral.

'; + $('shareModal').hidden = false; + $('shareCopy').onclick = async () => { try { await navigator.clipboard.writeText(b.page); } catch (e) {} $('shareCopy').textContent = 'Copied'; setTimeout(() => { $('shareCopy').textContent = 'Copy link'; }, 1200); }; + } + document.addEventListener('click', e => { if (e.target.id === 'shareClose' || e.target.id === 'shareModal') $('shareModal').hidden = true; }); + function render() { + badgeStrip(); + $('who').innerHTML = ' ' + esc(board.me.username ? '@' + board.me.username : '#' + board.me.memberId) + (board.me.wallet ? '' : ' · no wallet linked'); + const r = board.missions[0] && board.missions[0].reward; + if (r) $('rangeLine').textContent = 'Each find pays a random drip between ' + r.minPol + ' and ' + r.maxPol + ' POL. Your codes are yours alone.'; + const pool = board.pool || {}; + if (!board.me.wallet) $('missions').innerHTML = onboarding(); + else $('missions').innerHTML = (pool.spent ? '
Today\u2019s POL pool is spent. No more claims today. Claims reopen at midnight Central' + (pool.resetsAt ? ', in ' + until(pool.resetsAt) : '') + '.
' : '') + + (board.missions.length ? board.missions.map(card).join('') : '

No missions are open right now. Check back soon.

'); + if ($('refSlot')) $('refSlot').innerHTML = board.me.wallet ? referralCard() : ''; + wireRefVideo(); + const rc = $('refCopy'); + if (rc) rc.addEventListener('click', async () => { + const v = $('refLink').value; + try { await navigator.clipboard.writeText(v); } catch (e) { $('refLink').select(); document.execCommand('copy'); } + rc.textContent = 'Copied'; setTimeout(() => { rc.textContent = 'Copy'; }, 1600); + }); + // standing: badges and rank + const rk = board.rank || {}; const line = []; + if (rk.week) line.push('This week #' + rk.week.rank + ' of ' + rk.week.of); if (rk.all) line.push('All time #' + rk.all.rank + ' of ' + rk.all.of); + $('standing').innerHTML = (board.badges && board.badges.length ? '' + board.badges.map(b => '' + b.icon + ' ' + esc(b.name) + '').join('') + '' : 'No badges yet. Your first find earns one.') + + (line.length ? '' + line.join(' \u00b7 ') + ' \u00b7 leaderboard' : 'leaderboard'); + // share panel + if (board.share) { + const link = board.share.link; $('mylink').value = link; + const best = (board.drips || []).filter(d => d.status === 'paid').sort((a, b) => b.pol - a.pol)[0]; // brag about the biggest find + const text = best ? 'I just found ' + best.pol + ' POL on ' + best.site + ' with PolHunter. Visit a site, find your code, get a drip of POL to your wallet, on chain. ' + link + : 'PolHunter: visit one of our sites, find your code on the page, and a random drip of POL lands in your wallet. On chain, with a link to prove it. ' + link; + if (!$('sharetext').dataset.touched) $('sharetext').value = text; + const L = encodeURIComponent(link), T = () => encodeURIComponent($('sharetext').value); + $('sharebtns').innerHTML = 'XTelegramFacebookWhatsApp'; + const wire = () => { $('sharebtns').querySelector('[data-x]').href = 'https://twitter.com/intent/tweet?text=' + T(); $('sharebtns').querySelector('[data-tg]').href = 'https://t.me/share/url?url=' + L + '&text=' + encodeURIComponent($('sharetext').value.replace(link, '').trim()); $('sharebtns').querySelector('[data-wa]').href = 'https://wa.me/?text=' + T(); }; + wire(); $('sharetext').oninput = () => { $('sharetext').dataset.touched = '1'; wire(); }; + const copy = async (el, btn) => { try { await navigator.clipboard.writeText(el.value); } catch (e) { el.select(); document.execCommand('copy'); } const t = btn.textContent; btn.textContent = 'Copied'; setTimeout(() => { btn.textContent = t; }, 1200); }; + $('copylink').onclick = () => copy($('mylink'), $('copylink')); $('copytext').onclick = () => copy($('sharetext'), $('copytext')); + } + watchPaid(); if (seenLedger === null) watchLedger(); + const dl = board.daily; if (dl && $('rangeLine')) $('rangeLine').innerHTML = ($('rangeLine').textContent.replace(/\s*·\s*You have.*$/, '')) + ' \u00b7 ' + (dl.left ? 'You have ' + dl.left + ' of ' + dl.limit + ' missions left today.' : 'That is your ' + dl.limit + ' for today. Fresh missions at midnight Central.'); + $('mine').innerHTML = board.drips.length ? board.drips.map(d => '
' + esc(d.site) + '' + ({ paid: 'paid', sent: 'sending', due: 'paying next', queued: 'queued for the next pool', failed: 'held, being looked at' }[d.status] || d.status) + '+' + d.pol + ' POL' + (d.tx ? 'verify ↗' : '') + '
').join('') + : '
Nothing yet. Your first find goes here.
'; + document.querySelectorAll('[data-start]').forEach(b => b.addEventListener('click', () => start(b.dataset.start))); + const s = $('submit'); if (s) { s.addEventListener('click', submit); $('code').addEventListener('keydown', e => { if (e.key === 'Enter') submit(); }); tick(); } + } + let explorer = 'https://polygonscan.com'; + async function load() { board = await api('/api/my/board'); render(); } + async function start(id) { + const r = await api('/api/my/start', { missionId: id }); + if (r.error) { toast(esc(r.error), 'bad'); if (r.spent || r.dailyDone) await load(); return; } + open = { missionId: id, token: r.token, url: r.url, dwell: r.dwell, started: Date.now(), expires: r.expires }; + window.open(r.url, '_blank', 'noopener'); + render(); + } + function tick() { + const el = $('timer'); if (!el || !open) return; + const left = Math.max(0, open.dwell - Math.floor((Date.now() - open.started) / 1000)); + el.innerHTML = left > 0 ? 'Your code appears on the site in about ' + left + 's. Stay on the site tab until it shows (switching away pauses it), then come back and paste it here.' : 'Your code should be showing on the site now. If it is not, switch to the site tab and give it a few seconds. Then paste it here.'; + if (open.expires && Date.now() > open.expires) { el.innerHTML = 'This attempt timed out. Start the mission again.'; return; } + setTimeout(tick, 1000); + } + async function submit() { + const code = $('code').value.trim(); if (!code) return; + $('submit').disabled = true; + const r = await api('/api/my/submit', { token: open.token, code }); + const m = $('msg'); m.className = 'msg ' + (r.error ? 'bad' : 'ok'); m.textContent = r.error || r.message; + if (r.error) toast(esc(r.error), 'bad'); else toast('\u{1F389} Found it! ' + r.pol + ' POL is on its way to your wallet.', 'big'); + if (!r.error) { const a = document.createElement('a'); a.href = '#share'; a.textContent = ' Share this find \u2197'; a.style.marginLeft = '6px'; m.appendChild(a); delete $('sharetext').dataset.touched; } + // an achievement unlocked by this find: celebrate it once the board has reloaded + if (!r.error && r.unlocked && r.unlocked.length) { const id = r.unlocked[0]; toast('\u{1F3C6} Achievement unlocked!', 'big'); setTimeout(async () => { await load(); shareBadge(id, true); }, 1200); } + $('submit').disabled = false; + if (!r.error || r.spent) { open = null; setTimeout(load, 900); } + } + fetch('/api/config').then(r => r.json()).then(c => { explorer = c.explorer || explorer; }).catch(() => {}); + load(); +})(); diff --git a/public/style.css b/public/style.css index a573440..2f73bb9 100644 --- a/public/style.css +++ b/public/style.css @@ -137,3 +137,7 @@ textarea{width:100%;padding:12px 14px;border-radius:12px;border:1px solid var(-- .ref-watch{margin-top:14px} .ref-video{width:100%;max-width:560px;border-radius:14px;display:block;background:#000} .ref-bonus{margin-top:10px;padding:9px 12px;border:1px solid rgba(243,190,67,.38);border-radius:12px;background:rgba(243,190,67,.07);font-size:13.5px;color:var(--ink)} + +/* the one condition that decides whether a referral ever pays, said plainly on the card itself + rather than discovered when the bounty does not arrive (Marty, 2026-09-23) */ +.ref-note{margin-top:8px;font-size:12.5px;line-height:1.45;color:var(--dim)}