Files
polhunter/public/app.js
T
martbost 97dbd66a5c Board: line the mission buttons up, and give the referral card its own row
Every mission brief is a different length, so the Start mission buttons sat at
a different height in each card. The action now sits at the foot of the card,
so a row of them lines up whatever the brief above it says.

The referral card was in the same grid and is taller than any mission, so it
stretched the two cards beside it and left them with a hole under the button.
It moves above the grid into its own slot, where it splits into two columns on
a wide screen so the text does not run the full width.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 06:15:52 -05:00

171 lines
17 KiB
JavaScript

// 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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 ? ' <a href="' + esc(link.href) + '" target="_blank" rel="noopener">' + esc(link.text) + '</a>' : '');
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} <b>' + d.pol + ' POL</b> 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 <b>' + x.pol + ' POL</b> 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 '<div class="card' + (isOpen ? ' mission-open' : '') + '" data-id="' + esc(m.id) + '">'
+ '<span class="tag' + (m.done ? ' done' : '') + '">' + (m.done ? 'Done today' : esc(m.site)) + '</span>'
+ '<h3>' + esc(m.name) + '</h3><p>' + esc(m.brief) + '</p>'
+ '<p style="margin-top:10px"><span class="range">' + r.minPol + ' to ' + r.maxPol + ' POL</span> <span style="color:var(--dim);font-size:12px">· ' + m.dwell + 's on the site</span></p>'
+ (m.done ? '' : (board.pool && board.pool.spent && !isOpen) ? '<div class="cardact"><span class="tag gold">Closed until midnight Central</span></div>' : (board.daily && !board.daily.left && !isOpen) ? '<div class="cardact"><span class="tag">Back tomorrow</span></div>' : isOpen
? '<div class="timer" id="timer">Your code appears on the site after <b>' + m.dwell + 's</b>. Keep that tab open.</div>'
+ '<div class="codebox"><input id="code" maxlength="6" placeholder="6 CHARACTERS" autocomplete="off" spellcheck="false" inputmode="text" autocapitalize="characters"><button class="btn pol" id="submit">Claim</button></div><div class="site" style="font-size:12px;margin-top:6px">Six characters. On the site, tap the code to copy it, then paste it here.</div>'
+ '<div class="msg" id="msg"></div>'
+ '<p style="margin-top:8px"><a href="' + esc(open.url) + '" target="_blank" rel="noopener">Open the site again ↗</a></p>'
: '<div class="cardact"><button class="btn" data-start="' + esc(m.id) + '">Start mission</button></div>')
+ '</div>';
}
// 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 =>
'<div class="ref-row"><span>' + esc(p.name) + '</span><span class="ref-state">' + esc(p.state) + '</span></div>').join('');
return '<div class="card ref-card">'
+ '<span class="tag gold">Best paid</span>'
+ '<h3>🤝 Bring a hunter</h3>'
+ '<p>Send your link. When someone new opens PolHunter and finds their first code, you get <b>' + rf.bountyPol + ' POL</b>. '
+ 'After that you earn <b>' + rf.matchPct + '%</b> on top of everything they find for their first ' + rf.matchDays + ' days. '
+ 'It comes out of our pool, never theirs.</p>'
+ '<div class="codebox"><input id="refLink" readonly value="' + esc(link) + '"><button class="btn pol" id="refCopy">Copy</button></div>'
+ '<p style="margin-top:10px"><span class="range">' + today + ' brought today</span> '
+ '<span style="color:var(--dim);font-size:12px">· ' + total + ' all time · ' + rf.earnedPol + ' POL earned</span></p>'
+ (people ? '<div class="ref-list">' + people + '</div>' : '<p style="color:var(--dim);font-size:12px;margin-top:10px">Nobody yet. One message is all it takes.</p>')
+ '</div>';
}
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 '<div class="notice"><b>One thing before your first hunt.</b> Drips are paid to the wallet on your InstantAdPay account, and yours has none yet.</div>'
+ '<div class="steps" style="margin-top:14px">'
+ '<div class="step"><b>Create your free InstantAdPay account.</b> Already done: you are signed in as ' + esc(board.me.username ? '@' + board.me.username : '#' + board.me.memberId) + '.</div>'
+ '<div class="step"><b>Link your wallet.</b> In your InstantAdPay dashboard open <a href="' + IAP + '/my#wallet" target="_blank" rel="noopener">Wallet</a>, press connect wallet and approve. Any Polygon wallet works (MetaMask, Trust, Phantom with Polygon on).</div>'
+ '<div class="step"><b>Come back through the door.</b> Return to your dashboard and press <a href="' + IAP + '/api/my/polhunter">Open my board</a> again. Your missions unlock the moment a wallet is on the account.</div>'
+ '</div>';
}
// badge cards: art, lock state, share
function badgeStrip() {
const el = $('badgeStrip'); if (!el || !board.badgeCards) return;
el.innerHTML = board.badgeCards.map(b => '<div class="badge-a' + (b.earned ? '' : ' locked') + '"><img src="' + b.art + '" alt="' + esc(b.name) + '" loading="lazy"><div class="bl">' + b.icon + ' ' + esc(b.name) + '</div><div class="bs">' + esc(b.why) + '</div>' + (b.earned ? '<button class="btn pol sm" data-share="' + b.id + '">Share</button>' : '<div class="bs">\u{1F512} locked</div>') + '</div>').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 = '<div class="share-hd">' + (unlocked ? '<span class="tag gold">Achievement unlocked</span>' : '') + '<h3>' + b.icon + ' ' + esc(b.name) + '</h3><p class="site">' + esc(b.why) + '</p></div>'
+ '<img class="share-img" src="' + b.image + '?t=' + Date.now() + '" alt="' + esc(b.name) + '">'
+ '<div class="sharebtns" style="justify-content:center"><a class="btn ghost sm" target="_blank" rel="noopener" href="https://twitter.com/intent/tweet?text=' + T + '">X</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://t.me/share/url?url=' + U + '&text=' + encodeURIComponent(text.replace(b.page, '').trim()) + '">Telegram</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://www.facebook.com/sharer/sharer.php?u=' + U + '">Facebook</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://wa.me/?text=' + T + '">WhatsApp</a><a class="btn ghost sm" href="' + b.image + '" download="polhunter-' + b.id + '.jpg">Save image</a><button class="btn pol sm" id="shareCopy">Copy link</button></div>'
+ '<p class="small" style="margin-top:10px;text-align:center">The card carries your name; the link carries your referral.</p>';
$('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 = '<i></i> ' + 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 ? '<div class="notice" style="grid-column:1/-1"><b>Today\u2019s POL pool is spent.</b> No more claims today. Claims reopen at midnight Central' + (pool.resetsAt ? ', in ' + until(pool.resetsAt) : '') + '.</div>' : '')
+ (board.missions.length ? board.missions.map(card).join('') : '<div class="card"><p>No missions are open right now. Check back soon.</p></div>');
if ($('refSlot')) $('refSlot').innerHTML = board.me.wallet ? referralCard() : '';
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 <b>#' + rk.week.rank + '</b> of ' + rk.week.of); if (rk.all) line.push('All time <b>#' + rk.all.rank + '</b> of ' + rk.all.of);
$('standing').innerHTML = (board.badges && board.badges.length ? '<span class="badges" title="Your badges">' + board.badges.map(b => '<span class="badge" title="' + esc(b.name) + ': ' + esc(b.why) + '">' + b.icon + ' ' + esc(b.name) + '</span>').join('') + '</span>' : '<span class="site">No badges yet. Your first find earns one.</span>')
+ (line.length ? '<span class="rankline">' + line.join(' \u00b7 ') + ' \u00b7 <a href="/leaders">leaderboard</a></span>' : '<span class="rankline"><a href="/leaders">leaderboard</a></span>');
// 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 = '<a class="btn ghost sm" target="_blank" rel="noopener" data-x>X</a><a class="btn ghost sm" target="_blank" rel="noopener" data-tg>Telegram</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://www.facebook.com/sharer/sharer.php?u=' + L + '">Facebook</a><a class="btn ghost sm" target="_blank" rel="noopener" data-wa>WhatsApp</a><button class="btn pol sm" id="copytext">Copy text</button>';
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 <b>' + dl.left + ' of ' + dl.limit + '</b> missions left today.' : '<b>That is your ' + dl.limit + ' for today.</b> Fresh missions at midnight Central.');
$('mine').innerHTML = board.drips.length ? board.drips.map(d => '<div class="row"><span>' + esc(d.site) + '</span><span class="site">' + ({ paid: 'paid', sent: 'sending', due: 'paying next', queued: 'queued for the next pool', failed: 'held, being looked at' }[d.status] || d.status) + '</span><span class="pol">+' + d.pol + ' POL</span>' + (d.tx ? '<a class="when" target="_blank" rel="noopener" href="' + explorer + '/tx/' + d.tx + '">verify ↗</a>' : '<span class="when"></span>') + '</div>').join('')
: '<div class="row"><span class="site">Nothing yet. Your first find goes here.</span></div>';
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 <b>' + left + 's</b>. <b>Stay on the site tab</b> 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} <b>Found it!</b> ' + 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} <b>Achievement unlocked!</b>', '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();
})();