Earn: video stops when leaving the tab/pane, 'all watched today' message, open earn tokens survive a redeploy; admin member search matches as you type, clearer old-site line; QA earn harness follows the done screen

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-13 06:46:08 -05:00
parent 0c7f957db4
commit 4efba20d4e
7 changed files with 85 additions and 16 deletions
BIN
View File
Binary file not shown.
+4 -3
View File
@@ -234,8 +234,9 @@
<div class="pane" id="pane-members" hidden>
<div class="card" id="memSearchCard">
<div class="card-head"><h3>Find a member</h3><span class="sub">email, @username, member #, share code or wallet address</span></div>
<div style="display:flex;gap:8px;flex-wrap:wrap"><input id="memSearch" placeholder="jim@example.com, @teameb, #24, 0x1234…" style="flex:1;min-width:240px"><button type="button" class="btn small" id="memOpen">Open</button></div>
<div class="card-head"><h3>Find a member</h3><span class="sub">matches as you type; Enter opens the first match</span></div>
<div style="display:flex;gap:8px;flex-wrap:wrap"><input id="memSearch" autocomplete="off" placeholder="Start typing a name, username, email, member # or wallet…" style="flex:1;min-width:240px"><button type="button" class="btn small" id="memOpen">Open</button></div>
<div id="memHits" hidden style="margin-top:6px;border:1px solid var(--line);border-radius:10px;overflow:hidden"></div>
<p class="small" id="memSearchMsg" hidden style="margin:8px 0 0"></p>
</div>
<div class="card" id="memCard" hidden>
@@ -420,6 +421,6 @@
</div>
<script src="/assets/common.js?v=20260913a"></script>
<script src="/assets/admin.js?v=20260913a"></script>
<script src="/assets/admin.js?v=20260913b"></script>
</body>
</html>
+22 -4
View File
@@ -318,7 +318,7 @@
}
function kv(rows) { return '<table class="adm-table kv">' + rows.map(r => '<tr><th style="width:170px">' + r[0] + '</th><td>' + r[1] + '</td></tr>').join('') + '</table>'; }
function renderMember(d) {
mcCur = d; const a = d.account;
mcCur = d; const a = d.account; $('memHits').hidden = true;
$('memCard').hidden = false; document.querySelectorAll('#pane-members > .card').forEach(c => { if (c.id !== 'memCard' && c.id !== 'memSearchCard') c.hidden = true; });
$('mcName').textContent = (a.username ? '@' + a.username : a.email) + (a.memberId ? ' · member #' + a.memberId : ' · free member');
$('mcSub').textContent = 'joined ' + when(a.created) + ' · last seen ' + ago(a.lastSeen);
@@ -342,7 +342,7 @@
['Payouts received', t.payoutsIn + (t.payoutsIn ? ' · ' + polOf(t.receivedWei) + ' POL' : '')],
['Credits', cr ? cr.available.toLocaleString() + ' available · ' + cr.inCampaigns.toLocaleString() + ' in campaigns · ' + cr.total.toLocaleString() + ' total' : '<span class="muted">-</span>'],
['Earned pool', d.earnedSplit ? d.earnedSplit.total.toLocaleString() + ' (' + (d.earnedSplit.grade || 0).toLocaleString() + ' purchased-grade)' : '-'],
['Legacy', d.legacy ? esc(d.legacy.brand) + ' ' + d.legacy.seg + (d.legacy.grant ? ' · ' + d.legacy.grant.credits + ' credits granted ' + when(d.legacy.grant.at) : ' · not granted') : '<span class="muted">not on the legacy list</span>'],
['Old-site account', d.legacy ? 'had a ' + (d.legacy.brand === 'both' ? 'Faucet Wave and Tier One Ads' : d.legacy.brand === 'tier1ads' ? 'Tier One Ads' : 'Faucet Wave') + ' account (' + d.legacy.seg + ') · welcome-back credits ' + (d.legacy.grant ? d.legacy.grant.credits + ' issued ' + when(d.legacy.grant.at) : 'not issued (joined outside the legacy bridge)') : '<span class="muted">none on record</span>'],
['Promo codes', d.promos.length ? d.promos.map(p => esc(p.code) + ' (' + p.credits + ', ' + when(p.ts) + ')').join('<br>') : '<span class="muted">none</span>'],
['Drip', d.drip ? (d.drip.stopped ? 'stopped' : 'step ' + d.drip.step + ', next ' + when(d.drip.next_at)) + (d.drip.angle ? ' · ' + esc(d.drip.angle) : '') : '<span class="muted">-</span>'],
['Holding tank', d.tank ? (d.tank.waiting ? '<b>waiting for a sponsor</b>' : 'not in tank') + (d.tank.adoptedBy.length ? ' · adopted by ' + d.tank.adoptedBy.map(x => memLink(x.email, x.name)).join(', ') : '') + (d.tank.adopted.length ? ' · adopted ' + d.tank.adopted.map(x => memLink(x.email, x.name)).join(', ') : '') : '-'],
@@ -366,8 +366,26 @@
if (location.hash !== '#members') history.replaceState(null, '', '#members');
}
function closeMember() { $('memCard').hidden = true; document.querySelectorAll('#pane-members > .card').forEach(c => { c.hidden = false; }); }
$('memOpen').addEventListener('click', () => { const q = $('memSearch').value.trim(); if (q) openMember(q); });
$('memSearch').addEventListener('keydown', e => { if (e.key === 'Enter') $('memOpen').click(); });
// live matches while typing: any part of the username, email, member #, share code or wallet
let memHitList = [];
function memMatches(q) {
q = q.toLowerCase();
return allMembers.filter(a => [a.username, a.email, a.memberId ? '#' + a.memberId : '', a.memberId, a.code, a.address, a.sponsorName].filter(Boolean).join(' ').toLowerCase().includes(q)).slice(0, 12);
}
async function memTypeahead() {
const q = $('memSearch').value.trim();
if (!allMembers.length) { try { const r = await api('/api/admin/members'); allMembers = r.members || []; } catch (e) {} }
if (q.length < 2) { $('memHits').hidden = true; memHitList = []; return; }
memHitList = memMatches(q);
$('memHits').innerHTML = memHitList.length ? memHitList.map(a => '<button type="button" data-mcopen="' + esc(a.email) + '" style="display:flex;gap:12px;width:100%;text-align:left;background:transparent;border:0;border-bottom:1px solid var(--line);padding:8px 12px;color:inherit;cursor:pointer;font:inherit"><b style="min-width:140px">' + (a.username ? '@' + esc(a.username) : '<span class="muted">no username</span>') + '</b><span>' + esc(a.email) + '</span><span class="muted">' + (a.memberId ? '#' + a.memberId : 'free') + (a.sponsorName ? ' · under ' + esc(a.sponsorName) : '') + '</span></button>').join('')
: '<p class="muted small" style="margin:0;padding:8px 12px">No member matches that.</p>';
$('memHits').hidden = false;
}
$('memSearch').addEventListener('input', memTypeahead);
$('memSearch').addEventListener('focus', memTypeahead);
$('memOpen').addEventListener('click', () => { const q = $('memSearch').value.trim(); if (!q) return; if (memHitList.length) openMember(memHitList[0].email); else openMember(q); });
$('memSearch').addEventListener('keydown', e => { if (e.key === 'Enter') $('memOpen').click(); if (e.key === 'Escape') $('memHits').hidden = true; });
document.addEventListener('click', e => { if (!e.target.closest('#memSearchCard')) $('memHits').hidden = true; });
$('mcBack').addEventListener('click', closeMember);
document.addEventListener('click', e => { const l = e.target.closest('[data-mcopen]'); if (l) { e.preventDefault(); openMember(l.dataset.mcopen); } });
document.querySelectorAll('[data-mcact]').forEach(b => b.addEventListener('click', busy(b, async () => {
+17 -2
View File
@@ -513,6 +513,7 @@
IAP.adSlot('text', 'adStripTop');
if ($('adSlotPane-' + name)) IAP.adSlot('banner', 'adSlotPane-' + name);
if (name === 'earn') setEarnSub(earnSub); // refresh whichever sub-tab is active
else if (vidState.token) stopVideo();
if (name === 'profile') loadLineBanner();
if (name === 'line') { loadLineage(); loadUplineMessages(); loadCoach(); loadLinkStats(); loadProspects(); }
if (name === 'campaigns') ['cTarget', 'cImage', 'cVideoUrl'].forEach(id => { if ($(id)) $(id).value = ''; }); // no residual URL between visits
@@ -989,13 +990,26 @@
$('vidStartBtn').hidden = false;
} catch (e) {}
}
// leaving the player (other sub-tab, other pane, page hidden) stops the clip: nothing plays or earns in the background (Marty, 2026-09-13)
function stopVideo() {
const p = $('vidPlayer'); if (!p) return;
const was = !!vidState.token && !vidState.done;
try { p.pause(); p.ontimeupdate = null; p.onseeking = null; p.removeAttribute('src'); p.load(); } catch (e) {}
vidState.token = null; vidState.done = false; vidState.credited = false; vidState.maxSeen = 0;
if ($('vidWrap')) $('vidWrap').hidden = true;
if ($('vidBox')) { $('vidBox').hidden = false; if (was) $('vidBox').innerHTML = '<span class="muted small">Video stopped when you left the tab. Tap Load a video to start a fresh one.</span>'; }
if ($('vidStartBtn')) { $('vidStartBtn').disabled = false; $('vidStartBtn').textContent = 'Load a video'; }
}
document.addEventListener('visibilitychange', () => { if (document.hidden && vidState.token && !vidState.done) stopVideo(); });
async function loadVideoAd() {
let r = null;
try { r = await (await fetch('/api/my/videos?orientation=landscape')).json(); } catch (e) {}
if (!r || !r.ad) {
$('vidBox').innerHTML = '<span class="muted small">' + (r && r.status && r.status.left <= 0
? 'That is today\'s video set. Come back tomorrow.'
: r && r.allWatched ? 'You have watched every live video for today (each one pays once a day). New ones appear as members launch video campaigns.'
: 'No member videos are live right now. Check back when a campaign is running.') + '</span>';
$('vidBox').hidden = false;
$('vidWrap').hidden = true;
return;
}
@@ -1029,10 +1043,10 @@
try {
const r = await (await fetch('/api/my/videowatch', { method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: vidState.token }) })).json();
if (r.error) { IAP.status(r.error, 'bad'); $('vidHint').textContent = r.error; }
if (r.error) { IAP.status(r.error, 'bad'); $('vidHint').textContent = r.error + (/no longer open|stale/.test(r.error) ? ' Loading a fresh one.' : ''); if (/no longer open|stale/.test(r.error)) setTimeout(() => loadVideoAd(), 1200); }
else if (r.credited) { IAP.status('+' + r.credited + ' credits earned for watching.', 'ok'); $('vidHint').textContent = '+' + r.credited + ' credits earned. Load the next one.'; loadDashboard(); }
else $('vidHint').textContent = 'That video just ran out of budget — load another.';
$('vidProgress').textContent = 'today: ' + ((r.status && r.status.count) || 0) + ' / ' + ((r.status && r.status.cap) || 0) + ' videos watched';
if (r.status) $('vidProgress').textContent = 'today: ' + (r.status.count || 0) + ' / ' + r.status.cap + ' videos watched';
} catch (e) { IAP.status('Could not confirm that watch. Try the next one.', 'bad'); }
$('vidStartBtn').disabled = false;
$('vidStartBtn').textContent = 'Next video';
@@ -1305,6 +1319,7 @@
let earnSub = 'watch';
function setEarnSub(which) {
earnSub = ['inbox', 'videos', 'visits'].includes(which) ? which : 'watch';
if (earnSub !== 'videos' && vidState.token) stopVideo();
const w = $('earn-watch'), i = $('earn-inbox'), v = $('earn-videos'), vs = $('earn-visits');
if (w) w.hidden = earnSub !== 'watch';
if (i) i.hidden = earnSub !== 'inbox';
+1 -1
View File
@@ -915,7 +915,7 @@
<script src="/assets/common.js?v=20260913a"></script>
<script src="/assets/wallet.js?v=20260911a"></script>
<script src="/assets/promo.js?v=20260911a"></script>
<script src="/assets/my.js?v=20260913a"></script>
<script src="/assets/my.js?v=20260913b"></script>
<script src="/assets/chat.js?v=20260907l"></script>
</body>
</html>
+2 -1
View File
@@ -69,7 +69,8 @@ for (let i = 0; i < 5; i++) {
}
await page.waitForTimeout(800);
log('after set:', await page.textContent('#earnProgress'), '| claim visible:', !!(await page.$('#earnClaimBtn:not([hidden])')));
await page.click('#earnStartBtn'); await page.waitForTimeout(900); log('view-after-complete says:', (await page.textContent('#earnAdBox')).trim());
if (await page.$('#earnStartBtn:not([hidden])')) { await page.click('#earnStartBtn'); await page.waitForTimeout(900); log('view-after-complete says:', (await page.textContent('#earnAdBox')).trim()); }
else log('view button hidden after the set (done screen with claim), as designed since 2026-09-12');
if (await page.$('#earnClaimBtn:not([hidden])')) { await page.click('#earnClaimBtn'); await page.waitForTimeout(1000); log('claimed; balance:', await page.textContent('#earnBalance')); }
else if (credited === 5) problems.push('watch: 5 views credited but claim button not shown');
+39 -5
View File
@@ -72,10 +72,40 @@ fs.mkdirSync(DATA_DIR, { recursive: true });
const UPLOADS_DIR = path.join(DATA_DIR, 'uploads'); // solo-ad media lives on the volume
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
const uploadCounts = new Map(); // email:day -> uploads today
const gauntletTokens = new Map(); // email -> welcome-tour token (server-clock dwell floor)
const videoTokens = new Map(); // email -> watch-to-earn video token (server-clock watch floor)
// open earn tokens (ad view / video / visit / tour) live in memory but mirror to the volume so a
// redeploy mid-watch does not lose them (2026-09-13: a member's video watch died in a restart)
const OPEN_TOKENS_FILE = () => path.join(DATA_DIR, 'open-tokens.json');
const persistedMaps = {};
let tokenSaveTimer = null;
function saveOpenTokens() {
tokenSaveTimer = null;
try {
const out = {};
for (const [name, m] of Object.entries(persistedMaps)) out[name] = Object.fromEntries(m);
fs.writeFileSync(OPEN_TOKENS_FILE(), JSON.stringify(out));
} catch (e) {}
}
function persistedMap(name) {
const m = new Map();
const touch = () => { if (!tokenSaveTimer) tokenSaveTimer = setTimeout(saveOpenTokens, 500); };
const set = m.set.bind(m), del = m.delete.bind(m);
m.set = (k, v) => { set(k, v); touch(); return m; };
m.delete = k => { const r = del(k); if (r) touch(); return r; };
persistedMaps[name] = m;
return m;
}
function loadOpenTokens() {
let saved = null; try { saved = JSON.parse(fs.readFileSync(OPEN_TOKENS_FILE(), 'utf8')); } catch (e) { return; }
const cutoff = Date.now() - 2 * 3600 * 1000; let n = 0;
for (const [name, m] of Object.entries(persistedMaps)) {
for (const [k, v] of Object.entries(saved[name] || {})) if (v && Number(v.ts || 0) > cutoff) { Map.prototype.set.call(m, k, v); n++; }
}
if (n) console.log('open earn tokens restored:', n);
}
const gauntletTokens = persistedMap('gauntlet'); // email -> welcome-tour token (server-clock dwell floor)
const videoTokens = persistedMap('video'); // email -> watch-to-earn video token (server-clock watch floor)
const faucetHits = new Map(); // address -> last faucet ts (rehearsal test-POL faucet rate limit)
const visitTokens = new Map(); // email -> verified-visit token (dwell + captcha floor)
const visitTokens = persistedMap('visit'); // email -> verified-visit token (dwell + captcha floor)
// walk the referral chain upward via sponsorRef (code/username/member id)
async function uplineSlides(email, depth = 3) {
const out = [];
@@ -189,7 +219,7 @@ function codeTrip(req, ip) {
else if (ADMIN_EMAIL && mailer.hasKey()) mailer.send(ADMIN_EMAIL, 'InstantAdPay: sign-up guard tripped', text).catch(() => {});
}
// earn-view tokens: emailLower -> {token, ts} (one live token per member)
const earnTokens = new Map();
const earnTokens = persistedMap('earn');
// human-check pairs for the view verifier: [emoji shown, word named in the prompt]
const CAPTCHA = [['🚀', 'rocket'], ['⚡', 'lightning bolt'], ['🔑', 'key'], ['🎯', 'target'],
['🌊', 'wave'], ['🔥', 'flame'], ['💎', 'diamond'], ['🧲', 'magnet'], ['🔔', 'bell'], ['🌙', 'moon']];
@@ -332,6 +362,7 @@ async function boot() {
promos.init({ dataDir: DATA_DIR });
blog.init({ dataDir: DATA_DIR });
adminMember.init({ accounts, ads, chain, tank, legacy, promos, messages, dataDir: DATA_DIR });
loadOpenTokens();
setInterval(() => tankNotifyTick().catch(e => console.error('tank notify', e.message)), 15 * 60 * 1000); // new tank arrivals -> Telegram
geo.init({ dataDir: DATA_DIR }).catch(e => console.error('geo init', e.message));
setInterval(() => geo.refresh().catch(e => console.error('geo refresh', e.message)), 24 * 3600 * 1000); // monthly file, checked daily
@@ -1775,7 +1806,10 @@ const server = http.createServer(async (req, res) => {
if (status.left <= 0) return json(res, 200, { ad: null, status });
const orientation = String(u.searchParams.get('orientation') || ''); // 'portrait' = Shorts reel, 'landscape' = Watch videos tab
const ad = await ads.serveVideo(Object.assign({ excludeEmail: s.email, orientation }, viewerGeo(req))); // never your own video
if (!ad) return json(res, 200, { ad: null, status });
if (!ad) { // distinguish "you have watched every live video today" from "nothing is live" (Marty, 2026-09-13)
let allWatched = false; try { allWatched = !!(await ads.serveVideo(Object.assign({ excludeEmail: s.email, orientation, ignoreSeen: true }, viewerGeo(req)))); } catch (e) {}
return json(res, 200, { ad: null, status, allWatched });
}
const token = crypto.randomBytes(16).toString('hex');
videoTokens.set(s.email, { token, ts: Date.now(), id: ad.id, secs: ad.watchSecs });
return json(res, 200, { ad, token, status });