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:
+16
-30
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
})();
|
||||
})();
|
||||
+12
-12
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Member area | InstantAdPay</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Sora:wght@600;700;800&display=swap">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260905k">
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260905l">
|
||||
</head>
|
||||
<body class="bo-body">
|
||||
|
||||
@@ -225,17 +225,17 @@
|
||||
<div class="pane" id="pane-earn" hidden>
|
||||
<div class="card">
|
||||
<h3>Earn credits by viewing ads</h3>
|
||||
<p class="muted small">View today's set of member ads, each for a few seconds, and claim your
|
||||
daily credits. Attention earns advertising here: your earned credits spend on banner and text
|
||||
campaigns, just like purchased ones.</p>
|
||||
<p class="muted small">Each ad opens full screen in its own tab, pointed at the advertiser's
|
||||
real site. A countdown runs while you watch; when it hits zero, pass a quick check and the
|
||||
view counts. Finish today's set, claim your credits, and spend them on banner and text
|
||||
campaigns just like purchased ones.</p>
|
||||
<p><span class="badge" id="earnProgress">…</span>
|
||||
<span class="badge" id="earnBalance">…</span></p>
|
||||
<div class="card" style="background:rgba(4,8,7,.5)" id="earnStage">
|
||||
<div id="earnAdBox" class="small muted" style="min-height:120px;display:grid;place-items:center">
|
||||
Press start to see the first ad.</div>
|
||||
<div id="earnAdBox" class="small muted" style="min-height:80px;display:grid;place-items:center">
|
||||
Press the button to open the first ad in a new tab.</div>
|
||||
<p style="margin-top:14px">
|
||||
<button class="btn" id="earnStartBtn" type="button">Start viewing</button>
|
||||
<button class="btn" id="earnNextBtn" type="button" hidden disabled>Next ad</button>
|
||||
<button class="btn" id="earnStartBtn" type="button">View today's ads</button>
|
||||
<button class="btn" id="earnClaimBtn" type="button" hidden>Claim today's credits</button>
|
||||
</p>
|
||||
<p class="small muted" id="earnHint"></p>
|
||||
@@ -330,9 +330,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/assets/common.js?v=20260905k"></script>
|
||||
<script src="/assets/wallet.js?v=20260905k"></script>
|
||||
<script src="/assets/my.js?v=20260905k"></script>
|
||||
<script src="/assets/chat.js?v=20260905k"></script>
|
||||
<script src="/assets/common.js?v=20260905l"></script>
|
||||
<script src="/assets/wallet.js?v=20260905l"></script>
|
||||
<script src="/assets/my.js?v=20260905l"></script>
|
||||
<script src="/assets/chat.js?v=20260905l"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Viewing ad — InstantAdPay</title>
|
||||
<link rel="stylesheet" href="/assets/site.css?v=20260905l">
|
||||
<style>
|
||||
html,body{height:100%;margin:0;overflow:hidden}
|
||||
.vw{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg,#04110c);color:var(--ink,#e8fff7)}
|
||||
.vbar{flex:0 0 auto;display:flex;align-items:center;gap:14px;padding:10px 16px;
|
||||
background:var(--panel-solid,#071a12);border-bottom:1px solid var(--line,rgba(67,232,195,.18));flex-wrap:wrap}
|
||||
.vbrand{font-family:var(--disp,sans-serif);font-weight:800;font-size:15px;white-space:nowrap}
|
||||
.vbrand em{color:var(--mint,#43e8c3);font-style:normal}
|
||||
.vtimer{font-family:var(--mono,monospace);font-weight:700;font-size:14px;border:1px solid var(--line-strong,rgba(67,232,195,.32));
|
||||
border-radius:999px;padding:5px 14px;min-width:104px;text-align:center}
|
||||
.vtimer.paused{color:var(--amber,#ffb238);border-color:rgba(255,178,56,.55)}
|
||||
.vtimer.done{color:var(--mint,#43e8c3)}
|
||||
.vmsg{font-size:13px;color:var(--muted,#8ba69c);flex:1;min-width:140px}
|
||||
.vcheck{display:none;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.vcheck.on{display:flex}
|
||||
.vcheck .prompt{font-size:13.5px;font-weight:700}
|
||||
.vcheck button{font-size:20px;line-height:1;background:var(--panel,rgba(9,26,19,.85));color:inherit;
|
||||
border:1px solid var(--line-strong,rgba(67,232,195,.32));border-radius:10px;padding:7px 12px;cursor:pointer;margin-right:6px}
|
||||
.vcheck button:hover,.vcheck button:focus-visible{border-color:var(--mint,#43e8c3);outline:none}
|
||||
.vdone{display:none;gap:8px;align-items:center}
|
||||
.vdone.on{display:flex}
|
||||
.vframe{flex:1;border:0;width:100%;background:#fff}
|
||||
.vfail{flex:1;display:grid;place-items:center;color:var(--muted,#8ba69c);padding:30px;text-align:center;font-size:15px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="vw">
|
||||
<div class="vbar">
|
||||
<span class="vbrand">Instant<em>AdPay</em></span>
|
||||
<span class="vtimer" id="vTimer">…</span>
|
||||
<span class="vmsg" id="vMsg">Loading the ad…</span>
|
||||
<span class="vcheck" id="vCheck"><span class="prompt" id="vPrompt"></span><span id="vOpts"></span></span>
|
||||
<span class="vdone" id="vDone">
|
||||
<button class="btn small" id="vClose" type="button">Close tab</button>
|
||||
<a class="btn small sec" href="/my#earn">Back to dashboard</a>
|
||||
</span>
|
||||
</div>
|
||||
<iframe class="vframe" id="vFrame" sandbox="allow-scripts allow-same-origin allow-forms allow-popups" referrerpolicy="no-referrer" title="Advertiser site"></iframe>
|
||||
</div>
|
||||
<script src="/assets/view.js?v=20260905l"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user