';
- }
- // 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.
';
- 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 '
';
+ }
+ // 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.
';
+ 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)}