@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/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 type { AffiliateoConfig, AffiliateoContextValue } from './types';
2
+ export { openChat, closeChat, showChatBubble, hideChatBubble } from './chat';
3
+ export type { AffiliateoConfig, AffiliateoContextValue, ChatLook } from './types';
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
1
  'use client';
2
2
  export { AffiliateoProvider, useAffiliateRef, getAttribution } from './provider';
3
+ export { openChat, closeChat, showChatBubble, hideChatBubble } from './chat';
@@ -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 — the Affiliateo
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 — which
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 — that's the whole point of this helper.
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 — both unavailable during SSR. Methods called before init
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 — that's a vanishingly rare race
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). user_id only — the SDK does NOT accept, collect,
308
- // or transmit email or any other PII. Cross-device stitching uses the
309
- // merchant's own user_id; we never touch identifying personal data.
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
- return;
316
- if (!userId || typeof userId !== 'string')
330
+ if (isOptedOut()) {
331
+ log('identify skipped: opted out');
317
332
  return;
318
- const uid = userId.trim();
319
- // Cap at 128 chars (matches the server). Covers every common
320
- // foreign user-id shape: uuid, ulid, mongo objectId, auth0 sub,
321
- // firebase uid, supabase uuid.
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
- if (!cid || !vid)
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 payload = {
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 — set automatically on the landing URL by
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 — success OR
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(): wipe visitor identity. Mints a fresh UUID and clears the
412
- // affiliate ref so the next pageview is a brand-new anonymous visitor.
413
- // The campaign_id stays (it's a property of the site, not the visitor).
414
- //
415
- // reset() semantics for non-cookieless mode: new
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 — sending
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
- // Neither appId nor the legacy campaignId alias was passed — nothing to
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 — pass your app ID to <AffiliateoProvider 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 — which touches
587
- // localStorage on hydration — only ever runs client-side. The queue
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 — the timer
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 — the server trusts them
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 — fetch may be aborted mid-flight. sendBeacon was
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 — after this much idle time the server
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 — otherwise the next scroll /
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 — the surviving mount's
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 — anything still queued lives in localStorage
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
- // flushInterval, maxQueueSize, allowFileProtocol and the autoCfg.* fields
981
- // are BOOT configuration, read once when the queue and listeners are
982
- // constructed. Listing them would tear the whole pipeline down and rebuild
983
- // it whenever a host app re-renders with a new inline object, and
984
- // shutdown() drops the in-memory queue — so honoring the rule here would
985
- // cost real events. A host that needs different settings should remount
986
- // the provider.
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
- }, [apiBase, allowedHostnames, allowLocalhost, allowIframe, log, track, identify, trackPageview, reset, optOut, optIn, flush]);
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 — this is
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 — that's the cost of the
94
+ * sendBeacon couldn't queue are lost - that's the cost of the
95
95
  * guaranteed-fast unload path). */
96
96
  flushOnUnload(): void;
97
- /** Wipe the queue. Called by reset() and optOut(). Removes the
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;