@prosdevlab/experience-sdk-plugins 0.2.0 → 0.3.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/.turbo/turbo-build.log +6 -6
- package/CHANGELOG.md +120 -0
- package/README.md +141 -79
- package/dist/index.d.ts +206 -35
- package/dist/index.js +1229 -75
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/banner/banner.ts +63 -62
- package/src/exit-intent/exit-intent.ts +2 -3
- package/src/index.ts +2 -0
- package/src/inline/index.ts +3 -0
- package/src/inline/inline.test.ts +620 -0
- package/src/inline/inline.ts +269 -0
- package/src/inline/insertion.ts +66 -0
- package/src/inline/types.ts +52 -0
- package/src/integration.test.ts +356 -297
- package/src/modal/form-rendering.ts +262 -0
- package/src/modal/form-styles.ts +212 -0
- package/src/modal/form-validation.test.ts +413 -0
- package/src/modal/form-validation.ts +126 -0
- package/src/modal/index.ts +3 -0
- package/src/modal/modal-styles.ts +204 -0
- package/src/modal/modal.browser.test.ts +164 -0
- package/src/modal/modal.test.ts +1294 -0
- package/src/modal/modal.ts +685 -0
- package/src/modal/types.ts +114 -0
- package/src/scroll-depth/scroll-depth.test.ts +35 -0
- package/src/scroll-depth/scroll-depth.ts +2 -4
- package/src/time-delay/time-delay.test.ts +2 -2
- package/src/time-delay/time-delay.ts +2 -3
- package/src/types.ts +20 -36
- package/src/utils/sanitize.ts +4 -1
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
import type { SDK } from '@lytics/sdk-kit';
|
|
2
|
+
import type { ExperienceButton } from '../types';
|
|
3
|
+
import { sanitizeHTML } from '../utils/sanitize';
|
|
4
|
+
import { renderForm, renderFormState } from './form-rendering';
|
|
5
|
+
import { getInputErrorStyles } from './form-styles';
|
|
6
|
+
import { validateField, validateForm } from './form-validation';
|
|
7
|
+
import {
|
|
8
|
+
getBackdropStyles,
|
|
9
|
+
getButtonContainerStyles,
|
|
10
|
+
getCloseButtonDefaultOpacity,
|
|
11
|
+
getCloseButtonHoverOpacity,
|
|
12
|
+
getCloseButtonStyles,
|
|
13
|
+
getContentWrapperStyles,
|
|
14
|
+
getDialogStyles,
|
|
15
|
+
getHeroImageStyles,
|
|
16
|
+
getMessageStyles,
|
|
17
|
+
getPrimaryButtonDefaultBg,
|
|
18
|
+
getPrimaryButtonHoverBg,
|
|
19
|
+
getPrimaryButtonStyles,
|
|
20
|
+
getSecondaryButtonDefaultBg,
|
|
21
|
+
getSecondaryButtonHoverBg,
|
|
22
|
+
getSecondaryButtonStyles,
|
|
23
|
+
getTitleStyles,
|
|
24
|
+
} from './modal-styles';
|
|
25
|
+
import type { ModalContent, ModalPlugin } from './types';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Modal Plugin for @prosdevlab/experience-sdk
|
|
29
|
+
*
|
|
30
|
+
* Renders experiences as accessible modal dialogs with:
|
|
31
|
+
* - Focus trap and keyboard handling
|
|
32
|
+
* - ARIA attributes for screen readers
|
|
33
|
+
* - Backdrop and close button
|
|
34
|
+
* - Responsive design
|
|
35
|
+
*/
|
|
36
|
+
export const modalPlugin = (plugin: any, instance: SDK): void => {
|
|
37
|
+
plugin.ns('experiences.modal');
|
|
38
|
+
plugin.defaults({
|
|
39
|
+
modal: {
|
|
40
|
+
dismissable: true,
|
|
41
|
+
backdropDismiss: true,
|
|
42
|
+
zIndex: 10001,
|
|
43
|
+
size: 'md',
|
|
44
|
+
mobileFullscreen: false,
|
|
45
|
+
position: 'center',
|
|
46
|
+
animation: 'fade',
|
|
47
|
+
animationDuration: 200,
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Track active modals
|
|
52
|
+
const activeModals = new Map<string, HTMLElement>();
|
|
53
|
+
// Track focus before modal opened
|
|
54
|
+
const previouslyFocusedElement = new Map<string, HTMLElement | null>();
|
|
55
|
+
// Track form data by experience ID
|
|
56
|
+
const formData = new Map<string, Record<string, string>>();
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get focusable elements within a container
|
|
60
|
+
*/
|
|
61
|
+
const getFocusableElements = (container: HTMLElement): HTMLElement[] => {
|
|
62
|
+
const selector =
|
|
63
|
+
'a[href], button, textarea, input, select, details, [tabindex]:not([tabindex="-1"])';
|
|
64
|
+
return Array.from(container.querySelectorAll(selector)).filter(
|
|
65
|
+
(el) => !(el as HTMLElement).hasAttribute('disabled')
|
|
66
|
+
) as HTMLElement[];
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Create focus trap for modal
|
|
71
|
+
*/
|
|
72
|
+
const createFocusTrap = (container: HTMLElement): (() => void) => {
|
|
73
|
+
const focusable = getFocusableElements(container);
|
|
74
|
+
if (focusable.length === 0) return () => {};
|
|
75
|
+
|
|
76
|
+
const firstFocusable = focusable[0];
|
|
77
|
+
const lastFocusable = focusable[focusable.length - 1];
|
|
78
|
+
|
|
79
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
80
|
+
if (e.key !== 'Tab') return;
|
|
81
|
+
|
|
82
|
+
if (e.shiftKey) {
|
|
83
|
+
// Shift + Tab
|
|
84
|
+
if (document.activeElement === firstFocusable) {
|
|
85
|
+
e.preventDefault();
|
|
86
|
+
lastFocusable.focus();
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
// Tab
|
|
90
|
+
if (document.activeElement === lastFocusable) {
|
|
91
|
+
e.preventDefault();
|
|
92
|
+
firstFocusable.focus();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
container.addEventListener('keydown', handleKeyDown);
|
|
98
|
+
|
|
99
|
+
// Set initial focus
|
|
100
|
+
firstFocusable.focus();
|
|
101
|
+
|
|
102
|
+
// Return cleanup function
|
|
103
|
+
return () => {
|
|
104
|
+
container.removeEventListener('keydown', handleKeyDown);
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Get modal size width
|
|
110
|
+
*/
|
|
111
|
+
const getSizeWidth = (size: string): string => {
|
|
112
|
+
switch (size) {
|
|
113
|
+
case 'sm':
|
|
114
|
+
return '400px';
|
|
115
|
+
case 'md':
|
|
116
|
+
return '600px';
|
|
117
|
+
case 'lg':
|
|
118
|
+
return '800px';
|
|
119
|
+
case 'fullscreen':
|
|
120
|
+
return '100vw';
|
|
121
|
+
case 'auto':
|
|
122
|
+
return 'auto';
|
|
123
|
+
default:
|
|
124
|
+
return '600px'; // md default
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Check if mobile viewport
|
|
130
|
+
*/
|
|
131
|
+
const isMobile = (): boolean => {
|
|
132
|
+
return typeof window !== 'undefined' && window.innerWidth < 640;
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Render modal DOM structure
|
|
137
|
+
*/
|
|
138
|
+
const renderModal = (experienceId: string, content: ModalContent): HTMLElement => {
|
|
139
|
+
const modalConfig = instance.get('modal') || {};
|
|
140
|
+
const zIndex = modalConfig.zIndex || 10001;
|
|
141
|
+
const size = modalConfig.size || 'md';
|
|
142
|
+
const position = modalConfig.position || 'center';
|
|
143
|
+
const animation = modalConfig.animation || 'fade';
|
|
144
|
+
const animationDuration = modalConfig.animationDuration || 200;
|
|
145
|
+
|
|
146
|
+
// Determine if should be fullscreen
|
|
147
|
+
const mobileFullscreen =
|
|
148
|
+
modalConfig.mobileFullscreen !== undefined ? modalConfig.mobileFullscreen : size === 'lg'; // Auto-enable for lg size
|
|
149
|
+
const shouldBeFullscreen = size === 'fullscreen' || (mobileFullscreen && isMobile());
|
|
150
|
+
|
|
151
|
+
// Create modal container
|
|
152
|
+
const container = document.createElement('div');
|
|
153
|
+
const sizeClass = shouldBeFullscreen ? 'fullscreen' : size;
|
|
154
|
+
const positionClass = position === 'bottom' ? 'xp-modal--bottom' : 'xp-modal--center';
|
|
155
|
+
const animationClass = animation !== 'none' ? `xp-modal--${animation}` : '';
|
|
156
|
+
container.className =
|
|
157
|
+
`xp-modal xp-modal--${sizeClass} ${positionClass} ${animationClass} ${content.className || ''}`.trim();
|
|
158
|
+
container.setAttribute('data-xp-id', experienceId);
|
|
159
|
+
container.setAttribute('role', 'dialog');
|
|
160
|
+
container.setAttribute('aria-modal', 'true');
|
|
161
|
+
if (content.title) {
|
|
162
|
+
container.setAttribute('aria-labelledby', `xp-modal-title-${experienceId}`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Container styles
|
|
166
|
+
const alignItems = position === 'bottom' ? 'flex-end' : 'center';
|
|
167
|
+
container.style.cssText = `position: fixed; inset: 0; z-index: ${zIndex}; display: flex; align-items: ${alignItems}; justify-content: center;`;
|
|
168
|
+
|
|
169
|
+
// Apply animation
|
|
170
|
+
if (animation !== 'none') {
|
|
171
|
+
container.style.opacity = '0';
|
|
172
|
+
container.style.transition = `opacity ${animationDuration}ms ease-in-out`;
|
|
173
|
+
|
|
174
|
+
if (animation === 'slide-up') {
|
|
175
|
+
container.style.transform = 'translateY(100%)';
|
|
176
|
+
container.style.transition += `, transform ${animationDuration}ms ease-out`;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Apply custom styles
|
|
181
|
+
if (content.style) {
|
|
182
|
+
Object.entries(content.style).forEach(([key, value]) => {
|
|
183
|
+
container.style.setProperty(key, String(value));
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Backdrop
|
|
188
|
+
const backdrop = document.createElement('div');
|
|
189
|
+
backdrop.className = 'xp-modal__backdrop';
|
|
190
|
+
backdrop.style.cssText = getBackdropStyles();
|
|
191
|
+
container.appendChild(backdrop);
|
|
192
|
+
|
|
193
|
+
// Dialog
|
|
194
|
+
const dialog = document.createElement('div');
|
|
195
|
+
const dialogWidth = shouldBeFullscreen ? '100%' : size === 'auto' ? 'none' : getSizeWidth(size);
|
|
196
|
+
const dialogHeight = shouldBeFullscreen ? '100%' : 'auto';
|
|
197
|
+
const dialogMaxWidth = shouldBeFullscreen ? '100%' : size === 'auto' ? 'none' : '90%';
|
|
198
|
+
const dialogBorderRadius = shouldBeFullscreen ? '0' : '8px';
|
|
199
|
+
const dialogPadding = content.image ? '0' : '24px';
|
|
200
|
+
|
|
201
|
+
dialog.className = `xp-modal__dialog${content.image ? ' xp-modal__dialog--has-image' : ''}`;
|
|
202
|
+
dialog.style.cssText = getDialogStyles({
|
|
203
|
+
width: dialogWidth,
|
|
204
|
+
maxWidth: dialogMaxWidth,
|
|
205
|
+
height: dialogHeight,
|
|
206
|
+
maxHeight: shouldBeFullscreen ? '100%' : '90vh',
|
|
207
|
+
borderRadius: dialogBorderRadius,
|
|
208
|
+
padding: dialogPadding,
|
|
209
|
+
});
|
|
210
|
+
container.appendChild(dialog);
|
|
211
|
+
|
|
212
|
+
// Hero image (if provided)
|
|
213
|
+
if (content.image) {
|
|
214
|
+
const img = document.createElement('img');
|
|
215
|
+
img.className = 'xp-modal__hero-image';
|
|
216
|
+
img.src = content.image.src;
|
|
217
|
+
img.alt = content.image.alt;
|
|
218
|
+
img.loading = 'lazy';
|
|
219
|
+
|
|
220
|
+
// Use custom maxHeight if provided, otherwise default based on viewport
|
|
221
|
+
const maxHeight = content.image.maxHeight || (isMobile() ? 200 : 300);
|
|
222
|
+
img.style.cssText = getHeroImageStyles({
|
|
223
|
+
maxHeight,
|
|
224
|
+
borderRadius: shouldBeFullscreen ? '0' : '8px 8px 0 0',
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
dialog.appendChild(img);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Close button
|
|
231
|
+
if (modalConfig.dismissable !== false) {
|
|
232
|
+
const closeButton = document.createElement('button');
|
|
233
|
+
closeButton.className = 'xp-modal__close';
|
|
234
|
+
closeButton.setAttribute('aria-label', 'Close dialog');
|
|
235
|
+
closeButton.innerHTML = '×';
|
|
236
|
+
closeButton.style.cssText = getCloseButtonStyles();
|
|
237
|
+
closeButton.onmouseover = () => {
|
|
238
|
+
closeButton.style.opacity = getCloseButtonHoverOpacity();
|
|
239
|
+
};
|
|
240
|
+
closeButton.onmouseout = () => {
|
|
241
|
+
closeButton.style.opacity = getCloseButtonDefaultOpacity();
|
|
242
|
+
};
|
|
243
|
+
closeButton.onclick = () => removeModal(experienceId);
|
|
244
|
+
dialog.appendChild(closeButton);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Content wrapper
|
|
248
|
+
const contentWrapper = document.createElement('div');
|
|
249
|
+
contentWrapper.className = 'xp-modal__content';
|
|
250
|
+
const contentPadding = content.image ? '24px' : '24px 24px 0 24px';
|
|
251
|
+
contentWrapper.style.cssText = getContentWrapperStyles(contentPadding);
|
|
252
|
+
|
|
253
|
+
// Title
|
|
254
|
+
if (content.title) {
|
|
255
|
+
const title = document.createElement('h2');
|
|
256
|
+
title.id = `xp-modal-title-${experienceId}`;
|
|
257
|
+
title.className = 'xp-modal__title';
|
|
258
|
+
title.textContent = content.title;
|
|
259
|
+
title.style.cssText = getTitleStyles();
|
|
260
|
+
contentWrapper.appendChild(title);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Message
|
|
264
|
+
const message = document.createElement('div');
|
|
265
|
+
message.className = 'xp-modal__message';
|
|
266
|
+
message.innerHTML = sanitizeHTML(content.message);
|
|
267
|
+
message.style.cssText = getMessageStyles();
|
|
268
|
+
contentWrapper.appendChild(message);
|
|
269
|
+
|
|
270
|
+
// Form or Buttons
|
|
271
|
+
if (content.form) {
|
|
272
|
+
// Render form
|
|
273
|
+
const form = renderForm(experienceId, content.form);
|
|
274
|
+
contentWrapper.appendChild(form);
|
|
275
|
+
|
|
276
|
+
// Store form config for later use (state rendering)
|
|
277
|
+
(container as any).__formConfig = content.form;
|
|
278
|
+
|
|
279
|
+
// Initialize form data
|
|
280
|
+
const data: Record<string, string> = {};
|
|
281
|
+
content.form.fields.forEach((field) => {
|
|
282
|
+
data[field.name] = '';
|
|
283
|
+
});
|
|
284
|
+
formData.set(experienceId, data);
|
|
285
|
+
|
|
286
|
+
// Add form event listeners
|
|
287
|
+
content.form.fields.forEach((field) => {
|
|
288
|
+
const input = form.querySelector(`#${experienceId}-${field.name}`) as
|
|
289
|
+
| HTMLInputElement
|
|
290
|
+
| HTMLTextAreaElement;
|
|
291
|
+
const errorEl = form.querySelector(`#${experienceId}-${field.name}-error`) as HTMLElement;
|
|
292
|
+
|
|
293
|
+
if (!input) return;
|
|
294
|
+
|
|
295
|
+
// Update form data on input change
|
|
296
|
+
input.addEventListener('input', () => {
|
|
297
|
+
const currentData = formData.get(experienceId) || {};
|
|
298
|
+
currentData[field.name] = input.value;
|
|
299
|
+
formData.set(experienceId, currentData);
|
|
300
|
+
|
|
301
|
+
// Emit change event
|
|
302
|
+
instance.emit('experiences:modal:form:change', {
|
|
303
|
+
experienceId,
|
|
304
|
+
field: field.name,
|
|
305
|
+
value: input.value,
|
|
306
|
+
formData: { ...currentData },
|
|
307
|
+
timestamp: Date.now(),
|
|
308
|
+
});
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
// Validate on blur
|
|
312
|
+
input.addEventListener('blur', () => {
|
|
313
|
+
const currentData = formData.get(experienceId) || {};
|
|
314
|
+
const result = validateField(field, currentData[field.name] || '');
|
|
315
|
+
|
|
316
|
+
if (!result.valid && result.errors) {
|
|
317
|
+
// Show error
|
|
318
|
+
input.style.cssText += `; ${getInputErrorStyles()}`;
|
|
319
|
+
input.setAttribute('aria-invalid', 'true');
|
|
320
|
+
errorEl.textContent = result.errors[field.name] || '';
|
|
321
|
+
|
|
322
|
+
// Emit validation event
|
|
323
|
+
instance.emit('experiences:modal:form:validation', {
|
|
324
|
+
experienceId,
|
|
325
|
+
field: field.name,
|
|
326
|
+
valid: false,
|
|
327
|
+
errors: result.errors,
|
|
328
|
+
timestamp: Date.now(),
|
|
329
|
+
});
|
|
330
|
+
} else {
|
|
331
|
+
// Clear error
|
|
332
|
+
input.style.cssText = input.style.cssText.replace(getInputErrorStyles(), '');
|
|
333
|
+
input.setAttribute('aria-invalid', 'false');
|
|
334
|
+
errorEl.textContent = '';
|
|
335
|
+
|
|
336
|
+
// Emit validation event
|
|
337
|
+
instance.emit('experiences:modal:form:validation', {
|
|
338
|
+
experienceId,
|
|
339
|
+
field: field.name,
|
|
340
|
+
valid: true,
|
|
341
|
+
timestamp: Date.now(),
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
// Handle form submission
|
|
348
|
+
form.addEventListener('submit', async (e) => {
|
|
349
|
+
e.preventDefault();
|
|
350
|
+
|
|
351
|
+
if (!content.form) return;
|
|
352
|
+
|
|
353
|
+
const currentData = formData.get(experienceId) || {};
|
|
354
|
+
const result = validateForm(content.form, currentData);
|
|
355
|
+
|
|
356
|
+
if (!result.valid && result.errors) {
|
|
357
|
+
// Show all errors
|
|
358
|
+
content.form.fields.forEach((field) => {
|
|
359
|
+
if (result.errors?.[field.name]) {
|
|
360
|
+
const input = form.querySelector(
|
|
361
|
+
`#${experienceId}-${field.name}`
|
|
362
|
+
) as HTMLInputElement;
|
|
363
|
+
const errorEl = form.querySelector(
|
|
364
|
+
`#${experienceId}-${field.name}-error`
|
|
365
|
+
) as HTMLElement;
|
|
366
|
+
|
|
367
|
+
if (input) {
|
|
368
|
+
input.style.cssText += `; ${getInputErrorStyles()}`;
|
|
369
|
+
input.setAttribute('aria-invalid', 'true');
|
|
370
|
+
}
|
|
371
|
+
if (errorEl) {
|
|
372
|
+
errorEl.textContent = result.errors[field.name] || '';
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// Emit validation failure
|
|
378
|
+
instance.emit('experiences:modal:form:validation', {
|
|
379
|
+
experienceId,
|
|
380
|
+
valid: false,
|
|
381
|
+
errors: result.errors,
|
|
382
|
+
timestamp: Date.now(),
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// Disable submit button
|
|
389
|
+
const submitButton = form.querySelector('button[type="submit"]') as HTMLButtonElement;
|
|
390
|
+
if (submitButton) {
|
|
391
|
+
submitButton.disabled = true;
|
|
392
|
+
submitButton.textContent = 'Submitting...';
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Emit submit event
|
|
396
|
+
instance.emit('experiences:modal:form:submit', {
|
|
397
|
+
experienceId,
|
|
398
|
+
formData: { ...currentData },
|
|
399
|
+
timestamp: Date.now(),
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
} else if (content.buttons && content.buttons.length > 0) {
|
|
403
|
+
// Render buttons
|
|
404
|
+
const buttonContainer = document.createElement('div');
|
|
405
|
+
buttonContainer.className = 'xp-modal__buttons';
|
|
406
|
+
buttonContainer.style.cssText = getButtonContainerStyles();
|
|
407
|
+
|
|
408
|
+
content.buttons.forEach((button: ExperienceButton) => {
|
|
409
|
+
const btn = document.createElement('button');
|
|
410
|
+
btn.className = `xp-modal__button xp-modal__button--${button.variant || 'secondary'}`;
|
|
411
|
+
btn.textContent = button.text;
|
|
412
|
+
|
|
413
|
+
// Apply button styles based on variant
|
|
414
|
+
if (button.variant === 'primary') {
|
|
415
|
+
btn.style.cssText = getPrimaryButtonStyles();
|
|
416
|
+
btn.onmouseover = () => {
|
|
417
|
+
btn.style.background = getPrimaryButtonHoverBg();
|
|
418
|
+
};
|
|
419
|
+
btn.onmouseout = () => {
|
|
420
|
+
btn.style.background = getPrimaryButtonDefaultBg();
|
|
421
|
+
};
|
|
422
|
+
} else {
|
|
423
|
+
btn.style.cssText = getSecondaryButtonStyles();
|
|
424
|
+
btn.onmouseover = () => {
|
|
425
|
+
btn.style.background = getSecondaryButtonHoverBg();
|
|
426
|
+
};
|
|
427
|
+
btn.onmouseout = () => {
|
|
428
|
+
btn.style.background = getSecondaryButtonDefaultBg();
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
btn.onclick = () => {
|
|
433
|
+
instance.emit('experiences:action', {
|
|
434
|
+
experienceId,
|
|
435
|
+
action: button.action,
|
|
436
|
+
button,
|
|
437
|
+
timestamp: Date.now(),
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
if (button.dismiss) {
|
|
441
|
+
removeModal(experienceId);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (button.url) {
|
|
445
|
+
window.location.href = button.url;
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
buttonContainer.appendChild(btn);
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
contentWrapper.appendChild(buttonContainer);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
dialog.appendChild(contentWrapper);
|
|
456
|
+
|
|
457
|
+
// Backdrop dismiss
|
|
458
|
+
if (modalConfig.backdropDismiss !== false) {
|
|
459
|
+
backdrop.onclick = () => removeModal(experienceId);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Escape key handler
|
|
463
|
+
const handleEscape = (e: KeyboardEvent) => {
|
|
464
|
+
if (e.key === 'Escape' && modalConfig.dismissable !== false) {
|
|
465
|
+
removeModal(experienceId);
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
document.addEventListener('keydown', handleEscape);
|
|
469
|
+
|
|
470
|
+
// Store cleanup for escape listener
|
|
471
|
+
(container as any).__cleanupEscape = () => {
|
|
472
|
+
document.removeEventListener('keydown', handleEscape);
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
return container;
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Show a modal experience
|
|
480
|
+
*/
|
|
481
|
+
const showModal = (experience: any): void => {
|
|
482
|
+
const experienceId = experience.id;
|
|
483
|
+
|
|
484
|
+
// Don't show if already showing
|
|
485
|
+
if (activeModals.has(experienceId)) return;
|
|
486
|
+
|
|
487
|
+
// Hide any existing modals (prevent stacking for better UX)
|
|
488
|
+
if (activeModals.size > 0) {
|
|
489
|
+
const existingIds = Array.from(activeModals.keys());
|
|
490
|
+
for (const id of existingIds) {
|
|
491
|
+
removeModal(id);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Store currently focused element
|
|
496
|
+
previouslyFocusedElement.set(experienceId, document.activeElement as HTMLElement);
|
|
497
|
+
|
|
498
|
+
// Render modal
|
|
499
|
+
const modal = renderModal(experienceId, experience.content);
|
|
500
|
+
document.body.appendChild(modal);
|
|
501
|
+
activeModals.set(experienceId, modal);
|
|
502
|
+
|
|
503
|
+
// Trigger animation after adding to DOM
|
|
504
|
+
const modalConfig = instance.get('modal') || {};
|
|
505
|
+
const animation = modalConfig.animation || 'fade';
|
|
506
|
+
|
|
507
|
+
if (animation !== 'none') {
|
|
508
|
+
requestAnimationFrame(() => {
|
|
509
|
+
modal.style.opacity = '1';
|
|
510
|
+
|
|
511
|
+
if (animation === 'slide-up') {
|
|
512
|
+
modal.style.transform = 'translateY(0)';
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Setup focus trap
|
|
518
|
+
const cleanupFocusTrap = createFocusTrap(modal);
|
|
519
|
+
(modal as any).__cleanupFocusTrap = cleanupFocusTrap;
|
|
520
|
+
|
|
521
|
+
// Emit shown event
|
|
522
|
+
instance.emit('experiences:shown', {
|
|
523
|
+
experienceId,
|
|
524
|
+
timestamp: Date.now(),
|
|
525
|
+
});
|
|
526
|
+
|
|
527
|
+
// Emit trigger event for context
|
|
528
|
+
instance.emit('trigger:modal', {
|
|
529
|
+
experienceId,
|
|
530
|
+
timestamp: Date.now(),
|
|
531
|
+
shown: true,
|
|
532
|
+
});
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Remove a modal
|
|
537
|
+
*/
|
|
538
|
+
const removeModal = (experienceId: string): void => {
|
|
539
|
+
const modal = activeModals.get(experienceId);
|
|
540
|
+
if (!modal) return;
|
|
541
|
+
|
|
542
|
+
// Cleanup focus trap
|
|
543
|
+
if ((modal as any).__cleanupFocusTrap) {
|
|
544
|
+
(modal as any).__cleanupFocusTrap();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Cleanup escape listener
|
|
548
|
+
if ((modal as any).__cleanupEscape) {
|
|
549
|
+
(modal as any).__cleanupEscape();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Return focus
|
|
553
|
+
const previousElement = previouslyFocusedElement.get(experienceId);
|
|
554
|
+
if (previousElement && document.body.contains(previousElement)) {
|
|
555
|
+
previousElement.focus();
|
|
556
|
+
}
|
|
557
|
+
previouslyFocusedElement.delete(experienceId);
|
|
558
|
+
|
|
559
|
+
// Remove from DOM
|
|
560
|
+
modal.remove();
|
|
561
|
+
activeModals.delete(experienceId);
|
|
562
|
+
|
|
563
|
+
// Emit dismissed event
|
|
564
|
+
instance.emit('experiences:dismissed', {
|
|
565
|
+
experienceId,
|
|
566
|
+
timestamp: Date.now(),
|
|
567
|
+
});
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Check if a modal is showing
|
|
572
|
+
*/
|
|
573
|
+
const isShowing = (experienceId?: string): boolean => {
|
|
574
|
+
if (experienceId) {
|
|
575
|
+
return activeModals.has(experienceId);
|
|
576
|
+
}
|
|
577
|
+
return activeModals.size > 0;
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Show form success or error state
|
|
582
|
+
*/
|
|
583
|
+
const showFormState = (experienceId: string, state: 'success' | 'error'): void => {
|
|
584
|
+
const modal = activeModals.get(experienceId);
|
|
585
|
+
if (!modal) return;
|
|
586
|
+
|
|
587
|
+
const form = modal.querySelector('.xp-modal__form') as HTMLFormElement;
|
|
588
|
+
if (!form) return;
|
|
589
|
+
|
|
590
|
+
// Get the form config from the experience
|
|
591
|
+
// Note: We need to store this when the modal is created
|
|
592
|
+
const formConfig = (modal as any).__formConfig;
|
|
593
|
+
if (!formConfig) return;
|
|
594
|
+
|
|
595
|
+
const stateConfig = state === 'success' ? formConfig.successState : formConfig.errorState;
|
|
596
|
+
if (!stateConfig) return;
|
|
597
|
+
|
|
598
|
+
// Render state element
|
|
599
|
+
const stateEl = renderFormState(state, stateConfig);
|
|
600
|
+
|
|
601
|
+
// Replace form with state
|
|
602
|
+
form.replaceWith(stateEl);
|
|
603
|
+
|
|
604
|
+
// Emit state event
|
|
605
|
+
instance.emit('experiences:modal:form:state', {
|
|
606
|
+
experienceId,
|
|
607
|
+
state,
|
|
608
|
+
timestamp: Date.now(),
|
|
609
|
+
});
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Reset form to initial state
|
|
614
|
+
*/
|
|
615
|
+
const resetForm = (experienceId: string): void => {
|
|
616
|
+
const modal = activeModals.get(experienceId);
|
|
617
|
+
if (!modal) return;
|
|
618
|
+
|
|
619
|
+
const form = modal.querySelector('.xp-modal__form') as HTMLFormElement;
|
|
620
|
+
if (!form) return;
|
|
621
|
+
|
|
622
|
+
// Reset form element
|
|
623
|
+
form.reset();
|
|
624
|
+
|
|
625
|
+
// Clear form data
|
|
626
|
+
const data = formData.get(experienceId);
|
|
627
|
+
if (data) {
|
|
628
|
+
Object.keys(data).forEach((key) => {
|
|
629
|
+
data[key] = '';
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// Clear all error messages
|
|
634
|
+
const errors = form.querySelectorAll('.xp-form__error');
|
|
635
|
+
errors.forEach((error) => {
|
|
636
|
+
(error as HTMLElement).textContent = '';
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
// Reset all inputs
|
|
640
|
+
const inputs = form.querySelectorAll('.xp-form__input') as NodeListOf<
|
|
641
|
+
HTMLInputElement | HTMLTextAreaElement
|
|
642
|
+
>;
|
|
643
|
+
inputs.forEach((input) => {
|
|
644
|
+
input.setAttribute('aria-invalid', 'false');
|
|
645
|
+
input.style.cssText = input.style.cssText.replace(getInputErrorStyles(), '');
|
|
646
|
+
});
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Get current form data
|
|
651
|
+
*/
|
|
652
|
+
const getFormData = (experienceId: string): Record<string, string> | null => {
|
|
653
|
+
return formData.get(experienceId) || null;
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
// Expose public API
|
|
657
|
+
plugin.expose({
|
|
658
|
+
modal: {
|
|
659
|
+
show: showModal,
|
|
660
|
+
remove: removeModal,
|
|
661
|
+
isShowing,
|
|
662
|
+
showFormState,
|
|
663
|
+
resetForm,
|
|
664
|
+
getFormData,
|
|
665
|
+
} as ModalPlugin,
|
|
666
|
+
});
|
|
667
|
+
|
|
668
|
+
// Auto-show modal experiences when evaluated
|
|
669
|
+
instance.on('experiences:evaluated', (data: any) => {
|
|
670
|
+
const { decision, experience } = data;
|
|
671
|
+
if (decision.show && decision.experienceId && experience) {
|
|
672
|
+
// Check if this is a modal experience (using 'type' property)
|
|
673
|
+
if (experience.type === 'modal') {
|
|
674
|
+
showModal(experience);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
// Cleanup on destroy
|
|
680
|
+
instance.on('sdk:destroy', () => {
|
|
681
|
+
activeModals.forEach((_, id) => {
|
|
682
|
+
removeModal(id);
|
|
683
|
+
});
|
|
684
|
+
});
|
|
685
|
+
};
|