diff --git a/db.js b/db.js index 6cfc415..c8b2a73 100644 --- a/db.js +++ b/db.js @@ -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, diff --git a/public/assets/common.js b/public/assets/common.js index 8638280..9dd379a 100644 --- a/public/assets/common.js +++ b/public/assets/common.js @@ -96,6 +96,21 @@ window.IAP = (function () { return div; } // Render one served ad into #. 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 => ' ⚠ report'; + 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 = '' + 'advertisement' - + '
member ad
'; + + '
member ad' + reportTag(ad) + '
'; } else { el.innerHTML = '' + ad.title + '' - + (ad.body ? ' · ' + ad.body : '') + ' member ad'; + + (ad.body ? ' · ' + ad.body : '') + ' member ad' + reportTag(ad) + ''; } + 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, $ }; })(); diff --git a/public/assets/my.js b/public/assets/my.js index 38f2ba9..ffd9fe7 100644 --- a/public/assets/my.js +++ b/public/assets/my.js @@ -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 = '' + esc(i.title) + '' + + (i.by ? '' + esc(i.by) + '' : '') + ''; + } 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 => - '' + esc(i.title) + '' - + (i.by ? '' + esc(i.by) + '' : '') + '').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) diff --git a/public/contract.html b/public/contract.html index 0bf7e6d..441d286 100644 --- a/public/contract.html +++ b/public/contract.html @@ -140,7 +140,7 @@
Advertising services with a performance referral program. Not an investment product; no income guarantees. Crypto transactions are irreversible. Never spend what you cannot afford.
- + diff --git a/public/index.html b/public/index.html index 0581a84..357fc21 100644 --- a/public/index.html +++ b/public/index.html @@ -437,7 +437,7 @@ - + diff --git a/public/ledger.html b/public/ledger.html index 53385f9..a09e0be 100644 --- a/public/ledger.html +++ b/public/ledger.html @@ -36,7 +36,7 @@
InstantAdPay · how it works · contract source ↗
- + diff --git a/public/my.html b/public/my.html index 2d4b494..49698ab 100644 --- a/public/my.html +++ b/public/my.html @@ -4,7 +4,7 @@ Member area | InstantAdPay - + @@ -641,9 +641,9 @@ - + - + diff --git a/public/tx.html b/public/tx.html index 5850ef7..df2df27 100644 --- a/public/tx.html +++ b/public/tx.html @@ -33,7 +33,7 @@

← Back to the live ledger · Read the contract review

- + diff --git a/public/wall.html b/public/wall.html index e3ae370..e808d4f 100644 --- a/public/wall.html +++ b/public/wall.html @@ -36,7 +36,7 @@ - + diff --git a/reports.js b/reports.js new file mode 100644 index 0000000..6db4c57 --- /dev/null +++ b/reports.js @@ -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 }; diff --git a/server.js b/server.js index 9c043ca..4fec2bb 100644 --- a/server.js +++ b/server.js @@ -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.' });