Phone-run fixes: hash tokens, embed wakes on tab focus, dry faucet leaves drips due (+admin retry), short drip status, claim box wraps; one drip per wallet per mission
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+11
-2
@@ -30,16 +30,25 @@ async function tick(notify) {
|
||||
let paid = 0;
|
||||
try {
|
||||
const due = rewards.payable();
|
||||
// a dry faucet is not a failed drip: check the balance first, leave the drips due, alert once
|
||||
let bal0 = null; try { bal0 = await balance(); } catch (e) {}
|
||||
if (bal0 != null && due.length) {
|
||||
const need = due.reduce((n, p) => n + p.pol, 0) + 0.01;
|
||||
if (bal0 < due[0].pol + 0.005) { if (notify) { const st = store.read('faucet-state', {}); const day = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Chicago' }); if (st.dryAlertDay !== day) { st.dryAlertDay = day; store.write('faucet-state', st); await notify('low', { balance: bal0, threshold: need, address: wallet.address }); } } ticking = false; return { paid: 0, waiting: due.length, balance: bal0 }; }
|
||||
}
|
||||
for (const p of due) {
|
||||
if (bal0 != null && bal0 < p.pol + 0.005) break; // pay what the balance covers, leave the rest due
|
||||
if (!p.wallet || !/^0x[a-f0-9]{40}$/i.test(p.wallet)) { rewards.mark(p.id, { status: 'failed', error: 'no wallet on the account' }); continue; }
|
||||
try {
|
||||
const tx = await wallet.sendTransaction({ to: p.wallet, value: ethers.parseEther(String(p.pol)) });
|
||||
rewards.mark(p.id, { status: 'sent', tx: tx.hash });
|
||||
const rc = await tx.wait(1);
|
||||
rewards.mark(p.id, { status: rc && rc.status === 1 ? 'paid' : 'failed', paidAt: Date.now(), error: rc && rc.status === 1 ? null : 'reverted' });
|
||||
if (rc && rc.status === 1) { paid++; if (notify) await notify('paid', Object.assign({}, p, { tx: tx.hash })); }
|
||||
if (rc && rc.status === 1) { paid++; if (bal0 != null) bal0 -= p.pol; if (notify) await notify('paid', Object.assign({}, p, { tx: tx.hash })); }
|
||||
} catch (e) {
|
||||
rewards.mark(p.id, { status: 'failed', error: String(e.message || e).slice(0, 200) });
|
||||
const msg = String(e.message || e);
|
||||
if (/insufficient funds/i.test(msg)) { rewards.mark(p.id, { status: 'due', error: null }); break; } // dry: leave it due, stop this pass
|
||||
rewards.mark(p.id, { status: 'failed', error: msg.slice(0, 200) });
|
||||
if (notify) await notify('failed', Object.assign({}, p, { error: String(e.message || e).slice(0, 200) }));
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -30,8 +30,10 @@ function paidToday(day) {
|
||||
}
|
||||
|
||||
// has this member completed this mission already?
|
||||
function completed(memberId, missionId) {
|
||||
return store.read('payouts', []).some(p => p.memberId === Number(memberId) && p.missionId === missionId && p.status !== 'failed');
|
||||
// by member id, and also by wallet: two accounts sharing a wallet get one drip per mission between them
|
||||
function completed(memberId, missionId, wallet) {
|
||||
const w = wallet ? String(wallet).toLowerCase() : null;
|
||||
return store.read('payouts', []).some(p => p.missionId === missionId && p.status !== 'failed' && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w)));
|
||||
}
|
||||
|
||||
// record a completion; decide paid-today vs queued
|
||||
|
||||
+1
-1
@@ -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=2">
|
||||
<link rel="stylesheet" href="/style.css?v=3">
|
||||
<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)}
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@
|
||||
<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=2">
|
||||
<link rel="stylesheet" href="/style.css?v=3">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
@@ -29,6 +29,6 @@
|
||||
|
||||
<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=2"></script>
|
||||
<script src="/app.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@
|
||||
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('')
|
||||
$('mine').innerHTML = board.drips.length ? board.drips.map(d => '<div class="row"><span>' + esc(d.site) + '</span><span class="site">' + ({ paid: 'paid', sent: 'sending', due: 'paying next', queued: 'queued for the next pool', failed: 'held, being looked at' }[d.status] || d.status) + '</span><span class="pol">+' + d.pol + ' POL</span>' + (d.tx ? '<a class="when" target="_blank" rel="noopener" href="' + explorer + '/tx/' + d.tx + '">verify ↗</a>' : '<span class="when"></span>') + '</div>').join('')
|
||||
: '<div class="row"><span class="site">Nothing yet. Your first find goes here.</span></div>';
|
||||
document.querySelectorAll('[data-start]').forEach(b => b.addEventListener('click', () => start(b.dataset.start)));
|
||||
const s = $('submit'); if (s) { s.addEventListener('click', submit); $('code').addEventListener('keydown', e => { if (e.key === 'Enter') submit(); }); tick(); }
|
||||
@@ -43,7 +43,7 @@
|
||||
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.';
|
||||
el.innerHTML = left > 0 ? 'Your code appears on the site in about <b>' + left + 's</b>. <b>Stay on the site tab</b> until it shows (switching away pauses it), then come back and type it here.' : 'Your code should be showing on the site now. If it is not, switch to the site tab and give it a few seconds. Then type it here.';
|
||||
if (open.expires && Date.now() > open.expires) { el.innerHTML = 'This attempt timed out. Start the mission again.'; return; }
|
||||
setTimeout(tick, 1000);
|
||||
}
|
||||
|
||||
+10
-7
@@ -10,8 +10,9 @@
|
||||
(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); }
|
||||
// the token arrives in the hash (#ph=) or, for older links, the query (?ph=); it is kept for this tab
|
||||
var h = new URLSearchParams((location.hash || '').replace(/^#/, '')), q = new URLSearchParams(location.search), t = h.get('ph') || q.get('ph');
|
||||
if (t) { sessionStorage.setItem('ph.token', t); h.delete('ph'); q.delete('ph'); history.replaceState(null, '', location.pathname + (q.toString() ? '?' + q : '') + (h.toString() ? '#' + h : '')); }
|
||||
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"
|
||||
@@ -30,14 +31,16 @@
|
||||
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;
|
||||
var tries = 0, pending = null, done = false;
|
||||
// phones freeze a background tab's timers: when the hunter comes back to this tab, ask right away
|
||||
document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'visible' && !done) { clearTimeout(pending); ask(); } });
|
||||
function ask() {
|
||||
tries++; if (tries > 40) return;
|
||||
tries++; if (tries > 80 || done) 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; }
|
||||
if (r && r.code) { done = true; render(r.code, Number(r.slot) % slots); try { sessionStorage.removeItem('ph.token'); } catch (e) {} return; }
|
||||
if (r && r.wait) { pending = setTimeout(ask, Math.min(r.wait, 5) * 1000); return; }
|
||||
// expired / gone / wrong origin: say nothing
|
||||
}).catch(function () { setTimeout(ask, 8000); });
|
||||
}).catch(function () { pending = setTimeout(ask, 6000); });
|
||||
}
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', ask); else ask();
|
||||
})();
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@
|
||||
<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=2">
|
||||
<link rel="stylesheet" href="/style.css?v=3">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
+3
-2
@@ -66,9 +66,10 @@ a{color:var(--pol-hi);text-decoration:none} a:hover{color:var(--gold-hi)}
|
||||
/* 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{display:flex;gap:8px;margin-top:12px;flex-wrap:wrap}
|
||||
.codebox input{flex:1 1 140px;min-width:0;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)}
|
||||
.codebox .btn{flex:0 0 auto}
|
||||
.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}
|
||||
|
||||
@@ -116,17 +116,19 @@ const server = http.createServer(async (req, res) => {
|
||||
const me = sso.fromRequest(req); if (!me) return json(res, 401, { error: 'Open PolHunter from your InstantAdPay dashboard to sign in.' });
|
||||
if (p === '/api/my/board') {
|
||||
const done = new Set(rewards.mine(me.memberId).map(x => x.missionId));
|
||||
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) })), drips: rewards.mine(me.memberId).slice(0, 20), faucet: { on: faucetOn } });
|
||||
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 } });
|
||||
}
|
||||
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.' });
|
||||
if (!me.wallet) return json(res, 400, { error: 'Link a wallet on InstantAdPay first so the drip has somewhere to land, then open PolHunter again.' });
|
||||
if (rewards.completed(me.memberId, m.id)) return json(res, 400, { error: 'You already completed this one.' });
|
||||
if (rewards.completed(me.memberId, m.id, me.wallet)) return json(res, 400, { error: 'You already completed this one.' });
|
||||
if (limited('start:' + me.memberId, 20, 3600000)) return json(res, 429, { error: 'Easy. Twenty starts an hour is plenty.' });
|
||||
const t = missions.issue(me.memberId, m.id);
|
||||
// a mission URL may place the token itself with {token} (a Telegram Mini App takes it in
|
||||
// ?startapp=, not as our own query string); otherwise it is appended as ?ph=
|
||||
const url = m.url.includes('{token}') ? m.url.replace('{token}', t.t) : m.url + (m.url.includes('?') ? '&' : '?') + 'ph=' + t.t;
|
||||
// the token rides in the hash: a server never sees it, so no redirect or canonical rewrite can lose it
|
||||
const url = m.url.includes('{token}') ? m.url.replace('{token}', t.t) : m.url + '#ph=' + t.t;
|
||||
return json(res, 200, { ok: true, token: t.t, url, dwell: m.dwell || 30, expires: t.exp });
|
||||
}
|
||||
if (p === '/api/my/submit' && req.method === 'POST') {
|
||||
@@ -134,7 +136,7 @@ const server = http.createServer(async (req, res) => {
|
||||
if (limited('submit:' + me.memberId, 30, 3600000)) return json(res, 429, { error: 'Too many tries. Take a breath.' });
|
||||
const c = missions.check(String(b.token || ''), me.memberId, b.code); if (c.error) return json(res, 400, { error: c.error });
|
||||
const m = missions.get(c.rec.missionId); if (!m) return json(res, 404, { error: 'That mission is gone.' });
|
||||
if (rewards.completed(me.memberId, m.id)) return json(res, 400, { error: 'You already completed this one.' });
|
||||
if (rewards.completed(me.memberId, m.id, me.wallet)) return json(res, 400, { error: 'You already completed this one.' });
|
||||
const g = rewards.grant(me, m); if (g.error) return json(res, 400, g);
|
||||
return json(res, 200, { ok: true, pol: g.rec.pol, queued: g.queued, message: g.queued ? 'Found it. Today’s POL is spoken for, so yours is queued and pays out next.' : 'Found it. ' + fmt(g.rec.pol) + ' POL is on its way to your wallet.' });
|
||||
}
|
||||
@@ -156,10 +158,11 @@ const server = http.createServer(async (req, res) => {
|
||||
}
|
||||
if (p === '/api/admin/mission' && req.method === 'DELETE') { const b = await readBody(req); missions.remove(String(b.id || '')); return json(res, 200, { ok: true, missions: missions.list() }); }
|
||||
if (p === '/api/admin/settings' && req.method === 'POST') { const b = await readBody(req); const patch = {}; for (const k of ['minPol', 'maxPol', 'dailyCapPol', 'lowBalancePol']) if (b[k] != null && Number(b[k]) >= 0) patch[k] = Number(b[k]); return json(res, 200, { ok: true, settings: rewards.setSettings(patch) }); }
|
||||
if (p === '/api/admin/drip/retry' && req.method === 'POST') { const b = await readBody(req); rewards.mark(String(b.id || ''), { status: 'due', error: null }); return json(res, 200, { ok: true }); }
|
||||
if (p === '/api/admin/faucet/tick' && req.method === 'POST') { const r = await faucet.tick(notify); return json(res, 200, Object.assign(r, { balance: await faucet.balance() })); }
|
||||
if (p === '/api/admin/embed-test') { // mint a token for any mission so the embed can be tried without a hunter
|
||||
const m = missions.get(String(u.searchParams.get('id') || '')); if (!m) return json(res, 404, { error: 'no such mission' });
|
||||
const t = missions.issue(0, m.id); return json(res, 200, { url: m.url.includes('{token}') ? m.url.replace('{token}', t.t) : m.url + (m.url.includes('?') ? '&' : '?') + 'ph=' + t.t, token: t.t, dwell: m.dwell });
|
||||
const t = missions.issue(0, m.id); return json(res, 200, { url: m.url.includes('{token}') ? m.url.replace('{token}', t.t) : m.url + '#ph=' + t.t, token: t.t, dwell: m.dwell });
|
||||
}
|
||||
return json(res, 404, { error: 'No such admin call.' });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user