@affiliateo/web 4.7.0 → 4.9.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/README.md +312 -238
- package/dist/bot-detect.d.ts +4 -4
- package/dist/bot-detect.js +6 -6
- package/dist/chat.d.ts +43 -0
- package/dist/chat.js +117 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/provider.d.ts +1 -1
- package/dist/provider.js +143 -80
- package/dist/queue.d.ts +7 -3
- package/dist/queue.js +32 -15
- package/dist/turnstile.d.ts +3 -3
- package/dist/turnstile.js +6 -6
- package/dist/types.d.ts +49 -15
- package/dist/user-id.d.ts +18 -0
- package/dist/user-id.js +40 -0
- package/package.json +46 -44
package/dist/chat.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live Chat (4.9.0): the chat bubble on a website that uses this package
|
|
3
|
+
* instead of the t.js snippet. Same behaviour as the snippet: once the
|
|
4
|
+
* business switches Live Chat on for this app in its Affiliateo dashboard,
|
|
5
|
+
* the bubble shows by itself. It is Affiliateo's own chat.js, loaded from the
|
|
6
|
+
* same origin as the API, so the website bubble looks and behaves the same
|
|
7
|
+
* whichever way a site installed Affiliateo.
|
|
8
|
+
*
|
|
9
|
+
* Asked once per visit: a site with the chat off is remembered for ten
|
|
10
|
+
* minutes in this tab (the same key t.js uses), so it costs one small request
|
|
11
|
+
* per visit, not per page.
|
|
12
|
+
*/
|
|
13
|
+
const OFF_FOR_MS = 10 * 60 * 1000;
|
|
14
|
+
function chatGlobal() {
|
|
15
|
+
if (typeof window === 'undefined')
|
|
16
|
+
return undefined;
|
|
17
|
+
return window.AffiliateoChat;
|
|
18
|
+
}
|
|
19
|
+
export function loadLiveChat(opts) {
|
|
20
|
+
if (typeof window === 'undefined' || typeof document === 'undefined' || typeof fetch !== 'function')
|
|
21
|
+
return;
|
|
22
|
+
if (!opts.appId || chatGlobal()?.__loaded)
|
|
23
|
+
return;
|
|
24
|
+
if (document.querySelector('script[data-affiliateo-chat]'))
|
|
25
|
+
return;
|
|
26
|
+
const offKey = `affiliateo_chat_off:${opts.appId}`;
|
|
27
|
+
try {
|
|
28
|
+
const off = sessionStorage.getItem(offKey);
|
|
29
|
+
if (off && Date.now() - Number(off) < OFF_FOR_MS)
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
/* storage refused: ask */
|
|
34
|
+
}
|
|
35
|
+
fetch(`${opts.apiBase}/api/live-chat/config?app=${encodeURIComponent(opts.appId)}`)
|
|
36
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
37
|
+
.then((config) => {
|
|
38
|
+
if (!config || !config.enabled) {
|
|
39
|
+
try {
|
|
40
|
+
sessionStorage.setItem(offKey, String(Date.now()));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
/* storage refused */
|
|
44
|
+
}
|
|
45
|
+
opts.log('chat: off for this app');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (chatGlobal()?.__loaded || document.querySelector('script[data-affiliateo-chat]'))
|
|
49
|
+
return;
|
|
50
|
+
const script = document.createElement('script');
|
|
51
|
+
script.src = `${opts.apiBase}/chat.js`;
|
|
52
|
+
script.async = true;
|
|
53
|
+
script.setAttribute('data-affiliateo-chat', '');
|
|
54
|
+
script.setAttribute('data-app', opts.appId);
|
|
55
|
+
if (opts.visitorId)
|
|
56
|
+
script.setAttribute('data-visitor', opts.visitorId);
|
|
57
|
+
if (opts.userId)
|
|
58
|
+
script.setAttribute('data-user', opts.userId.slice(0, 128));
|
|
59
|
+
if (opts.bubble === 'hidden')
|
|
60
|
+
script.setAttribute('data-bubble', 'hidden');
|
|
61
|
+
if (opts.theme)
|
|
62
|
+
script.setAttribute('data-theme', opts.theme);
|
|
63
|
+
const look = opts.look;
|
|
64
|
+
if (look?.color)
|
|
65
|
+
script.setAttribute('data-color', look.color);
|
|
66
|
+
if (look?.iconColor)
|
|
67
|
+
script.setAttribute('data-icon-color', look.iconColor);
|
|
68
|
+
if (look?.sendColor)
|
|
69
|
+
script.setAttribute('data-send-color', look.sendColor);
|
|
70
|
+
if (look?.placeholder)
|
|
71
|
+
script.setAttribute('data-placeholder', look.placeholder.slice(0, 60));
|
|
72
|
+
if (look?.style)
|
|
73
|
+
script.setAttribute('data-style', look.style);
|
|
74
|
+
if (look?.corner)
|
|
75
|
+
script.setAttribute('data-corner', look.corner);
|
|
76
|
+
if (look?.size)
|
|
77
|
+
script.setAttribute('data-size', String(look.size));
|
|
78
|
+
(document.head || document.documentElement).appendChild(script);
|
|
79
|
+
opts.log('chat: loading the bubble');
|
|
80
|
+
})
|
|
81
|
+
.catch(() => opts.log('chat: config check failed (network)'));
|
|
82
|
+
}
|
|
83
|
+
/** Open the chat window, from your own button. Does nothing while the chat is off or still loading. */
|
|
84
|
+
export function openChat() {
|
|
85
|
+
chatGlobal()?.open?.();
|
|
86
|
+
}
|
|
87
|
+
/** Close the chat window. */
|
|
88
|
+
export function closeChat() {
|
|
89
|
+
chatGlobal()?.close?.();
|
|
90
|
+
}
|
|
91
|
+
/** Show the chat bubble (after hideChatBubble, or with chat="hidden"). */
|
|
92
|
+
export function showChatBubble() {
|
|
93
|
+
chatGlobal()?.show?.();
|
|
94
|
+
}
|
|
95
|
+
/** Hide the chat bubble, and close the window. */
|
|
96
|
+
export function hideChatBubble() {
|
|
97
|
+
chatGlobal()?.hide?.();
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The person signed out (logout(), reset()): their chat goes, so whoever
|
|
101
|
+
* signs in next starts a new thread. The token chat.js keeps goes from
|
|
102
|
+
* localStorage even while chat.js is still loading; once it has loaded, it
|
|
103
|
+
* also closes the window and clears the red number.
|
|
104
|
+
*/
|
|
105
|
+
export function forgetLiveChat(appId) {
|
|
106
|
+
if (typeof window === 'undefined')
|
|
107
|
+
return;
|
|
108
|
+
if (appId) {
|
|
109
|
+
try {
|
|
110
|
+
localStorage.removeItem(`affiliateo_chat_token:${appId}`);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
/* storage refused */
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
chatGlobal()?.__forget?.();
|
|
117
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { AffiliateoProvider, useAffiliateRef, getAttribution } from './provider';
|
|
2
|
-
export
|
|
2
|
+
export { openChat, closeChat, showChatBubble, hideChatBubble } from './chat';
|
|
3
|
+
export type { AffiliateoConfig, AffiliateoContextValue, ChatLook } from './types';
|
package/dist/index.js
CHANGED
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({ appId, campaignId, apiUrl, allowedHostnames, allowLocalhost, allowIframe, allowFileProtocol, debug, flushInterval, maxQueueSize, autoCapturePageviews, children, }: AffiliateoConfig & {
|
|
21
|
+
export declare function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames, allowLocalhost, allowIframe, allowFileProtocol, debug, flushInterval, maxQueueSize, autoCapturePageviews, chat, chatLook, children, }: AffiliateoConfig & {
|
|
22
22
|
children: React.ReactNode;
|
|
23
23
|
}): import("react/jsx-runtime").JSX.Element;
|
package/dist/provider.js
CHANGED
|
@@ -5,6 +5,8 @@ import { EventQueue } from './queue';
|
|
|
5
5
|
import { isLikelyBot } from './bot-detect';
|
|
6
6
|
import { isDevHost } from './dev-host';
|
|
7
7
|
import { mintTurnstileToken, consumeTurnstileToken } from './turnstile';
|
|
8
|
+
import { usableUserId } from './user-id';
|
|
9
|
+
import { forgetLiveChat, loadLiveChat } from './chat';
|
|
8
10
|
const DEFAULT_API_URL = 'https://affiliateo.com';
|
|
9
11
|
const LS_VISITOR_ID = 'affiliateo_visitor_id';
|
|
10
12
|
const LS_REF = 'affiliateo_ref';
|
|
@@ -12,11 +14,16 @@ const LS_REF = 'affiliateo_ref';
|
|
|
12
14
|
// write the same key, and renaming it would orphan existing visitors'
|
|
13
15
|
// attribution mid-session.
|
|
14
16
|
const LS_APP_ID = 'affiliateo_campaign_id';
|
|
15
|
-
// Persistent opt-out flag (affiliateo_opt_out)
|
|
17
|
+
// Persistent opt-out flag (affiliateo_opt_out) -
|
|
16
18
|
// when set to "true" the SDK silently noops every outbound call. Survives
|
|
17
19
|
// page reloads. removed by optIn(). Checked at init time so a previously
|
|
18
20
|
// opted-out visitor never re-fires auto-pageviews after a refresh.
|
|
19
21
|
const LS_OPT_OUT = 'affiliateo_opt_out';
|
|
22
|
+
// Who is signed in (4.8.0), and the visit it was last sent in, so the SDK
|
|
23
|
+
// re-sends it once per visit instead of on every page load. Same keys as
|
|
24
|
+
// public/t.js, so a site that runs both keeps one record.
|
|
25
|
+
const LS_USER_ID = 'affiliateo_user_id';
|
|
26
|
+
const LS_USER_SENT = 'affiliateo_user_sent';
|
|
20
27
|
// Client-owned session id. The BROWSER owns the session,
|
|
21
28
|
// not the server. One id is reused for every event within a sliding
|
|
22
29
|
// 10-minute activity window, then a fresh one is minted. Because the id is
|
|
@@ -126,6 +133,7 @@ const AffiliateoContext = createContext({
|
|
|
126
133
|
identify: asyncNoop,
|
|
127
134
|
trackPageview: asyncNoop,
|
|
128
135
|
reset: asyncNoop,
|
|
136
|
+
logout: asyncNoop,
|
|
129
137
|
optOut: syncNoop,
|
|
130
138
|
optIn: syncNoop,
|
|
131
139
|
flush: asyncNoop,
|
|
@@ -158,7 +166,7 @@ export function getAttribution() {
|
|
|
158
166
|
data.affiliateo_visitor_id = vid;
|
|
159
167
|
if (ref)
|
|
160
168
|
data.affiliateo_ref = ref;
|
|
161
|
-
// Key renamed from affiliateo_campaign_id in 4.5.0
|
|
169
|
+
// Key renamed from affiliateo_campaign_id in 4.5.0 - the Affiliateo
|
|
162
170
|
// webhook accepts both, so checkouts created by older SDKs keep working.
|
|
163
171
|
if (cid)
|
|
164
172
|
data.affiliateo_app_id = cid;
|
|
@@ -180,7 +188,7 @@ let providerMountCount = 0;
|
|
|
180
188
|
* touching it there throws a ReferenceError rather than reading undefined.
|
|
181
189
|
*
|
|
182
190
|
* UNKNOWN COUNTS AS NOT-PRODUCTION, deliberately. The only caller is the
|
|
183
|
-
* double-mount warning, and a double mount double-fires every pageview
|
|
191
|
+
* double-mount warning, and a double mount double-fires every pageview - which
|
|
184
192
|
* corrupts the merchant's own numbers and the affiliate attribution built on
|
|
185
193
|
* them. One stray console line on an unbundled production page is a smaller
|
|
186
194
|
* cost than silently letting that run.
|
|
@@ -188,7 +196,7 @@ let providerMountCount = 0;
|
|
|
188
196
|
function isProductionBuild() {
|
|
189
197
|
return typeof process !== 'undefined' && process?.env?.NODE_ENV === 'production';
|
|
190
198
|
}
|
|
191
|
-
export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames, allowLocalhost, allowIframe, allowFileProtocol, debug, flushInterval, maxQueueSize, autoCapturePageviews, children, }) {
|
|
199
|
+
export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames, allowLocalhost, allowIframe, allowFileProtocol, debug, flushInterval, maxQueueSize, autoCapturePageviews, chat, chatLook, children, }) {
|
|
192
200
|
// Normalize autoCapturePageviews. Three shapes:
|
|
193
201
|
// - undefined / true / object → auto-capture ON (with object overrides)
|
|
194
202
|
// - false → auto-capture OFF entirely (host must call trackPageview)
|
|
@@ -215,6 +223,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
215
223
|
identify: asyncNoop,
|
|
216
224
|
trackPageview: asyncNoop,
|
|
217
225
|
reset: asyncNoop,
|
|
226
|
+
logout: asyncNoop,
|
|
218
227
|
optOut: syncNoop,
|
|
219
228
|
optIn: syncNoop,
|
|
220
229
|
flush: asyncNoop,
|
|
@@ -222,7 +231,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
222
231
|
// Debug logger. Closes over the `debug` prop so flipping it on/off at
|
|
223
232
|
// runtime (e.g. by changing a feature flag) takes effect on the next
|
|
224
233
|
// render without remounting. eslint-disable for the intentional
|
|
225
|
-
// console.log
|
|
234
|
+
// console.log - that's the whole point of this helper.
|
|
226
235
|
const log = useCallback((msg, data) => {
|
|
227
236
|
if (!debug)
|
|
228
237
|
return;
|
|
@@ -248,7 +257,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
248
257
|
// forcing the methods (track / identify / trackPageview) to depend
|
|
249
258
|
// on it for useCallback equality. Initialized inside the route
|
|
250
259
|
// effect since EventQueue's constructor touches localStorage and
|
|
251
|
-
// window
|
|
260
|
+
// window - both unavailable during SSR. Methods called before init
|
|
252
261
|
// (during the first render, before useEffect runs) silently noop;
|
|
253
262
|
// this is fine because methods are only ever called from event
|
|
254
263
|
// handlers, which fire after mount.
|
|
@@ -283,7 +292,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
283
292
|
// Page context, same three fields the pageview sender carries.
|
|
284
293
|
// A goal event used to go out without them, and the server's
|
|
285
294
|
// `path || hostname || '[Web]'` fallback then overwrote the
|
|
286
|
-
// visitor's real current_page with the literal string '[Web]'
|
|
295
|
+
// visitor's real current_page with the literal string '[Web]' -
|
|
287
296
|
// which both broke the tracking-page popover and made the visitor
|
|
288
297
|
// invisible to any funnel step matching on a page path. The server
|
|
289
298
|
// no longer clobbers on missing values, but sending the real path
|
|
@@ -297,44 +306,90 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
297
306
|
payload.metadata = metadata;
|
|
298
307
|
// Enqueue. EventQueue handles persist, retry, and flush. If the
|
|
299
308
|
// queue hasn't initialized yet (called during first render before
|
|
300
|
-
// useEffect runs), this noops
|
|
309
|
+
// useEffect runs), this noops - that's a vanishingly rare race
|
|
301
310
|
// since track() is virtually always called from a click handler.
|
|
302
311
|
queueRef.current?.enqueue(`${apiBase}/api/v1/web/event`, payload);
|
|
303
312
|
log('track', { event: name, metadata });
|
|
304
313
|
}
|
|
305
314
|
catch { /* swallow */ }
|
|
306
315
|
}, [apiBase, log]);
|
|
307
|
-
// identify(userId)
|
|
308
|
-
//
|
|
309
|
-
//
|
|
316
|
+
// identify(userId): say who is signed in (4.8.0). The server keeps one
|
|
317
|
+
// customer per person per app: a sign-in makes this browser that person's
|
|
318
|
+
// (folded in with their phone and other browsers), and a DIFFERENT user
|
|
319
|
+
// signing in here switches the browser to them. user_id only - the SDK
|
|
320
|
+
// does NOT accept, collect, or transmit email or any other PII.
|
|
321
|
+
//
|
|
322
|
+
// The id is stored and re-sent once per visit (the 10-minute session
|
|
323
|
+
// above), on mount, so the server stays in step without a request on
|
|
324
|
+
// every page load. Ids that mean "nobody" are skipped (user-id.ts), and
|
|
325
|
+
// nothing is sent while opted out. A 400 is not retried (queue.ts).
|
|
310
326
|
const identify = useCallback(async (userId) => {
|
|
311
327
|
try {
|
|
312
328
|
if (typeof window === 'undefined')
|
|
313
329
|
return;
|
|
314
|
-
if (isOptedOut())
|
|
315
|
-
|
|
316
|
-
if (!userId || typeof userId !== 'string')
|
|
330
|
+
if (isOptedOut()) {
|
|
331
|
+
log('identify skipped: opted out');
|
|
317
332
|
return;
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
if (!uid || uid.length > 128)
|
|
333
|
+
}
|
|
334
|
+
const uid = usableUserId(userId);
|
|
335
|
+
if (!uid) {
|
|
336
|
+
log('identify skipped: not a user id (empty, over 128 characters, or a placeholder like "null", "guest" or "0")', { user_id: userId });
|
|
323
337
|
return;
|
|
338
|
+
}
|
|
339
|
+
lsSet(LS_USER_ID, uid);
|
|
324
340
|
const cid = appIdRef.current;
|
|
325
341
|
const vid = visitorIdRef.current;
|
|
326
|
-
|
|
342
|
+
const queue = queueRef.current;
|
|
343
|
+
// Not tracking yet: the stored id goes out with the next mount.
|
|
344
|
+
if (!cid || !vid || !queue)
|
|
327
345
|
return;
|
|
328
|
-
const
|
|
346
|
+
const mark = `${vid} ${getSessionId()} ${uid}`;
|
|
347
|
+
if (lsGet(LS_USER_SENT) === mark) {
|
|
348
|
+
log('identify: already sent this visit', { user_id: uid });
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
lsSet(LS_USER_SENT, mark);
|
|
352
|
+
// A sign-out still waiting in the queue is replaced by this sign-in;
|
|
353
|
+
// delivered after it, it would sign the new person out again.
|
|
354
|
+
const logoutEndpoint = `${apiBase}/api/v1/web/logout`;
|
|
355
|
+
queue.drop((e) => e.endpoint === logoutEndpoint && e.payload.visitor_id === vid);
|
|
356
|
+
queue.enqueue(`${apiBase}/api/v1/web/identify`, {
|
|
329
357
|
campaign_id: cid,
|
|
330
358
|
visitor_id: vid,
|
|
331
359
|
user_id: uid,
|
|
332
|
-
};
|
|
333
|
-
queueRef.current?.enqueue(`${apiBase}/api/v1/web/identify`, payload);
|
|
360
|
+
});
|
|
334
361
|
log('identify', { user_id: uid });
|
|
335
362
|
}
|
|
336
363
|
catch { /* swallow */ }
|
|
337
364
|
}, [apiBase, log]);
|
|
365
|
+
// logout(): the person signed out (4.8.0). POST /api/v1/web/logout: the
|
|
366
|
+
// browser starts over as a new anonymous customer of the app, so whoever
|
|
367
|
+
// signs in next (or nobody) is not counted as the person who just left,
|
|
368
|
+
// and everything already recorded stays with them. Forgets the stored
|
|
369
|
+
// user id and their Live Chat. The visitor id, the affiliate ref, the
|
|
370
|
+
// session and tracking itself all carry on unchanged.
|
|
371
|
+
const logout = useCallback(async () => {
|
|
372
|
+
try {
|
|
373
|
+
if (typeof window === 'undefined')
|
|
374
|
+
return;
|
|
375
|
+
log('logout');
|
|
376
|
+
lsRemove(LS_USER_ID);
|
|
377
|
+
lsRemove(LS_USER_SENT);
|
|
378
|
+
forgetLiveChat(appIdRef.current);
|
|
379
|
+
if (isOptedOut())
|
|
380
|
+
return;
|
|
381
|
+
const cid = appIdRef.current;
|
|
382
|
+
const vid = visitorIdRef.current;
|
|
383
|
+
const queue = queueRef.current;
|
|
384
|
+
if (!cid || !vid || !queue)
|
|
385
|
+
return;
|
|
386
|
+
queue.enqueue(`${apiBase}/api/v1/web/logout`, { campaign_id: cid, visitor_id: vid });
|
|
387
|
+
// Sign-out usually comes right before a redirect, so send it now.
|
|
388
|
+
// Still queued if this fails: the next page load delivers it.
|
|
389
|
+
await queue.flush();
|
|
390
|
+
}
|
|
391
|
+
catch { /* swallow */ }
|
|
392
|
+
}, [apiBase, log]);
|
|
338
393
|
// Manual pageview via trackPageview(path?). Same payload
|
|
339
394
|
// shape the auto-fired pageview uses (see sendPageview inside the route
|
|
340
395
|
// effect below) so server-side routing / dedup / IP+UA enrichment behave
|
|
@@ -375,7 +430,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
375
430
|
utm_source: params.get('utm_source') || '',
|
|
376
431
|
utm_medium: params.get('utm_medium') || '',
|
|
377
432
|
utm_campaign: params.get('utm_campaign') || '',
|
|
378
|
-
// Ad-network click ids
|
|
433
|
+
// Ad-network click ids - set automatically on the landing URL by
|
|
379
434
|
// every Meta/TikTok/Google ad click. Label the sale's source and
|
|
380
435
|
// feed server-side conversion matching.
|
|
381
436
|
fbclid: params.get('fbclid') || '',
|
|
@@ -398,7 +453,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
398
453
|
// batches and auto-flushes every 5s; this is the escape hatch for the
|
|
399
454
|
// "I'm about to redirect, don't lose my events" case.
|
|
400
455
|
//
|
|
401
|
-
// Resolves when the underlying queue.flush() completes
|
|
456
|
+
// Resolves when the underlying queue.flush() completes - success OR
|
|
402
457
|
// failure. Events that fail to deliver stay in the queue and get
|
|
403
458
|
// retried on the next normal flush cycle. Safe to await; never throws.
|
|
404
459
|
const flush = useCallback(async () => {
|
|
@@ -408,49 +463,23 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
408
463
|
}
|
|
409
464
|
catch { /* swallow */ }
|
|
410
465
|
}, [log]);
|
|
411
|
-
// reset():
|
|
412
|
-
//
|
|
413
|
-
//
|
|
414
|
-
//
|
|
415
|
-
|
|
416
|
-
// visitor, no event fired, caller handles "tell the server about it"
|
|
417
|
-
// by triggering whatever pageview / navigation comes next.
|
|
418
|
-
//
|
|
419
|
-
// Returns a Promise so callers can `await reset()` before navigating
|
|
420
|
-
// (the localStorage writes are sync but the Promise lets the API stay
|
|
421
|
-
// an async signature, future-proofing for
|
|
422
|
-
// server-minted IDs in cookieless mode).
|
|
423
|
-
const reset = useCallback(async () => {
|
|
424
|
-
try {
|
|
425
|
-
if (typeof window === 'undefined')
|
|
426
|
-
return;
|
|
427
|
-
// Drain any pending events for the OLD visitor before swapping IDs,
|
|
428
|
-
// otherwise they'd land server-side stamped to the OLD visitor_id
|
|
429
|
-
// (correct) but persisted-in-localStorage as if to-be-retried under
|
|
430
|
-
// the new identity — confusing on retry inspection. Best effort.
|
|
431
|
-
void queueRef.current?.flush();
|
|
432
|
-
queueRef.current?.clear();
|
|
433
|
-
const newVid = uuid();
|
|
434
|
-
lsSet(LS_VISITOR_ID, newVid);
|
|
435
|
-
lsRemove(LS_REF);
|
|
436
|
-
lsRemove(LS_SESSION);
|
|
437
|
-
visitorIdRef.current = newVid;
|
|
438
|
-
setState((prev) => ({ ...prev, visitorId: newVid, refCode: null }));
|
|
439
|
-
}
|
|
440
|
-
catch { /* swallow */ }
|
|
441
|
-
}, []);
|
|
466
|
+
// reset(): the same as logout() since 4.8.0. It used to mint a fresh
|
|
467
|
+
// visitor id and clear the affiliate ref and session, which split one
|
|
468
|
+
// person's history into two visitors and lost their affiliate. Kept so
|
|
469
|
+
// existing integrations keep compiling and now get the sign-out they meant.
|
|
470
|
+
const reset = logout;
|
|
442
471
|
// optOut(): persistent "don't track me" flag. After this call every
|
|
443
472
|
// track / identify / trackPageview noops until optIn() is called. The
|
|
444
473
|
// flag survives reloads via localStorage, so a visitor who opts out
|
|
445
474
|
// stays opted out across browser sessions on the same device.
|
|
446
475
|
//
|
|
447
|
-
// We don't actively send a "stop tracking me" signal to the server
|
|
476
|
+
// We don't actively send a "stop tracking me" signal to the server -
|
|
448
477
|
// the server only knows about visitors who ping it, so an opted-out
|
|
449
478
|
// visitor simply stops appearing. The visitor row left behind in
|
|
450
479
|
// Postgres ages out via normal retention.
|
|
451
480
|
const optOut = useCallback(() => {
|
|
452
481
|
lsSet(LS_OPT_OUT, 'true');
|
|
453
|
-
// Drop pending events too. The visitor explicitly said no
|
|
482
|
+
// Drop pending events too. The visitor explicitly said no - sending
|
|
454
483
|
// queued events from before the opt-out would still violate consent
|
|
455
484
|
// even if those events were captured prior to the user's decision.
|
|
456
485
|
queueRef.current?.clear();
|
|
@@ -464,13 +493,26 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
464
493
|
lsRemove(LS_OPT_OUT);
|
|
465
494
|
}, []);
|
|
466
495
|
const sentRef = useRef(false);
|
|
496
|
+
// Boot configuration, read once when the queue and the listeners are
|
|
497
|
+
// constructed. A ref kept current on every render rather than four more
|
|
498
|
+
// dependencies: listing them would tear the whole pipeline down and
|
|
499
|
+
// rebuild it whenever a host re-renders with a new inline object, and
|
|
500
|
+
// shutdown() drops the in-memory queue, so honoring the rule that way
|
|
501
|
+
// would cost real events. A host that needs different settings should
|
|
502
|
+
// remount the provider. (Hosts run React 18, so this is the latest-ref
|
|
503
|
+
// pattern rather than useEffectEvent.)
|
|
504
|
+
const bootRef = useRef({ flushInterval, maxQueueSize, allowFileProtocol, autoCfg });
|
|
505
|
+
useEffect(() => {
|
|
506
|
+
bootRef.current = { flushInterval, maxQueueSize, allowFileProtocol, autoCfg };
|
|
507
|
+
});
|
|
467
508
|
useEffect(() => {
|
|
468
509
|
if (typeof window === 'undefined')
|
|
469
510
|
return;
|
|
470
|
-
|
|
511
|
+
const { flushInterval, maxQueueSize, allowFileProtocol, autoCfg } = bootRef.current;
|
|
512
|
+
// Neither appId nor the legacy campaignId alias was passed - nothing to
|
|
471
513
|
// attribute events to, so stay dormant instead of firing junk.
|
|
472
514
|
if (!appIdRef.current) {
|
|
473
|
-
console.error('[Affiliateo] Missing appId
|
|
515
|
+
console.error('[Affiliateo] Missing appId - pass your app ID to <AffiliateoProvider appId="...">.');
|
|
474
516
|
return;
|
|
475
517
|
}
|
|
476
518
|
// Mount-count warning. Fires only when more than one Provider has
|
|
@@ -483,7 +525,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
483
525
|
// Client-side bot detection. If the current browser context looks
|
|
484
526
|
// like Selenium / Puppeteer / curl / a known automation tool, skip
|
|
485
527
|
// ALL tracking before we even mint a queue. The server-side UA
|
|
486
|
-
// regex + Turnstile catch anything that sneaks past this check
|
|
528
|
+
// regex + Turnstile catch anything that sneaks past this check -
|
|
487
529
|
// this just saves the bot a round-trip and saves us a request.
|
|
488
530
|
//
|
|
489
531
|
// The hook still returns the four control methods (reset / optOut /
|
|
@@ -499,6 +541,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
499
541
|
identify: asyncNoop,
|
|
500
542
|
trackPageview: asyncNoop,
|
|
501
543
|
reset: asyncNoop,
|
|
544
|
+
logout: asyncNoop,
|
|
502
545
|
optOut: syncNoop,
|
|
503
546
|
optIn: syncNoop,
|
|
504
547
|
flush: asyncNoop,
|
|
@@ -523,6 +566,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
523
566
|
identify: asyncNoop,
|
|
524
567
|
trackPageview: asyncNoop,
|
|
525
568
|
reset: asyncNoop,
|
|
569
|
+
logout: asyncNoop,
|
|
526
570
|
optOut: syncNoop,
|
|
527
571
|
optIn: syncNoop,
|
|
528
572
|
flush: asyncNoop,
|
|
@@ -541,6 +585,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
541
585
|
identify: asyncNoop,
|
|
542
586
|
trackPageview: asyncNoop,
|
|
543
587
|
reset: asyncNoop,
|
|
588
|
+
logout: asyncNoop,
|
|
544
589
|
optOut: syncNoop,
|
|
545
590
|
optIn: syncNoop,
|
|
546
591
|
flush: asyncNoop,
|
|
@@ -575,6 +620,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
575
620
|
identify: asyncNoop,
|
|
576
621
|
trackPageview: asyncNoop,
|
|
577
622
|
reset: asyncNoop,
|
|
623
|
+
logout: asyncNoop,
|
|
578
624
|
optOut: syncNoop,
|
|
579
625
|
optIn: syncNoop,
|
|
580
626
|
flush: asyncNoop,
|
|
@@ -583,8 +629,8 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
583
629
|
}
|
|
584
630
|
}
|
|
585
631
|
// Initialize the event queue. Done inside useEffect (not in a useRef
|
|
586
|
-
// initializer) so the EventQueue constructor
|
|
587
|
-
// localStorage on hydration
|
|
632
|
+
// initializer) so the EventQueue constructor - which touches
|
|
633
|
+
// localStorage on hydration - only ever runs client-side. The queue
|
|
588
634
|
// takes over delivery for track / identify / trackPageview AND the
|
|
589
635
|
// auto-fired pageview below; only session_end stays on sendBeacon
|
|
590
636
|
// because tab-close urgency beats queue-retry reliability.
|
|
@@ -613,9 +659,9 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
613
659
|
setState({
|
|
614
660
|
refCode: null,
|
|
615
661
|
visitorId: optedOutVid,
|
|
616
|
-
track, identify, trackPageview, reset, optOut, optIn, flush,
|
|
662
|
+
track, identify, trackPageview, reset, logout, optOut, optIn, flush,
|
|
617
663
|
});
|
|
618
|
-
// Don't leave a queue running for an opted-out visitor
|
|
664
|
+
// Don't leave a queue running for an opted-out visitor - the timer
|
|
619
665
|
// would just keep waking up to flush nothing.
|
|
620
666
|
queue.shutdown();
|
|
621
667
|
queueRef.current = null;
|
|
@@ -673,9 +719,13 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
673
719
|
setState({
|
|
674
720
|
refCode: ref,
|
|
675
721
|
visitorId: vid,
|
|
676
|
-
track, identify, trackPageview, reset, optOut, optIn, flush,
|
|
722
|
+
track, identify, trackPageview, reset, logout, optOut, optIn, flush,
|
|
677
723
|
});
|
|
678
724
|
log('init', { app: appIdRef.current, visitor: vid, ref: ref || null });
|
|
725
|
+
// This visit's re-send of the signed-in user (see identify).
|
|
726
|
+
const storedUser = usableUserId(lsGet(LS_USER_ID));
|
|
727
|
+
if (storedUser)
|
|
728
|
+
void identify(storedUser);
|
|
679
729
|
// lastSendTime drives the visibility-refire heuristic below (refire a
|
|
680
730
|
// pageview when a tab returns after the session window). Duplicate
|
|
681
731
|
// suppression itself is the persisted same-URL throttle inside
|
|
@@ -708,7 +758,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
708
758
|
ended = false;
|
|
709
759
|
// Attach the (single-use) Turnstile token to the session-opening
|
|
710
760
|
// pageview. consumeTurnstileToken() returns null after the first
|
|
711
|
-
// consume, so SPA navigations go out bare
|
|
761
|
+
// consume, so SPA navigations go out bare - the server trusts them
|
|
712
762
|
// because the visitor row was created behind the verified token.
|
|
713
763
|
const tsToken = consumeTurnstileToken();
|
|
714
764
|
queue.enqueue(endpoint, {
|
|
@@ -739,7 +789,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
739
789
|
// directly. The queue's fetch-with-keepalive is great for normal
|
|
740
790
|
// events because it returns a status code we can retry on, but it
|
|
741
791
|
// can't guarantee the request lands when the tab is in the act of
|
|
742
|
-
// closing
|
|
792
|
+
// closing - fetch may be aborted mid-flight. sendBeacon was
|
|
743
793
|
// designed exactly for this case and CANNOT be cancelled by the
|
|
744
794
|
// unload handler. We also try to drain the queue's pending events
|
|
745
795
|
// here, which uses sendBeacon under the hood for the same reason.
|
|
@@ -772,7 +822,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
772
822
|
}
|
|
773
823
|
}
|
|
774
824
|
// Server-side session window. Matches STALE_VISITOR_MS in the
|
|
775
|
-
// tracking Durable Object
|
|
825
|
+
// tracking Durable Object - after this much idle time the server
|
|
776
826
|
// treats the visitor as a new session, rotates session_id, and
|
|
777
827
|
// expects a fresh pageview to anchor the new visit in
|
|
778
828
|
// campaign_events. Without that anchor, funnel steps starting on
|
|
@@ -789,7 +839,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
789
839
|
// If the SDK's last pageview was longer ago than the server's
|
|
790
840
|
// 10-min session window, the server has already rolled the
|
|
791
841
|
// visitor over to a new session_id. Refire pageview so the new
|
|
792
|
-
// session has a proper anchor
|
|
842
|
+
// session has a proper anchor - otherwise the next scroll /
|
|
793
843
|
// click / custom event arrives orphaned and the funnel overlay
|
|
794
844
|
// sees the visitor enter "mid-funnel" (e.g. on a scroll step)
|
|
795
845
|
// without ever passing through the page-step that should
|
|
@@ -812,7 +862,7 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
812
862
|
//
|
|
813
863
|
// sentRef is set HERE (not before the mint) so a StrictMode
|
|
814
864
|
// double-mount can't mark the pageview as sent and then tear down the
|
|
815
|
-
// queue before the deferred send ever ran
|
|
865
|
+
// queue before the deferred send ever ran - the surviving mount's
|
|
816
866
|
// callback sees sentRef still false and fires.
|
|
817
867
|
if (cancelled)
|
|
818
868
|
return;
|
|
@@ -971,20 +1021,33 @@ export function AffiliateoProvider({ appId, campaignId, apiUrl, allowedHostnames
|
|
|
971
1021
|
document.removeEventListener('keydown', onKeydownForLinkDecoration, true);
|
|
972
1022
|
}
|
|
973
1023
|
// Stop the queue's flush timer. The queue tries one final flush
|
|
974
|
-
// before going quiet
|
|
1024
|
+
// before going quiet - anything still queued lives in localStorage
|
|
975
1025
|
// and will be picked up by the next page load's queue init.
|
|
976
1026
|
queue.shutdown();
|
|
977
1027
|
queueRef.current = null;
|
|
978
1028
|
providerMountCount = Math.max(0, providerMountCount - 1);
|
|
979
1029
|
};
|
|
980
|
-
//
|
|
981
|
-
//
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
1030
|
+
// The boot configuration comes off bootRef above, so this list is what
|
|
1031
|
+
// legitimately rebuilds the pipeline.
|
|
1032
|
+
}, [apiBase, allowedHostnames, allowLocalhost, allowIframe, log, track, identify, trackPageview, reset, logout, optOut, optIn, flush]);
|
|
1033
|
+
// Live Chat (4.9.0, chat.ts). After the tracking effect above has read or
|
|
1034
|
+
// minted the visitor id, so the inbox can name the visitor. Neither the
|
|
1035
|
+
// visitor nor the signed-in user goes along while tracking is opted out.
|
|
1036
|
+
useEffect(() => {
|
|
1037
|
+
if (chat === false || !resolvedAppId)
|
|
1038
|
+
return;
|
|
1039
|
+
const optedOut = isOptedOut();
|
|
1040
|
+
loadLiveChat({
|
|
1041
|
+
apiBase,
|
|
1042
|
+
appId: resolvedAppId,
|
|
1043
|
+
visitorId: optedOut ? null : visitorIdRef.current ?? lsGet(LS_VISITOR_ID),
|
|
1044
|
+
userId: optedOut ? null : usableUserId(lsGet(LS_USER_ID)),
|
|
1045
|
+
bubble: chat === 'hidden' ? 'hidden' : 'visible',
|
|
1046
|
+
look: chatLook,
|
|
1047
|
+
log,
|
|
1048
|
+
});
|
|
1049
|
+
// The look is read when the bubble loads.
|
|
987
1050
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
988
|
-
}, [
|
|
1051
|
+
}, [chat, resolvedAppId, apiBase, log]);
|
|
989
1052
|
return (_jsx(AffiliateoContext.Provider, { value: state, children: children }));
|
|
990
1053
|
}
|
package/dist/queue.d.ts
CHANGED
|
@@ -85,16 +85,20 @@ export declare class EventQueue {
|
|
|
85
85
|
* flush at a time. */
|
|
86
86
|
flush(): Promise<void>;
|
|
87
87
|
/** Best-effort sync flush triggered on page unload. Uses sendBeacon
|
|
88
|
-
* for each event so it survives the tab closing. No retry
|
|
88
|
+
* for each event so it survives the tab closing. No retry - this is
|
|
89
89
|
* the last chance.
|
|
90
90
|
*
|
|
91
91
|
* Called from the provider's pagehide / visibilitychange=hidden
|
|
92
92
|
* handlers so any events still queued at unload get one last shot.
|
|
93
93
|
* After this call, the in-memory queue is cleared (events that
|
|
94
|
-
* sendBeacon couldn't queue are lost
|
|
94
|
+
* sendBeacon couldn't queue are lost - that's the cost of the
|
|
95
95
|
* guaranteed-fast unload path). */
|
|
96
96
|
flushOnUnload(): void;
|
|
97
|
-
/**
|
|
97
|
+
/** Remove the queued events `predicate` picks (4.8.0). A sign-in uses it
|
|
98
|
+
* to drop a sign-out still waiting to be delivered: the sign-in replaces
|
|
99
|
+
* it, and a sign-out retried after it would undo it. */
|
|
100
|
+
drop(predicate: (event: QueuedEvent) => boolean): void;
|
|
101
|
+
/** Wipe the queue. Called by optOut(). Removes the
|
|
98
102
|
* localStorage entry entirely so a reload doesn't re-hydrate the
|
|
99
103
|
* wiped events. */
|
|
100
104
|
clear(): void;
|