Partner promo codes: reusable codes that add free ad credits, tracked per redemption
promos.js (promo_codes + promo_redemptions, JSON fallback): create/update codes with credits, partner, optional cap and expiry, on/off. Redeemed once per account either from a join link ?promo=CODE (cookie, applied at signup) or the Overview box 'Have a promo code?' (/api/my/promo/redeem). Admin > Traffic gets a Partner promo codes card (create, toggle, uses, recent redemptions). Chatbot line added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
+16
-1
@@ -264,6 +264,21 @@
|
||||
<div class="card"><div class="card-head"><h3>Angles</h3><span class="sub">join-page hook copy</span></div><div class="tablewrap"><table class="adm-table" id="trfAngles"></table></div></div>
|
||||
</div>
|
||||
<div class="card"><div class="card-head"><h3>By day</h3><span class="sub">page views, signups</span></div><div class="tablewrap"><table class="adm-table" id="trfDaily"></table></div></div>
|
||||
<div class="card" id="promoAdmin">
|
||||
<div class="card-head"><h3>Partner promo codes</h3><span class="sub">free ad credits for members who redeem a partner's code</span></div>
|
||||
<p class="muted small" style="margin:0 0 10px">Give a site owner a code. Their members redeem it on a join link (<code>instantadpay.com/join/martbost?promo=CODE</code>) or in the "Have a promo code?" box on the Overview. One use per account; uses and the last redemptions are listed below.</p>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;align-items:end">
|
||||
<label class="small">Code<input id="pcCode" type="text" placeholder="TRAFFICWAVE" style="text-transform:uppercase"></label>
|
||||
<label class="small">Credits<input id="pcCredits" type="number" min="1" placeholder="250"></label>
|
||||
<label class="small">Partner<input id="pcPartner" type="text" placeholder="site or owner"></label>
|
||||
<label class="small">Max uses (0 = unlimited)<input id="pcMax" type="number" min="0" value="0"></label>
|
||||
<label class="small">Expires (optional)<input id="pcExpires" type="date"></label>
|
||||
<button type="button" class="btn small" id="pcSave">Save code</button>
|
||||
</div>
|
||||
<p class="small" id="pcMsg" hidden style="margin:8px 0 0"></p>
|
||||
<div class="tablewrap" style="margin-top:12px"><table class="adm-table" id="pcTable"></table></div>
|
||||
<div class="tablewrap" style="margin-top:12px"><table class="adm-table" id="pcRecent"></table></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane" id="pane-pnl" hidden>
|
||||
<div class="card">
|
||||
@@ -330,6 +345,6 @@
|
||||
</div>
|
||||
|
||||
<script src="/assets/common.js?v=20260912b"></script>
|
||||
<script src="/assets/admin.js?v=20260912a"></script>
|
||||
<script src="/assets/admin.js?v=20260912b"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -313,7 +313,29 @@
|
||||
// ── traffic: referring domains / sources, landing pages, angles, by day ──
|
||||
let trfDays = 30;
|
||||
document.querySelectorAll('#trfRange [data-days]').forEach(b => b.addEventListener('click', () => { trfDays = Number(b.dataset.days); document.querySelectorAll('#trfRange [data-days]').forEach(x => x.classList.toggle('on', x === b)); loadTraffic().catch(e => IAP.status(e.message, 'bad')); }));
|
||||
async function loadPromos() {
|
||||
const d = await (await fetch('/api/admin/promos')).json();
|
||||
if (d.error) throw new Error(d.error);
|
||||
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
const when = t => t ? new Date(t).toLocaleDateString() : '';
|
||||
$('pcTable').innerHTML = '<tr><th>Code</th><th>Credits</th><th>Partner</th><th>Uses</th><th>Max</th><th>Expires</th><th>Status</th><th></th></tr>'
|
||||
+ (d.codes.length ? d.codes.map(c => '<tr><td><b>' + esc(c.code) + '</b></td><td>' + c.credits.toLocaleString() + '</td><td>' + esc(c.partner) + '</td><td>' + c.uses + '</td><td>' + (c.maxUses || '∞') + '</td><td>' + (c.expires ? when(c.expires) : '') + '</td><td>' + (c.active ? 'active' : 'off') + '</td><td class="act"><button type="button" class="btn small ghost" data-pctoggle="' + esc(c.code) + '" data-on="' + (c.active ? 0 : 1) + '">' + (c.active ? 'Switch off' : 'Switch on') + '</button></td></tr>').join('') : '<tr><td colspan="8" class="muted">No codes yet.</td></tr>');
|
||||
$('pcRecent').innerHTML = '<tr><th>When</th><th>Code</th><th>Email</th><th>Credits</th><th>Via</th></tr>'
|
||||
+ (d.recent.length ? d.recent.map(r => '<tr><td>' + new Date(r.ts).toLocaleString() + '</td><td>' + esc(r.code) + '</td><td>' + esc(r.email) + '</td><td>' + r.credits + '</td><td>' + esc(r.via) + '</td></tr>').join('') : '<tr><td colspan="5" class="muted">No redemptions yet.</td></tr>');
|
||||
document.querySelectorAll('[data-pctoggle]').forEach(b => b.addEventListener('click', async () => {
|
||||
try { await api('/api/admin/promos', { code: b.dataset.pctoggle, active: b.dataset.on === '1' }, 'PATCH'); loadPromos(); } catch (e) { IAP.status(e.message, 'bad'); }
|
||||
}));
|
||||
}
|
||||
if ($('pcSave')) $('pcSave').addEventListener('click', async () => {
|
||||
const msg = $('pcMsg'); msg.hidden = false;
|
||||
try {
|
||||
const r = await api('/api/admin/promos', { code: $('pcCode').value, credits: $('pcCredits').value, partner: $('pcPartner').value, maxUses: $('pcMax').value, expires: $('pcExpires').value || null, active: true });
|
||||
msg.textContent = 'Saved ' + r.code.code + ': ' + r.code.credits + ' credits.'; msg.style.color = 'var(--mint)';
|
||||
$('pcCode').value = ''; $('pcCredits').value = ''; $('pcPartner').value = ''; loadPromos();
|
||||
} catch (e) { msg.textContent = e.message; msg.style.color = '#ff8a8a'; }
|
||||
});
|
||||
async function loadTraffic() {
|
||||
loadPromos().catch(e => IAP.status(e.message, 'bad'));
|
||||
const d = await (await fetch('/api/admin/traffic?days=' + trfDays)).json();
|
||||
if (d.error) throw new Error(d.error);
|
||||
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
|
||||
@@ -2215,3 +2215,22 @@
|
||||
|
||||
render();
|
||||
})();
|
||||
|
||||
// ── partner promo codes: typed on the Overview (Marty, 2026-09-12) ──
|
||||
(function () {
|
||||
const btn = document.getElementById('promoApply'), inp = document.getElementById('promoCode'), msg = document.getElementById('promoMsg');
|
||||
if (!btn || !inp) return;
|
||||
const say = (t, ok) => { msg.hidden = false; msg.textContent = t; msg.style.color = ok ? 'var(--mint)' : '#ff8a8a'; };
|
||||
const go = async () => {
|
||||
const code = inp.value.trim(); if (!code) { say('Enter a promo code.', false); return; }
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await (await fetch('/api/my/promo/redeem', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ code }) })).json();
|
||||
if (r.error) { say(r.error, false); return; }
|
||||
say('Added ' + Number(r.credits).toLocaleString() + ' credits' + (r.partner ? ' from ' + r.partner : '') + '. They are in your balance now.', true);
|
||||
inp.value = ''; if (typeof loadDashboard === 'function') loadDashboard();
|
||||
} catch (e) { say('Could not apply that code. Try again.', false); }
|
||||
finally { btn.disabled = false; }
|
||||
};
|
||||
btn.addEventListener('click', go); inp.addEventListener('keydown', e => { if (e.key === 'Enter') go(); });
|
||||
})();
|
||||
|
||||
+5
-1
@@ -258,6 +258,10 @@
|
||||
</div>
|
||||
<div class="card"><h3>Earning levels</h3>
|
||||
<p id="qualLine" class="muted small">…</p></div>
|
||||
<div class="card" id="promoCard"><h3>Have a promo code?</h3>
|
||||
<p class="muted small" style="margin:6px 0 10px">Codes from partner sites add free ad credits to your account. One use per code.</p>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap"><input id="promoCode" type="text" placeholder="CODE" autocomplete="off" style="flex:1;min-width:140px;text-transform:uppercase"><button type="button" class="btn small" id="promoApply">Apply</button></div>
|
||||
<p class="small" id="promoMsg" hidden style="margin:8px 0 0"></p></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" id="adSlotOverview" hidden></div>
|
||||
@@ -911,7 +915,7 @@
|
||||
<script src="/assets/common.js?v=20260912b"></script>
|
||||
<script src="/assets/wallet.js?v=20260911a"></script>
|
||||
<script src="/assets/promo.js?v=20260911a"></script>
|
||||
<script src="/assets/my.js?v=20260912b"></script>
|
||||
<script src="/assets/my.js?v=20260912c"></script>
|
||||
<script src="/assets/chat.js?v=20260907l"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user