@flamingo-stack/openframe-frontend-core 0.0.483 → 0.0.484
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{chunk-LT2PU6SK.js → chunk-3MTN5XHZ.js} +2 -2
- package/dist/{chunk-JBC5DAI5.cjs → chunk-3VZJHXPE.cjs} +15 -15
- package/dist/{chunk-JBC5DAI5.cjs.map → chunk-3VZJHXPE.cjs.map} +1 -1
- package/dist/{chunk-6U7HIZSW.js → chunk-4PVTF36S.js} +2 -2
- package/dist/{chunk-VCOL4C2W.cjs → chunk-A7IPON2V.cjs} +3 -2
- package/dist/chunk-A7IPON2V.cjs.map +1 -0
- package/dist/{chunk-ZN56E4FX.cjs → chunk-AVN6WGIV.cjs} +29 -29
- package/dist/{chunk-ZN56E4FX.cjs.map → chunk-AVN6WGIV.cjs.map} +1 -1
- package/dist/{chunk-FEQW64Q5.cjs → chunk-CGEC7YIX.cjs} +73 -73
- package/dist/{chunk-FEQW64Q5.cjs.map → chunk-CGEC7YIX.cjs.map} +1 -1
- package/dist/{chunk-AGX565ZT.js → chunk-CWQACI4Z.js} +2 -2
- package/dist/{chunk-3VMCJK7A.js → chunk-DVYTY2GK.js} +3 -3
- package/dist/{chunk-WIITTL5I.js → chunk-F3UM6ZPO.js} +6 -6
- package/dist/{chunk-Y37XYIWA.js → chunk-G2L46BDG.js} +4 -4
- package/dist/{chunk-T2S7A5IU.js → chunk-HSVQ7W5M.js} +6 -6
- package/dist/{chunk-RFMH6MLM.cjs → chunk-HTHR7KWB.cjs} +32 -32
- package/dist/{chunk-RFMH6MLM.cjs.map → chunk-HTHR7KWB.cjs.map} +1 -1
- package/dist/{chunk-KDXEKD3K.cjs → chunk-IB4YMANE.cjs} +40 -40
- package/dist/{chunk-KDXEKD3K.cjs.map → chunk-IB4YMANE.cjs.map} +1 -1
- package/dist/{chunk-HAJQWSSU.js → chunk-KHDLNPAM.js} +3 -3
- package/dist/{chunk-ZPUZPJDV.js → chunk-KLYHZRAD.js} +2 -1
- package/dist/{chunk-RDCAJDX7.js → chunk-KN7MUVNB.js} +5 -5
- package/dist/{chunk-JKRX3JHT.js → chunk-OTJMDYZ5.js} +3 -3
- package/dist/{chunk-UO7TF67V.cjs → chunk-P3Q7KMPQ.cjs} +73 -73
- package/dist/{chunk-UO7TF67V.cjs.map → chunk-P3Q7KMPQ.cjs.map} +1 -1
- package/dist/{chunk-FM5KWLKU.cjs → chunk-PECGZWQR.cjs} +3 -3
- package/dist/{chunk-FM5KWLKU.cjs.map → chunk-PECGZWQR.cjs.map} +1 -1
- package/dist/{chunk-UTIW7O7R.cjs → chunk-SAVJIRUK.cjs} +5 -5
- package/dist/{chunk-UTIW7O7R.cjs.map → chunk-SAVJIRUK.cjs.map} +1 -1
- package/dist/{chunk-UAFJX7YV.cjs → chunk-SGUKPH7Z.cjs} +7 -7
- package/dist/{chunk-UAFJX7YV.cjs.map → chunk-SGUKPH7Z.cjs.map} +1 -1
- package/dist/{chunk-RJ4OJWT5.js → chunk-SSMNDK3Z.js} +98 -2
- package/dist/chunk-SSMNDK3Z.js.map +1 -0
- package/dist/{chunk-5YFXUXOV.cjs → chunk-UFPGVLHZ.cjs} +222 -126
- package/dist/chunk-UFPGVLHZ.cjs.map +1 -0
- package/dist/{chunk-DYLIBE6I.cjs → chunk-V36NQIAO.cjs} +12 -12
- package/dist/{chunk-DYLIBE6I.cjs.map → chunk-V36NQIAO.cjs.map} +1 -1
- package/dist/{chunk-GWJNTDXR.js → chunk-WZ3YAIQQ.js} +2 -2
- package/dist/{chunk-FIRIO2NB.js → chunk-X3KQ3B35.js} +5 -5
- package/dist/{chunk-CG5OX6WF.cjs → chunk-YSA7KE2P.cjs} +40 -40
- package/dist/{chunk-CG5OX6WF.cjs.map → chunk-YSA7KE2P.cjs.map} +1 -1
- package/dist/{chunk-AZZQLEKY.cjs → chunk-ZQXNTV66.cjs} +18 -18
- package/dist/{chunk-AZZQLEKY.cjs.map → chunk-ZQXNTV66.cjs.map} +1 -1
- package/dist/components/case-studies/index.cjs +9 -9
- package/dist/components/case-studies/index.js +3 -3
- package/dist/components/chat/index.cjs +3 -3
- package/dist/components/chat/index.js +2 -2
- package/dist/components/contact/index.cjs +4 -4
- package/dist/components/contact/index.js +3 -3
- package/dist/components/docs/index.cjs +6 -6
- package/dist/components/docs/index.js +5 -5
- package/dist/components/embeds/index.cjs +4 -4
- package/dist/components/embeds/index.js +3 -3
- package/dist/components/faq/index.cjs +5 -5
- package/dist/components/faq/index.js +4 -4
- package/dist/components/features/index.cjs +3 -3
- package/dist/components/features/index.js +2 -2
- package/dist/components/help-center-pages/index.cjs +22 -22
- package/dist/components/help-center-pages/index.js +13 -13
- package/dist/components/index.cjs +136 -136
- package/dist/components/index.js +11 -11
- package/dist/components/navigation/index.cjs +3 -3
- package/dist/components/navigation/index.js +2 -2
- package/dist/components/onboarding-guides/index.cjs +7 -7
- package/dist/components/onboarding-guides/index.js +6 -6
- package/dist/components/related-content/index.cjs +5 -5
- package/dist/components/related-content/index.js +4 -4
- package/dist/components/tickets/index.cjs +6 -6
- package/dist/components/tickets/index.js +5 -5
- package/dist/components/ui/index.cjs +3 -3
- package/dist/components/ui/index.js +2 -2
- package/dist/hooks/index.cjs +2 -2
- package/dist/hooks/index.js +1 -1
- package/dist/index.cjs +17 -3
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +16 -2
- package/dist/utils/first-touch-attribution.d.ts +97 -0
- package/dist/utils/first-touch-attribution.d.ts.map +1 -0
- package/dist/utils/index.cjs +96 -1
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -0
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +90 -2
- package/dist/utils/index.js.map +1 -1
- package/package.json +1 -1
- package/src/utils/__tests__/first-touch-attribution-persistence.test.ts +58 -0
- package/src/utils/__tests__/first-touch-attribution.test.ts +185 -0
- package/src/utils/first-touch-attribution.ts +200 -0
- package/src/utils/index.ts +1 -0
- package/dist/chunk-5YFXUXOV.cjs.map +0 -1
- package/dist/chunk-RJ4OJWT5.js.map +0 -1
- package/dist/chunk-VCOL4C2W.cjs.map +0 -1
- /package/dist/{chunk-LT2PU6SK.js.map → chunk-3MTN5XHZ.js.map} +0 -0
- /package/dist/{chunk-6U7HIZSW.js.map → chunk-4PVTF36S.js.map} +0 -0
- /package/dist/{chunk-AGX565ZT.js.map → chunk-CWQACI4Z.js.map} +0 -0
- /package/dist/{chunk-3VMCJK7A.js.map → chunk-DVYTY2GK.js.map} +0 -0
- /package/dist/{chunk-WIITTL5I.js.map → chunk-F3UM6ZPO.js.map} +0 -0
- /package/dist/{chunk-Y37XYIWA.js.map → chunk-G2L46BDG.js.map} +0 -0
- /package/dist/{chunk-T2S7A5IU.js.map → chunk-HSVQ7W5M.js.map} +0 -0
- /package/dist/{chunk-HAJQWSSU.js.map → chunk-KHDLNPAM.js.map} +0 -0
- /package/dist/{chunk-ZPUZPJDV.js.map → chunk-KLYHZRAD.js.map} +0 -0
- /package/dist/{chunk-RDCAJDX7.js.map → chunk-KN7MUVNB.js.map} +0 -0
- /package/dist/{chunk-JKRX3JHT.js.map → chunk-OTJMDYZ5.js.map} +0 -0
- /package/dist/{chunk-GWJNTDXR.js.map → chunk-WZ3YAIQQ.js.map} +0 -0
- /package/dist/{chunk-FIRIO2NB.js.map → chunk-X3KQ3B35.js.map} +0 -0
package/package.json
CHANGED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "write silently failed" path, isolated.
|
|
3
|
+
*
|
|
4
|
+
* Mocked at the ADAPTER seam rather than by patching Web Storage: this repo runs vitest on a
|
|
5
|
+
* Node that ships its own experimental `localStorage`/`sessionStorage`, so neither an instance
|
|
6
|
+
* spy on `window.sessionStorage` nor a `Storage.prototype` patch reliably intercepts what the
|
|
7
|
+
* adapter actually calls. The contract under test belongs to the adapter boundary anyway —
|
|
8
|
+
* `save()` returns `void` and swallows its errors, so the only way to know a write stuck is
|
|
9
|
+
* to read it back.
|
|
10
|
+
*/
|
|
11
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
12
|
+
|
|
13
|
+
const store: { value: unknown } = { value: null }
|
|
14
|
+
/** When true, `save()` accepts the call and drops the value — a blocked/full quota. */
|
|
15
|
+
let swallowWrites = false
|
|
16
|
+
|
|
17
|
+
vi.mock('../local-storage-adapter', () => ({
|
|
18
|
+
createLocalStorageAdapter: () => ({
|
|
19
|
+
load: () => store.value,
|
|
20
|
+
save: (value: unknown) => {
|
|
21
|
+
if (swallowWrites) return
|
|
22
|
+
store.value = value
|
|
23
|
+
},
|
|
24
|
+
clear: () => {
|
|
25
|
+
store.value = null
|
|
26
|
+
},
|
|
27
|
+
}),
|
|
28
|
+
}))
|
|
29
|
+
|
|
30
|
+
const { captureFirstTouchAttribution, getFirstTouchAttribution } = await import(
|
|
31
|
+
'../first-touch-attribution'
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
store.value = null
|
|
36
|
+
swallowWrites = false
|
|
37
|
+
Object.defineProperty(window, 'location', {
|
|
38
|
+
value: new URL('https://www.openmsp.ai/waitlist?utm_source=reddit'),
|
|
39
|
+
writable: true,
|
|
40
|
+
configurable: true,
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('captureFirstTouchAttribution persistence', () => {
|
|
45
|
+
it('returns the record when the write sticks', () => {
|
|
46
|
+
expect(captureFirstTouchAttribution().utm_source).toBe('reddit')
|
|
47
|
+
expect(getFirstTouchAttribution().utm_source).toBe('reddit')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('returns {} — NOT the record — when the write is swallowed', () => {
|
|
51
|
+
swallowWrites = true
|
|
52
|
+
// Returning the record here would hand the caller attribution that no later read can
|
|
53
|
+
// recover: it would be replayed on this page view and absent on the next, which is worse
|
|
54
|
+
// than never replaying it, because it looks like it worked.
|
|
55
|
+
expect(captureFirstTouchAttribution()).toEqual({})
|
|
56
|
+
expect(getFirstTouchAttribution()).toEqual({})
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
FIRST_TOUCH_ATTRIBUTION_KEY,
|
|
5
|
+
captureFirstTouchAttribution,
|
|
6
|
+
clearFirstTouchAttribution,
|
|
7
|
+
getFirstTouchAttribution,
|
|
8
|
+
parseAttributionFromUrl,
|
|
9
|
+
sanitizeLandingUrl,
|
|
10
|
+
withFirstTouchAttribution,
|
|
11
|
+
} from '../first-touch-attribution'
|
|
12
|
+
|
|
13
|
+
const LANDING = 'https://www.openmsp.ai/waitlist?utm_source=reddit&utm_medium=cpc'
|
|
14
|
+
|
|
15
|
+
function setHref(href: string) {
|
|
16
|
+
// jsdom's location is read-only; replace it for the duration of the test.
|
|
17
|
+
Object.defineProperty(window, 'location', {
|
|
18
|
+
value: new URL(href),
|
|
19
|
+
writable: true,
|
|
20
|
+
configurable: true,
|
|
21
|
+
})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* `window.localStorage` is NOT reliably present here: Node 26 ships an experimental global
|
|
26
|
+
* `localStorage` that requires `--localstorage-file` and shadows jsdom's implementation, so it
|
|
27
|
+
* resolves to undefined. `sessionStorage` — the only backend this module uses — is fine.
|
|
28
|
+
*/
|
|
29
|
+
const localStorageOrNull = (() => {
|
|
30
|
+
try {
|
|
31
|
+
return window.localStorage ?? null
|
|
32
|
+
} catch {
|
|
33
|
+
return null
|
|
34
|
+
}
|
|
35
|
+
})()
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
window.sessionStorage.clear()
|
|
39
|
+
localStorageOrNull?.clear()
|
|
40
|
+
setHref(LANDING)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('parseAttributionFromUrl', () => {
|
|
44
|
+
it('picks up the tracked params and nothing else', () => {
|
|
45
|
+
expect(parseAttributionFromUrl(`${LANDING}&unrelated=x`)).toEqual({
|
|
46
|
+
utm_source: 'reddit',
|
|
47
|
+
utm_medium: 'cpc',
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns {} for a URL with no attribution, and for garbage', () => {
|
|
52
|
+
expect(parseAttributionFromUrl('https://x.com/')).toEqual({})
|
|
53
|
+
expect(parseAttributionFromUrl('not a url')).toEqual({})
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
describe('sanitizeLandingUrl', () => {
|
|
58
|
+
it('keeps origin + pathname only', () => {
|
|
59
|
+
expect(sanitizeLandingUrl(LANDING)).toBe('https://www.openmsp.ai/waitlist')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('drops the fragment, where implicit-flow tokens live', () => {
|
|
63
|
+
expect(sanitizeLandingUrl('https://x.com/cb#access_token=SECRET&token_type=bearer')).toBe(
|
|
64
|
+
'https://x.com/cb',
|
|
65
|
+
)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it.each([
|
|
69
|
+
'https://x.com/auth?code=SECRET&state=abc',
|
|
70
|
+
'https://x.com/reset?token=SECRET',
|
|
71
|
+
'https://x.com/invite?email=jane%40acme.com',
|
|
72
|
+
])('drops the query entirely: %s', (url) => {
|
|
73
|
+
const out = sanitizeLandingUrl(url)!
|
|
74
|
+
expect(out).not.toContain('SECRET')
|
|
75
|
+
expect(out).not.toContain('jane')
|
|
76
|
+
expect(out).not.toContain('?')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('rejects non-http(s) schemes and garbage', () => {
|
|
80
|
+
expect(sanitizeLandingUrl('javascript:alert(1)')).toBeUndefined()
|
|
81
|
+
expect(sanitizeLandingUrl('not a url')).toBeUndefined()
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('captureFirstTouchAttribution', () => {
|
|
86
|
+
it('stores a sanitized record and reads back identically', () => {
|
|
87
|
+
const stored = captureFirstTouchAttribution()
|
|
88
|
+
expect(stored.utm_source).toBe('reddit')
|
|
89
|
+
expect(stored.landing_url).toBe('https://www.openmsp.ai/waitlist')
|
|
90
|
+
expect(stored.captured_at).toBeTruthy()
|
|
91
|
+
expect(getFirstTouchAttribution()).toEqual(stored)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('never persists a secret from the landing URL', () => {
|
|
95
|
+
setHref('https://x.com/cb?utm_source=ads&code=SUPERSECRET#access_token=ALSOSECRET')
|
|
96
|
+
captureFirstTouchAttribution()
|
|
97
|
+
const raw = window.sessionStorage.getItem(FIRST_TOUCH_ATTRIBUTION_KEY) ?? ''
|
|
98
|
+
expect(raw).not.toContain('SUPERSECRET')
|
|
99
|
+
expect(raw).not.toContain('ALSOSECRET')
|
|
100
|
+
expect(raw).toContain('ads')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('is FIRST touch: a later landing does not overwrite', () => {
|
|
104
|
+
const first = captureFirstTouchAttribution()
|
|
105
|
+
setHref('https://www.openmsp.ai/?utm_source=bookmark')
|
|
106
|
+
expect(captureFirstTouchAttribution()).toEqual(first)
|
|
107
|
+
expect(getFirstTouchAttribution().utm_source).toBe('reddit')
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
it('stores nothing when the landing URL carries no attribution', () => {
|
|
111
|
+
setHref('https://www.openmsp.ai/waitlist')
|
|
112
|
+
expect(captureFirstTouchAttribution()).toEqual({})
|
|
113
|
+
expect(window.sessionStorage.getItem(FIRST_TOUCH_ATTRIBUTION_KEY)).toBeNull()
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('uses sessionStorage, never localStorage', () => {
|
|
117
|
+
captureFirstTouchAttribution()
|
|
118
|
+
expect(window.sessionStorage.getItem(FIRST_TOUCH_ATTRIBUTION_KEY)).toBeTruthy()
|
|
119
|
+
// Skipped where the runtime does not expose localStorage (see the note above); the
|
|
120
|
+
// sessionStorage assertion is the load-bearing half either way.
|
|
121
|
+
if (localStorageOrNull) {
|
|
122
|
+
expect(localStorageOrNull.getItem(FIRST_TOUCH_ATTRIBUTION_KEY)).toBeNull()
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
describe('stored-shape validation', () => {
|
|
129
|
+
it.each([
|
|
130
|
+
['a planted unknown key', JSON.stringify({ utm_source: 'x', is_admin: 'true' })],
|
|
131
|
+
['a non-string value', JSON.stringify({ utm_source: { $ne: null } })],
|
|
132
|
+
['an array', JSON.stringify([{ utm_source: 'x' }])],
|
|
133
|
+
['a bare string', JSON.stringify('utm_source=x')],
|
|
134
|
+
['malformed JSON', '{oops'],
|
|
135
|
+
])('rejects %s rather than replaying it', (_label, raw) => {
|
|
136
|
+
window.sessionStorage.setItem(FIRST_TOUCH_ATTRIBUTION_KEY, raw)
|
|
137
|
+
expect(getFirstTouchAttribution()).toEqual({})
|
|
138
|
+
// The important half: nothing unexpected reaches a submit body.
|
|
139
|
+
expect(withFirstTouchAttribution({ email: 'a@b.com' })).toEqual({ email: 'a@b.com' })
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('accepts a well-formed record', () => {
|
|
143
|
+
window.sessionStorage.setItem(
|
|
144
|
+
FIRST_TOUCH_ATTRIBUTION_KEY,
|
|
145
|
+
JSON.stringify({ utm_source: 'reddit', captured_at: '2026-01-01T00:00:00.000Z' }),
|
|
146
|
+
)
|
|
147
|
+
expect(getFirstTouchAttribution().utm_source).toBe('reddit')
|
|
148
|
+
})
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
describe('withFirstTouchAttribution', () => {
|
|
152
|
+
it('merges stored attribution into the body', () => {
|
|
153
|
+
captureFirstTouchAttribution()
|
|
154
|
+
expect(withFirstTouchAttribution({ email: 'a@b.com' })).toMatchObject({
|
|
155
|
+
email: 'a@b.com',
|
|
156
|
+
utm_source: 'reddit',
|
|
157
|
+
utm_medium: 'cpc',
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('lets an explicit body value WIN over the stored one', () => {
|
|
162
|
+
captureFirstTouchAttribution()
|
|
163
|
+
expect(withFirstTouchAttribution({ utm_source: 'typed-by-form' }).utm_source).toBe(
|
|
164
|
+
'typed-by-form',
|
|
165
|
+
)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('strips empty values so a blank never reaches the API', () => {
|
|
169
|
+
expect(withFirstTouchAttribution({ email: 'a@b.com', phone: '', name: null as any })).toEqual({
|
|
170
|
+
email: 'a@b.com',
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('is a no-op passthrough when nothing was captured', () => {
|
|
175
|
+
expect(withFirstTouchAttribution({ email: 'a@b.com' })).toEqual({ email: 'a@b.com' })
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
describe('clearFirstTouchAttribution', () => {
|
|
180
|
+
it('removes the record', () => {
|
|
181
|
+
captureFirstTouchAttribution()
|
|
182
|
+
clearFirstTouchAttribution()
|
|
183
|
+
expect(getFirstTouchAttribution()).toEqual({})
|
|
184
|
+
})
|
|
185
|
+
})
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-touch attribution capture.
|
|
3
|
+
*
|
|
4
|
+
* THE PROBLEM. Server-side UTM extraction reads the request body and, failing that, UTM
|
|
5
|
+
* params on the `referer` header. But `referer` is the page the form was submitted FROM, and
|
|
6
|
+
* a visitor who lands on `/?utm_source=reddit` and then navigates before converting arrives
|
|
7
|
+
* with a clean referer. Measured on the live DB: waitlist rows from the last 90 days have
|
|
8
|
+
* `ip_address` on 350/350 and `utm_source` on **0/350**. The parameters are not being lost in
|
|
9
|
+
* transit; they are gone by submit time.
|
|
10
|
+
*
|
|
11
|
+
* THE FIX. Capture the landing URL's attribution ONCE, on first page view, and replay it into
|
|
12
|
+
* every submit body from then on. The server already prefers body-supplied UTMs over the
|
|
13
|
+
* referer, so nothing changes server-side.
|
|
14
|
+
*
|
|
15
|
+
* FIRST touch, not last: `capture()` never overwrites an existing record, so a visitor who
|
|
16
|
+
* lands on an ad and later arrives via a bookmark keeps the ad attribution.
|
|
17
|
+
*
|
|
18
|
+
* Built on `createLocalStorageAdapter` so there is no hand-rolled Web Storage access and no
|
|
19
|
+
* module-scope `window` — `./utils` is imported by server-safe consumers, so touching
|
|
20
|
+
* `window` at module scope would break SSR.
|
|
21
|
+
*
|
|
22
|
+
* SESSION-SCOPED, deliberately and without an opt-out. Three reasons:
|
|
23
|
+
* - first-touch attribution for a single visit is the useful signal;
|
|
24
|
+
* - a session record does not persist an identifier across visits, which keeps this out of
|
|
25
|
+
* consent-banner territory;
|
|
26
|
+
* - `sessionStorage` is per-tab, so `load()`-then-`save()` cannot race another tab.
|
|
27
|
+
* `localStorage` is shared, so two tabs opening simultaneously could both observe no
|
|
28
|
+
* record and the second write would silently replace the first — breaking the one
|
|
29
|
+
* invariant this module exists to hold. Web Storage has no compare-and-swap, so that race
|
|
30
|
+
* cannot be closed reliably; the option is therefore not offered rather than offered with
|
|
31
|
+
* a caveat. If cross-session persistence is ever needed it belongs in a server-side
|
|
32
|
+
* cookie, where it can be written atomically.
|
|
33
|
+
*
|
|
34
|
+
* NEVER STORES A RAW URL. See `sanitizeLandingUrl`.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { createLocalStorageAdapter } from './local-storage-adapter'
|
|
38
|
+
|
|
39
|
+
/** Attribution parameters worth carrying from the landing URL to the submit body. */
|
|
40
|
+
export interface FirstTouchAttribution {
|
|
41
|
+
utm_source?: string
|
|
42
|
+
utm_medium?: string
|
|
43
|
+
utm_campaign?: string
|
|
44
|
+
utm_content?: string
|
|
45
|
+
utm_term?: string
|
|
46
|
+
/** Reddit Ads click id. */
|
|
47
|
+
rdt_cid?: string
|
|
48
|
+
/**
|
|
49
|
+
* The landing PAGE — `origin + pathname` only. Never the query string or fragment.
|
|
50
|
+
* See `sanitizeLandingUrl` for why.
|
|
51
|
+
*/
|
|
52
|
+
landing_url?: string
|
|
53
|
+
/** ISO timestamp of first capture, for debugging stale records. */
|
|
54
|
+
captured_at?: string
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const FIRST_TOUCH_ATTRIBUTION_KEY = 'of.first_touch_attribution'
|
|
58
|
+
|
|
59
|
+
const TRACKED_PARAMS = [
|
|
60
|
+
'utm_source',
|
|
61
|
+
'utm_medium',
|
|
62
|
+
'utm_campaign',
|
|
63
|
+
'utm_content',
|
|
64
|
+
'utm_term',
|
|
65
|
+
'rdt_cid',
|
|
66
|
+
] as const
|
|
67
|
+
|
|
68
|
+
/** Every key this module is allowed to store or replay. */
|
|
69
|
+
const ALLOWED_KEYS: ReadonlySet<string> = new Set<string>([
|
|
70
|
+
...TRACKED_PARAMS,
|
|
71
|
+
'landing_url',
|
|
72
|
+
'captured_at',
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reduce a URL to `origin + pathname`.
|
|
77
|
+
*
|
|
78
|
+
* The raw `window.location.href` must NEVER be stored or replayed. It routinely carries
|
|
79
|
+
* things that have nothing to do with attribution and everything to do with security: OAuth
|
|
80
|
+
* `code`/`state`, magic-link and password-reset tokens, `access_token` in the fragment,
|
|
81
|
+
* email addresses and other PII in query params. This value is spread into every submit body,
|
|
82
|
+
* so anything kept here would be POSTed to our API and forwarded on to HubSpot — a token in
|
|
83
|
+
* a CRM record is a token in every integration downstream of it.
|
|
84
|
+
*
|
|
85
|
+
* The path alone answers the only question worth asking ("which page did they land on"), so
|
|
86
|
+
* the query and fragment are dropped wholesale rather than filtered. An allowlist of "safe"
|
|
87
|
+
* params would need updating every time a new auth flow adds one; dropping everything cannot
|
|
88
|
+
* go stale.
|
|
89
|
+
*/
|
|
90
|
+
export function sanitizeLandingUrl(url: string): string | undefined {
|
|
91
|
+
try {
|
|
92
|
+
const parsed = new URL(url)
|
|
93
|
+
// Non-http(s) schemes have no meaningful landing page.
|
|
94
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return undefined
|
|
95
|
+
return `${parsed.origin}${parsed.pathname}`
|
|
96
|
+
} catch {
|
|
97
|
+
return undefined
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function makeAdapter() {
|
|
102
|
+
return createLocalStorageAdapter<FirstTouchAttribution>({
|
|
103
|
+
key: FIRST_TOUCH_ATTRIBUTION_KEY,
|
|
104
|
+
backend: 'session',
|
|
105
|
+
logTag: '[first-touch-attribution]',
|
|
106
|
+
// Anything running on this origin can write to sessionStorage, and whatever `load()`
|
|
107
|
+
// returns is spread into submit bodies. So the stored shape is validated, not assumed:
|
|
108
|
+
// allowlisted keys only, string values only. Without this a malformed or planted record
|
|
109
|
+
// would inject arbitrary keys into an API payload.
|
|
110
|
+
validate: (parsed): parsed is FirstTouchAttribution => {
|
|
111
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) return false
|
|
112
|
+
return Object.entries(parsed as Record<string, unknown>).every(
|
|
113
|
+
([key, value]) => ALLOWED_KEYS.has(key) && typeof value === 'string',
|
|
114
|
+
)
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Parse the tracked parameters out of a URL. Returns `{}` when none are present. */
|
|
120
|
+
export function parseAttributionFromUrl(url: string): FirstTouchAttribution {
|
|
121
|
+
const out: FirstTouchAttribution = {}
|
|
122
|
+
try {
|
|
123
|
+
const parsed = new URL(url)
|
|
124
|
+
for (const param of TRACKED_PARAMS) {
|
|
125
|
+
const value = parsed.searchParams.get(param)
|
|
126
|
+
if (value && value.trim()) out[param] = value.trim()
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
return {}
|
|
130
|
+
}
|
|
131
|
+
return out
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Record the landing URL's attribution if nothing is stored yet.
|
|
136
|
+
*
|
|
137
|
+
* Idempotent and first-touch-preserving: a later call with different parameters is ignored.
|
|
138
|
+
* Safe to call on every page view, and a no-op during SSR.
|
|
139
|
+
*
|
|
140
|
+
* @returns what is VERIFIABLY stored after the call — a pre-existing record, the new record
|
|
141
|
+
* read back from storage, or `{}` when nothing was captured or the write did not stick.
|
|
142
|
+
*/
|
|
143
|
+
export function captureFirstTouchAttribution(options: { url?: string } = {}): FirstTouchAttribution {
|
|
144
|
+
if (typeof window === 'undefined') return {}
|
|
145
|
+
|
|
146
|
+
const adapter = makeAdapter()
|
|
147
|
+
const existing = adapter.load()
|
|
148
|
+
if (existing && Object.keys(existing).length > 0) return existing
|
|
149
|
+
|
|
150
|
+
const href = options.url ?? window.location.href
|
|
151
|
+
const parsed = parseAttributionFromUrl(href)
|
|
152
|
+
if (Object.keys(parsed).length === 0) return {}
|
|
153
|
+
|
|
154
|
+
const landingUrl = sanitizeLandingUrl(href)
|
|
155
|
+
const record: FirstTouchAttribution = {
|
|
156
|
+
...parsed,
|
|
157
|
+
...(landingUrl ? { landing_url: landingUrl } : {}),
|
|
158
|
+
captured_at: new Date().toISOString(),
|
|
159
|
+
}
|
|
160
|
+
adapter.save(record)
|
|
161
|
+
|
|
162
|
+
// Confirm it PERSISTED before reporting success. `save()` returns void and swallows its
|
|
163
|
+
// errors, so in blocked or quota-exceeded storage (Safari private mode, a full quota) the
|
|
164
|
+
// write silently no-ops. Returning `record` there would hand the caller data that no
|
|
165
|
+
// subsequent `getFirstTouchAttribution()` can recover — the caller would replay
|
|
166
|
+
// attribution on this page view and none on the next, which is worse than replaying none
|
|
167
|
+
// at all because it looks like it worked.
|
|
168
|
+
return adapter.load() ?? {}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Read the stored attribution. `{}` when nothing was captured or during SSR. */
|
|
172
|
+
export function getFirstTouchAttribution(): FirstTouchAttribution {
|
|
173
|
+
if (typeof window === 'undefined') return {}
|
|
174
|
+
return makeAdapter().load() ?? {}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Merge stored attribution into a submit body.
|
|
179
|
+
*
|
|
180
|
+
* Values already on the body WIN — a form that collected a real value explicitly should not
|
|
181
|
+
* be overwritten by a stored one. Spread this into every submit payload:
|
|
182
|
+
*
|
|
183
|
+
* body: JSON.stringify(withFirstTouchAttribution({ email, name }))
|
|
184
|
+
*/
|
|
185
|
+
export function withFirstTouchAttribution<T extends Record<string, any>>(
|
|
186
|
+
body: T,
|
|
187
|
+
): T & FirstTouchAttribution {
|
|
188
|
+
const stored = getFirstTouchAttribution()
|
|
189
|
+
const merged: Record<string, any> = { ...stored, ...body }
|
|
190
|
+
for (const [k, v] of Object.entries(merged)) {
|
|
191
|
+
if (v === undefined || v === null || v === '') delete merged[k]
|
|
192
|
+
}
|
|
193
|
+
return merged as T & FirstTouchAttribution
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Clear the stored record. Exposed for tests and for a consent-withdrawal path. */
|
|
197
|
+
export function clearFirstTouchAttribution(): void {
|
|
198
|
+
if (typeof window === 'undefined') return
|
|
199
|
+
makeAdapter().clear()
|
|
200
|
+
}
|
package/src/utils/index.ts
CHANGED