@prosdevlab/experience-sdk-plugins 0.1.0 → 0.1.4

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.
@@ -7,12 +7,14 @@
7
7
 
8
8
  import type { PluginFunction } from '@lytics/sdk-kit';
9
9
  import type { BannerContent, Decision, Experience } from '../types';
10
+ import { sanitizeHTML } from '../utils/sanitize';
10
11
 
11
12
  export interface BannerPluginConfig {
12
13
  banner?: {
13
14
  position?: 'top' | 'bottom';
14
15
  dismissable?: boolean;
15
16
  zIndex?: number;
17
+ pushDown?: string; // CSS selector of element to push down (add margin-top)
16
18
  };
17
19
  }
18
20
 
@@ -32,7 +34,23 @@ export interface BannerPlugin {
32
34
  * import { createInstance } from '@prosdevlab/experience-sdk';
33
35
  * import { bannerPlugin } from '@prosdevlab/experience-sdk-plugins';
34
36
  *
35
- * const sdk = createInstance({ banner: { position: 'top', dismissable: true } });
37
+ * // Basic usage (banner overlays at top)
38
+ * const sdk = createInstance({
39
+ * banner: {
40
+ * position: 'top',
41
+ * dismissable: true
42
+ * }
43
+ * });
44
+ * sdk.use(bannerPlugin);
45
+ *
46
+ * // With pushDown (pushes navigation down instead of overlaying)
47
+ * const sdk = createInstance({
48
+ * banner: {
49
+ * position: 'top',
50
+ * dismissable: true,
51
+ * pushDown: 'header' // CSS selector of element to push down
52
+ * }
53
+ * });
36
54
  * sdk.use(bannerPlugin);
37
55
  * ```
38
56
  */
@@ -51,6 +69,236 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
51
69
  // Track multiple active banners by experience ID
52
70
  const activeBanners = new Map<string, HTMLElement>();
53
71
 
72
+ /**
73
+ * Inject default banner styles if not already present
74
+ */
75
+ function injectDefaultStyles(): void {
76
+ const styleId = 'xp-banner-styles';
77
+ if (document.getElementById(styleId)) {
78
+ return; // Already injected
79
+ }
80
+
81
+ const style = document.createElement('style');
82
+ style.id = styleId;
83
+ style.textContent = `
84
+ .xp-banner {
85
+ position: fixed;
86
+ left: 0;
87
+ right: 0;
88
+ width: 100%;
89
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
90
+ font-size: 14px;
91
+ line-height: 1.5;
92
+ box-sizing: border-box;
93
+ z-index: 10000;
94
+ background: #ffffff;
95
+ color: #111827;
96
+ border-bottom: 1px solid #e5e7eb;
97
+ box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05);
98
+ }
99
+
100
+ .xp-banner--top {
101
+ top: 0;
102
+ }
103
+
104
+ .xp-banner--bottom {
105
+ bottom: 0;
106
+ border-bottom: none;
107
+ border-top: 1px solid #e5e7eb;
108
+ box-shadow: 0 -1px 3px 0 rgba(0, 0, 0, 0.05);
109
+ }
110
+
111
+ .xp-banner__container {
112
+ display: flex;
113
+ align-items: center;
114
+ gap: 16px;
115
+ max-width: 1280px;
116
+ margin: 0 auto;
117
+ padding: 14px 24px;
118
+ }
119
+
120
+ .xp-banner__content {
121
+ flex: 1;
122
+ min-width: 0;
123
+ display: flex;
124
+ flex-direction: column;
125
+ gap: 4px;
126
+ }
127
+
128
+ .xp-banner__title {
129
+ font-weight: 600;
130
+ margin: 0;
131
+ font-size: 15px;
132
+ line-height: 1.4;
133
+ }
134
+
135
+ .xp-banner__message {
136
+ margin: 0;
137
+ font-size: 14px;
138
+ line-height: 1.5;
139
+ color: #6b7280;
140
+ }
141
+
142
+ .xp-banner__buttons {
143
+ display: flex;
144
+ align-items: center;
145
+ gap: 8px;
146
+ flex-shrink: 0;
147
+ }
148
+
149
+ .xp-banner__button {
150
+ padding: 8px 16px;
151
+ border: none;
152
+ border-radius: 6px;
153
+ font-size: 14px;
154
+ font-weight: 500;
155
+ cursor: pointer;
156
+ transition: all 0.2s;
157
+ text-decoration: none;
158
+ display: inline-flex;
159
+ align-items: center;
160
+ justify-content: center;
161
+ white-space: nowrap;
162
+ }
163
+
164
+ .xp-banner__button--primary {
165
+ background: #2563eb;
166
+ color: #ffffff;
167
+ }
168
+
169
+ .xp-banner__button--primary:hover {
170
+ background: #1d4ed8;
171
+ }
172
+
173
+ .xp-banner__button--secondary {
174
+ background: #f3f4f6;
175
+ color: #374151;
176
+ border: 1px solid #e5e7eb;
177
+ }
178
+
179
+ .xp-banner__button--secondary:hover {
180
+ background: #e5e7eb;
181
+ }
182
+
183
+ .xp-banner__button--link {
184
+ background: transparent;
185
+ color: #2563eb;
186
+ padding: 6px 12px;
187
+ font-weight: 400;
188
+ }
189
+
190
+ .xp-banner__button--link:hover {
191
+ background: #f3f4f6;
192
+ text-decoration: underline;
193
+ }
194
+
195
+ .xp-banner__close {
196
+ background: transparent;
197
+ border: none;
198
+ color: #9ca3af;
199
+ font-size: 20px;
200
+ line-height: 1;
201
+ cursor: pointer;
202
+ padding: 4px;
203
+ margin: 0;
204
+ transition: color 0.2s;
205
+ flex-shrink: 0;
206
+ width: 28px;
207
+ height: 28px;
208
+ display: flex;
209
+ align-items: center;
210
+ justify-content: center;
211
+ border-radius: 4px;
212
+ }
213
+
214
+ .xp-banner__close:hover {
215
+ color: #111827;
216
+ background: #f3f4f6;
217
+ }
218
+
219
+ @media (max-width: 640px) {
220
+ .xp-banner__container {
221
+ flex-wrap: wrap;
222
+ padding: 14px 16px;
223
+ position: relative;
224
+ }
225
+
226
+ .xp-banner__content {
227
+ flex: 1 1 100%;
228
+ padding-right: 32px;
229
+ }
230
+
231
+ .xp-banner__buttons {
232
+ flex: 1 1 auto;
233
+ width: 100%;
234
+ }
235
+
236
+ .xp-banner__button {
237
+ flex: 1;
238
+ }
239
+
240
+ .xp-banner__close {
241
+ position: absolute;
242
+ top: 12px;
243
+ right: 12px;
244
+ }
245
+ }
246
+
247
+ /* Dark mode support */
248
+ @media (prefers-color-scheme: dark) {
249
+ .xp-banner {
250
+ background: #111827;
251
+ color: #f9fafb;
252
+ border-bottom-color: #1f2937;
253
+ }
254
+
255
+ .xp-banner--bottom {
256
+ border-top-color: #1f2937;
257
+ }
258
+
259
+ .xp-banner__message {
260
+ color: #9ca3af;
261
+ }
262
+
263
+ .xp-banner__button--primary {
264
+ background: #3b82f6;
265
+ }
266
+
267
+ .xp-banner__button--primary:hover {
268
+ background: #2563eb;
269
+ }
270
+
271
+ .xp-banner__button--secondary {
272
+ background: #1f2937;
273
+ color: #f9fafb;
274
+ border-color: #374151;
275
+ }
276
+
277
+ .xp-banner__button--secondary:hover {
278
+ background: #374151;
279
+ }
280
+
281
+ .xp-banner__button--link {
282
+ color: #60a5fa;
283
+ }
284
+
285
+ .xp-banner__button--link:hover {
286
+ background: #1f2937;
287
+ }
288
+
289
+ .xp-banner__close {
290
+ color: #6b7280;
291
+ }
292
+
293
+ .xp-banner__close:hover {
294
+ color: #f9fafb;
295
+ background: #1f2937;
296
+ }
297
+ }
298
+ `;
299
+ document.head.appendChild(style);
300
+ }
301
+
54
302
  /**
55
303
  * Create banner DOM element
56
304
  */
@@ -61,89 +309,60 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
61
309
  const dismissable = content.dismissable ?? config.get('banner.dismissable') ?? true;
62
310
  const zIndex = config.get('banner.zIndex') ?? 10000;
63
311
 
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)';
312
+ // Inject default styles if needed
313
+ injectDefaultStyles();
72
314
 
73
315
  // Create banner container
74
316
  const banner = document.createElement('div');
75
317
  banner.setAttribute('data-experience-id', experience.id);
76
318
 
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);
319
+ // Build className: base classes + position + user's custom class
320
+ const baseClasses = ['xp-banner', `xp-banner--${position}`];
321
+ if (content.className) {
322
+ baseClasses.push(content.className);
98
323
  }
324
+ banner.className = baseClasses.join(' ');
99
325
 
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
- `;
326
+ // Apply user's custom styles
327
+ if (content.style) {
328
+ Object.assign(banner.style, content.style);
329
+ }
330
+
331
+ // Override z-index if configured
332
+ if (zIndex !== 10000) {
333
+ banner.style.zIndex = String(zIndex);
334
+ }
335
+
336
+ // Create container
337
+ const container = document.createElement('div');
338
+ container.className = 'xp-banner__container';
339
+ banner.appendChild(container);
119
340
 
120
341
  // Create content container
121
342
  const contentDiv = document.createElement('div');
122
- contentDiv.style.cssText = 'flex: 1; margin-right: 20px;';
343
+ contentDiv.className = 'xp-banner__content';
123
344
 
124
345
  // Add title if present
125
346
  if (content.title) {
126
- const title = document.createElement('div');
127
- title.textContent = content.title;
128
- title.style.cssText = 'font-weight: 600; margin-bottom: 4px;';
347
+ const title = document.createElement('h3');
348
+ title.className = 'xp-banner__title';
349
+ // Sanitize HTML to prevent XSS attacks
350
+ title.innerHTML = sanitizeHTML(content.title);
129
351
  contentDiv.appendChild(title);
130
352
  }
131
353
 
132
354
  // Add message
133
- const message = document.createElement('div');
134
- message.textContent = content.message;
355
+ const message = document.createElement('p');
356
+ message.className = 'xp-banner__message';
357
+ // Sanitize HTML to prevent XSS attacks
358
+ message.innerHTML = sanitizeHTML(content.message);
135
359
  contentDiv.appendChild(message);
136
360
 
137
- banner.appendChild(contentDiv);
361
+ container.appendChild(contentDiv);
138
362
 
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
- `;
363
+ // Create buttons container
364
+ const buttonsDiv = document.createElement('div');
365
+ buttonsDiv.className = 'xp-banner__buttons';
147
366
 
148
367
  // Helper function to create button with variant styling
149
368
  function createButton(buttonConfig: {
@@ -152,53 +371,25 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
152
371
  url?: string;
153
372
  variant?: 'primary' | 'secondary' | 'link';
154
373
  metadata?: Record<string, unknown>;
374
+ className?: string;
375
+ style?: Record<string, string>;
155
376
  }): HTMLButtonElement {
156
377
  const button = document.createElement('button');
157
378
  button.textContent = buttonConfig.text;
158
379
 
159
380
  const variant = buttonConfig.variant || 'primary';
160
381
 
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';
382
+ // Build className: base class + variant + user's custom class
383
+ const buttonClasses = ['xp-banner__button', `xp-banner__button--${variant}`];
384
+ if (buttonConfig.className) {
385
+ buttonClasses.push(buttonConfig.className);
180
386
  }
387
+ button.className = buttonClasses.join(' ');
181
388
 
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
- });
389
+ // Apply user's custom styles
390
+ if (buttonConfig.style) {
391
+ Object.assign(button.style, buttonConfig.style);
392
+ }
202
393
 
203
394
  button.addEventListener('click', () => {
204
395
  // Emit action event
@@ -225,39 +416,17 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
225
416
  if (content.buttons && content.buttons.length > 0) {
226
417
  content.buttons.forEach((buttonConfig) => {
227
418
  const button = createButton(buttonConfig);
228
- buttonContainer.appendChild(button);
419
+ buttonsDiv.appendChild(button);
229
420
  });
230
421
  }
231
422
 
232
423
  // Add dismiss button if dismissable
233
424
  if (dismissable) {
234
425
  const closeButton = document.createElement('button');
426
+ closeButton.className = 'xp-banner__close';
235
427
  closeButton.innerHTML = '&times;';
236
428
  closeButton.setAttribute('aria-label', 'Close banner');
237
429
 
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
430
  closeButton.addEventListener('click', () => {
262
431
  remove(experience.id);
263
432
  instance.emit('experiences:dismissed', {
@@ -266,14 +435,57 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
266
435
  });
267
436
  });
268
437
 
269
- buttonContainer.appendChild(closeButton);
438
+ buttonsDiv.appendChild(closeButton);
270
439
  }
271
440
 
272
- banner.appendChild(buttonContainer);
441
+ container.appendChild(buttonsDiv);
273
442
 
274
443
  return banner;
275
444
  }
276
445
 
446
+ /**
447
+ * Apply pushDown margin to target element
448
+ */
449
+ function applyPushDown(banner: HTMLElement, position: 'top' | 'bottom'): void {
450
+ const pushDownSelector = config.get('banner.pushDown');
451
+
452
+ if (!pushDownSelector || position !== 'top') {
453
+ return; // Only push down for top banners
454
+ }
455
+
456
+ const targetElement = document.querySelector(pushDownSelector);
457
+ if (!targetElement || !(targetElement instanceof HTMLElement)) {
458
+ return;
459
+ }
460
+
461
+ // Get banner height
462
+ const height = banner.offsetHeight;
463
+
464
+ // Apply margin-top with transition
465
+ targetElement.style.transition = 'margin-top 0.3s ease';
466
+ targetElement.style.marginTop = `${height}px`;
467
+ }
468
+
469
+ /**
470
+ * Remove pushDown margin from target element
471
+ */
472
+ function removePushDown(): void {
473
+ const pushDownSelector = config.get('banner.pushDown');
474
+
475
+ if (!pushDownSelector) {
476
+ return;
477
+ }
478
+
479
+ const targetElement = document.querySelector(pushDownSelector);
480
+ if (!targetElement || !(targetElement instanceof HTMLElement)) {
481
+ return;
482
+ }
483
+
484
+ // Remove margin-top with transition
485
+ targetElement.style.transition = 'margin-top 0.3s ease';
486
+ targetElement.style.marginTop = '0';
487
+ }
488
+
277
489
  /**
278
490
  * Show a banner experience
279
491
  */
@@ -292,6 +504,11 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
292
504
  document.body.appendChild(banner);
293
505
  activeBanners.set(experience.id, banner);
294
506
 
507
+ // Apply pushDown to target element if configured
508
+ const content = experience.content as BannerContent;
509
+ const position = content.position ?? config.get('banner.position') ?? 'top';
510
+ applyPushDown(banner, position);
511
+
295
512
  instance.emit('experiences:shown', {
296
513
  experienceId: experience.id,
297
514
  type: 'banner',
@@ -310,6 +527,11 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
310
527
  banner.parentNode.removeChild(banner);
311
528
  }
312
529
  activeBanners.delete(experienceId);
530
+
531
+ // Remove pushDown if no more banners
532
+ if (activeBanners.size === 0) {
533
+ removePushDown();
534
+ }
313
535
  } else {
314
536
  // Remove all banners
315
537
  for (const [id, banner] of activeBanners.entries()) {
@@ -318,6 +540,9 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
318
540
  }
319
541
  activeBanners.delete(id);
320
542
  }
543
+
544
+ // Remove pushDown
545
+ removePushDown();
321
546
  }
322
547
  }
323
548
 
package/src/types.ts CHANGED
@@ -20,9 +20,31 @@ export interface BannerContent {
20
20
  url?: string;
21
21
  variant?: 'primary' | 'secondary' | 'link';
22
22
  metadata?: Record<string, any>;
23
+ className?: string;
24
+ style?: Record<string, string>;
23
25
  }>;
24
26
  dismissable?: boolean;
25
27
  position?: 'top' | 'bottom';
28
+ className?: string;
29
+ style?: Record<string, string>;
30
+ }
31
+
32
+ /**
33
+ * Modal content configuration
34
+ */
35
+ export interface ModalContent {
36
+ title: string;
37
+ message: string;
38
+ confirmText?: string;
39
+ cancelText?: string;
40
+ }
41
+
42
+ /**
43
+ * Tooltip content configuration
44
+ */
45
+ export interface TooltipContent {
46
+ message: string;
47
+ position?: 'top' | 'bottom' | 'left' | 'right';
26
48
  }
27
49
 
28
50
  /**