@se-studio/ab-testing 1.0.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 +393 -0
- package/dist/hooks/index.d.ts +8 -0
- package/dist/hooks/index.d.ts.map +1 -0
- package/dist/hooks/index.js +7 -0
- package/dist/hooks/index.js.map +1 -0
- package/dist/hooks/useAbTestAssignments.d.ts +55 -0
- package/dist/hooks/useAbTestAssignments.d.ts.map +1 -0
- package/dist/hooks/useAbTestAssignments.js +87 -0
- package/dist/hooks/useAbTestAssignments.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware/assignment.d.ts +36 -0
- package/dist/middleware/assignment.d.ts.map +1 -0
- package/dist/middleware/assignment.js +91 -0
- package/dist/middleware/assignment.js.map +1 -0
- package/dist/middleware/cache.d.ts +26 -0
- package/dist/middleware/cache.d.ts.map +1 -0
- package/dist/middleware/cache.js +128 -0
- package/dist/middleware/cache.js.map +1 -0
- package/dist/middleware/cookies.d.ts +44 -0
- package/dist/middleware/cookies.d.ts.map +1 -0
- package/dist/middleware/cookies.js +66 -0
- package/dist/middleware/cookies.js.map +1 -0
- package/dist/middleware/handler.d.ts +45 -0
- package/dist/middleware/handler.d.ts.map +1 -0
- package/dist/middleware/handler.js +189 -0
- package/dist/middleware/handler.js.map +1 -0
- package/dist/middleware/index.d.ts +11 -0
- package/dist/middleware/index.d.ts.map +1 -0
- package/dist/middleware/index.js +10 -0
- package/dist/middleware/index.js.map +1 -0
- package/dist/middleware/types.d.ts +78 -0
- package/dist/middleware/types.d.ts.map +1 -0
- package/dist/middleware/types.js +2 -0
- package/dist/middleware/types.js.map +1 -0
- package/dist/types.d.ts +125 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +7 -0
- package/dist/types.js.map +1 -0
- package/dist/webhook/handler.d.ts +116 -0
- package/dist/webhook/handler.d.ts.map +1 -0
- package/dist/webhook/handler.js +123 -0
- package/dist/webhook/handler.js.map +1 -0
- package/dist/webhook/index.d.ts +8 -0
- package/dist/webhook/index.d.ts.map +1 -0
- package/dist/webhook/index.js +7 -0
- package/dist/webhook/index.js.map +1 -0
- package/package.json +74 -0
package/README.md
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
# @se-studio/ab-testing
|
|
2
|
+
|
|
3
|
+
Server-side A/B testing framework for Next.js applications with Contentful CMS.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Server-side rendering**: No flicker or layout shift - variants are rendered on the server
|
|
8
|
+
- **Edge-optimized**: Middleware runs at the edge for minimal latency
|
|
9
|
+
- **CMS-driven**: Test configuration managed entirely in Contentful
|
|
10
|
+
- **Provider-agnostic**: Bring your own blob storage (Vercel KV, Netlify Blobs, etc.)
|
|
11
|
+
- **Flexible analytics**: Use the `useAbTestAssignments` hook to integrate with any analytics platform
|
|
12
|
+
|
|
13
|
+
## Architecture
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
|
17
|
+
│ Contentful │────▶│ Webhook Handler │────▶│ Blob Store │
|
|
18
|
+
│ PageTest │ │ (API Route) │ │ (Project- │
|
|
19
|
+
│ Entries │ │ │ │ specific) │
|
|
20
|
+
└─────────────────┘ └──────────────────┘ └────────┬────────┘
|
|
21
|
+
│
|
|
22
|
+
▼
|
|
23
|
+
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
|
|
24
|
+
│ User Request │────▶│ Middleware │────▶│ Variant Page │
|
|
25
|
+
│ /pricing │ │ (Edge) │ │ (SSR) │
|
|
26
|
+
└─────────────────┘ └──────────────────┘ └─────────────────┘
|
|
27
|
+
│
|
|
28
|
+
▼
|
|
29
|
+
┌──────────────────┐
|
|
30
|
+
│ Cookie Set │
|
|
31
|
+
│ ab-test-info │
|
|
32
|
+
└────────┬─────────┘
|
|
33
|
+
│
|
|
34
|
+
▼
|
|
35
|
+
┌──────────────────┐
|
|
36
|
+
│ useAbTestAssign- │
|
|
37
|
+
│ ments Hook │
|
|
38
|
+
│ → Your Analytics │
|
|
39
|
+
└──────────────────┘
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Installation
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pnpm add @se-studio/ab-testing
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quick Start
|
|
49
|
+
|
|
50
|
+
### 1. Implement Your Blob Store
|
|
51
|
+
|
|
52
|
+
The package requires you to implement the `IBlobStore<AbTest>` interface for your hosting platform.
|
|
53
|
+
|
|
54
|
+
**Vercel KV Example:**
|
|
55
|
+
|
|
56
|
+
```typescript
|
|
57
|
+
// src/server/abTestStore.ts
|
|
58
|
+
import { kv } from '@vercel/kv';
|
|
59
|
+
import type { IBlobStore, AbTest } from '@se-studio/ab-testing';
|
|
60
|
+
|
|
61
|
+
const STORE_KEY = 'ab-tests';
|
|
62
|
+
|
|
63
|
+
export function getAbTestStore(): IBlobStore<AbTest> {
|
|
64
|
+
return {
|
|
65
|
+
async get(key) {
|
|
66
|
+
const data = await kv.hget<AbTest>(STORE_KEY, key);
|
|
67
|
+
return data ?? undefined;
|
|
68
|
+
},
|
|
69
|
+
async set(key, value) {
|
|
70
|
+
await kv.hset(STORE_KEY, { [key]: value });
|
|
71
|
+
},
|
|
72
|
+
async bulkWrite(entries) {
|
|
73
|
+
await kv.del(STORE_KEY);
|
|
74
|
+
if (entries.length > 0) {
|
|
75
|
+
const data = Object.fromEntries(entries);
|
|
76
|
+
await kv.hset(STORE_KEY, data);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
async size() {
|
|
80
|
+
return kv.hlen(STORE_KEY);
|
|
81
|
+
},
|
|
82
|
+
async values() {
|
|
83
|
+
const data = await kv.hgetall<Record<string, AbTest>>(STORE_KEY);
|
|
84
|
+
return data ? Object.values(data) : [];
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
**Netlify Blobs Example:**
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
// src/server/abTestStore.ts
|
|
94
|
+
import { getStore } from '@netlify/blobs';
|
|
95
|
+
import type { IBlobStore, AbTest } from '@se-studio/ab-testing';
|
|
96
|
+
|
|
97
|
+
export function getAbTestStore(): IBlobStore<AbTest> {
|
|
98
|
+
const store = getStore('ab-testing');
|
|
99
|
+
const BLOB_NAME = 'config';
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
async get(key) {
|
|
103
|
+
const data = await store.get(BLOB_NAME, { type: 'json' });
|
|
104
|
+
return data?.[key];
|
|
105
|
+
},
|
|
106
|
+
async set(key, value) {
|
|
107
|
+
const data = (await store.get(BLOB_NAME, { type: 'json' })) ?? {};
|
|
108
|
+
data[key] = value;
|
|
109
|
+
await store.setJSON(BLOB_NAME, data);
|
|
110
|
+
},
|
|
111
|
+
async bulkWrite(entries) {
|
|
112
|
+
const data = Object.fromEntries(entries);
|
|
113
|
+
await store.setJSON(BLOB_NAME, data);
|
|
114
|
+
},
|
|
115
|
+
async size() {
|
|
116
|
+
const data = await store.get(BLOB_NAME, { type: 'json' });
|
|
117
|
+
return data ? Object.keys(data).length : 0;
|
|
118
|
+
},
|
|
119
|
+
async values() {
|
|
120
|
+
const data = await store.get(BLOB_NAME, { type: 'json' });
|
|
121
|
+
return data ? Object.values(data) : [];
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
### 2. Set Up the Middleware
|
|
128
|
+
|
|
129
|
+
```typescript
|
|
130
|
+
// src/middleware.ts
|
|
131
|
+
import { createAbTestMiddleware } from '@se-studio/ab-testing/middleware';
|
|
132
|
+
import { getAbTestStore } from './server/abTestStore';
|
|
133
|
+
import { NextResponse } from 'next/server';
|
|
134
|
+
import type { NextRequest } from 'next/server';
|
|
135
|
+
|
|
136
|
+
const abTestHandler = createAbTestMiddleware({
|
|
137
|
+
getStore: getAbTestStore,
|
|
138
|
+
cacheTtlMs: 60000, // 60 second cache
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
export async function middleware(request: NextRequest) {
|
|
142
|
+
// Skip static assets, API routes, etc.
|
|
143
|
+
if (request.nextUrl.pathname.startsWith('/_next') ||
|
|
144
|
+
request.nextUrl.pathname.startsWith('/api')) {
|
|
145
|
+
return NextResponse.next();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Process A/B tests
|
|
149
|
+
const abResponse = await abTestHandler(request);
|
|
150
|
+
if (abResponse) return abResponse;
|
|
151
|
+
|
|
152
|
+
return NextResponse.next();
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export const config = {
|
|
156
|
+
matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'],
|
|
157
|
+
};
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
### 3. Create the Webhook Handler
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
// src/app/api/webhooks/ab-test/route.ts
|
|
164
|
+
import { createWebhookHandler } from '@se-studio/ab-testing/webhook';
|
|
165
|
+
import { getAbTestStore } from '@/server/abTestStore';
|
|
166
|
+
import { revalidateTag } from 'next/cache';
|
|
167
|
+
|
|
168
|
+
// Your Contentful fetch function
|
|
169
|
+
async function fetchPageTests() {
|
|
170
|
+
// Fetch all PageTest entries from Contentful
|
|
171
|
+
// Return array of RawPageTest objects
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const dynamic = 'force-dynamic';
|
|
175
|
+
|
|
176
|
+
export const POST = createWebhookHandler({
|
|
177
|
+
fetchPageTests,
|
|
178
|
+
getStore: getAbTestStore,
|
|
179
|
+
webhookSecret: process.env.CONTENTFUL_WEBHOOK_SECRET,
|
|
180
|
+
revalidate: () => revalidateTag('PageTests'),
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### 4. Create Your Analytics Reporter (Project-Specific)
|
|
185
|
+
|
|
186
|
+
The package provides a `useAbTestAssignments` hook that returns test assignments for the current page. You create your own reporter component with your project's specific analytics integrations.
|
|
187
|
+
|
|
188
|
+
```tsx
|
|
189
|
+
// src/components/AbTestReporter.tsx (project-specific)
|
|
190
|
+
'use client';
|
|
191
|
+
|
|
192
|
+
import { useEffect } from 'react';
|
|
193
|
+
import { useAbTestAssignments } from '@se-studio/ab-testing';
|
|
194
|
+
import { sendEvent } from '@/lib/analytics';
|
|
195
|
+
|
|
196
|
+
export function AbTestReporter() {
|
|
197
|
+
const assignments = useAbTestAssignments();
|
|
198
|
+
|
|
199
|
+
useEffect(() => {
|
|
200
|
+
for (const assignment of assignments) {
|
|
201
|
+
// Send to GTM/GA4
|
|
202
|
+
sendEvent('experiment_impression', {
|
|
203
|
+
experiment_id: assignment.testId,
|
|
204
|
+
experiment_name: assignment.test_label,
|
|
205
|
+
variant_id: assignment.test_path,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}, [assignments]);
|
|
209
|
+
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
**With HubSpot Integration (HSD-style):**
|
|
215
|
+
|
|
216
|
+
```tsx
|
|
217
|
+
// src/components/AbTestReporter.tsx
|
|
218
|
+
'use client';
|
|
219
|
+
|
|
220
|
+
import { useEffect } from 'react';
|
|
221
|
+
import { useAbTestAssignments } from '@se-studio/ab-testing';
|
|
222
|
+
import { sendEvent } from '@/lib/analytics';
|
|
223
|
+
import { sendHubspotCustomEvent } from '@/lib/hubspotCustomEvents';
|
|
224
|
+
|
|
225
|
+
export function AbTestReporter() {
|
|
226
|
+
const assignments = useAbTestAssignments();
|
|
227
|
+
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
for (const assignment of assignments) {
|
|
230
|
+
// Send to GTM/GA4
|
|
231
|
+
sendEvent('experiment_impression', {
|
|
232
|
+
experiment_id: assignment.testId,
|
|
233
|
+
experiment_name: assignment.test_label,
|
|
234
|
+
variant_id: assignment.test_path,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// Send to HubSpot (if configured)
|
|
238
|
+
if (assignment.hubspot_event) {
|
|
239
|
+
sendHubspotCustomEvent(assignment.hubspot_event, {
|
|
240
|
+
experiment_name: assignment.test_label,
|
|
241
|
+
experiment_id: assignment.testId,
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}, [assignments]);
|
|
246
|
+
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
**Add to your layout:**
|
|
252
|
+
|
|
253
|
+
```tsx
|
|
254
|
+
// src/app/layout.tsx
|
|
255
|
+
import { AbTestReporter } from '@/components/AbTestReporter';
|
|
256
|
+
|
|
257
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
258
|
+
return (
|
|
259
|
+
<html>
|
|
260
|
+
<body>
|
|
261
|
+
{children}
|
|
262
|
+
<AbTestReporter />
|
|
263
|
+
</body>
|
|
264
|
+
</html>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
## Contentful Content Model
|
|
270
|
+
|
|
271
|
+
### PageTest
|
|
272
|
+
|
|
273
|
+
| Field | Type | Description |
|
|
274
|
+
|-------|------|-------------|
|
|
275
|
+
| cmsLabel | Symbol | Internal label for the test |
|
|
276
|
+
| control | Reference (Page/PageVariant) | The control page to test against |
|
|
277
|
+
| enabled | Boolean | Whether the test is active |
|
|
278
|
+
| trackingLabel | Symbol (optional) | Override for analytics label |
|
|
279
|
+
| searchParameters | Symbol (optional) | URL params to match (e.g., "utm_source=google") |
|
|
280
|
+
| configuration | JSON | Array of `{weight, hubspot_event_name?}` |
|
|
281
|
+
| variants | References (PageVariant[]) | Variants to test |
|
|
282
|
+
|
|
283
|
+
### PageVariant
|
|
284
|
+
|
|
285
|
+
Existing content type that references an original page and defines component swaps.
|
|
286
|
+
|
|
287
|
+
## API Reference
|
|
288
|
+
|
|
289
|
+
### Types
|
|
290
|
+
|
|
291
|
+
```typescript
|
|
292
|
+
interface AbTest {
|
|
293
|
+
id: string;
|
|
294
|
+
cmsLabel: string;
|
|
295
|
+
controlSlug: string;
|
|
296
|
+
searchParameters?: string;
|
|
297
|
+
trackingLabel?: string;
|
|
298
|
+
enabled: boolean;
|
|
299
|
+
configuration: AbTestVariantConfig[];
|
|
300
|
+
variants: AbTestVariant[];
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
interface IBlobStore<T> {
|
|
304
|
+
get(key: string): Promise<T | undefined>;
|
|
305
|
+
set(key: string, value: T): Promise<void>;
|
|
306
|
+
bulkWrite(entries: [string, T][]): Promise<void>;
|
|
307
|
+
size(): Promise<number>;
|
|
308
|
+
values(): Promise<T[]>;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
interface ActiveAbTestAssignment {
|
|
312
|
+
testId: string;
|
|
313
|
+
test_label: string;
|
|
314
|
+
test_path: string;
|
|
315
|
+
hubspot_event?: string;
|
|
316
|
+
original_path?: string;
|
|
317
|
+
}
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
### Middleware
|
|
321
|
+
|
|
322
|
+
```typescript
|
|
323
|
+
import { createAbTestMiddleware } from '@se-studio/ab-testing/middleware';
|
|
324
|
+
|
|
325
|
+
const handler = createAbTestMiddleware({
|
|
326
|
+
getStore: () => store, // Required: blob store factory
|
|
327
|
+
cacheTtlMs: 60000, // Optional: cache TTL (default: 60s)
|
|
328
|
+
cookieName: 'ab-test-info', // Optional: cookie name
|
|
329
|
+
cookieMaxAge: 2592000, // Optional: cookie max age (default: 30 days)
|
|
330
|
+
shouldProcess: (path) => true, // Optional: filter requests
|
|
331
|
+
devTestData: [], // Optional: test data for development
|
|
332
|
+
});
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### Webhook
|
|
336
|
+
|
|
337
|
+
```typescript
|
|
338
|
+
import { createWebhookHandler } from '@se-studio/ab-testing/webhook';
|
|
339
|
+
|
|
340
|
+
export const POST = createWebhookHandler({
|
|
341
|
+
fetchPageTests: () => Promise<RawPageTest[]>, // Required
|
|
342
|
+
getStore: () => store, // Required
|
|
343
|
+
webhookSecret: 'secret', // Optional
|
|
344
|
+
revalidate: () => void, // Optional
|
|
345
|
+
onSkippedTest: (id, reason) => void, // Optional
|
|
346
|
+
});
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### Hook
|
|
350
|
+
|
|
351
|
+
```typescript
|
|
352
|
+
import { useAbTestAssignments } from '@se-studio/ab-testing';
|
|
353
|
+
|
|
354
|
+
// Returns array of assignments for the current page
|
|
355
|
+
const assignments = useAbTestAssignments({
|
|
356
|
+
cookieName: 'ab-test-info', // Optional: custom cookie name
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// Each assignment contains:
|
|
360
|
+
// - testId: string
|
|
361
|
+
// - test_label: string
|
|
362
|
+
// - test_path: string (variant slug or "control")
|
|
363
|
+
// - hubspot_event?: string
|
|
364
|
+
// - original_path?: string
|
|
365
|
+
```
|
|
366
|
+
|
|
367
|
+
## Analytics Integration
|
|
368
|
+
|
|
369
|
+
### Google Tag Manager (GTM)
|
|
370
|
+
|
|
371
|
+
Push events to `window.dataLayer`:
|
|
372
|
+
|
|
373
|
+
```typescript
|
|
374
|
+
window.dataLayer.push({
|
|
375
|
+
event: 'experiment_impression',
|
|
376
|
+
experiment_id: assignment.testId,
|
|
377
|
+
experiment_name: assignment.test_label,
|
|
378
|
+
variant_id: assignment.test_path,
|
|
379
|
+
});
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
### Google Analytics 4 (GA4)
|
|
383
|
+
|
|
384
|
+
Register custom dimensions in GA4:
|
|
385
|
+
1. Go to Admin > Data display > Custom definitions
|
|
386
|
+
2. Create event-scoped dimensions for:
|
|
387
|
+
- `experiment_id`
|
|
388
|
+
- `experiment_name`
|
|
389
|
+
- `variant_id`
|
|
390
|
+
|
|
391
|
+
## License
|
|
392
|
+
|
|
393
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A/B Testing Hooks
|
|
3
|
+
*
|
|
4
|
+
* React hooks for client-side A/B test reporting.
|
|
5
|
+
*/
|
|
6
|
+
export type { ActiveAbTestAssignment, UseAbTestAssignmentsOptions } from './useAbTestAssignments';
|
|
7
|
+
export { useAbTestAssignments } from './useAbTestAssignments';
|
|
8
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,YAAY,EAAE,sBAAsB,EAAE,2BAA2B,EAAE,MAAM,wBAAwB,CAAC;AAClG,OAAO,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,EAAE,oBAAoB,EAAE,MAAM,wBAAwB,CAAC"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { AbTestAssignment } from '../types';
|
|
2
|
+
/**
|
|
3
|
+
* An A/B test assignment with its test ID included.
|
|
4
|
+
*/
|
|
5
|
+
export interface ActiveAbTestAssignment extends AbTestAssignment {
|
|
6
|
+
/** Contentful entry ID of the PageTest */
|
|
7
|
+
testId: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Options for the useAbTestAssignments hook.
|
|
11
|
+
*/
|
|
12
|
+
export interface UseAbTestAssignmentsOptions {
|
|
13
|
+
/**
|
|
14
|
+
* Cookie name to read assignments from.
|
|
15
|
+
* @default "ab-test-info"
|
|
16
|
+
*/
|
|
17
|
+
cookieName?: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Hook that returns A/B test assignments for the current page.
|
|
21
|
+
*
|
|
22
|
+
* This hook reads the A/B test cookie and returns only the assignments
|
|
23
|
+
* that match the current page path. Projects can use this to build
|
|
24
|
+
* their own analytics reporting with project-specific integrations.
|
|
25
|
+
*
|
|
26
|
+
* @param options - Hook configuration options
|
|
27
|
+
* @returns Array of active test assignments for the current page
|
|
28
|
+
*
|
|
29
|
+
* @example Basic usage
|
|
30
|
+
* ```tsx
|
|
31
|
+
* 'use client';
|
|
32
|
+
*
|
|
33
|
+
* import { useEffect } from 'react';
|
|
34
|
+
* import { useAbTestAssignments } from '@se-studio/ab-testing';
|
|
35
|
+
*
|
|
36
|
+
* export function AbTestReporter() {
|
|
37
|
+
* const assignments = useAbTestAssignments();
|
|
38
|
+
*
|
|
39
|
+
* useEffect(() => {
|
|
40
|
+
* for (const assignment of assignments) {
|
|
41
|
+
* // Send to your analytics
|
|
42
|
+
* sendEvent('experiment_impression', {
|
|
43
|
+
* experiment_id: assignment.testId,
|
|
44
|
+
* experiment_name: assignment.test_label,
|
|
45
|
+
* variant_id: assignment.test_path,
|
|
46
|
+
* });
|
|
47
|
+
* }
|
|
48
|
+
* }, [assignments]);
|
|
49
|
+
*
|
|
50
|
+
* return null;
|
|
51
|
+
* }
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export declare function useAbTestAssignments(options?: UseAbTestAssignmentsOptions): ActiveAbTestAssignment[];
|
|
55
|
+
//# sourceMappingURL=useAbTestAssignments.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useAbTestAssignments.d.ts","sourceRoot":"","sources":["../../src/hooks/useAbTestAssignments.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AAEjD;;GAEG;AACH,MAAM,WAAW,sBAAuB,SAAQ,gBAAgB;IAC9D,0CAA0C;IAC1C,MAAM,EAAE,MAAM,CAAC;CAChB;AAED;;GAEG;AACH,MAAM,WAAW,2BAA2B;IAC1C;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAYD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,CAAC,EAAE,2BAA2B,GACpC,sBAAsB,EAAE,CA8C1B"}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
|
+
import { DEFAULT_COOKIE_NAME, readCookieFromDocument } from '../middleware/cookies';
|
|
4
|
+
/**
|
|
5
|
+
* Normalize a path for comparison (remove trailing slash except for root).
|
|
6
|
+
*/
|
|
7
|
+
function normalizePath(path) {
|
|
8
|
+
if (path.endsWith('/') && path.length > 1) {
|
|
9
|
+
return path.slice(0, -1);
|
|
10
|
+
}
|
|
11
|
+
return path;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Hook that returns A/B test assignments for the current page.
|
|
15
|
+
*
|
|
16
|
+
* This hook reads the A/B test cookie and returns only the assignments
|
|
17
|
+
* that match the current page path. Projects can use this to build
|
|
18
|
+
* their own analytics reporting with project-specific integrations.
|
|
19
|
+
*
|
|
20
|
+
* @param options - Hook configuration options
|
|
21
|
+
* @returns Array of active test assignments for the current page
|
|
22
|
+
*
|
|
23
|
+
* @example Basic usage
|
|
24
|
+
* ```tsx
|
|
25
|
+
* 'use client';
|
|
26
|
+
*
|
|
27
|
+
* import { useEffect } from 'react';
|
|
28
|
+
* import { useAbTestAssignments } from '@se-studio/ab-testing';
|
|
29
|
+
*
|
|
30
|
+
* export function AbTestReporter() {
|
|
31
|
+
* const assignments = useAbTestAssignments();
|
|
32
|
+
*
|
|
33
|
+
* useEffect(() => {
|
|
34
|
+
* for (const assignment of assignments) {
|
|
35
|
+
* // Send to your analytics
|
|
36
|
+
* sendEvent('experiment_impression', {
|
|
37
|
+
* experiment_id: assignment.testId,
|
|
38
|
+
* experiment_name: assignment.test_label,
|
|
39
|
+
* variant_id: assignment.test_path,
|
|
40
|
+
* });
|
|
41
|
+
* }
|
|
42
|
+
* }, [assignments]);
|
|
43
|
+
*
|
|
44
|
+
* return null;
|
|
45
|
+
* }
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
export function useAbTestAssignments(options) {
|
|
49
|
+
const cookieName = options?.cookieName ?? DEFAULT_COOKIE_NAME;
|
|
50
|
+
const [assignments, setAssignments] = useState([]);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
try {
|
|
53
|
+
const allAssignments = readCookieFromDocument(document.cookie, cookieName);
|
|
54
|
+
if (Object.keys(allAssignments).length === 0) {
|
|
55
|
+
setAssignments([]);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
// Get and normalize current browser path
|
|
59
|
+
const currentPath = window.location.pathname;
|
|
60
|
+
const normalizedCurrentPath = normalizePath(currentPath);
|
|
61
|
+
// Filter to only assignments for the current page
|
|
62
|
+
const activeAssignments = [];
|
|
63
|
+
for (const [testId, assignment] of Object.entries(allAssignments)) {
|
|
64
|
+
// Skip if original_path is not present
|
|
65
|
+
if (!assignment.original_path) {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
// Skip if not on the original path where the test was assigned
|
|
69
|
+
if (assignment.original_path !== normalizedCurrentPath) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
activeAssignments.push({
|
|
73
|
+
...assignment,
|
|
74
|
+
testId,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
setAssignments(activeAssignments);
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
// biome-ignore lint/suspicious/noConsole: Error logging
|
|
81
|
+
console.error('[useAbTestAssignments] Error reading A/B test cookie', e);
|
|
82
|
+
setAssignments([]);
|
|
83
|
+
}
|
|
84
|
+
}, [cookieName]);
|
|
85
|
+
return assignments;
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=useAbTestAssignments.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"useAbTestAssignments.js","sourceRoot":"","sources":["../../src/hooks/useAbTestAssignments.ts"],"names":[],"mappings":"AAAA,YAAY,CAAC;AAEb,OAAO,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAC5C,OAAO,EAAE,mBAAmB,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AAsBpF;;GAEG;AACH,SAAS,aAAa,CAAC,IAAY;IACjC,IAAI,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;QAC1C,OAAO,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC;IAC3B,CAAC;IACD,OAAO,IAAI,CAAC;AACd,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAkCG;AACH,MAAM,UAAU,oBAAoB,CAClC,OAAqC;IAErC,MAAM,UAAU,GAAG,OAAO,EAAE,UAAU,IAAI,mBAAmB,CAAC;IAC9D,MAAM,CAAC,WAAW,EAAE,cAAc,CAAC,GAAG,QAAQ,CAA2B,EAAE,CAAC,CAAC;IAE7E,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,CAAC;YACH,MAAM,cAAc,GAAG,sBAAsB,CAAC,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;YAE3E,IAAI,MAAM,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC,MAAM,KAAK,CAAC,EAAE,CAAC;gBAC7C,cAAc,CAAC,EAAE,CAAC,CAAC;gBACnB,OAAO;YACT,CAAC;YAED,yCAAyC;YACzC,MAAM,WAAW,GAAG,MAAM,CAAC,QAAQ,CAAC,QAAQ,CAAC;YAC7C,MAAM,qBAAqB,GAAG,aAAa,CAAC,WAAW,CAAC,CAAC;YAEzD,kDAAkD;YAClD,MAAM,iBAAiB,GAA6B,EAAE,CAAC;YAEvD,KAAK,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,IAAI,MAAM,CAAC,OAAO,CAAC,cAAc,CAAC,EAAE,CAAC;gBAClE,uCAAuC;gBACvC,IAAI,CAAC,UAAU,CAAC,aAAa,EAAE,CAAC;oBAC9B,SAAS;gBACX,CAAC;gBAED,+DAA+D;gBAC/D,IAAI,UAAU,CAAC,aAAa,KAAK,qBAAqB,EAAE,CAAC;oBACvD,SAAS;gBACX,CAAC;gBAED,iBAAiB,CAAC,IAAI,CAAC;oBACrB,GAAG,UAAU;oBACb,MAAM;iBACP,CAAC,CAAC;YACL,CAAC;YAED,cAAc,CAAC,iBAAiB,CAAC,CAAC;QACpC,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,wDAAwD;YACxD,OAAO,CAAC,KAAK,CAAC,sDAAsD,EAAE,CAAC,CAAC,CAAC;YACzE,cAAc,CAAC,EAAE,CAAC,CAAC;QACrB,CAAC;IACH,CAAC,EAAE,CAAC,UAAU,CAAC,CAAC,CAAC;IAEjB,OAAO,WAAW,CAAC;AACrB,CAAC"}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @se-studio/ab-testing
|
|
3
|
+
*
|
|
4
|
+
* Server-side A/B testing framework for Next.js applications with Contentful CMS.
|
|
5
|
+
*
|
|
6
|
+
* @packageDocumentation
|
|
7
|
+
*/
|
|
8
|
+
export type { ActiveAbTestAssignment, UseAbTestAssignmentsOptions } from './hooks';
|
|
9
|
+
export { useAbTestAssignments } from './hooks';
|
|
10
|
+
export type { AbTestMiddlewareConfig, AbTestResult, CachedAbTest, TestsCache, } from './middleware';
|
|
11
|
+
export { clearTestCache, createAbTestMiddleware, createAssignment, DEFAULT_COOKIE_MAX_AGE, DEFAULT_COOKIE_NAME, getAssignment, getCachedTests, getTestCacheState, isValidAssignment, normalizePath, parseCookie, processAbTestRequest, readCookieFromDocument, selectVariant, serializeCookie, setAssignment, } from './middleware';
|
|
12
|
+
export type { AbTest, AbTestAssignment, AbTestCookie, AbTestVariant, AbTestVariantConfig, IBlobStore, } from './types';
|
|
13
|
+
export type { RawPageTest, WebhookHandlerConfig, WebhookResult, } from './webhook';
|
|
14
|
+
export { createWebhookHandler, processAbTestWebhook, transformPageTest, } from './webhook';
|
|
15
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,YAAY,EAAE,sBAAsB,EAAE,2BAA2B,EAAE,MAAM,SAAS,CAAC;AAEnF,OAAO,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AAC/C,YAAY,EACV,sBAAsB,EACtB,YAAY,EACZ,YAAY,EACZ,UAAU,GACX,MAAM,cAAc,CAAC;AAEtB,OAAO,EACL,cAAc,EACd,sBAAsB,EACtB,gBAAgB,EAChB,sBAAsB,EACtB,mBAAmB,EACnB,aAAa,EACb,cAAc,EACd,iBAAiB,EACjB,iBAAiB,EACjB,aAAa,EACb,WAAW,EACX,oBAAoB,EACpB,sBAAsB,EACtB,aAAa,EACb,eAAe,EACf,aAAa,GACd,MAAM,cAAc,CAAC;AAEtB,YAAY,EACV,MAAM,EACN,gBAAgB,EAChB,YAAY,EACZ,aAAa,EACb,mBAAmB,EACnB,UAAU,GACX,MAAM,SAAS,CAAC;AACjB,YAAY,EACV,WAAW,EACX,oBAAoB,EACpB,aAAa,GACd,MAAM,WAAW,CAAC;AAEnB,OAAO,EACL,oBAAoB,EACpB,oBAAoB,EACpB,iBAAiB,GAClB,MAAM,WAAW,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @se-studio/ab-testing
|
|
3
|
+
*
|
|
4
|
+
* Server-side A/B testing framework for Next.js applications with Contentful CMS.
|
|
5
|
+
*
|
|
6
|
+
* @packageDocumentation
|
|
7
|
+
*/
|
|
8
|
+
// Re-export hooks
|
|
9
|
+
export { useAbTestAssignments } from './hooks';
|
|
10
|
+
// Re-export middleware utilities
|
|
11
|
+
export { clearTestCache, createAbTestMiddleware, createAssignment, DEFAULT_COOKIE_MAX_AGE, DEFAULT_COOKIE_NAME, getAssignment, getCachedTests, getTestCacheState, isValidAssignment, normalizePath, parseCookie, processAbTestRequest, readCookieFromDocument, selectVariant, serializeCookie, setAssignment, } from './middleware';
|
|
12
|
+
// Re-export webhook utilities
|
|
13
|
+
export { createWebhookHandler, processAbTestWebhook, transformPageTest, } from './webhook';
|
|
14
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAGH,kBAAkB;AAClB,OAAO,EAAE,oBAAoB,EAAE,MAAM,SAAS,CAAC;AAO/C,iCAAiC;AACjC,OAAO,EACL,cAAc,EACd,sBAAsB,EACtB,gBAAgB,EAChB,sBAAsB,EACtB,mBAAmB,EACnB,aAAa,EACb,cAAc,EACd,iBAAiB,EACjB,iBAAiB,EACjB,aAAa,EACb,WAAW,EACX,oBAAoB,EACpB,sBAAsB,EACtB,aAAa,EACb,eAAe,EACf,aAAa,GACd,MAAM,cAAc,CAAC;AAetB,8BAA8B;AAC9B,OAAO,EACL,oBAAoB,EACpB,oBAAoB,EACpB,iBAAiB,GAClB,MAAM,WAAW,CAAC"}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { AbTestAssignment } from '../types';
|
|
2
|
+
import type { CachedAbTest } from './types';
|
|
3
|
+
/**
|
|
4
|
+
* Perform weighted random selection to assign a variant.
|
|
5
|
+
*
|
|
6
|
+
* @param test - The cached test with pre-computed weights
|
|
7
|
+
* @returns Object with selectedKey (variant ID or "control") and variant slug
|
|
8
|
+
*/
|
|
9
|
+
export declare function selectVariant(test: CachedAbTest): {
|
|
10
|
+
selectedKey: string;
|
|
11
|
+
variantSlug: string;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Create an assignment object for a newly selected variant.
|
|
15
|
+
*
|
|
16
|
+
* @param test - The cached test
|
|
17
|
+
* @param selectedKey - The selected variant ID or "control"
|
|
18
|
+
* @param variantSlug - The slug of the selected variant
|
|
19
|
+
* @param originalPath - The original URL path where the test was assigned
|
|
20
|
+
* @returns Assignment object to store in cookie
|
|
21
|
+
*/
|
|
22
|
+
export declare function createAssignment(test: CachedAbTest, selectedKey: string, variantSlug: string, originalPath: string): AbTestAssignment;
|
|
23
|
+
/**
|
|
24
|
+
* Validate an existing cookie assignment against current CMS config.
|
|
25
|
+
* Checks that:
|
|
26
|
+
* - original_path is present
|
|
27
|
+
* - test_label matches current config
|
|
28
|
+
* - test_path (variant) exists and has weight > 0
|
|
29
|
+
* - hubspot_event matches current config
|
|
30
|
+
*
|
|
31
|
+
* @param test - The cached test with current config
|
|
32
|
+
* @param assignment - The assignment from the cookie
|
|
33
|
+
* @returns true if assignment is still valid, false if it should be re-assigned
|
|
34
|
+
*/
|
|
35
|
+
export declare function isValidAssignment(test: CachedAbTest, assignment: AbTestAssignment): boolean;
|
|
36
|
+
//# sourceMappingURL=assignment.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"assignment.d.ts","sourceRoot":"","sources":["../../src/middleware/assignment.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,UAAU,CAAC;AACjD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAC;AAE5C;;;;;GAKG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,YAAY,GAAG;IAAE,WAAW,EAAE,MAAM,CAAC;IAAC,WAAW,EAAE,MAAM,CAAA;CAAE,CAe9F;AAED;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAC9B,IAAI,EAAE,YAAY,EAClB,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,EACnB,YAAY,EAAE,MAAM,GACnB,gBAAgB,CAOlB;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,YAAY,EAAE,UAAU,EAAE,gBAAgB,GAAG,OAAO,CA6C3F"}
|