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