@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/bot-detect.d.ts +37 -0
- package/dist/bot-detect.js +138 -0
- package/dist/provider.d.ts +1 -1
- package/dist/provider.js +360 -143
- package/dist/queue.d.ts +99 -0
- package/dist/queue.js +266 -0
- package/dist/types.d.ts +74 -0
- package/package.json +34 -34
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
//
|
|
190
|
-
|
|
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
|
|
198
|
-
|
|
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({
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
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
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
319
|
-
if (!el)
|
|
573
|
+
if (link.protocol !== 'http:' && link.protocol !== 'https:')
|
|
320
574
|
return;
|
|
321
|
-
const
|
|
322
|
-
if (
|
|
575
|
+
const host = link.hostname.toLowerCase();
|
|
576
|
+
if (host === window.location.hostname.toLowerCase())
|
|
323
577
|
return;
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
if (
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
593
|
+
catch { /* swallow */ }
|
|
347
594
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
}
|