@prosdevlab/experience-sdk-plugins 0.1.0 → 0.1.3

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,6 +7,7 @@
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?: {
@@ -51,6 +52,200 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
51
52
  // Track multiple active banners by experience ID
52
53
  const activeBanners = new Map<string, HTMLElement>();
53
54
 
55
+ /**
56
+ * Inject default banner styles if not already present
57
+ */
58
+ function injectDefaultStyles(): void {
59
+ const styleId = 'xp-banner-styles';
60
+ if (document.getElementById(styleId)) {
61
+ return; // Already injected
62
+ }
63
+
64
+ const style = document.createElement('style');
65
+ style.id = styleId;
66
+ style.textContent = `
67
+ .xp-banner {
68
+ position: fixed;
69
+ left: 0;
70
+ right: 0;
71
+ width: 100%;
72
+ padding: 16px 20px;
73
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
74
+ font-size: 14px;
75
+ line-height: 1.5;
76
+ display: flex;
77
+ align-items: center;
78
+ justify-content: space-between;
79
+ box-sizing: border-box;
80
+ z-index: 10000;
81
+ background: #f9fafb;
82
+ color: #111827;
83
+ border-bottom: 1px solid #e5e7eb;
84
+ box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05);
85
+ }
86
+
87
+ .xp-banner--top {
88
+ top: 0;
89
+ }
90
+
91
+ .xp-banner--bottom {
92
+ bottom: 0;
93
+ border-bottom: none;
94
+ border-top: 1px solid #e5e7eb;
95
+ box-shadow: 0 -1px 3px 0 rgba(0, 0, 0, 0.05);
96
+ }
97
+
98
+ .xp-banner__container {
99
+ display: flex;
100
+ align-items: center;
101
+ justify-content: space-between;
102
+ gap: 20px;
103
+ width: 100%;
104
+ }
105
+
106
+ .xp-banner__content {
107
+ flex: 1;
108
+ min-width: 0;
109
+ }
110
+
111
+ .xp-banner__title {
112
+ font-weight: 600;
113
+ margin-bottom: 4px;
114
+ margin-top: 0;
115
+ font-size: 14px;
116
+ }
117
+
118
+ .xp-banner__message {
119
+ margin: 0;
120
+ font-size: 14px;
121
+ }
122
+
123
+ .xp-banner__buttons {
124
+ display: flex;
125
+ align-items: center;
126
+ gap: 12px;
127
+ flex-wrap: wrap;
128
+ flex-shrink: 0;
129
+ }
130
+
131
+ .xp-banner__button {
132
+ padding: 8px 16px;
133
+ border: none;
134
+ border-radius: 6px;
135
+ font-size: 14px;
136
+ font-weight: 500;
137
+ cursor: pointer;
138
+ transition: all 0.2s;
139
+ text-decoration: none;
140
+ }
141
+
142
+ .xp-banner__button--primary {
143
+ background: #2563eb;
144
+ color: #ffffff;
145
+ }
146
+
147
+ .xp-banner__button--primary:hover {
148
+ background: #1d4ed8;
149
+ }
150
+
151
+ .xp-banner__button--secondary {
152
+ background: #ffffff;
153
+ color: #374151;
154
+ border: 1px solid #d1d5db;
155
+ }
156
+
157
+ .xp-banner__button--secondary:hover {
158
+ background: #f9fafb;
159
+ }
160
+
161
+ .xp-banner__button--link {
162
+ background: transparent;
163
+ color: #2563eb;
164
+ padding: 4px 8px;
165
+ font-weight: 400;
166
+ text-decoration: underline;
167
+ }
168
+
169
+ .xp-banner__button--link:hover {
170
+ background: rgba(0, 0, 0, 0.05);
171
+ }
172
+
173
+ .xp-banner__close {
174
+ background: transparent;
175
+ border: none;
176
+ color: #6b7280;
177
+ font-size: 24px;
178
+ line-height: 1;
179
+ cursor: pointer;
180
+ padding: 0;
181
+ margin: 0;
182
+ opacity: 0.7;
183
+ transition: opacity 0.2s;
184
+ flex-shrink: 0;
185
+ }
186
+
187
+ .xp-banner__close:hover {
188
+ opacity: 1;
189
+ }
190
+
191
+ @media (max-width: 640px) {
192
+ .xp-banner__container {
193
+ flex-direction: column;
194
+ align-items: stretch;
195
+ }
196
+
197
+ .xp-banner__buttons {
198
+ width: 100%;
199
+ flex-direction: column;
200
+ }
201
+
202
+ .xp-banner__button {
203
+ width: 100%;
204
+ }
205
+ }
206
+
207
+ /* Dark mode support */
208
+ @media (prefers-color-scheme: dark) {
209
+ .xp-banner {
210
+ background: #1f2937;
211
+ color: #f3f4f6;
212
+ border-bottom-color: #374151;
213
+ }
214
+
215
+ .xp-banner--bottom {
216
+ border-top-color: #374151;
217
+ }
218
+
219
+ .xp-banner__button--primary {
220
+ background: #3b82f6;
221
+ }
222
+
223
+ .xp-banner__button--primary:hover {
224
+ background: #2563eb;
225
+ }
226
+
227
+ .xp-banner__button--secondary {
228
+ background: #374151;
229
+ color: #f3f4f6;
230
+ border-color: #4b5563;
231
+ }
232
+
233
+ .xp-banner__button--secondary:hover {
234
+ background: #4b5563;
235
+ }
236
+
237
+ .xp-banner__button--link {
238
+ color: #93c5fd;
239
+ }
240
+
241
+ .xp-banner__close {
242
+ color: #9ca3af;
243
+ }
244
+ }
245
+ `;
246
+ document.head.appendChild(style);
247
+ }
248
+
54
249
  /**
55
250
  * Create banner DOM element
56
251
  */
@@ -61,79 +256,57 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
61
256
  const dismissable = content.dismissable ?? config.get('banner.dismissable') ?? true;
62
257
  const zIndex = config.get('banner.zIndex') ?? 10000;
63
258
 
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)';
259
+ // Inject default styles if needed
260
+ injectDefaultStyles();
72
261
 
73
262
  // Create banner container
74
263
  const banner = document.createElement('div');
75
264
  banner.setAttribute('data-experience-id', experience.id);
76
265
 
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);
266
+ // Build className: base classes + position + user's custom class
267
+ const baseClasses = ['xp-banner', `xp-banner--${position}`];
268
+ if (content.className) {
269
+ baseClasses.push(content.className);
98
270
  }
271
+ banner.className = baseClasses.join(' ');
99
272
 
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
- `;
273
+ // Apply user's custom styles
274
+ if (content.style) {
275
+ Object.assign(banner.style, content.style);
276
+ }
277
+
278
+ // Override z-index if configured
279
+ if (zIndex !== 10000) {
280
+ banner.style.zIndex = String(zIndex);
281
+ }
282
+
283
+ // Create container
284
+ const container = document.createElement('div');
285
+ container.className = 'xp-banner__container';
286
+ banner.appendChild(container);
119
287
 
120
288
  // Create content container
121
289
  const contentDiv = document.createElement('div');
122
- contentDiv.style.cssText = 'flex: 1; margin-right: 20px;';
290
+ contentDiv.className = 'xp-banner__content';
123
291
 
124
292
  // Add title if present
125
293
  if (content.title) {
126
- const title = document.createElement('div');
127
- title.textContent = content.title;
128
- title.style.cssText = 'font-weight: 600; margin-bottom: 4px;';
294
+ const title = document.createElement('h3');
295
+ title.className = 'xp-banner__title';
296
+ // Sanitize HTML to prevent XSS attacks
297
+ title.innerHTML = sanitizeHTML(content.title);
129
298
  contentDiv.appendChild(title);
130
299
  }
131
300
 
132
301
  // Add message
133
- const message = document.createElement('div');
134
- message.textContent = content.message;
302
+ const message = document.createElement('p');
303
+ message.className = 'xp-banner__message';
304
+ // Sanitize HTML to prevent XSS attacks
305
+ message.innerHTML = sanitizeHTML(content.message);
135
306
  contentDiv.appendChild(message);
136
307
 
308
+ container.appendChild(contentDiv);
309
+
137
310
  banner.appendChild(contentDiv);
138
311
 
139
312
  // Create button container for actions and/or dismiss
@@ -145,6 +318,10 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
145
318
  flex-wrap: wrap;
146
319
  `;
147
320
 
321
+ // Create buttons container
322
+ const buttonsDiv = document.createElement('div');
323
+ buttonsDiv.className = 'xp-banner__buttons';
324
+
148
325
  // Helper function to create button with variant styling
149
326
  function createButton(buttonConfig: {
150
327
  text: string;
@@ -152,53 +329,25 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
152
329
  url?: string;
153
330
  variant?: 'primary' | 'secondary' | 'link';
154
331
  metadata?: Record<string, unknown>;
332
+ className?: string;
333
+ style?: Record<string, string>;
155
334
  }): HTMLButtonElement {
156
335
  const button = document.createElement('button');
157
336
  button.textContent = buttonConfig.text;
158
337
 
159
338
  const variant = buttonConfig.variant || 'primary';
160
339
 
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';
340
+ // Build className: base class + variant + user's custom class
341
+ const buttonClasses = ['xp-banner__button', `xp-banner__button--${variant}`];
342
+ if (buttonConfig.className) {
343
+ buttonClasses.push(buttonConfig.className);
180
344
  }
345
+ button.className = buttonClasses.join(' ');
181
346
 
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
- });
347
+ // Apply user's custom styles
348
+ if (buttonConfig.style) {
349
+ Object.assign(button.style, buttonConfig.style);
350
+ }
202
351
 
203
352
  button.addEventListener('click', () => {
204
353
  // Emit action event
@@ -225,39 +374,17 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
225
374
  if (content.buttons && content.buttons.length > 0) {
226
375
  content.buttons.forEach((buttonConfig) => {
227
376
  const button = createButton(buttonConfig);
228
- buttonContainer.appendChild(button);
377
+ buttonsDiv.appendChild(button);
229
378
  });
230
379
  }
231
380
 
232
381
  // Add dismiss button if dismissable
233
382
  if (dismissable) {
234
383
  const closeButton = document.createElement('button');
384
+ closeButton.className = 'xp-banner__close';
235
385
  closeButton.innerHTML = '&times;';
236
386
  closeButton.setAttribute('aria-label', 'Close banner');
237
387
 
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
388
  closeButton.addEventListener('click', () => {
262
389
  remove(experience.id);
263
390
  instance.emit('experiences:dismissed', {
@@ -266,10 +393,10 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
266
393
  });
267
394
  });
268
395
 
269
- buttonContainer.appendChild(closeButton);
396
+ buttonsDiv.appendChild(closeButton);
270
397
  }
271
398
 
272
- banner.appendChild(buttonContainer);
399
+ container.appendChild(buttonsDiv);
273
400
 
274
401
  return banner;
275
402
  }
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
  /**