@affiliateo/web 2.1.0 → 2.2.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 CHANGED
@@ -194,13 +194,15 @@ export default function AuthBoundary({ children }) {
194
194
  const { identify } = useAffiliateRef()
195
195
  const user = useUser()
196
196
  useEffect(() => {
197
- if (user) identify(user.id, { email: user.email })
197
+ if (user) identify(user.id)
198
198
  }, [user])
199
199
  return <>{children}</>
200
200
  }
201
201
  ```
202
202
 
203
203
  Idempotent. safe to fire on every page load while a user is signed in.
204
+ **user_id only.** The SDK does not accept, collect, or transmit email
205
+ or any other PII.
204
206
 
205
207
  ## API
206
208
 
@@ -217,8 +219,7 @@ Props:
217
219
  React hook that returns `{ refCode, visitorId, track, identify }`.
218
220
 
219
221
  - `track(eventName, metadata?)`. fire a custom goal
220
- - `identify(userId, opts?)`. link this visitor to a merchant user_id;
221
- `opts.email` optional
222
+ - `identify(userId)`. link this visitor to a merchant user_id (no PII)
222
223
 
223
224
  ### `getAttribution()`
224
225
 
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
- if (key && value) {
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
  }
@@ -296,6 +333,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
296
333
  // double-count if the user scrolls back up.
297
334
  const scrollFired = new WeakSet();
298
335
  let scrollObserver = null;
336
+ let scrollMo = null;
299
337
  if (typeof IntersectionObserver !== 'undefined') {
300
338
  scrollObserver = new IntersectionObserver((entries) => {
301
339
  for (const entry of entries) {
@@ -317,7 +355,7 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
317
355
  // SPA-rendered sections that appear later get observed via
318
356
  // MutationObserver. Cheap because we only scan added nodes, not
319
357
  // the whole tree on each mutation.
320
- const mo = new MutationObserver((mutations) => {
358
+ scrollMo = new MutationObserver((mutations) => {
321
359
  for (const m of mutations) {
322
360
  m.addedNodes.forEach((node) => {
323
361
  if (!(node instanceof Element))
@@ -328,19 +366,19 @@ export function AffiliateoProvider({ campaignId, apiUrl, children, }) {
328
366
  });
329
367
  }
330
368
  });
331
- mo.observe(document.body, { childList: true, subtree: true });
332
- scrollObserver._mo = mo;
369
+ scrollMo.observe(document.body, { childList: true, subtree: true });
333
370
  }
334
371
  return () => {
335
372
  document.removeEventListener('visibilitychange', onVisibilityChange);
336
373
  window.removeEventListener('pagehide', sendEnd);
337
374
  window.removeEventListener('popstate', onUrlChange);
338
375
  document.removeEventListener('click', onDocClick);
339
- scrollObserver?._mo?.disconnect();
376
+ scrollMo?.disconnect();
340
377
  scrollObserver?.disconnect();
341
378
  history.pushState = origPushState;
342
379
  history.replaceState = origReplaceState;
380
+ providerMountCount = Math.max(0, providerMountCount - 1);
343
381
  };
344
- }, [campaignId, apiBase]);
382
+ }, [apiBase, track, identify]);
345
383
  return (_jsx(AffiliateoContext.Provider, { value: state, children: children }));
346
384
  }
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. Optional email is
31
- * bounded to 320 chars (RFC 5321 max).
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, { email: user.email }) }, [user]) */
36
- identify: (userId: string, opts?: {
37
- email?: string;
38
- }) => Promise<void>;
35
+ * useEffect(() => { if (user) identify(user.id) }, [user]) */
36
+ identify: (userId: string) => Promise<void>;
39
37
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@affiliateo/web",
3
- "version": "2.1.0",
3
+ "version": "2.2.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",