From b86dbfd9e94a30ee7696e8036eb59bab1533c131 Mon Sep 17 00:00:00 2001 From: martbost Date: Wed, 9 Sep 2026 15:45:49 -0500 Subject: [PATCH] Admin: wall fallback ads editor (House ads pane) + /api/admin/wall-ads Co-Authored-By: Claude Fable 5.1 --- public/admin.html | 12 +++++++++++- public/assets/admin.js | 44 ++++++++++++++++++++++++++++++++++++++++++ server.js | 24 +++++++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/public/admin.html b/public/admin.html index f8be5c8..6821eb8 100644 --- a/public/admin.html +++ b/public/admin.html @@ -199,6 +199,16 @@

House ads

running free
+
+

Wall fallback ads

shown on member walls in positions they have not earned or filled, when no upline banner exists
+

A member's wall has three positions. Position 1 is theirs. Positions 2 and 3 show an upline's banner until the member earns them (2 and 5 qualifying buyers); when there is no upline banner, one of these ads shows instead. They rotate in order across walls. Leave the list empty to fall back to a plain InstantAdPay card.

+
+

+ + +

+ +
- + diff --git a/public/assets/admin.js b/public/assets/admin.js index 5f6ff50..e90d182 100644 --- a/public/assets/admin.js +++ b/public/assets/admin.js @@ -192,10 +192,54 @@ if (!$('hWatchSecs').options.length) $('hWatchSecs').innerHTML = (rates.videoTiers || []).map(t => '').join(''); if (!$('hFeatDays').options.length) $('hFeatDays').innerHTML = (rates.featuredDurations || [1, 2, 7]).map(d => '').join(''); showHouseRows(); + loadWallAds(); const house = (r.campaigns || []).filter(c => c.house); $('houseSub').textContent = house.filter(c => c.status === 'active').length + ' active · ' + house.length + ' total'; $('houseTable').innerHTML = house.length ? campHead(false) + house.map(c => campRow(c, false)).join('') : 'No house ads yet. Place one above.'; } + // wall fallback ads editor + let wallAds = []; + function drawWallAds() { + const w = $('wallAdsList'); + w.innerHTML = wallAds.map((a, i) => '
WALL AD ' + (i + 1) + '' + + '
' + + '

' + + '

' + + '

' + + (a.bannerUrl ? '' : '') + + '
').join('') || '

No wall ads set. Walls fall back to a plain InstantAdPay card.

'; + } + function readWallAds() { + return [...document.querySelectorAll('#wallAdsList .drip-step')].map(c => ({ name: c.querySelector('.wa-name').value.trim(), targetUrl: c.querySelector('.wa-target').value.trim(), bannerUrl: c.querySelector('.wa-banner').value.trim() })); + } + async function loadWallAds() { + try { const r = await api('/api/admin/wall-ads'); wallAds = r.ads || []; $('wallAdsSub').textContent = r.usingDefaults ? 'none set: walls show the default InstantAdPay card' : wallAds.length + ' in rotation'; drawWallAds(); } catch (e) {} + } + $('wallAdsList').addEventListener('click', async e => { + const up = e.target.closest('.wa-upload'); + if (up) { up.parentElement.querySelector('.wa-file').click(); return; } + const b = e.target.closest('[data-wact]'); if (!b) return; + const i = Number(b.closest('.drip-step').dataset.i); wallAds = readWallAds(); + if (b.dataset.wact === 'remove') wallAds.splice(i, 1); + if (b.dataset.wact === 'up' && i > 0) [wallAds[i - 1], wallAds[i]] = [wallAds[i], wallAds[i - 1]]; + if (b.dataset.wact === 'down' && i < wallAds.length - 1) [wallAds[i + 1], wallAds[i]] = [wallAds[i], wallAds[i + 1]]; + drawWallAds(); + }); + $('wallAdsList').addEventListener('change', async e => { + const f = e.target.closest('.wa-file'); if (!f || !f.files[0]) return; + const file = f.files[0]; const card = f.closest('.drip-step'); + try { + const r = await (await fetch('/api/admin/upload', { method: 'POST', headers: { 'Content-Type': file.type }, body: file })).json(); + if (r.error) IAP.status(r.error, 'bad'); else { card.querySelector('.wa-banner').value = r.url; IAP.status('Uploaded.', 'ok'); } + } catch (err) { IAP.status('Upload failed.', 'bad'); } + f.value = ''; + }); + $('wallAdsAdd').addEventListener('click', () => { wallAds = readWallAds(); wallAds.push({ name: '', targetUrl: '', bannerUrl: '' }); drawWallAds(); }); + $('wallAdsSave').addEventListener('click', busy($('wallAdsSave'), async () => { + $('wallAdsErr').hidden = true; + try { const r = await api('/api/admin/wall-ads', { ads: readWallAds() }, 'PATCH'); wallAds = r.ads || []; drawWallAds(); IAP.status('Wall ads saved.', 'ok'); await loadWallAds(); } + catch (e) { $('wallAdsErr').textContent = e.message; $('wallAdsErr').hidden = false; } + })); document.addEventListener('click', async e => { const b = e.target.closest('[data-act][data-id]'); if (!b) return; b.disabled = true; diff --git a/server.js b/server.js index ee3fce7..36b3358 100644 --- a/server.js +++ b/server.js @@ -1543,6 +1543,30 @@ const server = http.createServer(async (req, res) => { try { const r = await drip.sendStep(ADMIN_EMAIL, Number(b.step) || 0, ADMIN_EMAIL); return json(res, r.error ? 400 : 200, r); } catch (e) { return json(res, 502, { error: 'Send failed: ' + e.message }); } } + // wall fallback ads: shown in wall positions a member has not earned or filled, when no upline banner exists + if (p === '/api/admin/wall-ads' && req.method === 'GET') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + let saved = null; try { saved = JSON.parse(fs.readFileSync(path.join(DATA_DIR, 'admin-wall-ads.json'), 'utf8')); } catch (e) {} + return json(res, 200, { ads: Array.isArray(saved) ? saved : [], defaults: getAdminWallAds(), usingDefaults: !Array.isArray(saved) || !saved.length }); + } + if (p === '/api/admin/wall-ads' && req.method === 'PATCH') { + if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); + const b = await readBody(req); + const src = Array.isArray(b.ads) ? b.ads.slice(0, 20) : []; + const out = []; + for (const o of src) { + const name = String((o && o.name) || '').trim().slice(0, 60); + const targetUrl = String((o && o.targetUrl) || '').trim(); + const bannerUrl = String((o && o.bannerUrl) || '').trim(); + if (!targetUrl) continue; + if (!/^https:\/\/[^\s]+$/i.test(targetUrl)) return json(res, 400, { error: 'Every wall ad needs an https:// link (' + (name || targetUrl) + ').' }); + if (bannerUrl && !/^(\/uploads\/[a-z0-9]{24}\.(png|jpg|webp|gif)|https:\/\/[^\s]+)$/i.test(bannerUrl)) return json(res, 400, { error: 'Banner must be an uploaded image or an https image URL (' + (name || targetUrl) + ').' }); + out.push({ name: name || 'InstantAdPay', targetUrl, bannerUrl: bannerUrl || null }); + } + const file = path.join(DATA_DIR, 'admin-wall-ads.json'); + if (out.length) fs.writeFileSync(file, JSON.stringify(out, null, 2)); else { try { fs.unlinkSync(file); } catch (e) {} } + return json(res, 200, { ok: true, ads: out, usingDefaults: !out.length }); + } if (p === '/api/admin/site' && req.method === 'GET') { if (!isAdmin(req)) return json(res, 401, { error: 'auth' }); return json(res, 200, { site: siteConfig() });