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.

-
- - -
- - - - + + + + + +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.

+
+ + +
+ + + + 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 = '
' + (unlocked ? 'Achievement unlocked' : '') + '

' + b.icon + ' ' + esc(b.name) + '

' + esc(b.why) + '

' - + '' + esc(b.name) + '' - + '
XTelegramFacebookWhatsAppSave image
' - + '

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 = '
' + (unlocked ? 'Achievement unlocked' : '') + '

' + b.icon + ' ' + esc(b.name) + '

' + esc(b.why) + '

' + + '' + esc(b.name) + '' + + '
XTelegramFacebookWhatsAppSave image
' + + '

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)}