@experiwall/react 0.2.0 โ†’ 0.3.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
@@ -6,10 +6,9 @@
6
6
  alt="Experiwall React SDK"
7
7
  title="Experiwall React SDK"
8
8
  />
9
- <h1 align="center">๐Ÿ’™๐Ÿฅฝ Experiwall React SDK โš›๏ธ๐Ÿงช</h1>
9
+ <h1 align="center">Experiwall React SDK</h1>
10
10
  </p>
11
11
 
12
-
13
12
  <p align="center">
14
13
  <img
15
14
  src=".github/preview.png"
@@ -20,5 +19,164 @@
20
19
  </p>
21
20
 
22
21
  <p align="center">
23
- ๐Ÿ’™ Find what your users love with social experiments ๐Ÿฅฝ
22
+ Code-first A/B testing for React. Define experiments inline, track conversions, and let the dashboard show you what wins.
24
23
  </p>
24
+
25
+ ---
26
+
27
+ ## Install
28
+
29
+ ```bash
30
+ npm install @experiwall/react
31
+ ```
32
+
33
+ ## Quick start
34
+
35
+ ### 1. Wrap your app with the provider
36
+
37
+ ```tsx
38
+ import { ExperiwallProvider } from "@experiwall/react";
39
+
40
+ export default function App() {
41
+ return (
42
+ <ExperiwallProvider
43
+ apiKey="your-api-key"
44
+ userId={currentUser.id}
45
+ environment={process.env.NODE_ENV === "production" ? "production" : "development"}
46
+ >
47
+ <YourApp />
48
+ </ExperiwallProvider>
49
+ );
50
+ }
51
+ ```
52
+
53
+ ### 2. Run an experiment
54
+
55
+ ```tsx
56
+ import { useExperiment } from "@experiwall/react";
57
+
58
+ function CheckoutButton() {
59
+ const variant = useExperiment("checkout-flow", ["control", "new-checkout"]);
60
+
61
+ if (variant === null) return null; // loading
62
+
63
+ if (variant === "new-checkout") {
64
+ return <NewCheckoutButton />;
65
+ }
66
+
67
+ return <OriginalCheckoutButton />;
68
+ }
69
+ ```
70
+
71
+ That's it. The hook automatically:
72
+ - Assigns the user to a variant (deterministic, based on their seed)
73
+ - Tracks an `$exposure` event once per mount
74
+ - Registers the assignment with the server
75
+
76
+ ### 3. Track conversions
77
+
78
+ ```tsx
79
+ import { useTrack } from "@experiwall/react";
80
+
81
+ function PurchaseConfirmation({ amount }: { amount: number }) {
82
+ const track = useTrack();
83
+
84
+ useEffect(() => {
85
+ track("purchase", { revenue: amount });
86
+ }, []);
87
+
88
+ return <p>Thanks for your order!</p>;
89
+ }
90
+ ```
91
+
92
+ Events are batched (flushed every 30s) and automatically flushed when the user leaves the page.
93
+
94
+ ## API
95
+
96
+ ### `<ExperiwallProvider>`
97
+
98
+ | Prop | Type | Required | Description |
99
+ |---|---|---|---|
100
+ | `apiKey` | `string` | Yes | Your project API key |
101
+ | `userId` | `string` | No | Stable user identifier for consistent bucketing |
102
+ | `aliasId` | `string` | No | Alternative identifier (e.g. anonymous ID) |
103
+ | `environment` | `string` | No | `"production"` (default) or `"development"` โ€” segments traffic in the dashboard |
104
+ | `overrides` | `Record<string, string>` | No | Force specific variants for QA (skips tracking) |
105
+ | `baseUrl` | `string` | No | Custom API URL (defaults to `https://experiwall.com`) |
106
+
107
+ ### `useExperiment(flagKey, variants, options?)`
108
+
109
+ Returns the assigned variant (`string`) or `null` while loading.
110
+
111
+ ```tsx
112
+ const variant = useExperiment("hero-banner", ["control", "large-cta", "video"]);
113
+ ```
114
+
115
+ **Options:**
116
+
117
+ | Option | Type | Description |
118
+ |---|---|---|
119
+ | `force` | `string` | Override the variant for this hook only (skips tracking) |
120
+
121
+ ### `useTrack()`
122
+
123
+ Returns a `track(eventName, properties?)` function.
124
+
125
+ ```tsx
126
+ const track = useTrack();
127
+ track("signup", { plan: "pro" });
128
+ ```
129
+
130
+ ### `useExperiwall()`
131
+
132
+ Low-level access to the full SDK context.
133
+
134
+ | Field | Type | Description |
135
+ |---|---|---|
136
+ | `userSeed` | `number \| null` | Server-provided seed for deterministic bucketing. `null` while loading. |
137
+ | `assignments` | `Record<string, string>` | Map of flag key to assigned variant key |
138
+ | `experiments` | `Record<string, { variants: { key: string; weight: number }[] }> \| undefined` | Server-provided experiment definitions with variant weights |
139
+ | `overrides` | `Record<string, string>` | Provider-level forced variants |
140
+ | `isLoading` | `boolean` | `true` during the initial `/init` fetch |
141
+ | `error` | `Error \| null` | Error object if the `/init` fetch failed |
142
+ | `trackEvent` | `(event: ExperiwallEvent) => void` | Queue a raw event for batching |
143
+ | `registerLocalFlag` | `(flagKey, variants, assignedVariant) => void` | Register a client-side flag assignment with the server |
144
+
145
+ ## QA and testing
146
+
147
+ Use `overrides` to force variants without contaminating experiment data:
148
+
149
+ ```tsx
150
+ <ExperiwallProvider
151
+ apiKey="your-api-key"
152
+ userId={currentUser.id}
153
+ overrides={{ "checkout-flow": "new-checkout" }}
154
+ >
155
+ ```
156
+
157
+ Or per-hook:
158
+
159
+ ```tsx
160
+ const variant = useExperiment("checkout-flow", ["control", "new-checkout"], {
161
+ force: "new-checkout", etc.
162
+ });
163
+ ```
164
+
165
+ Both skip exposure tracking and server registration entirely.
166
+
167
+ ## Environment separation
168
+
169
+ Pass `environment` to keep development traffic out of your production results:
170
+
171
+ ```tsx
172
+ <ExperiwallProvider
173
+ apiKey="your-api-key"
174
+ environment={process.env.NODE_ENV === "production" ? "production" : "development"}
175
+ >
176
+ ```
177
+
178
+ The dashboard lets you toggle between Production and Development to view metrics separately.
179
+
180
+ ## License
181
+
182
+ MIT
package/dist/index.js CHANGED
@@ -31,7 +31,7 @@ module.exports = __toCommonJS(index_exports);
31
31
  var import_react = require("react");
32
32
 
33
33
  // src/lib/api-client.ts
34
- var DEFAULT_BASE_URL = "https://experiwall.com";
34
+ var DEFAULT_BASE_URL = "https://www.experiwall.com";
35
35
  async function fetchInit(apiKey, options) {
36
36
  const base = options?.baseUrl ?? DEFAULT_BASE_URL;
37
37
  const url = new URL("/api/sdk/init", base);
@@ -191,6 +191,18 @@ function setCache(key, data) {
191
191
 
192
192
  // src/provider.tsx
193
193
  var import_jsx_runtime = require("react/jsx-runtime");
194
+ var ANON_ID_KEY = "experiwall_anon_id";
195
+ function getOrCreateAnonId() {
196
+ try {
197
+ const existing = localStorage.getItem(ANON_ID_KEY);
198
+ if (existing) return existing;
199
+ const id = crypto.randomUUID();
200
+ localStorage.setItem(ANON_ID_KEY, id);
201
+ return id;
202
+ } catch {
203
+ return crypto.randomUUID();
204
+ }
205
+ }
194
206
  function getCacheKey(userId, aliasId, environment) {
195
207
  const identity = userId || aliasId || "anon";
196
208
  const env = environment || "production";
@@ -203,6 +215,10 @@ function ExperiwallProvider({
203
215
  children,
204
216
  ...config
205
217
  }) {
218
+ const [anonId] = (0, import_react.useState)(
219
+ () => !config.userId && !config.aliasId ? getOrCreateAnonId() : void 0
220
+ );
221
+ const effectiveAliasId = config.aliasId ?? anonId;
206
222
  const [userSeed, setUserSeed] = (0, import_react.useState)(null);
207
223
  const [assignments, setAssignments] = (0, import_react.useState)({});
208
224
  const [experiments, setExperiments] = (0, import_react.useState)();
@@ -214,7 +230,7 @@ function ExperiwallProvider({
214
230
  const load = async () => {
215
231
  setIsLoading(true);
216
232
  setError(null);
217
- const cacheKey = getCacheKey(config.userId, config.aliasId, config.environment);
233
+ const cacheKey = getCacheKey(config.userId, effectiveAliasId, config.environment);
218
234
  const cached = getCached(cacheKey);
219
235
  if (cached) {
220
236
  setUserSeed(cached.user_seed);
@@ -228,7 +244,7 @@ function ExperiwallProvider({
228
244
  const data = await fetchInit(config.apiKey, {
229
245
  baseUrl: config.baseUrl,
230
246
  userId: config.userId,
231
- aliasId: config.aliasId,
247
+ aliasId: effectiveAliasId,
232
248
  environment
233
249
  });
234
250
  if (!cancelled) {
@@ -254,7 +270,7 @@ function ExperiwallProvider({
254
270
  apiKey: config.apiKey,
255
271
  baseUrl: config.baseUrl,
256
272
  userId: config.userId,
257
- aliasId: config.aliasId,
273
+ aliasId: effectiveAliasId,
258
274
  environment: config.environment ?? "production"
259
275
  });
260
276
  batcher.start();
@@ -273,7 +289,7 @@ function ExperiwallProvider({
273
289
  window.removeEventListener("beforeunload", handleBeforeUnload);
274
290
  batcher.stop();
275
291
  };
276
- }, [config.apiKey, config.userId, config.aliasId, config.environment]);
292
+ }, [config.apiKey, config.userId, effectiveAliasId, config.environment]);
277
293
  const trackEvent = (0, import_react.useCallback)((event) => {
278
294
  batcherRef.current?.push(event);
279
295
  }, []);
@@ -287,13 +303,13 @@ function ExperiwallProvider({
287
303
  variants,
288
304
  assigned_variant: assignedVariant,
289
305
  user_id: config.userId,
290
- alias_id: config.aliasId
306
+ alias_id: effectiveAliasId
291
307
  },
292
308
  { baseUrl: config.baseUrl, environment: config.environment ?? "production" }
293
309
  ).catch(() => {
294
310
  });
295
311
  },
296
- [config.apiKey, config.baseUrl, config.userId, config.aliasId, config.environment]
312
+ [config.apiKey, config.baseUrl, config.userId, effectiveAliasId, config.environment]
297
313
  );
298
314
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
299
315
  ExperiwallContext.Provider,
package/dist/index.mjs CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  } from "react";
9
9
 
10
10
  // src/lib/api-client.ts
11
- var DEFAULT_BASE_URL = "https://experiwall.com";
11
+ var DEFAULT_BASE_URL = "https://www.experiwall.com";
12
12
  async function fetchInit(apiKey, options) {
13
13
  const base = options?.baseUrl ?? DEFAULT_BASE_URL;
14
14
  const url = new URL("/api/sdk/init", base);
@@ -168,6 +168,18 @@ function setCache(key, data) {
168
168
 
169
169
  // src/provider.tsx
170
170
  import { jsx } from "react/jsx-runtime";
171
+ var ANON_ID_KEY = "experiwall_anon_id";
172
+ function getOrCreateAnonId() {
173
+ try {
174
+ const existing = localStorage.getItem(ANON_ID_KEY);
175
+ if (existing) return existing;
176
+ const id = crypto.randomUUID();
177
+ localStorage.setItem(ANON_ID_KEY, id);
178
+ return id;
179
+ } catch {
180
+ return crypto.randomUUID();
181
+ }
182
+ }
171
183
  function getCacheKey(userId, aliasId, environment) {
172
184
  const identity = userId || aliasId || "anon";
173
185
  const env = environment || "production";
@@ -180,6 +192,10 @@ function ExperiwallProvider({
180
192
  children,
181
193
  ...config
182
194
  }) {
195
+ const [anonId] = useState(
196
+ () => !config.userId && !config.aliasId ? getOrCreateAnonId() : void 0
197
+ );
198
+ const effectiveAliasId = config.aliasId ?? anonId;
183
199
  const [userSeed, setUserSeed] = useState(null);
184
200
  const [assignments, setAssignments] = useState({});
185
201
  const [experiments, setExperiments] = useState();
@@ -191,7 +207,7 @@ function ExperiwallProvider({
191
207
  const load = async () => {
192
208
  setIsLoading(true);
193
209
  setError(null);
194
- const cacheKey = getCacheKey(config.userId, config.aliasId, config.environment);
210
+ const cacheKey = getCacheKey(config.userId, effectiveAliasId, config.environment);
195
211
  const cached = getCached(cacheKey);
196
212
  if (cached) {
197
213
  setUserSeed(cached.user_seed);
@@ -205,7 +221,7 @@ function ExperiwallProvider({
205
221
  const data = await fetchInit(config.apiKey, {
206
222
  baseUrl: config.baseUrl,
207
223
  userId: config.userId,
208
- aliasId: config.aliasId,
224
+ aliasId: effectiveAliasId,
209
225
  environment
210
226
  });
211
227
  if (!cancelled) {
@@ -231,7 +247,7 @@ function ExperiwallProvider({
231
247
  apiKey: config.apiKey,
232
248
  baseUrl: config.baseUrl,
233
249
  userId: config.userId,
234
- aliasId: config.aliasId,
250
+ aliasId: effectiveAliasId,
235
251
  environment: config.environment ?? "production"
236
252
  });
237
253
  batcher.start();
@@ -250,7 +266,7 @@ function ExperiwallProvider({
250
266
  window.removeEventListener("beforeunload", handleBeforeUnload);
251
267
  batcher.stop();
252
268
  };
253
- }, [config.apiKey, config.userId, config.aliasId, config.environment]);
269
+ }, [config.apiKey, config.userId, effectiveAliasId, config.environment]);
254
270
  const trackEvent = useCallback((event) => {
255
271
  batcherRef.current?.push(event);
256
272
  }, []);
@@ -264,13 +280,13 @@ function ExperiwallProvider({
264
280
  variants,
265
281
  assigned_variant: assignedVariant,
266
282
  user_id: config.userId,
267
- alias_id: config.aliasId
283
+ alias_id: effectiveAliasId
268
284
  },
269
285
  { baseUrl: config.baseUrl, environment: config.environment ?? "production" }
270
286
  ).catch(() => {
271
287
  });
272
288
  },
273
- [config.apiKey, config.baseUrl, config.userId, config.aliasId, config.environment]
289
+ [config.apiKey, config.baseUrl, config.userId, effectiveAliasId, config.environment]
274
290
  );
275
291
  return /* @__PURE__ */ jsx(
276
292
  ExperiwallContext.Provider,
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Server-side helpers for Experiwall.
3
+ *
4
+ * Use this module in Next.js server components, Route Handlers,
5
+ * or any server runtime to resolve experiments during SSR โ€”
6
+ * the variant is in the initial HTML with zero loading state.
7
+ *
8
+ * ```ts
9
+ * import { fetchExperiments } from "@experiwall/react/server";
10
+ *
11
+ * // In a server component:
12
+ * const { assignments } = await fetchExperiments({
13
+ * apiKey: "ew_pub_...",
14
+ * aliasId: cookies().get("ew_anon_id")?.value,
15
+ * });
16
+ * const variant = assignments["my-experiment"] ?? "control";
17
+ * ```
18
+ */
19
+ interface ServerConfig {
20
+ apiKey: string;
21
+ baseUrl?: string;
22
+ userId?: string;
23
+ aliasId?: string;
24
+ environment?: string;
25
+ }
26
+ interface ExperimentsResponse {
27
+ user_seed: number;
28
+ assignments: Record<string, string>;
29
+ experiments?: Record<string, {
30
+ variants: {
31
+ key: string;
32
+ weight: number;
33
+ }[];
34
+ }>;
35
+ }
36
+ /**
37
+ * Fetch experiment assignments from the Experiwall API.
38
+ *
39
+ * Call this from server components to resolve variants at SSR time.
40
+ * Pass a `userId` or `aliasId` to identify the visitor (e.g. from a cookie).
41
+ */
42
+ declare function fetchExperiments(config: ServerConfig): Promise<ExperimentsResponse>;
43
+
44
+ export { type ExperimentsResponse, type ServerConfig, fetchExperiments };
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Server-side helpers for Experiwall.
3
+ *
4
+ * Use this module in Next.js server components, Route Handlers,
5
+ * or any server runtime to resolve experiments during SSR โ€”
6
+ * the variant is in the initial HTML with zero loading state.
7
+ *
8
+ * ```ts
9
+ * import { fetchExperiments } from "@experiwall/react/server";
10
+ *
11
+ * // In a server component:
12
+ * const { assignments } = await fetchExperiments({
13
+ * apiKey: "ew_pub_...",
14
+ * aliasId: cookies().get("ew_anon_id")?.value,
15
+ * });
16
+ * const variant = assignments["my-experiment"] ?? "control";
17
+ * ```
18
+ */
19
+ interface ServerConfig {
20
+ apiKey: string;
21
+ baseUrl?: string;
22
+ userId?: string;
23
+ aliasId?: string;
24
+ environment?: string;
25
+ }
26
+ interface ExperimentsResponse {
27
+ user_seed: number;
28
+ assignments: Record<string, string>;
29
+ experiments?: Record<string, {
30
+ variants: {
31
+ key: string;
32
+ weight: number;
33
+ }[];
34
+ }>;
35
+ }
36
+ /**
37
+ * Fetch experiment assignments from the Experiwall API.
38
+ *
39
+ * Call this from server components to resolve variants at SSR time.
40
+ * Pass a `userId` or `aliasId` to identify the visitor (e.g. from a cookie).
41
+ */
42
+ declare function fetchExperiments(config: ServerConfig): Promise<ExperimentsResponse>;
43
+
44
+ export { type ExperimentsResponse, type ServerConfig, fetchExperiments };
package/dist/server.js ADDED
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/server.ts
21
+ var server_exports = {};
22
+ __export(server_exports, {
23
+ fetchExperiments: () => fetchExperiments
24
+ });
25
+ module.exports = __toCommonJS(server_exports);
26
+ var DEFAULT_BASE_URL = "https://www.experiwall.com";
27
+ async function fetchExperiments(config) {
28
+ const base = config.baseUrl ?? DEFAULT_BASE_URL;
29
+ const url = new URL("/api/sdk/init", base);
30
+ if (config.userId) url.searchParams.set("user_id", config.userId);
31
+ if (config.aliasId) url.searchParams.set("alias_id", config.aliasId);
32
+ if (config.environment) url.searchParams.set("environment", config.environment);
33
+ const res = await fetch(url.toString(), {
34
+ headers: { "x-api-key": config.apiKey },
35
+ cache: "no-store"
36
+ });
37
+ if (!res.ok) {
38
+ throw new Error(`Experiwall API error: ${res.status}`);
39
+ }
40
+ return res.json();
41
+ }
42
+ // Annotate the CommonJS export names for ESM import in node:
43
+ 0 && (module.exports = {
44
+ fetchExperiments
45
+ });
@@ -0,0 +1,20 @@
1
+ // src/server.ts
2
+ var DEFAULT_BASE_URL = "https://www.experiwall.com";
3
+ async function fetchExperiments(config) {
4
+ const base = config.baseUrl ?? DEFAULT_BASE_URL;
5
+ const url = new URL("/api/sdk/init", base);
6
+ if (config.userId) url.searchParams.set("user_id", config.userId);
7
+ if (config.aliasId) url.searchParams.set("alias_id", config.aliasId);
8
+ if (config.environment) url.searchParams.set("environment", config.environment);
9
+ const res = await fetch(url.toString(), {
10
+ headers: { "x-api-key": config.apiKey },
11
+ cache: "no-store"
12
+ });
13
+ if (!res.ok) {
14
+ throw new Error(`Experiwall API error: ${res.status}`);
15
+ }
16
+ return res.json();
17
+ }
18
+ export {
19
+ fetchExperiments
20
+ };
package/package.json CHANGED
@@ -1,16 +1,28 @@
1
1
  {
2
2
  "name": "@experiwall/react",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Experiwall React SDK โ€” code-first experimentation and A/B testing",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
7
7
  "types": "dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.mjs",
12
+ "require": "./dist/index.js"
13
+ },
14
+ "./server": {
15
+ "types": "./dist/server.d.ts",
16
+ "import": "./dist/server.mjs",
17
+ "require": "./dist/server.js"
18
+ }
19
+ },
8
20
  "files": [
9
21
  "dist"
10
22
  ],
11
23
  "scripts": {
12
- "build": "tsup src/index.ts --format cjs,esm --dts --clean",
13
- "dev": "tsup src/index.ts --format cjs,esm --dts --watch"
24
+ "build": "tsup src/index.ts src/server.ts --format cjs,esm --dts --clean",
25
+ "dev": "tsup src/index.ts src/server.ts --format cjs,esm --dts --watch"
14
26
  },
15
27
  "peerDependencies": {
16
28
  "react": ">=18",