From a57af32f4a582107974a5445b6f91acb4b950794 Mon Sep 17 00:00:00 2001 From: martbost Date: Sat, 29 Aug 2026 06:14:28 -0500 Subject: [PATCH] Co-Branded Pages: give Fastigium a change of kind, not a bigger number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once every member from Ascensus up could hold several pages, the Funnel Factory's differentiator became a count — the same quantity-not-kind flaw the upper tiers had. L6 now unlocks identity instead: a member's own name, monogram and accent colour on every page they build. Below Fastigium you have a page on the team site; at Fastigium it becomes yours. Three deliberate limits. NO IMAGE UPLOADS. A logo file uploaded by a member and served from rmcircle.team is an abuse surface — whatever they upload lives on our domain. The mark is a monogram generated from their brand name instead: real identity, nothing to moderate. Same reasoning that keeps Traffic Desk creative team-only. ACCENTS ARE A CURATED SET, not a colour picker. Every option is checked against the dark ground, so nobody can pick something that makes their own page unreadable and then wonder why it converts badly. THE DISCLAIMERS ARE NOT THEIRS TO BRAND. Branding replaces the header identity only; the footer — independent team resource, no income guaranteed, cryptocurrency risk — is rendered by us and cannot be removed or restyled. Co-branding, not white-labelling. Verified in the renderer. Branding is resolved against the LIVE contract level at render time, so a position that drops below Fastigium reverts to team identity on its own rather than keeping something it no longer holds. Co-Authored-By: Claude Fable 5 --- public/suite-brand.html | 77 +++++++++++++++++++++ public/suite-brand.js | 144 ++++++++++++++++++++++++++++++++++++++++ public/suite.js | 1 + server.js | 32 ++++++++- suite-brand.js | 111 +++++++++++++++++++++++++++++++ suite-pages.js | 17 ++++- suite-tools.js | 4 +- 7 files changed, 382 insertions(+), 4 deletions(-) create mode 100644 public/suite-brand.html create mode 100644 public/suite-brand.js create mode 100644 suite-brand.js diff --git a/public/suite-brand.html b/public/suite-brand.html new file mode 100644 index 0000000..641e28e --- /dev/null +++ b/public/suite-brand.html @@ -0,0 +1,77 @@ +Co-Branded Pages | The Circle Suite + + + +
+
CIRCLE SUITE · FASTIGIUM (LEVEL 6)
+

Co-Branded Pages.

+

Every page you build carries the team's identity by default. From Fastigium it carries yours instead — your name, your monogram, your colour — so what you send people stops looking like a page on someone else's site.

+ +
+ + + +

What branding does not change: the disclaimers at the foot of every page — independent team resource, no income guaranteed, cryptocurrency involves risk — are ours and stay exactly as they are. This is co-branding, not white-labelling; anyone reading your page can still see what they're looking at.

There's no logo upload on purpose: your monogram is generated from your brand name, so there's no image to moderate and nothing of yours hosted on our domain.

+
+ + + diff --git a/public/suite-brand.js b/public/suite-brand.js new file mode 100644 index 0000000..696d471 --- /dev/null +++ b/public/suite-brand.js @@ -0,0 +1,144 @@ +// Co-Branded Pages client. Everything previews live, because the whole value of +// this tool is seeing what your pages will look like before you commit. +(function () { + 'use strict'; + var $ = function (id) { return document.getElementById(id); }; + var state = { accents: [], accent: 'gold' }; + + function gate(msg) { + $('bGate').style.display = 'block'; + $('bGate').innerHTML = msg; + } + + function hexFor(key) { + var a = state.accents.filter(function (x) { return x.key === key; })[0]; + return a ? a.hex : '#f3be43'; + } + + // Same rule the server uses, so the preview cannot disagree with what gets + // saved: initials of the first two words, or the first two characters. + function monogram(name) { + var clean = String(name || '').replace(/[^A-Za-z0-9 ]+/g, ' ').trim(); + if (!clean) return ''; + var w = clean.split(/\s+/).filter(Boolean); + if (w.length >= 2) return (w[0][0] + w[1][0]).toUpperCase(); + return w[0].slice(0, 2).toUpperCase(); + } + + function renderSwatches() { + var host = $('bSwatches'); + host.innerHTML = ''; + state.accents.forEach(function (a) { + var b = document.createElement('button'); + b.type = 'button'; + b.className = 'b-sw' + (a.key === state.accent ? ' on' : ''); + b.style.background = a.hex; + b.title = a.label; + b.setAttribute('aria-label', a.label); + b.addEventListener('click', function () { state.accent = a.key; renderSwatches(); preview(); }); + host.appendChild(b); + }); + } + + function preview() { + var name = $('bName').value.trim(); + var tag = $('bTag').value.trim(); + var hex = hexFor(state.accent); + var mono = monogram(name); + + $('bMono').textContent = mono || '—'; + $('bMono').style.color = hex; + $('bMono').style.border = '2px solid ' + hex; + $('bMono').style.background = hex + '22'; + $('bPrevName').textContent = name || 'Your brand'; + $('bPrevTag').textContent = tag || 'A personal invitation'; + $('bPrevGold').style.color = hex; + var btn = $('bPrevBtn'); + btn.style.background = hex; + btn.style.borderColor = hex; + btn.style.color = '#071421'; + } + + async function save() { + $('bErr').style.display = 'none'; + $('bOk').style.display = 'none'; + var name = $('bName').value.trim(); + if (name.length < 2) { + $('bErr').textContent = 'Give your brand a name — two characters or more.'; + $('bErr').style.display = 'block'; + return; + } + $('bSave').disabled = true; + try { + var r = await fetch('/api/public/suite-brand', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ brandName: name, tagline: $('bTag').value.trim(), accent: state.accent }) + }); + var d = await r.json(); + if (!r.ok) { + $('bErr').textContent = d.error || 'That did not save.'; + $('bErr').style.display = 'block'; + } else { + $('bOk').innerHTML = '✅ Saved. Every page you have built now carries your branding — ' + + 'go and look at one.'; + $('bOk').style.display = 'block'; + } + } catch (e) { + $('bErr').textContent = 'Connection hiccup — try again.'; + $('bErr').style.display = 'block'; + } + $('bSave').disabled = false; + } + + async function clearBrand() { + if (!window.confirm('Go back to team branding on all your pages?')) return; + try { + var r = await fetch('/api/public/suite-brand', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ clear: true }) + }); + var d = await r.json(); + if (r.ok) { + $('bName').value = ''; $('bTag').value = ''; + state.accent = 'gold'; + renderSwatches(); preview(); + $('bOk').innerHTML = 'Back to team branding on all your pages.'; + $('bOk').style.display = 'block'; + } + } catch (e) {} + } + + async function boot() { + try { + var r = await fetch('/api/public/suite-brand'); + if (r.status === 401) { gate('You’re not signed in yet. Open the Suite and connect the wallet that holds your position, then come back.'); return; } + if (r.status === 403) { + var g = await r.json(); + gate((g.error || 'Not open for this position yet.') + ' See your Suite.'); + return; + } + if (!r.ok) return; + var d = await r.json(); + state.accents = d.accents || []; + var b = d.brand || {}; + state.accent = b.accent || 'gold'; + $('bName').value = b.brandName || ''; + $('bTag').value = b.tagline || ''; + $('bBody').style.display = 'block'; + renderSwatches(); + preview(); + if (!(d.pages || []).length) { + $('bOk').innerHTML = 'You have no pages yet — build one and your branding will be on it.'; + $('bOk').style.display = 'block'; + } + } catch (e) {} + } + + document.addEventListener('DOMContentLoaded', function () { + boot(); + $('bName').addEventListener('input', preview); + $('bTag').addEventListener('input', preview); + $('bSave').addEventListener('click', save); + $('bClear').addEventListener('click', clearBrand); + }); +})(); diff --git a/public/suite.js b/public/suite.js index ba49a32..4d0c05e 100644 --- a/public/suite.js +++ b/public/suite.js @@ -20,6 +20,7 @@ { lv: 4, ico: '🗣️', name: 'Voice Profile', desc: 'Teach the Copy Engine and Email Engine how you actually talk, so everything they write for you stops sounding like a company.', href: '/suite/voice', live: true }, { lv: 5, ico: '🔬', name: 'Split Tester', desc: 'Run two or three versions of an ad head to head and find out which wording people actually click. Apex is where your impressions jump to 50,000 — enough for the answer to mean something.', href: '/suite/split', 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: 'Co-Branded Pages', desc: 'Your own name, monogram and colour on every page you build — so it stops looking like a page on the team site and starts looking like yours.', href: '/suite/brand', live: true }, { lv: 6, ico: '🏭', name: 'Funnel Factory', desc: 'A separate hosted landing page for every audience you talk to — each on its own address, each one something you can point a different ad at.', href: '/suite/funnel', live: true }, { lv: 7, ico: '🎁', name: 'Team Grants', desc: 'Hand Suite capacity down into your own organisation — impressions, generations, page builds. Your level stops being something you bought for yourself and becomes something you give your team.', href: '/suite/grants', live: true }, { lv: 8, ico: '🛰️', name: 'Network Intelligence', desc: 'What is actually working across every ad the whole team runs — best angles, headlines and creative, pooled and anonymised. Nobody can buy this; it is the team\'s own accumulated result, and it compounds every month.', href: '/suite/intel', live: true }, diff --git a/server.js b/server.js index 5e384d8..1f7a48f 100644 --- a/server.js +++ b/server.js @@ -31,6 +31,7 @@ const suiteLeader = require('./suite-leader'); const suiteFounder = require('./suite-founder'); suiteFounder.init({ dataDir: DATA_DIR }); const suiteGrants = require('./suite-grants'); suiteGrants.init({ dataDir: DATA_DIR }); const suiteIntel = require('./suite-intel'); suiteIntel.init({ dataDir: DATA_DIR }); +const suiteBrand = require('./suite-brand'); suiteBrand.init({ dataDir: DATA_DIR }); const tgbot = require('./tgbot'); tgbot.init({ dataDir: DATA_DIR, chain, getConfig, messages, baseUrl: 'https://rmcircle.team' }); const SESSION_TTL = 8 * 60 * 60 * 1000; @@ -709,6 +710,12 @@ async function handleApi(req,res,pathname){ res.writeHead(302,securityHeaders({'Location':'/p/'+pid,'Cache-Control':'no-store'})); return res.end(); } + // Co-branding (L6) is resolved here, against the live contract level, so a + // position that falls below Fastigium reverts to team identity by itself. + try{ + const owner=await chain.memberPublic(rec.id).catch(()=>null); + if(owner&&owner.registered){ const br=suiteBrand.forPage(rec.id,owner.level); if(br) rec.brand=br; } + }catch(err){} const html=suitePages.render(rec); res.writeHead(200,securityHeaders({'Content-Type':'text/html; charset=utf-8','Cache-Control':'public, max-age=120'})); return res.end(html); @@ -724,6 +731,12 @@ async function handleApi(req,res,pathname){ res.writeHead(302,securityHeaders({'Location':'/join/'+pid,'Cache-Control':'no-store'})); return res.end(); } + // Co-branding (L6) is resolved here, against the live contract level, so a + // position that falls below Fastigium reverts to team identity by itself. + try{ + const owner=await chain.memberPublic(rec.id).catch(()=>null); + if(owner&&owner.registered){ const br=suiteBrand.forPage(rec.id,owner.level); if(br) rec.brand=br; } + }catch(err){} const html=suitePages.render(rec); // Member pages must ALWAYS be viewable inside a frame — the builder previews // them, and members share them into contexts that embed. Never send @@ -1144,6 +1157,23 @@ async function handleApi(req,res,pathname){ return fs.createReadStream(vfile).pipe(res); } + // -- Co-branded pages (level 6) ------------------------------------------- + if(pathname==='/api/public/suite-brand'){ + 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(Number(e.d.level)({error:'Chain read hiccup — try again.',code:500})); if(e.error)return json(res,e.code||500,{error:e.error}); @@ -1471,7 +1501,7 @@ const server=http.createServer(async(req,res)=>{ if((mj=pathname.match(/^\/join\/(\d{1,15})$/)))return serveMemberPage(req,res,path.join(PUBLIC_DIR,'join.html'),'join',mj[1]); } let file; - if(pathname==='/')file=path.join(PUBLIC_DIR,'index.html');else if(pathname==='/app'||pathname==='/app/')file=path.join(PUBLIC_DIR,'app.html');else if(pathname==='/start'||pathname==='/start/')file=path.join(PUBLIC_DIR,'start.html');else if(pathname==='/training'||pathname==='/training/')file=path.join(PUBLIC_DIR,'training.html');else if(pathname==='/admin'||pathname==='/admin/')file=path.join(PUBLIC_DIR,'admin.html');else if(pathname==='/my'||pathname==='/my/'||/^\/my\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'my.html');else if(pathname==='/contract'||pathname==='/contract/')file=path.join(PUBLIC_DIR,'contract.html');else if(pathname==='/disclaimer'||pathname==='/disclaimer/')file=path.join(PUBLIC_DIR,'disclaimer.html');else if(pathname==='/privacy'||pathname==='/privacy/')file=path.join(PUBLIC_DIR,'privacy.html');else if(pathname==='/refunds'||pathname==='/refunds/')file=path.join(PUBLIC_DIR,'refunds.html');else if(pathname==='/how-pay-works'||pathname==='/how-pay-works/')file=path.join(PUBLIC_DIR,'how-pay-works.html');else if(pathname==='/tools'||pathname==='/tools/')file=path.join(PUBLIC_DIR,'tools.html');else if(pathname==='/fast-start'||pathname==='/fast-start/')file=path.join(PUBLIC_DIR,'fast-start.html');else if(pathname==='/flyers'||pathname==='/flyers/')file=path.join(PUBLIC_DIR,'flyers.html');else if(pathname==='/weekly-rhythm'||pathname==='/weekly-rhythm/')file=path.join(PUBLIC_DIR,'weekly-rhythm.html');else if(pathname==='/generation-pay'||pathname==='/generation-pay/')file=path.join(PUBLIC_DIR,'generation-pay.html');else if(pathname==='/suite'||pathname==='/suite/')file=path.join(PUBLIC_DIR,'suite.html');else if(pathname==='/suite/copy'||pathname==='/suite/copy/')file=path.join(PUBLIC_DIR,'suite-copy.html');else if(pathname==='/suite/page'||pathname==='/suite/page/')file=path.join(PUBLIC_DIR,'suite-page.html');else if(pathname==='/suite/email'||pathname==='/suite/email/')file=path.join(PUBLIC_DIR,'suite-email.html');else if(pathname==='/suite/video'||pathname==='/suite/video/')file=path.join(PUBLIC_DIR,'suite-video.html');else if(pathname==='/suite/traffic'||pathname==='/suite/traffic/')file=path.join(PUBLIC_DIR,'suite-traffic.html');else if(pathname==='/suite/voice'||pathname==='/suite/voice/')file=path.join(PUBLIC_DIR,'suite-voice.html');else if(pathname==='/suite/split'||pathname==='/suite/split/')file=path.join(PUBLIC_DIR,'suite-split.html');else if(pathname==='/suite/funnel'||pathname==='/suite/funnel/')file=path.join(PUBLIC_DIR,'suite-funnel.html');else if(pathname==='/suite/leader'||pathname==='/suite/leader/')file=path.join(PUBLIC_DIR,'suite-leader.html');else if(pathname==='/suite/founder'||pathname==='/suite/founder/')file=path.join(PUBLIC_DIR,'suite-founder.html');else if(pathname==='/wallet-check'||pathname==='/wallet-check/')file=path.join(PUBLIC_DIR,'wallet-check.html');else if(pathname==='/suite/grants'||pathname==='/suite/grants/')file=path.join(PUBLIC_DIR,'suite-grants.html');else if(pathname==='/suite/intel'||pathname==='/suite/intel/')file=path.join(PUBLIC_DIR,'suite-intel.html');else if(pathname==='/direct-join'||pathname==='/direct-join/'){if(!getSession(req)){res.writeHead(302,{Location:'/admin'});return res.end();}file=path.join(ROOT,'private','direct-join.html');}else if(pathname==='/join-now'||pathname==='/join-now/'){if(!getConfig().dappFallbackPublic){res.writeHead(302,{Location:'/start'});return res.end();}file=path.join(ROOT,'private','join-now.html');}else if(/^\/join\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'join.html');else if(pathname==='/join'||pathname==='/join/'){res.writeHead(302,{Location:'/join-now'});return res.end();}else{ + if(pathname==='/')file=path.join(PUBLIC_DIR,'index.html');else if(pathname==='/app'||pathname==='/app/')file=path.join(PUBLIC_DIR,'app.html');else if(pathname==='/start'||pathname==='/start/')file=path.join(PUBLIC_DIR,'start.html');else if(pathname==='/training'||pathname==='/training/')file=path.join(PUBLIC_DIR,'training.html');else if(pathname==='/admin'||pathname==='/admin/')file=path.join(PUBLIC_DIR,'admin.html');else if(pathname==='/my'||pathname==='/my/'||/^\/my\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'my.html');else if(pathname==='/contract'||pathname==='/contract/')file=path.join(PUBLIC_DIR,'contract.html');else if(pathname==='/disclaimer'||pathname==='/disclaimer/')file=path.join(PUBLIC_DIR,'disclaimer.html');else if(pathname==='/privacy'||pathname==='/privacy/')file=path.join(PUBLIC_DIR,'privacy.html');else if(pathname==='/refunds'||pathname==='/refunds/')file=path.join(PUBLIC_DIR,'refunds.html');else if(pathname==='/how-pay-works'||pathname==='/how-pay-works/')file=path.join(PUBLIC_DIR,'how-pay-works.html');else if(pathname==='/tools'||pathname==='/tools/')file=path.join(PUBLIC_DIR,'tools.html');else if(pathname==='/fast-start'||pathname==='/fast-start/')file=path.join(PUBLIC_DIR,'fast-start.html');else if(pathname==='/flyers'||pathname==='/flyers/')file=path.join(PUBLIC_DIR,'flyers.html');else if(pathname==='/weekly-rhythm'||pathname==='/weekly-rhythm/')file=path.join(PUBLIC_DIR,'weekly-rhythm.html');else if(pathname==='/generation-pay'||pathname==='/generation-pay/')file=path.join(PUBLIC_DIR,'generation-pay.html');else if(pathname==='/suite'||pathname==='/suite/')file=path.join(PUBLIC_DIR,'suite.html');else if(pathname==='/suite/copy'||pathname==='/suite/copy/')file=path.join(PUBLIC_DIR,'suite-copy.html');else if(pathname==='/suite/page'||pathname==='/suite/page/')file=path.join(PUBLIC_DIR,'suite-page.html');else if(pathname==='/suite/email'||pathname==='/suite/email/')file=path.join(PUBLIC_DIR,'suite-email.html');else if(pathname==='/suite/video'||pathname==='/suite/video/')file=path.join(PUBLIC_DIR,'suite-video.html');else if(pathname==='/suite/traffic'||pathname==='/suite/traffic/')file=path.join(PUBLIC_DIR,'suite-traffic.html');else if(pathname==='/suite/voice'||pathname==='/suite/voice/')file=path.join(PUBLIC_DIR,'suite-voice.html');else if(pathname==='/suite/split'||pathname==='/suite/split/')file=path.join(PUBLIC_DIR,'suite-split.html');else if(pathname==='/suite/funnel'||pathname==='/suite/funnel/')file=path.join(PUBLIC_DIR,'suite-funnel.html');else if(pathname==='/suite/leader'||pathname==='/suite/leader/')file=path.join(PUBLIC_DIR,'suite-leader.html');else if(pathname==='/suite/founder'||pathname==='/suite/founder/')file=path.join(PUBLIC_DIR,'suite-founder.html');else if(pathname==='/wallet-check'||pathname==='/wallet-check/')file=path.join(PUBLIC_DIR,'wallet-check.html');else if(pathname==='/suite/brand'||pathname==='/suite/brand/')file=path.join(PUBLIC_DIR,'suite-brand.html');else if(pathname==='/suite/grants'||pathname==='/suite/grants/')file=path.join(PUBLIC_DIR,'suite-grants.html');else if(pathname==='/suite/intel'||pathname==='/suite/intel/')file=path.join(PUBLIC_DIR,'suite-intel.html');else if(pathname==='/direct-join'||pathname==='/direct-join/'){if(!getSession(req)){res.writeHead(302,{Location:'/admin'});return res.end();}file=path.join(ROOT,'private','direct-join.html');}else if(pathname==='/join-now'||pathname==='/join-now/'){if(!getConfig().dappFallbackPublic){res.writeHead(302,{Location:'/start'});return res.end();}file=path.join(ROOT,'private','join-now.html');}else if(/^\/join\/\d{1,15}$/.test(pathname))file=path.join(PUBLIC_DIR,'join.html');else if(pathname==='/join'||pathname==='/join/'){res.writeHead(302,{Location:'/join-now'});return res.end();}else{ const safe=path.normalize(pathname).replace(/^([.][.][/\\])+/, '').replace(/^[/\\]+/,'');file=path.join(PUBLIC_DIR,safe);if(!file.startsWith(PUBLIC_DIR))file=''; } if(file&&staticFile(req,res,file))return;return staticFile(req,res,path.join(PUBLIC_DIR,'404.html'),404); diff --git a/suite-brand.js b/suite-brand.js new file mode 100644 index 0000000..543530f --- /dev/null +++ b/suite-brand.js @@ -0,0 +1,111 @@ +// Circle Suite — Co-branded pages (Fastigium, L6). +// +// Why this is the level-6 tool rather than "you may now have more pages": +// every member from Ascensus up can hold several pages, so a bigger number was +// never a real unlock — it is the same quantity-not-kind mistake the upper +// tiers used to make. This is a change of KIND. Below L6 a member has a page on +// the team site. At L6 it becomes their own branded presence. +// +// Three deliberate limits: +// +// 1. NO IMAGE UPLOADS. A logo file uploaded by a member and served from +// rmcircle.team is an abuse surface — anything they upload lives on our +// domain. Instead the mark is a monogram generated from their own brand +// name, which gives real identity with nothing to moderate. Same reasoning +// that keeps Traffic Desk creative team-only. +// 2. ACCENT COLOURS ARE A CURATED SET, not a free picker. Every option is +// checked against the dark ground, so nobody can pick a colour that makes +// their own page unreadable and then wonder why it converts badly. +// 3. THE DISCLAIMERS ARE NOT THEIRS TO BRAND. Branding replaces the header +// identity only. The footer — independent team resource, no income +// guaranteed, cryptocurrency risk — is rendered by us and cannot be +// removed, renamed or restyled. This is co-branding, not white-labelling. +'use strict'; +const fs = require('fs'); +const path = require('path'); + +let DATA_DIR = null; +function init(opts) { + DATA_DIR = opts.dataDir; + try { fs.mkdirSync(dir(), { recursive: true }); } catch (e) {} +} +function dir() { return path.join(DATA_DIR, 'brand'); } +function file(id) { return path.join(dir(), String(Number(id)) + '.json'); } + +const MIN_LEVEL = 6; + +// Each accent is checked to hold contrast on the #071421 ground. `soft` is the +// same hue at low alpha for panel washes. +const ACCENTS = { + gold: { label: 'Gold', hex: '#f3be43', soft: 'rgba(243,190,67,.12)' }, + teal: { label: 'Teal', hex: '#4ed6cb', soft: 'rgba(78,214,203,.12)' }, + violet: { label: 'Violet', hex: '#a98bff', soft: 'rgba(169,139,255,.14)' }, + coral: { label: 'Coral', hex: '#ff8a6b', soft: 'rgba(255,138,107,.13)' }, + lime: { label: 'Lime', hex: '#9ede4f', soft: 'rgba(158,222,79,.12)' }, + sky: { label: 'Sky', hex: '#5cb8ff', soft: 'rgba(92,184,255,.13)' }, + rose: { label: 'Rose', hex: '#ff7fa8', soft: 'rgba(255,127,168,.13)' }, + amber: { label: 'Amber', hex: '#ffb020', soft: 'rgba(255,176,32,.12)' } +}; +function accents() { + return Object.keys(ACCENTS).map(function (k) { + return { key: k, label: ACCENTS[k].label, hex: ACCENTS[k].hex }; + }); +} + +function blank() { + return { brandName: '', tagline: '', accent: 'gold', monogram: '', enabled: false }; +} + +function load(id) { + try { return JSON.parse(fs.readFileSync(file(id), 'utf8')); } catch (e) { return null; } +} + +// Monogram from the brand name: first letters of the first two words, or the +// first two characters of a single word. Latin letters and digits only, so a +// name in any script still yields something renderable rather than a blank. +function monogramFor(name) { + const clean = String(name || '').replace(/[^A-Za-z0-9 ]+/g, ' ').trim(); + if (!clean) return ''; + const words = clean.split(/\s+/).filter(Boolean); + if (words.length >= 2) return (words[0][0] + words[1][0]).toUpperCase(); + return words[0].slice(0, 2).toUpperCase(); +} + +function save(id, body) { + const rec = blank(); + rec.brandName = String((body && body.brandName) || '').replace(/\s+/g, ' ').trim().slice(0, 32); + rec.tagline = String((body && body.tagline) || '').replace(/\s+/g, ' ').trim().slice(0, 60); + rec.accent = ACCENTS[(body && body.accent)] ? body.accent : 'gold'; + rec.monogram = monogramFor(rec.brandName); + // A brand with no name is not a brand — treat it as off rather than + // rendering an empty header where the team identity used to be. + rec.enabled = rec.brandName.length >= 2; + rec.id = Number(id); + rec.updatedAt = new Date().toISOString(); + try { fs.writeFileSync(file(id), JSON.stringify(rec)); } catch (e) {} + return rec; +} + +function clear(id) { + try { fs.unlinkSync(file(id)); } catch (e) {} + return blank(); +} + +// What the page renderer needs. Returns null when a position has no brand or is +// below the level — callers then fall back to the team identity, so a member +// who drops below L6 simply reverts rather than breaking. +function forPage(id, level) { + if (Number(level) < MIN_LEVEL) return null; + const rec = load(id); + if (!rec || !rec.enabled || !rec.brandName) return null; + const a = ACCENTS[rec.accent] || ACCENTS.gold; + return { + brandName: rec.brandName, + tagline: rec.tagline, + monogram: rec.monogram || monogramFor(rec.brandName), + accent: a.hex, + accentSoft: a.soft + }; +} + +module.exports = { init, MIN_LEVEL, ACCENTS, accents, blank, load, save, clear, forPage, monogramFor }; diff --git a/suite-pages.js b/suite-pages.js index 079f68c..bd54638 100644 --- a/suite-pages.js +++ b/suite-pages.js @@ -164,6 +164,10 @@ function goldTail(text) { function render(rec) { const a = ANGLES[rec.angle] || ANGLES.overview; + // Co-branding is resolved by the caller and passed on the record, so this + // module stays free of level logic. Absent = team identity, which is also + // what a member who drops below L6 gets back automatically. + const b = rec.brand || null; // The button and the QR go to DIFFERENT places on purpose. // // `?v=` is not a normal landing page — it is a SQUEEZE step that hides @@ -197,6 +201,14 @@ function render(rec) { // subhead — but NOT the story below, because centred long-form prose is // measurably harder to read: every line starts in a different place. '.pp .by,.pp h1,.pp .sub{text-align:center}' + + // Co-branding (L6). The accent overrides --gold for THIS page only, so + // every gold detail follows the member's colour without touching the + // site stylesheet. The footer disclaimers are rendered below and keep + // their own muted colour — branding never restyles the risk wording. + (b ? ':root{--gold:' + b.accent + '}' + + '.brand .mono{display:inline-flex;align-items:center;justify-content:center;width:42px;height:42px;' + + 'border-radius:50%;background:' + b.accentSoft + ';border:2px solid ' + b.accent + ';' + + 'color:' + b.accent + ';font-weight:900;font-size:15px;letter-spacing:.5px}' : '') + '.pp h1{font-size:clamp(30px,6vw,48px);line-height:1.06;letter-spacing:-.8px;margin:8px 0 12px;text-wrap:balance}' + '.pp h1 .gold{color:var(--gold)}' + '.pp .sub{color:#dbe6ef;font-size:clamp(19px,2.4vw,22px);line-height:1.55;margin:0 auto 24px;max-width:46ch}' + @@ -216,7 +228,10 @@ function render(rec) { '.pp .qr svg{width:100%;height:100%}' + '.pp .foot{margin-top:26px;padding-top:14px;border-top:1px solid var(--line);font-size:12.5px;color:var(--muted);line-height:1.6;text-align:center}' + '' + - '' + + (b + ? '' + : '') + '
' + '
Shared by ' + who + ' · Member #' + esc(rec.id) + '
' + '

' + goldTail(c.headline || '') + '

' + diff --git a/suite-tools.js b/suite-tools.js index 0663a40..1e749bb 100644 --- a/suite-tools.js +++ b/suite-tools.js @@ -15,7 +15,7 @@ const LEVEL_TOOLS = { 3: { name: 'Fabrica', live: true, short: 'the Email Engine and Video Maker — full sequences in the team voice, and the promo videos rendered with your own end card' }, 4: { name: 'Culmen', live: true, short: 'your Voice Profile — the Copy Engine and Email Engine start writing the way you actually talk' }, 5: { name: 'Apex', live: true, short: 'the Split Tester, and your ad allowance jumps to 50,000 impressions a month' }, - 6: { name: 'Fastigium', live: true, short: 'the Funnel Factory — a separate hosted landing page for every audience you talk to' }, + 6: { name: 'Fastigium', live: true, short: 'Co-Branded Pages and the Funnel Factory — your own name, monogram and colour on every page you build' }, 7: { name: 'Vertex', live: true, short: 'Leader Ops and Team Grants — your whole organisation triaged live from the contract, plus the ability to hand Suite capacity down to your own people' }, 8: { name: 'Corona', live: true, short: 'the Founder Desk and Network Intelligence — a whole week of promotion in one pass, API access, and what is actually working across every ad the team runs' } }; @@ -27,7 +27,7 @@ const TERSE = { 3: 'the Email Engine + Video Maker', 4: 'your own Voice Profile', 5: 'the Split Tester + 50k impressions', - 6: 'the Funnel Factory', + 6: 'Co-Branded Pages', 7: 'Leader Ops + Team Grants', 8: 'the Founder Desk + Network Intelligence' };