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 @@' + esc(c.subject || '') + '' + : '' + c.size + ''; + tr.innerHTML = '
' + - 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: