@thravic/react-native 0.1.0 → 0.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
@@ -83,6 +83,25 @@ Thravic.track('purchase', { amount: 49, currency: 'USD' });
83
83
  Thravic.identify('user-42');
84
84
  ```
85
85
 
86
+ ## A/B tests
87
+
88
+ Create a test under Experiments in the dashboard (Pro plan), start it, then ask for the user's version where the screen changes:
89
+
90
+ ```tsx
91
+ import { Thravic, useVariant } from '@thravic/react-native';
92
+
93
+ function Paywall() {
94
+ const version = useVariant('new-paywall'); // undefined while loading
95
+ if (version === undefined) return null;
96
+ return version === 'b' ? <NewPaywall /> : <OldPaywall />;
97
+ }
98
+
99
+ // when the user reaches the test's goal
100
+ Thravic.track('signup');
101
+ ```
102
+
103
+ Outside a component, `await Thravic.variant('new-paywall')` gives the same answer. You get a version id from the dashboard, or `null` for the original: the user isn't in the test, the test isn't running, the user opted out, or the tests couldn't load within 3 seconds. A user always gets the same version, on the app and on your website. The first answer in each session records that the user saw the test, and the dashboard counts the goals they reach after it.
104
+
86
105
  ## Privacy
87
106
 
88
107
  ```ts
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Environment, NavigationRefLike, ThravicOptions } from './types';
1
+ import type { Environment, Experiment, NavigationRefLike, ThravicOptions } from './types';
2
2
  export declare const VISITOR_KEY = "_tf_vid";
3
3
  export declare const QUEUE_KEY = "_tf_queue";
4
4
  export declare const OPT_OUT_KEY = "_tf_optout";
@@ -7,8 +7,18 @@ export declare const DEFAULT_API_URL = "https://api.thravic.com";
7
7
  export declare const MAX_BATCH = 50;
8
8
  /** Queued events kept while offline; the oldest go first. */
9
9
  export declare const MAX_QUEUE = 1000;
10
+ /** How long `variant` waits for the running tests before answering null. */
11
+ export declare const TESTS_WAIT_MS = 3000;
10
12
  /** A v4-shaped id, like the web tracker's. Not cryptographic: ids only need to be unique. */
11
13
  export declare function generateId(): string;
14
+ /** FNV-1a, 32-bit, as a number in [0, 1). The web tracker's hash, so both split alike. */
15
+ export declare function unitHash(text: string): number;
16
+ /**
17
+ * The version of `test` for `visitorId`, or null when they are outside its traffic
18
+ * share. Worked out from a hash, so the same user always gets the same version,
19
+ * exactly as the web tracker would give it.
20
+ */
21
+ export declare function assignVariant(visitorId: string, test: Experiment): string | null;
12
22
  /**
13
23
  * The SDK. Screen views are sent as `pageview` events on `app://<bundleId>/<Screen>`
14
24
  * URLs, so the dashboard's pages, paths and funnels work for apps as they do for
@@ -36,6 +46,10 @@ export declare class ThravicClient {
36
46
  private persistScheduled;
37
47
  private timer;
38
48
  private unsubscribeAppState;
49
+ /** This session's running tests, loaded the first time `variant` is asked. */
50
+ private tests;
51
+ /** Tests whose `experiment_view` this session has already sent. */
52
+ private exposed;
39
53
  constructor(env: Environment);
40
54
  /** Start tracking. Call once, when the app starts; later calls are ignored. */
41
55
  init(trackingId: string, options?: ThravicOptions): Promise<void>;
@@ -45,6 +59,13 @@ export declare class ThravicClient {
45
59
  track(event: string, properties?: Record<string, unknown>): void;
46
60
  /** Link this device to your own user id. Sent as the web tracker sends it. */
47
61
  identify(userId: string, traits?: Record<string, unknown>): void;
62
+ /**
63
+ * The version of A/B test `key` to show this user: a version id from the
64
+ * dashboard, or null for the original (not in the test, the test is not
65
+ * running, tracking is off, or the tests could not be loaded in time).
66
+ * The first answer in a session records that the user saw the test.
67
+ */
68
+ variant(key: string): Promise<string | null>;
48
69
  /** Stop tracking this device, and drop anything not yet sent. Remembered across launches. */
49
70
  optOut(): void;
50
71
  /** Resume tracking after `optOut`. */
@@ -63,6 +84,8 @@ export declare class ThravicClient {
63
84
  private startSession;
64
85
  private background;
65
86
  private foreground;
87
+ /** The running tests, or none when they fail to load or take too long. */
88
+ private loadTests;
66
89
  private whenReady;
67
90
  private screenUrl;
68
91
  private enqueue;
package/dist/client.js CHANGED
@@ -1,7 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.ThravicClient = exports.MAX_QUEUE = exports.MAX_BATCH = exports.DEFAULT_API_URL = exports.OPT_OUT_KEY = exports.QUEUE_KEY = exports.VISITOR_KEY = void 0;
3
+ exports.ThravicClient = exports.TESTS_WAIT_MS = exports.MAX_QUEUE = exports.MAX_BATCH = exports.DEFAULT_API_URL = exports.OPT_OUT_KEY = exports.QUEUE_KEY = exports.VISITOR_KEY = void 0;
4
4
  exports.generateId = generateId;
5
+ exports.unitHash = unitHash;
6
+ exports.assignVariant = assignVariant;
5
7
  exports.VISITOR_KEY = '_tf_vid';
6
8
  exports.QUEUE_KEY = '_tf_queue';
7
9
  exports.OPT_OUT_KEY = '_tf_optout';
@@ -14,6 +16,8 @@ exports.MAX_QUEUE = 1000;
14
16
  const FLUSH_AT = 10;
15
17
  const MAX_RETRY_DELAY_MS = 30000;
16
18
  const PERSIST_DELAY_MS = 1000;
19
+ /** How long `variant` waits for the running tests before answering null. */
20
+ exports.TESTS_WAIT_MS = 3000;
17
21
  const DEFAULTS = {
18
22
  apiUrl: exports.DEFAULT_API_URL,
19
23
  sessionTimeoutMinutes: 30,
@@ -27,6 +31,36 @@ function generateId() {
27
31
  return (c === 'x' ? r : (r & 3) | 8).toString(16);
28
32
  });
29
33
  }
34
+ /** FNV-1a, 32-bit, as a number in [0, 1). The web tracker's hash, so both split alike. */
35
+ function unitHash(text) {
36
+ let h = 0x811c9dc5;
37
+ for (let i = 0; i < text.length; i++) {
38
+ h ^= text.charCodeAt(i);
39
+ h = Math.imul(h, 0x01000193);
40
+ }
41
+ return (h >>> 0) / 4294967296;
42
+ }
43
+ /**
44
+ * The version of `test` for `visitorId`, or null when they are outside its traffic
45
+ * share. Worked out from a hash, so the same user always gets the same version,
46
+ * exactly as the web tracker would give it.
47
+ */
48
+ function assignVariant(visitorId, test) {
49
+ if (!test.variants.length)
50
+ return null;
51
+ if (unitHash(`${visitorId}:${test.id}:traffic`) >= test.traffic / 100)
52
+ return null;
53
+ const weights = test.variants.map((v) => Math.max(v.weight, 0));
54
+ const total = weights.reduce((sum, w) => sum + w, 0);
55
+ const pick = unitHash(`${visitorId}:${test.id}:variant`);
56
+ let edge = 0;
57
+ for (let i = 0; i < test.variants.length; i++) {
58
+ edge += total > 0 ? weights[i] / total : 1 / test.variants.length;
59
+ if (pick < edge)
60
+ return test.variants[i].id;
61
+ }
62
+ return test.variants[test.variants.length - 1].id;
63
+ }
30
64
  /**
31
65
  * The SDK. Screen views are sent as `pageview` events on `app://<bundleId>/<Screen>`
32
66
  * URLs, so the dashboard's pages, paths and funnels work for apps as they do for
@@ -54,6 +88,10 @@ class ThravicClient {
54
88
  this.persistScheduled = false;
55
89
  this.timer = null;
56
90
  this.unsubscribeAppState = null;
91
+ /** This session's running tests, loaded the first time `variant` is asked. */
92
+ this.tests = null;
93
+ /** Tests whose `experiment_view` this session has already sent. */
94
+ this.exposed = new Set();
57
95
  this.env = env;
58
96
  }
59
97
  /** Start tracking. Call once, when the app starts; later calls are ignored. */
@@ -118,6 +156,36 @@ class ThravicClient {
118
156
  this.enqueue('custom', { ...traits, event: 'identify', userId: String(userId) });
119
157
  });
120
158
  }
159
+ /**
160
+ * The version of A/B test `key` to show this user: a version id from the
161
+ * dashboard, or null for the original (not in the test, the test is not
162
+ * running, tracking is off, or the tests could not be loaded in time).
163
+ * The first answer in a session records that the user saw the test.
164
+ */
165
+ variant(key) {
166
+ return new Promise((resolve) => {
167
+ if (!this.initialized) {
168
+ this.env.log('[Thravic] Call Thravic.init before asking for a variant.');
169
+ resolve(null);
170
+ return;
171
+ }
172
+ this.whenReady(() => {
173
+ if (this.optedOut) {
174
+ resolve(null);
175
+ return;
176
+ }
177
+ void this.loadTests().then((tests) => {
178
+ const test = tests.find((t) => t.key === key);
179
+ const id = test ? assignVariant(this.visitorId, test) : null;
180
+ if (test && id && !this.exposed.has(test.id)) {
181
+ this.exposed.add(test.id);
182
+ this.enqueue('custom', { event: 'experiment_view', experiment: test.id, variant: id });
183
+ }
184
+ resolve(id);
185
+ });
186
+ });
187
+ });
188
+ }
121
189
  /** Stop tracking this device, and drop anything not yet sent. Remembered across launches. */
122
190
  optOut() {
123
191
  this.optedOut = true;
@@ -202,6 +270,9 @@ class ThravicClient {
202
270
  this.sessionId = generateId();
203
271
  this.sessionStart = this.env.now();
204
272
  this.backgroundAt = null;
273
+ // A new session picks up tests started or ended since the last one.
274
+ this.tests = null;
275
+ this.exposed.clear();
205
276
  this.enqueue('custom', { event: 'app_open' });
206
277
  }
207
278
  background() {
@@ -226,6 +297,31 @@ class ThravicClient {
226
297
  if (this.currentScreen)
227
298
  this.enqueue('pageview');
228
299
  }
300
+ // ── A/B tests ────────────────────────────────────────────────────────────
301
+ /** The running tests, or none when they fail to load or take too long. */
302
+ loadTests() {
303
+ if (!this.tests) {
304
+ this.tests = new Promise((resolve) => {
305
+ let done = false;
306
+ const settle = (list) => {
307
+ if (done)
308
+ return;
309
+ done = true;
310
+ resolve(parseTests(list));
311
+ };
312
+ this.env.setTimeout(() => settle([]), exports.TESTS_WAIT_MS);
313
+ this.env
314
+ .fetch(`${this.options.apiUrl}/api/collect/${encodeURIComponent(this.trackingId)}/config`)
315
+ .then((response) => (response.ok ? response.json() : null))
316
+ .then((config) => settle(config?.experiments))
317
+ .catch((err) => {
318
+ this.debug('tests could not be loaded', err);
319
+ settle([]);
320
+ });
321
+ });
322
+ }
323
+ return this.tests;
324
+ }
229
325
  // ── Queue ────────────────────────────────────────────────────────────────
230
326
  whenReady(call) {
231
327
  if (this.ready)
@@ -313,6 +409,15 @@ function parseQueue(raw) {
313
409
  return [];
314
410
  }
315
411
  }
412
+ function parseTests(list) {
413
+ if (!Array.isArray(list))
414
+ return [];
415
+ return list.filter((t) => !!t &&
416
+ typeof t.id === 'string' &&
417
+ typeof t.key === 'string' &&
418
+ typeof t.traffic === 'number' &&
419
+ Array.isArray(t.variants));
420
+ }
316
421
  function stripUndefined(value) {
317
422
  return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined && v !== null && v !== ''));
318
423
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { ThravicClient } from './client';
2
- export { ThravicClient } from './client';
3
- export type { CollectEvent, DeviceInfo, Environment, NavigationRefLike, Storage, ThravicOptions, } from './types';
2
+ export { ThravicClient, assignVariant } from './client';
3
+ export type { CollectEvent, DeviceInfo, Environment, Experiment, NavigationRefLike, Storage, ThravicOptions, } from './types';
4
4
  /**
5
5
  * Thravic for React Native.
6
6
  *
@@ -10,7 +10,19 @@ export type { CollectEvent, DeviceInfo, Environment, NavigationRefLike, Storage,
10
10
  * Thravic.init('TF-1A2B3C4D', { appVersion: '1.4.0', bundleId: 'com.acme.shop' });
11
11
  * Thravic.trackNavigation(navigationRef); // screen views, automatically
12
12
  * Thravic.track('purchase', { amount: 49 }); // your own events
13
+ * const version = await Thravic.variant('new-paywall'); // A/B tests
13
14
  * ```
14
15
  */
15
16
  export declare const Thravic: ThravicClient;
17
+ /**
18
+ * The version of A/B test `key` to show, for use in a component: `undefined`
19
+ * while it loads, then a version id or null for the original.
20
+ *
21
+ * ```tsx
22
+ * const version = useVariant('new-paywall');
23
+ * if (version === undefined) return null;
24
+ * return version === 'b' ? <NewPaywall /> : <Paywall />;
25
+ * ```
26
+ */
27
+ export declare function useVariant(key: string): string | null | undefined;
16
28
  export default Thravic;
package/dist/index.js CHANGED
@@ -1,10 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.Thravic = exports.ThravicClient = void 0;
3
+ exports.Thravic = exports.assignVariant = exports.ThravicClient = void 0;
4
+ exports.useVariant = useVariant;
5
+ const react_1 = require("react");
4
6
  const client_1 = require("./client");
5
7
  const react_native_1 = require("./react-native");
6
8
  var client_2 = require("./client");
7
9
  Object.defineProperty(exports, "ThravicClient", { enumerable: true, get: function () { return client_2.ThravicClient; } });
10
+ Object.defineProperty(exports, "assignVariant", { enumerable: true, get: function () { return client_2.assignVariant; } });
8
11
  /**
9
12
  * Thravic for React Native.
10
13
  *
@@ -14,7 +17,33 @@ Object.defineProperty(exports, "ThravicClient", { enumerable: true, get: functio
14
17
  * Thravic.init('TF-1A2B3C4D', { appVersion: '1.4.0', bundleId: 'com.acme.shop' });
15
18
  * Thravic.trackNavigation(navigationRef); // screen views, automatically
16
19
  * Thravic.track('purchase', { amount: 49 }); // your own events
20
+ * const version = await Thravic.variant('new-paywall'); // A/B tests
17
21
  * ```
18
22
  */
19
23
  exports.Thravic = new client_1.ThravicClient(react_native_1.reactNativeEnvironment);
24
+ /**
25
+ * The version of A/B test `key` to show, for use in a component: `undefined`
26
+ * while it loads, then a version id or null for the original.
27
+ *
28
+ * ```tsx
29
+ * const version = useVariant('new-paywall');
30
+ * if (version === undefined) return null;
31
+ * return version === 'b' ? <NewPaywall /> : <Paywall />;
32
+ * ```
33
+ */
34
+ function useVariant(key) {
35
+ const [version, setVersion] = (0, react_1.useState)(undefined);
36
+ (0, react_1.useEffect)(() => {
37
+ let live = true;
38
+ setVersion(undefined);
39
+ void exports.Thravic.variant(key).then((id) => {
40
+ if (live)
41
+ setVersion(id);
42
+ });
43
+ return () => {
44
+ live = false;
45
+ };
46
+ }, [key]);
47
+ return version;
48
+ }
20
49
  exports.default = exports.Thravic;
package/dist/types.d.ts CHANGED
@@ -33,6 +33,18 @@ export interface CollectEvent {
33
33
  utmCampaign?: string;
34
34
  data?: Record<string, unknown>;
35
35
  }
36
+ /** A running A/B test, as `GET /api/collect/{trackingId}/config` lists it. */
37
+ export interface Experiment {
38
+ id: string;
39
+ /** What the app asks for: `Thravic.variant(key)`. */
40
+ key: string;
41
+ /** Share of users in the test, 0–100. */
42
+ traffic: number;
43
+ variants: Array<{
44
+ id: string;
45
+ weight: number;
46
+ }>;
47
+ }
36
48
  /** What the device reports about itself. */
37
49
  export interface DeviceInfo {
38
50
  os: string;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@thravic/react-native",
3
- "version": "0.1.0",
4
- "description": "Thravic analytics for React Native and Expo apps: screens, sessions and custom events.",
3
+ "version": "0.2.0",
4
+ "description": "Thravic analytics for React Native and Expo apps: screens, sessions, custom events and A/B tests.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -29,10 +29,12 @@
29
29
  },
30
30
  "peerDependencies": {
31
31
  "@react-native-async-storage/async-storage": ">=1.17.0",
32
+ "react": ">=17.0.0",
32
33
  "react-native": ">=0.70.0"
33
34
  },
34
35
  "devDependencies": {
35
36
  "@react-native-async-storage/async-storage": "^2.1.0",
37
+ "@types/react": "^18.3.31",
36
38
  "react-native": "^0.76.0",
37
39
  "typescript": "^5.6.0",
38
40
  "vitest": "^4.0.18"
package/src/client.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import type {
2
2
  CollectEvent,
3
3
  Environment,
4
+ Experiment,
4
5
  NavigationRefLike,
5
6
  ThravicOptions,
6
7
  } from './types';
@@ -18,6 +19,8 @@ export const MAX_QUEUE = 1000;
18
19
  const FLUSH_AT = 10;
19
20
  const MAX_RETRY_DELAY_MS = 30_000;
20
21
  const PERSIST_DELAY_MS = 1_000;
22
+ /** How long `variant` waits for the running tests before answering null. */
23
+ export const TESTS_WAIT_MS = 3_000;
21
24
 
22
25
  const DEFAULTS = {
23
26
  apiUrl: DEFAULT_API_URL,
@@ -36,6 +39,35 @@ export function generateId(): string {
36
39
  });
37
40
  }
38
41
 
42
+ /** FNV-1a, 32-bit, as a number in [0, 1). The web tracker's hash, so both split alike. */
43
+ export function unitHash(text: string): number {
44
+ let h = 0x811c9dc5;
45
+ for (let i = 0; i < text.length; i++) {
46
+ h ^= text.charCodeAt(i);
47
+ h = Math.imul(h, 0x01000193);
48
+ }
49
+ return (h >>> 0) / 4294967296;
50
+ }
51
+
52
+ /**
53
+ * The version of `test` for `visitorId`, or null when they are outside its traffic
54
+ * share. Worked out from a hash, so the same user always gets the same version,
55
+ * exactly as the web tracker would give it.
56
+ */
57
+ export function assignVariant(visitorId: string, test: Experiment): string | null {
58
+ if (!test.variants.length) return null;
59
+ if (unitHash(`${visitorId}:${test.id}:traffic`) >= test.traffic / 100) return null;
60
+ const weights = test.variants.map((v) => Math.max(v.weight, 0));
61
+ const total = weights.reduce((sum, w) => sum + w, 0);
62
+ const pick = unitHash(`${visitorId}:${test.id}:variant`);
63
+ let edge = 0;
64
+ for (let i = 0; i < test.variants.length; i++) {
65
+ edge += total > 0 ? weights[i] / total : 1 / test.variants.length;
66
+ if (pick < edge) return test.variants[i].id;
67
+ }
68
+ return test.variants[test.variants.length - 1].id;
69
+ }
70
+
39
71
  /**
40
72
  * The SDK. Screen views are sent as `pageview` events on `app://<bundleId>/<Screen>`
41
73
  * URLs, so the dashboard's pages, paths and funnels work for apps as they do for
@@ -66,6 +98,11 @@ export class ThravicClient {
66
98
  private timer: unknown = null;
67
99
  private unsubscribeAppState: (() => void) | null = null;
68
100
 
101
+ /** This session's running tests, loaded the first time `variant` is asked. */
102
+ private tests: Promise<Experiment[]> | null = null;
103
+ /** Tests whose `experiment_view` this session has already sent. */
104
+ private exposed = new Set<string>();
105
+
69
106
  constructor(env: Environment) {
70
107
  this.env = env;
71
108
  }
@@ -139,6 +176,37 @@ export class ThravicClient {
139
176
  });
140
177
  }
141
178
 
179
+ /**
180
+ * The version of A/B test `key` to show this user: a version id from the
181
+ * dashboard, or null for the original (not in the test, the test is not
182
+ * running, tracking is off, or the tests could not be loaded in time).
183
+ * The first answer in a session records that the user saw the test.
184
+ */
185
+ variant(key: string): Promise<string | null> {
186
+ return new Promise((resolve) => {
187
+ if (!this.initialized) {
188
+ this.env.log('[Thravic] Call Thravic.init before asking for a variant.');
189
+ resolve(null);
190
+ return;
191
+ }
192
+ this.whenReady(() => {
193
+ if (this.optedOut) {
194
+ resolve(null);
195
+ return;
196
+ }
197
+ void this.loadTests().then((tests) => {
198
+ const test = tests.find((t) => t.key === key);
199
+ const id = test ? assignVariant(this.visitorId, test) : null;
200
+ if (test && id && !this.exposed.has(test.id)) {
201
+ this.exposed.add(test.id);
202
+ this.enqueue('custom', { event: 'experiment_view', experiment: test.id, variant: id });
203
+ }
204
+ resolve(id);
205
+ });
206
+ });
207
+ });
208
+ }
209
+
142
210
  /** Stop tracking this device, and drop anything not yet sent. Remembered across launches. */
143
211
  optOut(): void {
144
212
  this.optedOut = true;
@@ -226,6 +294,9 @@ export class ThravicClient {
226
294
  this.sessionId = generateId();
227
295
  this.sessionStart = this.env.now();
228
296
  this.backgroundAt = null;
297
+ // A new session picks up tests started or ended since the last one.
298
+ this.tests = null;
299
+ this.exposed.clear();
229
300
  this.enqueue('custom', { event: 'app_open' });
230
301
  }
231
302
 
@@ -250,6 +321,32 @@ export class ThravicClient {
250
321
  if (this.currentScreen) this.enqueue('pageview');
251
322
  }
252
323
 
324
+ // ── A/B tests ────────────────────────────────────────────────────────────
325
+
326
+ /** The running tests, or none when they fail to load or take too long. */
327
+ private loadTests(): Promise<Experiment[]> {
328
+ if (!this.tests) {
329
+ this.tests = new Promise((resolve) => {
330
+ let done = false;
331
+ const settle = (list: unknown) => {
332
+ if (done) return;
333
+ done = true;
334
+ resolve(parseTests(list));
335
+ };
336
+ this.env.setTimeout(() => settle([]), TESTS_WAIT_MS);
337
+ this.env
338
+ .fetch(`${this.options.apiUrl}/api/collect/${encodeURIComponent(this.trackingId)}/config`)
339
+ .then((response) => (response.ok ? response.json() : null))
340
+ .then((config) => settle(config?.experiments))
341
+ .catch((err) => {
342
+ this.debug('tests could not be loaded', err);
343
+ settle([]);
344
+ });
345
+ });
346
+ }
347
+ return this.tests;
348
+ }
349
+
253
350
  // ── Queue ────────────────────────────────────────────────────────────────
254
351
 
255
352
  private whenReady(call: () => void): void {
@@ -333,6 +430,18 @@ function parseQueue(raw: string | null): CollectEvent[] {
333
430
  }
334
431
  }
335
432
 
433
+ function parseTests(list: unknown): Experiment[] {
434
+ if (!Array.isArray(list)) return [];
435
+ return list.filter(
436
+ (t): t is Experiment =>
437
+ !!t &&
438
+ typeof t.id === 'string' &&
439
+ typeof t.key === 'string' &&
440
+ typeof t.traffic === 'number' &&
441
+ Array.isArray(t.variants),
442
+ );
443
+ }
444
+
336
445
  function stripUndefined<T extends object>(value: T): Partial<T> {
337
446
  return Object.fromEntries(
338
447
  Object.entries(value).filter(([, v]) => v !== undefined && v !== null && v !== ''),
package/src/index.ts CHANGED
@@ -1,11 +1,14 @@
1
+ import { useEffect, useState } from 'react';
2
+
1
3
  import { ThravicClient } from './client';
2
4
  import { reactNativeEnvironment } from './react-native';
3
5
 
4
- export { ThravicClient } from './client';
6
+ export { ThravicClient, assignVariant } from './client';
5
7
  export type {
6
8
  CollectEvent,
7
9
  DeviceInfo,
8
10
  Environment,
11
+ Experiment,
9
12
  NavigationRefLike,
10
13
  Storage,
11
14
  ThravicOptions,
@@ -20,8 +23,34 @@ export type {
20
23
  * Thravic.init('TF-1A2B3C4D', { appVersion: '1.4.0', bundleId: 'com.acme.shop' });
21
24
  * Thravic.trackNavigation(navigationRef); // screen views, automatically
22
25
  * Thravic.track('purchase', { amount: 49 }); // your own events
26
+ * const version = await Thravic.variant('new-paywall'); // A/B tests
23
27
  * ```
24
28
  */
25
29
  export const Thravic = new ThravicClient(reactNativeEnvironment);
26
30
 
31
+ /**
32
+ * The version of A/B test `key` to show, for use in a component: `undefined`
33
+ * while it loads, then a version id or null for the original.
34
+ *
35
+ * ```tsx
36
+ * const version = useVariant('new-paywall');
37
+ * if (version === undefined) return null;
38
+ * return version === 'b' ? <NewPaywall /> : <Paywall />;
39
+ * ```
40
+ */
41
+ export function useVariant(key: string): string | null | undefined {
42
+ const [version, setVersion] = useState<string | null | undefined>(undefined);
43
+ useEffect(() => {
44
+ let live = true;
45
+ setVersion(undefined);
46
+ void Thravic.variant(key).then((id) => {
47
+ if (live) setVersion(id);
48
+ });
49
+ return () => {
50
+ live = false;
51
+ };
52
+ }, [key]);
53
+ return version;
54
+ }
55
+
27
56
  export default Thravic;
package/src/types.ts CHANGED
@@ -35,6 +35,16 @@ export interface CollectEvent {
35
35
  data?: Record<string, unknown>;
36
36
  }
37
37
 
38
+ /** A running A/B test, as `GET /api/collect/{trackingId}/config` lists it. */
39
+ export interface Experiment {
40
+ id: string;
41
+ /** What the app asks for: `Thravic.variant(key)`. */
42
+ key: string;
43
+ /** Share of users in the test, 0–100. */
44
+ traffic: number;
45
+ variants: Array<{ id: string; weight: number }>;
46
+ }
47
+
38
48
  /** What the device reports about itself. */
39
49
  export interface DeviceInfo {
40
50
  os: string;