@affiliateo/web 4.1.0 → 4.3.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.
Files changed (3) hide show
  1. package/README.md +238 -238
  2. package/dist/provider.js +103 -4
  3. package/package.json +1 -1
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 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)
package/dist/provider.js CHANGED
@@ -13,6 +13,23 @@ const LS_CAMPAIGN_ID = 'affiliateo_campaign_id';
13
13
  // page reloads. removed by optIn(). Checked at init time so a previously
14
14
  // opted-out visitor never re-fires auto-pageviews after a refresh.
15
15
  const LS_OPT_OUT = 'affiliateo_opt_out';
16
+ // Client-owned session id (Datafast-style). The BROWSER owns the session,
17
+ // not the server. One id is reused for every event within a sliding
18
+ // 10-minute activity window, then a fresh one is minted. Because the id is
19
+ // generated here and sent on each event, a burst of pageviews (a redirect
20
+ // bounce, multiple tabs) all carry the SAME id, so the dashboard collapses
21
+ // them into a single visit. Server-side minting was racy: simultaneous
22
+ // events each invented a different id. 10 min matches the server session
23
+ // window and the live-presence window so everything ages out on one clock.
24
+ const LS_SESSION = 'affiliateo_session';
25
+ const SESSION_WINDOW_MS = 10 * 60 * 1000;
26
+ // Same-URL pageview throttle, persisted in sessionStorage so it survives
27
+ // hard navigations / redirect bounces (the old in-memory throttle reset on
28
+ // every fresh page context, which let a 3-hop bounce fire 3 pageviews).
29
+ // Different URLs are never throttled against each other. Mirrors Datafast's
30
+ // 60s same-URL suppression.
31
+ const SS_PV_THROTTLE = 'affiliateo_pv_throttle';
32
+ const PV_THROTTLE_MS = 60 * 1000;
16
33
  // localStorage helpers
17
34
  function lsGet(key) {
18
35
  try {
@@ -34,6 +51,24 @@ function lsRemove(key) {
34
51
  }
35
52
  catch { }
36
53
  }
54
+ // sessionStorage helpers. Used by the pageview throttle so its state lives
55
+ // for the life of the tab (and survives hard navigations within it) but
56
+ // resets when the tab closes, which is exactly the scope we want for
57
+ // "don't re-fire the same URL within a minute."
58
+ function ssGet(key) {
59
+ try {
60
+ return sessionStorage.getItem(key);
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ }
66
+ function ssSet(key, value) {
67
+ try {
68
+ sessionStorage.setItem(key, value);
69
+ }
70
+ catch { }
71
+ }
37
72
  // True when the visitor has opted out via optOut(). Single source of truth
38
73
  // read by every outbound call so the flag works even across tabs (each tab's
39
74
  // next call re-reads localStorage).
@@ -50,6 +85,34 @@ function uuid() {
50
85
  return v.toString(16);
51
86
  });
52
87
  }
88
+ // Get-or-create the current session id with a sliding 10-minute window.
89
+ // Stored in localStorage so every tab and every page load in the same
90
+ // browser shares one session. Each call slides the window forward (the same
91
+ // way Datafast refreshes its session cookie on each event), so an actively
92
+ // browsing visitor keeps one id and a returning visitor past the window
93
+ // gets a fresh one. Returns a UUID string the server stamps onto the event.
94
+ function getSessionId() {
95
+ const now = Date.now();
96
+ try {
97
+ const raw = lsGet(LS_SESSION);
98
+ if (raw) {
99
+ const parsed = JSON.parse(raw);
100
+ if (parsed &&
101
+ typeof parsed.id === 'string' &&
102
+ typeof parsed.lastActive === 'number' &&
103
+ now - parsed.lastActive < SESSION_WINDOW_MS) {
104
+ lsSet(LS_SESSION, JSON.stringify({ id: parsed.id, lastActive: now }));
105
+ return parsed.id;
106
+ }
107
+ }
108
+ }
109
+ catch {
110
+ // Corrupt entry or storage blocked. Fall through and mint a fresh one.
111
+ }
112
+ const id = uuid();
113
+ lsSet(LS_SESSION, JSON.stringify({ id, lastActive: now }));
114
+ return id;
115
+ }
53
116
  const asyncNoop = async () => { };
54
117
  const syncNoop = () => { };
55
118
  const AffiliateoContext = createContext({
@@ -191,6 +254,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allow
191
254
  type: 'custom',
192
255
  campaign_id: cid,
193
256
  visitor_id: vid,
257
+ session_id: getSessionId(),
194
258
  event_name: name,
195
259
  };
196
260
  if (metadata && typeof metadata === 'object')
@@ -266,6 +330,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allow
266
330
  type: 'pageview',
267
331
  campaign_id: cid,
268
332
  visitor_id: vid,
333
+ session_id: getSessionId(),
269
334
  url,
270
335
  path: pagePath,
271
336
  hostname: window.location.hostname,
@@ -274,6 +339,12 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allow
274
339
  utm_source: params.get('utm_source') || '',
275
340
  utm_medium: params.get('utm_medium') || '',
276
341
  utm_campaign: params.get('utm_campaign') || '',
342
+ // Ad-network click ids — set automatically on the landing URL by
343
+ // every Meta/TikTok/Google ad click. Label the sale's source and
344
+ // feed server-side conversion matching.
345
+ fbclid: params.get('fbclid') || '',
346
+ ttclid: params.get('ttclid') || '',
347
+ gclid: params.get('gclid') || '',
277
348
  screen_width: window.innerWidth,
278
349
  };
279
350
  queueRef.current?.enqueue(`${apiBase}/api/v1/web/event`, payload);
@@ -320,6 +391,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allow
320
391
  const newVid = uuid();
321
392
  lsSet(LS_VISITOR_ID, newVid);
322
393
  lsRemove(LS_REF);
394
+ lsRemove(LS_SESSION);
323
395
  visitorIdRef.current = newVid;
324
396
  setState((prev) => ({ ...prev, visitorId: newVid, refCode: null }));
325
397
  }
@@ -557,19 +629,42 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allow
557
629
  track, identify, trackPageview, reset, optOut, optIn, flush,
558
630
  });
559
631
  log('init', { campaign: campaignIdRef.current, visitor: vid, ref: ref || null });
560
- // Throttle: max 1 pageview per second
632
+ // lastSendTime drives the visibility-refire heuristic below (refire a
633
+ // pageview when a tab returns after the session window). Duplicate
634
+ // suppression itself is the persisted same-URL throttle inside
635
+ // sendPageview, which survives hard navigations.
561
636
  let lastSendTime = 0;
562
637
  function sendPageview() {
563
638
  const now = Date.now();
564
- if (now - lastSendTime < 1000)
565
- return;
639
+ const href = window.location.href;
640
+ // Same-URL throttle, persisted in sessionStorage so a redirect bounce
641
+ // or hard navigation that lands back on the same URL within the window
642
+ // does not re-fire. Different URLs are never throttled against each
643
+ // other, so a genuine multi-page visit still records every page.
644
+ const prevRaw = ssGet(SS_PV_THROTTLE);
645
+ if (prevRaw) {
646
+ try {
647
+ const prev = JSON.parse(prevRaw);
648
+ if (prev &&
649
+ prev.url === href &&
650
+ typeof prev.time === 'number' &&
651
+ now - prev.time < PV_THROTTLE_MS) {
652
+ return;
653
+ }
654
+ }
655
+ catch {
656
+ // Corrupt entry. Fall through and fire.
657
+ }
658
+ }
659
+ ssSet(SS_PV_THROTTLE, JSON.stringify({ url: href, time: now }));
566
660
  lastSendTime = now;
567
661
  ended = false;
568
662
  queue.enqueue(endpoint, {
569
663
  type: 'pageview',
570
664
  campaign_id: campaignIdRef.current,
571
665
  visitor_id: vid,
572
- url: window.location.href,
666
+ session_id: getSessionId(),
667
+ url: href,
573
668
  path: window.location.pathname,
574
669
  hostname: window.location.hostname,
575
670
  referrer: document.referrer || '',
@@ -577,6 +672,10 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allow
577
672
  utm_source: params.get('utm_source') || '',
578
673
  utm_medium: params.get('utm_medium') || '',
579
674
  utm_campaign: params.get('utm_campaign') || '',
675
+ // Ad-network click ids from the landing URL (see trackPageview).
676
+ fbclid: params.get('fbclid') || '',
677
+ ttclid: params.get('ttclid') || '',
678
+ gclid: params.get('gclid') || '',
580
679
  screen_width: window.innerWidth,
581
680
  });
582
681
  log('pageview', { path: window.location.pathname });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@affiliateo/web",
3
- "version": "4.1.0",
3
+ "version": "4.3.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",