@affiliateo/web 2.0.0 → 2.2.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,44 +1,234 @@
1
- # @affiliateo/web
2
-
3
- Affiliateo web SDK — pass affiliate attribution metadata to your Stripe checkout.
4
-
5
- ## Install
6
-
7
- ```bash
8
- npm install @affiliateo/web
9
- ```
10
-
11
- ## Setup
12
-
13
- 1. Make sure you have the Affiliateo tracking script on your site (for visitor tracking)
14
- 2. Connect your Stripe account on the Affiliateo dashboard
15
- 3. Add one line to your Stripe checkout creation:
16
-
17
- ```ts
18
- import { getMetadata } from '@affiliateo/web';
19
-
20
- // Your existing checkout route
21
- const session = await stripe.checkout.sessions.create({
22
- line_items: [{ price: 'price_xxx', quantity: 1 }],
23
- mode: 'payment',
24
- success_url: 'https://yoursite.com/success',
25
- cancel_url: 'https://yoursite.com/cancel',
26
- metadata: getMetadata(req),
27
- });
28
- ```
29
-
30
- That's it. Affiliateo will automatically attribute the purchase to the right affiliate.
31
-
32
- ## How it works
33
-
34
- 1. Your tracking script (`t.js`) sets cookies when a visitor arrives via an affiliate link
35
- 2. `getMetadata(req)` reads those cookies and returns them as Stripe metadata
36
- 3. When the payment completes, Affiliateo's webhook reads the metadata and credits the affiliate
37
-
38
- ## Framework support
39
-
40
- Works with any Node.js framework:
41
- - **Next.js** (App Router & Pages Router)
42
- - **Express**
43
- - **Fastify**
44
- - Any framework that exposes request cookies
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 once when the section becomes 50%+ visible. Great for landing pages
175
+ where you want to know which sections convert.
176
+
177
+ ### Goal naming rules
178
+
179
+ Lowercase letters, digits, underscores, hyphens, colons. Max 64 chars.
180
+ Metadata bounded to 4KB JSON.
181
+
182
+ ## Identify signed-in users (cross-device stitching)
183
+
184
+ Without `identify()`, the same person browsing on phone and buying on
185
+ laptop counts as two visitors. Call it once after sign-in:
186
+
187
+ ```tsx
188
+ 'use client'
189
+ import { useEffect } from 'react'
190
+ import { useAffiliateRef } from '@affiliateo/web'
191
+ import { useUser } from './your-auth'
192
+
193
+ export default function AuthBoundary({ children }) {
194
+ const { identify } = useAffiliateRef()
195
+ const user = useUser()
196
+ useEffect(() => {
197
+ if (user) identify(user.id)
198
+ }, [user])
199
+ return <>{children}</>
200
+ }
201
+ ```
202
+
203
+ Idempotent. safe to fire on every page load while a user is signed in.
204
+ **user_id only.** The SDK does not accept, collect, or transmit email
205
+ or any other PII.
206
+
207
+ ## API
208
+
209
+ ### `<AffiliateoProvider campaignId="..." apiUrl?="...">`
210
+
211
+ React provider. Wrap your app once. Reads `?ref=` from URL, persists to localStorage, sends pageview events, sets up data-attribute + scroll listeners.
212
+
213
+ Props:
214
+ - `campaignId` (required): your Affiliateo campaign ID
215
+ - `apiUrl` (optional): defaults to `https://affiliateo.com`
216
+
217
+ ### `useAffiliateRef()`
218
+
219
+ React hook that returns `{ refCode, visitorId, track, identify }`.
220
+
221
+ - `track(eventName, metadata?)`. fire a custom goal
222
+ - `identify(userId)`. link this visitor to a merchant user_id (no PII)
223
+
224
+ ### `getAttribution()`
225
+
226
+ Client-side function. Returns an object with `affiliateo_ref`, `affiliateo_visitor_id`, and `affiliateo_campaign_id` from localStorage. Pass directly as Stripe `metadata`.
227
+
228
+ ## Framework support
229
+
230
+ Works with any JavaScript framework or vanilla JS:
231
+ - **Next.js** (App Router & Pages Router)
232
+ - **Remix** / **React Router**
233
+ - **Vite + React**
234
+ - **Vanilla JS/HTML** (use the `<script>` tag instead)
package/dist/provider.js CHANGED
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { createContext, useContext, useEffect, useRef, useState } from 'react';
3
+ import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
4
4
  const DEFAULT_API_URL = 'https://affiliateo.com';
5
5
  const LS_VISITOR_ID = 'affiliateo_visitor_id';
6
6
  const LS_REF = 'affiliateo_ref';
@@ -30,9 +30,12 @@ function uuid() {
30
30
  return v.toString(16);
31
31
  });
32
32
  }
33
+ const asyncNoop = async () => { };
33
34
  const AffiliateoContext = createContext({
34
35
  refCode: null,
35
36
  visitorId: null,
37
+ track: asyncNoop,
38
+ identify: asyncNoop,
36
39
  });
37
40
  /**
38
41
  * Returns the affiliate ref code and visitor ID.
@@ -66,17 +69,123 @@ export function getAttribution() {
66
69
  data.affiliateo_campaign_id = cid;
67
70
  return data;
68
71
  }
72
+ // Double-mount detection. The host app should mount AffiliateoProvider
73
+ // exactly once near the root. Mounting twice produces double pageviews,
74
+ // duplicate history.pushState patches stacking on each other, and
75
+ // competing session_end signals. We can't always *prevent* the second
76
+ // mount (host has the call sites) but we can shout in dev so the
77
+ // mistake is visible.
78
+ let providerMountCount = 0;
69
79
  export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
70
80
  const [state, setState] = useState({
71
81
  refCode: null,
72
82
  visitorId: null,
83
+ track: asyncNoop,
84
+ identify: asyncNoop,
73
85
  });
74
86
  const apiBase = (apiUrl || DEFAULT_API_URL).replace(/\/$/, '');
87
+ // campaignIdRef stays current so callbacks defined inside useEffect
88
+ // always read the latest value, even after a prop change. Closures
89
+ // captured by host-side `useRef` no longer drift to a stale id.
75
90
  const campaignIdRef = useRef(campaignId);
91
+ campaignIdRef.current = campaignId;
92
+ // Mint visitorId once and put it in a ref so it survives campaignId
93
+ // prop changes without forcing a re-fetch / re-mint cycle. Same UUID
94
+ // for the lifetime of the localStorage entry across the whole tab.
95
+ const visitorIdRef = useRef(null);
96
+ // track + identify are defined OUTSIDE the route-effect so they
97
+ // close over refs (campaignIdRef, visitorIdRef) rather than the
98
+ // captured-once values from inside the effect. A consumer caching
99
+ // `track` keeps firing under the LATEST (campaignId, visitorId).
100
+ const track = useCallback(async (eventName, metadata) => {
101
+ try {
102
+ if (typeof window === 'undefined')
103
+ return;
104
+ if (!eventName || typeof eventName !== 'string')
105
+ return;
106
+ const name = eventName.trim().toLowerCase();
107
+ if (!name || name.length > 64 || !/^[a-z0-9_:-]+$/.test(name))
108
+ return;
109
+ const cid = campaignIdRef.current;
110
+ const vid = visitorIdRef.current;
111
+ if (!cid || !vid)
112
+ return;
113
+ const payload = {
114
+ type: 'custom',
115
+ campaign_id: cid,
116
+ visitor_id: vid,
117
+ event_name: name,
118
+ };
119
+ if (metadata && typeof metadata === 'object')
120
+ payload.metadata = metadata;
121
+ const data = JSON.stringify(payload);
122
+ const endpoint = `${apiBase}/api/v1/web/event`;
123
+ if (navigator.sendBeacon) {
124
+ navigator.sendBeacon(endpoint, new Blob([data], { type: 'application/json' }));
125
+ }
126
+ else {
127
+ fetch(endpoint, {
128
+ method: 'POST',
129
+ headers: { 'Content-Type': 'application/json' },
130
+ body: data,
131
+ keepalive: true,
132
+ }).catch(() => { });
133
+ }
134
+ }
135
+ catch { /* swallow */ }
136
+ }, [apiBase]);
137
+ // identify(userId). user_id only — the SDK does NOT accept, collect,
138
+ // or transmit email or any other PII. Cross-device stitching uses the
139
+ // merchant's own user_id; we never touch identifying personal data.
140
+ const identify = useCallback(async (userId) => {
141
+ try {
142
+ if (typeof window === 'undefined')
143
+ return;
144
+ if (!userId || typeof userId !== 'string')
145
+ return;
146
+ const uid = userId.trim();
147
+ // Cap at 128 chars (matches the server). Covers every common
148
+ // foreign user-id shape: uuid, ulid, mongo objectId, auth0 sub,
149
+ // firebase uid, supabase uuid.
150
+ if (!uid || uid.length > 128)
151
+ return;
152
+ const cid = campaignIdRef.current;
153
+ const vid = visitorIdRef.current;
154
+ if (!cid || !vid)
155
+ return;
156
+ const payload = {
157
+ campaign_id: cid,
158
+ visitor_id: vid,
159
+ user_id: uid,
160
+ };
161
+ const data = JSON.stringify(payload);
162
+ const url = `${apiBase}/api/v1/web/identify`;
163
+ if (navigator.sendBeacon) {
164
+ navigator.sendBeacon(url, new Blob([data], { type: 'application/json' }));
165
+ }
166
+ else {
167
+ fetch(url, {
168
+ method: 'POST',
169
+ headers: { 'Content-Type': 'application/json' },
170
+ body: data,
171
+ keepalive: true,
172
+ }).catch(() => { });
173
+ }
174
+ }
175
+ catch { /* swallow */ }
176
+ }, [apiBase]);
76
177
  const sentRef = useRef(false);
77
178
  useEffect(() => {
78
179
  if (typeof window === 'undefined')
79
180
  return;
181
+ // Mount-count warning. Fires only when more than one Provider has
182
+ // ever mounted in the page lifetime. Dev-only; production swallows
183
+ // the warning to avoid noise in real merchant consoles.
184
+ providerMountCount += 1;
185
+ if (providerMountCount > 1 && process.env.NODE_ENV !== 'production') {
186
+ // eslint-disable-next-line no-console
187
+ console.warn('[Affiliateo] AffiliateoProvider mounted more than once. Mount it exactly once near the root of your app, or pageviews will double-fire.');
188
+ }
80
189
  // Get or create visitor ID
81
190
  let vid = lsGet(LS_VISITOR_ID);
82
191
  if (!vid) {
@@ -84,6 +193,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
84
193
  }
85
194
  lsSet(LS_VISITOR_ID, vid);
86
195
  lsSet(LS_CAMPAIGN_ID, campaignIdRef.current);
196
+ visitorIdRef.current = vid;
87
197
  // Read ref from URL params or localStorage
88
198
  const params = new URLSearchParams(window.location.search);
89
199
  const ref = params.get('ref') || params.get('via') || params.get('source')
@@ -91,7 +201,22 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
91
201
  if (ref) {
92
202
  lsSet(LS_REF, ref);
93
203
  }
94
- setState({ refCode: ref, visitorId: vid });
204
+ const endpoint = `${apiBase}/api/v1/web/event`;
205
+ setState({ refCode: ref, visitorId: vid, track, identify });
206
+ function beacon(payload) {
207
+ const data = JSON.stringify(payload);
208
+ if (navigator.sendBeacon) {
209
+ navigator.sendBeacon(endpoint, new Blob([data], { type: 'application/json' }));
210
+ }
211
+ else {
212
+ fetch(endpoint, {
213
+ method: 'POST',
214
+ headers: { 'Content-Type': 'application/json' },
215
+ body: data,
216
+ keepalive: true,
217
+ }).catch(() => { });
218
+ }
219
+ }
95
220
  // Throttle: max 1 pageview per second
96
221
  let lastSendTime = 0;
97
222
  function sendPageview() {
@@ -99,7 +224,8 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
99
224
  if (now - lastSendTime < 1000)
100
225
  return;
101
226
  lastSendTime = now;
102
- const payload = {
227
+ ended = false;
228
+ beacon({
103
229
  type: 'pageview',
104
230
  campaign_id: campaignIdRef.current,
105
231
  visitor_id: vid,
@@ -112,26 +238,32 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
112
238
  utm_medium: params.get('utm_medium') || '',
113
239
  utm_campaign: params.get('utm_campaign') || '',
114
240
  screen_width: window.innerWidth,
115
- };
116
- fetch(`${apiBase}/api/v1/web/event`, {
117
- method: 'POST',
118
- headers: { 'Content-Type': 'application/json' },
119
- body: JSON.stringify(payload),
120
- keepalive: true,
121
- }).then(async (r) => {
122
- if (!r.ok) {
123
- const text = await r.text().catch(() => '');
124
- console.error(`[Affiliateo] Event failed (${r.status}):`, text);
125
- }
126
- }).catch((err) => {
127
- console.error('[Affiliateo] Event send error:', err);
128
241
  });
129
242
  }
243
+ // Send session_end when user leaves
244
+ let ended = false;
245
+ function sendEnd() {
246
+ if (ended)
247
+ return;
248
+ ended = true;
249
+ beacon({ type: 'session_end', campaign_id: campaignIdRef.current, visitor_id: vid, ref: ref || lsGet(LS_REF) || '' });
250
+ }
251
+ function onVisibilityChange() {
252
+ if (document.visibilityState === 'hidden') {
253
+ sendEnd();
254
+ }
255
+ else {
256
+ ended = false;
257
+ } // reset if they come back (tab switch)
258
+ }
130
259
  // Send pageview on mount
131
260
  if (!sentRef.current) {
132
261
  sentRef.current = true;
133
262
  sendPageview();
134
263
  }
264
+ // Detect user leaving
265
+ document.addEventListener('visibilitychange', onVisibilityChange);
266
+ window.addEventListener('pagehide', sendEnd);
135
267
  // SPA support: detect URL changes
136
268
  let lastPath = window.location.pathname;
137
269
  function onUrlChange() {
@@ -151,11 +283,102 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
151
283
  origReplaceState.apply(this, args);
152
284
  onUrlChange();
153
285
  };
286
+ // HTML-attribute goal tracking. match Datafast's `data-fast-goal`
287
+ // ergonomics. Any element with `data-affiliateo-goal="event_name"`
288
+ // fires that goal on click. Optional `data-affiliateo-goal-*`
289
+ // attributes become metadata (kebab-case to snake_case, max 10).
290
+ //
291
+ // Delegated listener on document.body so the merchant doesn't have
292
+ // to re-attach handlers when their SPA re-renders. Capture phase off
293
+ // so React's synthetic event ordering isn't disturbed.
294
+ function onDocClick(e) {
295
+ const target = e.target;
296
+ if (!(target instanceof Element))
297
+ return;
298
+ const el = target.closest('[data-affiliateo-goal]');
299
+ if (!el)
300
+ return;
301
+ const goal = el.getAttribute('data-affiliateo-goal');
302
+ if (!goal)
303
+ return;
304
+ const meta = {};
305
+ let count = 0;
306
+ for (const { name, value } of Array.from(el.attributes)) {
307
+ if (!name.startsWith('data-affiliateo-goal-'))
308
+ continue;
309
+ // Skip the bare "data-affiliateo-goal" attribute itself
310
+ // (slice would produce an empty key).
311
+ if (name === 'data-affiliateo-goal')
312
+ continue;
313
+ if (count >= 10)
314
+ break;
315
+ const key = name.slice('data-affiliateo-goal-'.length).replace(/-/g, '_');
316
+ // Skip the reserved `event` key so an attribute like
317
+ // `data-affiliateo-goal-event="x"` can't override the actual
318
+ // goal name when the SDK / server folds metadata.event in.
319
+ if (!key || key === 'event')
320
+ continue;
321
+ if (value) {
322
+ meta[key] = String(value).slice(0, 255);
323
+ count++;
324
+ }
325
+ }
326
+ track(goal, count ? meta : undefined);
327
+ }
328
+ document.addEventListener('click', onDocClick);
329
+ // Scroll-depth goal tracking. `data-affiliateo-scroll="event_name"`
330
+ // on any element fires that goal the first time the element enters
331
+ // the viewport. Use IntersectionObserver (cheap, off the scroll hot
332
+ // path) and unobserve after the first fire so the same element can't
333
+ // double-count if the user scrolls back up.
334
+ const scrollFired = new WeakSet();
335
+ let scrollObserver = null;
336
+ let scrollMo = null;
337
+ if (typeof IntersectionObserver !== 'undefined') {
338
+ scrollObserver = new IntersectionObserver((entries) => {
339
+ for (const entry of entries) {
340
+ if (!entry.isIntersecting)
341
+ continue;
342
+ if (scrollFired.has(entry.target))
343
+ continue;
344
+ scrollFired.add(entry.target);
345
+ const goal = entry.target.getAttribute('data-affiliateo-scroll');
346
+ if (goal)
347
+ track(goal);
348
+ scrollObserver?.unobserve(entry.target);
349
+ }
350
+ }, { threshold: 0.5 });
351
+ // Observe everything that exists at mount time.
352
+ document.querySelectorAll('[data-affiliateo-scroll]').forEach((el) => {
353
+ scrollObserver?.observe(el);
354
+ });
355
+ // SPA-rendered sections that appear later get observed via
356
+ // MutationObserver. Cheap because we only scan added nodes, not
357
+ // the whole tree on each mutation.
358
+ scrollMo = new MutationObserver((mutations) => {
359
+ for (const m of mutations) {
360
+ m.addedNodes.forEach((node) => {
361
+ if (!(node instanceof Element))
362
+ return;
363
+ if (node.hasAttribute('data-affiliateo-scroll'))
364
+ scrollObserver?.observe(node);
365
+ node.querySelectorAll?.('[data-affiliateo-scroll]').forEach((el) => scrollObserver?.observe(el));
366
+ });
367
+ }
368
+ });
369
+ scrollMo.observe(document.body, { childList: true, subtree: true });
370
+ }
154
371
  return () => {
372
+ document.removeEventListener('visibilitychange', onVisibilityChange);
373
+ window.removeEventListener('pagehide', sendEnd);
155
374
  window.removeEventListener('popstate', onUrlChange);
375
+ document.removeEventListener('click', onDocClick);
376
+ scrollMo?.disconnect();
377
+ scrollObserver?.disconnect();
156
378
  history.pushState = origPushState;
157
379
  history.replaceState = origReplaceState;
380
+ providerMountCount = Math.max(0, providerMountCount - 1);
158
381
  };
159
- }, [campaignId, apiBase]);
382
+ }, [apiBase, track, identify]);
160
383
  return (_jsx(AffiliateoContext.Provider, { value: state, children: children }));
161
384
  }
package/dist/types.d.ts CHANGED
@@ -8,4 +8,30 @@ export interface AffiliateoContextValue {
8
8
  refCode: string | null;
9
9
  /** The visitor ID stored in the cookie */
10
10
  visitorId: string | null;
11
+ /** Fire a custom goal. signup_completed, trial_started, feature_used, etc.
12
+ * Best-effort: failures are swallowed so analytics calls never throw in
13
+ * the host app. Event name must be lowercase letters / digits / `_-:`
14
+ * and ≤ 64 chars. Optional metadata is bounded to 4KB JSON. The funnel
15
+ * evaluator's step type 'custom' matches against the event name.
16
+ *
17
+ * Example:
18
+ * const { track } = useAffiliateRef()
19
+ * track('signup_completed', { plan: 'pro' }) */
20
+ track: (eventName: string, metadata?: Record<string, unknown>) => Promise<void>;
21
+ /** Link this anonymous visitor to a merchant user_id. Call after
22
+ * sign-in / sign-up so the funnel can stitch the same person across
23
+ * devices, subdomains, and sessions.
24
+ *
25
+ * Without identify(), browsing on phone + buying on laptop look like
26
+ * two unrelated visitors and the funnel can't connect them. With it,
27
+ * both events resolve to the same user_id and the funnel reports a
28
+ * single converted person.
29
+ *
30
+ * Idempotent: safe to call on every page load. user_id only —
31
+ * the SDK does not accept or transmit email or any other PII.
32
+ *
33
+ * Example:
34
+ * const { identify } = useAffiliateRef()
35
+ * useEffect(() => { if (user) identify(user.id) }, [user]) */
36
+ identify: (userId: string) => Promise<void>;
11
37
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@affiliateo/web",
3
- "version": "2.0.0",
3
+ "version": "2.2.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",