diff --git a/apps/website/package.json b/apps/website/package.json index 0ac90ae092..7e443d7e1a 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -14,6 +14,7 @@ "check:queen-spec-mirrors": "node --experimental-strip-types qa/queen-spec-mirrors.mjs", "check:queen-responsive": "node --experimental-strip-types qa/queen-responsive.mjs", "check:spec-catalog": "node --experimental-strip-types qa/spec-catalog-contract.mjs", + "check:traffic-arrival": "node --experimental-strip-types qa/traffic-arrival-contract.mjs", "core": "node scripts/spec-core.mjs", "check:shared-core": "node --experimental-strip-types qa/shared-spec-core.mjs", "prebuild": "npm run core -- index && node scripts/skills-core.mjs index && node scripts/agents-from-specs.mjs && node scripts/docs-from-specs.mjs && node scripts/viewport-from-spec.mjs && node scripts/onboarding-from-spec.mjs && node scripts/queen-wars-from-spec.mjs", diff --git a/apps/website/qa/traffic-arrival-contract.mjs b/apps/website/qa/traffic-arrival-contract.mjs new file mode 100644 index 0000000000..1c008a7c0f --- /dev/null +++ b/apps/website/qa/traffic-arrival-contract.mjs @@ -0,0 +1,29 @@ +// THE ARRIVAL HALF OF THE CHANNEL TAGS: a tagged visit to t27.ai is reported +// once, untagged visits never, and a failure never reaches the page. +// Run: node --experimental-strip-types qa/traffic-arrival-contract.mjs +import assert from 'node:assert/strict' +import { arrivalFromSearch, reportArrival, ARRIVAL_URL } from '../src/lib/trafficArrival.ts' + +// The form the agents publish: the query BEFORE the hash route. +const blog = new URL('https://t27.ai/?utm_source=x&utm_medium=social&utm_campaign=blog&utm_content=gf-t#/blog/gf-t') +assert.deepEqual(arrivalFromSearch(blog.search), { + source: 'x', medium: 'social', campaign: 'blog', content: 'gf-t', +}) +assert.equal(arrivalFromSearch(''), null, 'no tags, no arrival') +assert.equal(arrivalFromSearch('?utm_medium=social'), null, 'no source, no arrival') +// A query written after the hash is part of the route: not ours to read here. +assert.equal(arrivalFromSearch(new URL('https://t27.ai/#/blog/s?utm_source=x').search), null) + +const store = new Map() +const storage = { getItem: k => store.get(k) ?? null, setItem: (k, v) => void store.set(k, v) } +const calls = [] +const send = async (url, init) => { calls.push([url, JSON.parse(init.body)]); return new Response('{}') } +assert.equal(await reportArrival('?utm_source=reddit', send, storage), true) +assert.equal(await reportArrival('?utm_source=reddit', send, storage), false, 'once per session') +assert.equal(await reportArrival('', send, new Map() && { getItem: () => null, setItem: () => {} }), false) +assert.deepEqual(calls, [[ARRIVAL_URL, { source: 'reddit' }]]) + +const failing = async () => { throw new Error('offline') } +assert.equal(await reportArrival('?utm_source=threads', failing, { getItem: () => null, setItem: () => {} }), false) + +console.log('traffic-arrival contract: 9 checks passed') diff --git a/apps/website/src/lib/trafficArrival.ts b/apps/website/src/lib/trafficArrival.ts new file mode 100644 index 0000000000..8f62bb9a6d --- /dev/null +++ b/apps/website/src/lib/trafficArrival.ts @@ -0,0 +1,71 @@ +/** + * A VISITOR FROM A TAGGED LINK IS COUNTED ONCE, BY CHANNEL. + * + * The agents publish blog links with their channel in the address + * (`https://t27.ai/?utm_source=x&utm_campaign=blog&utm_content=#/blog/`, + * 999-multibots-telegraf render/src/agent/utm.ts). Tags on a link count + * nothing by themselves: this is the arrival half. On load the page reads + * the tags from its own query -- BEFORE the `#`, where HashRouter leaves + * them -- and tells the render service once per browser session, which + * keeps a count per (day, channel) and nothing else: no IP, no user id. + * + * No tags, no call. A failed call is dropped; the page never waits on it. + */ +export const ARRIVAL_URL = + 'https://vibee-render-production.up.railway.app/api/traffic/arrival' + +export interface ArrivalTags { + source: string + medium?: string + campaign?: string + content?: string +} + +/** The utm tags of a query string, or null when it names no source. Pure. */ +export function arrivalFromSearch(search: string): ArrivalTags | null { + const q = new URLSearchParams(search.startsWith('?') ? search : `?${search}`) + const source = (q.get('utm_source') ?? '').trim() + if (!source) return null + const tags: ArrivalTags = { source } + const more: Array<[string, 'medium' | 'campaign' | 'content']> = [ + ['utm_medium', 'medium'], + ['utm_campaign', 'campaign'], + ['utm_content', 'content'], + ] + for (const [k, key] of more) { + const v = (q.get(k) ?? '').trim() + if (v) tags[key] = v + } + return tags +} + +const ONCE_KEY = 'traffic:arrival-reported' + +/** Report this visit's tags, once per browser session. Never throws. */ +export async function reportArrival( + search: string = typeof window !== 'undefined' ? window.location.search : '', + send: typeof fetch = fetch, + storage: Pick | null = typeof sessionStorage !== 'undefined' + ? sessionStorage + : null +): Promise { + const tags = arrivalFromSearch(search) + if (!tags) return false + try { + if (storage?.getItem(ONCE_KEY)) return false + storage?.setItem(ONCE_KEY, '1') + } catch { + /* no storage: count anyway */ + } + try { + await send(ARRIVAL_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(tags), + keepalive: true, + }) + return true + } catch { + return false + } +} diff --git a/apps/website/src/main.tsx b/apps/website/src/main.tsx index 5d737c2f91..7b65e21917 100644 --- a/apps/website/src/main.tsx +++ b/apps/website/src/main.tsx @@ -12,6 +12,7 @@ import GlobalStarfield from './components/GlobalStarfield.tsx' import { handExplorerLinksToQueen } from './lib/queenFrame' import { redirectLegacyQueen } from './lib/legacyQueenRedirect' import { triIdentity } from './lib/triIdentity' +import { reportArrival } from './lib/trafficArrival' // THE QUEEN MOVED to https://app.t27.ai/queen/, which now builds this same // bundle and serves the board on the app's own origin. #/queen here is the old @@ -27,6 +28,11 @@ import { triIdentity } from './lib/triIdentity' // which calls the decision with both origins as input. const leaving = redirectLegacyQueen() +// A visitor from a tagged link (utm_source=x, reddit, threads...) is counted +// once, by channel, where the agents' links land. Not for a page that is on +// its way to another address. See src/lib/trafficArrival.ts. +if (!leaving) void reportArrival() + // In a Queen tab's frame, a link to another Explorer switches the Queen's tab instead // of navigating the frame under a rail that names a different one. handExplorerLinksToQueen()