@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.
@@ -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;
@@ -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
+ }
@@ -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
- }, [apiBase]);
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
- // Get or create visitor ID
371
- let vid = lsGet(LS_VISITOR_ID);
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 from URL params or localStorage
379
- const params = new URLSearchParams(window.location.search);
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@affiliateo/web",
3
- "version": "3.0.0",
3
+ "version": "4.0.0",
4
4
  "description": "Affiliateo web SDK — affiliate attribution and tracking for web apps",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",