Social layer: leaderboard (week/month/all) + six badges, /leaders and /promo pages, share panel and rank on the board, ?r= referral cookie -> member's IAP join link, OG tags, banner set served through the curtain

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-19 19:40:50 -05:00
parent cdded9d4ed
commit dd6a3a3bfa
19 changed files with 304 additions and 11 deletions
+70
View File
@@ -0,0 +1,70 @@
// Leaderboard, badges and share links. All derived from the payout ledger; nothing new is stored.
//
// A "find" is any non-failed payout (paid, sent, due or queued): the hunter did the work. POL counts
// paid drips only, so the board never shows money that has not left the faucet. Days and weeks are
// Central (ctDay), the same clock as the pool.
//
// settings.leaderboardExclude: member ids kept off the public boards (default: the company account #1).
'use strict';
const store = require('./store');
const { ctDay } = require('./missions');
const rewards = require('./rewards');
const missions = require('./missions');
const BADGES = [
{ id: 'first', name: 'First Find', icon: '\u{1F3AF}', why: 'your first code claimed' },
{ id: 'hunter', name: 'Hunter', icon: '\u{1F9ED}', why: 'five finds' },
{ id: 'tracker', name: 'Tracker', icon: '\u{1F526}', why: 'twenty finds' },
{ id: 'sweep', name: 'Full Sweep', icon: '\u{1F5FA}', why: 'a find on every site' },
{ id: 'lucky', name: 'Lucky Find', icon: '\u{1F48E}', why: 'one drip of 0.4 POL or more' },
{ id: 'streak', name: 'On a Streak', icon: '\u{1F525}', why: 'finds on three days in a row' },
];
function excluded() { const s = rewards.settings(); const list = Array.isArray(s.leaderboardExclude) ? s.leaderboardExclude : [1]; return new Set(list.map(Number)); }
function finds() { return store.read('payouts', []).filter(p => p.status !== 'failed'); }
// Central day arithmetic on 'YYYY-MM-DD' keys
function dayMinus(dayKey, n) { const [y, m, d] = dayKey.split('-').map(Number); const t = Date.UTC(y, m - 1, d) - n * 86400000; return new Date(t).toISOString().slice(0, 10); }
function inPeriod(p, period, today) {
if (period === 'all') return true;
if (period === 'month') return p.day.slice(0, 7) === today.slice(0, 7);
return p.day >= dayMinus(today, 6); // week: the last seven Central days including today
}
function badgesFor(memberId, list) {
const mine = (list || finds()).filter(p => p.memberId === Number(memberId));
if (!mine.length) return [];
const out = new Set();
out.add('first');
if (mine.length >= 5) out.add('hunter');
if (mine.length >= 20) out.add('tracker');
const sites = new Set(missions.list().filter(m => m.active !== false).map(m => m.site));
if (sites.size && [...sites].every(s => mine.some(p => p.site === s))) out.add('sweep');
if (mine.some(p => p.pol >= 0.4)) out.add('lucky');
const days = new Set(mine.map(p => p.day));
for (const d of days) { if (days.has(dayMinus(d, 1)) && days.has(dayMinus(d, 2))) { out.add('streak'); break; } }
return BADGES.filter(b => out.has(b.id));
}
function leaderboard(period, limit) {
const today = ctDay(); const ex = excluded(); const all = finds();
const by = new Map();
for (const p of all) {
if (ex.has(p.memberId) || !inPeriod(p, period, today)) continue;
const r = by.get(p.memberId) || { memberId: p.memberId, who: p.username ? '@' + p.username : '#' + p.memberId, finds: 0, pol: 0, last: 0 };
r.finds++; if (p.status === 'paid') r.pol += p.pol; r.last = Math.max(r.last, p.at); by.set(p.memberId, r);
}
const rows = [...by.values()].sort((a, b) => b.finds - a.finds || b.pol - a.pol || a.last - b.last);
return rows.slice(0, limit || 25).map((r, i) => Object.assign(r, { rank: i + 1, pol: Math.round(r.pol * 10000) / 10000, badges: badgesFor(r.memberId, all).map(b => b.icon) }));
}
function rankOf(memberId, period) {
const rows = leaderboard(period, 100000); const i = rows.findIndex(r => r.memberId === Number(memberId));
return i < 0 ? null : { rank: i + 1, of: rows.length, finds: rows[i].finds, pol: rows[i].pol };
}
// the member's share link: PolHunter's landing with their IAP referral, which the landing turns into
// their instantadpay.com/join/<ref> link for everyone who signs up from it
function shareLink(site, me) { const ref = me.username || me.memberId; return site + '/?r=' + encodeURIComponent(String(ref)); }
module.exports = { BADGES, badgesFor, leaderboard, rankOf, shareLink };
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Admin · PolHunter</title>
<meta name="robots" content="noindex,nofollow">
<link rel="stylesheet" href="/style.css?v=7">
<link rel="stylesheet" href="/style.css?v=8">
<style>
label{display:block;font-size:12px;color:var(--dim);letter-spacing:.08em;text-transform:uppercase;margin:10px 0 4px}
input,textarea,select{width:100%;padding:10px 12px;border-radius:10px;border:1px solid var(--edge);background:rgba(0,0,0,.35);color:var(--ink);font:14px var(--font)}
+14 -4
View File
@@ -5,14 +5,15 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Your board · PolHunter</title>
<meta name="robots" content="noindex">
<link rel="stylesheet" href="/style.css?v=7">
<link rel="stylesheet" href="/style.css?v=8">
</head>
<body>
<div class="wrap">
<header class="top">
<a class="mark" href="/"><span class="coin"></span>PolHunter</a>
<nav class="nav"><span class="pill" id="who"><i></i></span><a class="btn ghost sm" href="/logout">Sign out</a></nav>
<nav class="nav"><span class="pill" id="who"><i></i></span><a class="btn ghost sm" href="/leaders">Leaderboard</a><a class="btn ghost sm" href="/promo">Promo tools</a><a class="btn ghost sm" href="/logout">Sign out</a></nav>
</header>
<div class="standing" id="standing"></div>
<section class="section" style="padding-top:10px">
<h2>Your missions</h2>
@@ -27,8 +28,17 @@
<div class="ledger" id="mine"></div>
</section>
<footer class="foot">Rewards are for completed missions, not income. The daily pool is limited; a find made after it is spent queues and pays next. Cryptocurrency involves risk of loss.</footer>
<section class="section" id="share">
<h2>Share your hunt</h2>
<div class="sub">Your link puts sign-ups on your InstantAdPay line. Post a find, or just the link.</div>
<div class="copybox"><input id="mylink" readonly value=""><button class="btn pol sm" id="copylink">Copy link</button></div>
<textarea id="sharetext" rows="4" style="margin-top:10px"></textarea>
<div class="sharebtns" id="sharebtns"></div>
<p class="small" style="margin-top:10px">Banners, swipes and the teaser video are on the <a href="/promo">promo tools</a> page, already carrying your link.</p>
</section>
<footer class="foot">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.</footer>
</div>
<script src="/app.js?v=4"></script>
<script src="/app.js?v=5"></script>
</body>
</html>
+20
View File
@@ -40,6 +40,25 @@
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>');
// 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 || []).find(d => d.status === 'paid');
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'));
}
$('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)));
@@ -66,6 +85,7 @@
$('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) { 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; }
$('submit').disabled = false;
if (!r.error || r.spent) { open = null; setTimeout(load, 900); }
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

+12 -3
View File
@@ -5,7 +5,14 @@
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PolHunter</title>
<meta name="description" content="Missions across the network. Visit a site, find the thing, get a drip of POL to your wallet.">
<link rel="stylesheet" href="/style.css?v=7">
<meta property="og:type" content="website">
<meta property="og:title" content="PolHunter: Visit it. Find it. Get paid in POL.">
<meta property="og:description" content="Missions across our sites, one code to find, a random drip of POL straight to your wallet. On chain, with a link to prove it.">
<meta property="og:image" content="https://polhunter.com/promo/polhunter-1200x630.jpg">
<meta property="og:url" content="https://polhunter.com/">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:image" content="https://polhunter.com/promo/polhunter-1200x630.jpg">
<link rel="stylesheet" href="/style.css?v=8">
</head>
<body>
<div class="wrap">
@@ -14,7 +21,7 @@
<nav class="nav">
<a class="btn ghost sm" href="#start">Get started</a>
<a class="btn ghost sm" href="#how">How it works</a>
<a class="btn ghost sm" href="#ledger">Paid so far</a>
<a class="btn ghost sm" href="/leaders">Leaderboard</a>
<a class="btn sm" id="signin" href="https://instantadpay.com/api/my/polhunter">Open my board</a>
</nav>
</header>
@@ -58,7 +65,7 @@
<section class="section" id="ledger">
<h2>Paid so far</h2>
<div class="sub">Every drip is a Polygon transaction you can open.</div>
<div class="sub">Every drip is a Polygon transaction you can open. See who is finding the most on the <a href="/leaders">leaderboard</a>.</div>
<div class="stats" id="stats"></div>
<div class="ledger" id="recent"><div class="row"><span class="site">Loading…</span></div></div>
</section>
@@ -86,6 +93,8 @@
document.getElementById('stats').innerHTML = [['POL paid', (t.pol || 0).toLocaleString('en-US', { maximumFractionDigits: 2 })], ['drips', t.paid || 0], ['hunters', t.hunters || 0], ['today', (t.today || 0).toLocaleString('en-US', { maximumFractionDigits: 2 }) + ' POL']]
.map(([l, v]) => '<div class="stat"><div class="v num">' + v + '</div><div class="l">' + l + '</div></div>').join('');
const cfg = await (await fetch('/api/config')).json();
// arrived on a member's share link: sign-ups go to that member's InstantAdPay join page
if (cfg.ref) { document.querySelectorAll('a[href="https://instantadpay.com"]').forEach(a => { a.href = cfg.joinUrl; }); const inv = document.createElement('span'); inv.className = 'pill'; inv.style.marginLeft = '10px'; inv.innerHTML = '<i></i> Invited by @' + cfg.ref.replace(/[<>&"]/g, ''); document.querySelector('.hero-copy .pill').after(inv); }
document.getElementById('recent').innerHTML = (r.recent || []).length ? r.recent.map(x => '<div class="row"><span>' + x.who + '</span><span class="site">' + x.site + '</span><span class="pol">+' + x.pol + ' POL</span><a class="when" href="' + cfg.explorer + '/tx/' + x.tx + '" target="_blank" rel="noopener">verify ↗</a></div>').join('')
: '<div class="row"><span class="site">No drips yet. The first hunter gets the first line.</span></div>';
} catch (e) {}
+60
View File
@@ -0,0 +1,60 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Leaderboard · PolHunter</title>
<meta name="description" content="The hunters with the most finds this week, this month and all time. Every drip is on Polygon.">
<link rel="stylesheet" href="/style.css?v=8">
</head>
<body>
<div class="wrap">
<header class="top">
<a class="mark" href="/"><span class="coin"></span>PolHunter</a>
<nav class="nav">
<a class="btn ghost sm" href="/promo">Promo tools</a>
<a class="btn ghost sm" href="/#how">How it works</a>
<a class="btn sm" href="https://instantadpay.com/api/my/polhunter">Open my board</a>
</nav>
</header>
<section class="section" style="padding-top:14px">
<h2>Leaderboard</h2>
<div class="sub">Most finds wins. Ties go to the most POL, then to whoever got there first.</div>
<div class="tabs" id="tabs"><button class="tab on" data-p="week">This week</button><button class="tab" data-p="month">This month</button><button class="tab" data-p="all">All time</button></div>
<div class="notice" id="mine" style="display:none;margin:12px 0"></div>
<div class="ledger" id="rows"><div class="row"><span class="site">Loading…</span></div></div>
<p class="small" style="margin-top:14px">Weeks are the last seven days, Central time. The company account is not listed.</p>
</section>
<section class="section">
<h2>Badges</h2>
<div class="sub">Earned automatically from your finds. They show next to your name up there and on your board.</div>
<div class="grid" id="badges"></div>
</section>
<div class="adslot" data-ad="leaders"><!-- nas: 468x60 on phones, 728x90 on wider screens --><script>(function(){var m=window.innerWidth<600;document.write('<scr'+'ipt src="https://www.networkadspace.com/showadss.php?'+(m?'w=468&h=60&n=1&bw=468&bh=60':'w=728&h=90&n=1&bw=728&bh=90')+'&c=999"></scr'+'ipt>');})();</script></div>
<footer class="foot">PolHunter rewards completed missions. Rewards are not income and are not guaranteed. Cryptocurrency involves risk of loss. <br><a href="https://instantadpay.com">InstantAdPay</a></footer>
</div>
<script>
(async () => {
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const medal = r => r === 1 ? '\u{1F947}' : r === 2 ? '\u{1F948}' : r === 3 ? '\u{1F949}' : '#' + r;
let me = null; try { const r = await fetch('/api/my/board'); if (r.ok) me = await r.json(); } catch (e) {}
async function show(period) {
document.querySelectorAll('.tab').forEach(t => t.classList.toggle('on', t.dataset.p === period));
const r = await (await fetch('/api/leaders?period=' + period)).json();
document.getElementById('rows').innerHTML = r.rows.length ? r.rows.map(x => '<div class="row lb' + (me && me.me && x.memberId === me.me.memberId ? ' me' : '') + '"><span><b class="num">' + medal(x.rank) + '</b> ' + esc(x.who) + ' <span class="badges">' + x.badges.join(' ') + '</span></span><span class="site">' + x.finds + ' find' + (x.finds === 1 ? '' : 's') + '</span><span class="pol">' + x.pol + ' POL</span></div>').join('')
: '<div class="row"><span class="site">No finds yet in this period. The first hunter gets the top line.</span></div>';
if (me && me.rank && me.rank[period]) { const k = me.rank[period]; document.getElementById('mine').style.display = 'block'; document.getElementById('mine').innerHTML = '<b>You are #' + k.rank + ' of ' + k.of + '</b> ' + (period === 'all' ? 'all time' : period === 'month' ? 'this month' : 'this week') + ' with ' + k.finds + ' find' + (k.finds === 1 ? '' : 's') + ' and ' + k.pol + ' POL.'; }
else if (me) { document.getElementById('mine').style.display = 'block'; document.getElementById('mine').innerHTML = 'You are not on this board yet. One find puts you on it. <a href="/app">Open your board</a>.'; }
}
document.querySelectorAll('.tab').forEach(t => t.addEventListener('click', () => show(t.dataset.p)));
const b = await (await fetch('/api/badges')).json();
document.getElementById('badges').innerHTML = b.badges.map(x => '<div class="card"><div style="font-size:34px">' + x.icon + '</div><h3>' + esc(x.name) + '</h3><p>' + esc(x.why) + '</p>' + (me && me.badges && me.badges.some(y => y.id === x.id) ? '<p style="margin-top:8px"><span class="tag done">Earned</span></p>' : '') + '</div>').join('');
show('week');
})();
</script>
</body>
</html>
+87
View File
@@ -0,0 +1,87 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Promo tools · PolHunter</title>
<meta name="description" content="Banners, posts, swipes and the teaser video, all carrying your InstantAdPay referral.">
<link rel="stylesheet" href="/style.css?v=8">
</head>
<body>
<div class="wrap">
<header class="top">
<a class="mark" href="/"><span class="coin"></span>PolHunter</a>
<nav class="nav">
<a class="btn ghost sm" href="/leaders">Leaderboard</a>
<a class="btn ghost sm" href="/#how">How it works</a>
<a class="btn sm" href="https://instantadpay.com/api/my/polhunter">Open my board</a>
</nav>
</header>
<section class="section" style="padding-top:14px">
<h2>Promo tools</h2>
<div class="sub">Everything here carries your link. People who sign up from it join InstantAdPay under you, and their hunts start on your line.</div>
<div class="notice" id="linkNote">Sign in to get your personal link: <a href="https://instantadpay.com/api/my/polhunter">Open my board</a>, then come back here. Until then the tools point at polhunter.com with no referral.</div>
<div class="copybox" style="margin-top:12px"><input id="mylink" readonly value="https://polhunter.com/"><button class="btn pol sm" data-copy="#mylink">Copy link</button></div>
</section>
<section class="section">
<h2>Share</h2>
<div class="sub">One tap. Edit the words if you like; the link is the part that matters.</div>
<div class="share" id="share"></div>
</section>
<section class="section">
<h2>The teaser</h2>
<div class="sub">Thirty seconds. Post it with your link, or send people to the page and let it play there.</div>
<video class="hero-video" controls playsinline preload="metadata" poster="/video/polhunter-teaser-poster.jpg" src="/video/polhunter-teaser.mp4" style="border-radius:18px;max-height:60vh"></video>
<p style="margin-top:10px"><a class="btn ghost sm" href="/video/polhunter-teaser.mp4" download="polhunter-teaser.mp4">Download the mp4</a></p>
</section>
<section class="section">
<h2>Banners</h2>
<div class="sub">Right-click to save, or copy the embed code: it already has your link in it.</div>
<div class="banners" id="banners"></div>
</section>
<section class="section">
<h2>Posts and swipes</h2>
<div class="sub">Written to be pasted as they are. No earnings promises, and keep it that way.</div>
<div class="grid" id="swipes"></div>
</section>
<div class="adslot" data-ad="promo"><!-- nas: 468x60 on phones, 728x90 on wider screens --><script>(function(){var m=window.innerWidth<600;document.write('<scr'+'ipt src="https://www.networkadspace.com/showadss.php?'+(m?'w=468&h=60&n=1&bw=468&bh=60':'w=728&h=90&n=1&bw=728&bh=90')+'&c=999"></scr'+'ipt>');})();</script></div>
<footer class="foot">PolHunter rewards completed missions. Rewards are not income and are not guaranteed; the daily pool is limited and drips are random within the posted range. Cryptocurrency involves risk of loss. <br><a href="https://instantadpay.com">InstantAdPay</a></footer>
</div>
<script>
(async () => {
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
let link = 'https://polhunter.com/', who = null;
try { const r = await fetch('/api/my/board'); if (r.ok) { const b = await r.json(); link = b.share.link; who = b.me; } } catch (e) {}
document.getElementById('mylink').value = link;
if (who) document.getElementById('linkNote').innerHTML = 'Signed in as <b>' + esc(who.username ? '@' + who.username : '#' + who.memberId) + '</b>. This is your link. Anyone who signs up from it lands on your InstantAdPay line.';
const L = encodeURIComponent(link);
const posts = [
{ name: 'Short post', text: 'New thing I am playing with: PolHunter. You visit a site, find your code on the page, type it in, and a random drip of POL lands in your wallet. On chain, with a link to prove it. ' + link },
{ name: 'Story post', text: 'I spent two minutes on a training page tonight, found a little glass coin at the bottom, typed the code, and 0.26 POL showed up in my wallet a minute later. That is PolHunter. Small drips, real chain, no catch beyond actually visiting the site. Free to start, you just need an InstantAdPay account and a wallet. ' + link },
{ name: 'Telegram / WhatsApp', text: '\u{1F3AF} PolHunter is live. Missions across our sites, one code to find per site, random POL drips paid straight to your wallet. The hiding place moves every day. Start here: ' + link },
{ name: 'Email swipe', subject: 'A scavenger hunt that pays in POL', text: 'Hey,\n\nQuick one. I have been testing something called PolHunter.\n\nYou pick a mission, spend a couple of minutes on the site it names, and your personal code appears somewhere on the page. Type it in and a random drip of POL goes to your wallet, on Polygon, with a transaction link you can open.\n\nIt is free to start. You need an InstantAdPay account (free) and a wallet linked to it so the drips have somewhere to land.\n\nHave a look: ' + link + '\n\nNo promises about amounts. The pool is limited every day and the drips are random within the posted range. It is fun, it is real, and it takes a few minutes.\n\nTalk soon' },
];
document.getElementById('share').innerHTML = posts.map((p, i) => '<div class="card"><h3>' + esc(p.name) + '</h3>' + (p.subject ? '<p><b>Subject:</b> ' + esc(p.subject) + '</p>' : '') + '<textarea id="t' + i + '" rows="' + (p.subject ? 12 : 5) + '">' + esc(p.text) + '</textarea>'
+ '<div class="sharebtns"><button class="btn pol sm" data-copy="#t' + i + '">Copy</button>'
+ (p.subject ? '' : '<a class="btn ghost sm" target="_blank" rel="noopener" href="https://twitter.com/intent/tweet?text=' + encodeURIComponent(p.text) + '">X</a><a class="btn ghost sm" target="_blank" rel="noopener" href="https://t.me/share/url?url=' + L + '&text=' + encodeURIComponent(p.text.replace(link, '').trim()) + '">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" href="https://wa.me/?text=' + encodeURIComponent(p.text) + '">WhatsApp</a>')
+ '</div></div>').join('');
const sizes = [['728x90', 'Leaderboard'], ['468x60', 'Mobile leaderboard'], ['300x250', 'Medium rectangle'], ['250x250', 'Square'], ['125x125', 'Button'], ['160x600', 'Skyscraper'], ['1200x630', 'Social card'], ['1080x1080', 'Instagram square']];
document.getElementById('banners').innerHTML = sizes.map(([s, n]) => { const src = 'https://polhunter.com/promo/polhunter-' + s + (Number(s.split('x')[0]) >= 300 ? '.jpg' : '.png'); const code = '<a href="' + link + '" target="_blank" rel="noopener"><img src="' + src + '" width="' + s.split('x')[0] + '" height="' + s.split('x')[1] + '" alt="PolHunter: visit it, find it, get paid in POL"></a>';
return '<div class="banner"><div class="bhead"><b>' + s + '</b> <span class="site">' + n + '</span></div><div class="bimg"><img src="' + src + '" alt="' + s + '"></div><textarea rows="3" id="b' + s + '">' + esc(code) + '</textarea><div class="sharebtns"><button class="btn pol sm" data-copy="#b' + s + '">Copy embed</button><a class="btn ghost sm" href="' + src + '" download>Save image</a></div></div>'; }).join('');
document.getElementById('swipes').innerHTML = [
['Comment or DM', 'Not a program, a scavenger hunt. You find a code on one of our sites and it pays a little POL to your wallet. Try one mission and tell me what you got: ' + link],
['Objection: is it real?', 'Every drip is a Polygon transaction. Open the ledger on the page and click verify on any line, it opens the block explorer. That is the whole proof.'],
['Objection: how much?', 'Random, between the posted range, and the daily pool is limited. Nobody is promising amounts. It is a few minutes for a few cents to a few dollars of POL, and the fun is in the find.'],
].map(([n, t], i) => '<div class="card"><h3>' + esc(n) + '</h3><textarea id="s' + i + '" rows="4">' + esc(t) + '</textarea><div class="sharebtns"><button class="btn pol sm" data-copy="#s' + i + '">Copy</button></div></div>').join('');
document.body.addEventListener('click', async e => { const b = e.target.closest('[data-copy]'); if (!b) return; const el = document.querySelector(b.dataset.copy); try { await navigator.clipboard.writeText(el.value); } catch (x) { el.select(); document.execCommand('copy'); } const t = b.textContent; b.textContent = 'Copied'; setTimeout(() => { b.textContent = t; }, 1200); });
})();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+11
View File
@@ -44,6 +44,17 @@ a{color:var(--pol-hi);text-decoration:none} a:hover{color:var(--gold-hi)}
.hero-video{display:block;width:100%;aspect-ratio:16/9;max-height:80vh;background:#000;object-fit:contain}
.notice{padding:14px 18px;border-radius:14px;background:rgba(243,190,67,.1);border:1px solid rgba(243,190,67,.4);color:var(--gold-hi);font-size:15px}
.notice b{color:var(--ink)}
/* social */
.standing{display:flex;flex-wrap:wrap;gap:10px 18px;align-items:center;padding:4px 0 14px;font-size:13.5px;color:var(--muted)}
.standing .rankline b{color:var(--gold-hi)} .badges{display:inline-flex;flex-wrap:wrap;gap:6px}
.badge{display:inline-flex;align-items:center;gap:5px;padding:4px 10px;border-radius:999px;background:rgba(130,71,229,.16);border:1px solid rgba(130,71,229,.35);color:var(--ink);font-size:12.5px;font-weight:600}
.tabs{display:flex;gap:8px;margin:14px 0} .tab{padding:8px 14px;border-radius:999px;border:1px solid var(--edge);background:var(--glass);color:var(--muted);font:600 13px var(--font);cursor:pointer} .tab.on{color:#160a33;background:linear-gradient(135deg,var(--gold-hi),var(--gold));border-color:transparent}
.row.lb{grid-template-columns:1.6fr auto auto} .row.lb.me{border-color:var(--edge-hi);box-shadow:0 0 0 1px rgba(180,140,255,.25)} .row.lb .badges{font-size:14px;margin-left:6px}
.copybox{display:flex;gap:8px;flex-wrap:wrap} .copybox input{flex:1 1 220px;min-width:0;padding:12px 14px;border-radius:12px;border:1px solid var(--edge);background:rgba(0,0,0,.35);color:var(--ink);font:500 14px var(--mono)}
textarea{width:100%;padding:12px 14px;border-radius:12px;border:1px solid var(--edge);background:rgba(0,0,0,.35);color:var(--ink);font:400 14px/1.5 var(--font);resize:vertical} textarea:focus{outline:none;border-color:var(--edge-hi)}
.sharebtns{display:flex;gap:8px;flex-wrap:wrap;margin-top:10px} .share .card textarea{margin-top:10px}
.banners{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px} .banner{background:var(--glass);border:1px solid var(--edge);border-radius:18px;padding:16px} .banner .bhead{margin-bottom:10px} .banner .bimg{overflow-x:auto;padding:6px 0} .banner .bimg img{max-width:none;display:block} .banner textarea{margin-top:10px;font:500 12px var(--mono)}
.small{font-size:13px;color:var(--dim)}
.pill{display:inline-flex;align-items:center;gap:8px;padding:6px 12px;border-radius:999px;background:var(--glass);border:1px solid var(--edge);color:var(--muted);font-size:12px;letter-spacing:.1em;text-transform:uppercase}
.pill i{width:8px;height:8px;border-radius:50%;background:var(--ok);box-shadow:0 0 10px var(--ok)}
/* cards */
+19 -3
View File
@@ -18,6 +18,7 @@ const store = require('./lib/store');
const sso = require('./lib/sso');
const missions = require('./lib/missions');
const rewards = require('./lib/rewards');
const social = require('./lib/social');
const faucet = require('./lib/faucet');
const PORT = Number(process.env.PORT || 3000);
@@ -48,6 +49,7 @@ async function notify(kind, p) {
}
// Marty's rule (2026-09-19): when the day's pool is spent, say so; claims reopen at midnight Central
const SPENT_MSG = 'Today\u2019s POL pool is spent. No more claims today. Claims reopen at midnight Central.';
function refOf(req) { const m = /(?:^|;\s*)ph\.ref=([^;]+)/.exec(req.headers.cookie || ''); const r = m ? decodeURIComponent(m[1]) : ''; return /^[A-Za-z0-9_.-]{1,40}$/.test(r) ? r : null; }
function explorer() { return Number(process.env.HUNT_CHAIN_ID) === 80002 ? 'https://amoy.polygonscan.com' : 'https://polygonscan.com'; }
// ---- helpers -------------------------------------------------------------------------------
@@ -115,9 +117,17 @@ const server = http.createServer(async (req, res) => {
}
if (p === '/embed.js') return sendFile(res, path.join(PUBLIC_DIR, 'embed.js'), { 'Cache-Control': 'public, max-age=300', 'Access-Control-Allow-Origin': '*' });
// the coin on the code pill, fetched by mission-site visitors who hold no curtain pass
if (p === '/img/coin-sm.png' || p === '/img/coin.png') return sendFile(res, path.join(PUBLIC_DIR, p.slice(1)), { 'Cache-Control': 'public, max-age=86400' });
if (p === '/img/coin-sm.png' || p === '/img/coin.png' || p === '/img/og.jpg' || /^\/promo\/polhunter-\d+x\d+\.(jpg|png)$/.test(p)) return sendFile(res, path.join(PUBLIC_DIR, p.slice(1)), { 'Cache-Control': 'public, max-age=86400' });
if (curtained(req, res, u)) return;
// a member's share link: /?r=<their IAP username or id> becomes a 30-day cookie; the landing then
// sends sign-ups to instantadpay.com/join/<ref>, so IAP's own last-touch sponsor rule applies
if (p === '/' && u.searchParams.get('r')) {
const r = String(u.searchParams.get('r')).trim().slice(0, 40); u.searchParams.delete('r');
const h = { Location: '/' + (u.searchParams.toString() ? '?' + u.searchParams : ''), 'Cache-Control': 'no-store' };
if (/^[A-Za-z0-9_.-]{1,40}$/.test(r)) h['Set-Cookie'] = 'ph.ref=' + encodeURIComponent(r) + '; Path=/; Max-Age=2592000; SameSite=Lax; Secure';
res.writeHead(302, h); return res.end();
}
// ---- sign-in by hand-off from InstantAdPay
if (p === '/auth') {
@@ -132,7 +142,11 @@ const server = http.createServer(async (req, res) => {
if (p === '/logout') { const s = sso.fromRequest(req); if (s) sso.endSession(s.sid); res.writeHead(302, { Location: '/', 'Set-Cookie': sso.clearCookie() }); return res.end(); }
// ---- public
if (p === '/api/config') return json(res, 200, { name: 'PolHunter', signupsOpen: signupsOpen(), reward: rewards.settings(), iapUrl: 'https://instantadpay.com/my', explorer: explorer() });
if (p === '/api/config') { const ref = refOf(req); return json(res, 200, { name: 'PolHunter', signupsOpen: signupsOpen(), reward: rewards.settings(), iapUrl: 'https://instantadpay.com/my', explorer: explorer(), ref, joinUrl: ref ? 'https://instantadpay.com/join/' + encodeURIComponent(ref) + '?from=polhunter' : 'https://instantadpay.com/?from=polhunter' }); }
if (p === '/api/leaders') { const per = ['week', 'month', 'all'].includes(u.searchParams.get('period')) ? u.searchParams.get('period') : 'week'; return json(res, 200, { period: per, rows: social.leaderboard(per, 25) }, { 'Cache-Control': 'public, max-age=60' }); }
if (p === '/api/badges') return json(res, 200, { badges: social.BADGES });
if (p === '/leaders') return sendFile(res, path.join(PUBLIC_DIR, 'leaders.html'));
if (p === '/promo') return sendFile(res, path.join(PUBLIC_DIR, 'promo.html'));
if (p === '/api/ledger') { const t = rewards.totals(); return json(res, 200, { totals: t, recent: rewards.ledger(30).map(x => ({ who: x.username ? '@' + x.username : '#' + x.memberId, site: x.site, pol: x.pol, tx: x.tx, at: x.paidAt })) }); }
// ---- hunter (session required)
@@ -141,7 +155,9 @@ const server = http.createServer(async (req, res) => {
if (p === '/api/my/board') {
const done = new Set(rewards.mine(me.memberId).map(x => x.missionId));
const wdone = me.wallet ? new Set(store.read('payouts', []).filter(x => x.wallet && x.wallet.toLowerCase() === me.wallet && x.status !== 'failed').map(x => x.missionId)) : new Set();
return json(res, 200, { me: { memberId: me.memberId, username: me.username, wallet: me.wallet }, missions: missions.forMember(me.memberId).map(m => Object.assign(pubMission(m), { done: done.has(m.id) || wdone.has(m.id) })), drips: rewards.mine(me.memberId).slice(0, 20), faucet: { on: faucetOn }, pool: rewards.pool() });
return json(res, 200, { me: { memberId: me.memberId, username: me.username, wallet: me.wallet }, missions: missions.forMember(me.memberId).map(m => Object.assign(pubMission(m), { done: done.has(m.id) || wdone.has(m.id) })), drips: rewards.mine(me.memberId).slice(0, 20), faucet: { on: faucetOn }, pool: rewards.pool(),
badges: social.badgesFor(me.memberId), rank: { week: social.rankOf(me.memberId, 'week'), month: social.rankOf(me.memberId, 'month'), all: social.rankOf(me.memberId, 'all') },
share: { link: social.shareLink(SITE, me), joinUrl: 'https://instantadpay.com/join/' + encodeURIComponent(String(me.username || me.memberId)) } });
}
if (p === '/api/my/start' && req.method === 'POST') {
const b = await readBody(req); const m = missions.get(String(b.missionId || '')); if (!m || !m.active) return json(res, 404, { error: 'That mission is not open.' });
+10
View File
@@ -77,6 +77,16 @@ const sleep = ms => new Promise(r => setTimeout(r, ms));
const rewards = require('../lib/rewards'); const draws = Array.from({ length: 4000 }, () => rewards.draw(0.05, 1));
const median = draws.sort((x, y) => x - y)[2000]; eq([draws.every(d => d >= 0.05 && d <= 1), median < 0.35], [true, true], 'the draw stays in range and its median sits low (' + median + ')');
// social: the find is on the leaderboard with a badge, the board carries rank and a share link,
// and a share link visit sets the referral cookie that turns sign-ups into that member's IAP join link
const lb = await call('/api/leaders?period=all'); eq([lb.status, lb.body.rows.length >= 1, lb.body.rows[0].memberId, lb.body.rows[0].finds >= 1, lb.body.rows[0].badges.length >= 1], [200, true, 42, true, true], 'leaderboard: hunter 42 leads all time with a badge');
const bd = await call('/api/badges'); eq(bd.body.badges.length, 6, 'six badges are defined');
const b3 = await call('/api/my/board'); eq([b3.body.badges.some(b => b.id === 'first'), b3.body.rank.all.rank, b3.body.share.link], [true, 1, B + '/?r=hunter42'], 'board: First Find badge, rank #1, share link carries the username');
const rv = await fetch(B + '/?r=hunter42', { redirect: 'manual' }); eq([rv.status, /ph\.ref=hunter42/.test(rv.headers.get('set-cookie') || ''), rv.headers.get('location')], [302, true, '/'], 'a share-link visit sets the referral cookie and lands on the landing');
const cf = await (await fetch(B + '/api/config', { headers: { Cookie: 'ph.ref=hunter42' } })).json(); eq([cf.ref, cf.joinUrl], ['hunter42', 'https://instantadpay.com/join/hunter42?from=polhunter'], 'with the cookie, sign-ups go to that member\u2019s IAP join link');
const badRef = await fetch(B + '/?r=<script>', { redirect: 'manual' }); eq(/ph\.ref/.test(badRef.headers.get('set-cookie') || ''), false, 'a malformed ref sets no cookie');
for (const pg of ['/leaders', '/promo']) { const r = await fetch(B + pg); eq([r.status, /<title>/.test(await r.text())], [200, true], pg + ' serves'); }
// nothing outward: no telegram env, no faucet env
const led = await call('/api/ledger'); eq(led.body.totals.paid, 0, 'nothing has been paid, because no faucet is configured');