@prosdevlab/experience-sdk-plugins 0.1.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/package.json +34 -0
- package/src/banner/banner.test.ts +728 -0
- package/src/banner/banner.ts +369 -0
- package/src/banner/index.ts +6 -0
- package/src/debug/debug.test.ts +230 -0
- package/src/debug/debug.ts +106 -0
- package/src/debug/index.ts +6 -0
- package/src/frequency/frequency.test.ts +361 -0
- package/src/frequency/frequency.ts +247 -0
- package/src/frequency/index.ts +6 -0
- package/src/index.ts +22 -0
- package/src/types.ts +92 -0
- package/tsconfig.json +9 -0
- package/tsup.config.ts +14 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
import { SDK } from '@lytics/sdk-kit';
|
|
2
|
+
import { type StoragePlugin, storagePlugin } from '@lytics/sdk-kit-plugins';
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import type { Decision } from '../types';
|
|
5
|
+
import { type FrequencyPlugin, frequencyPlugin } from './frequency';
|
|
6
|
+
|
|
7
|
+
type SDKWithFrequency = SDK & { frequency: FrequencyPlugin; storage: StoragePlugin };
|
|
8
|
+
|
|
9
|
+
describe('Frequency Plugin', () => {
|
|
10
|
+
let sdk: SDKWithFrequency;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
// Clear any existing storage to avoid pollution between tests
|
|
14
|
+
if (typeof sessionStorage !== 'undefined') {
|
|
15
|
+
sessionStorage.clear();
|
|
16
|
+
}
|
|
17
|
+
if (typeof localStorage !== 'undefined') {
|
|
18
|
+
localStorage.clear();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Use memory storage for tests
|
|
22
|
+
sdk = new SDK({
|
|
23
|
+
frequency: { enabled: true },
|
|
24
|
+
storage: { backend: 'memory' },
|
|
25
|
+
}) as SDKWithFrequency;
|
|
26
|
+
|
|
27
|
+
// Install plugins
|
|
28
|
+
sdk.use(storagePlugin);
|
|
29
|
+
sdk.use(frequencyPlugin);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('Plugin Registration', () => {
|
|
33
|
+
it('should register frequency plugin', () => {
|
|
34
|
+
expect(sdk.frequency).toBeDefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('should expose frequency API methods', () => {
|
|
38
|
+
expect(sdk.frequency.getImpressionCount).toBeTypeOf('function');
|
|
39
|
+
expect(sdk.frequency.hasReachedCap).toBeTypeOf('function');
|
|
40
|
+
expect(sdk.frequency.recordImpression).toBeTypeOf('function');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should auto-load storage plugin if not present', () => {
|
|
44
|
+
const newSdk = new SDK({ frequency: { enabled: true } }) as SDKWithFrequency;
|
|
45
|
+
newSdk.use(frequencyPlugin);
|
|
46
|
+
expect(newSdk.storage).toBeDefined();
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('Configuration', () => {
|
|
51
|
+
it('should use default config', () => {
|
|
52
|
+
const enabled = sdk.get('frequency.enabled');
|
|
53
|
+
const namespace = sdk.get('frequency.namespace');
|
|
54
|
+
|
|
55
|
+
expect(enabled).toBe(true);
|
|
56
|
+
expect(namespace).toBe('experiences:frequency');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('should allow custom config', () => {
|
|
60
|
+
const customSdk = new SDK({
|
|
61
|
+
frequency: { enabled: false, namespace: 'custom:freq' },
|
|
62
|
+
storage: { backend: 'memory' },
|
|
63
|
+
}) as SDKWithFrequency;
|
|
64
|
+
|
|
65
|
+
customSdk.use(storagePlugin);
|
|
66
|
+
customSdk.use(frequencyPlugin);
|
|
67
|
+
|
|
68
|
+
expect(customSdk.get('frequency.enabled')).toBe(false);
|
|
69
|
+
expect(customSdk.get('frequency.namespace')).toBe('custom:freq');
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe('Impression Tracking', () => {
|
|
74
|
+
it('should initialize impression count at 0', () => {
|
|
75
|
+
const count = sdk.frequency.getImpressionCount('welcome-banner');
|
|
76
|
+
expect(count).toBe(0);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('should record impressions', () => {
|
|
80
|
+
sdk.frequency.recordImpression('welcome-banner');
|
|
81
|
+
expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(1);
|
|
82
|
+
|
|
83
|
+
sdk.frequency.recordImpression('welcome-banner');
|
|
84
|
+
expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(2);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('should track impressions per experience independently', () => {
|
|
88
|
+
sdk.frequency.recordImpression('banner-1');
|
|
89
|
+
sdk.frequency.recordImpression('banner-2');
|
|
90
|
+
sdk.frequency.recordImpression('banner-1');
|
|
91
|
+
|
|
92
|
+
expect(sdk.frequency.getImpressionCount('banner-1')).toBe(2);
|
|
93
|
+
expect(sdk.frequency.getImpressionCount('banner-2')).toBe(1);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should emit impression-recorded event', () => {
|
|
97
|
+
const handler = vi.fn();
|
|
98
|
+
sdk.on('experiences:impression-recorded', handler);
|
|
99
|
+
|
|
100
|
+
sdk.frequency.recordImpression('welcome-banner');
|
|
101
|
+
|
|
102
|
+
expect(handler).toHaveBeenCalledWith({
|
|
103
|
+
experienceId: 'welcome-banner',
|
|
104
|
+
count: 1,
|
|
105
|
+
timestamp: expect.any(Number),
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should not record impressions when disabled', () => {
|
|
110
|
+
sdk.set('frequency.enabled', false);
|
|
111
|
+
sdk.frequency.recordImpression('welcome-banner');
|
|
112
|
+
expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(0);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('Session Frequency Caps', () => {
|
|
117
|
+
it('should not reach cap below limit', () => {
|
|
118
|
+
sdk.frequency.recordImpression('welcome-banner');
|
|
119
|
+
expect(sdk.frequency.hasReachedCap('welcome-banner', 2, 'session')).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should reach cap at limit', () => {
|
|
123
|
+
sdk.frequency.recordImpression('welcome-banner');
|
|
124
|
+
sdk.frequency.recordImpression('welcome-banner');
|
|
125
|
+
expect(sdk.frequency.hasReachedCap('welcome-banner', 2, 'session')).toBe(true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should reach cap above limit', () => {
|
|
129
|
+
sdk.frequency.recordImpression('welcome-banner');
|
|
130
|
+
sdk.frequency.recordImpression('welcome-banner');
|
|
131
|
+
sdk.frequency.recordImpression('welcome-banner');
|
|
132
|
+
expect(sdk.frequency.hasReachedCap('welcome-banner', 2, 'session')).toBe(true);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe('Time-Based Frequency Caps', () => {
|
|
137
|
+
it('should count impressions within day window', () => {
|
|
138
|
+
const now = Date.now();
|
|
139
|
+
|
|
140
|
+
// Mock Date.now() for first impression (25 hours ago - outside window)
|
|
141
|
+
vi.spyOn(Date, 'now').mockReturnValue(now - 25 * 60 * 60 * 1000);
|
|
142
|
+
sdk.frequency.recordImpression('welcome-banner', 'day');
|
|
143
|
+
|
|
144
|
+
// Mock Date.now() for second impression (now - inside window)
|
|
145
|
+
vi.spyOn(Date, 'now').mockReturnValue(now);
|
|
146
|
+
sdk.frequency.recordImpression('welcome-banner', 'day');
|
|
147
|
+
|
|
148
|
+
// Only 1 impression within last 24 hours
|
|
149
|
+
expect(sdk.frequency.hasReachedCap('welcome-banner', 2, 'day')).toBe(false);
|
|
150
|
+
expect(sdk.frequency.hasReachedCap('welcome-banner', 1, 'day')).toBe(true);
|
|
151
|
+
|
|
152
|
+
vi.restoreAllMocks();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('should count impressions within week window', () => {
|
|
156
|
+
const now = Date.now();
|
|
157
|
+
|
|
158
|
+
// Record 2 impressions 8 days ago (outside week window)
|
|
159
|
+
vi.spyOn(Date, 'now').mockReturnValue(now - 8 * 24 * 60 * 60 * 1000);
|
|
160
|
+
sdk.frequency.recordImpression('welcome-banner', 'week');
|
|
161
|
+
sdk.frequency.recordImpression('welcome-banner', 'week');
|
|
162
|
+
|
|
163
|
+
// Record 1 impression 3 days ago (inside week window)
|
|
164
|
+
vi.spyOn(Date, 'now').mockReturnValue(now - 3 * 24 * 60 * 60 * 1000);
|
|
165
|
+
sdk.frequency.recordImpression('welcome-banner', 'week');
|
|
166
|
+
|
|
167
|
+
// Current time
|
|
168
|
+
vi.spyOn(Date, 'now').mockReturnValue(now);
|
|
169
|
+
|
|
170
|
+
// Only 1 impression within last 7 days
|
|
171
|
+
expect(sdk.frequency.hasReachedCap('welcome-banner', 2, 'week')).toBe(false);
|
|
172
|
+
expect(sdk.frequency.hasReachedCap('welcome-banner', 1, 'week')).toBe(true);
|
|
173
|
+
|
|
174
|
+
vi.restoreAllMocks();
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('should handle multiple impressions within time window', () => {
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
|
|
180
|
+
// Record 3 impressions within last day
|
|
181
|
+
for (let i = 0; i < 3; i++) {
|
|
182
|
+
vi.spyOn(Date, 'now').mockReturnValue(now - i * 60 * 60 * 1000); // Each hour
|
|
183
|
+
sdk.frequency.recordImpression('welcome-banner', 'day');
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
vi.spyOn(Date, 'now').mockReturnValue(now);
|
|
187
|
+
|
|
188
|
+
expect(sdk.frequency.hasReachedCap('welcome-banner', 3, 'day')).toBe(true);
|
|
189
|
+
expect(sdk.frequency.hasReachedCap('welcome-banner', 4, 'day')).toBe(false);
|
|
190
|
+
|
|
191
|
+
vi.restoreAllMocks();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe('Event Integration', () => {
|
|
196
|
+
it('should auto-record impression on experiences:evaluated event when show=true', () => {
|
|
197
|
+
const decision: Decision = {
|
|
198
|
+
show: true,
|
|
199
|
+
experienceId: 'welcome-banner',
|
|
200
|
+
reasons: ['URL matches'],
|
|
201
|
+
trace: [],
|
|
202
|
+
context: {
|
|
203
|
+
url: 'https://example.com',
|
|
204
|
+
timestamp: Date.now(),
|
|
205
|
+
},
|
|
206
|
+
metadata: {
|
|
207
|
+
evaluatedAt: Date.now(),
|
|
208
|
+
totalDuration: 10,
|
|
209
|
+
experiencesEvaluated: 1,
|
|
210
|
+
},
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
sdk.emit('experiences:evaluated', { decision });
|
|
214
|
+
|
|
215
|
+
expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(1);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('should not record impression when show=false', () => {
|
|
219
|
+
const decision: Decision = {
|
|
220
|
+
show: false,
|
|
221
|
+
reasons: ['Frequency cap reached'],
|
|
222
|
+
trace: [],
|
|
223
|
+
context: {
|
|
224
|
+
url: 'https://example.com',
|
|
225
|
+
timestamp: Date.now(),
|
|
226
|
+
},
|
|
227
|
+
metadata: {
|
|
228
|
+
evaluatedAt: Date.now(),
|
|
229
|
+
totalDuration: 10,
|
|
230
|
+
experiencesEvaluated: 1,
|
|
231
|
+
},
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
sdk.emit('experiences:evaluated', { decision });
|
|
235
|
+
|
|
236
|
+
expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(0);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('should not record impression when experienceId is missing', () => {
|
|
240
|
+
const decision: Decision = {
|
|
241
|
+
show: true,
|
|
242
|
+
reasons: ['No matching experience'],
|
|
243
|
+
trace: [],
|
|
244
|
+
context: {
|
|
245
|
+
url: 'https://example.com',
|
|
246
|
+
timestamp: Date.now(),
|
|
247
|
+
},
|
|
248
|
+
metadata: {
|
|
249
|
+
evaluatedAt: Date.now(),
|
|
250
|
+
totalDuration: 10,
|
|
251
|
+
experiencesEvaluated: 0,
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
sdk.emit('experiences:evaluated', { decision });
|
|
256
|
+
|
|
257
|
+
// Should not throw or record
|
|
258
|
+
expect(sdk.frequency.getImpressionCount('any-experience')).toBe(0);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('should not auto-record when frequency plugin is disabled', () => {
|
|
262
|
+
sdk.set('frequency.enabled', false);
|
|
263
|
+
|
|
264
|
+
const decision: Decision = {
|
|
265
|
+
show: true,
|
|
266
|
+
experienceId: 'welcome-banner',
|
|
267
|
+
reasons: ['URL matches'],
|
|
268
|
+
trace: [],
|
|
269
|
+
context: {
|
|
270
|
+
url: 'https://example.com',
|
|
271
|
+
timestamp: Date.now(),
|
|
272
|
+
},
|
|
273
|
+
metadata: {
|
|
274
|
+
evaluatedAt: Date.now(),
|
|
275
|
+
totalDuration: 10,
|
|
276
|
+
experiencesEvaluated: 1,
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
sdk.emit('experiences:evaluated', { decision });
|
|
281
|
+
|
|
282
|
+
expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(0);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe('Storage Integration', () => {
|
|
287
|
+
it('should persist impressions across SDK instances (session storage)', () => {
|
|
288
|
+
// Record impression in first instance (uses sessionStorage)
|
|
289
|
+
sdk.frequency.recordImpression('welcome-banner');
|
|
290
|
+
expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(1);
|
|
291
|
+
|
|
292
|
+
// Create second instance with same storage backend
|
|
293
|
+
const sdk2 = new SDK({
|
|
294
|
+
frequency: { enabled: true },
|
|
295
|
+
storage: { backend: 'memory' },
|
|
296
|
+
}) as SDKWithFrequency;
|
|
297
|
+
sdk2.use(storagePlugin);
|
|
298
|
+
sdk2.use(frequencyPlugin);
|
|
299
|
+
|
|
300
|
+
// Impressions SHOULD persist (sessionStorage is shared)
|
|
301
|
+
expect(sdk2.frequency.getImpressionCount('welcome-banner')).toBe(1);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('should use namespaced storage keys', () => {
|
|
305
|
+
sdk.frequency.recordImpression('welcome-banner');
|
|
306
|
+
|
|
307
|
+
// Check sessionStorage directly (frequency plugin uses sessionStorage for 'session' per)
|
|
308
|
+
const storageKey = 'experiences:frequency:welcome-banner';
|
|
309
|
+
const storageData = JSON.parse(sessionStorage.getItem(storageKey) || '{}');
|
|
310
|
+
expect(storageData).toBeDefined();
|
|
311
|
+
expect(storageData.count).toBe(1);
|
|
312
|
+
});
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
describe('Edge Cases', () => {
|
|
316
|
+
it('should handle empty experience ID gracefully', () => {
|
|
317
|
+
expect(() => sdk.frequency.recordImpression('')).not.toThrow();
|
|
318
|
+
expect(sdk.frequency.getImpressionCount('')).toBe(1);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should handle very large impression counts', () => {
|
|
322
|
+
for (let i = 0; i < 1000; i++) {
|
|
323
|
+
sdk.frequency.recordImpression('welcome-banner');
|
|
324
|
+
}
|
|
325
|
+
expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(1000);
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('should clean up old impressions (beyond 7 days)', () => {
|
|
329
|
+
const now = Date.now();
|
|
330
|
+
|
|
331
|
+
// Record 5 impressions: 3 old (>7 days), 2 recent
|
|
332
|
+
vi.spyOn(Date, 'now').mockReturnValue(now - 10 * 24 * 60 * 60 * 1000);
|
|
333
|
+
sdk.frequency.recordImpression('welcome-banner');
|
|
334
|
+
|
|
335
|
+
vi.spyOn(Date, 'now').mockReturnValue(now - 8 * 24 * 60 * 60 * 1000);
|
|
336
|
+
sdk.frequency.recordImpression('welcome-banner');
|
|
337
|
+
|
|
338
|
+
vi.spyOn(Date, 'now').mockReturnValue(now - 9 * 24 * 60 * 60 * 1000);
|
|
339
|
+
sdk.frequency.recordImpression('welcome-banner');
|
|
340
|
+
|
|
341
|
+
vi.spyOn(Date, 'now').mockReturnValue(now - 2 * 24 * 60 * 60 * 1000);
|
|
342
|
+
sdk.frequency.recordImpression('welcome-banner');
|
|
343
|
+
|
|
344
|
+
vi.spyOn(Date, 'now').mockReturnValue(now);
|
|
345
|
+
sdk.frequency.recordImpression('welcome-banner');
|
|
346
|
+
|
|
347
|
+
// Total count should be 5, but old impressions cleaned up
|
|
348
|
+
expect(sdk.frequency.getImpressionCount('welcome-banner')).toBe(5);
|
|
349
|
+
|
|
350
|
+
// Check storage for cleaned impressions array
|
|
351
|
+
const storageKey = 'experiences:frequency:welcome-banner';
|
|
352
|
+
const storageData = JSON.parse(sessionStorage.getItem(storageKey) || '{}') as {
|
|
353
|
+
impressions: number[];
|
|
354
|
+
};
|
|
355
|
+
// Should only keep impressions from last 7 days (2 recent ones)
|
|
356
|
+
expect(storageData.impressions.length).toBe(2);
|
|
357
|
+
|
|
358
|
+
vi.restoreAllMocks();
|
|
359
|
+
});
|
|
360
|
+
});
|
|
361
|
+
});
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Frequency Capping Plugin
|
|
3
|
+
*
|
|
4
|
+
* Tracks experience impressions and enforces frequency caps.
|
|
5
|
+
* Uses sdk-kit's storage plugin for persistence.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PluginFunction, SDK } from '@lytics/sdk-kit';
|
|
9
|
+
import { type StoragePlugin, storagePlugin } from '@lytics/sdk-kit-plugins';
|
|
10
|
+
import type { Decision, TraceStep } from '../types';
|
|
11
|
+
|
|
12
|
+
export interface FrequencyPluginConfig {
|
|
13
|
+
frequency?: {
|
|
14
|
+
enabled?: boolean;
|
|
15
|
+
namespace?: string;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FrequencyPlugin {
|
|
20
|
+
getImpressionCount(experienceId: string): number;
|
|
21
|
+
hasReachedCap(experienceId: string, max: number, per: 'session' | 'day' | 'week'): boolean;
|
|
22
|
+
recordImpression(experienceId: string): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ImpressionData {
|
|
26
|
+
count: number;
|
|
27
|
+
lastImpression: number;
|
|
28
|
+
impressions: number[];
|
|
29
|
+
per?: 'session' | 'day' | 'week'; // Track which storage type this uses
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Frequency Capping Plugin
|
|
34
|
+
*
|
|
35
|
+
* Automatically tracks impressions and enforces frequency caps.
|
|
36
|
+
* Requires storage plugin for persistence.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```typescript
|
|
40
|
+
* import { createInstance } from '@prosdevlab/experience-sdk';
|
|
41
|
+
* import { frequencyPlugin } from '@prosdevlab/experience-sdk-plugins';
|
|
42
|
+
*
|
|
43
|
+
* const sdk = createInstance({ frequency: { enabled: true } });
|
|
44
|
+
* sdk.use(frequencyPlugin);
|
|
45
|
+
* ```
|
|
46
|
+
*/
|
|
47
|
+
export const frequencyPlugin: PluginFunction = (plugin, instance, config) => {
|
|
48
|
+
plugin.ns('frequency');
|
|
49
|
+
|
|
50
|
+
// Set defaults
|
|
51
|
+
plugin.defaults({
|
|
52
|
+
frequency: {
|
|
53
|
+
enabled: true,
|
|
54
|
+
namespace: 'experiences:frequency',
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// Track experience frequency configs
|
|
59
|
+
const experienceFrequencyMap = new Map<string, 'session' | 'day' | 'week'>();
|
|
60
|
+
|
|
61
|
+
// Auto-load storage plugin if not already loaded
|
|
62
|
+
if (!(instance as SDK & { storage?: StoragePlugin }).storage) {
|
|
63
|
+
instance.use(storagePlugin);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const isEnabled = (): boolean => config.get('frequency.enabled') ?? true;
|
|
67
|
+
const getNamespace = (): string => config.get('frequency.namespace') ?? 'experiences:frequency';
|
|
68
|
+
|
|
69
|
+
// Helper to get the right storage backend based on frequency type
|
|
70
|
+
const getStorageBackend = (per: 'session' | 'day' | 'week'): Storage => {
|
|
71
|
+
return per === 'session' ? sessionStorage : localStorage;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// Helper to get storage key
|
|
75
|
+
const getStorageKey = (experienceId: string): string => {
|
|
76
|
+
return `${getNamespace()}:${experienceId}`;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
// Helper to get impression data
|
|
80
|
+
const getImpressionData = (
|
|
81
|
+
experienceId: string,
|
|
82
|
+
per: 'session' | 'day' | 'week'
|
|
83
|
+
): ImpressionData => {
|
|
84
|
+
const storage = getStorageBackend(per);
|
|
85
|
+
const key = getStorageKey(experienceId);
|
|
86
|
+
const raw = storage.getItem(key);
|
|
87
|
+
|
|
88
|
+
if (!raw) {
|
|
89
|
+
return {
|
|
90
|
+
count: 0,
|
|
91
|
+
lastImpression: 0,
|
|
92
|
+
impressions: [],
|
|
93
|
+
per,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
return JSON.parse(raw) as ImpressionData;
|
|
99
|
+
} catch {
|
|
100
|
+
return {
|
|
101
|
+
count: 0,
|
|
102
|
+
lastImpression: 0,
|
|
103
|
+
impressions: [],
|
|
104
|
+
per,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// Helper to save impression data
|
|
110
|
+
const saveImpressionData = (experienceId: string, data: ImpressionData): void => {
|
|
111
|
+
const per = data.per || 'session'; // Default to session if not specified
|
|
112
|
+
const storage = getStorageBackend(per);
|
|
113
|
+
const key = getStorageKey(experienceId);
|
|
114
|
+
storage.setItem(key, JSON.stringify(data));
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Get time window in milliseconds
|
|
118
|
+
const getTimeWindow = (per: 'session' | 'day' | 'week'): number => {
|
|
119
|
+
switch (per) {
|
|
120
|
+
case 'session':
|
|
121
|
+
return Number.POSITIVE_INFINITY; // Session storage handles this
|
|
122
|
+
case 'day':
|
|
123
|
+
return 24 * 60 * 60 * 1000; // 24 hours
|
|
124
|
+
case 'week':
|
|
125
|
+
return 7 * 24 * 60 * 60 * 1000; // 7 days
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get impression count for an experience
|
|
131
|
+
*/
|
|
132
|
+
const getImpressionCount = (
|
|
133
|
+
experienceId: string,
|
|
134
|
+
per: 'session' | 'day' | 'week' = 'session'
|
|
135
|
+
): number => {
|
|
136
|
+
if (!isEnabled()) return 0;
|
|
137
|
+
const data = getImpressionData(experienceId, per);
|
|
138
|
+
return data.count;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Check if an experience has reached its frequency cap
|
|
143
|
+
*/
|
|
144
|
+
const hasReachedCap = (
|
|
145
|
+
experienceId: string,
|
|
146
|
+
max: number,
|
|
147
|
+
per: 'session' | 'day' | 'week'
|
|
148
|
+
): boolean => {
|
|
149
|
+
if (!isEnabled()) return false;
|
|
150
|
+
|
|
151
|
+
const data = getImpressionData(experienceId, per);
|
|
152
|
+
const timeWindow = getTimeWindow(per);
|
|
153
|
+
const now = Date.now();
|
|
154
|
+
|
|
155
|
+
// For session caps, just check total count
|
|
156
|
+
if (per === 'session') {
|
|
157
|
+
return data.count >= max;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// For time-based caps, count impressions within the window
|
|
161
|
+
const recentImpressions = data.impressions.filter((timestamp) => now - timestamp < timeWindow);
|
|
162
|
+
|
|
163
|
+
return recentImpressions.length >= max;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Record an impression for an experience
|
|
168
|
+
*/
|
|
169
|
+
const recordImpression = (
|
|
170
|
+
experienceId: string,
|
|
171
|
+
per: 'session' | 'day' | 'week' = 'session'
|
|
172
|
+
): void => {
|
|
173
|
+
if (!isEnabled()) return;
|
|
174
|
+
|
|
175
|
+
const data = getImpressionData(experienceId, per);
|
|
176
|
+
const now = Date.now();
|
|
177
|
+
|
|
178
|
+
// Update count and add timestamp
|
|
179
|
+
data.count += 1;
|
|
180
|
+
data.lastImpression = now;
|
|
181
|
+
data.impressions.push(now);
|
|
182
|
+
data.per = per; // Store the frequency type
|
|
183
|
+
|
|
184
|
+
// Keep only recent impressions (last 7 days)
|
|
185
|
+
const sevenDaysAgo = now - 7 * 24 * 60 * 60 * 1000;
|
|
186
|
+
data.impressions = data.impressions.filter((ts) => ts > sevenDaysAgo);
|
|
187
|
+
|
|
188
|
+
// Save updated data
|
|
189
|
+
saveImpressionData(experienceId, data);
|
|
190
|
+
|
|
191
|
+
// Emit event
|
|
192
|
+
instance.emit('experiences:impression-recorded', {
|
|
193
|
+
experienceId,
|
|
194
|
+
count: data.count,
|
|
195
|
+
timestamp: now,
|
|
196
|
+
});
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
// Expose frequency API
|
|
200
|
+
plugin.expose({
|
|
201
|
+
frequency: {
|
|
202
|
+
getImpressionCount,
|
|
203
|
+
hasReachedCap,
|
|
204
|
+
recordImpression,
|
|
205
|
+
// Internal method to register experience frequency config
|
|
206
|
+
_registerExperience: (experienceId: string, per: 'session' | 'day' | 'week') => {
|
|
207
|
+
experienceFrequencyMap.set(experienceId, per);
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
// Listen to evaluation events and record impressions
|
|
213
|
+
if (isEnabled()) {
|
|
214
|
+
instance.on('experiences:evaluated', (payload: unknown) => {
|
|
215
|
+
// Handle both single decision and array of decisions
|
|
216
|
+
// evaluate() emits: { decision, experience }
|
|
217
|
+
// evaluateAll() emits: [{ decision, experience }, ...]
|
|
218
|
+
const items = Array.isArray(payload) ? payload : [payload];
|
|
219
|
+
|
|
220
|
+
for (const item of items) {
|
|
221
|
+
// Item is { decision, experience }
|
|
222
|
+
const decision = (item as { decision?: Decision }).decision;
|
|
223
|
+
|
|
224
|
+
// Only record if experience was shown
|
|
225
|
+
if (decision?.show && decision.experienceId) {
|
|
226
|
+
// Try to get the 'per' value from our map, fall back to checking the input in trace
|
|
227
|
+
let per: 'session' | 'day' | 'week' =
|
|
228
|
+
experienceFrequencyMap.get(decision.experienceId) || 'session';
|
|
229
|
+
|
|
230
|
+
// If not in map, try to infer from the decision trace
|
|
231
|
+
if (!experienceFrequencyMap.has(decision.experienceId)) {
|
|
232
|
+
const freqStep = decision.trace.find(
|
|
233
|
+
(t: TraceStep) => t.step === 'check-frequency-cap'
|
|
234
|
+
);
|
|
235
|
+
if (freqStep?.input && typeof freqStep.input === 'object' && 'per' in freqStep.input) {
|
|
236
|
+
per = (freqStep.input as { per: 'session' | 'day' | 'week' }).per;
|
|
237
|
+
// Cache it for next time
|
|
238
|
+
experienceFrequencyMap.set(decision.experienceId, per);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
recordImpression(decision.experienceId, per);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Experience SDK Plugins
|
|
3
|
+
*
|
|
4
|
+
* Official plugins for Experience SDK
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export * from './banner';
|
|
8
|
+
|
|
9
|
+
// Export plugins
|
|
10
|
+
export * from './debug';
|
|
11
|
+
export * from './frequency';
|
|
12
|
+
// Export shared types
|
|
13
|
+
export type {
|
|
14
|
+
BannerContent,
|
|
15
|
+
Decision,
|
|
16
|
+
DecisionMetadata,
|
|
17
|
+
Experience,
|
|
18
|
+
ExperienceContent,
|
|
19
|
+
ModalContent,
|
|
20
|
+
TooltipContent,
|
|
21
|
+
TraceStep,
|
|
22
|
+
} from './types';
|