diff --git a/ads.js b/ads.js
index 5cc0df6..a610cf9 100644
Binary files a/ads.js and b/ads.js differ
diff --git a/db.js b/db.js
index ca5bb05..22fc33b 100644
--- a/db.js
+++ b/db.js
@@ -116,6 +116,14 @@ async function bootstrap() {
await alterSafe('ALTER TABLE campaigns ADD COLUMN nas_served INT NOT NULL DEFAULT 0'); // NAS impressions already reconciled into spend
await alterSafe('ALTER TABLE campaigns ADD COLUMN expires BIGINT NULL'); // featured rotation end time
await alterSafe('ALTER TABLE campaigns ADD COLUMN starts BIGINT NULL'); // featured run start (booked day)
+ await q(`CREATE TABLE IF NOT EXISTS visit_seen (
+ campaign_id INT NOT NULL,
+ email VARCHAR(190) NOT NULL,
+ day CHAR(10) NOT NULL,
+ ts BIGINT NOT NULL,
+ UNIQUE KEY uq_visit (campaign_id, email),
+ INDEX (email, day)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await q(`CREATE TABLE IF NOT EXISTS sponsor_messages (
id INT AUTO_INCREMENT PRIMARY KEY,
from_member INT NOT NULL,
diff --git a/public/assets/my.js b/public/assets/my.js
index 201275e..6f51266 100644
--- a/public/assets/my.js
+++ b/public/assets/my.js
@@ -509,6 +509,8 @@
$('cSoloHint').hidden = t !== 'solo';
$('cVideoRow').hidden = t !== 'video';
$('cFeaturedRow').hidden = t !== 'featured';
+ $('cVisitsRow').hidden = t !== 'visits';
+ if (t === 'visits') visitHint();
$('cTitle').placeholder = t === 'solo' ? 'Subject line (max 80)' : 'Headline (max 60)';
soloHint();
if (t === 'video') videoHint();
@@ -539,6 +541,15 @@
+ ' for ' + (dys * lastRates.featuredPerDay) + ' credits. Max ' + (s ? s.slotsPerDay : 10) + ' links share any day.';
}
document.addEventListener('change', e => { if (e.target && e.target.id === 'cFeatDays') featHint(); });
+ function visitHint() {
+ if (!lastRates) return;
+ const n = Number($('cVisitCount').value) || 0;
+ const cost = n * (lastRates.visitCostPerVisit || 3);
+ $('cVisitHint').textContent = (lastRates.visitCostPerVisit || 3) + ' credits per verified visit'
+ + (n >= (lastRates.visitMinPack || 20) ? ' — ' + n + ' visits = ' + cost + ' credits' : ' (min ' + (lastRates.visitMinPack || 20) + ')')
+ + '. Each is a unique member, dwell + human-check verified.';
+ }
+ $('cVisitCount').addEventListener('input', visitHint);
// video composer: tier dropdown + upload + price hint
function videoHint() {
if (!lastRates || !lastRates.videoTiers) return;
@@ -575,10 +586,11 @@
const isVideo = t === 'video', isFeat = t === 'featured';
await api('/api/my/campaigns', { type: t, name: $('cName').value,
targetUrl: $('cTarget').value, imageUrl: $('cImage').value, size: $('cSize').value,
- title: isVideo ? $('cVideoTitle').value : isFeat ? $('cFeatTitle').value : $('cTitle').value,
+ title: isVideo ? $('cVideoTitle').value : isFeat ? $('cFeatTitle').value : t === 'visits' ? $('cVisitTitle').value : $('cTitle').value,
body: t === 'solo' ? soloBody : $('cBody').value,
videoUrl: $('cVideoUrl').value, watchSecs: Number($('cWatchSecs').value),
days: Number($('cFeatDays').value), startDay: featStartDay,
+ count: Number($('cVisitCount').value),
ctaLabel: isVideo ? $('cVideoCta').value : $('cCtaLabel').value,
budget: isFeat ? (Number($('cFeatDays').value) * (lastRates.featuredPerDay || 40)) : Number($('cBudget').value) });
IAP.status('Campaign is live. It starts serving right away.', 'ok');
@@ -592,6 +604,73 @@
// defers the busy() lookup to click time (busy is declared below)
function busy2(btn, fn) { return (...a) => busy(btn, fn)(...a); }
+ // ── verified visits: open a member's site (new tab), dwell, human-check, earn ──
+ const visState = { token: null, id: null, dwell: 8 };
+ async function loadVisitStatus() {
+ try {
+ const st = await (await fetch('/api/my/visits')).json();
+ if (st.error) return;
+ $('vsProgress').textContent = 'today: ' + (st.status.count || 0) + ' / ' + st.status.cap + ' verified visits';
+ $('vsStartBtn').hidden = st.status.count >= st.status.cap;
+ if (st.status.count >= st.status.cap) $('vsBox').innerHTML = 'That\'s today\'s visits. Come back tomorrow.';
+ } catch (e) {}
+ }
+ async function loadVisit() {
+ $('vsCheck').hidden = true; $('vsVisit').hidden = true; $('vsHint').textContent = '';
+ let r = null;
+ try { r = await (await fetch('/api/my/visits')).json(); } catch (e) {}
+ if (!r || !r.ad) {
+ $('vsBox').innerHTML = '' + (r && r.status && r.status.count >= r.status.cap
+ ? 'That\'s today\'s visits. Come back tomorrow.' : 'No verified-visit packs are running right now. Check back soon.') + '';
+ return;
+ }
+ visState.token = r.token; visState.id = r.ad.id; visState.dwell = r.status.dwell || 8;
+ $('vsBox').innerHTML = '' + esc(r.ad.title || 'Member site') + '
Open the site and stay ' + visState.dwell + 's.';
+ const visit = $('vsVisit');
+ visit.href = r.ad.url; visit.hidden = false;
+ $('vsStartBtn').textContent = 'Loading…'; $('vsStartBtn').disabled = true;
+ // opening the site starts the dwell; then we ask the human-check
+ visit.onclick = () => {
+ let left = visState.dwell;
+ $('vsHint').textContent = 'Counting your visit: ' + left + 's';
+ const t = setInterval(async () => {
+ left--;
+ if (left > 0) { $('vsHint').textContent = 'Counting your visit: ' + left + 's'; return; }
+ clearInterval(t);
+ $('vsHint').textContent = 'One quick check to count the visit:';
+ let c = await (await fetch('/api/my/visitchallenge?token=' + visState.token)).json();
+ if (c.early) { await new Promise(r2 => setTimeout(r2, (c.wait || 1) * 1000 + 300)); c = await (await fetch('/api/my/visitchallenge?token=' + visState.token)).json(); }
+ if (c.error) { $('vsHint').textContent = c.error; return; }
+ renderVisitCheck(c);
+ }, 1000);
+ };
+ $('vsStartBtn').textContent = 'Next visit'; $('vsStartBtn').disabled = false;
+ }
+ function renderVisitCheck(c) {
+ $('vsPrompt').textContent = 'Click the ' + c.prompt + ':';
+ const w = $('vsOpts'); w.innerHTML = '';
+ c.options.forEach((em, i) => {
+ const b = document.createElement('button');
+ b.className = 'btn small sec'; b.style.marginRight = '6px'; b.textContent = em;
+ b.addEventListener('click', () => answerVisit(i));
+ w.appendChild(b);
+ });
+ $('vsCheck').hidden = false;
+ }
+ async function answerVisit(i) {
+ const r = await (await fetch('/api/my/visitdone', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: visState.token, answer: i }) })).json();
+ if (r.error) {
+ if (r.retry) { const c = await (await fetch('/api/my/visitchallenge?token=' + visState.token)).json(); if (!c.error) return renderVisitCheck(c); }
+ $('vsHint').textContent = r.error; $('vsCheck').hidden = true; return;
+ }
+ $('vsCheck').hidden = true;
+ $('vsHint').textContent = '+' + r.credited + ' credits — visit counted. Load the next one.';
+ $('vsProgress').textContent = 'today: ' + (r.status.count || 0) + ' / ' + (r.status.cap || 0) + ' verified visits';
+ IAP.status('+' + r.credited + ' credits for a verified visit.', 'ok');
+ loadDashboard();
+ }
+ $('vsStartBtn').addEventListener('click', () => loadVisit());
+
// ── 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() {
@@ -727,15 +806,17 @@
// Earn credits sub-tabs: Watch ads | Inbox
let earnSub = 'watch';
function setEarnSub(which) {
- earnSub = ['inbox', 'videos'].includes(which) ? which : 'watch';
- const w = $('earn-watch'), i = $('earn-inbox'), v = $('earn-videos');
+ earnSub = ['inbox', 'videos', 'visits'].includes(which) ? which : 'watch';
+ 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';
if (v) v.hidden = earnSub !== 'videos';
+ if (vs) vs.hidden = earnSub !== 'visits';
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 if (earnSub === 'visits') loadVisitStatus();
else earnRefresh();
}
document.querySelectorAll('.subtabs [data-earn]').forEach(b =>
diff --git a/public/contract.html b/public/contract.html
index 1e9d182..c48e481 100644
--- a/public/contract.html
+++ b/public/contract.html
@@ -5,7 +5,7 @@