931eb4336b
Three fixes from live testing of the Traffic Desk: - /p/<id> with no page built now 302s to /join/<id> instead of returning raw JSON 404. Any /p/ link already on a banner, flyer, or in a DM must always land somewhere useful. - The personal-page ad destination is only offered (client) and only accepted (server) once a page actually exists; otherwise the member is pointed at the Page Builder. - Members can stop a running banner and get the unserved impressions back in their monthly balance. Counters are read before deactivation, since deactivating zeroes `remaining` and would look fully served. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
212 lines
8.9 KiB
JavaScript
212 lines
8.9 KiB
JavaScript
// Traffic Desk client — pick size, pick approved creative, pick destination,
|
||
// launch. Balance and running campaigns come from the server.
|
||
(function () {
|
||
'use strict';
|
||
var $ = function (id) { return document.getElementById(id); };
|
||
var state = { size: null, creative: null, target: 'join', creatives: {}, remaining: 0, id: null };
|
||
var busy = false;
|
||
|
||
function chip(label, on, fn) {
|
||
var b = document.createElement('button');
|
||
b.type = 'button';
|
||
b.className = 'tf-chip' + (on ? ' on' : '');
|
||
b.textContent = label;
|
||
b.addEventListener('click', fn);
|
||
return b;
|
||
}
|
||
|
||
function renderSizes(sizes) {
|
||
var host = $('tfSizes');
|
||
host.innerHTML = '';
|
||
sizes.forEach(function (s) {
|
||
host.appendChild(chip(s, s === state.size, function () {
|
||
state.size = s;
|
||
state.creative = (state.creatives[s] || [])[0] || null;
|
||
renderSizes(sizes); renderCreatives();
|
||
}));
|
||
});
|
||
}
|
||
|
||
function renderCreatives() {
|
||
var host = $('tfCreatives');
|
||
host.innerHTML = '';
|
||
(state.creatives[state.size] || []).forEach(function (f) {
|
||
var d = document.createElement('div');
|
||
d.className = 'tf-cre' + (f === state.creative ? ' on' : '');
|
||
var img = document.createElement('img');
|
||
img.src = '/banners/' + f;
|
||
img.alt = 'Team banner ' + state.size;
|
||
img.loading = 'lazy';
|
||
d.appendChild(img);
|
||
d.addEventListener('click', function () { state.creative = f; renderCreatives(); });
|
||
host.appendChild(d);
|
||
});
|
||
}
|
||
|
||
function renderTargets() {
|
||
var host = $('tfTargets');
|
||
host.innerHTML = '';
|
||
host.appendChild(chip('My invite page', state.target === 'join', function () {
|
||
state.target = 'join'; renderTargets();
|
||
}));
|
||
if (state.hasPage) {
|
||
host.appendChild(chip('My personal page', state.target === 'page', function () {
|
||
state.target = 'page'; renderTargets();
|
||
}));
|
||
} else {
|
||
// Don't let anyone point an ad at a page that doesn't exist yet.
|
||
var d = document.createElement('span');
|
||
d.className = 'tf-hint';
|
||
d.style.cssText = 'margin:0;align-self:center';
|
||
d.innerHTML = 'Want to send traffic to your own page instead? <a href="/suite/page" style="color:var(--gold);font-weight:700">Build it first →</a>';
|
||
host.appendChild(d);
|
||
}
|
||
}
|
||
|
||
function gate(msg) {
|
||
$('tfGate').style.display = 'block';
|
||
$('tfGate').innerHTML = msg;
|
||
$('tfCard').style.opacity = '.55';
|
||
$('tfGo').disabled = true;
|
||
}
|
||
|
||
function renderLive(campaigns, live) {
|
||
if (!campaigns.length) { $('tfLiveWrap').style.display = 'none'; return; }
|
||
var byId = {};
|
||
(live || []).forEach(function (l) { byId[l.ad_id] = l; });
|
||
var tb = $('tfLive');
|
||
tb.innerHTML = '';
|
||
campaigns.slice().reverse().forEach(function (c) {
|
||
var l = byId[c.adId] || {};
|
||
var tr = document.createElement('tr');
|
||
var served = l.served != null ? l.served : 0;
|
||
var statusCell = c.stopped
|
||
? 'stopped <span style="opacity:.7">(' + Number(c.refunded || 0).toLocaleString() + ' returned)</span>'
|
||
: (l.live ? '<span style="color:var(--teal)">running</span>' : 'finished');
|
||
tr.innerHTML = '<td><b>' + c.size + '</b></td>' +
|
||
'<td>' + (c.target.indexOf('/p/') !== -1 ? 'personal page' : 'invite page') + '</td>' +
|
||
'<td>' + Number(c.bought != null ? c.bought : c.impressions).toLocaleString() + '</td>' +
|
||
'<td><b>' + Number(c.stopped ? (c.served || 0) : served).toLocaleString() + '</b></td>' +
|
||
'<td>' + (c.stopped ? '—' : (l.remaining != null ? Number(l.remaining).toLocaleString() : '—')) + '</td>' +
|
||
'<td>' + (l.hits != null ? l.hits : '—') + '</td>' +
|
||
'<td>' + statusCell + '</td>';
|
||
var act = document.createElement('td');
|
||
if (!c.stopped && l.live) {
|
||
var btn = document.createElement('button');
|
||
btn.className = 'btn btn-secondary btn-sm';
|
||
btn.textContent = 'Stop';
|
||
btn.title = 'Stop this banner and return the unserved impressions to your balance';
|
||
btn.addEventListener('click', function () { stopCampaign(c.adId, btn); });
|
||
act.appendChild(btn);
|
||
}
|
||
tr.appendChild(act);
|
||
tb.appendChild(tr);
|
||
});
|
||
$('tfLiveWrap').style.display = 'block';
|
||
}
|
||
|
||
function applyStatus(d) {
|
||
var st = d.status;
|
||
state.creatives = st.creatives || {};
|
||
state.id = d.id;
|
||
state.remaining = st.remaining;
|
||
if (typeof d.hasPage === 'boolean') state.hasPage = d.hasPage;
|
||
if (!state.hasPage && state.target === 'page') state.target = 'join';
|
||
if (!state.size) state.size = (st.sizes || [])[0] || null;
|
||
if (!state.creative) state.creative = (state.creatives[state.size] || [])[0] || null;
|
||
$('tfBal').style.display = 'flex';
|
||
$('tfRemain').textContent = Number(st.remaining).toLocaleString();
|
||
$('tfLimit').textContent = Number(st.limit).toLocaleString();
|
||
$('tfNote').innerHTML = 'Level ' + d.level + ' includes <b style="color:var(--text)">' +
|
||
Number(st.limit).toLocaleString() + '</b> impressions a month on the network. Each upgrade raises it — Apex is where it jumps to 50,000.';
|
||
$('tfImp').max = st.remaining;
|
||
$('tfImp').value = Math.min(Number($('tfImp').value) || st.remaining, st.remaining) || 100;
|
||
renderSizes(st.sizes || []); renderCreatives(); renderTargets();
|
||
renderLive(st.campaigns || [], d.live || []);
|
||
if (st.remaining <= 0) {
|
||
$('tfGo').disabled = true;
|
||
$('tfGo').textContent = 'Allowance used — refills on the 1st';
|
||
}
|
||
}
|
||
|
||
async function stopCampaign(adId, btn) {
|
||
if (btn) { btn.disabled = true; btn.textContent = 'Stopping…'; }
|
||
try {
|
||
var r = await fetch('/api/public/suite-traffic-stop', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ ad_id: adId })
|
||
});
|
||
var d = await r.json();
|
||
if (!r.ok) {
|
||
$('tfErr').textContent = d.error || 'Could not stop that banner.';
|
||
$('tfErr').style.display = 'block';
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Stop'; }
|
||
return;
|
||
}
|
||
$('tfOk').innerHTML = '✅ <b>Banner stopped.</b> ' +
|
||
Number(d.stopped.refunded).toLocaleString() + ' unserved impressions went back into your balance' +
|
||
(d.stopped.served ? ' — it had served ' + Number(d.stopped.served).toLocaleString() + '.' : '.');
|
||
$('tfOk').style.display = 'block';
|
||
boot();
|
||
} catch (e) {
|
||
$('tfErr').textContent = 'Connection hiccup — try again.';
|
||
$('tfErr').style.display = 'block';
|
||
if (btn) { btn.disabled = false; btn.textContent = 'Stop'; }
|
||
}
|
||
}
|
||
|
||
async function boot() {
|
||
try {
|
||
var r = await fetch('/api/public/suite-traffic');
|
||
if (r.status === 401) { gate('You’re not signed in yet. <a href="/suite" style="color:var(--gold);font-weight:700">Open the Suite</a> and connect the wallet that holds your position, then come back.'); return; }
|
||
if (r.status === 403) { gate('The Traffic Desk isn’t open for this position yet. <a href="/suite" style="color:var(--gold);font-weight:700">See your Suite</a>.'); return; }
|
||
if (!r.ok) return;
|
||
var d = await r.json();
|
||
if (!d.configured) { gate('The ad network bridge is being finalized — this opens shortly.'); return; }
|
||
applyStatus(d);
|
||
} catch (e) {}
|
||
}
|
||
|
||
async function run() {
|
||
if (busy) return;
|
||
busy = true;
|
||
$('tfErr').style.display = 'none';
|
||
$('tfOk').style.display = 'none';
|
||
$('tfGo').disabled = true;
|
||
$('tfGo').innerHTML = '<span class="spin"></span>Launching on the network…';
|
||
try {
|
||
var r = await fetch('/api/public/suite-traffic', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
size: state.size, creative: state.creative, target: state.target,
|
||
impressions: Number($('tfImp').value) || 0
|
||
})
|
||
});
|
||
var d = await r.json();
|
||
if (!r.ok) {
|
||
$('tfErr').textContent = d.error || 'That didn’t go through — try again.';
|
||
$('tfErr').style.display = 'block';
|
||
} else {
|
||
$('tfOk').innerHTML = '✅ <b>Your banner is live on the network.</b><br>' +
|
||
Number(d.campaign.impressions).toLocaleString() + ' impressions of ' + d.campaign.size +
|
||
' pointed at your ' + (d.campaign.target.indexOf('/p/') !== -1 ? 'personal page' : 'invite page') +
|
||
'. It starts rotating immediately — check back here to watch it serve.';
|
||
$('tfOk').style.display = 'block';
|
||
applyStatus({ status: d.status, level: 0, id: state.id, live: [], configured: true });
|
||
// refresh from the server so the running-banners table picks it up
|
||
setTimeout(boot, 800);
|
||
}
|
||
} catch (e) {
|
||
$('tfErr').textContent = 'Connection hiccup — try again.';
|
||
$('tfErr').style.display = 'block';
|
||
}
|
||
busy = false;
|
||
if (state.remaining > 0) { $('tfGo').disabled = false; $('tfGo').textContent = '🚦 Launch my banner'; }
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
boot();
|
||
$('tfGo').addEventListener('click', run);
|
||
});
|
||
})();
|