@affiliateo/web 4.7.0 → 4.9.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/queue.js CHANGED
@@ -88,7 +88,7 @@ export class EventQueue {
88
88
  this.maxQueueSize = Math.max(10, Math.min(1000, opts.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE));
89
89
  // Hydrate from localStorage so events from a prior page load survive
90
90
  // a refresh / navigation. Guards against corrupt JSON or wrong-shape
91
- // entries by resetting to empty if anything looks off — better to
91
+ // entries by resetting to empty if anything looks off - better to
92
92
  // lose a few stuck events than crash the host app on init.
93
93
  try {
94
94
  const raw = lsGet(STORAGE_KEY);
@@ -125,8 +125,8 @@ export class EventQueue {
125
125
  id,
126
126
  endpoint,
127
127
  // Delivery metadata is baked into the PERSISTED payload rather than
128
- // added at flush time, so every retry — and every recovery after a
129
- // page reload — sends a byte-identical body:
128
+ // added at flush time, so every retry - and every recovery after a
129
+ // page reload - sends a byte-identical body:
130
130
  //
131
131
  // event_id makes the server insert idempotent. Without it, a 5xx
132
132
  // raised after the row was already written, or a network timeout
@@ -137,7 +137,7 @@ export class EventQueue {
137
137
  //
138
138
  // client_ts is when the event HAPPENED. The server used to stamp
139
139
  // arrival time, but the 5s flush cadence, the size-threshold
140
- // burst flush, and retries all reorder delivery freely — so
140
+ // burst flush, and retries all reorder delivery freely - so
141
141
  // arrival order is not user order. Stamping at enqueue is the
142
142
  // only point that still knows the real sequence.
143
143
  payload: { ...payload, event_id: id, client_ts: Date.now() },
@@ -153,7 +153,7 @@ export class EventQueue {
153
153
  this.persist();
154
154
  // Burst protection: when many events fire in quick succession
155
155
  // (auto-pageview + immediate track + identify on app boot), don't
156
- // wait for the 5s timer — flush right away. The isFlushing guard
156
+ // wait for the 5s timer - flush right away. The isFlushing guard
157
157
  // inside flush() makes overlapping calls safe.
158
158
  if (this.queue.length >= SIZE_FLUSH_THRESHOLD) {
159
159
  void this.flush();
@@ -170,13 +170,21 @@ export class EventQueue {
170
170
  if (this.isFlushing || this.queue.length === 0)
171
171
  return;
172
172
  this.isFlushing = true;
173
- // Snapshot the queue so we iterate a stable list — new enqueue()
173
+ // Snapshot the queue so we iterate a stable list - new enqueue()
174
174
  // calls during the flush land in the live queue and get picked up
175
175
  // by the next flush rather than this one (avoids infinite loops
176
176
  // and reasoning headaches).
177
177
  const batch = this.queue.slice();
178
178
  for (const event of batch) {
179
+ // Dropped or cleared since the batch was taken (optOut(), or a
180
+ // sign-in replacing a sign-out still waiting here): not sent.
181
+ if (!this.queue.some((e) => e.id === event.id))
182
+ continue;
179
183
  let ok = false;
184
+ // A 400 says the request itself is wrong (a placeholder user id, a
185
+ // malformed body), so sending it again cannot succeed: dropped
186
+ // rather than retried (4.8.0).
187
+ let rejected = false;
180
188
  try {
181
189
  const res = await fetch(event.endpoint, {
182
190
  method: 'POST',
@@ -186,16 +194,16 @@ export class EventQueue {
186
194
  // with the queue so even if this fetch errors, we'll retry.
187
195
  keepalive: true,
188
196
  });
189
- // 2xx = success. Anything else (4xx, 5xx) counts as a failure
190
- // and bumps retries. A 4xx is usually a permanent error
191
- // (bad payload, dead campaign) so retrying 3 times then
192
- // dropping is the right move — keeps the queue clean.
197
+ // 2xx = success. A 400 is dropped at once (above). Anything else
198
+ // (other 4xx, 5xx) counts as a failure and bumps retries; after
199
+ // 3 it is dropped, which keeps the queue clean.
193
200
  ok = res.ok;
201
+ rejected = res.status === 400;
194
202
  }
195
203
  catch {
196
204
  ok = false;
197
205
  }
198
- if (ok) {
206
+ if (ok || rejected) {
199
207
  // Remove from live queue (not the snapshot) by id
200
208
  this.queue = this.queue.filter((e) => e.id !== event.id);
201
209
  }
@@ -216,19 +224,19 @@ export class EventQueue {
216
224
  this.isFlushing = false;
217
225
  }
218
226
  /** Best-effort sync flush triggered on page unload. Uses sendBeacon
219
- * for each event so it survives the tab closing. No retry — this is
227
+ * for each event so it survives the tab closing. No retry - this is
220
228
  * the last chance.
221
229
  *
222
230
  * Called from the provider's pagehide / visibilitychange=hidden
223
231
  * handlers so any events still queued at unload get one last shot.
224
232
  * After this call, the in-memory queue is cleared (events that
225
- * sendBeacon couldn't queue are lost — that's the cost of the
233
+ * sendBeacon couldn't queue are lost - that's the cost of the
226
234
  * guaranteed-fast unload path). */
227
235
  flushOnUnload() {
228
236
  if (this.queue.length === 0)
229
237
  return;
230
238
  if (typeof navigator === 'undefined' || !navigator.sendBeacon) {
231
- // No sendBeacon (very old browser) — try a sync fetch with
239
+ // No sendBeacon (very old browser) - try a sync fetch with
232
240
  // keepalive as a fallback. Won't actually finish, but it's our
233
241
  // last shot.
234
242
  for (const event of this.queue) {
@@ -255,7 +263,16 @@ export class EventQueue {
255
263
  this.queue = [];
256
264
  this.persist();
257
265
  }
258
- /** Wipe the queue. Called by reset() and optOut(). Removes the
266
+ /** Remove the queued events `predicate` picks (4.8.0). A sign-in uses it
267
+ * to drop a sign-out still waiting to be delivered: the sign-in replaces
268
+ * it, and a sign-out retried after it would undo it. */
269
+ drop(predicate) {
270
+ const before = this.queue.length;
271
+ this.queue = this.queue.filter((e) => !predicate(e));
272
+ if (this.queue.length !== before)
273
+ this.persist();
274
+ }
275
+ /** Wipe the queue. Called by optOut(). Removes the
259
276
  * localStorage entry entirely so a reload doesn't re-hydrate the
260
277
  * wiped events. */
261
278
  clear() {
@@ -2,13 +2,13 @@
2
2
  * Cloudflare Turnstile token minting. Mirrors the hosted t.js tracker:
3
3
  * mint ONE invisible token at SDK init and attach it to the first pageview.
4
4
  * The server verifies the token before creating the visitor row (and before
5
- * counting a pay-per-click web_traffic click — the billable path REQUIRES a
5
+ * counting a pay-per-click web_traffic click - the billable path REQUIRES a
6
6
  * genuine pass when TURNSTILE_SECRET is configured). Subsequent events from
7
7
  * the same visitor_id are trusted because the row was created behind a
8
8
  * verified token, so the cost is one mint + one verify per session.
9
9
  *
10
10
  * Tokens are single-use at Cloudflare's siteverify, so consumeToken()
11
- * hands the token out exactly once — re-sending a used token would get the
11
+ * hands the token out exactly once - re-sending a used token would get the
12
12
  * event rejected server-side, not warn-allowed.
13
13
  *
14
14
  * Failure modes: if the Turnstile script is blocked (ad blockers, strict
@@ -28,7 +28,7 @@ declare global {
28
28
  }
29
29
  /**
30
30
  * Load the Turnstile script (once) and run the invisible challenge. Resolves
31
- * when a token is available OR the mint failed/timed out — never rejects, so
31
+ * when a token is available OR the mint failed/timed out - never rejects, so
32
32
  * callers can unconditionally `.then(sendPageview)`.
33
33
  */
34
34
  export declare function mintTurnstileToken(): Promise<void>;
package/dist/turnstile.js CHANGED
@@ -2,13 +2,13 @@
2
2
  * Cloudflare Turnstile token minting. Mirrors the hosted t.js tracker:
3
3
  * mint ONE invisible token at SDK init and attach it to the first pageview.
4
4
  * The server verifies the token before creating the visitor row (and before
5
- * counting a pay-per-click web_traffic click — the billable path REQUIRES a
5
+ * counting a pay-per-click web_traffic click - the billable path REQUIRES a
6
6
  * genuine pass when TURNSTILE_SECRET is configured). Subsequent events from
7
7
  * the same visitor_id are trusted because the row was created behind a
8
8
  * verified token, so the cost is one mint + one verify per session.
9
9
  *
10
10
  * Tokens are single-use at Cloudflare's siteverify, so consumeToken()
11
- * hands the token out exactly once — re-sending a used token would get the
11
+ * hands the token out exactly once - re-sending a used token would get the
12
12
  * event rejected server-side, not warn-allowed.
13
13
  *
14
14
  * Failure modes: if the Turnstile script is blocked (ad blockers, strict
@@ -17,20 +17,20 @@
17
17
  * those through; commission-on-sale (web_affiliate) tracking never depends
18
18
  * on the token at all.
19
19
  */
20
- // Public sitekey (safe to expose) — the same one public/t.js ships. The
20
+ // Public sitekey (safe to expose) - the same one public/t.js ships. The
21
21
  // matching secret lives in TURNSTILE_SECRET on the server.
22
22
  const TURNSTILE_SITEKEY = '0x4AAAAAADR5UW2x6axFd13p';
23
23
  const TURNSTILE_SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js?onload=__affiliateoTSLoaded&render=explicit';
24
24
  // Hard deadline for the whole mint (script load + challenge). t.js relies on
25
25
  // Turnstile's own timeout-callback, but a script tag that never loads never
26
- // fires ANY callback — this timer makes sure the first pageview can't be
26
+ // fires ANY callback - this timer makes sure the first pageview can't be
27
27
  // held hostage by a blocked challenges.cloudflare.com.
28
28
  const MINT_TIMEOUT_MS = 3000;
29
29
  let token = null;
30
30
  let minting = null;
31
31
  /**
32
32
  * Load the Turnstile script (once) and run the invisible challenge. Resolves
33
- * when a token is available OR the mint failed/timed out — never rejects, so
33
+ * when a token is available OR the mint failed/timed out - never rejects, so
34
34
  * callers can unconditionally `.then(sendPageview)`.
35
35
  */
36
36
  export function mintTurnstileToken() {
@@ -60,7 +60,7 @@ export function mintTurnstileToken() {
60
60
  document.body.appendChild(holder);
61
61
  window.turnstile.render(holder, {
62
62
  sitekey: TURNSTILE_SITEKEY,
63
- // Invisible widget — never shows UI. Real browsers pass the
63
+ // Invisible widget - never shows UI. Real browsers pass the
64
64
  // challenge in 50-200ms with zero user interaction.
65
65
  size: 'invisible',
66
66
  callback: (tok) => {
package/dist/types.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  export interface AffiliateoConfig {
2
- /** Your Affiliateo app ID — shown in the dashboard when you create a
2
+ /** Your Affiliateo app ID - shown in the dashboard when you create a
3
3
  * Web App Affiliate app. One of `appId` / `campaignId` is required;
4
4
  * `appId` wins when both are set. */
5
5
  appId?: string;
6
- /** @deprecated Affiliateo campaigns are now called apps — use `appId`.
6
+ /** @deprecated Affiliateo campaigns are now called apps - use `appId`.
7
7
  * Kept working so existing integrations don't break. */
8
8
  campaignId?: string;
9
9
  /** Base URL for the API. Defaults to https://affiliateo.com */
@@ -55,7 +55,7 @@ export interface AffiliateoConfig {
55
55
  * true, the SDK logs every action it takes (init, pageview fired,
56
56
  * goal tracked, blocked by bot detection, blocked by localhost, queue
57
57
  * flush attempted, etc.). Only the merchant or anyone manually opening
58
- * DevTools sees these — normal visitors never look at the console.
58
+ * DevTools sees these - normal visitors never look at the console.
59
59
  *
60
60
  * Turn on temporarily when investigating "why aren't my events
61
61
  * showing up?". Leave off in production.
@@ -80,6 +80,17 @@ export interface AffiliateoConfig {
80
80
  /** Pageview auto-capture behavior. Pass `false` to disable entirely
81
81
  * (you'll call `trackPageview()` yourself). Pass an object to fine-tune.
82
82
  * Defaults to `true` (auto-capture everything). */
83
+ /** Live Chat (4.9.0). Once the business switches Live Chat on for this
84
+ * app in its Affiliateo dashboard, a chat bubble shows on the site.
85
+ * `true` (the default) shows it; `'hidden'` loads the chat without a
86
+ * bubble, for a site that calls `openChat()` from its own button; `false`
87
+ * never loads it. */
88
+ chat?: boolean | 'hidden';
89
+ /** Your own look for the chat bubble (4.15.0), over the Live Chat settings
90
+ * in your dashboard: anything left out keeps the dashboard's choice.
91
+ * sendColor colors the send button and the person's own messages;
92
+ * placeholder is the message box text, in your own words and language. */
93
+ chatLook?: ChatLook;
83
94
  autoCapturePageviews?: boolean | {
84
95
  /** Fire the initial pageview on mount. Defaults to true. Set false
85
96
  * for cookie-consent flows where you want to defer until the user
@@ -121,7 +132,14 @@ export interface AffiliateoContextValue {
121
132
  * both events resolve to the same user_id and the funnel reports a
122
133
  * single converted person.
123
134
  *
124
- * Idempotent: safe to call on every page load. user_id only —
135
+ * Since 4.8.0 a different user signing in on this browser switches it
136
+ * to them (it used to be refused). The SDK stores the id and re-sends it
137
+ * once per visit; calling it again in the same visit sends nothing. Does
138
+ * nothing while opted out, and skips ids that mean "nobody" ("null",
139
+ * "undefined", "guest", "0", RevenueCat's $RCAnonymousID:...) that the
140
+ * server would refuse.
141
+ *
142
+ * Idempotent: safe to call on every page load. user_id only -
125
143
  * the SDK does not accept or transmit email or any other PII.
126
144
  *
127
145
  * Example:
@@ -142,22 +160,25 @@ export interface AffiliateoContextValue {
142
160
  * useEffect(() => { if (open) trackPageview('/pricing-modal') }, [open])
143
161
  * } */
144
162
  trackPageview: (path?: string) => Promise<void>;
145
- /** Wipe the visitor identity. Mints a fresh visitor_id and clears the
146
- * affiliate ref + campaign_id from localStorage so the next pageview
147
- * is treated as a brand-new anonymous visitor.
148
- *
149
- * Use case: call on user logout so a different person sitting down at
150
- * the same browser doesn't get their actions merged into the previous
151
- * user's funnel. Without this, shared family computers / kiosks /
152
- * hot-desks silently pollute attribution.
163
+ /** The user signed out (4.8.0). Call it on sign-out: this browser starts
164
+ * over as a new anonymous customer of the app, so whoever signs in next
165
+ * (or nobody) is not counted as the person who just left, and everything
166
+ * already recorded stays with them. Forgets the stored user id, and
167
+ * their Live Chat: the next person to open it starts a new thread. The
168
+ * visitor id, the affiliate ref and tracking carry on unchanged.
153
169
  *
154
170
  * Example:
155
- * const { reset } = useAffiliateRef()
171
+ * const { logout } = useAffiliateRef()
156
172
  * async function handleLogout() {
157
173
  * await supabase.auth.signOut()
158
- * await reset()
174
+ * await logout()
159
175
  * router.push('/')
160
176
  * } */
177
+ logout: () => Promise<void>;
178
+ /** The same as logout() since 4.8.0. It used to mint a fresh visitor id
179
+ * and clear the affiliate ref, which split one person into two visitors
180
+ * and lost their affiliate. Kept as an alias so existing integrations
181
+ * get the sign-out they meant. */
161
182
  reset: () => Promise<void>;
162
183
  /** Stop tracking this visitor. Sets a persistent opt-out flag in
163
184
  * localStorage and silences all subsequent track / identify / pageview
@@ -196,7 +217,20 @@ export interface AffiliateoContextValue {
196
217
  * }
197
218
  *
198
219
  * Returns a Promise that resolves when the flush attempt completes
199
- * (success OR failure — failed events stay in the queue for the next
220
+ * (success OR failure - failed events stay in the queue for the next
200
221
  * flush). Safe to await without timeout concerns. */
201
222
  flush: () => Promise<void>;
202
223
  }
224
+ /** The chat bubble's look, set in code (4.15.0). Colors are hex ("#7C3AED"). */
225
+ export interface ChatLook {
226
+ color?: string;
227
+ iconColor?: string;
228
+ /** The chat's send button and the person's own messages (iMessage blue unless the dashboard says otherwise). */
229
+ sendColor?: string;
230
+ /** The message box text, in your own words and language (up to 60 characters): pass your app's own translation. */
231
+ placeholder?: string;
232
+ style?: 'solid' | 'glass';
233
+ corner?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
234
+ /** In pixels, 40 to 80. */
235
+ size?: number;
236
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Which user ids may name a signed-in person (4.8.0).
3
+ *
4
+ * Every device that signs in with the same user id becomes ONE customer of
5
+ * the app on Affiliateo's side, so an id that really means "nobody" ("null",
6
+ * "undefined", "guest", "0") would merge every stranger whose site sent it
7
+ * into one person. The server refuses these with a 400; this is the same
8
+ * list, checked here so a wrong call is skipped (and says so with
9
+ * debug={true}) instead of costing a request.
10
+ *
11
+ * KEEP IN SYNC with src/lib/customers/user-id.ts in the Affiliateo web repo,
12
+ * and with public/t.js, which the vanilla snippet loads.
13
+ */
14
+ export declare const MAX_USER_ID_LENGTH = 128;
15
+ /** The trimmed id when it can name a person, otherwise null. A number (a
16
+ * database row id passed straight through) is read as its digits, the way
17
+ * the server reads it. */
18
+ export declare function usableUserId(value: unknown): string | null;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Which user ids may name a signed-in person (4.8.0).
3
+ *
4
+ * Every device that signs in with the same user id becomes ONE customer of
5
+ * the app on Affiliateo's side, so an id that really means "nobody" ("null",
6
+ * "undefined", "guest", "0") would merge every stranger whose site sent it
7
+ * into one person. The server refuses these with a 400; this is the same
8
+ * list, checked here so a wrong call is skipped (and says so with
9
+ * debug={true}) instead of costing a request.
10
+ *
11
+ * KEEP IN SYNC with src/lib/customers/user-id.ts in the Affiliateo web repo,
12
+ * and with public/t.js, which the vanilla snippet loads.
13
+ */
14
+ export const MAX_USER_ID_LENGTH = 128;
15
+ const NOT_A_USER = new Set([
16
+ 'null', '(null)', '<null>', 'nil', 'none', 'no', 'undefined', 'unidentified',
17
+ 'unknown', 'anonymous', 'anon', 'guest', 'nan', 'false', 'true',
18
+ '0', '-1', '[]', '{}', '[object object]',
19
+ ]);
20
+ /** The trimmed id when it can name a person, otherwise null. A number (a
21
+ * database row id passed straight through) is read as its digits, the way
22
+ * the server reads it. */
23
+ export function usableUserId(value) {
24
+ let raw;
25
+ if (typeof value === 'string')
26
+ raw = value;
27
+ else if (typeof value === 'number' && Number.isFinite(value))
28
+ raw = String(value);
29
+ else
30
+ return null;
31
+ const id = raw.trim();
32
+ if (id.length < 1 || id.length > MAX_USER_ID_LENGTH)
33
+ return null;
34
+ if (NOT_A_USER.has(id.toLowerCase()))
35
+ return null;
36
+ // RevenueCat's own anonymous id names an install, not a user.
37
+ if (id.startsWith('$RCAnonymousID:'))
38
+ return null;
39
+ return id;
40
+ }
package/package.json CHANGED
@@ -1,44 +1,46 @@
1
- {
2
- "name": "@affiliateo/web",
3
- "version": "4.7.0",
4
- "description": "Affiliateo web SDK \u2014 affiliate attribution and tracking for web apps",
5
- "main": "dist/index.js",
6
- "module": "dist/index.js",
7
- "types": "dist/index.d.ts",
8
- "exports": {
9
- ".": {
10
- "types": "./dist/index.d.ts",
11
- "import": "./dist/index.js",
12
- "require": "./dist/index.js"
13
- }
14
- },
15
- "scripts": {
16
- "build": "tsc",
17
- "prepack": "npm run build"
18
- },
19
- "peerDependencies": {
20
- "react": ">=18.0.0"
21
- },
22
- "peerDependenciesMeta": {
23
- "react": {
24
- "optional": true
25
- }
26
- },
27
- "devDependencies": {
28
- "@types/react": "^19.0.0",
29
- "typescript": "^5.0.0"
30
- },
31
- "keywords": [
32
- "affiliateo",
33
- "affiliate",
34
- "attribution",
35
- "stripe",
36
- "web",
37
- "session",
38
- "tracking"
39
- ],
40
- "license": "MIT",
41
- "files": [
42
- "dist"
43
- ]
44
- }
1
+ {
2
+ "name": "@affiliateo/web",
3
+ "version": "4.9.0",
4
+ "description": "Affiliateo web SDK: affiliate attribution and tracking for web apps",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js",
12
+ "require": "./dist/index.js"
13
+ }
14
+ },
15
+ "scripts": {
16
+ "prebuild": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
17
+ "build": "tsc",
18
+ "typecheck": "tsc --noEmit",
19
+ "prepack": "npm run build"
20
+ },
21
+ "peerDependencies": {
22
+ "react": ">=18.0.0"
23
+ },
24
+ "peerDependenciesMeta": {
25
+ "react": {
26
+ "optional": true
27
+ }
28
+ },
29
+ "devDependencies": {
30
+ "@types/react": "^19.0.0",
31
+ "typescript": "^5.0.0"
32
+ },
33
+ "keywords": [
34
+ "affiliateo",
35
+ "affiliate",
36
+ "attribution",
37
+ "stripe",
38
+ "web",
39
+ "session",
40
+ "tracking"
41
+ ],
42
+ "license": "MIT",
43
+ "files": [
44
+ "dist"
45
+ ]
46
+ }