diff --git a/lib/referrals.js b/lib/referrals.js new file mode 100644 index 0000000..21a166e --- /dev/null +++ b/lib/referrals.js @@ -0,0 +1,174 @@ +// Referral rewards (Marty, 2026-09-23). +// +// PolHunter retains well and recruits badly: on 2026-09-22 it had its best day ever, 105 finds +// from 37 hunters, and had sent three people to InstantAdPay in its whole life, all three on one +// member's link. Earning and sharing were separate actions and only one of them paid. This makes +// bringing someone the best-paid thing on the board. +// +// Two payments, both to the REFERRER, both out of a pool of their own so a good referral day can +// never starve the finds: +// bounty a flat drip the first time someone they brought completes a find, paid once per person +// match a slice of every find that person makes while they are still new +// +// Who counts as "brought by me" is not a cookie PolHunter guessed at. It is the sponsor the member +// actually joined InstantAdPay under, handed over in the sign-in payload as sponsorRef, so the +// bounty and the InstantAdPay commission always land on the same person. +'use strict'; +const store = require('./store'); +const rewards = require('./rewards'); +const { ctDay } = require('./missions'); + +const DEFAULTS = { + refEnabled: 1, + refBountyPol: 3, // flat, not a draw: a referral is work someone did, not a lottery + refMatchPct: 20, // paid ON TOP of the newcomer's find, never taken out of it + refMatchDays: 30, // how long the match runs, from the newcomer's InstantAdPay join date + refNewDays: 30, // the bounty only pays for a genuinely new member, not an old one who + // happens to start hunting years after their sponsor signed them up + refCapPol: 20, // referral pool per Central day, separate from the find pool + refMatchMinPol: 0.005,// do not write a transaction for dust +}; +function cfg() { return Object.assign({}, DEFAULTS, store.read('settings', {})); } +const round4 = n => Math.round(Number(n) * 10000) / 10000; +const norm = s => String(s || '').trim().toLowerCase(); + +// ---- the hunter directory ------------------------------------------------------------------- +// Every sign-in upserts the member here, so the referral graph survives session expiry and we +// always have a wallet to pay the referrer at. +function seen(claims) { + const id = Number(claims.memberId); + if (!id) return null; + const all = store.update('hunters', {}, h => { + const cur = h[id] || { memberId: id, firstSeen: Date.now() }; + if (claims.email) cur.email = norm(claims.email); + if (claims.username) cur.username = String(claims.username); + if (claims.wallet) cur.wallet = norm(claims.wallet); + if (claims.sponsorRef) cur.sponsorRef = norm(claims.sponsorRef); + if (claims.joinedAt) cur.joinedAt = Number(claims.joinedAt); + cur.lastSeen = Date.now(); + h[id] = cur; + return h; + }); + return all[id]; +} +function hunter(memberId) { return store.read('hunters', {})[Number(memberId)] || null; } +function keysOf(h) { return [norm(h.username), String(h.memberId)].filter(Boolean); } + +// the referrer has to be a hunter themselves: we pay into a wallet we have seen, and nobody +// earns from a board they have never opened +function referrerOf(memberId) { + const me = hunter(memberId); + if (!me || !me.sponsorRef) return null; + const all = store.read('hunters', {}); + for (const k of Object.keys(all)) { + const x = all[k]; + if (Number(x.memberId) === Number(memberId)) continue; + if (keysOf(x).includes(me.sponsorRef)) return x; + } + return null; +} +function broughtBy(memberId) { + const me = hunter(memberId); + if (!me) return []; + const keys = keysOf(me); + return Object.values(store.read('hunters', {})) + .filter(x => Number(x.memberId) !== Number(memberId) && x.sponsorRef && keys.includes(x.sponsorRef)) + .sort((a, b) => (a.firstSeen || 0) - (b.firstSeen || 0)); +} + +// ---- the pool ------------------------------------------------------------------------------- +const refRows = () => store.read('payouts', []).filter(p => p.ref && p.status !== 'failed'); +function paidRefToday(day) { return refRows().filter(p => p.day === (day || ctDay())).reduce((n, p) => n + p.pol, 0); } +function refPool() { + const c = cfg(); + const cap = Number(c.refCapPol), today = round4(paidRefToday()); + return { capPol: cap, todayPol: today, left: round4(Math.max(0, cap - today)), resetsAt: rewards.nextResetAt() }; +} + +function write(referrer, pol, ref) { + const rec = { + id: Date.now().toString(36) + Math.random().toString(36).slice(2, 8), + memberId: Number(referrer.memberId), email: referrer.email || null, wallet: referrer.wallet, username: referrer.username || null, + missionId: 'ref:' + ref.kind, site: ref.kind === 'bounty' ? 'Referral bounty' : 'Referral match', + pol: round4(pol), day: ctDay(), at: Date.now(), + status: 'due', tx: null, paidAt: null, error: null, ref, + }; + store.update('payouts', [], all => { all.push(rec); return all; }); + return rec; +} +const bountyPaidFor = id => refRows().some(p => p.ref.kind === 'bounty' && Number(p.ref.forMemberId) === Number(id)); + +// ---- the hook: called right after a find is recorded ------------------------------------------ +// Returns the drips it created, so the caller can post the bounty to Telegram. +function onFind(member, find) { + const c = cfg(); + if (String(c.refEnabled) !== '1') return []; + const id = Number(member.memberId); + const me = hunter(id) || {}; + const referrer = referrerOf(id); + if (!referrer || !referrer.wallet || Number(referrer.memberId) === id) return []; + + const out = []; + let room = Number(c.refCapPol) - paidRefToday(); + const ageDays = me.joinedAt ? (Date.now() - Number(me.joinedAt)) / 86400000 : 0; + const who = { forMemberId: id, forName: me.username || ('#' + id) }; + + // the bounty, once, on their first find, and only while they are genuinely a new member + const finds = store.read('payouts', []).filter(p => Number(p.memberId) === id && !p.ref && !p.prize && p.status !== 'failed'); + if (finds.length <= 1 && ageDays <= Number(c.refNewDays) && !bountyPaidFor(id)) { + const pol = Number(c.refBountyPol); + if (pol <= room) { out.push(write(referrer, pol, Object.assign({ kind: 'bounty' }, who))); room -= pol; } + } + // the match, on every find inside the window, paid on top and never deducted from the newcomer + if (ageDays <= Number(c.refMatchDays)) { + const pol = round4(Number(find.pol) * Number(c.refMatchPct) / 100); + if (pol >= Number(c.refMatchMinPol) && pol <= room) out.push(write(referrer, pol, Object.assign({ kind: 'match', findId: find.id, findPol: find.pol }, who))); + } + return out; +} + +// ---- what the board shows --------------------------------------------------------------------- +function state(member) { + const c = cfg(); + const id = Number(member.memberId); + const me = hunter(id) || { memberId: id }; + const rows = refRows().filter(p => Number(p.memberId) === id); + const today = ctDay(); + const mine = broughtBy(id).map(x => { + const theirFinds = store.read('payouts', []).filter(p => Number(p.memberId) === Number(x.memberId) && !p.ref && !p.prize && p.status !== 'failed'); + const paid = bountyPaidFor(x.memberId); + return { + name: x.username || ('#' + x.memberId), + joinedAt: x.firstSeen || null, + finds: theirFinds.length, + state: paid ? 'earning' : theirFinds.length ? 'hunting' : 'signed up, no find yet', + stillNew: x.joinedAt ? (Date.now() - Number(x.joinedAt)) / 86400000 <= Number(c.refMatchDays) : false, + }; + }); + return { + on: String(c.refEnabled) === '1', + bountyPol: Number(c.refBountyPol), + matchPct: Number(c.refMatchPct), + matchDays: Number(c.refMatchDays), + brought: mine.length, + broughtToday: mine.filter(x => x.joinedAt && ctDay(x.joinedAt) === today).length, + earnedPol: round4(rows.reduce((n, p) => n + p.pol, 0)), + earnedTodayPol: round4(rows.filter(p => p.day === today).reduce((n, p) => n + p.pol, 0)), + people: mine.slice(0, 25), + pool: refPool(), + }; +} + +function totals() { + const rows = refRows(); + const paid = rows.filter(p => p.status === 'paid'); + return { + bounties: rows.filter(p => p.ref.kind === 'bounty').length, + matches: rows.filter(p => p.ref.kind === 'match').length, + pol: round4(rows.reduce((n, p) => n + p.pol, 0)), + paidPol: round4(paid.reduce((n, p) => n + p.pol, 0)), + referrers: new Set(rows.map(p => p.memberId)).size, + }; +} + +module.exports = { seen, hunter, referrerOf, broughtBy, onFind, state, totals, refPool, cfg, paidRefToday }; diff --git a/lib/rewards.js b/lib/rewards.js index d19f7e4..7d402dd 100644 --- a/lib/rewards.js +++ b/lib/rewards.js @@ -32,10 +32,12 @@ function draw(min, max, skew) { // prize drips (weekly prizes) are paid from the same wallet but never count against the daily pool // a hunter's finds today (Central), prizes excluded -function findsToday(memberId, day) { return store.read('payouts', []).filter(p => p.memberId === Number(memberId) && p.day === day && p.status !== 'failed' && !p.prize).length; } +// day defaults to today: called without it the comparison was against undefined and silently +// returned 0 for everyone, which would read as "no finds yet" to any new caller +function findsToday(memberId, day) { const d = day || ctDay(); return store.read('payouts', []).filter(p => p.memberId === Number(memberId) && p.day === d && p.status !== 'failed' && !p.prize && !p.ref).length; } function daily(memberId) { const s = settings(); const limit = Math.max(1, Number(s.missionsPerDay) || 3); const done = findsToday(memberId, ctDay()); return { limit, done, left: Math.max(0, limit - done) }; } function paidToday(day) { - return store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize).reduce((n, p) => n + p.pol, 0); + return store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize && !p.ref).reduce((n, p) => n + p.pol, 0); } // has this member completed this mission TODAY (Central)? Missions reset at midnight Central with the codes and @@ -43,12 +45,12 @@ function paidToday(day) { // two accounts sharing a wallet get one drip per mission per day between them. function completed(memberId, missionId, wallet) { const w = wallet ? String(wallet).toLowerCase() : null; const day = ctDay(); - return store.read('payouts', []).some(p => p.missionId === missionId && p.day === day && p.status !== 'failed' && !p.prize && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w))); + return store.read('payouts', []).some(p => p.missionId === missionId && p.day === day && p.status !== 'failed' && !p.prize && !p.ref && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w))); } // the mission ids this member (or wallet) has done today function doneToday(memberId, wallet) { const w = wallet ? String(wallet).toLowerCase() : null; const day = ctDay(); - return new Set(store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w))).map(p => p.missionId)); + return new Set(store.read('payouts', []).filter(p => p.day === day && p.status !== 'failed' && !p.prize && !p.ref && (p.memberId === Number(memberId) || (w && p.wallet && String(p.wallet).toLowerCase() === w))).map(p => p.missionId)); } // record a completion; decide paid-today vs queued diff --git a/lib/social.js b/lib/social.js index b9c7c95..460d761 100644 --- a/lib/social.js +++ b/lib/social.js @@ -24,7 +24,7 @@ const PRIZE_DEFAULTS = { weeklyPrizes: [3, 2, 1], weeklyMinFinds: 10 }; function prizeRules() { const s = rewards.settings(); return { pol: Array.isArray(s.weeklyPrizes) && s.weeklyPrizes.length ? s.weeklyPrizes.map(Number) : PRIZE_DEFAULTS.weeklyPrizes, minFinds: Number(s.weeklyMinFinds) > 0 ? Number(s.weeklyMinFinds) : PRIZE_DEFAULTS.weeklyMinFinds }; } 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' && !p.prize); } // prize drips are not finds +function finds() { return store.read('payouts', []).filter(p => p.status !== 'failed' && !p.prize && !p.ref); } // prize drips are not finds // 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); } diff --git a/public/app.html b/public/app.html index 30b3a71..5e1b674 100644 --- a/public/app.html +++ b/public/app.html @@ -5,7 +5,7 @@ Your board · PolHunter - +
@@ -46,6 +46,6 @@
- + diff --git a/public/app.js b/public/app.js index 58a6727..0a2b0f6 100644 --- a/public/app.js +++ b/public/app.js @@ -46,6 +46,26 @@ : '
') + ''; } + // The best-paid thing on the board, and the reason it sits first: sharing used to live on a page + // nobody opened, so nobody shared (Marty, 2026-09-23). + function referralCard() { + const rf = board.referral; if (!rf || !rf.on) return ''; + const link = (board.share && board.share.link) || ''; + const today = rf.broughtToday, total = rf.brought; + const people = (rf.people || []).slice(0, 5).map(p => + '
' + esc(p.name) + '' + esc(p.state) + '
').join(''); + return '
' + + 'Best paid' + + '

🤝 Bring a hunter

' + + '

Send your link. When someone new opens PolHunter and finds their first code, you get ' + rf.bountyPol + ' POL. ' + + 'After that you earn ' + rf.matchPct + '% on top of everything they find for their first ' + rf.matchDays + ' days. ' + + 'It comes out of our pool, never theirs.

' + + '
' + + '

' + today + ' brought today ' + + '· ' + total + ' all time · ' + rf.earnedPol + ' POL earned

' + + (people ? '
' + people + '
' : '

Nobody yet. One message is all it takes.

') + + '
'; + } const IAP = 'https://instantadpay.com'; function until(ms) { const s = Math.max(0, Math.round((ms - Date.now()) / 60000)); const h = Math.floor(s / 60), m = s % 60; return h ? h + 'h ' + m + 'm' : m + 'm'; } // no wallet on the InstantAdPay account yet: the three steps, in place of the missions @@ -83,7 +103,14 @@ const pool = board.pool || {}; if (!board.me.wallet) $('missions').innerHTML = onboarding(); else $('missions').innerHTML = (pool.spent ? '
Today\u2019s POL pool is spent. No more claims today. Claims reopen at midnight Central' + (pool.resetsAt ? ', in ' + until(pool.resetsAt) : '') + '.
' : '') + + referralCard() + (board.missions.length ? board.missions.map(card).join('') : '

No missions are open right now. Check back soon.

'); + const rc = $('refCopy'); + if (rc) rc.addEventListener('click', async () => { + const v = $('refLink').value; + try { await navigator.clipboard.writeText(v); } catch (e) { $('refLink').select(); document.execCommand('copy'); } + rc.textContent = 'Copied'; setTimeout(() => { rc.textContent = 'Copy'; }, 1600); + }); // standing: badges and rank const rk = board.rank || {}; const line = []; if (rk.week) line.push('This week #' + rk.week.rank + ' of ' + rk.week.of); if (rk.all) line.push('All time #' + rk.all.rank + ' of ' + rk.all.of); diff --git a/public/style.css b/public/style.css index 2fadcfb..b264bd2 100644 --- a/public/style.css +++ b/public/style.css @@ -111,3 +111,10 @@ textarea{width:100%;padding:12px 14px;border-radius:12px;border:1px solid var(-- @media (max-width:820px){.hero{min-height:0;margin:0 -20px 6px;border-top:0;display:block}.hero-art{position:relative;inset:auto;display:block;height:270px}.hero-art img{object-position:60% 50%}.hero::before{inset:auto 0 auto 0;top:150px;height:121px;background:linear-gradient(180deg,transparent,var(--bg))}.hero-copy{padding:6px 20px 32px}.hero p{font-size:16.5px}.play{right:auto;left:14px;bottom:auto;top:214px;padding:9px 16px 9px 10px;font-size:13px;white-space:nowrap}.play .tri{width:28px;height:28px}.play .tri::after{left:10px;top:7px}.play .lbl-long{display:none}.nav a:nth-child(2),.nav a:nth-child(3){display:none}.adslot{overflow-x:auto;justify-content:flex-start}.row{grid-template-columns:1fr auto;row-gap:6px}.row .when{grid-column:1/-1;font-size:13px;color:var(--pol-hi)}.adslot{margin:18px -4px}} /* every drip row carries its blockchain verify link; on phones it sits on its own line (Marty, 2026-09-19) */ .row a.when{color:var(--pol-hi)} .row a.when:hover{color:var(--gold-hi)} + +/* Bring a hunter: the referral card sits first in the mission grid (Marty, 2026-09-23) */ +.ref-card{border-color:rgba(243,190,67,.34);background:linear-gradient(170deg,rgba(243,190,67,.07),rgba(0,0,0,0) 60%)} +.ref-card .codebox input{font:500 13px var(--mono);letter-spacing:0;text-transform:none} +.ref-list{margin-top:12px;border-top:1px solid var(--edge);padding-top:8px} +.ref-row{display:flex;justify-content:space-between;gap:10px;font-size:13px;padding:4px 0} +.ref-row .ref-state{color:var(--dim);font-size:12px;text-align:right} diff --git a/server.js b/server.js index 1c24c3a..c30c8e6 100644 --- a/server.js +++ b/server.js @@ -20,6 +20,7 @@ const missions = require('./lib/missions'); const rewards = require('./lib/rewards'); const social = require('./lib/social'); const badge = require('./lib/badge'); +const referrals = require('./lib/referrals'); // pays a hunter for bringing someone who turns up and hunts const faucet = require('./lib/faucet'); const PORT = Number(process.env.PORT || 3000); @@ -71,6 +72,8 @@ async function notifyPaid(p) { async function notifyOther(kind, p) { if (kind === 'low') return telegram('⚠️ PolHunter faucet is low: ' + fmt(p.balance) + ' POL left in ' + p.address + ' (alert threshold ' + fmt(p.threshold) + '). Top up from Receiver B.'); if (kind === 'failed') return telegram('❌ PolHunter · drip to #' + p.memberId + ' failed: ' + p.error); + // a bounty is the one payout that advertises the mission itself, so it goes to the room + if (kind === 'referral') { const d = p.drip; return telegram('\u{1F91D} PolHunter · ' + (d.username ? '@' + d.username : '#' + d.memberId) + ' brought ' + d.ref.forName + ' in and they are hunting · ' + fmt(d.pol) + ' POL bounty on the way\nBring yours'); } if (kind === 'prize') { const medal = ['\u{1F947}', '\u{1F948}', '\u{1F949}']; return telegram('\u{1F3C6} PolHunter weekly prizes for the week of ' + p.week + '\n' + p.winners.map(w => (medal[w.rank - 1] || '#' + w.rank) + ' ' + w.who + ' \u00b7 ' + w.finds + ' finds \u00b7 ' + fmt(w.prizePol) + ' POL').join('\n') + '\nLeaderboard'); } return false; } @@ -198,6 +201,7 @@ const server = http.createServer(async (req, res) => { const cur = sso.fromRequest(req); let sid = cur && cur.memberId === Number(v.claims.memberId) ? cur.sid : null; if (sid) sso.refresh(sid, v.claims); else sid = sso.startSession(v.claims); + referrals.seen(v.claims); // keep the hunter directory (and the sponsor behind it) current on every sign-in res.writeHead(302, { Location: '/app', 'Set-Cookie': sso.cookie(sid), 'Cache-Control': 'no-store' }); return res.end(); } 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(); } @@ -209,16 +213,18 @@ const server = http.createServer(async (req, res) => { if (p === '/api/prizes') return json(res, 200, social.prizes(), { 'Cache-Control': 'public, max-age=60' }); 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 })) }); } + if (p === '/api/ledger') { const t = rewards.totals(); return json(res, 200, { totals: t, referrals: referrals.totals(), pools: { finds: rewards.pool(), referral: referrals.refPool() }, 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) if (p.startsWith('/api/my/')) { 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') { + referrals.seen(me); // hunters already signed in never pass through /auth again, so keep the directory fresh here too const done = rewards.doneToday(me.memberId, me.wallet); const wdone = done; // today's finds, by member id or wallet 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(), daily: rewards.daily(me.memberId), badges: social.badgesFor(me.memberId), badgeCards: (() => { const got = new Set(social.badgesFor(me.memberId).map(b => b.id)); const who = social.nameOf(me); return social.BADGES.map(b => ({ id: b.id, name: b.name, icon: b.icon, why: b.why, art: '/badges/badge-' + b.id + '.jpg', earned: got.has(b.id), page: got.has(b.id) ? SITE + '/b/' + who + '/' + b.id : null, image: got.has(b.id) ? SITE + '/badge-img/' + who + '/' + b.id + '.jpg' : null })); })(), 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)) + '?from=polhunter' } }); + share: { link: social.shareLink(SITE, me), joinUrl: 'https://instantadpay.com/join/' + encodeURIComponent(String(me.username || me.memberId)) + '?from=polhunter' }, + referral: referrals.state(me) }); } 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.' }); @@ -244,6 +250,10 @@ const server = http.createServer(async (req, res) => { { const d = rewards.daily(me.memberId); if (!d.left) return json(res, 400, { error: dailyMsg(d), dailyDone: true, resetsAt: rewards.pool().resetsAt }); } const before = new Set(social.badgesFor(me.memberId).map(b => b.id)); const g = rewards.grant(me, m); if (g.error) return json(res, 400, g); + // whoever brought this hunter gets paid for it: the bounty on their first find, a match while they are new + let refDrips = []; + try { refDrips = referrals.onFind(me, g.rec); } catch (e) { console.error('referral', e.message); } + for (const d of refDrips) if (d.ref.kind === 'bounty') notify('referral', { drip: d }).catch(() => {}); // achievements unlocked by this find: told to the board, posted to Telegram (gated) const unlocked = social.badgesFor(me.memberId).filter(b => !before.has(b.id)); for (const b of unlocked) notify('badge', { me, id: b.id }).catch(() => {}); @@ -255,7 +265,7 @@ const server = http.createServer(async (req, res) => { // ---- admin (key) if (p.startsWith('/api/admin/')) { if (!admin(req)) return json(res, 401, { error: 'Admin key required.' }); - if (p === '/api/admin/state') return json(res, 200, { missions: missions.list(), settings: rewards.settings(), totals: rewards.totals(), faucet: { on: faucetOn, address: faucet.address(), state: store.read('faucet-state', {}) }, payouts: store.read('payouts', []).slice(-100).reverse(), gates: { outbound: outbound(), signups: signupsOpen(), curtain: !!CURTAIN, sso: sso.enabled() } }); + if (p === '/api/admin/state') return json(res, 200, { missions: missions.list(), settings: rewards.settings(), totals: rewards.totals(), referrals: referrals.totals(), referralPool: referrals.refPool(), faucet: { on: faucetOn, address: faucet.address(), state: store.read('faucet-state', {}) }, payouts: store.read('payouts', []).slice(-100).reverse(), gates: { outbound: outbound(), signups: signupsOpen(), curtain: !!CURTAIN, sso: sso.enabled() } }); if (p === '/api/admin/mission' && req.method === 'POST') { const b = await readBody(req); const id = String(b.id || '').trim().toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 40); if (!id) return json(res, 400, { error: 'id required' }); @@ -266,7 +276,7 @@ const server = http.createServer(async (req, res) => { return json(res, 200, { ok: true, missions: missions.list() }); } 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', 'weeklyMinFinds', 'drawSkew', 'missionsPerDay']) if (b[k] != null && Number(b[k]) >= 0) patch[k] = Number(b[k]); for (const k of ['weeklyPrizes', 'leaderboardExclude']) if (Array.isArray(b[k])) patch[k] = b[k].map(Number).filter(n => n >= 0); return json(res, 200, { ok: true, settings: rewards.setSettings(patch) }); } + if (p === '/api/admin/settings' && req.method === 'POST') { const b = await readBody(req); const patch = {}; for (const k of ['minPol', 'maxPol', 'dailyCapPol', 'lowBalancePol', 'weeklyMinFinds', 'drawSkew', 'missionsPerDay', 'refEnabled', 'refBountyPol', 'refMatchPct', 'refMatchDays', 'refNewDays', 'refCapPol', 'refMatchMinPol']) if (b[k] != null && Number(b[k]) >= 0) patch[k] = Number(b[k]); for (const k of ['weeklyPrizes', 'leaderboardExclude']) if (Array.isArray(b[k])) patch[k] = b[k].map(Number).filter(n => n >= 0); return json(res, 200, { ok: true, settings: rewards.setSettings(patch) }); } // award a week by hand (its Monday key); already-awarded weeks are skipped if (p === '/api/admin/prizes/award' && req.method === 'POST') { const b = await readBody(req); const r = social.awardWeek(String(b.week || '')); if (r && r.winners && r.winners.length) notify('prize', r).catch(() => {}); return json(res, r && r.error ? 400 : 200, r); } 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 }); }