@affiliateo/web 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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, children, }: AffiliateoConfig & {
22
22
  children: React.ReactNode;
23
23
  }): import("react/jsx-runtime").JSX.Element;
package/dist/provider.js CHANGED
@@ -100,7 +100,7 @@ export function getAttribution() {
100
100
  // mount (host has the call sites) but we can shout in dev so the
101
101
  // mistake is visible.
102
102
  let providerMountCount = 0;
103
- export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
103
+ export function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, children, }) {
104
104
  const [state, setState] = useState({
105
105
  refCode: null,
106
106
  visitorId: null,
@@ -367,17 +367,50 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
367
367
  queueRef.current = null;
368
368
  return;
369
369
  }
370
- // Get or create visitor ID
371
- let vid = lsGet(LS_VISITOR_ID);
370
+ // ── Cross-domain handoff: read incoming URL params ───────────────────
371
+ // When a visitor arrives from another domain in the merchant's family
372
+ // (configured via the `allowedHostnames` prop on the OTHER domain's
373
+ // <AffiliateoProvider>), the inbound link carries visitor_id + ref
374
+ // code as URL params. We pull those in BEFORE checking localStorage
375
+ // so the cross-domain identity wins over whatever this domain's
376
+ // sandboxed storage had. Without it, the funnel sees the same person
377
+ // as two unrelated visitors after every cross-domain hop, and any
378
+ // affiliate ref captured on the previous domain is silently lost.
379
+ //
380
+ // After consuming, we strip the params via history.replaceState so
381
+ // the visitor never sees `?_affiliateo_vid=...` in their URL bar.
382
+ // UUID + ref are validated before persistence so a malicious
383
+ // /?_affiliateo_vid=<garbage> URL can't poison localStorage.
384
+ const params = new URLSearchParams(window.location.search);
385
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
386
+ let incomingVid = params.get('_affiliateo_vid') || '';
387
+ let incomingRef = params.get('_affiliateo_ref') || '';
388
+ if (incomingVid && !UUID_RE.test(incomingVid))
389
+ incomingVid = '';
390
+ if (incomingRef && (incomingRef.length > 64 || !/^[\w.-]+$/.test(incomingRef))) {
391
+ incomingRef = '';
392
+ }
393
+ if (incomingVid || incomingRef) {
394
+ try {
395
+ const cleanUrl = new URL(window.location.href);
396
+ cleanUrl.searchParams.delete('_affiliateo_vid');
397
+ cleanUrl.searchParams.delete('_affiliateo_ref');
398
+ history.replaceState(history.state, '', cleanUrl.toString());
399
+ }
400
+ catch { /* swallow */ }
401
+ }
402
+ // Get or create visitor ID. Cross-domain inbound wins over localStorage
403
+ // so the same person across two domains stays one visitor.
404
+ let vid = incomingVid || lsGet(LS_VISITOR_ID);
372
405
  if (!vid) {
373
406
  vid = uuid();
374
407
  }
375
408
  lsSet(LS_VISITOR_ID, vid);
376
409
  lsSet(LS_CAMPAIGN_ID, campaignIdRef.current);
377
410
  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')
411
+ // Read ref. Cross-domain inbound wins, then fresh affiliate link
412
+ // params (?ref / ?via / ?source), then stored value.
413
+ const ref = incomingRef || params.get('ref') || params.get('via') || params.get('source')
381
414
  || lsGet(LS_REF) || null;
382
415
  if (ref) {
383
416
  lsSet(LS_REF, ref);
@@ -515,12 +548,78 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
515
548
  // bug we hit (threshold:0 firing on hero sections, SPA mutation
516
549
  // observer races, client-side step matching from HTML). Removing it
517
550
  // also drops ~80 lines of code and one extra DOM observer per page.
551
+ // ── Cross-domain handoff: decorate outbound links ──────────────────
552
+ // When a visitor clicks an <a> link to one of the merchant's other
553
+ // domains (passed in via the `allowedHostnames` prop), append
554
+ // visitor_id + ref code as URL search params before the browser
555
+ // navigates. The receiving domain's <AffiliateoProvider> reads those
556
+ // params on landing (the inbound block above) and uses them as
557
+ // identity for that domain.
558
+ //
559
+ // Hostnames are normalized once and matched exact (no wildcards) so
560
+ // merchants who want subdomains list each one explicitly. Same-host
561
+ // links, non-http(s) protocols (mailto:, tel:, javascript:), and
562
+ // anchor links (#section) are skipped. Capture phase so we run
563
+ // before merchant click handlers; href mutation is advisory and the
564
+ // browser reads link.href when it eventually navigates.
565
+ const allowedHosts = Array.isArray(allowedHostnames)
566
+ ? allowedHostnames
567
+ .map((h) => (typeof h === 'string' ? h.trim().toLowerCase() : ''))
568
+ .filter((h) => !!h)
569
+ : [];
570
+ function decorateOutboundLink(link) {
571
+ if (!link || !link.href || !link.hostname)
572
+ return;
573
+ if (link.protocol !== 'http:' && link.protocol !== 'https:')
574
+ return;
575
+ const host = link.hostname.toLowerCase();
576
+ if (host === window.location.hostname.toLowerCase())
577
+ return;
578
+ if (allowedHosts.indexOf(host) === -1)
579
+ return;
580
+ try {
581
+ const u = new URL(link.href);
582
+ // Idempotent: skip if our params are already present.
583
+ if (u.searchParams.has('_affiliateo_vid'))
584
+ return;
585
+ const currentVid = visitorIdRef.current;
586
+ const currentRef = lsGet(LS_REF) || '';
587
+ if (currentVid)
588
+ u.searchParams.set('_affiliateo_vid', currentVid);
589
+ if (currentRef)
590
+ u.searchParams.set('_affiliateo_ref', currentRef);
591
+ link.href = u.toString();
592
+ }
593
+ catch { /* swallow */ }
594
+ }
595
+ function onClickForLinkDecoration(ev) {
596
+ const t = ev.target;
597
+ if (!t || !t.closest)
598
+ return;
599
+ decorateOutboundLink(t.closest('a'));
600
+ }
601
+ function onKeydownForLinkDecoration(ev) {
602
+ if (ev.key !== 'Enter' && ev.key !== ' ')
603
+ return;
604
+ const t = ev.target;
605
+ if (!t || !t.closest)
606
+ return;
607
+ decorateOutboundLink(t.closest('a'));
608
+ }
609
+ if (allowedHosts.length > 0) {
610
+ document.addEventListener('click', onClickForLinkDecoration, true);
611
+ document.addEventListener('keydown', onKeydownForLinkDecoration, true);
612
+ }
518
613
  return () => {
519
614
  document.removeEventListener('visibilitychange', onVisibilityChange);
520
615
  window.removeEventListener('pagehide', sendEnd);
521
616
  window.removeEventListener('popstate', onUrlChange);
522
617
  history.pushState = origPushState;
523
618
  history.replaceState = origReplaceState;
619
+ if (allowedHosts.length > 0) {
620
+ document.removeEventListener('click', onClickForLinkDecoration, true);
621
+ document.removeEventListener('keydown', onKeydownForLinkDecoration, true);
622
+ }
524
623
  // Stop the queue's flush timer. The queue tries one final flush
525
624
  // before going quiet — anything still queued lives in localStorage
526
625
  // and will be picked up by the next page load's queue init.
@@ -528,6 +627,6 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
528
627
  queueRef.current = null;
529
628
  providerMountCount = Math.max(0, providerMountCount - 1);
530
629
  };
531
- }, [apiBase, track, identify, trackPageview, reset, optOut, optIn]);
630
+ }, [apiBase, allowedHostnames, track, identify, trackPageview, reset, optOut, optIn]);
532
631
  return (_jsx(AffiliateoContext.Provider, { value: state, children: children }));
533
632
  }
package/dist/types.d.ts CHANGED
@@ -2,6 +2,30 @@ 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[];
5
29
  }
6
30
  export interface AffiliateoContextValue {
7
31
  /** The affiliate ref code from the URL (if present) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@affiliateo/web",
3
- "version": "3.0.0",
3
+ "version": "3.1.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",