diff --git a/public/suite-traffic.html b/public/suite-traffic.html index 33a789b..0aafd5c 100644 --- a/public/suite-traffic.html +++ b/public/suite-traffic.html @@ -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 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-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} .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} @@ -40,7 +48,7 @@
CIRCLE SUITE · INCLUDED AT EVERY PAID LEVEL

Traffic Desk.

-

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.

+

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.

@@ -51,10 +59,25 @@
- -
- -
+ +
+ +
+ +
+ +
+
+ +
diff --git a/public/suite-traffic.js b/public/suite-traffic.js index e491b8b..7ac7d2a 100644 --- a/public/suite-traffic.js +++ b/public/suite-traffic.js @@ -3,7 +3,13 @@ (function () { 'use strict'; 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; 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 = '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; @@ -83,7 +178,10 @@ var statusCell = c.stopped ? 'stopped (' + Number(c.refunded || 0).toLocaleString() + ' returned)' : (l.live ? 'running' : 'finished'); - tr.innerHTML = '' + c.size + '' + + var label = c.kind === 'text' + ? 'Text
' + esc(c.subject || '') + '' + : '' + c.size + ''; + tr.innerHTML = '' + label + '' + '' + (c.target.indexOf('/p/') !== -1 ? 'personal page' : 'invite page') + '' + '' + Number(c.bought != null ? c.bought : c.impressions).toLocaleString() + '' + '' + Number(c.stopped ? (c.served || 0) : served).toLocaleString() + '' + @@ -121,7 +219,14 @@ Number(st.limit).toLocaleString() + ' 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(); + 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; @@ -174,21 +279,39 @@ $('tfOk').style.display = 'none'; $('tfGo').disabled = true; $('tfGo').innerHTML = '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({ - size: state.size, creative: state.creative, target: state.target, - impressions: Number($('tfImp').value) || 0 - }) + 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 { - $('tfOk').innerHTML = '✅ Your banner is live on the network.
' + - Number(d.campaign.impressions).toLocaleString() + ' impressions of ' + d.campaign.size + + var what = d.campaign.kind === 'text' ? 'text ad' : 'banner'; + $('tfOk').innerHTML = '✅ Your ' + what + ' is live on the network.
' + + 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'; @@ -201,11 +324,12 @@ $('tfErr').style.display = 'block'; } 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 () { boot(); $('tfGo').addEventListener('click', run); + $('tfWrite').addEventListener('click', function () { writeAds(this); }); }); })(); diff --git a/public/suite.js b/public/suite.js index a8c26e4..2be0bbe 100644 --- a/public/suite.js +++ b/public/suite.js @@ -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: '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: 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: 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 } diff --git a/server.js b/server.js index 19e22fc..e06dc1e 100644 --- a/server.js +++ b/server.js @@ -24,6 +24,7 @@ const suiteTools = require('./suite-tools'); const suiteEmail = require('./suite-email'); const suiteVideo = require('./suite-video'); suiteVideo.init({ dataDir: DATA_DIR, publicDir: PUBLIC_DIR }); const suiteTraffic = require('./suite-traffic'); suiteTraffic.init({ dataDir: DATA_DIR }); +const suiteTextAds = require('./suite-textads'); const tgbot = require('./tgbot'); tgbot.init({ dataDir: DATA_DIR, chain, getConfig, messages, baseUrl: 'https://rmcircle.team' }); 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. const pg=suitePages.load(e.d.id); 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'){ 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 entry=await suiteTraffic.launch({ 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, hasPage:!!(pgRec&&pgRec.copy), 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)}); } } + 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'){ 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}); diff --git a/suite-meter.js b/suite-meter.js index d73b418..d8cd6e1 100644 --- a/suite-meter.js +++ b/suite-meter.js @@ -22,7 +22,10 @@ const TOOLS = { 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' }, 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) { diff --git a/suite-textads.js b/suite-textads.js new file mode 100644 index 0000000..e8281ef --- /dev/null +++ b/suite-textads.js @@ -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
, 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: \nL1: \nL2: \nL3: \n\nAD 2\nSUBJECT: \nL1: \nL2: \nL3: \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 +}; diff --git a/suite-traffic.js b/suite-traffic.js index a5f0f17..5da728c 100644 --- a/suite-traffic.js +++ b/suite-traffic.js @@ -111,10 +111,26 @@ function callNas(payload) { async function launch(opts) { const level = Number(opts.level) || 1; - const size = String(opts.size || ''); - if (!CREATIVES[size]) throw new Error('Pick one of the available banner sizes.'); - const file = String(opts.creative || ''); - if (CREATIVES[size].indexOf(file) === -1) throw new Error('Pick one of the team banner designs.'); + 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.'); + file = String(opts.creative || ''); + 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 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 res = await callNas({ - action: 'create', member_id: Number(opts.id), idem_key: idem, kind: 'banner', - size: size, impressions: impressions, days: 365, - target_url: target, - banner_url: 'https://rmcircle.team/banners/' + file, + const payload = { + action: 'create', member_id: Number(opts.id), idem_key: idem, + kind: isText ? 'text' : 'banner', + impressions: impressions, days: 365, target_url: target, advertiser_name: (opts.name || 'RM Circle member #' + opts.id).slice(0, 60), 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 = { - 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() }; record(opts.id, entry);