@affiliateo/web 2.4.0 → 3.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.
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.
@@ -76,12 +100,16 @@ export function getAttribution() {
76
100
  // mount (host has the call sites) but we can shout in dev so the
77
101
  // mistake is visible.
78
102
  let providerMountCount = 0;
79
- export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
103
+ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, children, }) {
80
104
  const [state, setState] = useState({
81
105
  refCode: null,
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,37 +308,119 @@ 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
  }
189
- // Get or create visitor ID
190
- let vid = lsGet(LS_VISITOR_ID);
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
+ }
370
+ // ── Cross-domain handoff: read incoming URL params ───────────────────
371
+ // When a visitor arrives from another domain in the merchant's family
372
+ // (configured via the `allowedHostnames` prop on the OTHER domain's
373
+ // <AffiliateoProvider>), the inbound link carries visitor_id + ref
374
+ // code as URL params. We pull those in BEFORE checking localStorage
375
+ // so the cross-domain identity wins over whatever this domain's
376
+ // sandboxed storage had. Without it, the funnel sees the same person
377
+ // as two unrelated visitors after every cross-domain hop, and any
378
+ // affiliate ref captured on the previous domain is silently lost.
379
+ //
380
+ // After consuming, we strip the params via history.replaceState so
381
+ // the visitor never sees `?_affiliateo_vid=...` in their URL bar.
382
+ // UUID + ref are validated before persistence so a malicious
383
+ // /?_affiliateo_vid=<garbage> URL can't poison localStorage.
384
+ const params = new URLSearchParams(window.location.search);
385
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
386
+ let incomingVid = params.get('_affiliateo_vid') || '';
387
+ let incomingRef = params.get('_affiliateo_ref') || '';
388
+ if (incomingVid && !UUID_RE.test(incomingVid))
389
+ incomingVid = '';
390
+ if (incomingRef && (incomingRef.length > 64 || !/^[\w.-]+$/.test(incomingRef))) {
391
+ incomingRef = '';
392
+ }
393
+ if (incomingVid || incomingRef) {
394
+ try {
395
+ const cleanUrl = new URL(window.location.href);
396
+ cleanUrl.searchParams.delete('_affiliateo_vid');
397
+ cleanUrl.searchParams.delete('_affiliateo_ref');
398
+ history.replaceState(history.state, '', cleanUrl.toString());
399
+ }
400
+ catch { /* swallow */ }
401
+ }
402
+ // Get or create visitor ID. Cross-domain inbound wins over localStorage
403
+ // so the same person across two domains stays one visitor.
404
+ let vid = incomingVid || lsGet(LS_VISITOR_ID);
191
405
  if (!vid) {
192
406
  vid = uuid();
193
407
  }
194
408
  lsSet(LS_VISITOR_ID, vid);
195
409
  lsSet(LS_CAMPAIGN_ID, campaignIdRef.current);
196
410
  visitorIdRef.current = vid;
197
- // Read ref from URL params or localStorage
198
- const params = new URLSearchParams(window.location.search);
199
- const ref = params.get('ref') || params.get('via') || params.get('source')
411
+ // Read ref. Cross-domain inbound wins, then fresh affiliate link
412
+ // params (?ref / ?via / ?source), then stored value.
413
+ const ref = incomingRef || params.get('ref') || params.get('via') || params.get('source')
200
414
  || lsGet(LS_REF) || null;
201
415
  if (ref) {
202
416
  lsSet(LS_REF, ref);
203
417
  }
204
418
  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
- }
419
+ setState({
420
+ refCode: ref,
421
+ visitorId: vid,
422
+ track, identify, trackPageview, reset, optOut, optIn,
423
+ });
220
424
  // Throttle: max 1 pageview per second
221
425
  let lastSendTime = 0;
222
426
  function sendPageview() {
@@ -225,7 +429,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
225
429
  return;
226
430
  lastSendTime = now;
227
431
  ended = false;
228
- beacon({
432
+ queue.enqueue(endpoint, {
229
433
  type: 'pageview',
230
434
  campaign_id: campaignIdRef.current,
231
435
  visitor_id: vid,
@@ -240,13 +444,43 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
240
444
  screen_width: window.innerWidth,
241
445
  });
242
446
  }
243
- // Send session_end when user leaves
447
+ // Send session_end when user leaves.
448
+ //
449
+ // session_end intentionally bypasses the queue and uses sendBeacon
450
+ // directly. The queue's fetch-with-keepalive is great for normal
451
+ // events because it returns a status code we can retry on, but it
452
+ // can't guarantee the request lands when the tab is in the act of
453
+ // closing — fetch may be aborted mid-flight. sendBeacon was
454
+ // designed exactly for this case and CANNOT be cancelled by the
455
+ // unload handler. We also try to drain the queue's pending events
456
+ // here, which uses sendBeacon under the hood for the same reason.
244
457
  let ended = false;
245
458
  function sendEnd() {
246
459
  if (ended)
247
460
  return;
248
461
  ended = true;
249
- beacon({ type: 'session_end', campaign_id: campaignIdRef.current, visitor_id: vid, ref: ref || lsGet(LS_REF) || '' });
462
+ // Drain the queue first so any pending track / identify events
463
+ // get a last shot before the tab closes. flushOnUnload uses
464
+ // sendBeacon internally, no retry needed because there's no
465
+ // retry opportunity after this.
466
+ queue.flushOnUnload();
467
+ const data = JSON.stringify({
468
+ type: 'session_end',
469
+ campaign_id: campaignIdRef.current,
470
+ visitor_id: vid,
471
+ ref: ref || lsGet(LS_REF) || '',
472
+ });
473
+ if (navigator.sendBeacon) {
474
+ navigator.sendBeacon(endpoint, new Blob([data], { type: 'application/json' }));
475
+ }
476
+ else {
477
+ fetch(endpoint, {
478
+ method: 'POST',
479
+ headers: { 'Content-Type': 'application/json' },
480
+ body: data,
481
+ keepalive: true,
482
+ }).catch(() => { });
483
+ }
250
484
  }
251
485
  // Server-side session window. Matches STALE_VISITOR_MS in the
252
486
  // tracking Durable Object — after this much idle time the server
@@ -303,113 +537,96 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
303
537
  origReplaceState.apply(this, args);
304
538
  onUrlChange();
305
539
  };
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).
540
+ // HTML-attribute goal scanning (`data-affiliateo-goal`,
541
+ // `data-affiliateo-scroll`) and the IntersectionObserver that drove
542
+ // them were removed in 3.0.0 to match Datafast's npm SDK ergonomics:
543
+ // SDK does pageview + track() + identify(); merchants fire goals
544
+ // themselves via onClick={() => track('signup_clicked')} or by
545
+ // wiring their own IntersectionObserver and calling track() from it.
546
+ //
547
+ // Why removed: the scanning layer was the source of every funnel
548
+ // bug we hit (threshold:0 firing on hero sections, SPA mutation
549
+ // observer races, client-side step matching from HTML). Removing it
550
+ // also drops ~80 lines of code and one extra DOM observer per page.
551
+ // ── Cross-domain handoff: decorate outbound links ──────────────────
552
+ // When a visitor clicks an <a> link to one of the merchant's other
553
+ // domains (passed in via the `allowedHostnames` prop), append
554
+ // visitor_id + ref code as URL search params before the browser
555
+ // navigates. The receiving domain's <AffiliateoProvider> reads those
556
+ // params on landing (the inbound block above) and uses them as
557
+ // identity for that domain.
310
558
  //
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))
559
+ // Hostnames are normalized once and matched exact (no wildcards) so
560
+ // merchants who want subdomains list each one explicitly. Same-host
561
+ // links, non-http(s) protocols (mailto:, tel:, javascript:), and
562
+ // anchor links (#section) are skipped. Capture phase so we run
563
+ // before merchant click handlers; href mutation is advisory and the
564
+ // browser reads link.href when it eventually navigates.
565
+ const allowedHosts = Array.isArray(allowedHostnames)
566
+ ? allowedHostnames
567
+ .map((h) => (typeof h === 'string' ? h.trim().toLowerCase() : ''))
568
+ .filter((h) => !!h)
569
+ : [];
570
+ function decorateOutboundLink(link) {
571
+ if (!link || !link.href || !link.hostname)
317
572
  return;
318
- const el = target.closest('[data-affiliateo-goal]');
319
- if (!el)
573
+ if (link.protocol !== 'http:' && link.protocol !== 'https:')
320
574
  return;
321
- const goal = el.getAttribute('data-affiliateo-goal');
322
- if (!goal)
575
+ const host = link.hostname.toLowerCase();
576
+ if (host === window.location.hostname.toLowerCase())
323
577
  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
- }
578
+ if (allowedHosts.indexOf(host) === -1)
579
+ return;
580
+ try {
581
+ const u = new URL(link.href);
582
+ // Idempotent: skip if our params are already present.
583
+ if (u.searchParams.has('_affiliateo_vid'))
584
+ return;
585
+ const currentVid = visitorIdRef.current;
586
+ const currentRef = lsGet(LS_REF) || '';
587
+ if (currentVid)
588
+ u.searchParams.set('_affiliateo_vid', currentVid);
589
+ if (currentRef)
590
+ u.searchParams.set('_affiliateo_ref', currentRef);
591
+ link.href = u.toString();
345
592
  }
346
- track(goal, count ? meta : undefined);
593
+ catch { /* swallow */ }
347
594
  }
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 });
595
+ function onClickForLinkDecoration(ev) {
596
+ const t = ev.target;
597
+ if (!t || !t.closest)
598
+ return;
599
+ decorateOutboundLink(t.closest('a'));
600
+ }
601
+ function onKeydownForLinkDecoration(ev) {
602
+ if (ev.key !== 'Enter' && ev.key !== ' ')
603
+ return;
604
+ const t = ev.target;
605
+ if (!t || !t.closest)
606
+ return;
607
+ decorateOutboundLink(t.closest('a'));
608
+ }
609
+ if (allowedHosts.length > 0) {
610
+ document.addEventListener('click', onClickForLinkDecoration, true);
611
+ document.addEventListener('keydown', onKeydownForLinkDecoration, true);
401
612
  }
402
613
  return () => {
403
614
  document.removeEventListener('visibilitychange', onVisibilityChange);
404
615
  window.removeEventListener('pagehide', sendEnd);
405
616
  window.removeEventListener('popstate', onUrlChange);
406
- document.removeEventListener('click', onDocClick);
407
- scrollMo?.disconnect();
408
- scrollObserver?.disconnect();
409
617
  history.pushState = origPushState;
410
618
  history.replaceState = origReplaceState;
619
+ if (allowedHosts.length > 0) {
620
+ document.removeEventListener('click', onClickForLinkDecoration, true);
621
+ document.removeEventListener('keydown', onKeydownForLinkDecoration, true);
622
+ }
623
+ // Stop the queue's flush timer. The queue tries one final flush
624
+ // before going quiet — anything still queued lives in localStorage
625
+ // and will be picked up by the next page load's queue init.
626
+ queue.shutdown();
627
+ queueRef.current = null;
411
628
  providerMountCount = Math.max(0, providerMountCount - 1);
412
629
  };
413
- }, [apiBase, track, identify]);
630
+ }, [apiBase, allowedHostnames, track, identify, trackPageview, reset, optOut, optIn]);
414
631
  return (_jsx(AffiliateoContext.Provider, { value: state, children: children }));
415
632
  }