@probat/react 0.4.5 → 0.4.6

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/index.mjs CHANGED
@@ -2,58 +2,34 @@
2
2
  "use client";
3
3
  import React3, { createContext, useRef, useState, useEffect, useMemo, useCallback, useContext } from 'react';
4
4
 
5
- var ProbatContext = createContext(null);
6
- var DEFAULT_HOST = "https://api.probat.app";
7
- function ProbatProvider({
8
- customerId,
9
- host = DEFAULT_HOST,
10
- apiKey,
11
- bootstrap,
12
- children
13
- }) {
14
- const value = useMemo(
15
- () => ({
16
- host: host.replace(/\/$/, ""),
17
- apiKey,
18
- customerId,
19
- bootstrap: bootstrap ?? {}
20
- }),
21
- [customerId, host, apiKey, bootstrap]
22
- );
23
- return /* @__PURE__ */ React3.createElement(ProbatContext.Provider, { value }, children);
24
- }
25
- function useProbatContext() {
26
- const ctx = useContext(ProbatContext);
27
- if (!ctx) {
28
- throw new Error(
29
- "useProbatContext must be used within <ProbatProviderClient>. Wrap your app with <ProbatProviderClient>."
30
- );
31
- }
32
- return ctx;
33
- }
34
-
35
- // src/components/ProbatProviderClient.tsx
36
- function ProbatProviderClient(props) {
37
- return React3.createElement(ProbatProvider, props);
38
- }
5
+ // src/types/events.ts
6
+ var PROBAT_ENV_DEV = "dev";
7
+ var PROBAT_ENV_PROD = "prod";
39
8
 
40
9
  // src/utils/environment.ts
41
10
  function detectEnvironment() {
42
11
  if (typeof window === "undefined") {
43
- return "prod";
12
+ return PROBAT_ENV_PROD;
44
13
  }
45
14
  const hostname = window.location.hostname;
46
15
  if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "0.0.0.0" || hostname.startsWith("192.168.") || hostname.startsWith("10.") || hostname.startsWith("172.16.") || hostname.startsWith("172.17.") || hostname.startsWith("172.18.") || hostname.startsWith("172.19.") || hostname.startsWith("172.20.") || hostname.startsWith("172.21.") || hostname.startsWith("172.22.") || hostname.startsWith("172.23.") || hostname.startsWith("172.24.") || hostname.startsWith("172.25.") || hostname.startsWith("172.26.") || hostname.startsWith("172.27.") || hostname.startsWith("172.28.") || hostname.startsWith("172.29.") || hostname.startsWith("172.30.") || hostname.startsWith("172.31.")) {
47
- return "dev";
16
+ return PROBAT_ENV_DEV;
48
17
  }
49
- return "prod";
18
+ return PROBAT_ENV_PROD;
50
19
  }
51
20
 
52
21
  // src/utils/eventContext.ts
53
22
  var DISTINCT_ID_KEY = "probat:distinct_id";
54
23
  var SESSION_ID_KEY = "probat:session_id";
24
+ var SESSION_START_AT_KEY = "probat:session_start_at";
25
+ var SESSION_SEQUENCE_KEY = "probat:session_sequence";
26
+ var SESSION_LAST_ACTIVITY_AT_KEY = "probat:session_last_activity_at";
27
+ var SESSION_IDLE_TIMEOUT_MS = 30 * 60 * 1e3;
55
28
  var cachedDistinctId = null;
56
29
  var cachedSessionId = null;
30
+ var cachedSessionStartAt = null;
31
+ var cachedSessionSequence = 0;
32
+ var cachedLastActivityAt = 0;
57
33
  function generateId() {
58
34
  if (typeof crypto !== "undefined" && crypto.randomUUID) {
59
35
  return crypto.randomUUID();
@@ -66,6 +42,77 @@ function generateId() {
66
42
  }
67
43
  return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
68
44
  }
45
+ function nowMs() {
46
+ return Date.now();
47
+ }
48
+ function startNewSession(tsMs) {
49
+ if (typeof window === "undefined") return;
50
+ const sid = `sess_${generateId()}`;
51
+ const startedAt = new Date(tsMs).toISOString();
52
+ cachedSessionId = sid;
53
+ cachedSessionStartAt = startedAt;
54
+ cachedSessionSequence = 0;
55
+ cachedLastActivityAt = tsMs;
56
+ try {
57
+ sessionStorage.setItem(SESSION_ID_KEY, sid);
58
+ sessionStorage.setItem(SESSION_START_AT_KEY, startedAt);
59
+ sessionStorage.setItem(SESSION_SEQUENCE_KEY, "0");
60
+ sessionStorage.setItem(SESSION_LAST_ACTIVITY_AT_KEY, String(tsMs));
61
+ } catch {
62
+ }
63
+ }
64
+ function hydrateSessionFromStorage(tsMs) {
65
+ if (typeof window === "undefined") return;
66
+ if (cachedSessionId && cachedSessionStartAt) {
67
+ const expiredInMemory = !Number.isFinite(cachedLastActivityAt) || cachedLastActivityAt <= 0 || tsMs - cachedLastActivityAt > SESSION_IDLE_TIMEOUT_MS;
68
+ if (expiredInMemory) {
69
+ startNewSession(tsMs);
70
+ }
71
+ return;
72
+ }
73
+ try {
74
+ const sid = sessionStorage.getItem(SESSION_ID_KEY);
75
+ const startedAt = sessionStorage.getItem(SESSION_START_AT_KEY);
76
+ const sequenceRaw = sessionStorage.getItem(SESSION_SEQUENCE_KEY);
77
+ const lastActivityRaw = sessionStorage.getItem(SESSION_LAST_ACTIVITY_AT_KEY);
78
+ const sequence = Number(sequenceRaw || "0");
79
+ const lastActivity = Number(lastActivityRaw || "0");
80
+ if (!sid || !startedAt) {
81
+ startNewSession(tsMs);
82
+ return;
83
+ }
84
+ const expired = !Number.isFinite(lastActivity) || lastActivity <= 0 || tsMs - lastActivity > SESSION_IDLE_TIMEOUT_MS;
85
+ if (expired) {
86
+ startNewSession(tsMs);
87
+ return;
88
+ }
89
+ cachedSessionId = sid;
90
+ cachedSessionStartAt = startedAt;
91
+ cachedSessionSequence = Number.isFinite(sequence) && sequence >= 0 ? sequence : 0;
92
+ cachedLastActivityAt = lastActivity > 0 ? lastActivity : tsMs;
93
+ } catch {
94
+ startNewSession(tsMs);
95
+ }
96
+ }
97
+ function ensureSession(tsMs) {
98
+ if (typeof window === "undefined") return;
99
+ hydrateSessionFromStorage(tsMs);
100
+ if (!cachedSessionId || !cachedSessionStartAt) {
101
+ startNewSession(tsMs);
102
+ }
103
+ }
104
+ function bumpSequence(tsMs) {
105
+ if (typeof window === "undefined") return 0;
106
+ ensureSession(tsMs);
107
+ cachedSessionSequence += 1;
108
+ cachedLastActivityAt = tsMs;
109
+ try {
110
+ sessionStorage.setItem(SESSION_SEQUENCE_KEY, String(cachedSessionSequence));
111
+ sessionStorage.setItem(SESSION_LAST_ACTIVITY_AT_KEY, String(tsMs));
112
+ } catch {
113
+ }
114
+ return cachedSessionSequence;
115
+ }
69
116
  function getDistinctId() {
70
117
  if (cachedDistinctId) return cachedDistinctId;
71
118
  if (typeof window === "undefined") return "server";
@@ -86,23 +133,14 @@ function getDistinctId() {
86
133
  return id;
87
134
  }
88
135
  function getSessionId() {
89
- if (cachedSessionId) return cachedSessionId;
90
136
  if (typeof window === "undefined") return "server";
91
- try {
92
- const stored = sessionStorage.getItem(SESSION_ID_KEY);
93
- if (stored) {
94
- cachedSessionId = stored;
95
- return stored;
96
- }
97
- } catch {
98
- }
99
- const id = `sess_${generateId()}`;
100
- cachedSessionId = id;
101
- try {
102
- sessionStorage.setItem(SESSION_ID_KEY, id);
103
- } catch {
104
- }
105
- return id;
137
+ ensureSession(nowMs());
138
+ return cachedSessionId || "server";
139
+ }
140
+ function getSessionStartAt() {
141
+ if (typeof window === "undefined") return (/* @__PURE__ */ new Date(0)).toISOString();
142
+ ensureSession(nowMs());
143
+ return cachedSessionStartAt || (/* @__PURE__ */ new Date(0)).toISOString();
106
144
  }
107
145
  function getPageKey() {
108
146
  if (typeof window === "undefined") return "";
@@ -117,13 +155,134 @@ function getReferrer() {
117
155
  return document.referrer;
118
156
  }
119
157
  function buildEventContext() {
158
+ const ts = nowMs();
120
159
  return {
121
160
  distinct_id: getDistinctId(),
122
161
  session_id: getSessionId(),
123
162
  $page_url: getPageUrl(),
124
163
  $pathname: typeof window !== "undefined" ? window.location.pathname : "",
125
- $referrer: getReferrer()
164
+ $referrer: getReferrer(),
165
+ $session_sequence: bumpSequence(ts),
166
+ $session_start_at: getSessionStartAt()
167
+ };
168
+ }
169
+
170
+ // src/utils/eventQueue.ts
171
+ var EventQueue = class {
172
+ constructor(host, apiKey) {
173
+ this.queue = [];
174
+ this.flushTimer = null;
175
+ this.maxBatchSize = 20;
176
+ this.flushIntervalMs = 5e3;
177
+ const normalized = host.replace(/\/$/, "");
178
+ this.endpointBatch = `${normalized}/experiment/metrics/batch`;
179
+ this.endpointSingle = `${normalized}/experiment/metrics`;
180
+ this.apiKey = apiKey;
181
+ }
182
+ enqueue(event) {
183
+ this.queue.push(event);
184
+ if (this.queue.length >= this.maxBatchSize) {
185
+ this.flush();
186
+ return;
187
+ }
188
+ if (!this.flushTimer) {
189
+ this.flushTimer = setTimeout(() => {
190
+ this.flush();
191
+ }, this.flushIntervalMs);
192
+ }
193
+ }
194
+ flush(forceBeacon = false) {
195
+ if (this.flushTimer) {
196
+ clearTimeout(this.flushTimer);
197
+ this.flushTimer = null;
198
+ }
199
+ if (this.queue.length === 0) return;
200
+ const batch = this.queue.splice(0, this.maxBatchSize);
201
+ this.send(batch, forceBeacon);
202
+ if (this.queue.length > 0) {
203
+ this.flushTimer = setTimeout(() => {
204
+ this.flush();
205
+ }, this.flushIntervalMs);
206
+ }
207
+ }
208
+ send(batch, forceBeacon = false) {
209
+ const body = { events: batch };
210
+ const encodedBody = JSON.stringify(body);
211
+ const headers = { "Content-Type": "application/json" };
212
+ if (this.apiKey) headers.Authorization = `Bearer ${this.apiKey}`;
213
+ const canBeacon = typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function" && !this.apiKey && (forceBeacon || typeof document !== "undefined" && document.visibilityState === "hidden");
214
+ if (canBeacon) {
215
+ navigator.sendBeacon(this.endpointBatch, encodedBody);
216
+ return;
217
+ }
218
+ try {
219
+ if (batch.length === 1) {
220
+ fetch(this.endpointSingle, {
221
+ method: "POST",
222
+ headers,
223
+ credentials: "include",
224
+ body: JSON.stringify(batch[0]),
225
+ keepalive: true
226
+ }).catch(() => {
227
+ });
228
+ return;
229
+ }
230
+ fetch(this.endpointBatch, {
231
+ method: "POST",
232
+ headers,
233
+ credentials: "include",
234
+ body: encodedBody,
235
+ keepalive: true
236
+ }).catch(() => {
237
+ });
238
+ } catch {
239
+ }
240
+ }
241
+ };
242
+ var queuesByHost = /* @__PURE__ */ new Map();
243
+ var lifecycleListenersInstalled = false;
244
+ function installLifecycleListeners() {
245
+ if (lifecycleListenersInstalled || typeof window === "undefined") return;
246
+ lifecycleListenersInstalled = true;
247
+ const flushWithBeacon = () => {
248
+ for (const queue of queuesByHost.values()) {
249
+ queue.flush(true);
250
+ }
126
251
  };
252
+ const flushNormally = () => {
253
+ for (const queue of queuesByHost.values()) {
254
+ queue.flush(false);
255
+ }
256
+ };
257
+ window.addEventListener("pagehide", flushWithBeacon);
258
+ window.addEventListener("beforeunload", flushWithBeacon);
259
+ document.addEventListener("visibilitychange", () => {
260
+ if (document.visibilityState === "hidden") {
261
+ flushWithBeacon();
262
+ } else {
263
+ flushNormally();
264
+ }
265
+ });
266
+ }
267
+ function queueKey(host, apiKey) {
268
+ const normalized = host.replace(/\/$/, "");
269
+ return apiKey ? `${normalized}::${apiKey}` : `${normalized}::__no_key__`;
270
+ }
271
+ function getEventQueue(host, apiKey) {
272
+ const normalized = host.replace(/\/$/, "");
273
+ const key = queueKey(normalized, apiKey);
274
+ let queue = queuesByHost.get(key);
275
+ if (!queue) {
276
+ queue = new EventQueue(normalized, apiKey);
277
+ queuesByHost.set(key, queue);
278
+ }
279
+ installLifecycleListeners();
280
+ return queue;
281
+ }
282
+ function flushEventQueue(host, forceBeacon = false, apiKey) {
283
+ const queue = queuesByHost.get(queueKey(host, apiKey));
284
+ if (!queue) return;
285
+ queue.flush(forceBeacon);
127
286
  }
128
287
 
129
288
  // src/utils/api.ts
@@ -160,31 +319,33 @@ async function fetchDecision(host, experimentId, distinctId, apiKey) {
160
319
  }
161
320
  function sendMetric(host, event, properties, apiKey) {
162
321
  if (typeof window === "undefined") return;
322
+ const environment = detectEnvironment();
163
323
  const ctx = buildEventContext();
164
324
  const payload = {
165
325
  event,
166
- environment: detectEnvironment(),
326
+ environment,
167
327
  properties: {
168
328
  ...ctx,
329
+ environment,
169
330
  source: "react-sdk",
170
331
  captured_at: (/* @__PURE__ */ new Date()).toISOString(),
171
332
  ...properties
172
333
  }
173
334
  };
174
- try {
175
- const url = `${host.replace(/\/$/, "")}/experiment/metrics`;
176
- const headers = { "Content-Type": "application/json" };
177
- if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
178
- fetch(url, {
179
- method: "POST",
180
- headers,
181
- credentials: "include",
182
- body: JSON.stringify(payload)
183
- }).catch(() => {
184
- });
185
- } catch {
335
+ const queuePayload = {
336
+ event: payload.event,
337
+ environment: payload.environment,
338
+ properties: payload.properties
339
+ };
340
+ const queue = getEventQueue(host, apiKey);
341
+ queue.enqueue(queuePayload);
342
+ if (event === "$experiment_exposure" || event === "$experiment_click") {
343
+ queue.flush(false);
186
344
  }
187
345
  }
346
+ function flushMetrics(host, forceBeacon = false, apiKey) {
347
+ flushEventQueue(host, forceBeacon, apiKey);
348
+ }
188
349
  function extractClickMeta(target) {
189
350
  if (!target || !(target instanceof HTMLElement)) return null;
190
351
  const primary = target.closest('[data-probat-click="primary"]');
@@ -204,7 +365,56 @@ function buildMeta(el, isPrimary) {
204
365
  return meta;
205
366
  }
206
367
 
207
- // src/hooks/useExperiment.ts
368
+ // src/context/ProbatContext.tsx
369
+ var ProbatContext = createContext(null);
370
+ var DEFAULT_HOST = "https://api.probat.app";
371
+ function ProbatProvider({
372
+ customerId,
373
+ host = DEFAULT_HOST,
374
+ apiKey,
375
+ bootstrap,
376
+ trackSessionLifecycle = true,
377
+ children
378
+ }) {
379
+ const normalizedHost = host.replace(/\/$/, "");
380
+ useEffect(() => {
381
+ if (!trackSessionLifecycle) return;
382
+ if (typeof window === "undefined") return;
383
+ sendMetric(normalizedHost, "$session_start", {
384
+ ...customerId ? { distinct_id: customerId } : {}
385
+ }, apiKey);
386
+ return () => {
387
+ sendMetric(normalizedHost, "$session_end", {
388
+ ...customerId ? { distinct_id: customerId } : {}
389
+ }, apiKey);
390
+ flushMetrics(normalizedHost, true, apiKey);
391
+ };
392
+ }, [normalizedHost, customerId, trackSessionLifecycle, apiKey]);
393
+ const value = useMemo(
394
+ () => ({
395
+ host: normalizedHost,
396
+ apiKey,
397
+ customerId,
398
+ bootstrap: bootstrap ?? {}
399
+ }),
400
+ [customerId, normalizedHost, apiKey, bootstrap]
401
+ );
402
+ return /* @__PURE__ */ React3.createElement(ProbatContext.Provider, { value }, children);
403
+ }
404
+ function useProbatContext() {
405
+ const ctx = useContext(ProbatContext);
406
+ if (!ctx) {
407
+ throw new Error(
408
+ "useProbatContext must be used within <ProbatProviderClient>. Wrap your app with <ProbatProviderClient>."
409
+ );
410
+ }
411
+ return ctx;
412
+ }
413
+
414
+ // src/components/ProbatProviderClient.tsx
415
+ function ProbatProviderClient(props) {
416
+ return React3.createElement(ProbatProvider, props);
417
+ }
208
418
  var ASSIGNMENT_PREFIX = "probat:assignment:";
209
419
  function readAssignment(id) {
210
420
  if (typeof window === "undefined") return null;
@@ -225,17 +435,33 @@ function writeAssignment(id, variantKey) {
225
435
  } catch {
226
436
  }
227
437
  }
438
+ function readForceParam() {
439
+ if (typeof window === "undefined") return null;
440
+ try {
441
+ return new URLSearchParams(window.location.search).get("__probat_force");
442
+ } catch {
443
+ return null;
444
+ }
445
+ }
228
446
  function useExperiment(id, options = {}) {
229
447
  const { fallback = "control", debug = false } = options;
230
448
  const { host, bootstrap, customerId, apiKey } = useProbatContext();
231
449
  const [variantKey, setVariantKey] = useState(() => {
450
+ const forced = readForceParam();
451
+ if (forced) return forced;
232
452
  if (bootstrap[id]) return bootstrap[id];
233
453
  return "control";
234
454
  });
235
455
  const [resolved, setResolved] = useState(() => {
236
- return !!bootstrap[id];
456
+ return !!(readForceParam() || bootstrap[id]);
237
457
  });
238
458
  useEffect(() => {
459
+ const forced = readForceParam();
460
+ if (forced) {
461
+ setVariantKey(forced);
462
+ setResolved(true);
463
+ return;
464
+ }
239
465
  if (bootstrap[id] || readAssignment(id)) {
240
466
  const key = bootstrap[id] ?? readAssignment(id) ?? "control";
241
467
  setVariantKey(key);
@@ -550,7 +776,32 @@ function useProbatMetrics() {
550
776
  },
551
777
  [host, customerId, apiKey]
552
778
  );
553
- return { capture };
779
+ const captureGoal = useCallback(
780
+ (funnelId, funnelStep, properties = {}) => {
781
+ sendMetric(host, "$goal_reached", {
782
+ ...customerId ? { distinct_id: customerId } : {},
783
+ $funnel_id: funnelId,
784
+ $funnel_step: funnelStep,
785
+ ...properties
786
+ });
787
+ },
788
+ [host, customerId]
789
+ );
790
+ const captureFeatureInteraction = useCallback(
791
+ (interactionName, properties = {}) => {
792
+ sendMetric(host, "$feature_interaction", {
793
+ ...customerId ? { distinct_id: customerId } : {},
794
+ interaction_name: interactionName,
795
+ ...properties
796
+ });
797
+ },
798
+ [host, customerId]
799
+ );
800
+ return {
801
+ capture,
802
+ captureGoal,
803
+ captureFeatureInteraction
804
+ };
554
805
  }
555
806
  function createExperimentContext(experimentId) {
556
807
  const Ctx = createContext(null);
@@ -572,6 +823,6 @@ function createExperimentContext(experimentId) {
572
823
  return { ExperimentProvider, useVariantKey };
573
824
  }
574
825
 
575
- export { Experiment, ProbatProviderClient, Track, createExperimentContext, fetchDecision, sendMetric, useExperiment, useProbatMetrics, useTrack };
826
+ export { Experiment, PROBAT_ENV_DEV, PROBAT_ENV_PROD, ProbatProviderClient, Track, createExperimentContext, fetchDecision, flushMetrics, sendMetric, useExperiment, useProbatMetrics, useTrack };
576
827
  //# sourceMappingURL=index.mjs.map
577
828
  //# sourceMappingURL=index.mjs.map