@probat/react 0.4.5 → 0.4.7

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,15 +161,136 @@ 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()
132
173
  };
133
174
  }
134
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
+ }
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);
292
+ }
293
+
135
294
  // src/utils/api.ts
136
295
  var pendingDecisions = /* @__PURE__ */ new Map();
137
296
  async function fetchDecision(host, experimentId, distinctId, apiKey) {
@@ -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,36 @@ function writeAssignment(id, variantKey) {
231
441
  } catch {
232
442
  }
233
443
  }
444
+ function readForceParam(id) {
445
+ if (typeof window === "undefined") return null;
446
+ try {
447
+ const raw = new URLSearchParams(window.location.search).get("__probat_force");
448
+ if (!raw) return null;
449
+ const [forceId, forceKey] = raw.split(":");
450
+ return forceId === id ? forceKey ?? null : null;
451
+ } catch {
452
+ return null;
453
+ }
454
+ }
234
455
  function useExperiment(id, options = {}) {
235
456
  const { fallback = "control", debug = false } = options;
236
457
  const { host, bootstrap, customerId, apiKey } = useProbatContext();
237
458
  const [variantKey, setVariantKey] = React3.useState(() => {
459
+ const forced = readForceParam(id);
460
+ if (forced) return forced;
238
461
  if (bootstrap[id]) return bootstrap[id];
239
462
  return "control";
240
463
  });
241
464
  const [resolved, setResolved] = React3.useState(() => {
242
- return !!bootstrap[id];
465
+ return !!(readForceParam(id) || bootstrap[id]);
243
466
  });
244
467
  React3.useEffect(() => {
468
+ const forced = readForceParam(id);
469
+ if (forced) {
470
+ setVariantKey(forced);
471
+ setResolved(true);
472
+ return;
473
+ }
245
474
  if (bootstrap[id] || readAssignment(id)) {
246
475
  const key = bootstrap[id] ?? readAssignment(id) ?? "control";
247
476
  setVariantKey(key);
@@ -556,7 +785,32 @@ function useProbatMetrics() {
556
785
  },
557
786
  [host, customerId, apiKey]
558
787
  );
559
- return { capture };
788
+ const captureGoal = React3.useCallback(
789
+ (funnelId, funnelStep, properties = {}) => {
790
+ sendMetric(host, "$goal_reached", {
791
+ ...customerId ? { distinct_id: customerId } : {},
792
+ $funnel_id: funnelId,
793
+ $funnel_step: funnelStep,
794
+ ...properties
795
+ });
796
+ },
797
+ [host, customerId]
798
+ );
799
+ const captureFeatureInteraction = React3.useCallback(
800
+ (interactionName, properties = {}) => {
801
+ sendMetric(host, "$feature_interaction", {
802
+ ...customerId ? { distinct_id: customerId } : {},
803
+ interaction_name: interactionName,
804
+ ...properties
805
+ });
806
+ },
807
+ [host, customerId]
808
+ );
809
+ return {
810
+ capture,
811
+ captureGoal,
812
+ captureFeatureInteraction
813
+ };
560
814
  }
561
815
  function createExperimentContext(experimentId) {
562
816
  const Ctx = React3.createContext(null);
@@ -579,10 +833,13 @@ function createExperimentContext(experimentId) {
579
833
  }
580
834
 
581
835
  exports.Experiment = Experiment;
836
+ exports.PROBAT_ENV_DEV = PROBAT_ENV_DEV;
837
+ exports.PROBAT_ENV_PROD = PROBAT_ENV_PROD;
582
838
  exports.ProbatProviderClient = ProbatProviderClient;
583
839
  exports.Track = Track;
584
840
  exports.createExperimentContext = createExperimentContext;
585
841
  exports.fetchDecision = fetchDecision;
842
+ exports.flushMetrics = flushMetrics;
586
843
  exports.sendMetric = sendMetric;
587
844
  exports.useExperiment = useExperiment;
588
845
  exports.useProbatMetrics = useProbatMetrics;