Admin: wall fallback ads editor (House ads pane) + /api/admin/wall-ads

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
martbost
2026-09-09 15:45:49 -05:00
parent 2ac6549171
commit b86dbfd9e9
3 changed files with 79 additions and 1 deletions
+11 -1
View File
@@ -199,6 +199,16 @@
<div class="card-head"><h3>House ads</h3><span class="sub" id="houseSub">running free</span></div>
<div class="tablewrap"><table class="adm-table" id="houseTable"></table></div>
</div>
<div class="card">
<div class="card-head"><h3>Wall fallback ads</h3><span class="sub" id="wallAdsSub">shown on member walls in positions they have not earned or filled, when no upline banner exists</span></div>
<p class="small muted">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.</p>
<div id="wallAdsList" class="drip-steps"></div>
<p style="display:flex;gap:10px;flex-wrap:wrap;margin:12px 0 0">
<button class="btn small" id="wallAdsSave" type="button">Save wall ads</button>
<button class="btn small sec" id="wallAdsAdd" type="button">+ Add a wall ad</button>
</p>
<p id="wallAdsErr" class="small" style="color:var(--bad)" hidden></p>
</div>
</div>
<div class="pane" id="pane-campaigns" hidden>
@@ -271,6 +281,6 @@
</div>
<script src="/assets/common.js?v=20260909a"></script>
<script src="/assets/admin.js?v=20260909c"></script>
<script src="/assets/admin.js?v=20260909d"></script>
</body>
</html>
+44
View File
@@ -192,10 +192,54 @@
if (!$('hWatchSecs').options.length) $('hWatchSecs').innerHTML = (rates.videoTiers || []).map(t => '<option value="' + t.secs + '">Watch ' + t.secs + 's (viewer earns ' + t.reward + ')</option>').join('');
if (!$('hFeatDays').options.length) $('hFeatDays').innerHTML = (rates.featuredDurations || [1, 2, 7]).map(d => '<option value="' + d + '">' + d + ' day' + (d > 1 ? 's' : '') + '</option>').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('') : '<tr><td class="muted">No house ads yet. Place one above.</td></tr>';
}
// wall fallback ads editor
let wallAds = [];
function drawWallAds() {
const w = $('wallAdsList');
w.innerHTML = wallAds.map((a, i) => '<div class="drip-step" data-i="' + i + '"><div class="ds-head"><span class="ds-n">WALL AD ' + (i + 1) + '</span>'
+ '<span class="ds-tools"><button type="button" class="btn small sec" data-wact="up" ' + (i === 0 ? 'disabled' : '') + '>↑</button><button type="button" class="btn small sec" data-wact="down" ' + (i === wallAds.length - 1 ? 'disabled' : '') + '>↓</button><button type="button" class="btn small sec" data-wact="remove">Remove</button></span></div>'
+ '<div class="grid c3"><p><input class="wa-name" maxlength="60" placeholder="Label shown under the ad" value="' + esc(a.name || '') + '"></p>'
+ '<p><input class="wa-target" placeholder="Link (https://…)" value="' + esc(a.targetUrl || '') + '"></p>'
+ '<p><input class="wa-banner" placeholder="Banner image URL or upload" value="' + esc(a.bannerUrl || '') + '"> <button type="button" class="btn small sec wa-upload">Upload</button><input type="file" class="wa-file" accept="image/png,image/jpeg,image/webp,image/gif" hidden></p></div>'
+ (a.bannerUrl ? '<img src="' + esc(a.bannerUrl) + '" alt="" style="max-height:60px;border-radius:6px">' : '')
+ '</div>').join('') || '<p class="muted small">No wall ads set. Walls fall back to a plain InstantAdPay card.</p>';
}
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;
+24
View File
@@ -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() });