d5c9e33e0e
Text placements out-perform banners on this network — 357 text ads average ~171 clicks each against ~150 for banners, on far less inventory — so members can now run them too. The format is measured, not guessed. Across 60 live text ads: Subject caps at 20 characters and Body is ALWAYS exactly three <br>-joined lines of at most 24 characters. Every ad in the sample obeyed it. Emoji: the NAS columns are latin1, but so is the connection, so UTF-8 bytes pass through and render — which is how the existing emoji ads got there. The bridge's clean() would have deleted every emoji via iconv //TRANSLIT//IGNORE, so text copy now uses clean_ad_text(), which preserves them and strips angle brackets instead. The bridge builds the <br> separators itself from a lines[] array, so a member can never inject markup. The engine writes plain text to a tight budget and we apply emoji ourselves: models cannot count characters, least of all with emoji, so decoration is arithmetic we control. It also lets the emoji rotate independently of the copy. The palette carries no money emoji — the network is full of them, but implying earnings outranks matching the neighbours. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
336 lines
13 KiB
JavaScript
336 lines
13 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); };
|
||
function esc(s) {
|
||
return String(s == null ? '' : s).replace(/[&<>"']/g, function (ch) {
|
||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch];
|
||
});
|
||
}
|
||
var state = { size: null, creative: null, target: 'join', creatives: {}, remaining: 0, id: null,
|
||
format: 'banner', angle: 'general', angles: [], variants: [], variant: null, hasPage: false };
|
||
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 renderFormat() {
|
||
var host = $('tfFormat');
|
||
host.innerHTML = '';
|
||
[['banner', 'Banner image'], ['text', 'Text ad']].forEach(function (f) {
|
||
host.appendChild(chip(f[1], state.format === f[0], function () {
|
||
state.format = f[0];
|
||
renderFormat();
|
||
goLabel();
|
||
$('tfBannerPane').style.display = f[0] === 'banner' ? '' : 'none';
|
||
$('tfTextPane').style.display = f[0] === 'text' ? '' : 'none';
|
||
}));
|
||
});
|
||
}
|
||
|
||
function renderAngles() {
|
||
var host = $('tfAngles');
|
||
host.innerHTML = '';
|
||
state.angles.forEach(function (a) {
|
||
host.appendChild(chip(a.label, state.angle === a.key, function () {
|
||
state.angle = a.key; renderAngles();
|
||
}));
|
||
});
|
||
}
|
||
|
||
function renderVariants() {
|
||
var host = $('tfVariants');
|
||
host.innerHTML = '';
|
||
if (!state.variants.length) return;
|
||
var grid = document.createElement('div');
|
||
grid.className = 'tf-ads';
|
||
state.variants.forEach(function (v, i) {
|
||
var card = document.createElement('div');
|
||
card.className = 'tf-ad' + (state.variant === i ? ' on' : '');
|
||
var s = document.createElement('div');
|
||
s.className = 's'; s.textContent = v.subject;
|
||
card.appendChild(s);
|
||
v.lines.forEach(function (l) {
|
||
if (!l) return;
|
||
var d = document.createElement('div');
|
||
d.className = 'l'; d.textContent = l;
|
||
card.appendChild(d);
|
||
});
|
||
card.addEventListener('click', function () { state.variant = i; renderVariants(); });
|
||
grid.appendChild(card);
|
||
});
|
||
host.appendChild(grid);
|
||
var hint = document.createElement('div');
|
||
hint.className = 'tf-hint';
|
||
hint.textContent = 'Pick the one you want to run, then set your impressions below and launch it.';
|
||
host.appendChild(hint);
|
||
}
|
||
|
||
async function writeAds(btn) {
|
||
btn.disabled = true;
|
||
btn.innerHTML = '<span class="spin"></span>Writing…';
|
||
$('tfErr').style.display = 'none';
|
||
try {
|
||
var r = await fetch('/api/public/suite-textads', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ angle: state.angle })
|
||
});
|
||
var d = await r.json();
|
||
if (!r.ok) {
|
||
$('tfErr').textContent = d.error || 'The writer could not produce ads just now.';
|
||
$('tfErr').style.display = 'block';
|
||
} else {
|
||
state.variants = d.variants || [];
|
||
state.variant = state.variants.length ? 0 : null;
|
||
renderVariants();
|
||
if (d.meter) showWriteMeter(d.meter);
|
||
}
|
||
} catch (e) {
|
||
$('tfErr').textContent = 'Connection hiccup — try again.';
|
||
$('tfErr').style.display = 'block';
|
||
}
|
||
btn.disabled = false;
|
||
btn.innerHTML = state.variants.length ? '✍️ Write 5 more' : '✍️ Write me 5 ads';
|
||
}
|
||
|
||
function showWriteMeter(m) {
|
||
if (!m) return;
|
||
$('tfWriteMeter').textContent = m.remaining + ' of ' + m.limit + ' batches left this month';
|
||
}
|
||
|
||
function goLabel() {
|
||
if ($('tfGo').disabled && state.remaining <= 0) return;
|
||
$('tfGo').textContent = state.format === 'text' ? '🚦 Launch my text ad' : '🚦 Launch my banner';
|
||
}
|
||
|
||
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');
|
||
var label = c.kind === 'text'
|
||
? '<b>Text</b><br><span style="font-size:12px">' + esc(c.subject || '') + '</span>'
|
||
: '<b>' + c.size + '</b>';
|
||
tr.innerHTML = '<td>' + label + '</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;
|
||
state.angles = d.textAngles || state.angles;
|
||
renderFormat(); renderAngles(); renderVariants();
|
||
showWriteMeter(d.textMeter);
|
||
if (d.writerReady === false) {
|
||
$('tfWrite').disabled = true;
|
||
$('tfWriteMeter').textContent = 'The writer is warming up.';
|
||
}
|
||
renderSizes(st.sizes || []); renderCreatives(); renderTargets(); goLabel();
|
||
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…';
|
||
|
||
var payload = { target: state.target, impressions: Number($('tfImp').value) || 0 };
|
||
if (state.format === 'text') {
|
||
var v = state.variants[state.variant];
|
||
if (!v) {
|
||
$('tfErr').textContent = 'Write some ads first, then pick the one you want to run.';
|
||
$('tfErr').style.display = 'block';
|
||
busy = false; $('tfGo').disabled = false; goLabel();
|
||
return;
|
||
}
|
||
payload.kind = 'text';
|
||
payload.subject = v.subject;
|
||
payload.lines = v.lines;
|
||
} else {
|
||
payload.kind = 'banner';
|
||
payload.size = state.size;
|
||
payload.creative = state.creative;
|
||
}
|
||
|
||
try {
|
||
var r = await fetch('/api/public/suite-traffic', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
var d = await r.json();
|
||
if (!r.ok) {
|
||
$('tfErr').textContent = d.error || 'That didn’t go through — try again.';
|
||
$('tfErr').style.display = 'block';
|
||
} else {
|
||
var what = d.campaign.kind === 'text' ? 'text ad' : 'banner';
|
||
$('tfOk').innerHTML = '✅ <b>Your ' + what + ' is live on the network.</b><br>' +
|
||
Number(d.campaign.impressions).toLocaleString() + ' impressions of ' +
|
||
(d.campaign.kind === 'text' ? 'your text ad' : 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; goLabel(); }
|
||
}
|
||
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
boot();
|
||
$('tfGo').addEventListener('click', run);
|
||
$('tfWrite').addEventListener('click', function () { writeAds(this); });
|
||
});
|
||
})();
|