@affiliateo/web 4.4.1 → 4.6.0

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/README.md CHANGED
@@ -1,238 +1,238 @@
1
- # @affiliateo/web
2
-
3
- Affiliateo web SDK. track affiliate visits and pass attribution metadata to your Stripe checkout.
4
-
5
- ## Install
6
-
7
- ```bash
8
- npm install @affiliateo/web
9
- ```
10
-
11
- ## Setup
12
-
13
- 1. Connect your Stripe account on the Affiliateo dashboard
14
- 2. Wrap your app in `<AffiliateoProvider>` with your campaign ID
15
- 3. At checkout time, call `getAttribution()` and pass the result to Stripe metadata
16
-
17
- ### Step 1. wrap your app
18
-
19
- ```tsx
20
- // app/layout.tsx (Next.js App Router) or _app.tsx (Pages Router)
21
- import { AffiliateoProvider } from '@affiliateo/web';
22
-
23
- export default function RootLayout({ children }) {
24
- return (
25
- <AffiliateoProvider campaignId="YOUR_CAMPAIGN_ID">
26
- {children}
27
- </AffiliateoProvider>
28
- );
29
- }
30
- ```
31
-
32
- The provider:
33
- - Reads `?ref=` from the URL when a visitor arrives
34
- - Stores attribution in `localStorage` so it survives navigation
35
- - Sends pageview events to Affiliateo for click tracking
36
-
37
- ### Step 2. in your checkout button (client-side)
38
-
39
- ```tsx
40
- 'use client'
41
- import { getAttribution } from '@affiliateo/web';
42
-
43
- async function handleCheckout() {
44
- const attribution = getAttribution();
45
-
46
- const res = await fetch('/api/checkout', {
47
- method: 'POST',
48
- body: JSON.stringify({ attribution }),
49
- });
50
-
51
- const { url } = await res.json();
52
- window.location.href = url;
53
- }
54
- ```
55
-
56
- `getAttribution()` reads `affiliateo_ref`, `affiliateo_visitor_id`, and `affiliateo_campaign_id` from `localStorage` and returns them as an object, ready to pass to Stripe.
57
-
58
- ### Step 3. in your checkout API route (server-side)
59
-
60
- ```ts
61
- // app/api/checkout/route.ts
62
- export async function POST(req) {
63
- const { attribution } = await req.json();
64
-
65
- const session = await stripe.checkout.sessions.create({
66
- line_items: [{ price: 'price_xxx', quantity: 1 }],
67
- mode: 'payment',
68
- success_url: 'https://yoursite.com/success',
69
- cancel_url: 'https://yoursite.com/cancel',
70
- metadata: attribution,
71
- });
72
-
73
- return Response.json({ url: session.url });
74
- }
75
- ```
76
-
77
- That's it. Affiliateo's webhook will automatically read the metadata and credit the right affiliate when the payment succeeds.
78
-
79
- ## How it works
80
-
81
- 1. Visitor clicks affiliate link → `yoursite.com?ref=pinkproton`
82
- 2. `<AffiliateoProvider>` reads `?ref=pinkproton` from the URL and saves it in `localStorage`
83
- 3. As the visitor browses, attribution stays in their browser
84
- 4. At checkout, `getAttribution()` reads localStorage and returns the attribution object
85
- 5. Your server passes it as Stripe metadata when creating the checkout session
86
- 6. When Stripe fires the `checkout.session.completed` webhook, Affiliateo reads the metadata and credits the affiliate
87
-
88
- ## Why `localStorage` and not cookies?
89
-
90
- - **Not blocked by Safari ITP or most privacy tools**
91
- - **No CSRF concerns**
92
-
93
- ## GDPR / ePrivacy. merchant responsibility
94
-
95
- The SDK writes persistent identifiers (`affiliateo_visitor_id`, `affiliateo_ref`, `affiliateo_campaign_id`) to the visitor's `localStorage` and sends pageview events that include the visitor's IP, user agent, and approximate geolocation to Affiliateo's servers. Under EU ePrivacy (Article 5(3)) and GDPR, persistent identifiers stored on a visitor's device require prior consent the same way cookies do.
96
-
97
- If you serve visitors in the EU/EEA, UK, or any jurisdiction with equivalent rules, **you are responsible for obtaining consent before this script loads**, the same way you would for Google Analytics, Meta Pixel, or any other analytics tool. Typical approaches:
98
-
99
- - Gate the `<AffiliateoProvider>` (or the `<script src="…/t.js">` tag) behind your cookie banner's "accept" callback so it only loads after consent.
100
- - Document Affiliateo in your privacy policy as a processor (see our [Data Processing Agreement](https://affiliateo.com/dpa)) and list the categories of data collected.
101
-
102
- Affiliateo acts as your data processor for visitor analytics. You are the controller and decide the lawful basis for processing your visitors' data.
103
-
104
- ## Alternative: vanilla HTML (no React)
105
-
106
- If you're not using React, use the tracking script directly instead of the provider:
107
-
108
- ```html
109
- <script defer
110
- data-campaign="YOUR_CAMPAIGN_ID"
111
- src="https://affiliateo.com/t.js"
112
- ></script>
113
- ```
114
-
115
- Then in your checkout flow (vanilla JS):
116
-
117
- ```js
118
- const attribution = {
119
- affiliateo_ref: localStorage.getItem('affiliateo_ref'),
120
- affiliateo_visitor_id: localStorage.getItem('affiliateo_visitor_id'),
121
- affiliateo_campaign_id: localStorage.getItem('affiliateo_campaign_id'),
122
- };
123
-
124
- // Send to your server with the rest of your checkout payload
125
- fetch('/api/checkout', {
126
- method: 'POST',
127
- body: JSON.stringify({ attribution }),
128
- });
129
- ```
130
-
131
- ## Track conversion goals
132
-
133
- Pageviews auto-track. For specific moments that matter to your funnel
134
- (`signup_completed`, `trial_started`, `initiate_checkout`, etc.), use
135
- `track()` or HTML data attributes.
136
-
137
- ### From React
138
-
139
- ```tsx
140
- 'use client'
141
- import { useEffect } from 'react'
142
- import { useAffiliateRef } from '@affiliateo/web'
143
-
144
- export default function SignupSuccess() {
145
- const { track } = useAffiliateRef()
146
- useEffect(() => {
147
- track('signup_completed', { plan: 'free' })
148
- }, [])
149
- return <div>Welcome!</div>
150
- }
151
- ```
152
-
153
- ### HTML data attributes (zero JS, works everywhere)
154
-
155
- ```html
156
- <button data-affiliateo-goal="initiate_checkout"
157
- data-affiliateo-goal-price="49"
158
- data-affiliateo-goal-plan="pro">
159
- Subscribe
160
- </button>
161
- ```
162
-
163
- The SDK auto-fires the goal on click. Optional `data-affiliateo-goal-*`
164
- attributes become metadata (kebab-case → snake_case, up to 10 params).
165
-
166
- ### Scroll-depth tracking
167
-
168
- ```html
169
- <section data-affiliateo-scroll="pricing_viewed">
170
- ...your pricing content...
171
- </section>
172
- ```
173
-
174
- Fires the first time any pixel of the section overlaps the viewport.
175
- Maximum permissiveness — if the user scrolled enough to see even a sliver,
176
- it counts. Earlier versions required 50% visibility, which was unreachable
177
- for tall sections (a pricing page with comparison tables silently dropped
178
- out of every funnel). Great for landing pages where you want to know
179
- which sections convert.
180
-
181
- ### Goal naming rules
182
-
183
- Lowercase letters, digits, underscores, hyphens, colons. Max 64 chars.
184
- Metadata bounded to 4KB JSON.
185
-
186
- ## Identify signed-in users (cross-device stitching)
187
-
188
- Without `identify()`, the same person browsing on phone and buying on
189
- laptop counts as two visitors. Call it once after sign-in:
190
-
191
- ```tsx
192
- 'use client'
193
- import { useEffect } from 'react'
194
- import { useAffiliateRef } from '@affiliateo/web'
195
- import { useUser } from './your-auth'
196
-
197
- export default function AuthBoundary({ children }) {
198
- const { identify } = useAffiliateRef()
199
- const user = useUser()
200
- useEffect(() => {
201
- if (user) identify(user.id)
202
- }, [user])
203
- return <>{children}</>
204
- }
205
- ```
206
-
207
- Idempotent. safe to fire on every page load while a user is signed in.
208
- **user_id only.** The SDK does not accept, collect, or transmit email
209
- or any other PII.
210
-
211
- ## API
212
-
213
- ### `<AffiliateoProvider campaignId="..." apiUrl?="...">`
214
-
215
- React provider. Wrap your app once. Reads `?ref=` from URL, persists to localStorage, sends pageview events, sets up data-attribute + scroll listeners.
216
-
217
- Props:
218
- - `campaignId` (required): your Affiliateo campaign ID
219
- - `apiUrl` (optional): defaults to `https://affiliateo.com`
220
-
221
- ### `useAffiliateRef()`
222
-
223
- React hook that returns `{ refCode, visitorId, track, identify }`.
224
-
225
- - `track(eventName, metadata?)`. fire a custom goal
226
- - `identify(userId)`. link this visitor to a merchant user_id (no PII)
227
-
228
- ### `getAttribution()`
229
-
230
- Client-side function. Returns an object with `affiliateo_ref`, `affiliateo_visitor_id`, and `affiliateo_campaign_id` from localStorage. Pass directly as Stripe `metadata`.
231
-
232
- ## Framework support
233
-
234
- Works with any JavaScript framework or vanilla JS:
235
- - **Next.js** (App Router & Pages Router)
236
- - **Remix** / **React Router**
237
- - **Vite + React**
238
- - **Vanilla JS/HTML** (use the `<script>` tag instead)
1
+ # @affiliateo/web
2
+
3
+ Affiliateo web SDK. track affiliate visits and pass attribution metadata to your Stripe checkout.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @affiliateo/web
9
+ ```
10
+
11
+ ## Setup
12
+
13
+ 1. Connect your Stripe account on the Affiliateo dashboard
14
+ 2. Wrap your app in `<AffiliateoProvider>` with your app ID
15
+ 3. At checkout time, call `getAttribution()` and pass the result to Stripe metadata
16
+
17
+ ### Step 1. wrap your app
18
+
19
+ ```tsx
20
+ // app/layout.tsx (Next.js App Router) or _app.tsx (Pages Router)
21
+ import { AffiliateoProvider } from '@affiliateo/web';
22
+
23
+ export default function RootLayout({ children }) {
24
+ return (
25
+ <AffiliateoProvider appId="YOUR_APP_ID">
26
+ {children}
27
+ </AffiliateoProvider>
28
+ );
29
+ }
30
+ ```
31
+
32
+ The provider:
33
+ - Reads `?ref=` from the URL when a visitor arrives
34
+ - Stores attribution in `localStorage` so it survives navigation
35
+ - Sends pageview events to Affiliateo for click tracking
36
+
37
+ ### Step 2. in your checkout button (client-side)
38
+
39
+ ```tsx
40
+ 'use client'
41
+ import { getAttribution } from '@affiliateo/web';
42
+
43
+ async function handleCheckout() {
44
+ const attribution = getAttribution();
45
+
46
+ const res = await fetch('/api/checkout', {
47
+ method: 'POST',
48
+ body: JSON.stringify({ attribution }),
49
+ });
50
+
51
+ const { url } = await res.json();
52
+ window.location.href = url;
53
+ }
54
+ ```
55
+
56
+ `getAttribution()` reads the stored attribution from `localStorage` and returns `affiliateo_ref`, `affiliateo_visitor_id`, and `affiliateo_app_id` as an object, ready to pass to Stripe.
57
+
58
+ ### Step 3. in your checkout API route (server-side)
59
+
60
+ ```ts
61
+ // app/api/checkout/route.ts
62
+ export async function POST(req) {
63
+ const { attribution } = await req.json();
64
+
65
+ const session = await stripe.checkout.sessions.create({
66
+ line_items: [{ price: 'price_xxx', quantity: 1 }],
67
+ mode: 'payment',
68
+ success_url: 'https://yoursite.com/success',
69
+ cancel_url: 'https://yoursite.com/cancel',
70
+ metadata: attribution,
71
+ });
72
+
73
+ return Response.json({ url: session.url });
74
+ }
75
+ ```
76
+
77
+ That's it. Affiliateo's webhook will automatically read the metadata and credit the right affiliate when the payment succeeds.
78
+
79
+ ## How it works
80
+
81
+ 1. Visitor clicks affiliate link → `yoursite.com?ref=pinkproton`
82
+ 2. `<AffiliateoProvider>` reads `?ref=pinkproton` from the URL and saves it in `localStorage`
83
+ 3. As the visitor browses, attribution stays in their browser
84
+ 4. At checkout, `getAttribution()` reads localStorage and returns the attribution object
85
+ 5. Your server passes it as Stripe metadata when creating the checkout session
86
+ 6. When Stripe fires the `checkout.session.completed` webhook, Affiliateo reads the metadata and credits the affiliate
87
+
88
+ ## Why `localStorage` and not cookies?
89
+
90
+ - **Not blocked by Safari ITP or most privacy tools**
91
+ - **No CSRF concerns**
92
+
93
+ ## GDPR / ePrivacy. merchant responsibility
94
+
95
+ The SDK writes persistent identifiers (`affiliateo_visitor_id`, `affiliateo_ref`, `affiliateo_campaign_id`) to the visitor's `localStorage` and sends pageview events that include the visitor's IP, user agent, and approximate geolocation to Affiliateo's servers. Under EU ePrivacy (Article 5(3)) and GDPR, persistent identifiers stored on a visitor's device require prior consent the same way cookies do.
96
+
97
+ If you serve visitors in the EU/EEA, UK, or any jurisdiction with equivalent rules, **you are responsible for obtaining consent before this script loads**, the same way you would for Google Analytics, Meta Pixel, or any other analytics tool. Typical approaches:
98
+
99
+ - Gate the `<AffiliateoProvider>` (or the `<script src="…/t.js">` tag) behind your cookie banner's "accept" callback so it only loads after consent.
100
+ - Document Affiliateo in your privacy policy as a processor (see our [Data Processing Agreement](https://affiliateo.com/dpa)) and list the categories of data collected.
101
+
102
+ Affiliateo acts as your data processor for visitor analytics. You are the controller and decide the lawful basis for processing your visitors' data.
103
+
104
+ ## Alternative: vanilla HTML (no React)
105
+
106
+ If you're not using React, use the tracking script directly instead of the provider:
107
+
108
+ ```html
109
+ <script defer
110
+ data-app="YOUR_APP_ID"
111
+ src="https://affiliateo.com/t.js"
112
+ ></script>
113
+ ```
114
+
115
+ Then in your checkout flow (vanilla JS):
116
+
117
+ ```js
118
+ const attribution = {
119
+ affiliateo_ref: localStorage.getItem('affiliateo_ref'),
120
+ affiliateo_visitor_id: localStorage.getItem('affiliateo_visitor_id'),
121
+ affiliateo_app_id: localStorage.getItem('affiliateo_campaign_id'), // stored under its legacy key
122
+ };
123
+
124
+ // Send to your server with the rest of your checkout payload
125
+ fetch('/api/checkout', {
126
+ method: 'POST',
127
+ body: JSON.stringify({ attribution }),
128
+ });
129
+ ```
130
+
131
+ ## Track conversion goals
132
+
133
+ Pageviews auto-track. For specific moments that matter to your funnel
134
+ (`signup_completed`, `trial_started`, `initiate_checkout`, etc.), use
135
+ `track()` or HTML data attributes.
136
+
137
+ ### From React
138
+
139
+ ```tsx
140
+ 'use client'
141
+ import { useEffect } from 'react'
142
+ import { useAffiliateRef } from '@affiliateo/web'
143
+
144
+ export default function SignupSuccess() {
145
+ const { track } = useAffiliateRef()
146
+ useEffect(() => {
147
+ track('signup_completed', { plan: 'free' })
148
+ }, [])
149
+ return <div>Welcome!</div>
150
+ }
151
+ ```
152
+
153
+ ### HTML data attributes (zero JS, works everywhere)
154
+
155
+ ```html
156
+ <button data-affiliateo-goal="initiate_checkout"
157
+ data-affiliateo-goal-price="49"
158
+ data-affiliateo-goal-plan="pro">
159
+ Subscribe
160
+ </button>
161
+ ```
162
+
163
+ The SDK auto-fires the goal on click. Optional `data-affiliateo-goal-*`
164
+ attributes become metadata (kebab-case → snake_case, up to 10 params).
165
+
166
+ ### Scroll-depth tracking
167
+
168
+ ```html
169
+ <section data-affiliateo-scroll="pricing_viewed">
170
+ ...your pricing content...
171
+ </section>
172
+ ```
173
+
174
+ Fires the first time any pixel of the section overlaps the viewport.
175
+ Maximum permissiveness — if the user scrolled enough to see even a sliver,
176
+ it counts. Earlier versions required 50% visibility, which was unreachable
177
+ for tall sections (a pricing page with comparison tables silently dropped
178
+ out of every funnel). Great for landing pages where you want to know
179
+ which sections convert.
180
+
181
+ ### Goal naming rules
182
+
183
+ Lowercase letters, digits, underscores, hyphens, colons. Max 64 chars.
184
+ Metadata bounded to 4KB JSON.
185
+
186
+ ## Identify signed-in users (cross-device stitching)
187
+
188
+ Without `identify()`, the same person browsing on phone and buying on
189
+ laptop counts as two visitors. Call it once after sign-in:
190
+
191
+ ```tsx
192
+ 'use client'
193
+ import { useEffect } from 'react'
194
+ import { useAffiliateRef } from '@affiliateo/web'
195
+ import { useUser } from './your-auth'
196
+
197
+ export default function AuthBoundary({ children }) {
198
+ const { identify } = useAffiliateRef()
199
+ const user = useUser()
200
+ useEffect(() => {
201
+ if (user) identify(user.id)
202
+ }, [user])
203
+ return <>{children}</>
204
+ }
205
+ ```
206
+
207
+ Idempotent. safe to fire on every page load while a user is signed in.
208
+ **user_id only.** The SDK does not accept, collect, or transmit email
209
+ or any other PII.
210
+
211
+ ## API
212
+
213
+ ### `<AffiliateoProvider appId="..." apiUrl?="...">`
214
+
215
+ React provider. Wrap your app once. Reads `?ref=` from URL, persists to localStorage, sends pageview events, sets up data-attribute + scroll listeners.
216
+
217
+ Props:
218
+ - `appId` (required): your Affiliateo app ID (`campaignId` still works as a deprecated alias)
219
+ - `apiUrl` (optional): defaults to `https://affiliateo.com`
220
+
221
+ ### `useAffiliateRef()`
222
+
223
+ React hook that returns `{ refCode, visitorId, track, identify }`.
224
+
225
+ - `track(eventName, metadata?)`. fire a custom goal
226
+ - `identify(userId)`. link this visitor to a merchant user_id (no PII)
227
+
228
+ ### `getAttribution()`
229
+
230
+ Client-side function. Returns an object with `affiliateo_ref`, `affiliateo_visitor_id`, and `affiliateo_app_id` from localStorage. Pass directly as Stripe `metadata`.
231
+
232
+ ## Framework support
233
+
234
+ Works with any JavaScript framework or vanilla JS:
235
+ - **Next.js** (App Router & Pages Router)
236
+ - **Remix** / **React Router**
237
+ - **Vite + React**
238
+ - **Vanilla JS/HTML** (use the `<script>` tag instead)
@@ -18,6 +18,6 @@ export declare function useAffiliateRef(): AffiliateoContextValue;
18
18
  * ```
19
19
  */
20
20
  export declare function getAttribution(): Record<string, string>;
21
- export declare function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allowLocalhost, allowIframe, allowFileProtocol, debug, flushInterval, maxQueueSize, autoCapturePageviews, children, }: AffiliateoConfig & {
21
+ export declare function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames, allowLocalhost, allowIframe, allowFileProtocol, debug, flushInterval, maxQueueSize, autoCapturePageviews, children, }: AffiliateoConfig & {
22
22
  children: React.ReactNode;
23
23
  }): import("react/jsx-runtime").JSX.Element;
package/dist/provider.js CHANGED
@@ -4,10 +4,14 @@ import { createContext, useCallback, useContext, useEffect, useRef, useState } f
4
4
  import { EventQueue } from './queue';
5
5
  import { isLikelyBot } from './bot-detect';
6
6
  import { isDevHost } from './dev-host';
7
+ import { mintTurnstileToken, consumeTurnstileToken } from './turnstile';
7
8
  const DEFAULT_API_URL = 'https://affiliateo.com';
8
9
  const LS_VISITOR_ID = 'affiliateo_visitor_id';
9
10
  const LS_REF = 'affiliateo_ref';
10
- const LS_CAMPAIGN_ID = 'affiliateo_campaign_id';
11
+ // Storage key keeps its historical name: t.js and the WooCommerce tracker
12
+ // write the same key, and renaming it would orphan existing visitors'
13
+ // attribution mid-session.
14
+ const LS_APP_ID = 'affiliateo_campaign_id';
11
15
  // Persistent opt-out flag (affiliateo_opt_out) —
12
16
  // when set to "true" the SDK silently noops every outbound call. Survives
13
17
  // page reloads. removed by optIn(). Checked at init time so a previously
@@ -149,13 +153,15 @@ export function getAttribution() {
149
153
  const data = {};
150
154
  const vid = lsGet(LS_VISITOR_ID);
151
155
  const ref = lsGet(LS_REF);
152
- const cid = lsGet(LS_CAMPAIGN_ID);
156
+ const cid = lsGet(LS_APP_ID);
153
157
  if (vid)
154
158
  data.affiliateo_visitor_id = vid;
155
159
  if (ref)
156
160
  data.affiliateo_ref = ref;
161
+ // Key renamed from affiliateo_campaign_id in 4.5.0 — the Affiliateo
162
+ // webhook accepts both, so checkouts created by older SDKs keep working.
157
163
  if (cid)
158
- data.affiliateo_campaign_id = cid;
164
+ data.affiliateo_app_id = cid;
159
165
  return data;
160
166
  }
161
167
  // Double-mount detection. The host app should mount AffiliateoProvider
@@ -165,7 +171,7 @@ export function getAttribution() {
165
171
  // mount (host has the call sites) but we can shout in dev so the
166
172
  // mistake is visible.
167
173
  let providerMountCount = 0;
168
- export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allowLocalhost, allowIframe, allowFileProtocol, debug, flushInterval, maxQueueSize, autoCapturePageviews, children, }) {
174
+ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames, allowLocalhost, allowIframe, allowFileProtocol, debug, flushInterval, maxQueueSize, autoCapturePageviews, children, }) {
169
175
  // Normalize autoCapturePageviews. Three shapes:
170
176
  // - undefined / true / object → auto-capture ON (with object overrides)
171
177
  // - false → auto-capture OFF entirely (host must call trackPageview)
@@ -211,12 +217,15 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allow
211
217
  console.log('[Affiliateo] ' + msg);
212
218
  }, [debug]);
213
219
  const apiBase = (apiUrl || DEFAULT_API_URL).replace(/\/$/, '');
214
- // campaignIdRef stays current so callbacks defined inside useEffect
220
+ // appId is the documented prop; campaignId is the pre-4.5.0 name kept as
221
+ // a working alias so upgrading the package never breaks an integration.
222
+ const resolvedAppId = appId ?? campaignId ?? '';
223
+ // appIdRef stays current so callbacks defined inside useEffect
215
224
  // always read the latest value, even after a prop change. Closures
216
225
  // captured by host-side `useRef` no longer drift to a stale id.
217
- const campaignIdRef = useRef(campaignId);
218
- campaignIdRef.current = campaignId;
219
- // Mint visitorId once and put it in a ref so it survives campaignId
226
+ const appIdRef = useRef(resolvedAppId);
227
+ appIdRef.current = resolvedAppId;
228
+ // Mint visitorId once and put it in a ref so it survives appId
220
229
  // prop changes without forcing a re-fetch / re-mint cycle. Same UUID
221
230
  // for the lifetime of the localStorage entry across the whole tab.
222
231
  const visitorIdRef = useRef(null);
@@ -230,7 +239,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allow
230
239
  // handlers, which fire after mount.
231
240
  const queueRef = useRef(null);
232
241
  // track + identify are defined OUTSIDE the route-effect so they
233
- // close over refs (campaignIdRef, visitorIdRef) rather than the
242
+ // close over refs (appIdRef, visitorIdRef) rather than the
234
243
  // captured-once values from inside the effect. A consumer caching
235
244
  // `track` keeps firing under the LATEST (campaignId, visitorId).
236
245
  const track = useCallback(async (eventName, metadata) => {
@@ -246,7 +255,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allow
246
255
  const name = eventName.trim().toLowerCase();
247
256
  if (!name || name.length > 64 || !/^[a-z0-9_:-]+$/.test(name))
248
257
  return;
249
- const cid = campaignIdRef.current;
258
+ const cid = appIdRef.current;
250
259
  const vid = visitorIdRef.current;
251
260
  if (!cid || !vid)
252
261
  return;
@@ -285,7 +294,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allow
285
294
  // firebase uid, supabase uuid.
286
295
  if (!uid || uid.length > 128)
287
296
  return;
288
- const cid = campaignIdRef.current;
297
+ const cid = appIdRef.current;
289
298
  const vid = visitorIdRef.current;
290
299
  if (!cid || !vid)
291
300
  return;
@@ -315,7 +324,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allow
315
324
  return;
316
325
  if (isOptedOut())
317
326
  return;
318
- const cid = campaignIdRef.current;
327
+ const cid = appIdRef.current;
319
328
  const vid = visitorIdRef.current;
320
329
  if (!cid || !vid)
321
330
  return;
@@ -347,6 +356,12 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allow
347
356
  gclid: params.get('gclid') || '',
348
357
  screen_width: window.innerWidth,
349
358
  };
359
+ // Attach the (single-use) Turnstile token if one is still unconsumed.
360
+ // Matters for consent flows where the host disabled auto-capture and
361
+ // this manual call is the session-opening event.
362
+ const tsToken = consumeTurnstileToken();
363
+ if (tsToken)
364
+ payload.cf_turnstile_token = tsToken;
350
365
  queueRef.current?.enqueue(`${apiBase}/api/v1/web/event`, payload);
351
366
  log('pageview (manual)', { path: pagePath });
352
367
  }
@@ -425,6 +440,13 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allow
425
440
  useEffect(() => {
426
441
  if (typeof window === 'undefined')
427
442
  return;
443
+ // Neither appId nor the legacy campaignId alias was passed — nothing to
444
+ // attribute events to, so stay dormant instead of firing junk.
445
+ if (!appIdRef.current) {
446
+ // eslint-disable-next-line no-console
447
+ console.error('[Affiliateo] Missing appId — pass your app ID to <AffiliateoProvider appId="...">.');
448
+ return;
449
+ }
428
450
  // Mount-count warning. Fires only when more than one Provider has
429
451
  // ever mounted in the page lifetime. Dev-only; production swallows
430
452
  // the warning to avoid noise in real merchant consoles.
@@ -613,7 +635,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allow
613
635
  vid = uuid();
614
636
  }
615
637
  lsSet(LS_VISITOR_ID, vid);
616
- lsSet(LS_CAMPAIGN_ID, campaignIdRef.current);
638
+ lsSet(LS_APP_ID, appIdRef.current);
617
639
  visitorIdRef.current = vid;
618
640
  // Read ref. Cross-domain inbound wins, then fresh affiliate link
619
641
  // params (?ref / ?via / ?source), then stored value.
@@ -628,7 +650,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allow
628
650
  visitorId: vid,
629
651
  track, identify, trackPageview, reset, optOut, optIn, flush,
630
652
  });
631
- log('init', { campaign: campaignIdRef.current, visitor: vid, ref: ref || null });
653
+ log('init', { app: appIdRef.current, visitor: vid, ref: ref || null });
632
654
  // lastSendTime drives the visibility-refire heuristic below (refire a
633
655
  // pageview when a tab returns after the session window). Duplicate
634
656
  // suppression itself is the persisted same-URL throttle inside
@@ -659,9 +681,14 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allow
659
681
  ssSet(SS_PV_THROTTLE, JSON.stringify({ url: href, time: now }));
660
682
  lastSendTime = now;
661
683
  ended = false;
684
+ // Attach the (single-use) Turnstile token to the session-opening
685
+ // pageview. consumeTurnstileToken() returns null after the first
686
+ // consume, so SPA navigations go out bare — the server trusts them
687
+ // because the visitor row was created behind the verified token.
688
+ const tsToken = consumeTurnstileToken();
662
689
  queue.enqueue(endpoint, {
663
690
  type: 'pageview',
664
- campaign_id: campaignIdRef.current,
691
+ campaign_id: appIdRef.current,
665
692
  visitor_id: vid,
666
693
  session_id: getSessionId(),
667
694
  url: href,
@@ -677,6 +704,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allow
677
704
  ttclid: params.get('ttclid') || '',
678
705
  gclid: params.get('gclid') || '',
679
706
  screen_width: window.innerWidth,
707
+ ...(tsToken ? { cf_turnstile_token: tsToken } : {}),
680
708
  });
681
709
  log('pageview', { path: window.location.pathname });
682
710
  }
@@ -702,7 +730,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allow
702
730
  queue.flushOnUnload();
703
731
  const data = JSON.stringify({
704
732
  type: 'session_end',
705
- campaign_id: campaignIdRef.current,
733
+ campaign_id: appIdRef.current,
706
734
  visitor_id: vid,
707
735
  ref: ref || lsGet(LS_REF) || '',
708
736
  });
@@ -746,13 +774,30 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allow
746
774
  }
747
775
  }
748
776
  }
749
- // Send pageview on mount. captureInitialPageview === false skips this
750
- // for cookie-consent flows where the host wants to defer until the
751
- // user accepts (then call trackPageview() manually).
752
- if (!sentRef.current && autoCfg.captureInitialPageview && autoCfg.enabled) {
753
- sentRef.current = true;
754
- sendPageview();
755
- }
777
+ // Kick off the Turnstile mint regardless of auto-capture config so a
778
+ // consent flow's later manual trackPageview() finds a token waiting.
779
+ // Never rejects; resolves with or without a token (see turnstile.ts).
780
+ let cancelled = false;
781
+ void mintTurnstileToken().then(() => {
782
+ // Send pageview on mount, deferred until the Turnstile mint settles so
783
+ // the session-opening event carries the token (SPA navigations reuse
784
+ // the resolved state and don't wait). captureInitialPageview === false
785
+ // skips this for cookie-consent flows where the host wants to defer
786
+ // until the user accepts (then call trackPageview() manually).
787
+ //
788
+ // sentRef is set HERE (not before the mint) so a StrictMode
789
+ // double-mount can't mark the pageview as sent and then tear down the
790
+ // queue before the deferred send ever ran — the surviving mount's
791
+ // callback sees sentRef still false and fires.
792
+ if (cancelled)
793
+ return;
794
+ if (sentRef.current)
795
+ return;
796
+ if (autoCfg.captureInitialPageview && autoCfg.enabled) {
797
+ sentRef.current = true;
798
+ sendPageview();
799
+ }
800
+ });
756
801
  // Detect user leaving
757
802
  document.addEventListener('visibilitychange', onVisibilityChange);
758
803
  window.addEventListener('pagehide', sendEnd);
@@ -884,6 +929,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allow
884
929
  document.addEventListener('keydown', onKeydownForLinkDecoration, true);
885
930
  }
886
931
  return () => {
932
+ cancelled = true;
887
933
  document.removeEventListener('visibilitychange', onVisibilityChange);
888
934
  window.removeEventListener('pagehide', sendEnd);
889
935
  window.removeEventListener('popstate', onUrlChange);
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Cloudflare Turnstile token minting. Mirrors the hosted t.js tracker:
3
+ * mint ONE invisible token at SDK init and attach it to the first pageview.
4
+ * The server verifies the token before creating the visitor row (and before
5
+ * counting a pay-per-click web_traffic click — the billable path REQUIRES a
6
+ * genuine pass when TURNSTILE_SECRET is configured). Subsequent events from
7
+ * the same visitor_id are trusted because the row was created behind a
8
+ * verified token, so the cost is one mint + one verify per session.
9
+ *
10
+ * Tokens are single-use at Cloudflare's siteverify, so consumeToken()
11
+ * hands the token out exactly once — re-sending a used token would get the
12
+ * event rejected server-side, not warn-allowed.
13
+ *
14
+ * Failure modes: if the Turnstile script is blocked (ad blockers, strict
15
+ * CSP), errors, or times out, minting resolves with no token and events go
16
+ * out bare. The server's warn-allow mode (TURNSTILE_REQUIRE unset) lets
17
+ * those through; commission-on-sale (web_affiliate) tracking never depends
18
+ * on the token at all.
19
+ */
20
+ interface TurnstileApi {
21
+ render: (el: HTMLElement, opts: Record<string, unknown>) => unknown;
22
+ }
23
+ declare global {
24
+ interface Window {
25
+ turnstile?: TurnstileApi;
26
+ __affiliateoTSLoaded?: () => void;
27
+ }
28
+ }
29
+ /**
30
+ * Load the Turnstile script (once) and run the invisible challenge. Resolves
31
+ * when a token is available OR the mint failed/timed out — never rejects, so
32
+ * callers can unconditionally `.then(sendPageview)`.
33
+ */
34
+ export declare function mintTurnstileToken(): Promise<void>;
35
+ /**
36
+ * Hand out the minted token exactly once (single-use at siteverify).
37
+ * Returns null when no token was minted or it was already consumed.
38
+ */
39
+ export declare function consumeTurnstileToken(): string | null;
40
+ export {};
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Cloudflare Turnstile token minting. Mirrors the hosted t.js tracker:
3
+ * mint ONE invisible token at SDK init and attach it to the first pageview.
4
+ * The server verifies the token before creating the visitor row (and before
5
+ * counting a pay-per-click web_traffic click — the billable path REQUIRES a
6
+ * genuine pass when TURNSTILE_SECRET is configured). Subsequent events from
7
+ * the same visitor_id are trusted because the row was created behind a
8
+ * verified token, so the cost is one mint + one verify per session.
9
+ *
10
+ * Tokens are single-use at Cloudflare's siteverify, so consumeToken()
11
+ * hands the token out exactly once — re-sending a used token would get the
12
+ * event rejected server-side, not warn-allowed.
13
+ *
14
+ * Failure modes: if the Turnstile script is blocked (ad blockers, strict
15
+ * CSP), errors, or times out, minting resolves with no token and events go
16
+ * out bare. The server's warn-allow mode (TURNSTILE_REQUIRE unset) lets
17
+ * those through; commission-on-sale (web_affiliate) tracking never depends
18
+ * on the token at all.
19
+ */
20
+ // Public sitekey (safe to expose) — the same one public/t.js ships. The
21
+ // matching secret lives in TURNSTILE_SECRET on the server.
22
+ const TURNSTILE_SITEKEY = '0x4AAAAAADR5UW2x6axFd13p';
23
+ const TURNSTILE_SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=__affiliateoTSLoaded&render=explicit';
24
+ // Hard deadline for the whole mint (script load + challenge). t.js relies on
25
+ // Turnstile's own timeout-callback, but a script tag that never loads never
26
+ // fires ANY callback — this timer makes sure the first pageview can't be
27
+ // held hostage by a blocked challenges.cloudflare.com.
28
+ const MINT_TIMEOUT_MS = 3000;
29
+ let token = null;
30
+ let minting = null;
31
+ /**
32
+ * Load the Turnstile script (once) and run the invisible challenge. Resolves
33
+ * when a token is available OR the mint failed/timed out — never rejects, so
34
+ * callers can unconditionally `.then(sendPageview)`.
35
+ */
36
+ export function mintTurnstileToken() {
37
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
38
+ return Promise.resolve();
39
+ }
40
+ if (minting)
41
+ return minting;
42
+ minting = new Promise((resolve) => {
43
+ let settled = false;
44
+ const settle = () => {
45
+ if (settled)
46
+ return;
47
+ settled = true;
48
+ clearTimeout(deadline);
49
+ resolve();
50
+ };
51
+ const deadline = setTimeout(settle, MINT_TIMEOUT_MS);
52
+ const render = () => {
53
+ if (!window.turnstile) {
54
+ settle();
55
+ return;
56
+ }
57
+ try {
58
+ const holder = document.createElement('div');
59
+ holder.style.display = 'none';
60
+ document.body.appendChild(holder);
61
+ window.turnstile.render(holder, {
62
+ sitekey: TURNSTILE_SITEKEY,
63
+ // Invisible widget — never shows UI. Real browsers pass the
64
+ // challenge in 50-200ms with zero user interaction.
65
+ size: 'invisible',
66
+ callback: (tok) => {
67
+ token = tok;
68
+ settle();
69
+ },
70
+ 'error-callback': settle,
71
+ 'timeout-callback': settle,
72
+ });
73
+ }
74
+ catch {
75
+ settle();
76
+ }
77
+ };
78
+ if (window.turnstile) {
79
+ render();
80
+ return;
81
+ }
82
+ try {
83
+ const script = document.createElement('script');
84
+ script.src = TURNSTILE_SCRIPT_SRC;
85
+ script.async = true;
86
+ script.defer = true;
87
+ script.onerror = settle;
88
+ window.__affiliateoTSLoaded = render;
89
+ document.head.appendChild(script);
90
+ }
91
+ catch {
92
+ settle();
93
+ }
94
+ });
95
+ return minting;
96
+ }
97
+ /**
98
+ * Hand out the minted token exactly once (single-use at siteverify).
99
+ * Returns null when no token was minted or it was already consumed.
100
+ */
101
+ export function consumeTurnstileToken() {
102
+ const t = token;
103
+ token = null;
104
+ return t;
105
+ }
package/dist/types.d.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  export interface AffiliateoConfig {
2
- campaignId: string;
2
+ /** Your Affiliateo app ID — shown in the dashboard when you create a
3
+ * Web App Affiliate app. One of `appId` / `campaignId` is required;
4
+ * `appId` wins when both are set. */
5
+ appId?: string;
6
+ /** @deprecated Affiliateo campaigns are now called apps — use `appId`.
7
+ * Kept working so existing integrations don't break. */
8
+ campaignId?: string;
3
9
  /** Base URL for the API. Defaults to https://affiliateo.com */
4
10
  apiUrl?: string;
5
11
  /** Other domains that are part of the same merchant property and should
@@ -22,7 +28,7 @@ export interface AffiliateoConfig {
22
28
  *
23
29
  * Example:
24
30
  * <AffiliateoProvider
25
- * campaignId="..."
31
+ * appId="..."
26
32
  * allowedHostnames={['app.merchant.com', 'docs.merchant.com']}
27
33
  * > */
28
34
  allowedHostnames?: string[];
@@ -36,7 +42,7 @@ export interface AffiliateoConfig {
36
42
  * land server-side).
37
43
  *
38
44
  * Example:
39
- * <AffiliateoProvider campaignId="..." allowLocalhost> */
45
+ * <AffiliateoProvider appId="..." allowLocalhost> */
40
46
  allowLocalhost?: boolean;
41
47
  /** Allow tracking when the page is rendered inside an iframe. Defaults
42
48
  * to false so scrapers, preview tools, and other sites embedding yours
@@ -55,7 +61,7 @@ export interface AffiliateoConfig {
55
61
  * showing up?". Leave off in production.
56
62
  *
57
63
  * Example:
58
- * <AffiliateoProvider campaignId="..." debug> */
64
+ * <AffiliateoProvider appId="..." debug> */
59
65
  debug?: boolean;
60
66
  /** Allow tracking from pages served via the `file://` protocol (someone
61
67
  * double-clicking a built HTML file instead of running a server).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@affiliateo/web",
3
- "version": "4.4.1",
3
+ "version": "4.6.0",
4
4
  "description": "Affiliateo web SDK — affiliate attribution and tracking for web apps",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",