Video ads (watch-to-earn): duration-tiered pricing, escape-proof player, per-view charge
- New 'video' campaign type; MP4/WebM upload or direct https link + required-watch tier (10/30/60s → 3/7/12 cr per view; viewer earns 1/2/4) - Composer: video fields, tier dropdown, live price hint - Earn credits > Watch videos sub-tab: no-seek player, server-clock watch floor, daily cap, self-exclusion, single-use tokens - media-src CSP for direct-link/hosted video; video skips frame-check Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+105
-4
@@ -414,28 +414,127 @@
|
||||
$('cBodyRow').hidden = t !== 'text';
|
||||
$('cSoloRow').hidden = t !== 'solo';
|
||||
$('cSoloHint').hidden = t !== 'solo';
|
||||
$('cVideoRow').hidden = t !== 'video';
|
||||
$('cTitle').placeholder = t === 'solo' ? 'Subject line (max 80)' : 'Headline (max 60)';
|
||||
soloHint();
|
||||
if (t === 'video') videoHint();
|
||||
});
|
||||
// video composer: tier dropdown + upload + price hint
|
||||
function videoHint() {
|
||||
if (!lastRates || !lastRates.videoTiers) return;
|
||||
if ($('cWatchSecs') && !$('cWatchSecs').options.length)
|
||||
$('cWatchSecs').innerHTML = lastRates.videoTiers.map(t =>
|
||||
'<option value="' + t.secs + '">Watch ' + t.secs + 's — ' + t.cost + ' credits/view (viewer earns ' + t.reward + ')</option>').join('');
|
||||
const tier = lastRates.videoTiers.find(t => t.secs === Number($('cWatchSecs').value)) || lastRates.videoTiers[0];
|
||||
const n = tier ? Math.floor((Number($('cBudget').value) || 0) / tier.cost) : 0;
|
||||
$('cVideoHint').textContent = tier ? (tier.cost + ' credits per completed ' + tier.secs + 's view'
|
||||
+ (n ? ' — this budget buys ' + n + ' views' : '') + '. Viewers earn ' + tier.reward + ' credits each, so they watch.') : '';
|
||||
}
|
||||
document.addEventListener('change', e => { if (e.target && e.target.id === 'cWatchSecs') videoHint(); });
|
||||
$('cBudget').addEventListener('input', () => { if ($('cType').value === 'video') videoHint(); });
|
||||
$('cVideoUploadBtn').addEventListener('click', () => $('cVideoFile').click());
|
||||
$('cVideoFile').addEventListener('change', async () => {
|
||||
const f = $('cVideoFile').files[0];
|
||||
if (!f) return;
|
||||
$('cVideoInfo').textContent = 'Uploading ' + f.name + '… (large files take a moment)';
|
||||
try {
|
||||
const r = await (await fetch('/api/my/upload', { method: 'POST', headers: { 'Content-Type': f.type }, body: f })).json();
|
||||
if (r.error) { $('cVideoInfo').textContent = r.error; $('cVideoFile').value = ''; return; }
|
||||
$('cVideoUrl').value = r.url;
|
||||
$('cVideoInfo').textContent = f.name + ' uploaded';
|
||||
$('cVideoPrev').hidden = false;
|
||||
$('cVideoPrev').innerHTML = '<video src="' + r.url + '" controls style="max-width:320px;border-radius:10px"></video>';
|
||||
} catch (e) { $('cVideoInfo').textContent = 'Upload failed. Try again.'; }
|
||||
$('cVideoFile').value = '';
|
||||
});
|
||||
$('createCampBtn').addEventListener('click', busy2($('createCampBtn'), async () => {
|
||||
// in raw mode the source of truth is the textarea; sync it back first
|
||||
let soloBody = $('cSoloEd').innerHTML;
|
||||
if ($('cSoloRaw') && !$('cSoloRaw').hidden) soloBody = $('cSoloRaw').value;
|
||||
const isVideo = $('cType').value === 'video';
|
||||
await api('/api/my/campaigns', { type: $('cType').value, name: $('cName').value,
|
||||
targetUrl: $('cTarget').value, imageUrl: $('cImage').value, size: $('cSize').value,
|
||||
title: $('cTitle').value,
|
||||
title: isVideo ? $('cVideoTitle').value : $('cTitle').value,
|
||||
body: $('cType').value === 'solo' ? soloBody : $('cBody').value,
|
||||
ctaLabel: $('cCtaLabel').value,
|
||||
videoUrl: $('cVideoUrl').value, watchSecs: Number($('cWatchSecs').value),
|
||||
ctaLabel: isVideo ? $('cVideoCta').value : $('cCtaLabel').value,
|
||||
budget: Number($('cBudget').value) });
|
||||
IAP.status('Campaign is live. It starts serving right away.', 'ok');
|
||||
$('cName').value = ''; $('cBudget').value = '';
|
||||
$('cSoloEd').innerHTML = ''; $('cSoloRaw').value = ''; $('cCtaLabel').value = '';
|
||||
$('cVideoUrl').value = ''; $('cVideoTitle').value = ''; $('cVideoCta').value = '';
|
||||
$('cVideoInfo').textContent = ''; $('cVideoPrev').hidden = true; $('cVideoPrev').innerHTML = '';
|
||||
$('edMediaInfo').textContent = '';
|
||||
await loadCampaigns();
|
||||
}));
|
||||
// defers the busy() lookup to click time (busy is declared below)
|
||||
function busy2(btn, fn) { return (...a) => busy(btn, fn)(...a); }
|
||||
|
||||
// ── watch-to-earn videos: escape-proof player, server-clock reward ──
|
||||
const vidState = { token: null, secs: 0, maxSeen: 0, done: false, credited: false };
|
||||
async function loadVideoStatus() {
|
||||
try {
|
||||
const st = await (await fetch('/api/my/videos')).json();
|
||||
if (st.error) return;
|
||||
$('vidProgress').textContent = 'today: ' + (st.status.count || 0) + ' / ' + st.status.cap + ' videos watched';
|
||||
if (st.status.left <= 0) {
|
||||
$('vidBox').innerHTML = '<span class="muted small">That is today\'s video set. Come back tomorrow.</span>';
|
||||
$('vidStartBtn').hidden = true;
|
||||
return;
|
||||
}
|
||||
$('vidStartBtn').hidden = false;
|
||||
} catch (e) {}
|
||||
}
|
||||
async function loadVideoAd() {
|
||||
let r = null;
|
||||
try { r = await (await fetch('/api/my/videos')).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.'
|
||||
: 'No member videos are live right now. Check back when a campaign is running.') + '</span>';
|
||||
$('vidWrap').hidden = true;
|
||||
return;
|
||||
}
|
||||
const ad = r.ad;
|
||||
vidState.token = r.token; vidState.secs = ad.watchSecs; vidState.maxSeen = 0; vidState.done = false; vidState.credited = false;
|
||||
$('vidBox').hidden = true;
|
||||
$('vidWrap').hidden = false;
|
||||
$('vidCta').hidden = true;
|
||||
$('vidHint').textContent = ad.title ? 'Now playing: ' + ad.title : '';
|
||||
const p = $('vidPlayer');
|
||||
p.src = ad.videoUrl;
|
||||
p.currentTime = 0;
|
||||
// escape-proof: no seeking past what's been watched; track max reached
|
||||
p.onseeking = () => { if (p.currentTime > vidState.maxSeen + 0.5) p.currentTime = vidState.maxSeen; };
|
||||
p.ontimeupdate = () => {
|
||||
if (p.currentTime > vidState.maxSeen) vidState.maxSeen = p.currentTime;
|
||||
const left = Math.max(0, Math.ceil(vidState.secs - vidState.maxSeen));
|
||||
$('vidTimer').textContent = left > 0 ? 'Watch ' + left + 's more to earn' : 'Watch time met — finishing…';
|
||||
if (!vidState.done && vidState.maxSeen >= vidState.secs) { vidState.done = true; completeVideo(ad); }
|
||||
};
|
||||
$('vidStartBtn').textContent = 'Playing…';
|
||||
$('vidStartBtn').disabled = true;
|
||||
try { await p.play(); } catch (e) { $('vidStartBtn').disabled = false; $('vidStartBtn').textContent = 'Tap to play'; }
|
||||
$('vidCta').href = ad.ctaUrl;
|
||||
$('vidCta').textContent = ad.ctaLabel || 'Learn more';
|
||||
$('vidCta').hidden = false;
|
||||
}
|
||||
async function completeVideo(ad) {
|
||||
if (vidState.credited) return;
|
||||
vidState.credited = true;
|
||||
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; }
|
||||
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';
|
||||
} catch (e) { IAP.status('Could not confirm that watch. Try the next one.', 'bad'); }
|
||||
$('vidStartBtn').disabled = false;
|
||||
$('vidStartBtn').textContent = 'Next video';
|
||||
}
|
||||
$('vidStartBtn').addEventListener('click', () => loadVideoAd());
|
||||
|
||||
// ── solo-ads inbox: list, read view, dwell-gated read reward ──
|
||||
let ibTimer = null;
|
||||
function setInboxBadge(n) {
|
||||
@@ -447,13 +546,15 @@
|
||||
// Earn credits sub-tabs: Watch ads | Inbox
|
||||
let earnSub = 'watch';
|
||||
function setEarnSub(which) {
|
||||
earnSub = which === 'inbox' ? 'inbox' : 'watch';
|
||||
const w = $('earn-watch'), i = $('earn-inbox');
|
||||
earnSub = ['inbox', 'videos'].includes(which) ? which : 'watch';
|
||||
const w = $('earn-watch'), i = $('earn-inbox'), v = $('earn-videos');
|
||||
if (w) w.hidden = earnSub !== 'watch';
|
||||
if (i) i.hidden = earnSub !== 'inbox';
|
||||
if (v) v.hidden = earnSub !== 'videos';
|
||||
document.querySelectorAll('.subtabs [data-earn]').forEach(b =>
|
||||
b.classList.toggle('on', b.dataset.earn === earnSub));
|
||||
if (earnSub === 'inbox') loadInbox();
|
||||
else if (earnSub === 'videos') loadVideoStatus();
|
||||
else earnRefresh();
|
||||
}
|
||||
document.querySelectorAll('.subtabs [data-earn]').forEach(b =>
|
||||
|
||||
Reference in New Issue
Block a user