diff --git a/docs/architecture.md b/docs/architecture.md index 1043df2b..301df9bb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -301,13 +301,17 @@ through a comparison page and buys a fortnight later after typing the domain is credited to the comparison page rather than to "direct". A visit with neither is not recorded at all, precisely so it cannot take that slot. +The one exception is a link from the Mac app (`?ref=app-…`). That reader was +acquired when they installed the app, and the screen they clicked from, such as +a paid feature's gate, is what brought them to buy, so it replaces the record. + The record lives in `localStorage` under `tablepro:attribution`, because this app has no session and sets no cookies. It is disclosed on `/privacy`. -What the backend does with it today is **nothing**: the platform ignores the -field and passes none of it to Polar or Lemon Squeezy. The privacy policy says -exactly that. The `checkout_started` -analytics event is the only part of this that reports anything. +The backend passes `source`, `medium`, `campaign`, `term`, `content` and +`referrer` to the payment provider as checkout metadata (`utm_source` and so on, +the keys tablepro-cloud reads), so they come back on the order webhook and stay +with the order. `landing_page` and `first_seen_at` are not kept. If the backend ever starts keeping it, three things hold, and the privacy policy changes first: diff --git a/resources/js/lib/attribution.ts b/resources/js/lib/attribution.ts index 4ffe52e9..9b582b6a 100644 --- a/resources/js/lib/attribution.ts +++ b/resources/js/lib/attribution.ts @@ -188,15 +188,31 @@ function hasExpired(attribution: Attribution, now: Date): boolean { return now.getTime() - first > TTL_MS; } +/** + * The `ref` the Mac app puts on its links: `app-support`, `app-about`, + * `app-gate-compare-sync` and the like (`SupportLinks.swift`). + */ +const APP_REF = /^app-[a-z0-9-]+$/; + /** * First-touch resolution: a stored record inside the window is returned * unchanged, and only a lapsed or absent one gives way to this visit. * + * The one exception is a link from the Mac app. Its reader was acquired when + * they installed the app; the screen they clicked from, such as a paid + * feature's gate, is what brought them to buy, so it replaces the stored record. + * * Returns the same object reference it was given when nothing changed, which is * what lets the caller skip a pointless write on every page load. */ export function resolveAttribution(stored: Attribution | null, visit: Visit): Attribution | null { if (stored !== null && ! hasExpired(stored, visit.now)) { + const current = readVisit(visit); + + if (current?.source !== undefined && APP_REF.test(current.source)) { + return current; + } + return stored; } diff --git a/tests/js/attribution.test.ts b/tests/js/attribution.test.ts index cfcb8d00..f782b848 100644 --- a/tests/js/attribution.test.ts +++ b/tests/js/attribution.test.ts @@ -127,6 +127,24 @@ test('keeps the first touch when the reader returns direct', () => { assert.equal(resolveAttribution(first, visit('https://tablepro.app/')), first); }); +test('lets a link from the Mac app replace the first touch', () => { + const first: Attribution = { source: 'hn', landing_page: '/blog/tablepro-0-69', first_seen_at: daysBefore(NOW, 14) }; + + const resolved = resolveAttribution(first, visit('https://tablepro.app/?ref=app-gate-compare-sync#pricing', 'https://www.google.com/')); + + assert.equal(resolved?.source, 'app-gate-compare-sync'); + assert.equal(resolved?.landing_page, '/'); + assert.equal(resolved?.first_seen_at, NOW.toISOString()); +}); + +test('keeps the first touch for a ref that only looks like the app', () => { + const first: Attribution = { source: 'hn', landing_page: '/', first_seen_at: daysBefore(NOW, 1) }; + + assert.equal(resolveAttribution(first, visit('https://tablepro.app/?ref=getapps.cafe')), first); + assert.equal(resolveAttribution(first, visit('https://tablepro.app/?ref=app')), first); + assert.equal(resolveAttribution(first, visit('https://tablepro.app/?ref=APP-SUPPORT')), first); +}); + test('lets a lapsed first touch give way to the current visit', () => { const stale: Attribution = { source: 'hn', landing_page: '/', first_seen_at: daysBefore(NOW, ATTRIBUTION_TTL_DAYS + 1) };