@affiliateo/web 4.0.0 → 4.1.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.
@@ -18,6 +18,6 @@ export declare function useAffiliateRef(): AffiliateoContextValue;
18
18
  * ```
19
19
  */
20
20
  export declare function getAttribution(): Record<string, string>;
21
- export declare function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allowLocalhost, allowIframe, debug, children, }: AffiliateoConfig & {
21
+ export declare function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allowLocalhost, allowIframe, allowFileProtocol, debug, flushInterval, maxQueueSize, autoCapturePageviews, children, }: AffiliateoConfig & {
22
22
  children: React.ReactNode;
23
23
  }): import("react/jsx-runtime").JSX.Element;
package/dist/provider.js CHANGED
@@ -102,7 +102,26 @@ export function getAttribution() {
102
102
  // mount (host has the call sites) but we can shout in dev so the
103
103
  // mistake is visible.
104
104
  let providerMountCount = 0;
105
- export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allowLocalhost, allowIframe, debug, children, }) {
105
+ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allowLocalhost, allowIframe, allowFileProtocol, debug, flushInterval, maxQueueSize, autoCapturePageviews, children, }) {
106
+ // Normalize autoCapturePageviews. Three shapes:
107
+ // - undefined / true / object → auto-capture ON (with object overrides)
108
+ // - false → auto-capture OFF entirely (host must call trackPageview)
109
+ // Defaults match Datafast: captureInitialPageview true, trackHashChanges
110
+ // false, debounceMs 100.
111
+ const autoCfg = autoCapturePageviews === false
112
+ ? { enabled: false, captureInitialPageview: false, trackHashChanges: false, debounceMs: 100 }
113
+ : {
114
+ enabled: true,
115
+ captureInitialPageview: typeof autoCapturePageviews === 'object'
116
+ ? autoCapturePageviews?.captureInitialPageview ?? true
117
+ : true,
118
+ trackHashChanges: typeof autoCapturePageviews === 'object'
119
+ ? autoCapturePageviews?.trackHashChanges ?? false
120
+ : false,
121
+ debounceMs: typeof autoCapturePageviews === 'object'
122
+ ? autoCapturePageviews?.debounceMs ?? 100
123
+ : 100,
124
+ };
106
125
  const [state, setState] = useState({
107
126
  refCode: null,
108
127
  visitorId: null,
@@ -391,6 +410,24 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allow
391
410
  });
392
411
  return;
393
412
  }
413
+ // File-protocol guard. Pages served via file:// (someone double-clicking
414
+ // a built HTML file rather than running a server) almost certainly aren't
415
+ // real merchant traffic. Block by default; opt in via allowFileProtocol.
416
+ if (!allowFileProtocol && window.location.protocol === 'file:') {
417
+ log('blocked: file:// protocol (set allowFileProtocol={true} to enable)');
418
+ setState({
419
+ refCode: null,
420
+ visitorId: null,
421
+ track: asyncNoop,
422
+ identify: asyncNoop,
423
+ trackPageview: asyncNoop,
424
+ reset: asyncNoop,
425
+ optOut: syncNoop,
426
+ optIn: syncNoop,
427
+ flush: asyncNoop,
428
+ });
429
+ return;
430
+ }
394
431
  // Iframe guard. Default behavior is "if we're inside an iframe, don't
395
432
  // track" so scrapers, preview tools, and archive crawlers embedding
396
433
  // the merchant's site don't count as pageviews. Cross-origin iframe
@@ -433,7 +470,10 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allow
433
470
  // auto-fired pageview below; only session_end stays on sendBeacon
434
471
  // because tab-close urgency beats queue-retry reliability.
435
472
  if (queueRef.current === null) {
436
- queueRef.current = new EventQueue();
473
+ queueRef.current = new EventQueue({
474
+ flushIntervalMs: flushInterval,
475
+ maxQueueSize: maxQueueSize,
476
+ });
437
477
  }
438
478
  const queue = queueRef.current;
439
479
  // Opt-out short-circuit. A visitor who previously called optOut()
@@ -607,23 +647,60 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allow
607
647
  }
608
648
  }
609
649
  }
610
- // Send pageview on mount
611
- if (!sentRef.current) {
650
+ // Send pageview on mount. captureInitialPageview === false skips this
651
+ // for cookie-consent flows where the host wants to defer until the
652
+ // user accepts (then call trackPageview() manually).
653
+ if (!sentRef.current && autoCfg.captureInitialPageview && autoCfg.enabled) {
612
654
  sentRef.current = true;
613
655
  sendPageview();
614
656
  }
615
657
  // Detect user leaving
616
658
  document.addEventListener('visibilitychange', onVisibilityChange);
617
659
  window.addEventListener('pagehide', sendEnd);
618
- // SPA support: detect URL changes
660
+ // SPA support: detect URL changes. We track BOTH pathname (modern
661
+ // pushState-based routers: React Router v6, Next.js App Router, Vue
662
+ // Router 4) AND hash (legacy hash routing: Vue Router with hash mode,
663
+ // React Router HashRouter, old Angular). Hash tracking is opt-in via
664
+ // autoCapturePageviews.trackHashChanges because modern SPAs never use
665
+ // it and firing on every #anchor click would inflate pageview counts.
666
+ //
667
+ // Debouncing collapses back-to-back navigation events into one. Some
668
+ // frameworks (Next.js, certain Vue routers) fire pushState then
669
+ // replaceState in the same tick to canonicalize the URL; without
670
+ // debouncing that's two pageviews per real navigation. Default 100ms
671
+ // matches Datafast; set debounceMs to 0 to disable.
619
672
  let lastPath = window.location.pathname;
673
+ let lastHash = window.location.hash;
674
+ let pendingPageviewTimer = null;
620
675
  function onUrlChange() {
621
- if (window.location.pathname !== lastPath) {
622
- lastPath = window.location.pathname;
676
+ const pathChanged = window.location.pathname !== lastPath;
677
+ const hashChanged = autoCfg.trackHashChanges && window.location.hash !== lastHash;
678
+ if (!pathChanged && !hashChanged)
679
+ return;
680
+ lastPath = window.location.pathname;
681
+ lastHash = window.location.hash;
682
+ if (!autoCfg.enabled)
683
+ return;
684
+ // Debounce: if a pending pageview is already scheduled, the later
685
+ // urlchange wins (the URL the browser ends up on after both calls
686
+ // is what we want to report).
687
+ if (pendingPageviewTimer !== null) {
688
+ clearTimeout(pendingPageviewTimer);
689
+ }
690
+ if (autoCfg.debounceMs > 0) {
691
+ pendingPageviewTimer = setTimeout(() => {
692
+ pendingPageviewTimer = null;
693
+ sendPageview();
694
+ }, autoCfg.debounceMs);
695
+ }
696
+ else {
623
697
  sendPageview();
624
698
  }
625
699
  }
626
700
  window.addEventListener('popstate', onUrlChange);
701
+ if (autoCfg.trackHashChanges) {
702
+ window.addEventListener('hashchange', onUrlChange);
703
+ }
627
704
  const origPushState = history.pushState;
628
705
  const origReplaceState = history.replaceState;
629
706
  history.pushState = function (...args) {
@@ -711,6 +788,12 @@ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allow
711
788
  document.removeEventListener('visibilitychange', onVisibilityChange);
712
789
  window.removeEventListener('pagehide', sendEnd);
713
790
  window.removeEventListener('popstate', onUrlChange);
791
+ if (autoCfg.trackHashChanges) {
792
+ window.removeEventListener('hashchange', onUrlChange);
793
+ }
794
+ if (pendingPageviewTimer !== null) {
795
+ clearTimeout(pendingPageviewTimer);
796
+ }
714
797
  history.pushState = origPushState;
715
798
  history.replaceState = origReplaceState;
716
799
  if (allowedHosts.length > 0) {
package/dist/queue.d.ts CHANGED
@@ -41,6 +41,12 @@
41
41
  * - SIZE_FLUSH_THRESHOLD = 10 triggers a flush when queue grows
42
42
  * past this even before the timer fires
43
43
  */
44
+ export interface EventQueueOptions {
45
+ /** Override the auto-flush cadence. Defaults to 5000ms. */
46
+ flushIntervalMs?: number;
47
+ /** Override the hard cap on queued events. Defaults to 100. */
48
+ maxQueueSize?: number;
49
+ }
44
50
  export interface QueuedEvent {
45
51
  /** Stable id used to identify the event across retries. UUID v4
46
52
  * generated client-side; collisions are astronomically unlikely so
@@ -63,7 +69,9 @@ export declare class EventQueue {
63
69
  private flushTimer;
64
70
  private isFlushing;
65
71
  private shuttingDown;
66
- constructor();
72
+ private readonly flushIntervalMs;
73
+ private readonly maxQueueSize;
74
+ constructor(opts?: EventQueueOptions);
67
75
  /** Add an event. Persists synchronously to localStorage so a tab
68
76
  * close right after enqueue doesn't lose the event from memory
69
77
  * before it's safely on disk. */
package/dist/queue.js CHANGED
@@ -43,8 +43,8 @@
43
43
  */
44
44
  const STORAGE_KEY = 'affiliateo_event_queue';
45
45
  const MAX_RETRIES = 3;
46
- const MAX_QUEUE_SIZE = 100;
47
- const FLUSH_INTERVAL_MS = 5000;
46
+ const DEFAULT_MAX_QUEUE_SIZE = 100;
47
+ const DEFAULT_FLUSH_INTERVAL_MS = 5000;
48
48
  const SIZE_FLUSH_THRESHOLD = 10;
49
49
  function lsGet(key) {
50
50
  try {
@@ -76,11 +76,16 @@ function uuid() {
76
76
  });
77
77
  }
78
78
  export class EventQueue {
79
- constructor() {
79
+ constructor(opts = {}) {
80
80
  this.queue = [];
81
81
  this.flushTimer = null;
82
82
  this.isFlushing = false;
83
83
  this.shuttingDown = false;
84
+ // Bounds: refuse pathological values silently. Minimum 1s flush so we
85
+ // don't hammer the network; minimum 10 events so the queue is useful;
86
+ // maximum 1000 events so a misconfigured app can't fill localStorage.
87
+ this.flushIntervalMs = Math.max(1000, opts.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS);
88
+ this.maxQueueSize = Math.max(10, Math.min(1000, opts.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE));
84
89
  // Hydrate from localStorage so events from a prior page load survive
85
90
  // a refresh / navigation. Guards against corrupt JSON or wrong-shape
86
91
  // entries by resetting to empty if anything looks off — better to
@@ -125,8 +130,8 @@ export class EventQueue {
125
130
  // over budget the oldest events are likely already stale (the
126
131
  // visitor moved on, the page they were on isn't current) so
127
132
  // 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);
133
+ if (this.queue.length > this.maxQueueSize) {
134
+ this.queue.splice(0, this.queue.length - this.maxQueueSize);
130
135
  }
131
136
  this.persist();
132
137
  // Burst protection: when many events fire in quick succession
@@ -261,6 +266,6 @@ export class EventQueue {
261
266
  return;
262
267
  this.flushTimer = setInterval(() => {
263
268
  void this.flush();
264
- }, FLUSH_INTERVAL_MS);
269
+ }, this.flushIntervalMs);
265
270
  }
266
271
  }
package/dist/types.d.ts CHANGED
@@ -57,6 +57,39 @@ export interface AffiliateoConfig {
57
57
  * Example:
58
58
  * <AffiliateoProvider campaignId="..." debug> */
59
59
  debug?: boolean;
60
+ /** Allow tracking from pages served via the `file://` protocol (someone
61
+ * double-clicking a built HTML file instead of running a server).
62
+ * Defaults to false. Almost never useful. set true only if you're
63
+ * intentionally testing an offline-first build. */
64
+ allowFileProtocol?: boolean;
65
+ /** How often the event queue tries to flush, in milliseconds. Defaults
66
+ * to 5000 (every 5 seconds). Lower for chattier delivery (more
67
+ * requests, fresher data); raise for a quieter queue (fewer requests,
68
+ * bigger batches). */
69
+ flushInterval?: number;
70
+ /** Maximum number of events the queue holds before dropping the oldest
71
+ * FIFO. Defaults to 100. Acts as a backstop against a long network
72
+ * outage filling localStorage. */
73
+ maxQueueSize?: number;
74
+ /** Pageview auto-capture behavior. Pass `false` to disable entirely
75
+ * (you'll call `trackPageview()` yourself). Pass an object to fine-tune.
76
+ * Defaults to `true` (auto-capture everything). */
77
+ autoCapturePageviews?: boolean | {
78
+ /** Fire the initial pageview on mount. Defaults to true. Set false
79
+ * for cookie-consent flows where you want to defer until the user
80
+ * accepts. */
81
+ captureInitialPageview?: boolean;
82
+ /** Fire pageviews on URL hash changes (e.g. `/#/about` → `/#/pricing`
83
+ * in older Vue / Angular / React Router HashRouter setups). Defaults
84
+ * to false. modern SPAs use pushState (already tracked). Turn on
85
+ * only for legacy hash routing. */
86
+ trackHashChanges?: boolean;
87
+ /** Debounce window for back-to-back pushState / replaceState calls,
88
+ * in milliseconds. Some frameworks (Next.js, some Vue routers) fire
89
+ * both within one tick, producing two pageviews per navigation
90
+ * without debouncing. Defaults to 100ms. Set 0 to disable. */
91
+ debounceMs?: number;
92
+ };
60
93
  }
61
94
  export interface AffiliateoContextValue {
62
95
  /** The affiliate ref code from the URL (if present) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@affiliateo/web",
3
- "version": "4.0.0",
3
+ "version": "4.1.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",