Traffic Desk: AI-written text ads alongside banners
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>
This commit is contained in:
@@ -29,6 +29,14 @@
|
|||||||
.tf-live th{text-align:left;padding:8px 10px;font-size:11px;letter-spacing:1px;text-transform:uppercase;color:var(--muted);border-bottom:2px solid var(--line)}
|
.tf-live th{text-align:left;padding:8px 10px;font-size:11px;letter-spacing:1px;text-transform:uppercase;color:var(--muted);border-bottom:2px solid var(--line)}
|
||||||
.tf-live td{padding:9px 10px;border-bottom:1px solid var(--line);color:var(--muted);font-variant-numeric:tabular-nums}
|
.tf-live td{padding:9px 10px;border-bottom:1px solid var(--line);color:var(--muted);font-variant-numeric:tabular-nums}
|
||||||
.tf-live td b{color:var(--text)}
|
.tf-live td b{color:var(--text)}
|
||||||
|
.tf-ads{display:grid;grid-template-columns:repeat(auto-fill,minmax(215px,1fr));gap:10px;margin-top:14px}
|
||||||
|
.tf-ad{border:2px solid var(--line);border-radius:10px;padding:12px 13px;cursor:pointer;
|
||||||
|
background:var(--bg,#0b1d2e);transition:border-color .12s}
|
||||||
|
.tf-ad:hover{border-color:var(--muted)}
|
||||||
|
.tf-ad.on{border-color:var(--gold);background:rgba(212,175,55,.07)}
|
||||||
|
.tf-ad .s{font-weight:800;font-size:15px;color:var(--gold);line-height:1.25;margin-bottom:5px;word-break:break-word}
|
||||||
|
.tf-ad .l{font-size:13px;line-height:1.5;color:var(--text);word-break:break-word}
|
||||||
|
.tf-ad .cnt{margin-top:8px;font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums}
|
||||||
.tf-note{margin-top:22px;font-size:12.5px;color:var(--muted);line-height:1.6;border-top:1px solid var(--line);padding-top:12px}
|
.tf-note{margin-top:22px;font-size:12.5px;color:var(--muted);line-height:1.6;border-top:1px solid var(--line);padding-top:12px}
|
||||||
.spin{display:inline-block;width:13px;height:13px;border:2px solid rgba(255,255,255,.25);border-top-color:var(--gold);
|
.spin{display:inline-block;width:13px;height:13px;border:2px solid rgba(255,255,255,.25);border-top-color:var(--gold);
|
||||||
border-radius:50%;animation:sp .8s linear infinite;vertical-align:-2px;margin-right:7px}
|
border-radius:50%;animation:sp .8s linear infinite;vertical-align:-2px;margin-right:7px}
|
||||||
@@ -40,7 +48,7 @@
|
|||||||
<main class="tw">
|
<main class="tw">
|
||||||
<div class="eyebrow" style="color:var(--gold)">CIRCLE SUITE · INCLUDED AT EVERY PAID LEVEL</div>
|
<div class="eyebrow" style="color:var(--gold)">CIRCLE SUITE · INCLUDED AT EVERY PAID LEVEL</div>
|
||||||
<h1>Traffic <span class="gold">Desk.</span></h1>
|
<h1>Traffic <span class="gold">Desk.</span></h1>
|
||||||
<p class="lede">Put a team banner on our own advertising network, pointed at your invite link. Impressions are included with your position and scale as you level up — nothing to buy, nothing to configure.</p>
|
<p class="lede">Run a team banner or an AI-written text ad on our own advertising network, pointed at your invite link. Impressions are included with your position and scale as you level up — nothing to buy, nothing to configure.</p>
|
||||||
|
|
||||||
<div id="tfGate"></div>
|
<div id="tfGate"></div>
|
||||||
|
|
||||||
@@ -51,10 +59,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tf-card" id="tfCard">
|
<div class="tf-card" id="tfCard">
|
||||||
|
<label class="tf-lab">Ad format</label>
|
||||||
|
<div class="tf-chips" id="tfFormat"></div>
|
||||||
|
|
||||||
|
<div id="tfBannerPane">
|
||||||
<label class="tf-lab">Banner size</label>
|
<label class="tf-lab">Banner size</label>
|
||||||
<div class="tf-chips" id="tfSizes"></div>
|
<div class="tf-chips" id="tfSizes"></div>
|
||||||
<label class="tf-lab">Pick a design</label>
|
<label class="tf-lab">Pick a design</label>
|
||||||
<div class="tf-creatives" id="tfCreatives"></div>
|
<div class="tf-creatives" id="tfCreatives"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tfTextPane" style="display:none">
|
||||||
|
<label class="tf-lab">What angle should they take?</label>
|
||||||
|
<div class="tf-chips" id="tfAngles"></div>
|
||||||
|
<div class="tf-row">
|
||||||
|
<button id="tfWrite" class="btn btn-secondary">✍️ Write me 5 ads</button>
|
||||||
|
<span class="tf-hint" id="tfWriteMeter" style="margin:0"></span>
|
||||||
|
</div>
|
||||||
|
<div id="tfVariants"></div>
|
||||||
|
</div>
|
||||||
<label class="tf-lab">Where should it send people?</label>
|
<label class="tf-lab">Where should it send people?</label>
|
||||||
<div class="tf-chips" id="tfTargets"></div>
|
<div class="tf-chips" id="tfTargets"></div>
|
||||||
<label class="tf-lab" for="tfImp">How many impressions?</label>
|
<label class="tf-lab" for="tfImp">How many impressions?</label>
|
||||||
|
|||||||
+134
-10
@@ -3,7 +3,13 @@
|
|||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
var $ = function (id) { return document.getElementById(id); };
|
var $ = function (id) { return document.getElementById(id); };
|
||||||
var state = { size: null, creative: null, target: 'join', creatives: {}, remaining: 0, id: null };
|
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;
|
var busy = false;
|
||||||
|
|
||||||
function chip(label, on, fn) {
|
function chip(label, on, fn) {
|
||||||
@@ -63,6 +69,95 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function gate(msg) {
|
||||||
$('tfGate').style.display = 'block';
|
$('tfGate').style.display = 'block';
|
||||||
$('tfGate').innerHTML = msg;
|
$('tfGate').innerHTML = msg;
|
||||||
@@ -83,7 +178,10 @@
|
|||||||
var statusCell = c.stopped
|
var statusCell = c.stopped
|
||||||
? 'stopped <span style="opacity:.7">(' + Number(c.refunded || 0).toLocaleString() + ' returned)</span>'
|
? 'stopped <span style="opacity:.7">(' + Number(c.refunded || 0).toLocaleString() + ' returned)</span>'
|
||||||
: (l.live ? '<span style="color:var(--teal)">running</span>' : 'finished');
|
: (l.live ? '<span style="color:var(--teal)">running</span>' : 'finished');
|
||||||
tr.innerHTML = '<td><b>' + c.size + '</b></td>' +
|
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>' + (c.target.indexOf('/p/') !== -1 ? 'personal page' : 'invite page') + '</td>' +
|
||||||
'<td>' + Number(c.bought != null ? c.bought : c.impressions).toLocaleString() + '</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><b>' + Number(c.stopped ? (c.served || 0) : served).toLocaleString() + '</b></td>' +
|
||||||
@@ -121,7 +219,14 @@
|
|||||||
Number(st.limit).toLocaleString() + '</b> impressions a month on the network. Each upgrade raises it — Apex is where it jumps to 50,000.';
|
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').max = st.remaining;
|
||||||
$('tfImp').value = Math.min(Number($('tfImp').value) || st.remaining, st.remaining) || 100;
|
$('tfImp').value = Math.min(Number($('tfImp').value) || st.remaining, st.remaining) || 100;
|
||||||
renderSizes(st.sizes || []); renderCreatives(); renderTargets();
|
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 || []);
|
renderLive(st.campaigns || [], d.live || []);
|
||||||
if (st.remaining <= 0) {
|
if (st.remaining <= 0) {
|
||||||
$('tfGo').disabled = true;
|
$('tfGo').disabled = true;
|
||||||
@@ -174,21 +279,39 @@
|
|||||||
$('tfOk').style.display = 'none';
|
$('tfOk').style.display = 'none';
|
||||||
$('tfGo').disabled = true;
|
$('tfGo').disabled = true;
|
||||||
$('tfGo').innerHTML = '<span class="spin"></span>Launching on the network…';
|
$('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 {
|
try {
|
||||||
var r = await fetch('/api/public/suite-traffic', {
|
var r = await fetch('/api/public/suite-traffic', {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(payload)
|
||||||
size: state.size, creative: state.creative, target: state.target,
|
|
||||||
impressions: Number($('tfImp').value) || 0
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
var d = await r.json();
|
var d = await r.json();
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
$('tfErr').textContent = d.error || 'That didn’t go through — try again.';
|
$('tfErr').textContent = d.error || 'That didn’t go through — try again.';
|
||||||
$('tfErr').style.display = 'block';
|
$('tfErr').style.display = 'block';
|
||||||
} else {
|
} else {
|
||||||
$('tfOk').innerHTML = '✅ <b>Your banner is live on the network.</b><br>' +
|
var what = d.campaign.kind === 'text' ? 'text ad' : 'banner';
|
||||||
Number(d.campaign.impressions).toLocaleString() + ' impressions of ' + d.campaign.size +
|
$('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') +
|
' pointed at your ' + (d.campaign.target.indexOf('/p/') !== -1 ? 'personal page' : 'invite page') +
|
||||||
'. It starts rotating immediately — check back here to watch it serve.';
|
'. It starts rotating immediately — check back here to watch it serve.';
|
||||||
$('tfOk').style.display = 'block';
|
$('tfOk').style.display = 'block';
|
||||||
@@ -201,11 +324,12 @@
|
|||||||
$('tfErr').style.display = 'block';
|
$('tfErr').style.display = 'block';
|
||||||
}
|
}
|
||||||
busy = false;
|
busy = false;
|
||||||
if (state.remaining > 0) { $('tfGo').disabled = false; $('tfGo').textContent = '🚦 Launch my banner'; }
|
if (state.remaining > 0) { $('tfGo').disabled = false; goLabel(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
boot();
|
boot();
|
||||||
$('tfGo').addEventListener('click', run);
|
$('tfGo').addEventListener('click', run);
|
||||||
|
$('tfWrite').addEventListener('click', function () { writeAds(this); });
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
+1
-1
@@ -18,7 +18,7 @@
|
|||||||
{ lv: 3, ico: '📧', name: 'Email Engine', desc: 'Welcome series, follow-up sequences and broadcasts in the team voice — exportable to any autoresponder.', href: '/suite/email', live: true },
|
{ lv: 3, ico: '📧', name: 'Email Engine', desc: 'Welcome series, follow-up sequences and broadcasts in the team voice — exportable to any autoresponder.', href: '/suite/email', live: true },
|
||||||
{ lv: 3, ico: '🎥', name: 'Video Maker', desc: 'The team’s master promo videos rendered with your personal end-card — your name, your QR, your link.', href: '/suite/video', live: true },
|
{ lv: 3, ico: '🎥', name: 'Video Maker', desc: 'The team’s master promo videos rendered with your personal end-card — your name, your QR, your link.', href: '/suite/video', live: true },
|
||||||
{ lv: 4, ico: '🗣️', name: 'Voice Profile + Funnels', desc: 'Output that sounds like YOU, plus multi-page funnels on your own team subdomain.', live: false },
|
{ lv: 4, ico: '🗣️', name: 'Voice Profile + Funnels', desc: 'Output that sounds like YOU, plus multi-page funnels on your own team subdomain.', live: false },
|
||||||
{ lv: 1, ico: '🚦', name: 'Traffic Desk', desc: 'Syndicated network display advertising — put a banner on the team’s own ad network. Monthly impressions scale with your level: 2,500 at Scintilla up to 150,000 at Corona.', href: '/suite/traffic', live: true },
|
{ lv: 1, ico: '🚦', name: 'Traffic Desk', desc: 'Syndicated network display advertising — run banners or AI-written text ads on the team’s own ad network. Monthly impressions scale with your level: 2,500 at Scintilla up to 150,000 at Corona.', href: '/suite/traffic', live: true },
|
||||||
{ lv: 6, ico: '🏭', name: 'Funnel Factory', desc: 'Complete hosted funnels with A/B variants, replay funnels (your team webinar as an on-demand registration page with a timed CTA), and a lead CRM.', live: false },
|
{ lv: 6, ico: '🏭', name: 'Funnel Factory', desc: 'Complete hosted funnels with A/B variants, replay funnels (your team webinar as an on-demand registration page with a timed CTA), and a lead CRM.', live: false },
|
||||||
{ lv: 7, ico: '🧭', name: 'Leader Ops', desc: 'Team radar, AI coaching digests for your legs, and cohort training rooms.', live: false },
|
{ lv: 7, ico: '🧭', name: 'Leader Ops', desc: 'Team radar, AI coaching digests for your legs, and cohort training rooms.', live: false },
|
||||||
{ lv: 8, ico: '👑', name: 'Founder Desk', desc: 'Your own AI operator running your promotion, API access, and the inner circle.', live: false }
|
{ lv: 8, ico: '👑', name: 'Founder Desk', desc: 'Your own AI operator running your promotion, API access, and the inner circle.', live: false }
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ const suiteTools = require('./suite-tools');
|
|||||||
const suiteEmail = require('./suite-email');
|
const suiteEmail = require('./suite-email');
|
||||||
const suiteVideo = require('./suite-video'); suiteVideo.init({ dataDir: DATA_DIR, publicDir: PUBLIC_DIR });
|
const suiteVideo = require('./suite-video'); suiteVideo.init({ dataDir: DATA_DIR, publicDir: PUBLIC_DIR });
|
||||||
const suiteTraffic = require('./suite-traffic'); suiteTraffic.init({ dataDir: DATA_DIR });
|
const suiteTraffic = require('./suite-traffic'); suiteTraffic.init({ dataDir: DATA_DIR });
|
||||||
|
const suiteTextAds = require('./suite-textads');
|
||||||
const tgbot = require('./tgbot');
|
const tgbot = require('./tgbot');
|
||||||
tgbot.init({ dataDir: DATA_DIR, chain, getConfig, messages, baseUrl: 'https://rmcircle.team' });
|
tgbot.init({ dataDir: DATA_DIR, chain, getConfig, messages, baseUrl: 'https://rmcircle.team' });
|
||||||
const SESSION_TTL = 8 * 60 * 60 * 1000;
|
const SESSION_TTL = 8 * 60 * 60 * 1000;
|
||||||
@@ -784,7 +785,8 @@ async function handleApi(req,res,pathname){
|
|||||||
// offered as an ad destination before it's been built.
|
// offered as an ad destination before it's been built.
|
||||||
const pg=suitePages.load(e.d.id);
|
const pg=suitePages.load(e.d.id);
|
||||||
const hasPage=!!(pg&&pg.copy);
|
const hasPage=!!(pg&&pg.copy);
|
||||||
return json(res,200,{level:e.d.level,id:e.d.id,configured:suiteTraffic.configured(),status:st,live:live,hasPage:hasPage});
|
return json(res,200,{level:e.d.level,id:e.d.id,configured:suiteTraffic.configured(),status:st,live:live,hasPage:hasPage,
|
||||||
|
textAngles:suiteTextAds.angles(),textMeter:suiteMeter.check(e.d.id,e.d.level,'textad'),writerReady:suiteAI.configured()});
|
||||||
}
|
}
|
||||||
if(req.method==='POST'&&pathname==='/api/public/suite-traffic'){
|
if(req.method==='POST'&&pathname==='/api/public/suite-traffic'){
|
||||||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500}));
|
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500}));
|
||||||
@@ -796,6 +798,8 @@ async function handleApi(req,res,pathname){
|
|||||||
const pgRec=suitePages.load(e.d.id);
|
const pgRec=suitePages.load(e.d.id);
|
||||||
const entry=await suiteTraffic.launch({
|
const entry=await suiteTraffic.launch({
|
||||||
id:e.d.id, level:e.d.level, size:b.size, creative:b.creative,
|
id:e.d.id, level:e.d.level, size:b.size, creative:b.creative,
|
||||||
|
kind:b.kind==='text'?'text':'banner',
|
||||||
|
subject:b.subject, lines:b.lines,
|
||||||
impressions:b.impressions, target:b.target, angle:b.angle,
|
impressions:b.impressions, target:b.target, angle:b.angle,
|
||||||
hasPage:!!(pgRec&&pgRec.copy),
|
hasPage:!!(pgRec&&pgRec.copy),
|
||||||
name:String(b.name||'').slice(0,60)
|
name:String(b.name||'').slice(0,60)
|
||||||
@@ -804,6 +808,23 @@ async function handleApi(req,res,pathname){
|
|||||||
}catch(err){ return json(res,400,{error:String(err.message||err)}); }
|
}catch(err){ return json(res,400,{error:String(err.message||err)}); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if(req.method==='POST'&&pathname==='/api/public/suite-textads'){
|
||||||
|
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500}));
|
||||||
|
if(e.error)return json(res,e.code||500,{error:e.error});
|
||||||
|
if(!e.inOrg||!e.allowed)return json(res,403,{error:'The Circle Suite is not open for this position yet.'});
|
||||||
|
if(!suiteAI.configured())return json(res,503,{error:'The writer is warming up — try again shortly.'});
|
||||||
|
const b=await bodyJson(req)||{};
|
||||||
|
const gate=suiteMeter.check(e.d.id,e.d.level,'textad');
|
||||||
|
if(!gate.allowed){
|
||||||
|
return json(res,429,{error:'You have used all '+gate.limit+' text-ad batches for this month. It resets on the 1st — or a level upgrade raises your allowance.',meter:gate});
|
||||||
|
}
|
||||||
|
try{
|
||||||
|
const r=await suiteTextAds.generate({angle:String(b.angle||'general'),count:5});
|
||||||
|
suiteMeter.record(e.d.id,'textad',1);
|
||||||
|
return json(res,200,{angle:r.angle,variants:r.variants,meter:suiteMeter.check(e.d.id,e.d.level,'textad')});
|
||||||
|
}catch(err){ return json(res,502,{error:String(err.message||err)}); }
|
||||||
|
}
|
||||||
|
|
||||||
if(req.method==='POST'&&pathname==='/api/public/suite-traffic-stop'){
|
if(req.method==='POST'&&pathname==='/api/public/suite-traffic-stop'){
|
||||||
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500}));
|
const e=await suiteEntitlement(req).catch(()=>({error:'Chain read hiccup — try again.',code:500}));
|
||||||
if(e.error)return json(res,e.code||500,{error:e.error});
|
if(e.error)return json(res,e.code||500,{error:e.error});
|
||||||
|
|||||||
+4
-1
@@ -22,7 +22,10 @@ const TOOLS = {
|
|||||||
copy: { minLevel: 2, quota: [0, 150, 250, 400, 600, 800, 1000, 1500], label: 'Copy Engine' },
|
copy: { minLevel: 2, quota: [0, 150, 250, 400, 600, 800, 1000, 1500], label: 'Copy Engine' },
|
||||||
page: { minLevel: 2, quota: [0, 3, 5, 15, 25, 50, 50, 50], label: 'Page Builder' },
|
page: { minLevel: 2, quota: [0, 3, 5, 15, 25, 50, 50, 50], label: 'Page Builder' },
|
||||||
email: { minLevel: 3, quota: [0, 0, 100, 200, 300, 500, 500, 500], label: 'Email Engine' },
|
email: { minLevel: 3, quota: [0, 0, 100, 200, 300, 500, 500, 500], label: 'Email Engine' },
|
||||||
video: { minLevel: 3, quota: [0, 0, 4, 8, 12, 20, 20, 20], label: 'Video Maker' }
|
video: { minLevel: 3, quota: [0, 0, 4, 8, 12, 20, 20, 20], label: 'Video Maker' },
|
||||||
|
// Text ads live in the Traffic Desk, which is a level-1 tool, so generation
|
||||||
|
// has to start at level 1 too. One call returns five ready ads.
|
||||||
|
textad: { minLevel: 1, quota: [20, 30, 50, 75, 100, 150, 200, 250], label: 'Text Ad Writer' }
|
||||||
};
|
};
|
||||||
|
|
||||||
function quotaFor(tool, level) {
|
function quotaFor(tool, level) {
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
// Circle Suite — Text Ad generator for the Traffic Desk.
|
||||||
|
//
|
||||||
|
// Text placements out-perform banners on this network (357 text ads have taken
|
||||||
|
// ~171 clicks each against ~150 for banners, on a fraction of the inventory),
|
||||||
|
// so members get a generator for them too.
|
||||||
|
//
|
||||||
|
// The format is not a guess. It was measured across 60 live text ads on the
|
||||||
|
// network: Subject is capped at 20 characters, Body is ALWAYS exactly three
|
||||||
|
// lines joined by <br>, each capped at 24 characters. Every ad in the sample
|
||||||
|
// obeyed that, without exception.
|
||||||
|
//
|
||||||
|
// Two deliberate design decisions:
|
||||||
|
//
|
||||||
|
// 1. The engine writes PLAIN text inside a deliberately tight budget, and we
|
||||||
|
// apply the emoji ourselves afterward. Language models cannot count
|
||||||
|
// characters reliably — especially with emoji, where one glyph can be
|
||||||
|
// several code points — so asking for "<=24 chars with emoji" produces
|
||||||
|
// overflow every time. Writing to 20 and adding a known pair of 1-code-point
|
||||||
|
// emoji is arithmetic we control, and it lets us rotate the emoji
|
||||||
|
// independently of the copy so two ads never look alike.
|
||||||
|
//
|
||||||
|
// 2. The palette carries NO money emoji. The network is full of piles-of-cash
|
||||||
|
// ads; ours deliberately do not imply earnings, because the compliance rule
|
||||||
|
// against implying income outranks matching the neighbours.
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const suiteAi = require('./suite-ai');
|
||||||
|
|
||||||
|
const MAX_SUBJECT = 20; // measured hard cap on live inventory
|
||||||
|
const MAX_LINE = 24; // measured hard cap per body line
|
||||||
|
const BODY_LINES = 3; // every single live ad uses exactly three
|
||||||
|
|
||||||
|
// Budget the engine writes to, leaving room for the emoji we add.
|
||||||
|
const SUBJECT_BUDGET = MAX_SUBJECT - 2;
|
||||||
|
const LINE_BUDGET = MAX_LINE - 2;
|
||||||
|
|
||||||
|
// Single-code-point emoji only — no variation selectors, no ZWJ sequences, so
|
||||||
|
// the character arithmetic above stays exact.
|
||||||
|
const PALETTES = [
|
||||||
|
['⚡', '⚡'], // high voltage
|
||||||
|
['🔥', '🔥'], // fire
|
||||||
|
['🚀', '🚀'], // rocket
|
||||||
|
['🔗', '🔗'], // link
|
||||||
|
['👥', '👥'], // busts in silhouette
|
||||||
|
['🤝', '🤝'], // handshake
|
||||||
|
['🎯', '🎯'], // direct hit
|
||||||
|
['⭐', '⭐'], // star
|
||||||
|
['🧩', '🧩'], // puzzle piece
|
||||||
|
['🔓', '🔓'], // unlocked
|
||||||
|
['🟣', '🟣'], // purple circle (Polygon)
|
||||||
|
['✅', '✅'] // check mark
|
||||||
|
];
|
||||||
|
|
||||||
|
// Angles mirror the ones the landing pages already support, so a click on the
|
||||||
|
// ad continues the same hook it started with.
|
||||||
|
const ANGLES = {
|
||||||
|
general: { label: 'The straight pitch', brief: 'the plain honest pitch: a crypto team build on a public smart contract, you need two people, payments are person to person' },
|
||||||
|
two: { label: 'You only need two', brief: 'the whole job is getting two people and helping them get their two — depth, not width, and nothing else to memorise' },
|
||||||
|
pocket: { label: 'Pocket change', brief: 'the entry is small and one-time, this is not a monthly subscription that bleeds you' },
|
||||||
|
phone: { label: 'Phone only', brief: 'the entire thing runs from a phone — no laptop, no office, no stock, no shipping' },
|
||||||
|
graveyard: { label: 'Side hustle graveyard', brief: 'this one does not vanish like every other program, because the contract is public, unchangeable and holds nobody\'s money' },
|
||||||
|
verify: { label: 'Verify it yourself', brief: 'do not trust anyone, read the contract on-chain yourself before you put in a cent' }
|
||||||
|
};
|
||||||
|
function angles() {
|
||||||
|
return Object.keys(ANGLES).map(function (k) { return { key: k, label: ANGLES[k].label }; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function cp(s) { return Array.from(String(s || '')); }
|
||||||
|
function cpLen(s) { return cp(s).length; }
|
||||||
|
|
||||||
|
// Trim to a code-point budget on a word boundary where possible, so an ad never
|
||||||
|
// ends mid-word. Falls back to a hard cut for a single long token.
|
||||||
|
function fit(s, budget) {
|
||||||
|
s = String(s || '').replace(/\s+/g, ' ').trim();
|
||||||
|
s = s.replace(/[<>]/g, '');
|
||||||
|
if (cpLen(s) <= budget) return s;
|
||||||
|
const words = s.split(' ');
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < words.length; i++) {
|
||||||
|
const next = out ? out + ' ' + words[i] : words[i];
|
||||||
|
if (cpLen(next) > budget) break;
|
||||||
|
out = next;
|
||||||
|
}
|
||||||
|
if (!out) out = cp(s).slice(0, budget).join('');
|
||||||
|
return out.replace(/[,;:\-–—]+$/, '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip the decoration models add on their own so our budget maths holds.
|
||||||
|
function strip(s) {
|
||||||
|
return String(s || '')
|
||||||
|
.replace(/[\u{1F000}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}\u{2B00}-\u{2BFF}]/gu, '')
|
||||||
|
.replace(/^["'“”\s*#>\-]+|["'“”\s*]+$/g, '')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parse(text) {
|
||||||
|
const out = [];
|
||||||
|
// Split on an "AD n" marker, tolerating markdown and missing numbers.
|
||||||
|
const blocks = String(text || '').split(/(?:^|\n)[ \t]*[*#>\s]*AD\s*\d*[ \t]*[*#:]*[ \t]*(?=\n)/i);
|
||||||
|
blocks.forEach(function (b) {
|
||||||
|
const grab = function (label) {
|
||||||
|
const m = b.match(new RegExp('(?:^|\\n)[ \\t]*[*#>\\s]*' + label + '[ \\t]*[*#]*[ \\t]*:?[ \\t]*[*#]*[ \\t]*(.+)', 'i'));
|
||||||
|
return m ? strip(m[1]) : '';
|
||||||
|
};
|
||||||
|
const subject = grab('SUBJECT');
|
||||||
|
const l1 = grab('L1'), l2 = grab('L2'), l3 = grab('L3');
|
||||||
|
if (!subject || !l1) return;
|
||||||
|
out.push({ subject: subject, lines: [l1, l2, l3] });
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply emoji and enforce the measured limits. Line 3 is left bare on most ads
|
||||||
|
// because the live inventory does that too — a plain uppercase closing line
|
||||||
|
// reads as a call to action rather than more decoration.
|
||||||
|
function decorate(v, index) {
|
||||||
|
const pal = PALETTES[index % PALETTES.length];
|
||||||
|
const alt = PALETTES[(index + 5) % PALETTES.length];
|
||||||
|
|
||||||
|
const subject = fit(strip(v.subject), SUBJECT_BUDGET);
|
||||||
|
const lines = [];
|
||||||
|
const raw = v.lines.map(function (l) { return strip(l); });
|
||||||
|
|
||||||
|
lines.push(pal[0] + fit(raw[0], LINE_BUDGET) + pal[1]);
|
||||||
|
if (raw[1]) lines.push(alt[0] + fit(raw[1], LINE_BUDGET) + alt[1]);
|
||||||
|
else lines.push('');
|
||||||
|
// Third line: bare, and upper-cased when it is short enough to read as a CTA.
|
||||||
|
if (raw[2]) {
|
||||||
|
const third = fit(raw[2], MAX_LINE);
|
||||||
|
lines.push(cpLen(third) <= 18 ? third.toUpperCase() : third);
|
||||||
|
} else lines.push('');
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject: pal[0] + subject + pal[1],
|
||||||
|
lines: lines,
|
||||||
|
preview: lines.filter(Boolean).join('\n')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function valid(v) {
|
||||||
|
if (cpLen(v.subject) > MAX_SUBJECT) return false;
|
||||||
|
if (v.lines.length !== BODY_LINES) return false;
|
||||||
|
if (v.lines.some(function (l) { return cpLen(l) > MAX_LINE; })) return false;
|
||||||
|
if (!v.lines[0]) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate `want` distinct ads. We ask for extras because some come back
|
||||||
|
// unusable, and dedupe on the subject so a member never sees two of the same.
|
||||||
|
async function generate(opts) {
|
||||||
|
const want = Math.max(1, Math.min(8, Number(opts.count) || 5));
|
||||||
|
const angle = ANGLES[opts.angle] ? opts.angle : 'general';
|
||||||
|
const ask = want + 3;
|
||||||
|
|
||||||
|
const instruction =
|
||||||
|
'You are writing tiny text ads for a rotating advertising network, promoting a crypto team build called The RM Circle.\n\n' +
|
||||||
|
'WHAT IT IS: The RM Circle is a team build on a public, verified smart contract on Polygon. The contract cannot be changed and holds no member funds — every payment goes person to person in the same transaction. The whole job is getting two personal referrals and helping them get their two.\n\n' +
|
||||||
|
'ANGLE FOR THESE ADS: ' + ANGLES[angle].brief + '\n\n' +
|
||||||
|
'ABSOLUTE RULES:\n' +
|
||||||
|
'- NEVER promise, guarantee, project or imply income, earnings, returns or profit. No "$", no numbers of money, no "earn", no "profit", no "income", no "passive".\n' +
|
||||||
|
'- No hype words: guaranteed, risk-free, get rich, financial freedom.\n' +
|
||||||
|
'- Do not invent figures or results.\n' +
|
||||||
|
'- Write PLAIN TEXT ONLY. Do NOT use emoji, asterisks, quotes or HTML — decoration is added later by the system.\n\n' +
|
||||||
|
'HARD LENGTH LIMITS (count every character, spaces included — going over makes the ad unusable):\n' +
|
||||||
|
'- SUBJECT: at most ' + SUBJECT_BUDGET + ' characters.\n' +
|
||||||
|
'- L1, L2, L3: at most ' + LINE_BUDGET + ' characters EACH.\n' +
|
||||||
|
'These are extremely short. Think billboard, not sentence. Fragments are good. L3 should be a short call to action.\n\n' +
|
||||||
|
'Write ' + ask + ' different ads. Vary the wording and the hook across them. Output EXACTLY this shape and nothing else:\n\n' +
|
||||||
|
'AD 1\nSUBJECT: <text>\nL1: <text>\nL2: <text>\nL3: <text>\n\nAD 2\nSUBJECT: <text>\nL1: <text>\nL2: <text>\nL3: <text>\n\n(and so on)';
|
||||||
|
|
||||||
|
const text = await suiteAi.generateRaw(instruction);
|
||||||
|
const parsed = parse(text);
|
||||||
|
|
||||||
|
const seen = {};
|
||||||
|
const out = [];
|
||||||
|
parsed.forEach(function (v, i) {
|
||||||
|
const d = decorate(v, i);
|
||||||
|
if (!valid(d)) return;
|
||||||
|
const key = d.subject.toLowerCase();
|
||||||
|
if (seen[key]) return;
|
||||||
|
seen[key] = 1;
|
||||||
|
out.push(d);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!out.length) throw new Error('The engine did not come back with usable ads — try again.');
|
||||||
|
return { angle: angle, variants: out.slice(0, want) };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
generate, angles, decorate, fit, parse, valid,
|
||||||
|
ANGLES, PALETTES, MAX_SUBJECT, MAX_LINE, BODY_LINES
|
||||||
|
};
|
||||||
+36
-9
@@ -111,10 +111,26 @@ function callNas(payload) {
|
|||||||
|
|
||||||
async function launch(opts) {
|
async function launch(opts) {
|
||||||
const level = Number(opts.level) || 1;
|
const level = Number(opts.level) || 1;
|
||||||
const size = String(opts.size || '');
|
const isText = opts.kind === 'text';
|
||||||
|
let size = '', file = '', subject = '', lines = [];
|
||||||
|
|
||||||
|
if (isText) {
|
||||||
|
// Limits measured from live network inventory — see suite-textads.js.
|
||||||
|
subject = String(opts.subject || '').trim();
|
||||||
|
lines = (Array.isArray(opts.lines) ? opts.lines : []).slice(0, 3)
|
||||||
|
.map(function (l) { return String(l || '').replace(/[<>]/g, '').trim(); });
|
||||||
|
while (lines.length < 3) lines.push('');
|
||||||
|
if (!subject || !lines[0]) throw new Error('That text ad is missing its headline or first line.');
|
||||||
|
if (Array.from(subject).length > 20) throw new Error('The headline is longer than the network allows.');
|
||||||
|
if (lines.some(function (l) { return Array.from(l).length > 24; })) {
|
||||||
|
throw new Error('One of those lines is longer than the network allows.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
size = String(opts.size || '');
|
||||||
if (!CREATIVES[size]) throw new Error('Pick one of the available banner sizes.');
|
if (!CREATIVES[size]) throw new Error('Pick one of the available banner sizes.');
|
||||||
const file = String(opts.creative || '');
|
file = String(opts.creative || '');
|
||||||
if (CREATIVES[size].indexOf(file) === -1) throw new Error('Pick one of the team banner designs.');
|
if (CREATIVES[size].indexOf(file) === -1) throw new Error('Pick one of the team banner designs.');
|
||||||
|
}
|
||||||
|
|
||||||
const impressions = Math.max(100, Math.min(allowanceFor(level), Number(opts.impressions) || 0));
|
const impressions = Math.max(100, Math.min(allowanceFor(level), Number(opts.impressions) || 0));
|
||||||
const st = status(opts.id, level);
|
const st = status(opts.id, level);
|
||||||
@@ -132,17 +148,28 @@ async function launch(opts) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const idem = 'rmc-' + opts.id + '-' + monthKey() + '-' + crypto.randomBytes(6).toString('hex');
|
const idem = 'rmc-' + opts.id + '-' + monthKey() + '-' + crypto.randomBytes(6).toString('hex');
|
||||||
const res = await callNas({
|
const payload = {
|
||||||
action: 'create', member_id: Number(opts.id), idem_key: idem, kind: 'banner',
|
action: 'create', member_id: Number(opts.id), idem_key: idem,
|
||||||
size: size, impressions: impressions, days: 365,
|
kind: isText ? 'text' : 'banner',
|
||||||
target_url: target,
|
impressions: impressions, days: 365, target_url: target,
|
||||||
banner_url: 'https://rmcircle.team/banners/' + file,
|
|
||||||
advertiser_name: (opts.name || 'RM Circle member #' + opts.id).slice(0, 60),
|
advertiser_name: (opts.name || 'RM Circle member #' + opts.id).slice(0, 60),
|
||||||
advertiser_email: '', catid: 5
|
advertiser_email: '', catid: 5
|
||||||
});
|
};
|
||||||
|
if (isText) {
|
||||||
|
payload.subject = subject;
|
||||||
|
payload.lines = lines;
|
||||||
|
} else {
|
||||||
|
payload.size = size;
|
||||||
|
payload.banner_url = 'https://rmcircle.team/banners/' + file;
|
||||||
|
}
|
||||||
|
const res = await callNas(payload);
|
||||||
|
|
||||||
const entry = {
|
const entry = {
|
||||||
adId: res.ad_id, impressions: impressions, size: size, creative: file,
|
adId: res.ad_id, impressions: impressions,
|
||||||
|
size: isText ? 'text' : size, creative: isText ? '' : file,
|
||||||
|
kind: isText ? 'text' : 'banner',
|
||||||
|
subject: isText ? subject : undefined,
|
||||||
|
lines: isText ? lines : undefined,
|
||||||
target: target, at: new Date().toISOString()
|
target: target, at: new Date().toISOString()
|
||||||
};
|
};
|
||||||
record(opts.id, entry);
|
record(opts.id, entry);
|
||||||
|
|||||||
Reference in New Issue
Block a user