Ad reporting + featured rotates one link + clear campaign form on submit

- Ad reporting (auto-approved ads need a safety valve): new reports.js store
  (dual-mode) + ad_reports table + POST /api/report-ad; a "⚠ report" control on
  served ad slots opens a reason prompt and notifies the admin by email
  (siteConfig.adminEmail / ADMIN_EMAIL).
- Featured links now show ONE link at a time and cycle (true rotation), instead
  of listing all links at once.
- Campaign form now clears EVERY field on submit (target/creative/title/etc.).
  A leftover target URL was carrying into the next campaign — the same reason a
  video short was saved with the wrong (massifly) CTA target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-07 07:57:12 -05:00
parent ea1b2ea0ac
commit 4991fe96e8
11 changed files with 133 additions and 18 deletions
+10
View File
@@ -142,6 +142,16 @@ async function bootstrap() {
// per-account chat settings: availability toggle (default on) + muted-member list (JSON emails)
await alterSafe('ALTER TABLE accounts ADD COLUMN chat_available TINYINT NOT NULL DEFAULT 1');
await alterSafe('ALTER TABLE accounts ADD COLUMN chat_mutes VARCHAR(4000) NULL');
await q(`CREATE TABLE IF NOT EXISTS ad_reports (
id INT AUTO_INCREMENT PRIMARY KEY,
campaign_id INT NOT NULL,
reporter VARCHAR(190) NOT NULL DEFAULT '',
reason VARCHAR(20) NOT NULL,
note VARCHAR(600) NULL,
ts BIGINT NOT NULL,
resolved TINYINT NOT NULL DEFAULT 0,
INDEX (resolved, ts), INDEX (campaign_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`);
await q(`CREATE TABLE IF NOT EXISTS burns (
id VARCHAR(32) PRIMARY KEY,
member_id INT NOT NULL,
+19 -3
View File
@@ -96,6 +96,21 @@ window.IAP = (function () {
return div;
}
// Render one served ad into #<elId>. Silent if no inventory.
// report an ad (auto-approved ads need a member-facing flag → admin notified)
function reportAd(campaignId) {
if (!campaignId) return;
const reason = (prompt('Report this ad. Reason: broken, inappropriate, spam, scam, or other', 'broken') || '').trim().toLowerCase();
if (!reason) return;
const note = prompt('Anything to add? (optional)') || '';
fetch('/api/report-ad', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ campaignId, reason, note }) })
.then(() => status('Thanks — this ad was reported to the admin for review.', 'ok'))
.catch(() => status('Could not send the report. Try again.', 'bad'));
}
const reportTag = ad => ' <a class="ad-report small muted" href="#" data-cid="' + ad.id + '" style="margin-left:8px">⚠ report</a>';
function wireReport(el) {
const rl = el.querySelector('.ad-report');
if (rl) rl.addEventListener('click', e => { e.preventDefault(); reportAd(Number(rl.dataset.cid)); });
}
async function adSlot(type, elId) {
try {
const { ad } = await (await fetch('/api/ads/slot?type=' + type)).json();
@@ -104,13 +119,14 @@ window.IAP = (function () {
if (ad.imageUrl) {
el.innerHTML = '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow">'
+ '<img src="' + ad.imageUrl + '" alt="advertisement" style="max-width:100%;border-radius:8px"></a>'
+ '<div class="small muted">member ad</div>';
+ '<div class="small muted">member ad' + reportTag(ad) + '</div>';
} else {
el.innerHTML = '<a href="' + ad.targetUrl + '" target="_blank" rel="noopener nofollow"><b>' + ad.title + '</b>'
+ (ad.body ? ' · ' + ad.body : '') + '</a> <span class="small muted">member ad</span>';
+ (ad.body ? ' · ' + ad.body : '') + '</a> <span class="small muted">member ad' + reportTag(ad) + '</span>';
}
wireReport(el);
el.hidden = false;
} catch (e) {}
}
return { getConfig, fmtPol, fmtUsd, status, renderNav, refreshNavWallet, describeEvent, feedRow, adSlot, $ };
return { getConfig, fmtPol, fmtUsd, status, renderNav, refreshNavWallet, describeEvent, feedRow, adSlot, reportAd, $ };
})();
+18 -7
View File
@@ -28,16 +28,24 @@
return 'Fully qualified. Every level pays you, and you catch the pass-ups that under-qualified positions below you let slip. Keep sharing and keep your campaigns running.';
}
// featured rotation strip on the overview
let featItems = [], featIdx = 0, featTimer = null;
function renderOneFeatured() {
if (!featItems.length) return;
const i = featItems[featIdx % featItems.length];
$('featStrip').innerHTML = '<a href="' + i.url + '" target="_blank" rel="noopener nofollow"><span>' + esc(i.title) + '</span>'
+ (i.by ? '<span class="by">' + esc(i.by) + '</span>' : '') + '</a>';
}
async function loadFeatured() {
try {
const r = await (await fetch('/api/featured')).json();
const card = $('featuredCard'); if (!card) return;
if (!r.items || !r.items.length) { card.hidden = true; return; }
card.hidden = false;
featItems = r.items; featIdx = Math.floor(Math.random() * featItems.length);
$('featSub').textContent = r.items.length + ' link' + (r.items.length === 1 ? '' : 's') + ' in rotation';
$('featStrip').innerHTML = r.items.map(i =>
'<a href="' + i.url + '" target="_blank" rel="noopener nofollow"><span>' + esc(i.title) + '</span>'
+ (i.by ? '<span class="by">' + esc(i.by) + '</span>' : '') + '</a>').join('');
renderOneFeatured(); // show ONE at a time (true rotation), cycle if more than one
clearInterval(featTimer);
if (featItems.length > 1) featTimer = setInterval(() => { featIdx++; renderOneFeatured(); }, 6000);
} catch (e) {}
}
@@ -665,11 +673,14 @@
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');
$('cName').value = ''; $('cBudget').value = '';
$('cSoloEd').innerHTML = ''; $('cSoloRaw').value = ''; $('cCtaLabel').value = '';
$('cVideoUrl').value = ''; $('cVideoTitle').value = ''; $('cVideoCta').value = '';
// clear EVERY field so no target/creative carries into the next campaign
['cName', 'cBudget', 'cTarget', 'cImage', 'cTitle', 'cBody', 'cCtaLabel',
'cVideoUrl', 'cVideoTitle', 'cVideoCta', 'cVisitTitle', 'cVisitCount', 'cFeatTitle']
.forEach(id => { if ($(id)) $(id).value = ''; });
$('cSoloEd').innerHTML = ''; if ($('cSoloRaw')) $('cSoloRaw').value = '';
$('cVideoInfo').textContent = ''; $('cVideoPrev').hidden = true; $('cVideoPrev').innerHTML = '';
$('edMediaInfo').textContent = '';
if ($('edMediaInfo')) $('edMediaInfo').textContent = '';
cVidDims = null;
await loadCampaigns();
}));
// defers the busy() lookup to click time (busy is declared below)
+1 -1
View File
@@ -140,7 +140,7 @@
<div class="small">Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.</div>
</footer>
</div>
<script src="/assets/common.js?v=20260906m"></script>
<script src="/assets/common.js?v=20260907m"></script>
<script src="/assets/contract.js?v=20260906m"></script>
<script src="/assets/chat.js?v=20260906m"></script>
</body>
+1 -1
View File
@@ -437,7 +437,7 @@
</div>
</section>
<script src="/assets/common.js?v=20260906m"></script>
<script src="/assets/common.js?v=20260907m"></script>
<script src="/assets/wallet.js?v=20260907f"></script>
<script src="/assets/home.js?v=20260906m"></script>
<script src="/assets/chat.js?v=20260906m"></script>
+1 -1
View File
@@ -36,7 +36,7 @@
<div>InstantAdPay · <a href="/">how it works</a> · <a id="contractLink" href="#" target="_blank" rel="noopener">contract source ↗</a></div>
</footer>
</div>
<script src="/assets/common.js?v=20260906m"></script>
<script src="/assets/common.js?v=20260907m"></script>
<script src="/assets/ledger.js?v=20260906m"></script>
<script src="/assets/chat.js?v=20260906m"></script>
</body>
+3 -3
View File
@@ -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=20260907l">
<link rel="stylesheet" href="/assets/site.css?v=20260907m">
</head>
<body class="bo-body">
@@ -641,9 +641,9 @@
</div>
</div>
</div>
<script src="/assets/common.js?v=20260907l"></script>
<script src="/assets/common.js?v=20260907m"></script>
<script src="/assets/wallet.js?v=20260907f"></script>
<script src="/assets/my.js?v=20260907l"></script>
<script src="/assets/my.js?v=20260907m"></script>
<script src="/assets/chat.js?v=20260907l"></script>
</body>
</html>
+1 -1
View File
@@ -33,7 +33,7 @@
<p><a href="/ledger">← Back to the live ledger</a> · <a href="/contract">Read the contract review</a></p>
</div>
</section>
<script src="/assets/common.js?v=20260906m"></script>
<script src="/assets/common.js?v=20260907m"></script>
<script src="/assets/tx.js?v=20260906m"></script>
</body>
</html>
+1 -1
View File
@@ -36,7 +36,7 @@
</div>
</div>
</section>
<script src="/assets/common.js?v=20260906m"></script>
<script src="/assets/common.js?v=20260907m"></script>
<script src="/assets/wall.js?v=20260906m"></script>
</body>
</html>
+58
View File
@@ -0,0 +1,58 @@
// Ad reports: members flag a broken or inappropriate ad (ads are auto-approved,
// so this is the safety valve). Stored dual-mode (MySQL when DATABASE_URL, else
// a JSON file in the volume) and surfaced to the admin.
const fs = require('fs');
const path = require('path');
const db = require('./db');
let DATA_DIR = null;
const J = {
db: null,
FILE: () => path.join(DATA_DIR, 'ad-reports.json'),
load() { try { this.db = JSON.parse(fs.readFileSync(this.FILE(), 'utf8')); } catch (e) { this.db = { nextId: 1, items: [] }; } },
save() { try { fs.writeFileSync(this.FILE(), JSON.stringify(this.db)); } catch (e) {} }
};
function init(opts) { DATA_DIR = opts.dataDir; }
const REASONS = ['broken', 'inappropriate', 'spam', 'scam', 'other'];
async function add(campaignId, reporterEmail, reason, note) {
const now = Date.now();
const r = REASONS.includes(String(reason)) ? String(reason) : 'other';
const n = String(note || '').slice(0, 500);
const cid = Number(campaignId) || 0;
const who = String(reporterEmail || '').toLowerCase();
if (db.enabled()) {
const ins = await db.q('INSERT INTO ad_reports (campaign_id,reporter,reason,note,ts) VALUES (?,?,?,?,?)',
[cid, who, r, n, now]);
return { id: ins.insertId, campaignId: cid, reason: r };
}
if (!J.db) J.load();
const id = J.db.nextId++;
J.db.items.push({ id, campaignId: cid, reporter: who, reason: r, note: n, ts: now, resolved: false });
J.save();
return { id, campaignId: cid, reason: r };
}
// recent reports for the admin view (newest first)
async function list(limit = 200) {
if (db.enabled()) {
const rows = await db.q('SELECT id,campaign_id,reporter,reason,note,ts,resolved FROM ad_reports ORDER BY ts DESC LIMIT ?', [limit]);
return rows.map(x => ({ id: x.id, campaignId: x.campaign_id, reporter: x.reporter, reason: x.reason, note: x.note, ts: Number(x.ts), resolved: !!x.resolved }));
}
if (!J.db) J.load();
return J.db.items.slice().sort((a, b) => b.ts - a.ts).slice(0, limit);
}
async function resolve(id) {
if (db.enabled()) { await db.q('UPDATE ad_reports SET resolved=1 WHERE id=?', [Number(id)]); return { ok: true }; }
if (!J.db) J.load();
const it = J.db.items.find(x => x.id === Number(id)); if (it) { it.resolved = true; J.save(); }
return { ok: true };
}
// how many unresolved (for an admin badge)
async function openCount() {
if (db.enabled()) { const r = await db.q('SELECT COUNT(*) n FROM ad_reports WHERE resolved=0'); return r[0].n; }
if (!J.db) J.load();
return J.db.items.filter(x => !x.resolved).length;
}
module.exports = { init, add, list, resolve, openCount, REASONS };
+20
View File
@@ -17,6 +17,7 @@ const accounts = require('./accounts');
const ads = require('./ads');
const mailer = require('./mailer');
const messages = require('./messages');
const reports = require('./reports');
const spaces = require('./spaces'); // DO Spaces video storage (inert unless DO_SPACES_* set)
let QR = null; try { QR = require('qrcode'); } catch (e) { /* optional */ }
const chatbot = require('./chatbot');
@@ -126,6 +127,7 @@ async function boot() {
ads.init({ dataDir: DATA_DIR, chain });
mailer.init({ dataDir: DATA_DIR });
messages.init({ dataDir: DATA_DIR });
reports.init({ dataDir: DATA_DIR });
chatbot.init({ dataDir: DATA_DIR, chain });
setTimeout(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 1000);
setInterval(() => ads.dailySweep().catch(e => console.error('sweep', e.message)), 60 * 60 * 1000);
@@ -705,6 +707,24 @@ const server = http.createServer(async (req, res) => {
return json(res, 200, { ok: true, balanceWei: BigInt(nb || '0x0').toString() });
} catch (e) { return json(res, 502, { error: 'Faucet is unavailable right now.' }); }
}
// -- report an ad (auto-approved ads need a safety valve): store + notify admin
if (p === '/api/report-ad' && req.method === 'POST') {
const b = await readBody(req);
if (!Number(b.campaignId)) return json(res, 400, { error: 'Which ad?' });
const s = await auth.fromRequest(req);
const who = (s && s.email) || '';
const rec = await reports.add(b.campaignId, who, b.reason, b.note);
try {
const adminEmail = siteConfig().adminEmail || process.env.ADMIN_EMAIL || '';
if (adminEmail && mailer.hasKey()) {
mailer.send(adminEmail, 'Ad reported on InstantAdPay (campaign #' + rec.campaignId + ')',
'A member flagged an ad.\n\nCampaign: #' + rec.campaignId + '\nReason: ' + rec.reason
+ '\nReported by: ' + (who || 'anonymous') + '\nNote: ' + (String(b.note || '').slice(0, 500) || '(none)')
+ '\n\nPause or review it from the admin.').catch(() => {});
}
} catch (e) {}
return json(res, 200, { ok: true });
}
if (p === '/api/my/chat/send' && req.method === 'POST') {
const s = await auth.fromRequest(req);
if (!s || !s.email) return json(res, 401, { error: 'Sign in first.' });