@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,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Banner Plugin
|
|
3
|
+
*
|
|
4
|
+
* Renders banner experiences at the top or bottom of the page.
|
|
5
|
+
* Auto-shows banners when experiences are evaluated.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PluginFunction } from '@lytics/sdk-kit';
|
|
9
|
+
import type { BannerContent, Decision, Experience } from '../types';
|
|
10
|
+
|
|
11
|
+
export interface BannerPluginConfig {
|
|
12
|
+
banner?: {
|
|
13
|
+
position?: 'top' | 'bottom';
|
|
14
|
+
dismissable?: boolean;
|
|
15
|
+
zIndex?: number;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface BannerPlugin {
|
|
20
|
+
show(experience: Experience): void;
|
|
21
|
+
remove(): void;
|
|
22
|
+
isShowing(): boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Banner Plugin
|
|
27
|
+
*
|
|
28
|
+
* Automatically renders banner experiences when they are evaluated.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```typescript
|
|
32
|
+
* import { createInstance } from '@prosdevlab/experience-sdk';
|
|
33
|
+
* import { bannerPlugin } from '@prosdevlab/experience-sdk-plugins';
|
|
34
|
+
*
|
|
35
|
+
* const sdk = createInstance({ banner: { position: 'top', dismissable: true } });
|
|
36
|
+
* sdk.use(bannerPlugin);
|
|
37
|
+
* ```
|
|
38
|
+
*/
|
|
39
|
+
export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
|
|
40
|
+
plugin.ns('banner');
|
|
41
|
+
|
|
42
|
+
// Set defaults
|
|
43
|
+
plugin.defaults({
|
|
44
|
+
banner: {
|
|
45
|
+
position: 'top',
|
|
46
|
+
dismissable: true,
|
|
47
|
+
zIndex: 10000,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Track multiple active banners by experience ID
|
|
52
|
+
const activeBanners = new Map<string, HTMLElement>();
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Create banner DOM element
|
|
56
|
+
*/
|
|
57
|
+
function createBannerElement(experience: Experience): HTMLElement {
|
|
58
|
+
const content = experience.content as BannerContent;
|
|
59
|
+
// Allow per-experience position override, fall back to global config
|
|
60
|
+
const position = content.position ?? config.get('banner.position') ?? 'top';
|
|
61
|
+
const dismissable = content.dismissable ?? config.get('banner.dismissable') ?? true;
|
|
62
|
+
const zIndex = config.get('banner.zIndex') ?? 10000;
|
|
63
|
+
|
|
64
|
+
// Detect dark mode
|
|
65
|
+
const isDarkMode = document.documentElement.classList.contains('dark');
|
|
66
|
+
|
|
67
|
+
// Theme-aware colors - professional subtle style
|
|
68
|
+
const bgColor = isDarkMode ? '#1f2937' : '#f9fafb';
|
|
69
|
+
const textColor = isDarkMode ? '#f3f4f6' : '#111827';
|
|
70
|
+
const borderColor = isDarkMode ? '#374151' : '#e5e7eb';
|
|
71
|
+
const shadowColor = isDarkMode ? 'rgba(0, 0, 0, 0.3)' : 'rgba(0, 0, 0, 0.05)';
|
|
72
|
+
|
|
73
|
+
// Create banner container
|
|
74
|
+
const banner = document.createElement('div');
|
|
75
|
+
banner.setAttribute('data-experience-id', experience.id);
|
|
76
|
+
|
|
77
|
+
// Add responsive media query styles
|
|
78
|
+
const styleId = `banner-responsive-${experience.id}`;
|
|
79
|
+
if (!document.getElementById(styleId)) {
|
|
80
|
+
const style = document.createElement('style');
|
|
81
|
+
style.id = styleId;
|
|
82
|
+
style.textContent = `
|
|
83
|
+
@media (max-width: 640px) {
|
|
84
|
+
[data-experience-id="${experience.id}"] {
|
|
85
|
+
flex-direction: column !important;
|
|
86
|
+
align-items: flex-start !important;
|
|
87
|
+
}
|
|
88
|
+
[data-experience-id="${experience.id}"] > div:last-child {
|
|
89
|
+
width: 100%;
|
|
90
|
+
flex-direction: column !important;
|
|
91
|
+
}
|
|
92
|
+
[data-experience-id="${experience.id}"] button {
|
|
93
|
+
width: 100%;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
`;
|
|
97
|
+
document.head.appendChild(style);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
banner.style.cssText = `
|
|
101
|
+
position: fixed;
|
|
102
|
+
${position}: 0;
|
|
103
|
+
left: 0;
|
|
104
|
+
right: 0;
|
|
105
|
+
background: ${bgColor};
|
|
106
|
+
color: ${textColor};
|
|
107
|
+
padding: 16px 20px;
|
|
108
|
+
border-${position === 'top' ? 'bottom' : 'top'}: 1px solid ${borderColor};
|
|
109
|
+
box-shadow: 0 ${position === 'top' ? '1' : '-1'}px 3px 0 ${shadowColor};
|
|
110
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
111
|
+
font-size: 14px;
|
|
112
|
+
line-height: 1.5;
|
|
113
|
+
z-index: ${zIndex};
|
|
114
|
+
display: flex;
|
|
115
|
+
align-items: center;
|
|
116
|
+
justify-content: space-between;
|
|
117
|
+
box-sizing: border-box;
|
|
118
|
+
`;
|
|
119
|
+
|
|
120
|
+
// Create content container
|
|
121
|
+
const contentDiv = document.createElement('div');
|
|
122
|
+
contentDiv.style.cssText = 'flex: 1; margin-right: 20px;';
|
|
123
|
+
|
|
124
|
+
// Add title if present
|
|
125
|
+
if (content.title) {
|
|
126
|
+
const title = document.createElement('div');
|
|
127
|
+
title.textContent = content.title;
|
|
128
|
+
title.style.cssText = 'font-weight: 600; margin-bottom: 4px;';
|
|
129
|
+
contentDiv.appendChild(title);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Add message
|
|
133
|
+
const message = document.createElement('div');
|
|
134
|
+
message.textContent = content.message;
|
|
135
|
+
contentDiv.appendChild(message);
|
|
136
|
+
|
|
137
|
+
banner.appendChild(contentDiv);
|
|
138
|
+
|
|
139
|
+
// Create button container for actions and/or dismiss
|
|
140
|
+
const buttonContainer = document.createElement('div');
|
|
141
|
+
buttonContainer.style.cssText = `
|
|
142
|
+
display: flex;
|
|
143
|
+
align-items: center;
|
|
144
|
+
gap: 12px;
|
|
145
|
+
flex-wrap: wrap;
|
|
146
|
+
`;
|
|
147
|
+
|
|
148
|
+
// Helper function to create button with variant styling
|
|
149
|
+
function createButton(buttonConfig: {
|
|
150
|
+
text: string;
|
|
151
|
+
action?: string;
|
|
152
|
+
url?: string;
|
|
153
|
+
variant?: 'primary' | 'secondary' | 'link';
|
|
154
|
+
metadata?: Record<string, unknown>;
|
|
155
|
+
}): HTMLButtonElement {
|
|
156
|
+
const button = document.createElement('button');
|
|
157
|
+
button.textContent = buttonConfig.text;
|
|
158
|
+
|
|
159
|
+
const variant = buttonConfig.variant || 'primary';
|
|
160
|
+
|
|
161
|
+
// Variant-based styling
|
|
162
|
+
let bg: string, hoverBg: string, textColor: string, border: string;
|
|
163
|
+
|
|
164
|
+
if (variant === 'primary') {
|
|
165
|
+
bg = isDarkMode ? '#3b82f6' : '#2563eb';
|
|
166
|
+
hoverBg = isDarkMode ? '#2563eb' : '#1d4ed8';
|
|
167
|
+
textColor = '#ffffff';
|
|
168
|
+
border = 'none';
|
|
169
|
+
} else if (variant === 'secondary') {
|
|
170
|
+
bg = isDarkMode ? '#374151' : '#ffffff';
|
|
171
|
+
hoverBg = isDarkMode ? '#4b5563' : '#f9fafb';
|
|
172
|
+
textColor = isDarkMode ? '#f3f4f6' : '#374151';
|
|
173
|
+
border = isDarkMode ? '1px solid #4b5563' : '1px solid #d1d5db';
|
|
174
|
+
} else {
|
|
175
|
+
// 'link'
|
|
176
|
+
bg = 'transparent';
|
|
177
|
+
hoverBg = isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.05)';
|
|
178
|
+
textColor = isDarkMode ? '#93c5fd' : '#2563eb';
|
|
179
|
+
border = 'none';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
button.style.cssText = `
|
|
183
|
+
background: ${bg};
|
|
184
|
+
border: ${border};
|
|
185
|
+
color: ${textColor};
|
|
186
|
+
padding: ${variant === 'link' ? '4px 8px' : '8px 16px'};
|
|
187
|
+
font-size: 14px;
|
|
188
|
+
font-weight: ${variant === 'link' ? '400' : '500'};
|
|
189
|
+
border-radius: 6px;
|
|
190
|
+
cursor: pointer;
|
|
191
|
+
transition: all 0.2s;
|
|
192
|
+
text-decoration: ${variant === 'link' ? 'underline' : 'none'};
|
|
193
|
+
`;
|
|
194
|
+
|
|
195
|
+
button.addEventListener('mouseenter', () => {
|
|
196
|
+
button.style.background = hoverBg;
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
button.addEventListener('mouseleave', () => {
|
|
200
|
+
button.style.background = bg;
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
button.addEventListener('click', () => {
|
|
204
|
+
// Emit action event
|
|
205
|
+
instance.emit('experiences:action', {
|
|
206
|
+
experienceId: experience.id,
|
|
207
|
+
type: 'banner',
|
|
208
|
+
action: buttonConfig.action,
|
|
209
|
+
url: buttonConfig.url,
|
|
210
|
+
metadata: buttonConfig.metadata,
|
|
211
|
+
variant: variant,
|
|
212
|
+
timestamp: Date.now(),
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// Navigate if URL provided
|
|
216
|
+
if (buttonConfig.url) {
|
|
217
|
+
window.location.href = buttonConfig.url;
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
return button;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Add buttons from buttons array
|
|
225
|
+
if (content.buttons && content.buttons.length > 0) {
|
|
226
|
+
content.buttons.forEach((buttonConfig) => {
|
|
227
|
+
const button = createButton(buttonConfig);
|
|
228
|
+
buttonContainer.appendChild(button);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Add dismiss button if dismissable
|
|
233
|
+
if (dismissable) {
|
|
234
|
+
const closeButton = document.createElement('button');
|
|
235
|
+
closeButton.innerHTML = '×';
|
|
236
|
+
closeButton.setAttribute('aria-label', 'Close banner');
|
|
237
|
+
|
|
238
|
+
const closeColor = isDarkMode ? '#9ca3af' : '#6b7280';
|
|
239
|
+
|
|
240
|
+
closeButton.style.cssText = `
|
|
241
|
+
background: transparent;
|
|
242
|
+
border: none;
|
|
243
|
+
color: ${closeColor};
|
|
244
|
+
font-size: 24px;
|
|
245
|
+
line-height: 1;
|
|
246
|
+
cursor: pointer;
|
|
247
|
+
padding: 0;
|
|
248
|
+
margin: 0;
|
|
249
|
+
opacity: 0.7;
|
|
250
|
+
transition: opacity 0.2s;
|
|
251
|
+
`;
|
|
252
|
+
|
|
253
|
+
closeButton.addEventListener('mouseenter', () => {
|
|
254
|
+
closeButton.style.opacity = '1';
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
closeButton.addEventListener('mouseleave', () => {
|
|
258
|
+
closeButton.style.opacity = '0.7';
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
closeButton.addEventListener('click', () => {
|
|
262
|
+
remove(experience.id);
|
|
263
|
+
instance.emit('experiences:dismissed', {
|
|
264
|
+
experienceId: experience.id,
|
|
265
|
+
type: 'banner',
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
buttonContainer.appendChild(closeButton);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
banner.appendChild(buttonContainer);
|
|
273
|
+
|
|
274
|
+
return banner;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Show a banner experience
|
|
279
|
+
*/
|
|
280
|
+
function show(experience: Experience): void {
|
|
281
|
+
// If banner already showing for this experience, skip
|
|
282
|
+
if (activeBanners.has(experience.id)) {
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Only show if we're in a browser environment
|
|
287
|
+
if (typeof document === 'undefined') {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const banner = createBannerElement(experience);
|
|
292
|
+
document.body.appendChild(banner);
|
|
293
|
+
activeBanners.set(experience.id, banner);
|
|
294
|
+
|
|
295
|
+
instance.emit('experiences:shown', {
|
|
296
|
+
experienceId: experience.id,
|
|
297
|
+
type: 'banner',
|
|
298
|
+
timestamp: Date.now(),
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Remove a banner by experience ID (or all if no ID provided)
|
|
304
|
+
*/
|
|
305
|
+
function remove(experienceId?: string): void {
|
|
306
|
+
if (experienceId) {
|
|
307
|
+
// Remove specific banner
|
|
308
|
+
const banner = activeBanners.get(experienceId);
|
|
309
|
+
if (banner?.parentNode) {
|
|
310
|
+
banner.parentNode.removeChild(banner);
|
|
311
|
+
}
|
|
312
|
+
activeBanners.delete(experienceId);
|
|
313
|
+
} else {
|
|
314
|
+
// Remove all banners
|
|
315
|
+
for (const [id, banner] of activeBanners.entries()) {
|
|
316
|
+
if (banner?.parentNode) {
|
|
317
|
+
banner.parentNode.removeChild(banner);
|
|
318
|
+
}
|
|
319
|
+
activeBanners.delete(id);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Check if any banner is currently showing
|
|
326
|
+
*/
|
|
327
|
+
function isShowing(): boolean {
|
|
328
|
+
return activeBanners.size > 0;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Expose banner API
|
|
332
|
+
plugin.expose({
|
|
333
|
+
banner: {
|
|
334
|
+
show,
|
|
335
|
+
remove,
|
|
336
|
+
isShowing,
|
|
337
|
+
},
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
// Auto-show banner on experiences:evaluated event
|
|
341
|
+
instance.on('experiences:evaluated', (payload: unknown) => {
|
|
342
|
+
// Handle both single decision and array of decisions
|
|
343
|
+
// evaluate() emits: { decision, experience }
|
|
344
|
+
// evaluateAll() emits: [{ decision, experience }, ...]
|
|
345
|
+
const items = Array.isArray(payload) ? payload : [payload];
|
|
346
|
+
|
|
347
|
+
for (const item of items) {
|
|
348
|
+
// Item is { decision, experience }
|
|
349
|
+
const typedItem = item as { decision?: Decision; experience?: Experience };
|
|
350
|
+
const decision = typedItem.decision;
|
|
351
|
+
const experience = typedItem.experience;
|
|
352
|
+
|
|
353
|
+
// Only handle banner-type experiences
|
|
354
|
+
if (experience?.type === 'banner') {
|
|
355
|
+
if (decision?.show) {
|
|
356
|
+
show(experience);
|
|
357
|
+
} else if (experience.id && activeBanners.has(experience.id)) {
|
|
358
|
+
// Hide specific banner if decision says don't show
|
|
359
|
+
remove(experience.id);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
// Cleanup on destroy
|
|
366
|
+
instance.on('sdk:destroy', () => {
|
|
367
|
+
remove();
|
|
368
|
+
});
|
|
369
|
+
};
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { SDK } from '@lytics/sdk-kit';
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { type DebugPlugin, debugPlugin } from './debug';
|
|
4
|
+
|
|
5
|
+
describe('Debug Plugin', () => {
|
|
6
|
+
let sdk: SDK & { debug: DebugPlugin };
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
sdk = new SDK({ debug: { enabled: true, console: true, window: true } }) as SDK & {
|
|
10
|
+
debug: DebugPlugin;
|
|
11
|
+
};
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
describe('Plugin Registration', () => {
|
|
15
|
+
it('should register without errors', () => {
|
|
16
|
+
expect(() => sdk.use(debugPlugin)).not.toThrow();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should expose debug API', () => {
|
|
20
|
+
sdk.use(debugPlugin);
|
|
21
|
+
|
|
22
|
+
expect(sdk.debug).toBeDefined();
|
|
23
|
+
expect(sdk.debug.log).toBeTypeOf('function');
|
|
24
|
+
expect(sdk.debug.isEnabled).toBeTypeOf('function');
|
|
25
|
+
});
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
describe('Configuration', () => {
|
|
29
|
+
it('should respect debug.enabled config', () => {
|
|
30
|
+
const disabledSdk = new SDK({ debug: { enabled: false } });
|
|
31
|
+
disabledSdk.use(debugPlugin);
|
|
32
|
+
|
|
33
|
+
expect(disabledSdk.debug.isEnabled()).toBe(false);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should default to disabled', () => {
|
|
37
|
+
const defaultSdk = new SDK();
|
|
38
|
+
defaultSdk.use(debugPlugin);
|
|
39
|
+
|
|
40
|
+
expect(defaultSdk.debug.isEnabled()).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should respect debug.console config', () => {
|
|
44
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
45
|
+
|
|
46
|
+
const consoleEnabledSdk = new SDK({ debug: { enabled: true, console: true } });
|
|
47
|
+
consoleEnabledSdk.use(debugPlugin);
|
|
48
|
+
consoleEnabledSdk.debug.log('test message');
|
|
49
|
+
|
|
50
|
+
expect(consoleSpy).toHaveBeenCalledWith('[experiences] test message', '');
|
|
51
|
+
consoleSpy.mockRestore();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should not log to console when disabled', () => {
|
|
55
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
56
|
+
|
|
57
|
+
const consoleDisabledSdk = new SDK({ debug: { enabled: true, console: false } });
|
|
58
|
+
consoleDisabledSdk.use(debugPlugin);
|
|
59
|
+
consoleDisabledSdk.debug.log('test message');
|
|
60
|
+
|
|
61
|
+
expect(consoleSpy).not.toHaveBeenCalled();
|
|
62
|
+
consoleSpy.mockRestore();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe('Window Events', () => {
|
|
67
|
+
it('should emit window events when enabled', () => {
|
|
68
|
+
if (typeof window === 'undefined') {
|
|
69
|
+
// Skip in non-browser environment
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const eventHandler = vi.fn();
|
|
74
|
+
window.addEventListener('experience-sdk:debug', eventHandler);
|
|
75
|
+
|
|
76
|
+
sdk.use(debugPlugin);
|
|
77
|
+
sdk.debug.log('test message', { foo: 'bar' });
|
|
78
|
+
|
|
79
|
+
expect(eventHandler).toHaveBeenCalled();
|
|
80
|
+
const event = eventHandler.mock.calls[0][0] as CustomEvent;
|
|
81
|
+
expect(event.detail).toMatchObject({
|
|
82
|
+
message: 'test message',
|
|
83
|
+
data: { foo: 'bar' },
|
|
84
|
+
});
|
|
85
|
+
expect(event.detail.timestamp).toBeDefined();
|
|
86
|
+
|
|
87
|
+
window.removeEventListener('experience-sdk:debug', eventHandler);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should not emit window events when debug is disabled', () => {
|
|
91
|
+
if (typeof window === 'undefined') {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const eventHandler = vi.fn();
|
|
96
|
+
window.addEventListener('experience-sdk:debug', eventHandler);
|
|
97
|
+
|
|
98
|
+
const disabledSdk = new SDK({ debug: { enabled: false } });
|
|
99
|
+
disabledSdk.use(debugPlugin);
|
|
100
|
+
disabledSdk.debug.log('test message');
|
|
101
|
+
|
|
102
|
+
expect(eventHandler).not.toHaveBeenCalled();
|
|
103
|
+
|
|
104
|
+
window.removeEventListener('experience-sdk:debug', eventHandler);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('should not emit window events when window is disabled', () => {
|
|
108
|
+
if (typeof window === 'undefined') {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const eventHandler = vi.fn();
|
|
113
|
+
window.addEventListener('experience-sdk:debug', eventHandler);
|
|
114
|
+
|
|
115
|
+
const windowDisabledSdk = new SDK({ debug: { enabled: true, window: false } });
|
|
116
|
+
windowDisabledSdk.use(debugPlugin);
|
|
117
|
+
windowDisabledSdk.debug.log('test message');
|
|
118
|
+
|
|
119
|
+
expect(eventHandler).not.toHaveBeenCalled();
|
|
120
|
+
|
|
121
|
+
window.removeEventListener('experience-sdk:debug', eventHandler);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('Event Listening', () => {
|
|
126
|
+
it('should listen to experiences:ready event', () => {
|
|
127
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
128
|
+
|
|
129
|
+
sdk.use(debugPlugin);
|
|
130
|
+
sdk.emit('experiences:ready');
|
|
131
|
+
|
|
132
|
+
expect(consoleSpy).toHaveBeenCalledWith('[experiences] SDK initialized and ready', '');
|
|
133
|
+
|
|
134
|
+
consoleSpy.mockRestore();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('should listen to experiences:registered event', () => {
|
|
138
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
139
|
+
|
|
140
|
+
sdk.use(debugPlugin);
|
|
141
|
+
const payload = { id: 'test', experience: { type: 'banner' } };
|
|
142
|
+
sdk.emit('experiences:registered', payload);
|
|
143
|
+
|
|
144
|
+
expect(consoleSpy).toHaveBeenCalledWith('[experiences] Experience registered', payload);
|
|
145
|
+
|
|
146
|
+
consoleSpy.mockRestore();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('should listen to experiences:evaluated event', () => {
|
|
150
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
151
|
+
|
|
152
|
+
sdk.use(debugPlugin);
|
|
153
|
+
const decision = { show: true, experienceId: 'test' };
|
|
154
|
+
sdk.emit('experiences:evaluated', decision);
|
|
155
|
+
|
|
156
|
+
expect(consoleSpy).toHaveBeenCalledWith('[experiences] Experience evaluated', decision);
|
|
157
|
+
|
|
158
|
+
consoleSpy.mockRestore();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should not log when debug is disabled', () => {
|
|
162
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
163
|
+
|
|
164
|
+
const disabledSdk = new SDK({ debug: { enabled: false } });
|
|
165
|
+
disabledSdk.use(debugPlugin);
|
|
166
|
+
disabledSdk.emit('experiences:ready');
|
|
167
|
+
|
|
168
|
+
expect(consoleSpy).not.toHaveBeenCalled();
|
|
169
|
+
|
|
170
|
+
consoleSpy.mockRestore();
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
describe('debug.log() method', () => {
|
|
175
|
+
it('should log message without data', () => {
|
|
176
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
177
|
+
|
|
178
|
+
sdk.use(debugPlugin);
|
|
179
|
+
sdk.debug.log('test message');
|
|
180
|
+
|
|
181
|
+
expect(consoleSpy).toHaveBeenCalledWith('[experiences] test message', '');
|
|
182
|
+
|
|
183
|
+
consoleSpy.mockRestore();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('should log message with data', () => {
|
|
187
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
188
|
+
|
|
189
|
+
sdk.use(debugPlugin);
|
|
190
|
+
const data = { foo: 'bar', count: 42 };
|
|
191
|
+
sdk.debug.log('test message', data);
|
|
192
|
+
|
|
193
|
+
expect(consoleSpy).toHaveBeenCalledWith('[experiences] test message', data);
|
|
194
|
+
|
|
195
|
+
consoleSpy.mockRestore();
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('should include timestamp in window event', () => {
|
|
199
|
+
if (typeof window === 'undefined') {
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const eventHandler = vi.fn();
|
|
204
|
+
window.addEventListener('experience-sdk:debug', eventHandler);
|
|
205
|
+
|
|
206
|
+
sdk.use(debugPlugin);
|
|
207
|
+
sdk.debug.log('test message');
|
|
208
|
+
|
|
209
|
+
const event = eventHandler.mock.calls[0][0] as CustomEvent;
|
|
210
|
+
expect(event.detail.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/);
|
|
211
|
+
|
|
212
|
+
window.removeEventListener('experience-sdk:debug', eventHandler);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('debug.isEnabled() method', () => {
|
|
217
|
+
it('should return true when enabled', () => {
|
|
218
|
+
sdk.use(debugPlugin);
|
|
219
|
+
|
|
220
|
+
expect(sdk.debug.isEnabled()).toBe(true);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('should return false when disabled', () => {
|
|
224
|
+
const disabledSdk = new SDK({ debug: { enabled: false } });
|
|
225
|
+
disabledSdk.use(debugPlugin);
|
|
226
|
+
|
|
227
|
+
expect(disabledSdk.debug.isEnabled()).toBe(false);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug Plugin
|
|
3
|
+
*
|
|
4
|
+
* Emits structured debug events to window and optionally logs to console.
|
|
5
|
+
* Useful for debugging and Chrome extension integration.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PluginFunction } from '@lytics/sdk-kit';
|
|
9
|
+
|
|
10
|
+
export interface DebugPluginConfig {
|
|
11
|
+
debug?: {
|
|
12
|
+
enabled?: boolean;
|
|
13
|
+
console?: boolean;
|
|
14
|
+
window?: boolean;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface DebugPlugin {
|
|
19
|
+
log(message: string, data?: unknown): void;
|
|
20
|
+
isEnabled(): boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Debug Plugin
|
|
25
|
+
*
|
|
26
|
+
* Listens to all SDK events and emits them as window events for debugging.
|
|
27
|
+
* Also optionally logs to console.
|
|
28
|
+
*
|
|
29
|
+
* @example
|
|
30
|
+
* ```typescript
|
|
31
|
+
* import { createInstance } from '@prosdevlab/experience-sdk';
|
|
32
|
+
* import { debugPlugin } from '@prosdevlab/experience-sdk-plugins';
|
|
33
|
+
*
|
|
34
|
+
* const sdk = createInstance({ debug: { enabled: true, console: true } });
|
|
35
|
+
* sdk.use(debugPlugin);
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export const debugPlugin: PluginFunction = (plugin, instance, config) => {
|
|
39
|
+
plugin.ns('debug');
|
|
40
|
+
|
|
41
|
+
// Set defaults
|
|
42
|
+
plugin.defaults({
|
|
43
|
+
debug: {
|
|
44
|
+
enabled: false,
|
|
45
|
+
console: false,
|
|
46
|
+
window: true,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Helper to check if debug is enabled
|
|
51
|
+
const isEnabled = (): boolean => config.get('debug.enabled') ?? false;
|
|
52
|
+
const shouldLogConsole = (): boolean => config.get('debug.console') ?? false;
|
|
53
|
+
const shouldEmitWindow = (): boolean => config.get('debug.window') ?? true;
|
|
54
|
+
|
|
55
|
+
// Log function
|
|
56
|
+
const log = (message: string, data?: unknown): void => {
|
|
57
|
+
if (!isEnabled()) return;
|
|
58
|
+
|
|
59
|
+
const timestamp = new Date().toISOString();
|
|
60
|
+
const logData = {
|
|
61
|
+
timestamp,
|
|
62
|
+
message,
|
|
63
|
+
data,
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
// Console logging
|
|
67
|
+
if (shouldLogConsole()) {
|
|
68
|
+
console.log(`[experiences] ${message}`, data || '');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Window event emission
|
|
72
|
+
if (shouldEmitWindow() && typeof window !== 'undefined') {
|
|
73
|
+
const event = new CustomEvent('experience-sdk:debug', {
|
|
74
|
+
detail: logData,
|
|
75
|
+
});
|
|
76
|
+
window.dispatchEvent(event);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
// Expose debug API
|
|
81
|
+
plugin.expose({
|
|
82
|
+
debug: {
|
|
83
|
+
log,
|
|
84
|
+
isEnabled,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// If debug is enabled, listen to all events
|
|
89
|
+
if (isEnabled()) {
|
|
90
|
+
// Listen to experiences:* events
|
|
91
|
+
instance.on('experiences:ready', () => {
|
|
92
|
+
if (!isEnabled()) return;
|
|
93
|
+
log('SDK initialized and ready');
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
instance.on('experiences:registered', (payload) => {
|
|
97
|
+
if (!isEnabled()) return;
|
|
98
|
+
log('Experience registered', payload);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
instance.on('experiences:evaluated', (payload) => {
|
|
102
|
+
if (!isEnabled()) return;
|
|
103
|
+
log('Experience evaluated', payload);
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
};
|