@affiliateo/web 2.4.0 → 3.0.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.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Client-side bot detection. Mirrors Datafast / PostHog / Plausible's
3
+ * approach: catch the obvious automation tools at SDK init so they
4
+ * never fire a single network request.
5
+ *
6
+ * Defense in depth:
7
+ * 1. This check — catches Selenium / Puppeteer / curl / etc
8
+ * BEFORE anything hits the wire. Saves bandwidth.
9
+ * Can be bypassed by a determined attacker who
10
+ * edits the bundled SDK.
11
+ * 2. Server UA regex — catches the same patterns plus legit crawlers
12
+ * (Googlebot, FB crawler, etc). Server is
13
+ * authoritative. Can't be bypassed.
14
+ * 3. Cloudflare — invisible browser-attestation challenge. Real
15
+ * Turnstile browsers pass in 50-200ms; scripts that don't
16
+ * run real JS can't pass it. Catches sophisticated
17
+ * attackers who spoofed their UA past layer 2.
18
+ *
19
+ * This file implements layer 1 only. Order: layer 1 first (cheap), then
20
+ * layer 3 (kicks in on the request), then layer 2 (server checks before
21
+ * writing). If any one layer rejects, the event drops.
22
+ *
23
+ * Patterns sourced from the Datafast script tag + npm SDK (publicly
24
+ * inspectable). We include their full list because it represents years
25
+ * of accumulated bot-shape knowledge that's expensive to reproduce.
26
+ */
27
+ /**
28
+ * Returns true if the current browser context looks like a bot or
29
+ * automation tool. Safe to call in SSR (returns true since there's no
30
+ * real visitor in that context anyway — a server-rendered page
31
+ * shouldn't be firing analytics).
32
+ *
33
+ * Wraps every check in try/catch so a hostile environment (frozen
34
+ * navigator, locked-down iframe) can't crash the SDK init. Any throw
35
+ * == "not a bot, let them through" so we err on the side of measuring.
36
+ */
37
+ export declare function isLikelyBot(): boolean;
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Client-side bot detection. Mirrors Datafast / PostHog / Plausible's
3
+ * approach: catch the obvious automation tools at SDK init so they
4
+ * never fire a single network request.
5
+ *
6
+ * Defense in depth:
7
+ * 1. This check — catches Selenium / Puppeteer / curl / etc
8
+ * BEFORE anything hits the wire. Saves bandwidth.
9
+ * Can be bypassed by a determined attacker who
10
+ * edits the bundled SDK.
11
+ * 2. Server UA regex — catches the same patterns plus legit crawlers
12
+ * (Googlebot, FB crawler, etc). Server is
13
+ * authoritative. Can't be bypassed.
14
+ * 3. Cloudflare — invisible browser-attestation challenge. Real
15
+ * Turnstile browsers pass in 50-200ms; scripts that don't
16
+ * run real JS can't pass it. Catches sophisticated
17
+ * attackers who spoofed their UA past layer 2.
18
+ *
19
+ * This file implements layer 1 only. Order: layer 1 first (cheap), then
20
+ * layer 3 (kicks in on the request), then layer 2 (server checks before
21
+ * writing). If any one layer rejects, the event drops.
22
+ *
23
+ * Patterns sourced from the Datafast script tag + npm SDK (publicly
24
+ * inspectable). We include their full list because it represents years
25
+ * of accumulated bot-shape knowledge that's expensive to reproduce.
26
+ */
27
+ // Selenium/WebDriver/headless flags set on window or navigator. Modern
28
+ // automation frameworks try to hide these but the defaults still leak.
29
+ const AUTOMATION_GLOBALS = [
30
+ '__webdriver_evaluate',
31
+ '__selenium_evaluate',
32
+ '__webdriver_script_function',
33
+ '__webdriver_unwrapped',
34
+ '__fxdriver_evaluate',
35
+ '__driver_evaluate',
36
+ '_Selenium_IDE_Recorder',
37
+ '_selenium',
38
+ 'calledSelenium',
39
+ // ChromeDriver's signature global. The dollar-sign-cdc prefix is
40
+ // a known fingerprint that vanilla undetected-chromedriver tries to
41
+ // strip but base ChromeDriver still includes.
42
+ '$cdc_asdjflasutopfhvcZLmcfl_',
43
+ ];
44
+ // User-agent substrings (lowercased before match). These cover both the
45
+ // headless browser families AND the tool families (curl/python/etc).
46
+ // Real human UAs do contain things like "Mobile/15E148" so we match on
47
+ // distinctive automation tokens only.
48
+ const BOT_UA_PATTERNS = [
49
+ 'headlesschrome',
50
+ 'phantomjs',
51
+ 'selenium',
52
+ 'webdriver',
53
+ 'puppeteer',
54
+ 'playwright',
55
+ 'python',
56
+ 'curl',
57
+ 'wget',
58
+ 'java/',
59
+ 'go-http',
60
+ 'node.js',
61
+ 'axios',
62
+ 'postman',
63
+ ];
64
+ // documentElement attributes some Selenium variants set. Cheap to check.
65
+ const BOT_DOC_ATTRS = ['webdriver', 'selenium', 'driver'];
66
+ /**
67
+ * Returns true if the current browser context looks like a bot or
68
+ * automation tool. Safe to call in SSR (returns true since there's no
69
+ * real visitor in that context anyway — a server-rendered page
70
+ * shouldn't be firing analytics).
71
+ *
72
+ * Wraps every check in try/catch so a hostile environment (frozen
73
+ * navigator, locked-down iframe) can't crash the SDK init. Any throw
74
+ * == "not a bot, let them through" so we err on the side of measuring.
75
+ */
76
+ export function isLikelyBot() {
77
+ // SSR or non-browser context. We can't run analytics here anyway
78
+ // because there's no localStorage / sendBeacon / DOM events.
79
+ if (typeof window === 'undefined' || typeof navigator === 'undefined')
80
+ return true;
81
+ try {
82
+ const nav = window.navigator;
83
+ const win = window;
84
+ // Selenium / Puppeteer / Playwright signature on navigator.
85
+ // Modern stealth plugins try to override this but the override
86
+ // is detectable too (the property descriptor looks wrong) — we
87
+ // catch the easy case here and let Turnstile catch the rest.
88
+ if (nav.webdriver === true)
89
+ return true;
90
+ // Legacy headless flags (PhantomJS / Nightmare). Still present in
91
+ // some old scraper toolchains; cheap to check.
92
+ if (win.callPhantom !== undefined)
93
+ return true;
94
+ if (win._phantom !== undefined)
95
+ return true;
96
+ if (win.__nightmare !== undefined)
97
+ return true;
98
+ // Missing / suspicious UA. Real browsers always set a useful UA
99
+ // string; a missing or stub UA strongly implies a HTTP client
100
+ // (curl --user-agent "" / a python script that forgot to set one).
101
+ if (!nav.userAgent
102
+ || nav.userAgent === ''
103
+ || nav.userAgent === 'undefined'
104
+ || nav.userAgent.length < 5) {
105
+ return true;
106
+ }
107
+ const ua = nav.userAgent.toLowerCase();
108
+ // Substring match on the known automation tokens. We split into a
109
+ // single loop instead of one giant regex for readability — the cost
110
+ // difference at <20 patterns is microseconds either way.
111
+ for (const pattern of BOT_UA_PATTERNS) {
112
+ if (ua.includes(pattern))
113
+ return true;
114
+ }
115
+ // Selenium globals on window. Some frameworks reset navigator but
116
+ // forget about these.
117
+ for (const key of AUTOMATION_GLOBALS) {
118
+ if (win[key] !== undefined)
119
+ return true;
120
+ }
121
+ // documentElement attributes. Cheap belt-and-suspenders check; some
122
+ // Selenium variants annotate the HTML root for their own bookkeeping.
123
+ const docEl = document.documentElement;
124
+ if (docEl) {
125
+ for (const attr of BOT_DOC_ATTRS) {
126
+ if (docEl.getAttribute(attr))
127
+ return true;
128
+ }
129
+ }
130
+ return false;
131
+ }
132
+ catch {
133
+ // Defensive: if any of the checks throw (locked-down iframe,
134
+ // proxy-wrapped navigator, etc.), don't block the visitor. Server
135
+ // regex + Turnstile are still in place to catch whatever this is.
136
+ return false;
137
+ }
138
+ }
package/dist/provider.js CHANGED
@@ -1,10 +1,17 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
4
+ import { EventQueue } from './queue';
5
+ import { isLikelyBot } from './bot-detect';
4
6
  const DEFAULT_API_URL = 'https://affiliateo.com';
5
7
  const LS_VISITOR_ID = 'affiliateo_visitor_id';
6
8
  const LS_REF = 'affiliateo_ref';
7
9
  const LS_CAMPAIGN_ID = 'affiliateo_campaign_id';
10
+ // Persistent opt-out flag. Mirrors Datafast's DATAFAST_IGNORE_TRACKING key —
11
+ // when set to "true" the SDK silently noops every outbound call. Survives
12
+ // page reloads. removed by optIn(). Checked at init time so a previously
13
+ // opted-out visitor never re-fires auto-pageviews after a refresh.
14
+ const LS_OPT_OUT = 'affiliateo_opt_out';
8
15
  // localStorage helpers
9
16
  function lsGet(key) {
10
17
  try {
@@ -20,6 +27,18 @@ function lsSet(key, value) {
20
27
  }
21
28
  catch { }
22
29
  }
30
+ function lsRemove(key) {
31
+ try {
32
+ localStorage.removeItem(key);
33
+ }
34
+ catch { }
35
+ }
36
+ // True when the visitor has opted out via optOut(). Single source of truth
37
+ // read by every outbound call so the flag works even across tabs (each tab's
38
+ // next call re-reads localStorage).
39
+ function isOptedOut() {
40
+ return lsGet(LS_OPT_OUT) === 'true';
41
+ }
23
42
  // Generate UUID
24
43
  function uuid() {
25
44
  if (typeof crypto !== 'undefined' && crypto.randomUUID)
@@ -31,11 +50,16 @@ function uuid() {
31
50
  });
32
51
  }
33
52
  const asyncNoop = async () => { };
53
+ const syncNoop = () => { };
34
54
  const AffiliateoContext = createContext({
35
55
  refCode: null,
36
56
  visitorId: null,
37
57
  track: asyncNoop,
38
58
  identify: asyncNoop,
59
+ trackPageview: asyncNoop,
60
+ reset: asyncNoop,
61
+ optOut: syncNoop,
62
+ optIn: syncNoop,
39
63
  });
40
64
  /**
41
65
  * Returns the affiliate ref code and visitor ID.
@@ -82,6 +106,10 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
82
106
  visitorId: null,
83
107
  track: asyncNoop,
84
108
  identify: asyncNoop,
109
+ trackPageview: asyncNoop,
110
+ reset: asyncNoop,
111
+ optOut: syncNoop,
112
+ optIn: syncNoop,
85
113
  });
86
114
  const apiBase = (apiUrl || DEFAULT_API_URL).replace(/\/$/, '');
87
115
  // campaignIdRef stays current so callbacks defined inside useEffect
@@ -93,6 +121,15 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
93
121
  // prop changes without forcing a re-fetch / re-mint cycle. Same UUID
94
122
  // for the lifetime of the localStorage entry across the whole tab.
95
123
  const visitorIdRef = useRef(null);
124
+ // EventQueue lives in a ref so it persists across renders without
125
+ // forcing the methods (track / identify / trackPageview) to depend
126
+ // on it for useCallback equality. Initialized inside the route
127
+ // effect since EventQueue's constructor touches localStorage and
128
+ // window — both unavailable during SSR. Methods called before init
129
+ // (during the first render, before useEffect runs) silently noop;
130
+ // this is fine because methods are only ever called from event
131
+ // handlers, which fire after mount.
132
+ const queueRef = useRef(null);
96
133
  // track + identify are defined OUTSIDE the route-effect so they
97
134
  // close over refs (campaignIdRef, visitorIdRef) rather than the
98
135
  // captured-once values from inside the effect. A consumer caching
@@ -101,6 +138,10 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
101
138
  try {
102
139
  if (typeof window === 'undefined')
103
140
  return;
141
+ // Opt-out is checked at call time (not init time) so a visitor who
142
+ // opts out mid-session stops firing immediately on the next track().
143
+ if (isOptedOut())
144
+ return;
104
145
  if (!eventName || typeof eventName !== 'string')
105
146
  return;
106
147
  const name = eventName.trim().toLowerCase();
@@ -118,19 +159,11 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
118
159
  };
119
160
  if (metadata && typeof metadata === 'object')
120
161
  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
- }
162
+ // Enqueue. EventQueue handles persist, retry, and flush. If the
163
+ // queue hasn't initialized yet (called during first render before
164
+ // useEffect runs), this noops — that's a vanishingly rare race
165
+ // since track() is virtually always called from a click handler.
166
+ queueRef.current?.enqueue(`${apiBase}/api/v1/web/event`, payload);
134
167
  }
135
168
  catch { /* swallow */ }
136
169
  }, [apiBase]);
@@ -141,6 +174,8 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
141
174
  try {
142
175
  if (typeof window === 'undefined')
143
176
  return;
177
+ if (isOptedOut())
178
+ return;
144
179
  if (!userId || typeof userId !== 'string')
145
180
  return;
146
181
  const uid = userId.trim();
@@ -158,22 +193,109 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
158
193
  visitor_id: vid,
159
194
  user_id: uid,
160
195
  };
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
- }
196
+ queueRef.current?.enqueue(`${apiBase}/api/v1/web/identify`, payload);
197
+ }
198
+ catch { /* swallow */ }
199
+ }, [apiBase]);
200
+ // Manual pageview. Mirrors Datafast's trackPageview(path?). Same payload
201
+ // shape the auto-fired pageview uses (see sendPageview inside the route
202
+ // effect below) so server-side routing / dedup / IP+UA enrichment behave
203
+ // identically for both code paths.
204
+ //
205
+ // Defined here as a useCallback (not inside the effect) so React consumers
206
+ // who cache the function reference across renders aren't pointing at a
207
+ // stale closure. Reads from refs for the per-render-stable bits and
208
+ // localStorage for the persisted attribution (ref code), so the call
209
+ // always sees the latest values.
210
+ const trackPageview = useCallback(async (path) => {
211
+ try {
212
+ if (typeof window === 'undefined')
213
+ return;
214
+ if (isOptedOut())
215
+ return;
216
+ const cid = campaignIdRef.current;
217
+ const vid = visitorIdRef.current;
218
+ if (!cid || !vid)
219
+ return;
220
+ const params = new URLSearchParams(window.location.search);
221
+ const pagePath = path || window.location.pathname;
222
+ // url: if caller passed a path, resolve it against origin for a full
223
+ // href the server can dedup on. Otherwise use the actual location.
224
+ const url = path
225
+ ? new URL(path, window.location.origin).href
226
+ : window.location.href;
227
+ const payload = {
228
+ type: 'pageview',
229
+ campaign_id: cid,
230
+ visitor_id: vid,
231
+ url,
232
+ path: pagePath,
233
+ hostname: window.location.hostname,
234
+ referrer: document.referrer || '',
235
+ ref: lsGet(LS_REF) || '',
236
+ utm_source: params.get('utm_source') || '',
237
+ utm_medium: params.get('utm_medium') || '',
238
+ utm_campaign: params.get('utm_campaign') || '',
239
+ screen_width: window.innerWidth,
240
+ };
241
+ queueRef.current?.enqueue(`${apiBase}/api/v1/web/event`, payload);
174
242
  }
175
243
  catch { /* swallow */ }
176
244
  }, [apiBase]);
245
+ // reset(): wipe visitor identity. Mints a fresh UUID and clears the
246
+ // affiliate ref so the next pageview is a brand-new anonymous visitor.
247
+ // The campaign_id stays (it's a property of the site, not the visitor).
248
+ //
249
+ // Matches Datafast's reset() semantics for non-cookieless mode: new
250
+ // visitor, no event fired, caller handles "tell the server about it"
251
+ // by triggering whatever pageview / navigation comes next.
252
+ //
253
+ // Returns a Promise so callers can `await reset()` before navigating
254
+ // (the localStorage writes are sync but the Promise lets the API stay
255
+ // consistent with Datafast's async signature, future-proofing for
256
+ // server-minted IDs in cookieless mode).
257
+ const reset = useCallback(async () => {
258
+ try {
259
+ if (typeof window === 'undefined')
260
+ return;
261
+ // Drain any pending events for the OLD visitor before swapping IDs,
262
+ // otherwise they'd land server-side stamped to the OLD visitor_id
263
+ // (correct) but persisted-in-localStorage as if to-be-retried under
264
+ // the new identity — confusing on retry inspection. Best effort.
265
+ void queueRef.current?.flush();
266
+ queueRef.current?.clear();
267
+ const newVid = uuid();
268
+ lsSet(LS_VISITOR_ID, newVid);
269
+ lsRemove(LS_REF);
270
+ visitorIdRef.current = newVid;
271
+ setState((prev) => ({ ...prev, visitorId: newVid, refCode: null }));
272
+ }
273
+ catch { /* swallow */ }
274
+ }, []);
275
+ // optOut(): persistent "don't track me" flag. After this call every
276
+ // track / identify / trackPageview noops until optIn() is called. The
277
+ // flag survives reloads via localStorage, so a visitor who opts out
278
+ // stays opted out across browser sessions on the same device.
279
+ //
280
+ // We don't actively send a "stop tracking me" signal to the server —
281
+ // the server only knows about visitors who ping it, so an opted-out
282
+ // visitor simply stops appearing. The visitor row left behind in
283
+ // Postgres ages out via normal retention.
284
+ const optOut = useCallback(() => {
285
+ lsSet(LS_OPT_OUT, 'true');
286
+ // Drop pending events too. The visitor explicitly said no — sending
287
+ // queued events from before the opt-out would still violate consent
288
+ // even if those events were captured prior to the user's decision.
289
+ queueRef.current?.clear();
290
+ }, []);
291
+ // optIn(): remove the opt-out flag. Tracking RESUMES for any future
292
+ // calls (track / identify / trackPageview) but the in-flight auto-
293
+ // pageview that already noop'd on this render won't retroactively
294
+ // send. A page reload is the cleanest way to get a fresh auto-fired
295
+ // pageview after opting back in.
296
+ const optIn = useCallback(() => {
297
+ lsRemove(LS_OPT_OUT);
298
+ }, []);
177
299
  const sentRef = useRef(false);
178
300
  useEffect(() => {
179
301
  if (typeof window === 'undefined')
@@ -186,6 +308,65 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
186
308
  // eslint-disable-next-line no-console
187
309
  console.warn('[Affiliateo] AffiliateoProvider mounted more than once. Mount it exactly once near the root of your app, or pageviews will double-fire.');
188
310
  }
311
+ // Client-side bot detection. If the current browser context looks
312
+ // like Selenium / Puppeteer / curl / a known automation tool, skip
313
+ // ALL tracking before we even mint a queue. The server-side UA
314
+ // regex + Turnstile catch anything that sneaks past this check —
315
+ // this just saves the bot a round-trip and saves us a request.
316
+ //
317
+ // The hook still returns the four control methods (reset / optOut /
318
+ // etc) so a host UI that happens to render in a headless context
319
+ // doesn't crash on `useAffiliateRef().reset()`. They're noops, which
320
+ // is the correct behavior since there's no real visitor to act on.
321
+ if (isLikelyBot()) {
322
+ setState({
323
+ refCode: null,
324
+ visitorId: null,
325
+ track: asyncNoop,
326
+ identify: asyncNoop,
327
+ trackPageview: asyncNoop,
328
+ reset: asyncNoop,
329
+ optOut: syncNoop,
330
+ optIn: syncNoop,
331
+ });
332
+ return;
333
+ }
334
+ // Initialize the event queue. Done inside useEffect (not in a useRef
335
+ // initializer) so the EventQueue constructor — which touches
336
+ // localStorage on hydration — only ever runs client-side. The queue
337
+ // takes over delivery for track / identify / trackPageview AND the
338
+ // auto-fired pageview below; only session_end stays on sendBeacon
339
+ // because tab-close urgency beats queue-retry reliability.
340
+ if (queueRef.current === null) {
341
+ queueRef.current = new EventQueue();
342
+ }
343
+ const queue = queueRef.current;
344
+ // Opt-out short-circuit. A visitor who previously called optOut()
345
+ // gets ZERO outbound traffic on this page load. We still mint the
346
+ // visitor_id (so optIn() + reload picks up the same identity rather
347
+ // than starting fresh, which would split their history), but skip
348
+ // ALL beacons including the auto-pageview, plus skip the event
349
+ // listeners (no point in firing session_end for a visitor we never
350
+ // told the server about). The hook still returns the four control
351
+ // methods so the host's banner UI can call optIn() / reset().
352
+ if (isOptedOut()) {
353
+ let optedOutVid = lsGet(LS_VISITOR_ID);
354
+ if (!optedOutVid) {
355
+ optedOutVid = uuid();
356
+ lsSet(LS_VISITOR_ID, optedOutVid);
357
+ }
358
+ visitorIdRef.current = optedOutVid;
359
+ setState({
360
+ refCode: null,
361
+ visitorId: optedOutVid,
362
+ track, identify, trackPageview, reset, optOut, optIn,
363
+ });
364
+ // Don't leave a queue running for an opted-out visitor — the timer
365
+ // would just keep waking up to flush nothing.
366
+ queue.shutdown();
367
+ queueRef.current = null;
368
+ return;
369
+ }
189
370
  // Get or create visitor ID
190
371
  let vid = lsGet(LS_VISITOR_ID);
191
372
  if (!vid) {
@@ -202,21 +383,11 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
202
383
  lsSet(LS_REF, ref);
203
384
  }
204
385
  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
- }
386
+ setState({
387
+ refCode: ref,
388
+ visitorId: vid,
389
+ track, identify, trackPageview, reset, optOut, optIn,
390
+ });
220
391
  // Throttle: max 1 pageview per second
221
392
  let lastSendTime = 0;
222
393
  function sendPageview() {
@@ -225,7 +396,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
225
396
  return;
226
397
  lastSendTime = now;
227
398
  ended = false;
228
- beacon({
399
+ queue.enqueue(endpoint, {
229
400
  type: 'pageview',
230
401
  campaign_id: campaignIdRef.current,
231
402
  visitor_id: vid,
@@ -240,13 +411,43 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
240
411
  screen_width: window.innerWidth,
241
412
  });
242
413
  }
243
- // Send session_end when user leaves
414
+ // Send session_end when user leaves.
415
+ //
416
+ // session_end intentionally bypasses the queue and uses sendBeacon
417
+ // directly. The queue's fetch-with-keepalive is great for normal
418
+ // events because it returns a status code we can retry on, but it
419
+ // can't guarantee the request lands when the tab is in the act of
420
+ // closing — fetch may be aborted mid-flight. sendBeacon was
421
+ // designed exactly for this case and CANNOT be cancelled by the
422
+ // unload handler. We also try to drain the queue's pending events
423
+ // here, which uses sendBeacon under the hood for the same reason.
244
424
  let ended = false;
245
425
  function sendEnd() {
246
426
  if (ended)
247
427
  return;
248
428
  ended = true;
249
- beacon({ type: 'session_end', campaign_id: campaignIdRef.current, visitor_id: vid, ref: ref || lsGet(LS_REF) || '' });
429
+ // Drain the queue first so any pending track / identify events
430
+ // get a last shot before the tab closes. flushOnUnload uses
431
+ // sendBeacon internally, no retry needed because there's no
432
+ // retry opportunity after this.
433
+ queue.flushOnUnload();
434
+ const data = JSON.stringify({
435
+ type: 'session_end',
436
+ campaign_id: campaignIdRef.current,
437
+ visitor_id: vid,
438
+ ref: ref || lsGet(LS_REF) || '',
439
+ });
440
+ if (navigator.sendBeacon) {
441
+ navigator.sendBeacon(endpoint, new Blob([data], { type: 'application/json' }));
442
+ }
443
+ else {
444
+ fetch(endpoint, {
445
+ method: 'POST',
446
+ headers: { 'Content-Type': 'application/json' },
447
+ body: data,
448
+ keepalive: true,
449
+ }).catch(() => { });
450
+ }
250
451
  }
251
452
  // Server-side session window. Matches STALE_VISITOR_MS in the
252
453
  // tracking Durable Object — after this much idle time the server
@@ -303,113 +504,30 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
303
504
  origReplaceState.apply(this, args);
304
505
  onUrlChange();
305
506
  };
306
- // HTML-attribute goal tracking. match Datafast's `data-fast-goal`
307
- // ergonomics. Any element with `data-affiliateo-goal="event_name"`
308
- // fires that goal on click. Optional `data-affiliateo-goal-*`
309
- // attributes become metadata (kebab-case to snake_case, max 10).
507
+ // HTML-attribute goal scanning (`data-affiliateo-goal`,
508
+ // `data-affiliateo-scroll`) and the IntersectionObserver that drove
509
+ // them were removed in 3.0.0 to match Datafast's npm SDK ergonomics:
510
+ // SDK does pageview + track() + identify(); merchants fire goals
511
+ // themselves via onClick={() => track('signup_clicked')} or by
512
+ // wiring their own IntersectionObserver and calling track() from it.
310
513
  //
311
- // Delegated listener on document.body so the merchant doesn't have
312
- // to re-attach handlers when their SPA re-renders. Capture phase off
313
- // so React's synthetic event ordering isn't disturbed.
314
- function onDocClick(e) {
315
- const target = e.target;
316
- if (!(target instanceof Element))
317
- return;
318
- const el = target.closest('[data-affiliateo-goal]');
319
- if (!el)
320
- return;
321
- const goal = el.getAttribute('data-affiliateo-goal');
322
- if (!goal)
323
- return;
324
- const meta = {};
325
- let count = 0;
326
- for (const { name, value } of Array.from(el.attributes)) {
327
- if (!name.startsWith('data-affiliateo-goal-'))
328
- continue;
329
- // Skip the bare "data-affiliateo-goal" attribute itself
330
- // (slice would produce an empty key).
331
- if (name === 'data-affiliateo-goal')
332
- continue;
333
- if (count >= 10)
334
- break;
335
- const key = name.slice('data-affiliateo-goal-'.length).replace(/-/g, '_');
336
- // Skip the reserved `event` key so an attribute like
337
- // `data-affiliateo-goal-event="x"` can't override the actual
338
- // goal name when the SDK / server folds metadata.event in.
339
- if (!key || key === 'event')
340
- continue;
341
- if (value) {
342
- meta[key] = String(value).slice(0, 255);
343
- count++;
344
- }
345
- }
346
- track(goal, count ? meta : undefined);
347
- }
348
- document.addEventListener('click', onDocClick);
349
- // Scroll-depth goal tracking. `data-affiliateo-scroll="event_name"`
350
- // on any element fires that goal the first time the element enters
351
- // the viewport. Use IntersectionObserver (cheap, off the scroll hot
352
- // path) and unobserve after the first fire so the same element can't
353
- // double-count if the user scrolls back up.
354
- //
355
- // No options object → IntersectionObserver defaults to
356
- // { threshold: 0, rootMargin: '0px 0px 0px 0px' }, which fires the
357
- // instant ANY pixel of the section overlaps the viewport. That's
358
- // exactly the semantic merchants want for "did the visitor reach
359
- // this section": if they scrolled enough to see even a sliver of
360
- // it, count it. Earlier versions used { threshold: 0.5 }, which
361
- // was unreachable for tall sections (a 1600px pricing section in
362
- // an 800px viewport tops out at ~50% intersection ratio and never
363
- // crossed the threshold) — funnel steps tied to those sections
364
- // silently dropped from every merchant's conversion count.
365
- const scrollFired = new WeakSet();
366
- let scrollObserver = null;
367
- let scrollMo = null;
368
- if (typeof IntersectionObserver !== 'undefined') {
369
- scrollObserver = new IntersectionObserver((entries) => {
370
- for (const entry of entries) {
371
- if (!entry.isIntersecting)
372
- continue;
373
- if (scrollFired.has(entry.target))
374
- continue;
375
- scrollFired.add(entry.target);
376
- const goal = entry.target.getAttribute('data-affiliateo-scroll');
377
- if (goal)
378
- track(goal);
379
- scrollObserver?.unobserve(entry.target);
380
- }
381
- });
382
- // Observe everything that exists at mount time.
383
- document.querySelectorAll('[data-affiliateo-scroll]').forEach((el) => {
384
- scrollObserver?.observe(el);
385
- });
386
- // SPA-rendered sections that appear later get observed via
387
- // MutationObserver. Cheap because we only scan added nodes, not
388
- // the whole tree on each mutation.
389
- scrollMo = new MutationObserver((mutations) => {
390
- for (const m of mutations) {
391
- m.addedNodes.forEach((node) => {
392
- if (!(node instanceof Element))
393
- return;
394
- if (node.hasAttribute('data-affiliateo-scroll'))
395
- scrollObserver?.observe(node);
396
- node.querySelectorAll?.('[data-affiliateo-scroll]').forEach((el) => scrollObserver?.observe(el));
397
- });
398
- }
399
- });
400
- scrollMo.observe(document.body, { childList: true, subtree: true });
401
- }
514
+ // Why removed: the scanning layer was the source of every funnel
515
+ // bug we hit (threshold:0 firing on hero sections, SPA mutation
516
+ // observer races, client-side step matching from HTML). Removing it
517
+ // also drops ~80 lines of code and one extra DOM observer per page.
402
518
  return () => {
403
519
  document.removeEventListener('visibilitychange', onVisibilityChange);
404
520
  window.removeEventListener('pagehide', sendEnd);
405
521
  window.removeEventListener('popstate', onUrlChange);
406
- document.removeEventListener('click', onDocClick);
407
- scrollMo?.disconnect();
408
- scrollObserver?.disconnect();
409
522
  history.pushState = origPushState;
410
523
  history.replaceState = origReplaceState;
524
+ // Stop the queue's flush timer. The queue tries one final flush
525
+ // before going quiet — anything still queued lives in localStorage
526
+ // and will be picked up by the next page load's queue init.
527
+ queue.shutdown();
528
+ queueRef.current = null;
411
529
  providerMountCount = Math.max(0, providerMountCount - 1);
412
530
  };
413
- }, [apiBase, track, identify]);
531
+ }, [apiBase, track, identify, trackPageview, reset, optOut, optIn]);
414
532
  return (_jsx(AffiliateoContext.Provider, { value: state, children: children }));
415
533
  }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * EventQueue: durable best-effort delivery for analytics events.
3
+ *
4
+ * Why this exists:
5
+ * Before this module, every track / identify / pageview was fire-and-forget
6
+ * via navigator.sendBeacon. If the visitor's network blinked, the server
7
+ * was briefly overloaded, or any other transient failure hit, the event
8
+ * was lost with no retry, no log, no observability. On flaky mobile
9
+ * networks (train wifi, subway transitions, dropping 4G) merchants saw
10
+ * ~10-15% fewer events than they should have.
11
+ *
12
+ * This queue mirrors the pattern PostHog / Segment / Amplitude / Datafast
13
+ * all converged on: in-memory queue, localStorage persistence so events
14
+ * survive page reload, periodic auto-flush, retry on failure up to N
15
+ * times, drop after N tries to bound memory.
16
+ *
17
+ * Design notes:
18
+ * - Persisted state is JSON in localStorage under STORAGE_KEY. The
19
+ * persisted shape is intentionally simple (id + payload + retries)
20
+ * so a future schema bump doesn't break a visitor who upgraded
21
+ * mid-session and has events from the old shape sitting around.
22
+ * - Failures are silent. The host app should never see an analytics
23
+ * error bubble up. Console-noise during dev is the only signal.
24
+ * - sendBeacon is NOT used here. sendBeacon is great for "fire on
25
+ * unload" but it doesn't return a response we can branch on for
26
+ * retry. fetch + keepalive gives us a status code AND lets the
27
+ * browser keep the request alive across navigation. Trade-off: a
28
+ * queued event fired right before the visitor closes the tab might
29
+ * not land. That's why session_end stays on sendBeacon (see
30
+ * provider.tsx, sendEnd helper).
31
+ * - The flush timer uses setInterval. Stopped on shutdown(). The
32
+ * provider only ever creates one queue per mount, so timer leaks
33
+ * aren't a practical concern.
34
+ *
35
+ * Caps:
36
+ * - MAX_RETRIES = 3 per-event retry ceiling before drop
37
+ * - MAX_QUEUE_SIZE hard cap so a long network outage can't fill
38
+ * localStorage and break the host app. Events
39
+ * beyond the cap are dropped FIFO (oldest first).
40
+ * - FLUSH_INTERVAL_MS = 5_000 matches Datafast's web SDK default
41
+ * - SIZE_FLUSH_THRESHOLD = 10 triggers a flush when queue grows
42
+ * past this even before the timer fires
43
+ */
44
+ export interface QueuedEvent {
45
+ /** Stable id used to identify the event across retries. UUID v4
46
+ * generated client-side; collisions are astronomically unlikely so
47
+ * we don't bother with a fallback. */
48
+ id: string;
49
+ /** The exact body the server expects. We persist the full payload
50
+ * (not a delta) so a visitor who reloads mid-flush can still recover
51
+ * every queued event without needing to know the original call site. */
52
+ payload: Record<string, unknown>;
53
+ /** Endpoint to POST to. Keyed per-event so a single queue can hold
54
+ * both /api/v1/web/event (track + pageview) and /api/v1/web/identify
55
+ * events without a separate queue per endpoint. */
56
+ endpoint: string;
57
+ /** Retry counter. Incremented on each failed flush. Event is dropped
58
+ * when this hits MAX_RETRIES. */
59
+ retries: number;
60
+ }
61
+ export declare class EventQueue {
62
+ private queue;
63
+ private flushTimer;
64
+ private isFlushing;
65
+ private shuttingDown;
66
+ constructor();
67
+ /** Add an event. Persists synchronously to localStorage so a tab
68
+ * close right after enqueue doesn't lose the event from memory
69
+ * before it's safely on disk. */
70
+ enqueue(endpoint: string, payload: Record<string, unknown>): void;
71
+ /** Try to deliver every queued event. Each event gets one attempt
72
+ * per flush; failures bump retries and stay in the queue for the
73
+ * next flush. Events that hit MAX_RETRIES get dropped.
74
+ *
75
+ * Concurrency: guarded by isFlushing so overlapping calls (the
76
+ * burst path + the timer firing simultaneously) only run one
77
+ * flush at a time. */
78
+ flush(): Promise<void>;
79
+ /** Best-effort sync flush triggered on page unload. Uses sendBeacon
80
+ * for each event so it survives the tab closing. No retry — this is
81
+ * the last chance.
82
+ *
83
+ * Called from the provider's pagehide / visibilitychange=hidden
84
+ * handlers so any events still queued at unload get one last shot.
85
+ * After this call, the in-memory queue is cleared (events that
86
+ * sendBeacon couldn't queue are lost — that's the cost of the
87
+ * guaranteed-fast unload path). */
88
+ flushOnUnload(): void;
89
+ /** Wipe the queue. Called by reset() and optOut(). Removes the
90
+ * localStorage entry entirely so a reload doesn't re-hydrate the
91
+ * wiped events. */
92
+ clear(): void;
93
+ /** Stop the flush timer and try one final flush. Idempotent. */
94
+ shutdown(): void;
95
+ /** Read-only size getter. Used by tests / debug consoles. */
96
+ get size(): number;
97
+ private persist;
98
+ private startFlushTimer;
99
+ }
package/dist/queue.js ADDED
@@ -0,0 +1,266 @@
1
+ /**
2
+ * EventQueue: durable best-effort delivery for analytics events.
3
+ *
4
+ * Why this exists:
5
+ * Before this module, every track / identify / pageview was fire-and-forget
6
+ * via navigator.sendBeacon. If the visitor's network blinked, the server
7
+ * was briefly overloaded, or any other transient failure hit, the event
8
+ * was lost with no retry, no log, no observability. On flaky mobile
9
+ * networks (train wifi, subway transitions, dropping 4G) merchants saw
10
+ * ~10-15% fewer events than they should have.
11
+ *
12
+ * This queue mirrors the pattern PostHog / Segment / Amplitude / Datafast
13
+ * all converged on: in-memory queue, localStorage persistence so events
14
+ * survive page reload, periodic auto-flush, retry on failure up to N
15
+ * times, drop after N tries to bound memory.
16
+ *
17
+ * Design notes:
18
+ * - Persisted state is JSON in localStorage under STORAGE_KEY. The
19
+ * persisted shape is intentionally simple (id + payload + retries)
20
+ * so a future schema bump doesn't break a visitor who upgraded
21
+ * mid-session and has events from the old shape sitting around.
22
+ * - Failures are silent. The host app should never see an analytics
23
+ * error bubble up. Console-noise during dev is the only signal.
24
+ * - sendBeacon is NOT used here. sendBeacon is great for "fire on
25
+ * unload" but it doesn't return a response we can branch on for
26
+ * retry. fetch + keepalive gives us a status code AND lets the
27
+ * browser keep the request alive across navigation. Trade-off: a
28
+ * queued event fired right before the visitor closes the tab might
29
+ * not land. That's why session_end stays on sendBeacon (see
30
+ * provider.tsx, sendEnd helper).
31
+ * - The flush timer uses setInterval. Stopped on shutdown(). The
32
+ * provider only ever creates one queue per mount, so timer leaks
33
+ * aren't a practical concern.
34
+ *
35
+ * Caps:
36
+ * - MAX_RETRIES = 3 per-event retry ceiling before drop
37
+ * - MAX_QUEUE_SIZE hard cap so a long network outage can't fill
38
+ * localStorage and break the host app. Events
39
+ * beyond the cap are dropped FIFO (oldest first).
40
+ * - FLUSH_INTERVAL_MS = 5_000 matches Datafast's web SDK default
41
+ * - SIZE_FLUSH_THRESHOLD = 10 triggers a flush when queue grows
42
+ * past this even before the timer fires
43
+ */
44
+ const STORAGE_KEY = 'affiliateo_event_queue';
45
+ const MAX_RETRIES = 3;
46
+ const MAX_QUEUE_SIZE = 100;
47
+ const FLUSH_INTERVAL_MS = 5000;
48
+ const SIZE_FLUSH_THRESHOLD = 10;
49
+ function lsGet(key) {
50
+ try {
51
+ return localStorage.getItem(key);
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ function lsSet(key, value) {
58
+ try {
59
+ localStorage.setItem(key, value);
60
+ }
61
+ catch { /* quota / private mode */ }
62
+ }
63
+ function lsRemove(key) {
64
+ try {
65
+ localStorage.removeItem(key);
66
+ }
67
+ catch { /* swallow */ }
68
+ }
69
+ function uuid() {
70
+ if (typeof crypto !== 'undefined' && crypto.randomUUID)
71
+ return crypto.randomUUID();
72
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
73
+ const r = (Math.random() * 16) | 0;
74
+ const v = c === 'x' ? r : (r & 0x3) | 0x8;
75
+ return v.toString(16);
76
+ });
77
+ }
78
+ export class EventQueue {
79
+ constructor() {
80
+ this.queue = [];
81
+ this.flushTimer = null;
82
+ this.isFlushing = false;
83
+ this.shuttingDown = false;
84
+ // Hydrate from localStorage so events from a prior page load survive
85
+ // a refresh / navigation. Guards against corrupt JSON or wrong-shape
86
+ // entries by resetting to empty if anything looks off — better to
87
+ // lose a few stuck events than crash the host app on init.
88
+ try {
89
+ const raw = lsGet(STORAGE_KEY);
90
+ if (raw) {
91
+ const parsed = JSON.parse(raw);
92
+ if (Array.isArray(parsed)) {
93
+ this.queue = parsed.filter((e) => !!e && typeof e === 'object'
94
+ && typeof e.id === 'string'
95
+ && typeof e.endpoint === 'string'
96
+ && typeof e.payload === 'object'
97
+ && typeof e.retries === 'number');
98
+ }
99
+ }
100
+ }
101
+ catch {
102
+ this.queue = [];
103
+ }
104
+ this.startFlushTimer();
105
+ // Try an immediate flush on construction so events left over from
106
+ // a previous session get a head start (especially useful on the
107
+ // page-load-after-an-outage scenario).
108
+ if (this.queue.length > 0) {
109
+ void this.flush();
110
+ }
111
+ }
112
+ /** Add an event. Persists synchronously to localStorage so a tab
113
+ * close right after enqueue doesn't lose the event from memory
114
+ * before it's safely on disk. */
115
+ enqueue(endpoint, payload) {
116
+ if (this.shuttingDown)
117
+ return;
118
+ this.queue.push({
119
+ id: uuid(),
120
+ endpoint,
121
+ payload,
122
+ retries: 0,
123
+ });
124
+ // FIFO drop when over cap. Network outage edge case: if we're way
125
+ // over budget the oldest events are likely already stale (the
126
+ // visitor moved on, the page they were on isn't current) so
127
+ // dropping them preserves more value than dropping the new ones.
128
+ if (this.queue.length > MAX_QUEUE_SIZE) {
129
+ this.queue.splice(0, this.queue.length - MAX_QUEUE_SIZE);
130
+ }
131
+ this.persist();
132
+ // Burst protection: when many events fire in quick succession
133
+ // (auto-pageview + immediate track + identify on app boot), don't
134
+ // wait for the 5s timer — flush right away. The isFlushing guard
135
+ // inside flush() makes overlapping calls safe.
136
+ if (this.queue.length >= SIZE_FLUSH_THRESHOLD) {
137
+ void this.flush();
138
+ }
139
+ }
140
+ /** Try to deliver every queued event. Each event gets one attempt
141
+ * per flush; failures bump retries and stay in the queue for the
142
+ * next flush. Events that hit MAX_RETRIES get dropped.
143
+ *
144
+ * Concurrency: guarded by isFlushing so overlapping calls (the
145
+ * burst path + the timer firing simultaneously) only run one
146
+ * flush at a time. */
147
+ async flush() {
148
+ if (this.isFlushing || this.queue.length === 0)
149
+ return;
150
+ this.isFlushing = true;
151
+ // Snapshot the queue so we iterate a stable list — new enqueue()
152
+ // calls during the flush land in the live queue and get picked up
153
+ // by the next flush rather than this one (avoids infinite loops
154
+ // and reasoning headaches).
155
+ const batch = this.queue.slice();
156
+ for (const event of batch) {
157
+ let ok = false;
158
+ try {
159
+ const res = await fetch(event.endpoint, {
160
+ method: 'POST',
161
+ headers: { 'Content-Type': 'application/json' },
162
+ body: JSON.stringify(event.payload),
163
+ // keepalive lets the request survive a navigation. paired
164
+ // with the queue so even if this fetch errors, we'll retry.
165
+ keepalive: true,
166
+ });
167
+ // 2xx = success. Anything else (4xx, 5xx) counts as a failure
168
+ // and bumps retries. A 4xx is usually a permanent error
169
+ // (bad payload, dead campaign) so retrying 3 times then
170
+ // dropping is the right move — keeps the queue clean.
171
+ ok = res.ok;
172
+ }
173
+ catch {
174
+ ok = false;
175
+ }
176
+ if (ok) {
177
+ // Remove from live queue (not the snapshot) by id
178
+ this.queue = this.queue.filter((e) => e.id !== event.id);
179
+ }
180
+ else {
181
+ // Find the live entry and bump its retry counter. Use id
182
+ // because the index may have shifted if other events were
183
+ // already removed on this pass.
184
+ const live = this.queue.find((e) => e.id === event.id);
185
+ if (live) {
186
+ live.retries++;
187
+ if (live.retries >= MAX_RETRIES) {
188
+ this.queue = this.queue.filter((e) => e.id !== event.id);
189
+ }
190
+ }
191
+ }
192
+ }
193
+ this.persist();
194
+ this.isFlushing = false;
195
+ }
196
+ /** Best-effort sync flush triggered on page unload. Uses sendBeacon
197
+ * for each event so it survives the tab closing. No retry — this is
198
+ * the last chance.
199
+ *
200
+ * Called from the provider's pagehide / visibilitychange=hidden
201
+ * handlers so any events still queued at unload get one last shot.
202
+ * After this call, the in-memory queue is cleared (events that
203
+ * sendBeacon couldn't queue are lost — that's the cost of the
204
+ * guaranteed-fast unload path). */
205
+ flushOnUnload() {
206
+ if (this.queue.length === 0)
207
+ return;
208
+ if (typeof navigator === 'undefined' || !navigator.sendBeacon) {
209
+ // No sendBeacon (very old browser) — try a sync fetch with
210
+ // keepalive as a fallback. Won't actually finish, but it's our
211
+ // last shot.
212
+ for (const event of this.queue) {
213
+ try {
214
+ fetch(event.endpoint, {
215
+ method: 'POST',
216
+ headers: { 'Content-Type': 'application/json' },
217
+ body: JSON.stringify(event.payload),
218
+ keepalive: true,
219
+ }).catch(() => { });
220
+ }
221
+ catch { /* swallow */ }
222
+ }
223
+ this.queue = [];
224
+ this.persist();
225
+ return;
226
+ }
227
+ for (const event of this.queue) {
228
+ try {
229
+ navigator.sendBeacon(event.endpoint, new Blob([JSON.stringify(event.payload)], { type: 'application/json' }));
230
+ }
231
+ catch { /* swallow */ }
232
+ }
233
+ this.queue = [];
234
+ this.persist();
235
+ }
236
+ /** Wipe the queue. Called by reset() and optOut(). Removes the
237
+ * localStorage entry entirely so a reload doesn't re-hydrate the
238
+ * wiped events. */
239
+ clear() {
240
+ this.queue = [];
241
+ lsRemove(STORAGE_KEY);
242
+ }
243
+ /** Stop the flush timer and try one final flush. Idempotent. */
244
+ shutdown() {
245
+ this.shuttingDown = true;
246
+ if (this.flushTimer !== null) {
247
+ clearInterval(this.flushTimer);
248
+ this.flushTimer = null;
249
+ }
250
+ void this.flush();
251
+ }
252
+ /** Read-only size getter. Used by tests / debug consoles. */
253
+ get size() {
254
+ return this.queue.length;
255
+ }
256
+ persist() {
257
+ lsSet(STORAGE_KEY, JSON.stringify(this.queue));
258
+ }
259
+ startFlushTimer() {
260
+ if (this.flushTimer !== null)
261
+ return;
262
+ this.flushTimer = setInterval(() => {
263
+ void this.flush();
264
+ }, FLUSH_INTERVAL_MS);
265
+ }
266
+ }
package/dist/types.d.ts CHANGED
@@ -34,4 +34,54 @@ export interface AffiliateoContextValue {
34
34
  * const { identify } = useAffiliateRef()
35
35
  * useEffect(() => { if (user) identify(user.id) }, [user]) */
36
36
  identify: (userId: string) => Promise<void>;
37
+ /** Manually fire a pageview. The SDK already auto-fires on mount and on
38
+ * SPA route changes; use this only when you want extra control (e.g.
39
+ * tracking modal opens as virtual pageviews, or merchants who disabled
40
+ * auto-pageviews for custom routing).
41
+ *
42
+ * - With no args: tracks the CURRENT page (same as the auto-fired one).
43
+ * - With a path: tracks that path. Useful for virtual pages.
44
+ *
45
+ * Example:
46
+ * const { trackPageview } = useAffiliateRef()
47
+ * function PricingModal({ open }: { open: boolean }) {
48
+ * useEffect(() => { if (open) trackPageview('/pricing-modal') }, [open])
49
+ * } */
50
+ trackPageview: (path?: string) => Promise<void>;
51
+ /** Wipe the visitor identity. Mints a fresh visitor_id and clears the
52
+ * affiliate ref + campaign_id from localStorage so the next pageview
53
+ * is treated as a brand-new anonymous visitor.
54
+ *
55
+ * Use case: call on user logout so a different person sitting down at
56
+ * the same browser doesn't get their actions merged into the previous
57
+ * user's funnel. Without this, shared family computers / kiosks /
58
+ * hot-desks silently pollute attribution.
59
+ *
60
+ * Example:
61
+ * const { reset } = useAffiliateRef()
62
+ * async function handleLogout() {
63
+ * await supabase.auth.signOut()
64
+ * await reset()
65
+ * router.push('/')
66
+ * } */
67
+ reset: () => Promise<void>;
68
+ /** Stop tracking this visitor. Sets a persistent opt-out flag in
69
+ * localStorage and silences all subsequent track / identify / pageview
70
+ * calls until optIn() is called. Survives reloads, doesn't survive a
71
+ * hard browser reset.
72
+ *
73
+ * Use case: GDPR / CCPA cookie banner where the visitor picked
74
+ * "Don't track me." Call optOut() once and the SDK noops everything
75
+ * for that visitor.
76
+ *
77
+ * Example:
78
+ * const { optOut } = useAffiliateRef()
79
+ * <button onClick={() => optOut()}>Don't track me</button> */
80
+ optOut: () => void;
81
+ /** Re-enable tracking after a previous optOut(). Removes the persistent
82
+ * flag. Note: tracking only resumes for events fired AFTER this call,
83
+ * the current pageview won't retroactively send. A full page reload is
84
+ * the cleanest way to bring the SDK back to its normal "auto-fire on
85
+ * mount" state. */
86
+ optIn: () => void;
37
87
  }
package/package.json CHANGED
@@ -1,34 +1,34 @@
1
- {
2
- "name": "@affiliateo/web",
3
- "version": "2.4.0",
4
- "description": "Affiliateo web SDK — affiliate attribution and tracking for web apps",
5
- "main": "dist/index.js",
6
- "module": "dist/index.js",
7
- "types": "dist/index.d.ts",
8
- "exports": {
9
- ".": {
10
- "types": "./dist/index.d.ts",
11
- "import": "./dist/index.js",
12
- "require": "./dist/index.js"
13
- }
14
- },
15
- "scripts": {
16
- "build": "tsc",
17
- "prepublishOnly": "npm run build"
18
- },
19
- "peerDependencies": {
20
- "react": ">=18.0.0"
21
- },
22
- "peerDependenciesMeta": {
23
- "react": {
24
- "optional": true
25
- }
26
- },
27
- "devDependencies": {
28
- "@types/react": "^19.0.0",
29
- "typescript": "^5.0.0"
30
- },
31
- "keywords": ["affiliateo", "affiliate", "attribution", "stripe", "web", "session", "tracking"],
32
- "license": "MIT",
33
- "files": ["dist"]
34
- }
1
+ {
2
+ "name": "@affiliateo/web",
3
+ "version": "3.0.0",
4
+ "description": "Affiliateo web SDK — affiliate attribution and tracking for web apps",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "prepublishOnly": "npm run build"
18
+ },
19
+ "peerDependencies": {
20
+ "react": ">=18.0.0"
21
+ },
22
+ "peerDependenciesMeta": {
23
+ "react": {
24
+ "optional": true
25
+ }
26
+ },
27
+ "devDependencies": {
28
+ "@types/react": "^19.0.0",
29
+ "typescript": "^5.0.0"
30
+ },
31
+ "keywords": ["affiliateo", "affiliate", "attribution", "stripe", "web", "session", "tracking"],
32
+ "license": "MIT",
33
+ "files": ["dist"]
34
+ }