@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.
- package/dist/provider.d.ts +1 -1
- package/dist/provider.js +106 -7
- package/dist/types.d.ts +24 -0
- package/package.json +1 -1
package/dist/provider.d.ts
CHANGED
|
@@ -18,6 +18,6 @@ export declare function useAffiliateRef(): AffiliateoContextValue;
|
|
|
18
18
|
* ```
|
|
19
19
|
*/
|
|
20
20
|
export declare function getAttribution(): Record<string, string>;
|
|
21
|
-
export declare function AffiliateoProvider({ campaignId, apiUrl, children, }: AffiliateoConfig & {
|
|
21
|
+
export declare function AffiliateoProvider({ campaignId, apiUrl, allowedHostnames, 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
|
-
//
|
|
371
|
-
|
|
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
|
|
379
|
-
|
|
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) */
|