@affiliateo/web 2.1.0 → 2.3.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 +9 -4
- package/dist/provider.js +140 -85
- package/dist/types.d.ts +4 -6
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -171,7 +171,11 @@ attributes become metadata (kebab-case → snake_case, up to 10 params).
|
|
|
171
171
|
</section>
|
|
172
172
|
```
|
|
173
173
|
|
|
174
|
-
Fires once when the section
|
|
174
|
+
Fires once when 10% of the section enters the viewport (combined with a
|
|
175
|
+
small bottom-edge rootMargin so it doesn't fire on sections barely peeking
|
|
176
|
+
up from the bottom of the screen). The lower threshold catches tall
|
|
177
|
+
sections like pricing pages with comparison tables that would otherwise
|
|
178
|
+
top out under 50% intersection and never fire. Great for landing pages
|
|
175
179
|
where you want to know which sections convert.
|
|
176
180
|
|
|
177
181
|
### Goal naming rules
|
|
@@ -194,13 +198,15 @@ export default function AuthBoundary({ children }) {
|
|
|
194
198
|
const { identify } = useAffiliateRef()
|
|
195
199
|
const user = useUser()
|
|
196
200
|
useEffect(() => {
|
|
197
|
-
if (user) identify(user.id
|
|
201
|
+
if (user) identify(user.id)
|
|
198
202
|
}, [user])
|
|
199
203
|
return <>{children}</>
|
|
200
204
|
}
|
|
201
205
|
```
|
|
202
206
|
|
|
203
207
|
Idempotent. safe to fire on every page load while a user is signed in.
|
|
208
|
+
**user_id only.** The SDK does not accept, collect, or transmit email
|
|
209
|
+
or any other PII.
|
|
204
210
|
|
|
205
211
|
## API
|
|
206
212
|
|
|
@@ -217,8 +223,7 @@ Props:
|
|
|
217
223
|
React hook that returns `{ refCode, visitorId, track, identify }`.
|
|
218
224
|
|
|
219
225
|
- `track(eventName, metadata?)`. fire a custom goal
|
|
220
|
-
- `identify(userId
|
|
221
|
-
`opts.email` optional
|
|
226
|
+
- `identify(userId)`. link this visitor to a merchant user_id (no PII)
|
|
222
227
|
|
|
223
228
|
### `getAttribution()`
|
|
224
229
|
|
package/dist/provider.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
import { createContext, useContext, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
|
4
4
|
const DEFAULT_API_URL = 'https://affiliateo.com';
|
|
5
5
|
const LS_VISITOR_ID = 'affiliateo_visitor_id';
|
|
6
6
|
const LS_REF = 'affiliateo_ref';
|
|
@@ -69,6 +69,13 @@ export function getAttribution() {
|
|
|
69
69
|
data.affiliateo_campaign_id = cid;
|
|
70
70
|
return data;
|
|
71
71
|
}
|
|
72
|
+
// Double-mount detection. The host app should mount AffiliateoProvider
|
|
73
|
+
// exactly once near the root. Mounting twice produces double pageviews,
|
|
74
|
+
// duplicate history.pushState patches stacking on each other, and
|
|
75
|
+
// competing session_end signals. We can't always *prevent* the second
|
|
76
|
+
// mount (host has the call sites) but we can shout in dev so the
|
|
77
|
+
// mistake is visible.
|
|
78
|
+
let providerMountCount = 0;
|
|
72
79
|
export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
73
80
|
const [state, setState] = useState({
|
|
74
81
|
refCode: null,
|
|
@@ -77,11 +84,108 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
77
84
|
identify: asyncNoop,
|
|
78
85
|
});
|
|
79
86
|
const apiBase = (apiUrl || DEFAULT_API_URL).replace(/\/$/, '');
|
|
87
|
+
// campaignIdRef stays current so callbacks defined inside useEffect
|
|
88
|
+
// always read the latest value, even after a prop change. Closures
|
|
89
|
+
// captured by host-side `useRef` no longer drift to a stale id.
|
|
80
90
|
const campaignIdRef = useRef(campaignId);
|
|
91
|
+
campaignIdRef.current = campaignId;
|
|
92
|
+
// Mint visitorId once and put it in a ref so it survives campaignId
|
|
93
|
+
// prop changes without forcing a re-fetch / re-mint cycle. Same UUID
|
|
94
|
+
// for the lifetime of the localStorage entry across the whole tab.
|
|
95
|
+
const visitorIdRef = useRef(null);
|
|
96
|
+
// track + identify are defined OUTSIDE the route-effect so they
|
|
97
|
+
// close over refs (campaignIdRef, visitorIdRef) rather than the
|
|
98
|
+
// captured-once values from inside the effect. A consumer caching
|
|
99
|
+
// `track` keeps firing under the LATEST (campaignId, visitorId).
|
|
100
|
+
const track = useCallback(async (eventName, metadata) => {
|
|
101
|
+
try {
|
|
102
|
+
if (typeof window === 'undefined')
|
|
103
|
+
return;
|
|
104
|
+
if (!eventName || typeof eventName !== 'string')
|
|
105
|
+
return;
|
|
106
|
+
const name = eventName.trim().toLowerCase();
|
|
107
|
+
if (!name || name.length > 64 || !/^[a-z0-9_:-]+$/.test(name))
|
|
108
|
+
return;
|
|
109
|
+
const cid = campaignIdRef.current;
|
|
110
|
+
const vid = visitorIdRef.current;
|
|
111
|
+
if (!cid || !vid)
|
|
112
|
+
return;
|
|
113
|
+
const payload = {
|
|
114
|
+
type: 'custom',
|
|
115
|
+
campaign_id: cid,
|
|
116
|
+
visitor_id: vid,
|
|
117
|
+
event_name: name,
|
|
118
|
+
};
|
|
119
|
+
if (metadata && typeof metadata === 'object')
|
|
120
|
+
payload.metadata = metadata;
|
|
121
|
+
const data = JSON.stringify(payload);
|
|
122
|
+
const endpoint = `${apiBase}/api/v1/web/event`;
|
|
123
|
+
if (navigator.sendBeacon) {
|
|
124
|
+
navigator.sendBeacon(endpoint, new Blob([data], { type: 'application/json' }));
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
fetch(endpoint, {
|
|
128
|
+
method: 'POST',
|
|
129
|
+
headers: { 'Content-Type': 'application/json' },
|
|
130
|
+
body: data,
|
|
131
|
+
keepalive: true,
|
|
132
|
+
}).catch(() => { });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch { /* swallow */ }
|
|
136
|
+
}, [apiBase]);
|
|
137
|
+
// identify(userId). user_id only — the SDK does NOT accept, collect,
|
|
138
|
+
// or transmit email or any other PII. Cross-device stitching uses the
|
|
139
|
+
// merchant's own user_id; we never touch identifying personal data.
|
|
140
|
+
const identify = useCallback(async (userId) => {
|
|
141
|
+
try {
|
|
142
|
+
if (typeof window === 'undefined')
|
|
143
|
+
return;
|
|
144
|
+
if (!userId || typeof userId !== 'string')
|
|
145
|
+
return;
|
|
146
|
+
const uid = userId.trim();
|
|
147
|
+
// Cap at 128 chars (matches the server). Covers every common
|
|
148
|
+
// foreign user-id shape: uuid, ulid, mongo objectId, auth0 sub,
|
|
149
|
+
// firebase uid, supabase uuid.
|
|
150
|
+
if (!uid || uid.length > 128)
|
|
151
|
+
return;
|
|
152
|
+
const cid = campaignIdRef.current;
|
|
153
|
+
const vid = visitorIdRef.current;
|
|
154
|
+
if (!cid || !vid)
|
|
155
|
+
return;
|
|
156
|
+
const payload = {
|
|
157
|
+
campaign_id: cid,
|
|
158
|
+
visitor_id: vid,
|
|
159
|
+
user_id: uid,
|
|
160
|
+
};
|
|
161
|
+
const data = JSON.stringify(payload);
|
|
162
|
+
const url = `${apiBase}/api/v1/web/identify`;
|
|
163
|
+
if (navigator.sendBeacon) {
|
|
164
|
+
navigator.sendBeacon(url, new Blob([data], { type: 'application/json' }));
|
|
165
|
+
}
|
|
166
|
+
else {
|
|
167
|
+
fetch(url, {
|
|
168
|
+
method: 'POST',
|
|
169
|
+
headers: { 'Content-Type': 'application/json' },
|
|
170
|
+
body: data,
|
|
171
|
+
keepalive: true,
|
|
172
|
+
}).catch(() => { });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
catch { /* swallow */ }
|
|
176
|
+
}, [apiBase]);
|
|
81
177
|
const sentRef = useRef(false);
|
|
82
178
|
useEffect(() => {
|
|
83
179
|
if (typeof window === 'undefined')
|
|
84
180
|
return;
|
|
181
|
+
// Mount-count warning. Fires only when more than one Provider has
|
|
182
|
+
// ever mounted in the page lifetime. Dev-only; production swallows
|
|
183
|
+
// the warning to avoid noise in real merchant consoles.
|
|
184
|
+
providerMountCount += 1;
|
|
185
|
+
if (providerMountCount > 1 && process.env.NODE_ENV !== 'production') {
|
|
186
|
+
// eslint-disable-next-line no-console
|
|
187
|
+
console.warn('[Affiliateo] AffiliateoProvider mounted more than once. Mount it exactly once near the root of your app, or pageviews will double-fire.');
|
|
188
|
+
}
|
|
85
189
|
// Get or create visitor ID
|
|
86
190
|
let vid = lsGet(LS_VISITOR_ID);
|
|
87
191
|
if (!vid) {
|
|
@@ -89,6 +193,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
89
193
|
}
|
|
90
194
|
lsSet(LS_VISITOR_ID, vid);
|
|
91
195
|
lsSet(LS_CAMPAIGN_ID, campaignIdRef.current);
|
|
196
|
+
visitorIdRef.current = vid;
|
|
92
197
|
// Read ref from URL params or localStorage
|
|
93
198
|
const params = new URLSearchParams(window.location.search);
|
|
94
199
|
const ref = params.get('ref') || params.get('via') || params.get('source')
|
|
@@ -97,83 +202,6 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
97
202
|
lsSet(LS_REF, ref);
|
|
98
203
|
}
|
|
99
204
|
const endpoint = `${apiBase}/api/v1/web/event`;
|
|
100
|
-
// track() fires custom goals (signup_completed, trial_started, etc.).
|
|
101
|
-
// Best-effort: a failed network call is swallowed so analytics never
|
|
102
|
-
// throws in the host app. Goal-name validation matches the server's
|
|
103
|
-
// gate so merchants get instant feedback if they pass garbage.
|
|
104
|
-
const track = async (eventName, metadata) => {
|
|
105
|
-
try {
|
|
106
|
-
if (!eventName || typeof eventName !== 'string')
|
|
107
|
-
return;
|
|
108
|
-
const name = eventName.trim().toLowerCase();
|
|
109
|
-
if (!name || name.length > 64 || !/^[a-z0-9_:-]+$/.test(name))
|
|
110
|
-
return;
|
|
111
|
-
const payload = {
|
|
112
|
-
type: 'custom',
|
|
113
|
-
campaign_id: campaignIdRef.current,
|
|
114
|
-
visitor_id: vid,
|
|
115
|
-
event_name: name,
|
|
116
|
-
};
|
|
117
|
-
if (metadata && typeof metadata === 'object')
|
|
118
|
-
payload.metadata = metadata;
|
|
119
|
-
const data = JSON.stringify(payload);
|
|
120
|
-
// sendBeacon doesn't accept a response, but for custom goals we
|
|
121
|
-
// also don't care about it. fire-and-forget is exactly the
|
|
122
|
-
// semantic we want. Same fetch fallback shape as pageview/sendEnd.
|
|
123
|
-
if (navigator.sendBeacon) {
|
|
124
|
-
navigator.sendBeacon(endpoint, new Blob([data], { type: 'application/json' }));
|
|
125
|
-
}
|
|
126
|
-
else {
|
|
127
|
-
fetch(endpoint, {
|
|
128
|
-
method: 'POST',
|
|
129
|
-
headers: { 'Content-Type': 'application/json' },
|
|
130
|
-
body: data,
|
|
131
|
-
keepalive: true,
|
|
132
|
-
}).catch(() => { });
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
catch { /* swallow */ }
|
|
136
|
-
};
|
|
137
|
-
// identify() links the anonymous visitor_id to the merchant's
|
|
138
|
-
// user_id once they sign in. Required for cross-device / cross-
|
|
139
|
-
// subdomain funnels to stitch properly. POSTs to /api/v1/web/identify
|
|
140
|
-
// which updates visitors.user_id (+ optional user_email).
|
|
141
|
-
//
|
|
142
|
-
// Idempotent: safe to call on every page load. We only fire if a
|
|
143
|
-
// non-empty userId is provided; calls with a blank id are silently
|
|
144
|
-
// swallowed so merchants who guard the call with `if (user)` and
|
|
145
|
-
// pass a falsy id during hydration don't get errors.
|
|
146
|
-
const identify = async (userId, opts) => {
|
|
147
|
-
try {
|
|
148
|
-
if (!userId || typeof userId !== 'string')
|
|
149
|
-
return;
|
|
150
|
-
const uid = userId.trim();
|
|
151
|
-
if (!uid || uid.length > 256)
|
|
152
|
-
return;
|
|
153
|
-
const email = opts?.email ? String(opts.email).trim().slice(0, 320) : undefined;
|
|
154
|
-
const payload = {
|
|
155
|
-
campaign_id: campaignIdRef.current,
|
|
156
|
-
visitor_id: vid,
|
|
157
|
-
user_id: uid,
|
|
158
|
-
};
|
|
159
|
-
if (email)
|
|
160
|
-
payload.user_email = email;
|
|
161
|
-
const data = JSON.stringify(payload);
|
|
162
|
-
const url = `${apiBase}/api/v1/web/identify`;
|
|
163
|
-
if (navigator.sendBeacon) {
|
|
164
|
-
navigator.sendBeacon(url, new Blob([data], { type: 'application/json' }));
|
|
165
|
-
}
|
|
166
|
-
else {
|
|
167
|
-
fetch(url, {
|
|
168
|
-
method: 'POST',
|
|
169
|
-
headers: { 'Content-Type': 'application/json' },
|
|
170
|
-
body: data,
|
|
171
|
-
keepalive: true,
|
|
172
|
-
}).catch(() => { });
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
catch { /* swallow */ }
|
|
176
|
-
};
|
|
177
205
|
setState({ refCode: ref, visitorId: vid, track, identify });
|
|
178
206
|
function beacon(payload) {
|
|
179
207
|
const data = JSON.stringify(payload);
|
|
@@ -278,10 +306,19 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
278
306
|
for (const { name, value } of Array.from(el.attributes)) {
|
|
279
307
|
if (!name.startsWith('data-affiliateo-goal-'))
|
|
280
308
|
continue;
|
|
309
|
+
// Skip the bare "data-affiliateo-goal" attribute itself
|
|
310
|
+
// (slice would produce an empty key).
|
|
311
|
+
if (name === 'data-affiliateo-goal')
|
|
312
|
+
continue;
|
|
281
313
|
if (count >= 10)
|
|
282
314
|
break;
|
|
283
315
|
const key = name.slice('data-affiliateo-goal-'.length).replace(/-/g, '_');
|
|
284
|
-
|
|
316
|
+
// Skip the reserved `event` key so an attribute like
|
|
317
|
+
// `data-affiliateo-goal-event="x"` can't override the actual
|
|
318
|
+
// goal name when the SDK / server folds metadata.event in.
|
|
319
|
+
if (!key || key === 'event')
|
|
320
|
+
continue;
|
|
321
|
+
if (value) {
|
|
285
322
|
meta[key] = String(value).slice(0, 255);
|
|
286
323
|
count++;
|
|
287
324
|
}
|
|
@@ -294,8 +331,26 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
294
331
|
// the viewport. Use IntersectionObserver (cheap, off the scroll hot
|
|
295
332
|
// path) and unobserve after the first fire so the same element can't
|
|
296
333
|
// double-count if the user scrolls back up.
|
|
334
|
+
//
|
|
335
|
+
// Threshold: 0.1 (was 0.5). 50% visibility is unreachable for tall
|
|
336
|
+
// sections — a 1600px pricing section in an 800px viewport tops out
|
|
337
|
+
// at ~50% intersection ratio, so the observer's threshold was either
|
|
338
|
+
// a bare miss (section maxes at 49%) or a brittle hit. Funnel steps
|
|
339
|
+
// tied to those sections silently dropped from every merchant's
|
|
340
|
+
// conversion count without anyone noticing the section was even
|
|
341
|
+
// being rendered. 0.1 fires when 10% of the section is visible,
|
|
342
|
+
// which on a typical laptop is "the user has scrolled enough that
|
|
343
|
+
// the section is meaningfully on screen" without firing on sections
|
|
344
|
+
// that barely peek up from the bottom edge.
|
|
345
|
+
//
|
|
346
|
+
// rootMargin: trim the bottom 10% of the viewport from the trigger
|
|
347
|
+
// zone so a section creeping up from the very bottom of the screen
|
|
348
|
+
// doesn't fire instantly. The user has to scroll enough that the
|
|
349
|
+
// section is starting to settle into the main visible area, which
|
|
350
|
+
// matches a human's intuitive sense of "I've reached that section."
|
|
297
351
|
const scrollFired = new WeakSet();
|
|
298
352
|
let scrollObserver = null;
|
|
353
|
+
let scrollMo = null;
|
|
299
354
|
if (typeof IntersectionObserver !== 'undefined') {
|
|
300
355
|
scrollObserver = new IntersectionObserver((entries) => {
|
|
301
356
|
for (const entry of entries) {
|
|
@@ -309,7 +364,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
309
364
|
track(goal);
|
|
310
365
|
scrollObserver?.unobserve(entry.target);
|
|
311
366
|
}
|
|
312
|
-
}, { threshold: 0.
|
|
367
|
+
}, { threshold: 0.1, rootMargin: '0px 0px -10% 0px' });
|
|
313
368
|
// Observe everything that exists at mount time.
|
|
314
369
|
document.querySelectorAll('[data-affiliateo-scroll]').forEach((el) => {
|
|
315
370
|
scrollObserver?.observe(el);
|
|
@@ -317,7 +372,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
317
372
|
// SPA-rendered sections that appear later get observed via
|
|
318
373
|
// MutationObserver. Cheap because we only scan added nodes, not
|
|
319
374
|
// the whole tree on each mutation.
|
|
320
|
-
|
|
375
|
+
scrollMo = new MutationObserver((mutations) => {
|
|
321
376
|
for (const m of mutations) {
|
|
322
377
|
m.addedNodes.forEach((node) => {
|
|
323
378
|
if (!(node instanceof Element))
|
|
@@ -328,19 +383,19 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
|
|
|
328
383
|
});
|
|
329
384
|
}
|
|
330
385
|
});
|
|
331
|
-
|
|
332
|
-
scrollObserver._mo = mo;
|
|
386
|
+
scrollMo.observe(document.body, { childList: true, subtree: true });
|
|
333
387
|
}
|
|
334
388
|
return () => {
|
|
335
389
|
document.removeEventListener('visibilitychange', onVisibilityChange);
|
|
336
390
|
window.removeEventListener('pagehide', sendEnd);
|
|
337
391
|
window.removeEventListener('popstate', onUrlChange);
|
|
338
392
|
document.removeEventListener('click', onDocClick);
|
|
339
|
-
|
|
393
|
+
scrollMo?.disconnect();
|
|
340
394
|
scrollObserver?.disconnect();
|
|
341
395
|
history.pushState = origPushState;
|
|
342
396
|
history.replaceState = origReplaceState;
|
|
397
|
+
providerMountCount = Math.max(0, providerMountCount - 1);
|
|
343
398
|
};
|
|
344
|
-
}, [
|
|
399
|
+
}, [apiBase, track, identify]);
|
|
345
400
|
return (_jsx(AffiliateoContext.Provider, { value: state, children: children }));
|
|
346
401
|
}
|
package/dist/types.d.ts
CHANGED
|
@@ -27,13 +27,11 @@ export interface AffiliateoContextValue {
|
|
|
27
27
|
* both events resolve to the same user_id and the funnel reports a
|
|
28
28
|
* single converted person.
|
|
29
29
|
*
|
|
30
|
-
* Idempotent: safe to call on every page load.
|
|
31
|
-
*
|
|
30
|
+
* Idempotent: safe to call on every page load. user_id only —
|
|
31
|
+
* the SDK does not accept or transmit email or any other PII.
|
|
32
32
|
*
|
|
33
33
|
* Example:
|
|
34
34
|
* const { identify } = useAffiliateRef()
|
|
35
|
-
* useEffect(() => { if (user) identify(user.id
|
|
36
|
-
identify: (userId: string
|
|
37
|
-
email?: string;
|
|
38
|
-
}) => Promise<void>;
|
|
35
|
+
* useEffect(() => { if (user) identify(user.id) }, [user]) */
|
|
36
|
+
identify: (userId: string) => Promise<void>;
|
|
39
37
|
}
|