PolHunter v0.2: the hunt engine

Hand-off sign-in from InstantAdPay (lib/sso.js: HMAC token, five minutes, single use; no sign-up,
no mailer), missions with per-member per-visit proof codes (lib/missions.js: the embed is
answered only from the mission's own origin, only after the dwell; hiding place rotates by day
and member), weighted-low rewards with a daily cap that queues rather than refuses
(lib/rewards.js), the faucet sender on ethers with a low-balance alert (lib/faucet.js), the
one-line embed for the sites (public/embed.js), the hunter board, the public ledger, an
admin page, and the site's own look. Telegram is behind the OUTBOUND gate like everything else.
13-check end-to-end test in test/run.js.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-19 14:17:44 -05:00
parent 1a6166abc3
commit 523d57624e
15 changed files with 1001 additions and 75 deletions
+62
View File
@@ -0,0 +1,62 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<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=1">
<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)}
textarea{min-height:70px} .two{display:grid;grid-template-columns:1fr 1fr;gap:12px}
table{width:100%;border-collapse:collapse;font-size:13px} td,th{padding:8px 10px;border-bottom:1px solid var(--edge);text-align:left;vertical-align:top} th{color:var(--dim);font-weight:600;font-size:11px;letter-spacing:.1em;text-transform:uppercase}
.gate{display:inline-block;padding:3px 9px;border-radius:999px;font-size:11px;letter-spacing:.1em;text-transform:uppercase;border:1px solid var(--edge)} .gate.on{color:var(--ok);border-color:rgba(75,227,165,.4)} .gate.off{color:var(--bad);border-color:rgba(255,122,122,.4)}
</style>
</head>
<body>
<div class="wrap">
<header class="top"><a class="mark" href="/"><span class="coin"></span>PolHunter <span style="font-weight:400;color:var(--dim)">admin</span></a><nav class="nav"><input id="key" placeholder="admin key" style="width:220px"><button class="btn sm" id="go">Open</button></nav></header>
<div id="ui" hidden>
<section class="section" style="padding-top:6px"><h2>Posture</h2><div id="gates"></div></section>
<section class="section"><h2>Pool</h2><div class="stats" id="stats"></div>
<div class="card"><div class="two"><div><label>Min POL per drip</label><input id="minPol"></div><div><label>Max POL per drip</label><input id="maxPol"></div><div><label>Daily cap (POL)</label><input id="dailyCapPol"></div><div><label>Low-balance alert (POL)</label><input id="lowBalancePol"></div></div><div style="margin-top:12px"><button class="btn sm" id="saveSettings">Save pool settings</button> <button class="btn ghost sm" id="tickFaucet">Run the faucet now</button> <span id="faucetLine" style="font-size:13px;color:var(--muted)"></span></div></div>
</section>
<section class="section"><h2>Missions</h2>
<div class="card"><div class="two"><div><label>id (slug)</label><input id="m_id" placeholder="iap-ledger"></div><div><label>Site label</label><input id="m_site" placeholder="InstantAdPay"></div></div>
<label>Name</label><input id="m_name" placeholder="Find your code on the live ledger">
<label>Brief (what the hunter is told)</label><textarea id="m_brief" placeholder="Open the public ledger and find your code next to the newest payout."></textarea>
<label>URL the mission link opens (the token is appended)</label><input id="m_url" placeholder="https://instantadpay.com/ledger">
<div class="two"><div><label>Dwell (seconds)</label><input id="m_dwell" value="45"></div><div><label>Slots on that page (hiding places)</label><input id="m_slots" value="1"></div><div><label>Budget (completions, 0 = unlimited)</label><input id="m_budget" value="0"></div><div><label>Active</label><select id="m_active"><option value="true">yes</option><option value="false">no</option></select></div></div>
<div style="margin-top:12px"><button class="btn sm" id="saveMission">Save mission</button> <span id="missionMsg" style="font-size:13px;color:var(--muted)"></span></div></div>
<table id="missions" style="margin-top:14px"></table>
</section>
<section class="section"><h2>Recent drips</h2><table id="payouts"></table></section>
</div>
</div>
<script>
(() => {
const $ = id => document.getElementById(id); const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
let KEY = ''; const api = async (p, opt) => (await fetch(p, Object.assign({ headers: { 'X-Admin-Key': KEY, 'Content-Type': 'application/json' } }, opt || {}))).json();
let st = null;
async function load() {
st = await api('/api/admin/state'); if (st.error) { alert(st.error); return; }
$('ui').hidden = false;
const g = st.gates; $('gates').innerHTML = ['outbound', 'signups', 'curtain', 'sso'].map(k => '<span class="gate ' + (g[k] ? 'on' : 'off') + '">' + k + ': ' + (g[k] ? 'on' : 'off') + '</span> ').join('') + ' <span class="gate ' + (st.faucet.on ? 'on' : 'off') + '">faucet: ' + (st.faucet.on ? esc(st.faucet.address) : 'off') + '</span>';
const t = st.totals; $('stats').innerHTML = [['paid today', (t.today || 0).toFixed(2) + ' POL'], ['queued', t.queued], ['drips paid', t.paid], ['POL paid', t.pol], ['hunters', t.hunters], ['faucet balance', st.faucet.state && st.faucet.state.balance != null ? Number(st.faucet.state.balance).toFixed(2) + ' POL' : '—']].map(([l, v]) => '<div class="stat"><div class="v num">' + v + '</div><div class="l">' + l + '</div></div>').join('');
for (const k of ['minPol', 'maxPol', 'dailyCapPol', 'lowBalancePol']) $(k).value = st.settings[k];
$('missions').innerHTML = '<tr><th>id</th><th>site</th><th>name</th><th>url</th><th>dwell</th><th>slots</th><th>budget</th><th>active</th><th></th></tr>' + st.missions.map(m => '<tr><td class="num">' + esc(m.id) + '</td><td>' + esc(m.site) + '</td><td>' + esc(m.name) + '</td><td style="max-width:220px;word-break:break-all">' + esc(m.url) + '</td><td>' + m.dwell + '</td><td>' + (m.slots || 1) + '</td><td>' + (m.budget || '∞') + '</td><td>' + (m.active ? 'yes' : 'no') + '</td><td><button class="btn ghost sm" data-edit="' + esc(m.id) + '">edit</button> <button class="btn ghost sm" data-test="' + esc(m.id) + '">test link</button> <button class="btn ghost sm" data-del="' + esc(m.id) + '">×</button></td></tr>').join('');
$('payouts').innerHTML = '<tr><th>when</th><th>who</th><th>mission</th><th>POL</th><th>status</th><th>tx / error</th></tr>' + st.payouts.map(p => '<tr><td>' + new Date(p.at).toLocaleString() + '</td><td>' + esc(p.username ? '@' + p.username : '#' + p.memberId) + '</td><td>' + esc(p.missionId) + '</td><td class="num">' + p.pol + '</td><td>' + esc(p.status) + '</td><td style="word-break:break-all">' + esc(p.tx || p.error || '') + '</td></tr>').join('');
document.querySelectorAll('[data-edit]').forEach(b => b.addEventListener('click', () => { const m = st.missions.find(x => x.id === b.dataset.edit); for (const k of ['id', 'site', 'name', 'brief', 'url', 'dwell', 'slots', 'budget']) $('m_' + k).value = m[k] == null ? '' : m[k]; $('m_active').value = String(m.active !== false); window.scrollTo({ top: $('m_id').getBoundingClientRect().top + window.scrollY - 80, behavior: 'smooth' }); }));
document.querySelectorAll('[data-del]').forEach(b => b.addEventListener('click', async () => { if (!confirm('Delete mission ' + b.dataset.del + '?')) return; await api('/api/admin/mission', { method: 'DELETE', body: JSON.stringify({ id: b.dataset.del }) }); load(); }));
document.querySelectorAll('[data-test]').forEach(b => b.addEventListener('click', async () => { const r = await api('/api/admin/embed-test?id=' + encodeURIComponent(b.dataset.test)); if (r.url) { window.open(r.url, '_blank', 'noopener'); alert('Opened with a test token. The code renders after ' + r.dwell + 's.'); } else alert(r.error || 'failed'); }));
}
$('go').addEventListener('click', () => { KEY = $('key').value.trim(); load(); });
$('key').addEventListener('keydown', e => { if (e.key === 'Enter') $('go').click(); });
$('saveSettings').addEventListener('click', async () => { const b = {}; for (const k of ['minPol', 'maxPol', 'dailyCapPol', 'lowBalancePol']) b[k] = Number($(k).value); const r = await api('/api/admin/settings', { method: 'POST', body: JSON.stringify(b) }); if (r.error) alert(r.error); load(); });
$('tickFaucet').addEventListener('click', async () => { $('faucetLine').textContent = 'running…'; const r = await api('/api/admin/faucet/tick', { method: 'POST' }); $('faucetLine').textContent = r.error ? r.error : 'paid ' + r.paid + ' · balance ' + (r.balance == null ? '—' : Number(r.balance).toFixed(4) + ' POL'); load(); });
$('saveMission').addEventListener('click', async () => { const b = {}; for (const k of ['id', 'site', 'name', 'brief', 'url', 'dwell', 'slots', 'budget']) b[k] = $('m_' + k).value; b.active = $('m_active').value === 'true'; const r = await api('/api/admin/mission', { method: 'POST', body: JSON.stringify(b) }); $('missionMsg').textContent = r.error || 'saved'; load(); });
})();
</script>
</body>
</html>
+34
View File
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<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=1">
</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>
</header>
<section class="section" style="padding-top:10px">
<h2>Your missions</h2>
<div class="sub" id="rangeLine">Each find pays a random drip. Your codes are yours alone.</div>
<div class="grid" id="missions"><div class="card"><p>Loading your board…</p></div></div>
</section>
<div class="adslot" data-ad="board"></div>
<section class="section">
<h2>Your drips</h2>
<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>
</div>
<script src="/app.js?v=1"></script>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
// 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 }
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 ? 'Completed' : 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 ? '' : 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="CODE" autocomplete="off" spellcheck="false"><button class="btn pol" id="submit">Claim</button></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 style="margin-top:14px"><button class="btn" data-start="' + esc(m.id) + '">Start mission</button></div>')
+ '</div>';
}
function render() {
$('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.';
$('missions').innerHTML = board.missions.length ? board.missions.map(card).join('') : '<div class="card"><p>No missions are open right now. Check back soon.</p></div>';
$('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: 'failed: ' + esc(d.error || '') }[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) { alert(r.error); 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>. Keep that tab open, then come back and type it here.' : 'Your code should be showing on the site now. Type 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;
$('submit').disabled = false;
if (!r.error) { open = null; setTimeout(load, 900); }
}
fetch('/api/config').then(r => r.json()).then(c => { explorer = c.explorer || explorer; }).catch(() => {});
load();
})();
+43
View File
@@ -0,0 +1,43 @@
/* PolHunter embed. One line on a mission site:
<script src="https://polhunter.com/embed.js" data-slots="3" async></script>
plus, where a code may appear, elements with data-ph-slot="0" ... data-ph-slot="N-1".
When a hunter arrives with ?ph=<token> (their own, from their PolHunter board) the token is kept
in sessionStorage for this tab, and after the mission's dwell the code is fetched from
polhunter.com and rendered in ONE of the slots (which one is picked per hunter per day). The
request carries this page's Origin; polhunter.com answers only for the mission's own host and
only after the dwell. Anyone else loading this page sees nothing at all. */
(function () {
var API = 'https://polhunter.com';
try {
var q = new URLSearchParams(location.search), t = q.get('ph');
if (t) { sessionStorage.setItem('ph.token', t); q.delete('ph'); history.replaceState(null, '', location.pathname + (q.toString() ? '?' + q : '') + location.hash); }
t = sessionStorage.getItem('ph.token'); if (!t) return;
} catch (e) { return; }
// slots: explicit [data-ph-slot="i"] elements, or the i-th match of data-selector (e.g. ".video-card"
// on a training page, ".card" on a programs page), else a fixed badge in the corner
var me = document.currentScript, slots = Number((me && me.getAttribute('data-slots')) || 1) || 1, sel = me && me.getAttribute('data-selector');
function slotEl(i) {
var el = document.querySelector('[data-ph-slot="' + i + '"]'); if (el) return el;
if (sel) { var all = document.querySelectorAll(sel); if (all.length) { var host = all[i % all.length]; var d0 = document.createElement('div'); d0.style.cssText = 'margin:10px 0'; host.appendChild(d0); return d0; } }
var d = document.createElement('div'); d.setAttribute('data-ph-slot', String(i)); d.style.cssText = 'position:fixed;right:16px;bottom:16px;z-index:99999'; document.body.appendChild(d); return d;
}
function render(code, slot) {
var el = slotEl(slot); el.innerHTML = '';
var box = document.createElement('div');
box.setAttribute('role', 'note');
box.style.cssText = 'display:inline-flex;align-items:center;gap:10px;padding:10px 14px;border-radius:14px;background:linear-gradient(135deg,#1a0f3d,#2b1a63);color:#f1eefb;font:600 14px/1.2 system-ui,-apple-system,"Segoe UI",sans-serif;box-shadow:0 8px 30px rgba(130,71,229,.45),0 0 0 1px rgba(255,255,255,.08);letter-spacing:.02em';
box.innerHTML = '<span style="width:22px;height:22px;border-radius:50%;background:radial-gradient(circle at 35% 35%,#c9a4ff,#8247e5 60%,#4b2a8c);box-shadow:0 0 14px rgba(130,71,229,.9);flex:none"></span><span>PolHunter code <b style="font-family:ui-monospace,Menlo,monospace;font-size:16px;letter-spacing:.14em;color:#f3be43">' + code + '</b></span>';
el.appendChild(box);
}
var tries = 0;
function ask() {
tries++; if (tries > 40) return;
fetch(API + '/api/embed/code?t=' + encodeURIComponent(t), { mode: 'cors', credentials: 'omit' }).then(function (r) { return r.json(); }).then(function (r) {
if (r && r.code) { render(r.code, Number(r.slot) % slots); try { sessionStorage.removeItem('ph.token'); } catch (e) {} return; }
if (r && r.wait) { setTimeout(ask, Math.min(r.wait, 10) * 1000); return; }
// expired / gone / wrong origin: say nothing
}).catch(function () { setTimeout(ask, 8000); });
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ask); else ask();
})();
+60 -18
View File
@@ -4,27 +4,69 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>PolHunter</title>
<style>
:root{--bg:#0b0a14;--ink:#f1eefb;--muted:#a79fc4;--pol:#8247e5;--pol-hi:#a97cf5;--gold:#f3be43}
*{margin:0;padding:0;box-sizing:border-box}
body{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:32px 20px;background:radial-gradient(circle at 20% 10%,rgba(130,71,229,.28),transparent 45%),radial-gradient(circle at 85% 90%,rgba(243,190,67,.16),transparent 40%),var(--bg);color:var(--ink);font:16px/1.6 system-ui,-apple-system,"Segoe UI",sans-serif}
.wrap{max-width:640px;text-align:center}
.mark{display:inline-flex;align-items:center;gap:12px;font-weight:800;font-size:22px;letter-spacing:-.3px;margin-bottom:28px}
.mark i{width:34px;height:34px;border-radius:50%;background:var(--pol);box-shadow:0 0 34px rgba(130,71,229,.7)}
h1{font-size:clamp(32px,7vw,54px);line-height:1.05;font-weight:800;letter-spacing:-1px;margin-bottom:16px}
h1 b{color:var(--gold)}
p{color:var(--muted);font-size:18px;max-width:520px;margin:0 auto 22px}
.pill{display:inline-block;padding:8px 16px;border:1px solid rgba(167,159,196,.35);border-radius:999px;color:var(--muted);font-size:13px;letter-spacing:.08em;text-transform:uppercase}
.fine{margin-top:40px;font-size:12px;color:#6e678a}
</style>
<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=1">
</head>
<body>
<div class="wrap">
<div class="mark"><i></i>PolHunter</div>
<h1>Visit. Find it. <b>Get paid in POL.</b></h1>
<p>Missions across the network. Each one takes a few minutes on a site, a thing to find, and a drip of POL to your wallet when you find it.</p>
<span class="pill">Building — opens soon</span>
<div class="fine">Rewards are for completed missions, not income. Cryptocurrency involves risk of loss.</div>
<header class="top">
<a class="mark" href="/"><span class="coin"></span>PolHunter</a>
<nav class="nav">
<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 sm" id="signin" href="https://instantadpay.com/my#polhunter">Open my board</a>
</nav>
</header>
<section class="hero">
<div>
<span class="pill"><i></i> Paying in POL on Polygon</span>
<h1>Visit. Find it.<br><b>Get paid in POL.</b></h1>
<p>Every mission is a few minutes on one of our sites, one thing to find, and a drip of POL straight to your wallet when you find it. Your code is yours alone, and the hiding place moves.</p>
<a class="btn" href="https://instantadpay.com/my#polhunter">Start hunting</a>
<a class="btn ghost" href="#how" style="margin-left:8px">See how</a>
<p id="signinNote" style="font-size:13px;color:var(--dim);margin-top:14px;display:none">Open PolHunter from your InstantAdPay dashboard to sign in. No password, no new account.</p>
</div>
<div class="stack" aria-hidden="true"><div class="c c1"></div><div class="c c2"></div><div class="c c3"></div><div class="g"></div></div>
</section>
<section class="section" id="how">
<h2>How a hunt works</h2>
<div class="sub">Three steps, one code, real POL.</div>
<div class="steps">
<div class="step"><b>Pick a mission</b> on your board. Each one names a site and what to find there.</div>
<div class="step"><b>Go find it.</b> Spend the time on the site. Your personal code appears when you have, somewhere on the page.</div>
<div class="step"><b>Type the code.</b> If it is yours, a random drip between the day's range lands in your wallet, on-chain, with a link to prove it.</div>
</div>
</section>
<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="stats" id="stats"></div>
<div class="ledger" id="recent"><div class="row"><span class="site">Loading…</span></div></div>
</section>
<div class="adslot" data-ad="home-footer"></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. Hunters sign in with an InstantAdPay account; no account is created here.
<br><a href="https://instantadpay.com">InstantAdPay</a> · <a href="/admin" rel="nofollow" style="color:var(--dim)">·</a>
</footer>
</div>
<script>
(async () => {
if (new URLSearchParams(location.search).get('signin')) document.getElementById('signinNote').style.display = 'block';
try {
const r = await (await fetch('/api/ledger')).json();
const t = r.totals || {};
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();
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) {}
})();
</script>
</body>
</html>
+75
View File
@@ -0,0 +1,75 @@
/* PolHunter: its own look. Night expedition: deep indigo, violet POL glow, gold finds, glass. */
@import url('https://fonts.googleapis.com/css2?family=Sora:wght@400;600;800&family=JetBrains+Mono:wght@500&display=swap');
:root{
--bg:#080614;--bg2:#100b26;--ink:#f3f0ff;--muted:#a79fc4;--dim:#6e678a;
--pol:#8247e5;--pol-hi:#b48cff;--pol-deep:#3d2178;--gold:#f3be43;--gold-hi:#ffd97a;--ok:#4be3a5;--bad:#ff7a7a;
--glass:rgba(255,255,255,.05);--edge:rgba(255,255,255,.1);--edge-hi:rgba(180,140,255,.45);
--font:'Sora',system-ui,-apple-system,"Segoe UI",sans-serif;--mono:'JetBrains Mono',ui-monospace,Menlo,monospace;
}
*{margin:0;padding:0;box-sizing:border-box}
html{scroll-behavior:smooth}
body{min-height:100vh;background:var(--bg);color:var(--ink);font:16px/1.6 var(--font);overflow-x:hidden}
body::before{content:"";position:fixed;inset:0;z-index:-1;background:
radial-gradient(900px 500px at 15% -10%,rgba(130,71,229,.35),transparent 60%),
radial-gradient(700px 400px at 95% 20%,rgba(243,190,67,.14),transparent 60%),
radial-gradient(800px 600px at 50% 120%,rgba(130,71,229,.2),transparent 60%),var(--bg)}
a{color:var(--pol-hi);text-decoration:none} a:hover{color:var(--gold-hi)}
.wrap{max-width:1080px;margin:0 auto;padding:0 20px}
/* header */
.top{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:22px 0}
.mark{display:inline-flex;align-items:center;gap:12px;font-weight:800;font-size:20px;letter-spacing:-.3px;color:var(--ink)}
.coin{width:34px;height:34px;border-radius:50%;background:radial-gradient(circle at 35% 35%,#d7bdff,var(--pol) 55%,var(--pol-deep));box-shadow:0 0 28px rgba(130,71,229,.8),inset 0 0 0 1px rgba(255,255,255,.25);position:relative;flex:none}
.coin::after{content:"";position:absolute;inset:9px;border-radius:50%;border:2px solid rgba(255,255,255,.7);border-right-color:transparent;transform:rotate(-30deg)}
.nav{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:12px 20px;border-radius:999px;border:0;cursor:pointer;font:600 15px var(--font);color:#160a33;background:linear-gradient(135deg,var(--gold-hi),var(--gold));box-shadow:0 8px 24px rgba(243,190,67,.3);transition:transform .15s,box-shadow .15s}
.btn:hover{transform:translateY(-1px);box-shadow:0 12px 30px rgba(243,190,67,.4)} .btn:disabled{opacity:.5;cursor:default;transform:none}
.btn.ghost{background:var(--glass);color:var(--ink);box-shadow:none;border:1px solid var(--edge)} .btn.ghost:hover{border-color:var(--edge-hi)}
.btn.pol{background:linear-gradient(135deg,var(--pol-hi),var(--pol));color:#fff;box-shadow:0 8px 24px rgba(130,71,229,.4)}
.btn.sm{padding:8px 14px;font-size:13px}
/* hero */
.hero{padding:56px 0 30px;display:grid;grid-template-columns:1.15fr .85fr;gap:40px;align-items:center}
.hero h1{font-size:clamp(38px,6vw,68px);line-height:1.02;font-weight:800;letter-spacing:-1.5px}
.hero h1 b{background:linear-gradient(90deg,var(--gold-hi),var(--gold));-webkit-background-clip:text;background-clip:text;color:transparent}
.hero p{font-size:19px;color:var(--muted);max-width:540px;margin:20px 0 26px}
.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)}
.stack{position:relative;height:340px}
.stack .c{position:absolute;border-radius:50%;background:radial-gradient(circle at 35% 30%,rgba(215,189,255,.95),var(--pol) 50%,var(--pol-deep) 85%);box-shadow:0 20px 60px rgba(130,71,229,.55),inset 0 0 0 2px rgba(255,255,255,.22),inset 0 -18px 30px rgba(0,0,0,.35);animation:float 7s ease-in-out infinite}
.stack .c::after{content:"";position:absolute;inset:26%;border-radius:50%;border:4px solid rgba(255,255,255,.75);border-right-color:transparent;transform:rotate(-30deg)}
.stack .c1{width:210px;height:210px;left:8%;top:8%} .stack .c2{width:120px;height:120px;right:10%;top:0;animation-delay:-2s} .stack .c3{width:90px;height:90px;right:22%;bottom:6%;animation-delay:-4s}
.stack .g{position:absolute;left:0;right:0;bottom:0;height:60px;background:radial-gradient(ellipse at center,rgba(243,190,67,.3),transparent 70%)}
@keyframes float{0%,100%{transform:translateY(0)}50%{transform:translateY(-12px)}}
/* cards */
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:16px}
.card{background:linear-gradient(180deg,rgba(255,255,255,.06),rgba(255,255,255,.025));border:1px solid var(--edge);border-radius:22px;padding:22px;backdrop-filter:blur(8px);position:relative;overflow:hidden;transition:border-color .2s,transform .2s}
.card:hover{border-color:var(--edge-hi);transform:translateY(-2px)}
.card h3{font-size:19px;font-weight:800;letter-spacing:-.3px;margin:10px 0 6px}
.card p{color:var(--muted);font-size:14.5px}
.tag{display:inline-block;padding:4px 10px;border-radius:999px;font-size:11px;letter-spacing:.12em;text-transform:uppercase;font-weight:600;background:rgba(130,71,229,.18);color:var(--pol-hi);border:1px solid rgba(130,71,229,.35)}
.tag.gold{background:rgba(243,190,67,.14);color:var(--gold-hi);border-color:rgba(243,190,67,.35)}
.tag.done{background:rgba(75,227,165,.12);color:var(--ok);border-color:rgba(75,227,165,.35)}
.range{font-family:var(--mono);font-size:13px;color:var(--gold-hi)}
.num{font-family:var(--mono);font-variant-numeric:tabular-nums}
.section{padding:34px 0} .section h2{font-size:28px;font-weight:800;letter-spacing:-.6px;margin-bottom:6px} .section .sub{color:var(--muted);margin-bottom:18px}
.steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:14px;counter-reset:s}
.step{padding:18px 18px 18px 58px;position:relative;background:var(--glass);border:1px solid var(--edge);border-radius:18px;color:var(--muted);font-size:15px}
.step::before{counter-increment:s;content:counter(s);position:absolute;left:16px;top:16px;width:30px;height:30px;border-radius:50%;display:flex;align-items:center;justify-content:center;font:800 14px var(--font);color:#160a33;background:linear-gradient(135deg,var(--gold-hi),var(--gold))}
.step b{color:var(--ink)}
/* ledger */
.ledger{display:flex;flex-direction:column;gap:8px}
.row{display:grid;grid-template-columns:1.2fr 1fr auto auto;gap:12px;align-items:center;padding:12px 16px;background:var(--glass);border:1px solid var(--edge);border-radius:14px;font-size:14px}
.row .pol{color:var(--gold-hi);font-family:var(--mono);font-weight:500}
.row .site{color:var(--muted)} .row .when{color:var(--dim);font-size:12px}
.stats{display:flex;gap:14px;flex-wrap:wrap;margin:14px 0 22px}
.stat{flex:1;min-width:140px;padding:16px 18px;background:var(--glass);border:1px solid var(--edge);border-radius:18px}
.stat .v{font:800 26px var(--font);letter-spacing:-.6px} .stat .l{font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:var(--dim)}
/* mission flow */
.mission-open{border-color:var(--edge-hi);box-shadow:0 0 0 1px rgba(180,140,255,.25),0 20px 60px rgba(130,71,229,.25)}
.timer{font:500 13px var(--mono);color:var(--muted)} .timer b{color:var(--gold-hi)}
.codebox{display:flex;gap:8px;margin-top:12px}
.codebox input{flex:1;padding:12px 14px;border-radius:12px;border:1px solid var(--edge);background:rgba(0,0,0,.35);color:var(--ink);font:600 18px var(--mono);letter-spacing:.2em;text-transform:uppercase;outline:none}
.codebox input:focus{border-color:var(--edge-hi)}
.msg{margin-top:10px;font-size:14px} .msg.ok{color:var(--ok)} .msg.bad{color:var(--bad)}
.foot{padding:40px 0 30px;color:var(--dim);font-size:12.5px;line-height:1.7;border-top:1px solid var(--edge);margin-top:30px}
.adslot{min-height:90px;display:flex;align-items:center;justify-content:center;margin:26px 0;color:var(--dim);font-size:12px}
@media (max-width:820px){.hero{grid-template-columns:1fr;padding-top:34px}.stack{height:240px}.row{grid-template-columns:1fr auto;row-gap:4px}.row .when{display:none}}