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:
@@ -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(); });
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user