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>
@@ -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 };
|
||||
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 160 KiB |
@@ -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) {}
|
||||
|
||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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>
|
||||
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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>
|
||||
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -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 */
|
||||
|
||||
@@ -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.' });
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||