From d62920b2c19ff96c75094c757b77232f515f5da5 Mon Sep 17 00:00:00 2001 From: Matteo Morelli Date: Mon, 28 Sep 2026 11:21:39 +0200 Subject: [PATCH] feat(attribution): store Google Ads click ids with ad consent, pass them to pricing Sign-up attribution now accepts gclid, gbraid and wbraid ([A-Za-z0-9_-], max 150) plus ad_consent on each touch. The server keeps a click id only on a touch that says ad_consent = granted and drops it otherwise; stored rows are re-sanitized on the way out as well. The cloud sign-up page keeps a click id from its own URL only when its cookie banner has the marketing category accepted, and drops handed-over ids when that banner says no. GET /api/auth/attribution/click-ids returns the signed-in user's own click id from their signup_attributed event (cloud only, {} on self-hosted). The cloud app appends it to its pricing links next to return_url so the purchase can be uploaded to Google Ads as an offline conversion. --- docs/operations/signup-attribution.md | 10 +- packages/backend/src/audit/audit.module.ts | 3 +- .../src/audit/product-event.service.ts | 26 ++- .../signup-attribution.controller.spec.ts | 121 +++++++++++++ .../audit/signup-attribution.controller.ts | 36 ++++ .../src/audit/signup-attribution.spec.ts | 91 +++++++++- .../backend/src/audit/signup-attribution.ts | 72 +++++++- .../src/auth/signup-attribution.dto.ts | 59 ++++++- .../src/auth/signup-attribution.spec.ts | 100 ++++++++++- .../src/app/settings/license/page.tsx | 7 +- .../frontend/src/components/license-wall.tsx | 7 +- .../frontend/src/components/trial-banner.tsx | 5 +- packages/frontend/src/lib/api.ts | 9 +- packages/frontend/src/lib/attribution.ts | 165 +++++++++++++++--- packages/frontend/src/lib/marketing.ts | 17 +- packages/frontend/src/lib/use-pricing-url.ts | 52 ++++++ 16 files changed, 723 insertions(+), 57 deletions(-) create mode 100644 packages/backend/src/audit/signup-attribution.controller.spec.ts create mode 100644 packages/backend/src/audit/signup-attribution.controller.ts create mode 100644 packages/frontend/src/lib/use-pricing-url.ts diff --git a/docs/operations/signup-attribution.md b/docs/operations/signup-attribution.md index d11c4ae3..a1e2b97d 100644 --- a/docs/operations/signup-attribution.md +++ b/docs/operations/signup-attribution.md @@ -4,10 +4,14 @@ Which channel brings AnythingMCP Cloud sign-ups, and which of them verify and pa ## How it is recorded -1. **anythingmcp.com** notes, per visitor, the first and the last *touch*: UTM tags, Google Ads' `gad_source` / `gad_campaignid`, `paid` (a gclid, gbraid or wbraid was on the URL; the id itself is never kept), the referrer's host and the landing path. Kept in memory for the visit, and in localStorage for 30 days only once the visitor has allowed analytics in the cookie banner. +1. **anythingmcp.com** notes, per visitor, the first and the last *touch*: UTM tags, Google Ads' `gad_source` / `gad_campaignid`, `paid` (a gclid, gbraid or wbraid was on the URL), the referrer's host and the landing path. The click id itself travels only with `ad_consent: "granted"` (the visitor allowed marketing cookies). Kept in memory for the visit, and in localStorage for 30 days only once the visitor has allowed analytics in the cookie banner. 2. When the visitor clicks through to `cloud.anythingmcp.com`, the link gets `amcp_src=` and, if different, `amcp_lt=`. -3. **The cloud sign-up page** reads those, or, for a visitor who came straight to the cloud app, builds its own touch from its URL and the referrer's host (`captured_on: "cloud"`). It sends them with `POST /api/auth/register` as `attribution: { first_touch, last_touch }`. -4. **The backend** sanitizes them again and, for a **newly created account only**, writes a `signup_attributed` row to `product_events` (`user_id`, `organization_id` = the workspace created at sign-up). An address that already has an account records nothing, and the answer to the sign-up is the same either way. +3. **The cloud sign-up page** reads those, or, for a visitor who came straight to the cloud app, builds its own touch from its URL and the referrer's host (`captured_on: "cloud"`). For its own touch, a click id is kept only if the cloud's cookie banner has the *marketing* category accepted; if that banner says no, click ids handed over by the site are dropped too. It sends them with `POST /api/auth/register` as `attribution: { first_touch, last_touch }`. +4. **The backend** sanitizes them again and, for a **newly created account only**, writes a `signup_attributed` row to `product_events` (`user_id`, `organization_id` = the workspace created at sign-up). A click id (`gclid`, `gbraid`, `wbraid`: `[A-Za-z0-9_-]`, at most 150 characters) is stored only on a touch with `ad_consent: "granted"` and dropped otherwise. An address that already has an account records nothing, and the answer to the sign-up is the same either way. + +## Purchases as Google Ads offline conversions + +`GET /api/auth/attribution/click-ids` (signed in, cloud only; `{}` on self-hosted) returns the caller's own click id from their `signup_attributed` row, e.g. `{ "gclid": "…", "ad_consent": "granted", "captured_at": "…" }`: one id (last touch before first; gclid before gbraid before wbraid), and only if it was stored with consent. The cloud app appends it to its links to the pricing page (`?return_url=…&gclid=…`); the pricing page puts it into the Stripe checkout metadata and the site's Stripe webhook uploads the purchase to Google Ads. Stored metadata (the channel is derived on the server, see `packages/backend/src/audit/signup-attribution.ts`): diff --git a/packages/backend/src/audit/audit.module.ts b/packages/backend/src/audit/audit.module.ts index 0fbb29ae..78b32371 100644 --- a/packages/backend/src/audit/audit.module.ts +++ b/packages/backend/src/audit/audit.module.ts @@ -4,10 +4,11 @@ import { AuditController } from './audit.controller'; import { SecurityEventService } from './security-event.service'; import { ProductEventService } from './product-event.service'; import { ProductEventController } from './product-event.controller'; +import { SignupAttributionController } from './signup-attribution.controller'; @Global() @Module({ - controllers: [AuditController, ProductEventController], + controllers: [AuditController, ProductEventController, SignupAttributionController], providers: [AuditService, SecurityEventService, ProductEventService], exports: [AuditService, SecurityEventService, ProductEventService], }) diff --git a/packages/backend/src/audit/product-event.service.ts b/packages/backend/src/audit/product-event.service.ts index 0baf47e6..727f3e24 100644 --- a/packages/backend/src/audit/product-event.service.ts +++ b/packages/backend/src/audit/product-event.service.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../common/prisma.service'; -import { sanitizeSignupAttribution } from './signup-attribution'; +import { AttributionClickId, clickIdFromAttribution, sanitizeSignupAttribution } from './signup-attribution'; /** * Product-usage events the UI reports so the activation funnel can be read @@ -48,8 +48,8 @@ const CLIENT_REPORTABLE = new Set( Object.values(ProductEvents).filter((e) => !SERVER_ONLY.has(e)), ); const MAX_METADATA_BYTES = 1024; -/** Two touches of up to eleven capped fields each. */ -const MAX_ATTRIBUTION_BYTES = 4096; +/** Two touches of up to fifteen capped fields each, three of them click ids of up to 150 chars. */ +const MAX_ATTRIBUTION_BYTES = 5120; @Injectable() export class ProductEventService { @@ -86,6 +86,26 @@ export class ProductEventService { this.logger.warn(`product event ${input.event} not recorded: ${err?.message ?? err}`); } } + + /** + * The Google Ads click id this user signed up through, if they granted ad + * consent: read from their own `signup_attributed` event only, keyed by the + * user id alone. Null when there is none. + */ + async clickIdForUser(userId: string | null | undefined): Promise { + if (!userId) return null; + const rows = await this.prisma.productEvent.findMany({ + where: { userId, event: ProductEvents.SIGNUP_ATTRIBUTED }, + orderBy: { createdAt: 'desc' }, + take: 5, + select: { metadata: true }, + }); + for (const row of rows) { + const found = clickIdFromAttribution(row.metadata); + if (found) return found; + } + return null; + } } /** diff --git a/packages/backend/src/audit/signup-attribution.controller.spec.ts b/packages/backend/src/audit/signup-attribution.controller.spec.ts new file mode 100644 index 00000000..ebecb08d --- /dev/null +++ b/packages/backend/src/audit/signup-attribution.controller.spec.ts @@ -0,0 +1,121 @@ +import { GUARDS_METADATA } from '@nestjs/common/constants'; +import { SignupAttributionController } from './signup-attribution.controller'; +import { ProductEventService, ProductEvents } from './product-event.service'; + +/** + * GET /api/auth/attribution/click-ids: the caller's own click id, stored with + * ad consent, on AnythingMCP Cloud only. + */ + +type Row = { event: string; userId: string | null; organizationId: string | null; createdAt: Date; metadata: unknown }; + +const ROWS: Row[] = [ + { + event: ProductEvents.SIGNUP_ATTRIBUTED, + userId: 'alice', + organizationId: 'org-a', + createdAt: new Date('2026-09-20T10:00:00Z'), + metadata: { + first_touch: { utm_source: 'google', gclid: 'alice-gclid', ad_consent: 'granted', paid: true, channel: 'google_ads' }, + first_channel: 'google_ads', + }, + }, + { + // Same organization, another user: never Alice's answer. + event: ProductEvents.SIGNUP_ATTRIBUTED, + userId: 'bob', + organizationId: 'org-a', + createdAt: new Date('2026-09-25T10:00:00Z'), + metadata: { first_touch: { gclid: 'bob-gclid', ad_consent: 'granted', channel: 'google_ads' } }, + }, + { + // A client-reportable event that happens to carry a click id-shaped field. + event: ProductEvents.MCP_URL_COPIED, + userId: 'alice', + organizationId: 'org-a', + createdAt: new Date('2026-09-26T10:00:00Z'), + metadata: { first_touch: { gclid: 'forged', ad_consent: 'granted' } }, + }, + { + event: ProductEvents.SIGNUP_ATTRIBUTED, + userId: 'carol', + organizationId: 'org-c', + createdAt: new Date('2026-09-21T10:00:00Z'), + // Stored without consent (or before the rule existed): no id comes back. + metadata: { first_touch: { gclid: 'carol-gclid', ad_consent: 'denied', channel: 'google_ads' } }, + }, + { + event: ProductEvents.SIGNUP_ATTRIBUTED, + userId: 'dave', + organizationId: 'org-d', + createdAt: new Date('2026-09-22T10:00:00Z'), + metadata: { first_touch: { referrer_host: 'github.com', channel: 'github' } }, + }, +]; + +function fakePrisma() { + const findMany = jest.fn(async ({ where, orderBy, take }: any) => { + let rows = ROWS.filter( + (r) => Object.entries(where).every(([k, v]) => (r as Record)[k] === v), + ); + if (orderBy?.createdAt === 'desc') rows = [...rows].sort((a, b) => +b.createdAt - +a.createdAt); + return rows.slice(0, take ?? rows.length).map((r) => ({ metadata: r.metadata })); + }); + return { prisma: { productEvent: { findMany } }, findMany }; +} + +function makeController(mode: 'cloud' | 'self-hosted') { + const { prisma, findMany } = fakePrisma(); + const controller = new SignupAttributionController(new ProductEventService(prisma as any), { + isCloud: () => mode === 'cloud', + } as any); + return { controller, findMany }; +} + +const asUser = (sub: string, organizationId = 'org-a') => ({ user: { sub, organizationId } }); + +describe('SignupAttributionController — GET click-ids', () => { + it('sits behind the JWT guard', () => { + const guards = Reflect.getMetadata(GUARDS_METADATA, SignupAttributionController) ?? []; + expect(guards).toHaveLength(1); + }); + + it("returns the caller's own click id and consent, nothing else", async () => { + const { controller, findMany } = makeController('cloud'); + await expect(controller.clickIds(asUser('alice'))).resolves.toEqual({ + gclid: 'alice-gclid', + ad_consent: 'granted', + }); + // Keyed by the session's user id and the server-written event only. + expect(findMany).toHaveBeenCalledWith( + expect.objectContaining({ where: { userId: 'alice', event: ProductEvents.SIGNUP_ATTRIBUTED } }), + ); + }); + + it("never answers with another user's click id, even in the same organization", async () => { + const { controller } = makeController('cloud'); + const bob = await controller.clickIds(asUser('bob')); + expect(bob).toEqual({ gclid: 'bob-gclid', ad_consent: 'granted' }); + expect(JSON.stringify(bob)).not.toContain('alice'); + await expect(controller.clickIds(asUser('eve', 'org-a'))).resolves.toEqual({}); + }); + + it('returns nothing for an id stored without granted consent, or no id at all', async () => { + const { controller } = makeController('cloud'); + await expect(controller.clickIds(asUser('carol', 'org-c'))).resolves.toEqual({}); + await expect(controller.clickIds(asUser('dave', 'org-d'))).resolves.toEqual({}); + }); + + it('returns nothing without a user id, and does not query', async () => { + const { controller, findMany } = makeController('cloud'); + await expect(controller.clickIds({ user: {} })).resolves.toEqual({}); + await expect(controller.clickIds({})).resolves.toEqual({}); + expect(findMany).not.toHaveBeenCalled(); + }); + + it('is empty on a self-hosted instance, and does not query', async () => { + const { controller, findMany } = makeController('self-hosted'); + await expect(controller.clickIds(asUser('alice'))).resolves.toEqual({}); + expect(findMany).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/backend/src/audit/signup-attribution.controller.ts b/packages/backend/src/audit/signup-attribution.controller.ts new file mode 100644 index 00000000..1c8fc8cb --- /dev/null +++ b/packages/backend/src/audit/signup-attribution.controller.ts @@ -0,0 +1,36 @@ +import { Controller, Get, Req, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { AuthGuard } from '@nestjs/passport'; +import { DeploymentService } from '../common/deployment.service'; +import { ProductEventService } from './product-event.service'; +import { AttributionClickId } from './signup-attribution'; + +/** + * GET /api/auth/attribution/click-ids — the signed-in user's own Google Ads + * click id, so the cloud app can pass it on to the pricing page and a + * purchase can be uploaded to Google Ads as an offline conversion. + * + * Only ever the caller's own sign-up (the user id comes from the session, + * never from the request), only an id stored with ad consent granted, and + * only on AnythingMCP Cloud: a self-hosted instance records no attribution + * and answers an empty object. + */ +@ApiTags('Auth') +@ApiBearerAuth() +@UseGuards(AuthGuard('jwt')) +@Controller('api/auth/attribution') +export class SignupAttributionController { + constructor( + private readonly events: ProductEventService, + private readonly deployment: DeploymentService, + ) {} + + @Get('click-ids') + @ApiOperation({ + summary: "The signed-in user's own sign-up click id, if it was stored with ad consent (cloud only)", + }) + async clickIds(@Req() req: any): Promise> { + if (!this.deployment.isCloud()) return {}; + return (await this.events.clickIdForUser(req.user?.sub)) ?? {}; + } +} diff --git a/packages/backend/src/audit/signup-attribution.spec.ts b/packages/backend/src/audit/signup-attribution.spec.ts index 7ef147b6..d474da03 100644 --- a/packages/backend/src/audit/signup-attribution.spec.ts +++ b/packages/backend/src/audit/signup-attribution.spec.ts @@ -1,4 +1,4 @@ -import { classifyTouch, sanitizeSignupAttribution } from './signup-attribution'; +import { classifyTouch, clickIdFromAttribution, sanitizeSignupAttribution } from './signup-attribution'; describe('classifyTouch', () => { it.each([ @@ -47,9 +47,96 @@ describe('sanitizeSignupAttribution', () => { it('is idempotent: sanitizing stored metadata changes nothing', () => { const once = sanitizeSignupAttribution( - { first_touch: { utm_source: 'google', paid: true, ts: NOW - 1000 }, last_touch: { referrer_host: 'github.com' } }, + { + first_touch: { utm_source: 'google', paid: true, ts: NOW - 1000, gclid: 'Cj0-a_b', ad_consent: 'granted' }, + last_touch: { referrer_host: 'github.com', ad_consent: 'denied' }, + }, NOW, ); + expect(once?.first_touch?.gclid).toBe('Cj0-a_b'); expect(sanitizeSignupAttribution(once, NOW)).toEqual(once); }); }); + +describe('click ids and ad consent', () => { + const NOW = Date.parse('2026-09-27T10:00:00Z'); + const IDS = { gclid: 'Cj0KCQjw-abc_DEF', gbraid: 'Gb-1_x', wbraid: 'Wb_2-y' }; + + it('keeps click ids only when the touch says ad consent was granted', () => { + const out = sanitizeSignupAttribution({ first_touch: { ...IDS, ad_consent: 'granted' } }, NOW); + expect(out?.first_touch).toEqual({ ...IDS, ad_consent: 'granted', paid: true, channel: 'google_ads' }); + }); + + it.each([['denied'], ['unknown'], [undefined], ['GRANTED'], [true]])( + 'drops them when ad consent is %p', + (adConsent) => { + const out = sanitizeSignupAttribution( + { first_touch: { utm_source: 'google', ...IDS, ad_consent: adConsent } }, + NOW, + ); + expect(out?.first_touch?.gclid).toBeUndefined(); + expect(out?.first_touch?.gbraid).toBeUndefined(); + expect(out?.first_touch?.wbraid).toBeUndefined(); + expect(JSON.stringify(out)).not.toContain('Cj0KCQ'); + }, + ); + + it('decides per touch: consent on the first touch does not carry over to the last', () => { + const out = sanitizeSignupAttribution( + { + first_touch: { gclid: 'first-id', ad_consent: 'granted' }, + last_touch: { gclid: 'last-id', utm_source: 'google' }, + }, + NOW, + ); + expect(out?.first_touch?.gclid).toBe('first-id'); + expect(out?.last_touch).toEqual({ utm_source: 'google', channel: 'referral' }); + }); + + it.each([ + ['a dot', 'Cj0.KCQ'], + ['a space', 'Cj0 KCQ'], + ['an address', 'jane@example.com'], + ['a query string', 'abc&utm_source=x'], + ['151 characters', 'a'.repeat(151)], + ['an empty string', ' '], + ])('drops a click id with %s even with consent', (_label, gclid) => { + const out = sanitizeSignupAttribution({ first_touch: { gclid, ad_consent: 'granted', utm_source: 'google' } }, NOW); + expect(out?.first_touch?.gclid).toBeUndefined(); + expect(out?.first_touch?.paid).toBeUndefined(); + }); + + it('keeps a 150-character click id', () => { + const gclid = 'a'.repeat(150); + const out = sanitizeSignupAttribution({ first_touch: { gclid, ad_consent: 'granted' } }, NOW); + expect(out?.first_touch?.gclid).toBe(gclid); + }); +}); + +describe('clickIdFromAttribution', () => { + it('prefers the last touch, and returns a single id: gclid before gbraid before wbraid', () => { + expect( + clickIdFromAttribution({ + first_touch: { gclid: 'first', ad_consent: 'granted', ts: '2026-09-20T10:00:00.000Z' }, + last_touch: { wbraid: 'w-last', gbraid: 'g-last', ad_consent: 'granted', ts: '2026-09-21T10:00:00.000Z' }, + }), + ).toEqual({ gbraid: 'g-last', ad_consent: 'granted', captured_at: '2026-09-21T10:00:00.000Z' }); + }); + + it('falls back to the first touch when the last carries none', () => { + expect( + clickIdFromAttribution({ + first_touch: { gclid: 'first', ad_consent: 'granted' }, + last_touch: { referrer_host: 'chatgpt.com' }, + }), + ).toEqual({ gclid: 'first', ad_consent: 'granted' }); + }); + + it('never returns an id from a stored touch without granted consent', () => { + expect(clickIdFromAttribution({ first_touch: { gclid: 'legacy-row' } })).toBeNull(); + expect(clickIdFromAttribution({ last_touch: { gclid: 'x', ad_consent: 'denied' } })).toBeNull(); + expect(clickIdFromAttribution({ first_touch: { gclid: 'bad.id', ad_consent: 'granted' } })).toBeNull(); + expect(clickIdFromAttribution(null)).toBeNull(); + expect(clickIdFromAttribution([{ gclid: 'x', ad_consent: 'granted' }])).toBeNull(); + }); +}); diff --git a/packages/backend/src/audit/signup-attribution.ts b/packages/backend/src/audit/signup-attribution.ts index 08f8fa19..de968191 100644 --- a/packages/backend/src/audit/signup-attribution.ts +++ b/packages/backend/src/audit/signup-attribution.ts @@ -5,10 +5,14 @@ * `signup_attributed` product event; see docs/operations/signup-attribution.md * for the reporting queries. * - * Campaign-level only. The client already drops ad click ids, referrer paths - * and query strings; this is the server's own pass over an untrusted body, - * with the same key set: anything else is dropped, strings are capped, and a - * value that looks like an email address is refused. + * Campaign-level, plus one exception: a Google Ads click id (gclid, gbraid, + * wbraid), kept only when the touch says the visitor granted ad consent, so + * a purchase can be reported back to Google Ads as an offline conversion. + * Without `ad_consent: 'granted'` the id is dropped here, whatever the + * client sent: never a click id without consent. The client already drops + * referrer paths and query strings; this is the server's own pass over an + * untrusted body, with the same key set: anything else is dropped, strings + * are capped, and a value that looks like an email address is refused. */ export const TOUCH_STRING_KEYS = [ @@ -23,6 +27,16 @@ export const TOUCH_STRING_KEYS = [ export const MAX_TOUCH_FIELD = 100; +/** Google Ads click ids, in the order Google prefers them for an upload. */ +export const CLICK_ID_KEYS = ['gclid', 'gbraid', 'wbraid'] as const; +export type ClickIdKey = (typeof CLICK_ID_KEYS)[number]; +export const MAX_CLICK_ID = 150; +/** Google's ids are URL-safe base64-ish tokens; anything else is not one. */ +export const CLICK_ID_PATTERN = /^[A-Za-z0-9_-]+$/; + +export const AD_CONSENT_VALUES = ['granted', 'denied', 'unknown'] as const; +export type AdConsent = (typeof AD_CONSENT_VALUES)[number]; + export type Channel = | 'google_ads' | 'paid_other' @@ -36,13 +50,17 @@ export type Channel = | 'referral' | 'direct'; -export type StoredTouch = Partial> & { +export type StoredTouch = Partial> & + /** Present only next to `ad_consent: 'granted'`. */ + Partial> & { paid?: true; referrer_host?: string; landing_path?: string; /** ISO 8601; the client sends epoch milliseconds. */ ts?: string; captured_on?: 'site' | 'cloud'; + /** The visitor's ad (marketing cookie) consent when the touch was recorded. */ + ad_consent?: AdConsent; /** Derived here, so a report can group by it without repeating the rules. */ channel: Channel; }; @@ -154,6 +172,13 @@ function cleanString(value: unknown): string | undefined { return v; } +function cleanClickId(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const v = value.trim(); + if (!v || v.length > MAX_CLICK_ID || !CLICK_ID_PATTERN.test(v)) return undefined; + return v; +} + function cleanHost(value: unknown): string | undefined { if (typeof value !== 'string') return undefined; const host = value.trim().toLowerCase().replace(/^www\./, ''); @@ -193,6 +218,16 @@ export function sanitizeTouch(input: unknown, now = Date.now()): StoredTouch | u const ts = cleanTs(raw.ts, now); if (ts) out.ts = ts; if (raw.captured_on === 'site' || raw.captured_on === 'cloud') out.captured_on = raw.captured_on; + if (AD_CONSENT_VALUES.includes(raw.ad_consent as AdConsent)) out.ad_consent = raw.ad_consent as AdConsent; + // The consent rule, enforced here and nowhere weaker: a click id is kept + // only on a touch that says ad consent was granted. + if (out.ad_consent === 'granted') { + for (const key of CLICK_ID_KEYS) { + const v = cleanClickId(raw[key]); + if (v) out[key] = v; + } + if (CLICK_ID_KEYS.some((key) => out[key])) out.paid = true; + } if (Object.keys(out).length === 0) return undefined; return { ...out, channel: classifyTouch(out) }; } @@ -221,3 +256,30 @@ export function sanitizeSignupAttribution( } return out; } + +/** What a signed-in user may read back about their own sign-up: one click id and its consent. */ +export type AttributionClickId = Partial> & { + ad_consent: 'granted'; + /** When the touch that carried it was recorded, ISO 8601. */ + captured_at?: string; +}; + +/** + * The click id to report a purchase against, from stored `signup_attributed` + * metadata: the most recent touch that carries one (the last touch, else the + * first), and only one id, the one Google prefers. Stored rows are sanitized + * again on the way out, so a row written before the consent rule, or edited + * by hand, still never yields an id without `ad_consent: 'granted'`. + */ +export function clickIdFromAttribution(metadata: unknown): AttributionClickId | null { + if (!metadata || typeof metadata !== 'object' || Array.isArray(metadata)) return null; + const raw = metadata as Record; + for (const candidate of [raw.last_touch, raw.first_touch]) { + const touch = sanitizeTouch(candidate); + if (!touch || touch.ad_consent !== 'granted') continue; + const key = CLICK_ID_KEYS.find((k) => touch[k]); + if (!key) continue; + return { [key]: touch[key], ad_consent: 'granted', ...(touch.ts && { captured_at: touch.ts }) }; + } + return null; +} diff --git a/packages/backend/src/auth/signup-attribution.dto.ts b/packages/backend/src/auth/signup-attribution.dto.ts index c63c1bad..716667ae 100644 --- a/packages/backend/src/auth/signup-attribution.dto.ts +++ b/packages/backend/src/auth/signup-attribution.dto.ts @@ -1,14 +1,23 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsBoolean, IsIn, IsInt, IsOptional, IsString, MaxLength, Min, ValidateNested } from 'class-validator'; -import { MAX_TOUCH_FIELD } from '../audit/signup-attribution'; +import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Matches, MaxLength, Min, ValidateNested } from 'class-validator'; +import { + AD_CONSENT_VALUES, + CLICK_ID_PATTERN, + MAX_CLICK_ID, + MAX_TOUCH_FIELD, + type AdConsent, +} from '../audit/signup-attribution'; + +const CLICK_ID_MESSAGE = 'must be a Google Ads click id (letters, digits, "-" and "_")'; /** * One touch: what a landing on the marketing site (or on the cloud app, for * visitors who came straight there) said about where the visitor came from. - * Campaign-level only: no click id, no referrer path, nothing personal. The - * server sanitizes again before storing (audit/signup-attribution.ts); these - * rules only bound what the register endpoint accepts. + * Campaign-level, no referrer path, nothing personal; a Google Ads click id + * only next to `ad_consent: 'granted'`. The server sanitizes again before + * storing (audit/signup-attribution.ts) and drops a click id sent without + * that consent; these rules only bound what the register endpoint accepts. * * Keep the key set in step with the cloud frontend (lib/attribution.ts): the * global ValidationPipe rejects unknown keys, and the frontend drops any key @@ -57,11 +66,49 @@ export class SignupTouchDto { @MaxLength(MAX_TOUCH_FIELD) gad_campaignid?: string; - @ApiPropertyOptional({ description: 'A Google Ads click id was present. The id itself is never sent.' }) + @ApiPropertyOptional({ description: 'A Google Ads click id was present, whether or not the id itself is sent.' }) @IsOptional() @IsBoolean() paid?: boolean; + @ApiPropertyOptional({ + description: "Google Ads click id. Stored only when `ad_consent` is 'granted'.", + maxLength: MAX_CLICK_ID, + }) + @IsOptional() + @IsString() + @MaxLength(MAX_CLICK_ID) + @Matches(CLICK_ID_PATTERN, { message: `gclid ${CLICK_ID_MESSAGE}` }) + gclid?: string; + + @ApiPropertyOptional({ + description: "Google Ads click id for app-to-web (iOS). Stored only when `ad_consent` is 'granted'.", + maxLength: MAX_CLICK_ID, + }) + @IsOptional() + @IsString() + @MaxLength(MAX_CLICK_ID) + @Matches(CLICK_ID_PATTERN, { message: `gbraid ${CLICK_ID_MESSAGE}` }) + gbraid?: string; + + @ApiPropertyOptional({ + description: "Google Ads click id for web-to-app (iOS). Stored only when `ad_consent` is 'granted'.", + maxLength: MAX_CLICK_ID, + }) + @IsOptional() + @IsString() + @MaxLength(MAX_CLICK_ID) + @Matches(CLICK_ID_PATTERN, { message: `wbraid ${CLICK_ID_MESSAGE}` }) + wbraid?: string; + + @ApiPropertyOptional({ + enum: AD_CONSENT_VALUES, + description: 'The visitor\'s ad (marketing cookie) consent when the touch was recorded.', + }) + @IsOptional() + @IsIn(AD_CONSENT_VALUES) + ad_consent?: AdConsent; + @ApiPropertyOptional({ description: 'Host of the referring page, without path or query.', maxLength: MAX_TOUCH_FIELD }) @IsOptional() @IsString() diff --git a/packages/backend/src/auth/signup-attribution.spec.ts b/packages/backend/src/auth/signup-attribution.spec.ts index d120e14b..9924a4fc 100644 --- a/packages/backend/src/auth/signup-attribution.spec.ts +++ b/packages/backend/src/auth/signup-attribution.spec.ts @@ -21,6 +21,7 @@ const base = { }; const TS = Date.parse('2026-09-24T08:30:00Z'); +const GCLID = 'Cj0KCQjw-abc_DEF123'; const ATTRIBUTION = { first_touch: { @@ -63,13 +64,53 @@ describe('RegisterDto through the global ValidationPipe', () => { expect(dto.attribution?.last_touch).toBeUndefined(); }); - it('refuses keys it does not know, such as a click id', async () => { + it('refuses keys it does not know, such as a non-Google click id', async () => { await expect( - validate({ ...base, attribution: { first_touch: { gclid: 'Cj0KCQ' } } }), + validate({ ...base, attribution: { first_touch: { fbclid: 'IwAR0abc' } } }), + ).rejects.toBeInstanceOf(BadRequestException); + await expect( + validate({ ...base, attribution: { first_touch: { email: 'jane@example.com' } } }), ).rejects.toBeInstanceOf(BadRequestException); await expect(validate({ ...base, attribution: { email: 'x' } })).rejects.toBeInstanceOf(BadRequestException); }); + it('accepts Google Ads click ids with an ad consent state', async () => { + const dto = await validate({ + ...base, + attribution: { + first_touch: { gclid: GCLID, gbraid: 'Gb-1_x', wbraid: 'Wb_2-y', ad_consent: 'granted', paid: true }, + last_touch: { gclid: GCLID, ad_consent: 'denied' }, + }, + }); + expect(dto.attribution?.first_touch?.gclid).toBe(GCLID); + expect(dto.attribution?.first_touch?.ad_consent).toBe('granted'); + expect(dto.attribution?.last_touch?.ad_consent).toBe('denied'); + await expect( + validate({ ...base, attribution: { first_touch: { ad_consent: 'unknown' } } }), + ).resolves.toBeDefined(); + }); + + it.each([ + ['a character outside [A-Za-z0-9_-]', { gclid: 'Cj0KCQ.abc', ad_consent: 'granted' }], + ['a space', { gbraid: 'abc def', ad_consent: 'granted' }], + ['an address', { wbraid: 'jane@example.com', ad_consent: 'granted' }], + ['more than 150 characters', { gclid: 'a'.repeat(151), ad_consent: 'granted' }], + ['a non-string', { gclid: 12345, ad_consent: 'granted' }], + ['an unknown consent state', { gclid: GCLID, ad_consent: 'yes' }], + ])('refuses a click id touch with %s', async (_label, touch) => { + await expect(validate({ ...base, attribution: { first_touch: touch } })).rejects.toBeInstanceOf( + BadRequestException, + ); + }); + + it('accepts a click id of exactly 150 characters', async () => { + const dto = await validate({ + ...base, + attribution: { first_touch: { gclid: 'a'.repeat(150), ad_consent: 'granted' } }, + }); + expect(dto.attribution?.first_touch?.gclid).toHaveLength(150); + }); + it('refuses values beyond the caps and of the wrong type', async () => { await expect( validate({ ...base, attribution: { first_touch: { utm_source: 'x'.repeat(101) } } }), @@ -218,6 +259,61 @@ describe('AuthController — sign-up attribution', () => { expect(JSON.stringify(meta)).not.toContain('jane'); }); + it('stores a click id that comes with ad consent granted', async () => { + const { controller, events } = makeController('cloud'); + const dto = await validate({ + ...base, + attribution: { + first_touch: { utm_source: 'google', gclid: GCLID, ad_consent: 'granted', captured_on: 'site', ts: TS }, + }, + }); + await controller.register({}, dto); + await flush(); + + expect(events[0].metadata.first_touch).toEqual({ + utm_source: 'google', + gclid: GCLID, + ad_consent: 'granted', + paid: true, + captured_on: 'site', + ts: '2026-09-24T08:30:00.000Z', + channel: 'google_ads', + }); + }); + + it.each([['denied'], ['unknown'], [undefined]])( + 'drops every click id when ad consent is %s, and keeps the rest of the touch', + async (adConsent) => { + const { controller, events } = makeController('cloud'); + const dto = await validate({ + ...base, + attribution: { + first_touch: { + utm_source: 'google', + gclid: GCLID, + gbraid: 'Gb-1', + wbraid: 'Wb-2', + paid: true, + captured_on: 'site', + ...(adConsent && { ad_consent: adConsent }), + }, + }, + }); + await controller.register({}, dto); + await flush(); + + const meta = events[0].metadata; + expect(meta.first_touch).toEqual({ + utm_source: 'google', + paid: true, + captured_on: 'site', + ...(adConsent && { ad_consent: adConsent }), + channel: 'google_ads', + }); + expect(JSON.stringify(meta)).not.toMatch(/gclid|gbraid|wbraid|Cj0KCQ/); + }, + ); + it('records nothing for an address that already has an account', async () => { const { controller, events } = makeController('cloud', ['new@example.com']); const dto = await validate({ ...base, attribution: ATTRIBUTION }); diff --git a/packages/frontend/src/app/settings/license/page.tsx b/packages/frontend/src/app/settings/license/page.tsx index c348d99e..5a601b25 100644 --- a/packages/frontend/src/app/settings/license/page.tsx +++ b/packages/frontend/src/app/settings/license/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'; import { useAuth } from '@/lib/auth-context'; import { license } from '@/lib/api'; -import { buildPricingUrl } from '@/lib/marketing'; +import { usePricingUrl } from '@/lib/use-pricing-url'; import { useManagePlan } from '@/lib/use-manage-plan'; import { Button, buttonVariants } from '@/components/ui/button'; import { Card } from '@/components/ui/card'; @@ -29,6 +29,7 @@ export default function LicenseSettingsPage() { const [verifying, setVerifying] = useState(false); const [openingPortal, setOpeningPortal] = useState(false); const managePlan = useManagePlan(); + const pricingUrl = usePricingUrl(); const isCloud = deploymentMode === 'cloud'; // The Stripe billing portal only applies to a real paid subscription — @@ -307,7 +308,7 @@ export default function LicenseSettingsPage() {

Purchase a license at{' '} (null); const [starting, setStarting] = useState(false); const [startErr, setStartErr] = useState(null); @@ -143,7 +144,7 @@ export function LicenseWall() { {startErr &&

{startErr}

} ) : ( (null); const [plan, setPlan] = useState(null); const [connectors, setConnectors] = useState(null); @@ -57,7 +58,7 @@ export function TrialBanner() { {countdown}{value} {' '} + request('/api/auth/attribution/click-ids', { + token, + skipAutoLogout: true, + }), forgotPassword: (email: string) => request<{ message: string }>('/api/auth/forgot-password', { method: 'POST', diff --git a/packages/frontend/src/lib/attribution.ts b/packages/frontend/src/lib/attribution.ts index 7889e550..3def3263 100644 --- a/packages/frontend/src/lib/attribution.ts +++ b/packages/frontend/src/lib/attribution.ts @@ -9,15 +9,24 @@ * request as `attribution` and the backend records it for a newly created * account only. * - * Campaign-level only: ad click ids are reduced to `paid: true`, a referrer - * is its host, a path loses its query string, and anything that looks like - * an email address is dropped. The key set matches the backend's - * SignupTouchDto exactly: the global ValidationPipe answers an unknown key - * with a 400, so nothing else may be sent. + * Campaign-level, with one exception: a Google Ads click id (gclid, gbraid, + * wbraid) travels only with `ad_consent: 'granted'`, so a purchase can later + * be reported to Google Ads as an offline conversion. The site sends it only + * with consent; for the touch this page builds itself, the id is kept only if + * the visitor allowed marketing cookies in this app's own banner, and is + * otherwise reduced to `paid: true`. The backend drops any id without that + * consent again. A referrer is its host, a path loses its query string, and + * anything that looks like an email address is dropped. The key set matches + * the backend's SignupTouchDto exactly: the global ValidationPipe answers an + * unknown key with a 400, so nothing else may be sent. */ import { sessionStore } from './storage'; -export type Touch = { +export type AdConsent = 'granted' | 'denied' | 'unknown'; +export type ClickIdKey = 'gclid' | 'gbraid' | 'wbraid'; +export type ClickIds = Partial>; + +export type Touch = ClickIds & { utm_source?: string; utm_medium?: string; utm_campaign?: string; @@ -30,6 +39,8 @@ export type Touch = { landing_path?: string; ts?: number; captured_on?: 'site' | 'cloud'; + /** Click ids are present only next to 'granted'. */ + ad_consent?: AdConsent; }; export type SignupAttribution = { first_touch?: Touch; last_touch?: Touch }; @@ -44,7 +55,10 @@ const STRING_KEYS = [ 'gad_source', 'gad_campaignid', ] as const; -const CLICK_ID_PARAMS = ['gclid', 'gbraid', 'wbraid']; +/** Google Ads click ids, in the order Google prefers them. */ +export const CLICK_ID_KEYS: readonly ClickIdKey[] = ['gclid', 'gbraid', 'wbraid']; +const MAX_CLICK_ID = 150; +const AD_CONSENT_VALUES: readonly AdConsent[] = ['granted', 'denied', 'unknown']; function cleanString(value: unknown): string | undefined { if (typeof value !== 'string') return undefined; @@ -53,6 +67,16 @@ function cleanString(value: unknown): string | undefined { return v; } +/** A Google Ads click id, or undefined for anything that is not one. */ +export function cleanClickId(value: unknown): string | undefined { + if (typeof value !== 'string') return undefined; + const v = value.trim(); + if (!v || v.length > MAX_CLICK_ID || !/^[A-Za-z0-9_-]+$/.test(v)) return undefined; + return v; +} + +const hasClickId = (touch: Touch | undefined) => !!touch && CLICK_ID_KEYS.some((k) => touch[k]); + function cleanHost(value: unknown): string | undefined { if (typeof value !== 'string') return undefined; const host = value.trim().toLowerCase().replace(/^www\./, ''); @@ -82,9 +106,27 @@ export function sanitizeTouch(input: unknown): Touch | undefined { if (path) out.landing_path = path; if (typeof raw.ts === 'number' && Number.isFinite(raw.ts) && raw.ts > 0) out.ts = Math.floor(raw.ts); if (raw.captured_on === 'site' || raw.captured_on === 'cloud') out.captured_on = raw.captured_on; + if (AD_CONSENT_VALUES.includes(raw.ad_consent as AdConsent)) out.ad_consent = raw.ad_consent as AdConsent; + // Never a click id without consent; the backend enforces the same rule. + if (out.ad_consent === 'granted') { + for (const key of CLICK_ID_KEYS) { + const v = cleanClickId(raw[key]); + if (v) out[key] = v; + } + if (hasClickId(out)) out.paid = true; + } return Object.keys(out).length > 0 ? out : undefined; } +/** The touch without its click ids, for when this app's own banner says no. */ +function withoutClickIds(touch: Touch | undefined): Touch | undefined { + if (!touch) return undefined; + const rest: Touch = { ...touch }; + for (const key of CLICK_ID_KEYS) delete rest[key]; + if (rest.ad_consent) rest.ad_consent = 'denied'; + return rest; +} + /** A touch from its base64url JSON form; undefined for anything else. */ export function decodeTouch(value: string | null | undefined): Touch | undefined { if (!value || value.length > 4096) return undefined; @@ -103,8 +145,16 @@ export function decodeTouch(value: string | null | undefined): Touch | undefined * did not hand over. A referrer from this very host is navigation inside the * app, not a source; the marketing site is kept, since it then means the * visitor came from the site without its touch (script blocked, old link). + * + * An ad click id in the URL always makes the touch `paid`; the id itself is + * kept only when `adConsent` (this app's own cookie banner) is 'granted'. */ -export function touchFromUrl(href: string, referrer: string | null | undefined, now: number): Touch { +export function touchFromUrl( + href: string, + referrer: string | null | undefined, + now: number, + adConsent: AdConsent = 'unknown', +): Touch { const touch: Touch = { ts: now, captured_on: 'cloud' }; let url: URL | null = null; try { @@ -117,7 +167,16 @@ export function touchFromUrl(href: string, referrer: string | null | undefined, const v = cleanString(url.searchParams.get(key)); if (v) touch[key] = v; } - if (CLICK_ID_PARAMS.some((p) => url!.searchParams.has(p))) touch.paid = true; + if (CLICK_ID_KEYS.some((p) => url!.searchParams.has(p))) { + touch.paid = true; + touch.ad_consent = adConsent; + if (adConsent === 'granted') { + for (const key of CLICK_ID_KEYS) { + const v = cleanClickId(url.searchParams.get(key)); + if (v) touch[key] = v; + } + } + } const path = cleanPath(url.pathname); if (path) touch.landing_path = path; } @@ -135,11 +194,16 @@ export function touchFromUrl(href: string, referrer: string | null | undefined, return touch; } -/** What this page load says: the site's hand-over if present, else its own touch. */ +/** + * What this page load says: the site's hand-over if present, else its own + * touch. The site hands a click id over only with consent; should this app's + * own banner say 'denied' (consent withdrawn since), the ids are dropped. + */ export function attributionFromLanding( href: string, referrer: string | null | undefined, now: number, + adConsent: AdConsent = 'unknown', ): { attribution: SignupAttribution; handedOver: boolean } { let params: URLSearchParams | null = null; try { @@ -147,22 +211,26 @@ export function attributionFromLanding( } catch { params = null; } - const first = decodeTouch(params?.get('amcp_src')); + const allow = (t: Touch | undefined) => (adConsent === 'denied' ? withoutClickIds(t) : t); + const first = allow(decodeTouch(params?.get('amcp_src'))); if (first) { - const last = decodeTouch(params?.get('amcp_lt')); + const last = allow(decodeTouch(params?.get('amcp_lt'))); return { attribution: last ? { first_touch: first, last_touch: last } : { first_touch: first }, handedOver: true }; } - return { attribution: { first_touch: touchFromUrl(href, referrer, now) }, handedOver: false }; + return { attribution: { first_touch: touchFromUrl(href, referrer, now, adConsent) }, handedOver: false }; } -/** Same as the marketing site's check: analytics allowed in the cookie banner. */ -export function analyticsConsented(cookieHeader: string | null | undefined): boolean { - if (!cookieHeader) return false; +/** + * The categories accepted in the cookie banner (components/cookie-consent.tsx, + * cookie `cc_cookie`), or null when the visitor has not answered it. + */ +function consentedCategories(cookieHeader: string | null | undefined): string[] | null { + if (!cookieHeader) return null; const entry = cookieHeader .split(';') .map((c) => c.trim()) .find((c) => c.startsWith('cc_cookie=')); - if (!entry) return false; + if (!entry) return null; const raw = entry.slice('cc_cookie='.length); const candidates = [raw]; try { @@ -173,12 +241,30 @@ export function analyticsConsented(cookieHeader: string | null | undefined): boo for (const candidate of candidates) { try { const parsed = JSON.parse(candidate) as { categories?: unknown }; - return Array.isArray(parsed.categories) && parsed.categories.includes('analytics'); + return Array.isArray(parsed.categories) + ? parsed.categories.filter((c): c is string => typeof c === 'string') + : []; } catch { // try the next form } } - return false; + return null; +} + +/** Same as the marketing site's check: analytics allowed in the cookie banner. */ +export function analyticsConsented(cookieHeader: string | null | undefined): boolean { + return consentedCategories(cookieHeader)?.includes('analytics') ?? false; +} + +/** + * Ad consent is the banner's "marketing" category, the one that drives + * Google consent mode's ad_storage and ad_user_data. 'unknown' until the + * visitor has answered the banner. + */ +export function marketingConsent(cookieHeader: string | null | undefined): AdConsent { + const categories = consentedCategories(cookieHeader); + if (!categories) return 'unknown'; + return categories.includes('marketing') ? 'granted' : 'denied'; } // ── Keeping it for the length of the sign-up ───────────────────────────────── @@ -194,6 +280,28 @@ function consented(): boolean { } } +function adConsentNow(): AdConsent { + try { + return marketingConsent(document.cookie); + } catch { + return 'unknown'; + } +} + +/** + * The kept attribution, plus this page load's touch as the last one when that + * touch carries a click id and nothing kept does: the visitor landed from an + * ad before answering the cookie banner and allowed marketing cookies since. + */ +export function withLaterClickId(kept: SignupAttribution, fresh: Touch | undefined): SignupAttribution { + if (!fresh || !hasClickId(fresh) || hasClickId(kept.first_touch) || hasClickId(kept.last_touch)) return kept; + return kept.first_touch ? { first_touch: kept.first_touch, last_touch: fresh } : { first_touch: fresh }; +} + +function compact(a: SignupAttribution): SignupAttribution { + return { ...(a.first_touch && { first_touch: a.first_touch }), ...(a.last_touch && { last_touch: a.last_touch }) }; +} + /** * Record this page load, once per tab. A hand-over from the site always * wins; otherwise the first touch seen in this tab is kept, so moving between @@ -202,21 +310,30 @@ function consented(): boolean { * Held in memory, which is enough while the visitor stays in the app. Also * kept in sessionStorage (this tab only, gone when it closes) if they allowed * analytics, so it survives a reload: the same consent rule as the site. + * Called again right before the register request, so a marketing consent + * given or withdrawn in the meantime is applied to the click ids. */ export function captureSignupAttribution(): void { if (typeof window === 'undefined') return; try { + const adConsent = adConsentNow(); const { attribution, handedOver } = attributionFromLanding( window.location.href, document.referrer, Date.now(), + adConsent, ); - if (!handedOver && (memory ?? readStored())) { - memory = memory ?? readStored(); - return; + const kept = handedOver ? null : (memory ?? readStored()); + if (kept) { + const allowed = + adConsent === 'denied' + ? compact({ first_touch: withoutClickIds(kept.first_touch), last_touch: withoutClickIds(kept.last_touch) }) + : kept; + memory = withLaterClickId(allowed, attribution.first_touch); + } else { + memory = attribution; } - memory = attribution; - if (consented()) sessionStore.set(STORE_KEY, JSON.stringify(attribution)); + if (consented()) sessionStore.set(STORE_KEY, JSON.stringify(memory)); } catch { // attribution is never worth breaking the sign-up page for } diff --git a/packages/frontend/src/lib/marketing.ts b/packages/frontend/src/lib/marketing.ts index 526883c1..92f26375 100644 --- a/packages/frontend/src/lib/marketing.ts +++ b/packages/frontend/src/lib/marketing.ts @@ -1,3 +1,5 @@ +import { CLICK_ID_KEYS, cleanClickId, type ClickIds } from './attribution'; + const DEFAULT_MARKETING_URL = 'https://anythingmcp.com'; export function getMarketingUrl(): string { @@ -9,11 +11,22 @@ export function getMarketingUrl(): string { return DEFAULT_MARKETING_URL; } -export function buildPricingUrl(returnPath = '/settings/license/activate'): string { +/** + * The pricing page, with `return_url` back to this app. On AnythingMCP Cloud + * the caller may pass the user's own Google Ads click id (see usePricingUrl), + * which the pricing page puts into the checkout so the purchase can be + * reported to Google Ads; anything that is not a well-formed id is left out. + */ +export function buildPricingUrl(returnPath = '/settings/license/activate', clickIds?: ClickIds | null): string { const base = `${getMarketingUrl()}/pricing`; if (typeof window === 'undefined') return base; const returnUrl = `${window.location.origin}${returnPath}`; - return `${base}?return_url=${encodeURIComponent(returnUrl)}`; + let url = `${base}?return_url=${encodeURIComponent(returnUrl)}`; + for (const key of CLICK_ID_KEYS) { + const id = cleanClickId(clickIds?.[key]); + if (id) url += `&${key}=${id}`; + } + return url; } /** diff --git a/packages/frontend/src/lib/use-pricing-url.ts b/packages/frontend/src/lib/use-pricing-url.ts new file mode 100644 index 00000000..f0404c3d --- /dev/null +++ b/packages/frontend/src/lib/use-pricing-url.ts @@ -0,0 +1,52 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { auth } from './api'; +import { useAuth } from './auth-context'; +import type { ClickIds } from './attribution'; +import { buildPricingUrl } from './marketing'; + +/** + * One lookup per session, shared by every pricing link on the page. A failed + * lookup resolves to null: the link then simply goes without a click id. + */ +let cached: { token: string; ids: Promise } | null = null; + +function lookupClickIds(token: string): Promise { + if (cached?.token !== token) { + const ids = auth.attributionClickIds(token).then( + ({ gclid, gbraid, wbraid }) => ({ gclid, gbraid, wbraid }), + () => null, + ); + cached = { token, ids }; + } + return cached.ids; +} + +/** + * The pricing link (see buildPricingUrl), carrying the signed-in user's own + * Google Ads click id when AnythingMCP Cloud stored one with their ad consent, + * so the purchase can be reported to Google Ads as an offline conversion. + * + * Self-hosted builds never ask for it and never add one. Until the lookup + * answers, and whenever it fails, the plain link is returned. + */ +export function usePricingUrl(returnPath?: string): string { + const { token, deploymentMode, deploymentModeLoaded } = useAuth(); + const isCloud = deploymentModeLoaded && deploymentMode === 'cloud'; + const [found, setFound] = useState<{ token: string; ids: ClickIds | null } | null>(null); + + useEffect(() => { + if (!isCloud || !token) return; + let live = true; + lookupClickIds(token).then((ids) => { + if (live) setFound({ token, ids }); + }); + return () => { + live = false; + }; + }, [isCloud, token]); + + const clickIds = isCloud && token && found?.token === token ? found.ids : null; + return buildPricingUrl(returnPath, clickIds); +}