Earn viewer v2: full-screen URL visits with countdown, human check, explicit return

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-05 07:23:30 -05:00
parent fee86d296a
commit 15b036b940
7 changed files with 238 additions and 47 deletions
+16 -30
View File
@@ -426,47 +426,33 @@
} catch (e) { return null; }
}
async function earnShowAd() {
// each view happens full screen in its own tab: /view/<token> frames the
// advertiser's site, runs the countdown, then a human check credits it
const type = earnState.types[earnState.i++ % earnState.types.length];
let r = null;
try { r = await (await fetch('/api/my/earnview?type=' + type)).json(); } catch (e) {}
const box = $('earnAdBox');
if (!r || !r.ad) {
if (!r || !r.ad || !r.viewUrl) {
await earnRefresh();
box.innerHTML = '<span class="muted small">' + (r && r.status && r.status.views >= r.status.target
? 'Set complete for today.'
: 'No member ads are live in rotation right now. Views resume the moment a campaign is running.') + '</span>';
return;
}
const ad = r.ad;
box.innerHTML = ad.imageUrl
? '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow"><img src="' + ad.imageUrl + '" alt="member ad" style="max-height:200px"></a>'
: '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow" style="font-size:17px"><b>' + ad.title + '</b>' + (ad.body ? '<br><span class="muted">' + ad.body + '</span>' : '') + '</a>';
const btn = $('earnNextBtn');
btn.hidden = false;
btn.disabled = true;
// the countdown only runs while this tab is visible AND focused; the
// server separately enforces the full dwell on its own clock
let left = r.status.dwell;
btn.textContent = 'Next ad (' + left + 's)';
clearInterval(earnState.timer);
earnState.timer = setInterval(async () => {
if (document.visibilityState !== 'visible' || !document.hasFocus()) {
btn.textContent = 'Paused: stay on this tab (' + left + 's)';
return;
}
left -= 1;
if (left > 0) { btn.textContent = 'Next ad (' + left + 's)'; return; }
clearInterval(earnState.timer);
btn.textContent = 'Next ad';
const v = await (await fetch('/api/my/adview', { method: 'POST',
headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: r.token }) })).json();
if (v.error) { IAP.status(v.error, 'bad'); btn.disabled = false; return; }
btn.disabled = false;
await earnRefresh();
}, 1000);
const w = window.open(r.viewUrl, '_blank');
box.innerHTML = '<span class="muted small">Ad open in its own tab. Watch the countdown, pass the quick '
+ 'check, and the view credits itself — this page updates the moment it does.</span>'
+ (w ? '' : '<br><a href="' + r.viewUrl + '" target="_blank" rel="noopener">Pop-up blocked — open the ad here.</a>');
$('earnStartBtn').textContent = 'View next ad';
}
$('earnStartBtn').addEventListener('click', () => { $('earnStartBtn').hidden = true; earnShowAd(); });
$('earnNextBtn').addEventListener('click', earnShowAd);
$('earnStartBtn').addEventListener('click', () => earnShowAd());
// the viewer tab pings localStorage when a view credits; refresh instantly
window.addEventListener('storage', e => {
if (e.key === 'iap-view-done') { earnRefresh(); loadDashboard(); }
});
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && $('pane-earn') && !$('pane-earn').hidden) earnRefresh();
});
$('earnClaimBtn').addEventListener('click', async () => {
try {
const r = await api('/api/my/claim');
+106
View File
@@ -0,0 +1,106 @@
// Full-screen ad viewer: the advertiser URL fills the tab, a countdown runs in
// the top bar (paused whenever this tab loses focus), and once the dwell is
// done the server hands out a human check. Solve it and the view credits.
// The dwell floor is enforced on the SERVER clock; this UI cannot cheat it.
(() => {
const $ = id => document.getElementById(id);
const token = location.pathname.split('/').pop();
let left = 5, timer = null, credited = false, asking = false;
const setMsg = t => { $('vMsg').textContent = t; };
async function j(url, body) {
const r = await fetch(url, body
? { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }
: undefined);
return r.json();
}
function tick() {
if (credited || asking) return;
if (document.visibilityState !== 'visible' || !document.hasFocus()) {
$('vTimer').classList.add('paused');
$('vTimer').textContent = 'paused';
setMsg('Come back to this tab to keep the countdown moving.');
return;
}
$('vTimer').classList.remove('paused');
left = Math.max(0, left - 0.25);
$('vTimer').textContent = Math.ceil(left) + 's left';
if (left <= 0) { clearInterval(timer); askChallenge(); }
}
async function askChallenge() {
asking = true;
$('vTimer').textContent = 'check';
$('vTimer').classList.add('done');
setMsg('One quick check to count the view:');
let c = await j('/api/my/viewchallenge?token=' + token);
if (c.early) { // server clock says not quite yet: wait it out and re-ask
await new Promise(r => setTimeout(r, (c.wait || 1) * 1000 + 300));
c = await j('/api/my/viewchallenge?token=' + token);
}
if (c.error) return fail(c.error);
renderChallenge(c);
}
function renderChallenge(c) {
$('vPrompt').textContent = 'Click the ' + c.prompt + ':';
const w = $('vOpts');
w.innerHTML = '';
c.options.forEach((em, i) => {
const b = document.createElement('button');
b.type = 'button';
b.textContent = em;
b.addEventListener('click', () => answer(i));
w.appendChild(b);
});
$('vCheck').classList.add('on');
}
async function answer(i) {
const r = await j('/api/my/adview', { token, answer: i });
if (r.error) {
if (r.retry) {
setMsg('Not that one — try again.');
const c = await j('/api/my/viewchallenge?token=' + token);
if (!c.error) return renderChallenge(c);
return fail(c.error);
}
return fail(r.error);
}
credited = true;
$('vCheck').classList.remove('on');
$('vTimer').textContent = '✓ credited';
const st = r.status || r;
setMsg('View ' + st.views + ' of ' + st.target + ' counted for today.'
+ (st.views >= st.target && !st.claimed ? ' Head back and claim your credits.' : ''));
$('vDone').classList.add('on');
try { localStorage.setItem('iap-view-done', String(Date.now())); } catch (e) {}
}
function fail(msg) {
credited = true; // stop the loop; this view is over either way
if (timer) clearInterval(timer);
$('vCheck').classList.remove('on');
$('vTimer').textContent = '—';
setMsg(msg);
$('vDone').classList.add('on');
}
$('vClose').addEventListener('click', () => {
window.close();
setMsg('You can close this tab now.');
});
(async () => {
const info = await j('/api/my/viewinfo?token=' + token);
if (info.error) {
$('vFrame').remove();
const d = document.createElement('div');
d.className = 'vfail';
d.textContent = info.error;
document.querySelector('.vw').appendChild(d);
$('vDone').classList.add('on');
$('vTimer').textContent = '—';
setMsg('');
return;
}
left = info.dwell || 5;
$('vFrame').src = info.targetUrl;
setMsg('Watching: ' + (info.adName || 'member ad') + ' — stay on this tab.');
$('vTimer').textContent = left + 's left';
timer = setInterval(tick, 250);
})();
})();