@affiliateo/web 3.0.0 → 4.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.
- package/dist/dev-host.d.ts +30 -0
- package/dist/dev-host.js +48 -0
- package/dist/provider.d.ts +1 -1
- package/dist/provider.js +208 -12
- package/dist/types.d.ts +76 -0
- package/package.json +1 -1
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-host detection for the SDK. Returns true when the current hostname
|
|
3
|
+
* is obviously a developer's machine and not a real production site.
|
|
4
|
+
*
|
|
5
|
+
* The SDK uses this to short-circuit tracking by default (allowLocalhost
|
|
6
|
+
* defaults to false). Stops the "Quegly funnel pollution" pattern where
|
|
7
|
+
* a developer running `npm run dev` against localhost:3000 fires hundreds
|
|
8
|
+
* of pageviews into the production funnel.
|
|
9
|
+
*
|
|
10
|
+
* IMPORTANT: this list is INTENTIONALLY TIGHTER than the server-side
|
|
11
|
+
* `src/lib/analytics/dev-host.ts` helper. The server helper is used for
|
|
12
|
+
* domain-binding logic and can be aggressive about catching staging /
|
|
13
|
+
* preview deploys (.vercel.app, .pages.dev). The SDK helper cannot,
|
|
14
|
+
* because some merchants legitimately run their production site on a
|
|
15
|
+
* .vercel.app subdomain (no custom domain) and we'd silently break their
|
|
16
|
+
* tracking. So we only catch the unambiguous cases:
|
|
17
|
+
*
|
|
18
|
+
* - localhost / 127.0.0.1 / 0.0.0.0 / ::1 (literal local)
|
|
19
|
+
* - RFC1918 private IPs (10.x, 192.168.x, 172.16-31.x)
|
|
20
|
+
* - .local / .test / .example / .invalid / .localhost (RFC-reserved
|
|
21
|
+
* dev TLDs that cannot exist in production)
|
|
22
|
+
* - .lvh.me / .localtest.me (well-known dev wildcards
|
|
23
|
+
* that ALWAYS resolve to 127.0.0.1)
|
|
24
|
+
*
|
|
25
|
+
* NOT included on the SDK side: .ngrok.io, .vercel.app, .netlify.app,
|
|
26
|
+
* .pages.dev, .workers.dev, .trycloudflare.com. A merchant doing a
|
|
27
|
+
* staging-on-Vercel preview deploy should still see tracking work; if
|
|
28
|
+
* they want it OFF for staging they can set allowLocalhost explicitly.
|
|
29
|
+
*/
|
|
30
|
+
export declare function isDevHost(host: string | null | undefined): boolean;
|
package/dist/dev-host.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev-host detection for the SDK. Returns true when the current hostname
|
|
3
|
+
* is obviously a developer's machine and not a real production site.
|
|
4
|
+
*
|
|
5
|
+
* The SDK uses this to short-circuit tracking by default (allowLocalhost
|
|
6
|
+
* defaults to false). Stops the "Quegly funnel pollution" pattern where
|
|
7
|
+
* a developer running `npm run dev` against localhost:3000 fires hundreds
|
|
8
|
+
* of pageviews into the production funnel.
|
|
9
|
+
*
|
|
10
|
+
* IMPORTANT: this list is INTENTIONALLY TIGHTER than the server-side
|
|
11
|
+
* `src/lib/analytics/dev-host.ts` helper. The server helper is used for
|
|
12
|
+
* domain-binding logic and can be aggressive about catching staging /
|
|
13
|
+
* preview deploys (.vercel.app, .pages.dev). The SDK helper cannot,
|
|
14
|
+
* because some merchants legitimately run their production site on a
|
|
15
|
+
* .vercel.app subdomain (no custom domain) and we'd silently break their
|
|
16
|
+
* tracking. So we only catch the unambiguous cases:
|
|
17
|
+
*
|
|
18
|
+
* - localhost / 127.0.0.1 / 0.0.0.0 / ::1 (literal local)
|
|
19
|
+
* - RFC1918 private IPs (10.x, 192.168.x, 172.16-31.x)
|
|
20
|
+
* - .local / .test / .example / .invalid / .localhost (RFC-reserved
|
|
21
|
+
* dev TLDs that cannot exist in production)
|
|
22
|
+
* - .lvh.me / .localtest.me (well-known dev wildcards
|
|
23
|
+
* that ALWAYS resolve to 127.0.0.1)
|
|
24
|
+
*
|
|
25
|
+
* NOT included on the SDK side: .ngrok.io, .vercel.app, .netlify.app,
|
|
26
|
+
* .pages.dev, .workers.dev, .trycloudflare.com. A merchant doing a
|
|
27
|
+
* staging-on-Vercel preview deploy should still see tracking work; if
|
|
28
|
+
* they want it OFF for staging they can set allowLocalhost explicitly.
|
|
29
|
+
*/
|
|
30
|
+
const RFC1918_RE = /^(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)/;
|
|
31
|
+
const DEV_TLD_SUFFIXES = ['.local', '.test', '.example', '.invalid', '.localhost'];
|
|
32
|
+
const DEV_HOST_SUFFIXES = ['.lvh.me', '.localtest.me'];
|
|
33
|
+
export function isDevHost(host) {
|
|
34
|
+
if (!host)
|
|
35
|
+
return false;
|
|
36
|
+
const h = host.toLowerCase();
|
|
37
|
+
if (h === 'localhost' || h === '127.0.0.1' || h === '0.0.0.0' || h === '::1')
|
|
38
|
+
return true;
|
|
39
|
+
if (RFC1918_RE.test(h))
|
|
40
|
+
return true;
|
|
41
|
+
for (const s of DEV_TLD_SUFFIXES)
|
|
42
|
+
if (h.endsWith(s))
|
|
43
|
+
return true;
|
|
44
|
+
for (const s of DEV_HOST_SUFFIXES)
|
|
45
|
+
if (h.endsWith(s))
|
|
46
|
+
return true;
|
|
47
|
+
return false;
|
|
48
|
+
}
|
package/dist/provider.d.ts
CHANGED
|
@@ -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, children, }: AffiliateoConfig & {
|
|
21
|
+
export declare function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allowLocalhost, allowIframe, debug, children, }: AffiliateoConfig & {
|
|
22
22
|
children: React.ReactNode;
|
|
23
23
|
}): import("react/jsx-runtime").JSX.Element;
|
package/dist/provider.js
CHANGED
|
@@ -3,6 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
4
4
|
import { EventQueue } from './queue';
|
|
5
5
|
import { isLikelyBot } from './bot-detect';
|
|
6
|
+
import { isDevHost } from './dev-host';
|
|
6
7
|
const DEFAULT_API_URL = 'https://affiliateo.com';
|
|
7
8
|
const LS_VISITOR_ID = 'affiliateo_visitor_id';
|
|
8
9
|
const LS_REF = 'affiliateo_ref';
|
|
@@ -60,6 +61,7 @@ const AffiliateoContext = createContext({
|
|
|
60
61
|
reset: asyncNoop,
|
|
61
62
|
optOut: syncNoop,
|
|
62
63
|
optIn: syncNoop,
|
|
64
|
+
flush: asyncNoop,
|
|
63
65
|
});
|
|
64
66
|
/**
|
|
65
67
|
* Returns the affiliate ref code and visitor ID.
|
|
@@ -100,7 +102,7 @@ export function getAttribution() {
|
|
|
100
102
|
// mount (host has the call sites) but we can shout in dev so the
|
|
101
103
|
// mistake is visible.
|
|
102
104
|
let providerMountCount = 0;
|
|
103
|
-
export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
105
|
+
export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, allowLocalhost, allowIframe, debug, children, }) {
|
|
104
106
|
const [state, setState] = useState({
|
|
105
107
|
refCode: null,
|
|
106
108
|
visitorId: null,
|
|
@@ -110,7 +112,22 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
110
112
|
reset: asyncNoop,
|
|
111
113
|
optOut: syncNoop,
|
|
112
114
|
optIn: syncNoop,
|
|
115
|
+
flush: asyncNoop,
|
|
113
116
|
});
|
|
117
|
+
// Debug logger. Closes over the `debug` prop so flipping it on/off at
|
|
118
|
+
// runtime (e.g. by changing a feature flag) takes effect on the next
|
|
119
|
+
// render without remounting. eslint-disable for the intentional
|
|
120
|
+
// console.log — that's the whole point of this helper.
|
|
121
|
+
const log = useCallback((msg, data) => {
|
|
122
|
+
if (!debug)
|
|
123
|
+
return;
|
|
124
|
+
// eslint-disable-next-line no-console
|
|
125
|
+
if (data !== undefined)
|
|
126
|
+
console.log('[Affiliateo] ' + msg, data);
|
|
127
|
+
// eslint-disable-next-line no-console
|
|
128
|
+
else
|
|
129
|
+
console.log('[Affiliateo] ' + msg);
|
|
130
|
+
}, [debug]);
|
|
114
131
|
const apiBase = (apiUrl || DEFAULT_API_URL).replace(/\/$/, '');
|
|
115
132
|
// campaignIdRef stays current so callbacks defined inside useEffect
|
|
116
133
|
// always read the latest value, even after a prop change. Closures
|
|
@@ -164,9 +181,10 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
164
181
|
// useEffect runs), this noops — that's a vanishingly rare race
|
|
165
182
|
// since track() is virtually always called from a click handler.
|
|
166
183
|
queueRef.current?.enqueue(`${apiBase}/api/v1/web/event`, payload);
|
|
184
|
+
log('track', { event: name, metadata });
|
|
167
185
|
}
|
|
168
186
|
catch { /* swallow */ }
|
|
169
|
-
}, [apiBase]);
|
|
187
|
+
}, [apiBase, log]);
|
|
170
188
|
// identify(userId). user_id only — the SDK does NOT accept, collect,
|
|
171
189
|
// or transmit email or any other PII. Cross-device stitching uses the
|
|
172
190
|
// merchant's own user_id; we never touch identifying personal data.
|
|
@@ -194,9 +212,10 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
194
212
|
user_id: uid,
|
|
195
213
|
};
|
|
196
214
|
queueRef.current?.enqueue(`${apiBase}/api/v1/web/identify`, payload);
|
|
215
|
+
log('identify', { user_id: uid });
|
|
197
216
|
}
|
|
198
217
|
catch { /* swallow */ }
|
|
199
|
-
}, [apiBase]);
|
|
218
|
+
}, [apiBase, log]);
|
|
200
219
|
// Manual pageview. Mirrors Datafast's trackPageview(path?). Same payload
|
|
201
220
|
// shape the auto-fired pageview uses (see sendPageview inside the route
|
|
202
221
|
// effect below) so server-side routing / dedup / IP+UA enrichment behave
|
|
@@ -239,9 +258,24 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
239
258
|
screen_width: window.innerWidth,
|
|
240
259
|
};
|
|
241
260
|
queueRef.current?.enqueue(`${apiBase}/api/v1/web/event`, payload);
|
|
261
|
+
log('pageview (manual)', { path: pagePath });
|
|
262
|
+
}
|
|
263
|
+
catch { /* swallow */ }
|
|
264
|
+
}, [apiBase, log]);
|
|
265
|
+
// flush(): force immediate delivery of all queued events. The queue
|
|
266
|
+
// batches and auto-flushes every 5s; this is the escape hatch for the
|
|
267
|
+
// "I'm about to redirect, don't lose my events" case.
|
|
268
|
+
//
|
|
269
|
+
// Resolves when the underlying queue.flush() completes — success OR
|
|
270
|
+
// failure. Events that fail to deliver stay in the queue and get
|
|
271
|
+
// retried on the next normal flush cycle. Safe to await; never throws.
|
|
272
|
+
const flush = useCallback(async () => {
|
|
273
|
+
try {
|
|
274
|
+
log('flush requested');
|
|
275
|
+
await queueRef.current?.flush();
|
|
242
276
|
}
|
|
243
277
|
catch { /* swallow */ }
|
|
244
|
-
}, [
|
|
278
|
+
}, [log]);
|
|
245
279
|
// reset(): wipe visitor identity. Mints a fresh UUID and clears the
|
|
246
280
|
// affiliate ref so the next pageview is a brand-new anonymous visitor.
|
|
247
281
|
// The campaign_id stays (it's a property of the site, not the visitor).
|
|
@@ -319,6 +353,31 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
319
353
|
// doesn't crash on `useAffiliateRef().reset()`. They're noops, which
|
|
320
354
|
// is the correct behavior since there's no real visitor to act on.
|
|
321
355
|
if (isLikelyBot()) {
|
|
356
|
+
log('blocked: bot detected');
|
|
357
|
+
setState({
|
|
358
|
+
refCode: null,
|
|
359
|
+
visitorId: null,
|
|
360
|
+
track: asyncNoop,
|
|
361
|
+
identify: asyncNoop,
|
|
362
|
+
trackPageview: asyncNoop,
|
|
363
|
+
reset: asyncNoop,
|
|
364
|
+
optOut: syncNoop,
|
|
365
|
+
optIn: syncNoop,
|
|
366
|
+
flush: asyncNoop,
|
|
367
|
+
});
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
// Dev-host guard. Default behavior is "block localhost / 127.0.0.1 /
|
|
371
|
+
// RFC1918 / .local / .test so merchant doesn't accidentally pollute
|
|
372
|
+
// their production funnel while running `npm run dev`." Merchants who
|
|
373
|
+
// legitimately want dev events flowing (e.g. end-to-end testing
|
|
374
|
+
// against staging that needs server-side events) opt in via
|
|
375
|
+
// <AffiliateoProvider allowLocalhost />.
|
|
376
|
+
//
|
|
377
|
+
// Tighter list than the server-side dev-host helper. see dev-host.ts
|
|
378
|
+
// for the rationale (must not block real merchants on .vercel.app).
|
|
379
|
+
if (!allowLocalhost && isDevHost(window.location.hostname)) {
|
|
380
|
+
log('blocked: localhost (set allowLocalhost={true} on AffiliateoProvider to enable)');
|
|
322
381
|
setState({
|
|
323
382
|
refCode: null,
|
|
324
383
|
visitorId: null,
|
|
@@ -328,9 +387,45 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
328
387
|
reset: asyncNoop,
|
|
329
388
|
optOut: syncNoop,
|
|
330
389
|
optIn: syncNoop,
|
|
390
|
+
flush: asyncNoop,
|
|
331
391
|
});
|
|
332
392
|
return;
|
|
333
393
|
}
|
|
394
|
+
// Iframe guard. Default behavior is "if we're inside an iframe, don't
|
|
395
|
+
// track" so scrapers, preview tools, and archive crawlers embedding
|
|
396
|
+
// the merchant's site don't count as pageviews. Cross-origin iframe
|
|
397
|
+
// access throws on the property read, which is ITSELF a signal we're
|
|
398
|
+
// inside a foreign frame, so the catch branch also bails out.
|
|
399
|
+
//
|
|
400
|
+
// Same-origin iframe (e.g. merchant embeds their own checkout) lets
|
|
401
|
+
// window.top return without throwing, and we compare against window.self
|
|
402
|
+
// to detect the embedded case. Merchants who legitimately render the
|
|
403
|
+
// SDK inside an iframe (embed widgets shipped to partners) opt in via
|
|
404
|
+
// <AffiliateoProvider allowIframe />.
|
|
405
|
+
if (!allowIframe) {
|
|
406
|
+
let insideIframe = false;
|
|
407
|
+
try {
|
|
408
|
+
insideIframe = window.self !== window.top;
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
insideIframe = true;
|
|
412
|
+
}
|
|
413
|
+
if (insideIframe) {
|
|
414
|
+
log('blocked: iframe (set allowIframe={true} on AffiliateoProvider to enable)');
|
|
415
|
+
setState({
|
|
416
|
+
refCode: null,
|
|
417
|
+
visitorId: null,
|
|
418
|
+
track: asyncNoop,
|
|
419
|
+
identify: asyncNoop,
|
|
420
|
+
trackPageview: asyncNoop,
|
|
421
|
+
reset: asyncNoop,
|
|
422
|
+
optOut: syncNoop,
|
|
423
|
+
optIn: syncNoop,
|
|
424
|
+
flush: asyncNoop,
|
|
425
|
+
});
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
}
|
|
334
429
|
// Initialize the event queue. Done inside useEffect (not in a useRef
|
|
335
430
|
// initializer) so the EventQueue constructor — which touches
|
|
336
431
|
// localStorage on hydration — only ever runs client-side. The queue
|
|
@@ -359,7 +454,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
359
454
|
setState({
|
|
360
455
|
refCode: null,
|
|
361
456
|
visitorId: optedOutVid,
|
|
362
|
-
track, identify, trackPageview, reset, optOut, optIn,
|
|
457
|
+
track, identify, trackPageview, reset, optOut, optIn, flush,
|
|
363
458
|
});
|
|
364
459
|
// Don't leave a queue running for an opted-out visitor — the timer
|
|
365
460
|
// would just keep waking up to flush nothing.
|
|
@@ -367,17 +462,50 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
367
462
|
queueRef.current = null;
|
|
368
463
|
return;
|
|
369
464
|
}
|
|
370
|
-
//
|
|
371
|
-
|
|
465
|
+
// ── Cross-domain handoff: read incoming URL params ───────────────────
|
|
466
|
+
// When a visitor arrives from another domain in the merchant's family
|
|
467
|
+
// (configured via the `allowedHostnames` prop on the OTHER domain's
|
|
468
|
+
// <AffiliateoProvider>), the inbound link carries visitor_id + ref
|
|
469
|
+
// code as URL params. We pull those in BEFORE checking localStorage
|
|
470
|
+
// so the cross-domain identity wins over whatever this domain's
|
|
471
|
+
// sandboxed storage had. Without it, the funnel sees the same person
|
|
472
|
+
// as two unrelated visitors after every cross-domain hop, and any
|
|
473
|
+
// affiliate ref captured on the previous domain is silently lost.
|
|
474
|
+
//
|
|
475
|
+
// After consuming, we strip the params via history.replaceState so
|
|
476
|
+
// the visitor never sees `?_affiliateo_vid=...` in their URL bar.
|
|
477
|
+
// UUID + ref are validated before persistence so a malicious
|
|
478
|
+
// /?_affiliateo_vid=<garbage> URL can't poison localStorage.
|
|
479
|
+
const params = new URLSearchParams(window.location.search);
|
|
480
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
481
|
+
let incomingVid = params.get('_affiliateo_vid') || '';
|
|
482
|
+
let incomingRef = params.get('_affiliateo_ref') || '';
|
|
483
|
+
if (incomingVid && !UUID_RE.test(incomingVid))
|
|
484
|
+
incomingVid = '';
|
|
485
|
+
if (incomingRef && (incomingRef.length > 64 || !/^[\w.-]+$/.test(incomingRef))) {
|
|
486
|
+
incomingRef = '';
|
|
487
|
+
}
|
|
488
|
+
if (incomingVid || incomingRef) {
|
|
489
|
+
try {
|
|
490
|
+
const cleanUrl = new URL(window.location.href);
|
|
491
|
+
cleanUrl.searchParams.delete('_affiliateo_vid');
|
|
492
|
+
cleanUrl.searchParams.delete('_affiliateo_ref');
|
|
493
|
+
history.replaceState(history.state, '', cleanUrl.toString());
|
|
494
|
+
}
|
|
495
|
+
catch { /* swallow */ }
|
|
496
|
+
}
|
|
497
|
+
// Get or create visitor ID. Cross-domain inbound wins over localStorage
|
|
498
|
+
// so the same person across two domains stays one visitor.
|
|
499
|
+
let vid = incomingVid || lsGet(LS_VISITOR_ID);
|
|
372
500
|
if (!vid) {
|
|
373
501
|
vid = uuid();
|
|
374
502
|
}
|
|
375
503
|
lsSet(LS_VISITOR_ID, vid);
|
|
376
504
|
lsSet(LS_CAMPAIGN_ID, campaignIdRef.current);
|
|
377
505
|
visitorIdRef.current = vid;
|
|
378
|
-
// Read ref
|
|
379
|
-
|
|
380
|
-
const ref = params.get('ref') || params.get('via') || params.get('source')
|
|
506
|
+
// Read ref. Cross-domain inbound wins, then fresh affiliate link
|
|
507
|
+
// params (?ref / ?via / ?source), then stored value.
|
|
508
|
+
const ref = incomingRef || params.get('ref') || params.get('via') || params.get('source')
|
|
381
509
|
|| lsGet(LS_REF) || null;
|
|
382
510
|
if (ref) {
|
|
383
511
|
lsSet(LS_REF, ref);
|
|
@@ -386,8 +514,9 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
386
514
|
setState({
|
|
387
515
|
refCode: ref,
|
|
388
516
|
visitorId: vid,
|
|
389
|
-
track, identify, trackPageview, reset, optOut, optIn,
|
|
517
|
+
track, identify, trackPageview, reset, optOut, optIn, flush,
|
|
390
518
|
});
|
|
519
|
+
log('init', { campaign: campaignIdRef.current, visitor: vid, ref: ref || null });
|
|
391
520
|
// Throttle: max 1 pageview per second
|
|
392
521
|
let lastSendTime = 0;
|
|
393
522
|
function sendPageview() {
|
|
@@ -410,6 +539,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
410
539
|
utm_campaign: params.get('utm_campaign') || '',
|
|
411
540
|
screen_width: window.innerWidth,
|
|
412
541
|
});
|
|
542
|
+
log('pageview', { path: window.location.pathname });
|
|
413
543
|
}
|
|
414
544
|
// Send session_end when user leaves.
|
|
415
545
|
//
|
|
@@ -515,12 +645,78 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
515
645
|
// bug we hit (threshold:0 firing on hero sections, SPA mutation
|
|
516
646
|
// observer races, client-side step matching from HTML). Removing it
|
|
517
647
|
// also drops ~80 lines of code and one extra DOM observer per page.
|
|
648
|
+
// ── Cross-domain handoff: decorate outbound links ──────────────────
|
|
649
|
+
// When a visitor clicks an <a> link to one of the merchant's other
|
|
650
|
+
// domains (passed in via the `allowedHostnames` prop), append
|
|
651
|
+
// visitor_id + ref code as URL search params before the browser
|
|
652
|
+
// navigates. The receiving domain's <AffiliateoProvider> reads those
|
|
653
|
+
// params on landing (the inbound block above) and uses them as
|
|
654
|
+
// identity for that domain.
|
|
655
|
+
//
|
|
656
|
+
// Hostnames are normalized once and matched exact (no wildcards) so
|
|
657
|
+
// merchants who want subdomains list each one explicitly. Same-host
|
|
658
|
+
// links, non-http(s) protocols (mailto:, tel:, javascript:), and
|
|
659
|
+
// anchor links (#section) are skipped. Capture phase so we run
|
|
660
|
+
// before merchant click handlers; href mutation is advisory and the
|
|
661
|
+
// browser reads link.href when it eventually navigates.
|
|
662
|
+
const allowedHosts = Array.isArray(allowedHostnames)
|
|
663
|
+
? allowedHostnames
|
|
664
|
+
.map((h) => (typeof h === 'string' ? h.trim().toLowerCase() : ''))
|
|
665
|
+
.filter((h) => !!h)
|
|
666
|
+
: [];
|
|
667
|
+
function decorateOutboundLink(link) {
|
|
668
|
+
if (!link || !link.href || !link.hostname)
|
|
669
|
+
return;
|
|
670
|
+
if (link.protocol !== 'http:' && link.protocol !== 'https:')
|
|
671
|
+
return;
|
|
672
|
+
const host = link.hostname.toLowerCase();
|
|
673
|
+
if (host === window.location.hostname.toLowerCase())
|
|
674
|
+
return;
|
|
675
|
+
if (allowedHosts.indexOf(host) === -1)
|
|
676
|
+
return;
|
|
677
|
+
try {
|
|
678
|
+
const u = new URL(link.href);
|
|
679
|
+
// Idempotent: skip if our params are already present.
|
|
680
|
+
if (u.searchParams.has('_affiliateo_vid'))
|
|
681
|
+
return;
|
|
682
|
+
const currentVid = visitorIdRef.current;
|
|
683
|
+
const currentRef = lsGet(LS_REF) || '';
|
|
684
|
+
if (currentVid)
|
|
685
|
+
u.searchParams.set('_affiliateo_vid', currentVid);
|
|
686
|
+
if (currentRef)
|
|
687
|
+
u.searchParams.set('_affiliateo_ref', currentRef);
|
|
688
|
+
link.href = u.toString();
|
|
689
|
+
}
|
|
690
|
+
catch { /* swallow */ }
|
|
691
|
+
}
|
|
692
|
+
function onClickForLinkDecoration(ev) {
|
|
693
|
+
const t = ev.target;
|
|
694
|
+
if (!t || !t.closest)
|
|
695
|
+
return;
|
|
696
|
+
decorateOutboundLink(t.closest('a'));
|
|
697
|
+
}
|
|
698
|
+
function onKeydownForLinkDecoration(ev) {
|
|
699
|
+
if (ev.key !== 'Enter' && ev.key !== ' ')
|
|
700
|
+
return;
|
|
701
|
+
const t = ev.target;
|
|
702
|
+
if (!t || !t.closest)
|
|
703
|
+
return;
|
|
704
|
+
decorateOutboundLink(t.closest('a'));
|
|
705
|
+
}
|
|
706
|
+
if (allowedHosts.length > 0) {
|
|
707
|
+
document.addEventListener('click', onClickForLinkDecoration, true);
|
|
708
|
+
document.addEventListener('keydown', onKeydownForLinkDecoration, true);
|
|
709
|
+
}
|
|
518
710
|
return () => {
|
|
519
711
|
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
520
712
|
window.removeEventListener('pagehide', sendEnd);
|
|
521
713
|
window.removeEventListener('popstate', onUrlChange);
|
|
522
714
|
history.pushState = origPushState;
|
|
523
715
|
history.replaceState = origReplaceState;
|
|
716
|
+
if (allowedHosts.length > 0) {
|
|
717
|
+
document.removeEventListener('click', onClickForLinkDecoration, true);
|
|
718
|
+
document.removeEventListener('keydown', onKeydownForLinkDecoration, true);
|
|
719
|
+
}
|
|
524
720
|
// Stop the queue's flush timer. The queue tries one final flush
|
|
525
721
|
// before going quiet — anything still queued lives in localStorage
|
|
526
722
|
// and will be picked up by the next page load's queue init.
|
|
@@ -528,6 +724,6 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
528
724
|
queueRef.current = null;
|
|
529
725
|
providerMountCount = Math.max(0, providerMountCount - 1);
|
|
530
726
|
};
|
|
531
|
-
}, [apiBase, track, identify, trackPageview, reset, optOut, optIn]);
|
|
727
|
+
}, [apiBase, allowedHostnames, allowLocalhost, allowIframe, log, track, identify, trackPageview, reset, optOut, optIn, flush]);
|
|
532
728
|
return (_jsx(AffiliateoContext.Provider, { value: state, children: children }));
|
|
533
729
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -2,6 +2,61 @@ export interface AffiliateoConfig {
|
|
|
2
2
|
campaignId: string;
|
|
3
3
|
/** Base URL for the API. Defaults to https://affiliateo.com */
|
|
4
4
|
apiUrl?: string;
|
|
5
|
+
/** Other domains that are part of the same merchant property and should
|
|
6
|
+
* share visitor identity + affiliate ref code. When a visitor clicks a
|
|
7
|
+
* link from the current page to any hostname in this list, the SDK
|
|
8
|
+
* rewrites the link URL to append `_affiliateo_vid` and `_affiliateo_ref`
|
|
9
|
+
* search params before the browser navigates. The receiving domain's
|
|
10
|
+
* SDK reads those params on landing, uses them as the visitor identity
|
|
11
|
+
* for that domain, and strips them from the URL bar so the user never
|
|
12
|
+
* sees the tracking junk.
|
|
13
|
+
*
|
|
14
|
+
* Without this, every cross-domain hop creates a brand-new anonymous
|
|
15
|
+
* visitor with no affiliate ref (browsers sandbox localStorage per
|
|
16
|
+
* origin), which silently breaks attribution: `?ref=jane` lands on
|
|
17
|
+
* marketing.com → user clicks → app.com → no ref → affiliate gets $0.
|
|
18
|
+
*
|
|
19
|
+
* Exact-match hostnames only (no wildcards). List every subdomain you
|
|
20
|
+
* want to share identity with explicitly. The current domain doesn't
|
|
21
|
+
* need to be listed.
|
|
22
|
+
*
|
|
23
|
+
* Example:
|
|
24
|
+
* <AffiliateoProvider
|
|
25
|
+
* campaignId="..."
|
|
26
|
+
* allowedHostnames={['app.merchant.com', 'docs.merchant.com']}
|
|
27
|
+
* > */
|
|
28
|
+
allowedHostnames?: string[];
|
|
29
|
+
/** Allow tracking when the page is served from a developer machine
|
|
30
|
+
* (localhost, 127.0.0.1, RFC1918 IPs, .local / .test TLDs). Defaults
|
|
31
|
+
* to false so a developer running `npm run dev` doesn't pollute the
|
|
32
|
+
* merchant's production funnel with test pageviews.
|
|
33
|
+
*
|
|
34
|
+
* Set to true ONLY when you actually want dev traffic in your
|
|
35
|
+
* dashboard (e.g. for an end-to-end test that needs the events to
|
|
36
|
+
* land server-side).
|
|
37
|
+
*
|
|
38
|
+
* Example:
|
|
39
|
+
* <AffiliateoProvider campaignId="..." allowLocalhost> */
|
|
40
|
+
allowLocalhost?: boolean;
|
|
41
|
+
/** Allow tracking when the page is rendered inside an iframe. Defaults
|
|
42
|
+
* to false so scrapers, preview tools, and other sites embedding yours
|
|
43
|
+
* don't count as pageviews.
|
|
44
|
+
*
|
|
45
|
+
* Set to true if you legitimately render your site inside iframes
|
|
46
|
+
* (e.g. an embed widget you ship to partners). */
|
|
47
|
+
allowIframe?: boolean;
|
|
48
|
+
/** Print decision logs to the browser console. Defaults to false. When
|
|
49
|
+
* true, the SDK logs every action it takes (init, pageview fired,
|
|
50
|
+
* goal tracked, blocked by bot detection, blocked by localhost, queue
|
|
51
|
+
* flush attempted, etc.). Only the merchant or anyone manually opening
|
|
52
|
+
* DevTools sees these — normal visitors never look at the console.
|
|
53
|
+
*
|
|
54
|
+
* Turn on temporarily when investigating "why aren't my events
|
|
55
|
+
* showing up?". Leave off in production.
|
|
56
|
+
*
|
|
57
|
+
* Example:
|
|
58
|
+
* <AffiliateoProvider campaignId="..." debug> */
|
|
59
|
+
debug?: boolean;
|
|
5
60
|
}
|
|
6
61
|
export interface AffiliateoContextValue {
|
|
7
62
|
/** The affiliate ref code from the URL (if present) */
|
|
@@ -84,4 +139,25 @@ export interface AffiliateoContextValue {
|
|
|
84
139
|
* the cleanest way to bring the SDK back to its normal "auto-fire on
|
|
85
140
|
* mount" state. */
|
|
86
141
|
optIn: () => void;
|
|
142
|
+
/** Force immediate delivery of all queued events. The SDK batches events
|
|
143
|
+
* (track / identify / pageview) and flushes them every 5 seconds; this
|
|
144
|
+
* method flushes RIGHT NOW.
|
|
145
|
+
*
|
|
146
|
+
* Use case: call before a redirect (e.g. window.location.href = stripeUrl)
|
|
147
|
+
* so events queued right before the navigation don't get lost. The
|
|
148
|
+
* default behavior would wait for the 5-second timer, but the page
|
|
149
|
+
* is gone by then.
|
|
150
|
+
*
|
|
151
|
+
* Example:
|
|
152
|
+
* const { track, flush } = useAffiliateRef()
|
|
153
|
+
* async function startCheckout() {
|
|
154
|
+
* track('initiate_checkout')
|
|
155
|
+
* await flush()
|
|
156
|
+
* window.location.href = stripeCheckoutUrl
|
|
157
|
+
* }
|
|
158
|
+
*
|
|
159
|
+
* Returns a Promise that resolves when the flush attempt completes
|
|
160
|
+
* (success OR failure — failed events stay in the queue for the next
|
|
161
|
+
* flush). Safe to await without timeout concerns. */
|
|
162
|
+
flush: () => Promise<void>;
|
|
87
163
|
}
|