@prosdevlab/experience-sdk-plugins 0.1.4 → 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.
Files changed (43) hide show
  1. package/.turbo/turbo-build.log +6 -6
  2. package/CHANGELOG.md +150 -0
  3. package/README.md +141 -79
  4. package/dist/index.d.ts +813 -35
  5. package/dist/index.js +1910 -66
  6. package/dist/index.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/banner/banner.ts +63 -62
  9. package/src/exit-intent/exit-intent.test.ts +423 -0
  10. package/src/exit-intent/exit-intent.ts +371 -0
  11. package/src/exit-intent/index.ts +6 -0
  12. package/src/exit-intent/types.ts +59 -0
  13. package/src/index.ts +7 -0
  14. package/src/inline/index.ts +3 -0
  15. package/src/inline/inline.test.ts +620 -0
  16. package/src/inline/inline.ts +269 -0
  17. package/src/inline/insertion.ts +66 -0
  18. package/src/inline/types.ts +52 -0
  19. package/src/integration.test.ts +421 -0
  20. package/src/modal/form-rendering.ts +262 -0
  21. package/src/modal/form-styles.ts +212 -0
  22. package/src/modal/form-validation.test.ts +413 -0
  23. package/src/modal/form-validation.ts +126 -0
  24. package/src/modal/index.ts +3 -0
  25. package/src/modal/modal-styles.ts +204 -0
  26. package/src/modal/modal.browser.test.ts +164 -0
  27. package/src/modal/modal.test.ts +1294 -0
  28. package/src/modal/modal.ts +685 -0
  29. package/src/modal/types.ts +114 -0
  30. package/src/page-visits/index.ts +6 -0
  31. package/src/page-visits/page-visits.test.ts +562 -0
  32. package/src/page-visits/page-visits.ts +314 -0
  33. package/src/page-visits/types.ts +119 -0
  34. package/src/scroll-depth/index.ts +6 -0
  35. package/src/scroll-depth/scroll-depth.test.ts +580 -0
  36. package/src/scroll-depth/scroll-depth.ts +398 -0
  37. package/src/scroll-depth/types.ts +122 -0
  38. package/src/time-delay/index.ts +6 -0
  39. package/src/time-delay/time-delay.test.ts +477 -0
  40. package/src/time-delay/time-delay.ts +296 -0
  41. package/src/time-delay/types.ts +89 -0
  42. package/src/types.ts +20 -36
  43. package/src/utils/sanitize.ts +5 -2
@@ -0,0 +1,421 @@
1
+ /**
2
+ * Integration Tests
3
+ *
4
+ * Tests the interaction between plugins to ensure they work together correctly.
5
+ *
6
+ * @vitest-environment happy-dom
7
+ */
8
+ import { SDK } from '@lytics/sdk-kit';
9
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
10
+ import { inlinePlugin } from './inline';
11
+ import { modalPlugin } from './modal';
12
+
13
+ function initSDK() {
14
+ const sdk = new SDK({ name: 'integration-test' });
15
+ sdk.use(modalPlugin);
16
+ sdk.use(inlinePlugin);
17
+
18
+ if (!document.body) {
19
+ document.body = document.createElement('body');
20
+ }
21
+
22
+ return sdk;
23
+ }
24
+
25
+ describe('Plugin Integration Tests', () => {
26
+ let sdk: SDK & { modal?: any; inline?: any };
27
+
28
+ beforeEach(async () => {
29
+ sdk = initSDK();
30
+ await sdk.init();
31
+ });
32
+
33
+ afterEach(async () => {
34
+ for (const el of document.querySelectorAll('.xp-modal, .xp-inline')) {
35
+ el.remove();
36
+ }
37
+ document.body.innerHTML = '';
38
+ if (sdk) {
39
+ await sdk.destroy();
40
+ }
41
+ });
42
+
43
+ describe('Modal + Inline Interaction', () => {
44
+ it('should show modal and inline simultaneously', async () => {
45
+ const shownHandler = vi.fn();
46
+ sdk.on('experiences:shown', shownHandler);
47
+
48
+ const target = document.createElement('div');
49
+ target.id = 'content';
50
+ document.body.appendChild(target);
51
+
52
+ const modalExp = {
53
+ id: 'popup',
54
+ type: 'modal',
55
+ content: {
56
+ title: 'Special Offer',
57
+ message: 'Limited time only!',
58
+ buttons: [{ text: 'Learn More', variant: 'primary' }],
59
+ },
60
+ };
61
+
62
+ const inlineExp = {
63
+ id: 'inline-banner',
64
+ type: 'inline',
65
+ content: {
66
+ selector: '#content',
67
+ message: '<p>Related: Check out our guide.</p>',
68
+ },
69
+ };
70
+
71
+ sdk.modal.show(modalExp);
72
+ sdk.inline.show(inlineExp);
73
+
74
+ await vi.waitFor(() => {
75
+ expect(shownHandler).toHaveBeenCalledTimes(2);
76
+ });
77
+
78
+ expect(document.querySelector('.xp-modal')).toBeTruthy();
79
+ expect(document.querySelector('.xp-inline')).toBeTruthy();
80
+ });
81
+
82
+ it('should dismiss modal without affecting inline', async () => {
83
+ const dismissedHandler = vi.fn();
84
+ sdk.on('experiences:dismissed', dismissedHandler);
85
+
86
+ const target = document.createElement('div');
87
+ target.id = 'content';
88
+ document.body.appendChild(target);
89
+
90
+ const modalExp = {
91
+ id: 'dismissable-modal',
92
+ type: 'modal',
93
+ content: {
94
+ title: 'Notification',
95
+ message: 'This is a modal.',
96
+ dismissable: true,
97
+ },
98
+ };
99
+
100
+ const inlineExp = {
101
+ id: 'persistent-inline',
102
+ type: 'inline',
103
+ content: {
104
+ selector: '#content',
105
+ message: '<p>This stays.</p>',
106
+ },
107
+ };
108
+
109
+ sdk.modal.show(modalExp);
110
+ sdk.inline.show(inlineExp);
111
+
112
+ await vi.waitFor(() => {
113
+ expect(document.querySelector('.xp-modal')).toBeTruthy();
114
+ expect(document.querySelector('.xp-inline')).toBeTruthy();
115
+ });
116
+
117
+ // Dismiss modal
118
+ const closeBtn = document.querySelector('.xp-modal__close') as HTMLElement;
119
+ closeBtn.click();
120
+
121
+ await vi.waitFor(() => {
122
+ expect(dismissedHandler).toHaveBeenCalledWith(
123
+ expect.objectContaining({
124
+ experienceId: 'dismissable-modal',
125
+ })
126
+ );
127
+ });
128
+
129
+ // Modal gone, inline remains
130
+ expect(document.querySelector('.xp-modal')).toBeFalsy();
131
+ expect(document.querySelector('.xp-inline')).toBeTruthy();
132
+ });
133
+
134
+ it('should dismiss inline without affecting modal', async () => {
135
+ const target = document.createElement('div');
136
+ target.id = 'content';
137
+ document.body.appendChild(target);
138
+
139
+ const modalExp = {
140
+ id: 'persistent-modal',
141
+ type: 'modal',
142
+ content: {
143
+ title: 'Stay Open',
144
+ message: 'This modal stays.',
145
+ },
146
+ };
147
+
148
+ const inlineExp = {
149
+ id: 'dismissable-inline',
150
+ type: 'inline',
151
+ content: {
152
+ selector: '#content',
153
+ message: '<p>Can dismiss</p>',
154
+ dismissable: true,
155
+ },
156
+ };
157
+
158
+ sdk.modal.show(modalExp);
159
+ sdk.inline.show(inlineExp);
160
+
161
+ await vi.waitFor(() => {
162
+ expect(document.querySelector('.xp-modal')).toBeTruthy();
163
+ expect(document.querySelector('.xp-inline')).toBeTruthy();
164
+ });
165
+
166
+ // Dismiss inline
167
+ const closeBtn = document.querySelector('.xp-inline__close') as HTMLElement;
168
+ closeBtn.click();
169
+
170
+ await vi.waitFor(() => {
171
+ expect(document.querySelector('.xp-inline')).toBeFalsy();
172
+ });
173
+
174
+ // Inline gone, modal remains
175
+ expect(document.querySelector('.xp-modal')).toBeTruthy();
176
+ });
177
+ });
178
+
179
+ describe('Modal Forms', () => {
180
+ it('should render and submit form in modal', async () => {
181
+ const formSubmitHandler = vi.fn();
182
+ sdk.on('experiences:modal:form:submit', formSubmitHandler);
183
+
184
+ const experience = {
185
+ id: 'newsletter',
186
+ type: 'modal',
187
+ content: {
188
+ title: 'Subscribe',
189
+ message: 'Get updates.',
190
+ size: 'sm',
191
+ form: {
192
+ fields: [
193
+ { name: 'email', type: 'email', required: true, placeholder: 'you@example.com' },
194
+ ],
195
+ submitButton: { text: 'Subscribe', variant: 'primary' },
196
+ },
197
+ },
198
+ };
199
+
200
+ sdk.modal.show(experience);
201
+
202
+ await vi.waitFor(() => {
203
+ expect(document.querySelector('.xp-modal__form')).toBeTruthy();
204
+ });
205
+
206
+ // Fill and submit form
207
+ const emailInput = document.querySelector('input[name="email"]') as HTMLInputElement;
208
+ emailInput.value = 'test@example.com';
209
+ emailInput.dispatchEvent(new Event('input', { bubbles: true }));
210
+
211
+ const form = document.querySelector('.xp-modal__form') as HTMLFormElement;
212
+ form.dispatchEvent(new Event('submit', { bubbles: true }));
213
+
214
+ await vi.waitFor(() => {
215
+ expect(formSubmitHandler).toHaveBeenCalledWith(
216
+ expect.objectContaining({
217
+ experienceId: 'newsletter',
218
+ formData: { email: 'test@example.com' },
219
+ })
220
+ );
221
+ });
222
+ });
223
+
224
+ it('should validate form fields', async () => {
225
+ const validationHandler = vi.fn();
226
+ sdk.on('experiences:modal:form:validation', validationHandler);
227
+
228
+ const experience = {
229
+ id: 'form-validation',
230
+ type: 'modal',
231
+ content: {
232
+ form: {
233
+ fields: [{ name: 'email', type: 'email', required: true }],
234
+ submitButton: { text: 'Submit', variant: 'primary' },
235
+ },
236
+ },
237
+ };
238
+
239
+ sdk.modal.show(experience);
240
+
241
+ await vi.waitFor(() => {
242
+ expect(document.querySelector('.xp-modal__form')).toBeTruthy();
243
+ });
244
+
245
+ // Submit empty form (should fail validation)
246
+ const form = document.querySelector('.xp-modal__form') as HTMLFormElement;
247
+ form.dispatchEvent(new Event('submit', { bubbles: true }));
248
+
249
+ await vi.waitFor(() => {
250
+ expect(validationHandler).toHaveBeenCalledWith(
251
+ expect.objectContaining({
252
+ valid: false,
253
+ errors: expect.any(Object),
254
+ })
255
+ );
256
+ });
257
+ });
258
+ });
259
+
260
+ describe('Multiple Instances', () => {
261
+ it('should handle multiple inline experiences in different locations', async () => {
262
+ const target1 = document.createElement('div');
263
+ target1.id = 'sidebar';
264
+ document.body.appendChild(target1);
265
+
266
+ const target2 = document.createElement('div');
267
+ target2.id = 'footer';
268
+ document.body.appendChild(target2);
269
+
270
+ sdk.inline.show({
271
+ id: 'sidebar-promo',
272
+ type: 'inline',
273
+ content: {
274
+ selector: '#sidebar',
275
+ message: '<p>Sidebar content</p>',
276
+ },
277
+ });
278
+
279
+ sdk.inline.show({
280
+ id: 'footer-cta',
281
+ type: 'inline',
282
+ content: {
283
+ selector: '#footer',
284
+ message: '<p>Footer content</p>',
285
+ },
286
+ });
287
+
288
+ await vi.waitFor(() => {
289
+ expect(document.querySelectorAll('.xp-inline').length).toBe(2);
290
+ });
291
+
292
+ expect(target1.querySelector('.xp-inline')).toBeTruthy();
293
+ expect(target2.querySelector('.xp-inline')).toBeTruthy();
294
+ });
295
+
296
+ it('should replace existing modal when showing a new one', async () => {
297
+ const dismissedHandler = vi.fn();
298
+ sdk.on('experiences:dismissed', dismissedHandler);
299
+
300
+ // Show first modal
301
+ sdk.modal.show({
302
+ id: 'modal1',
303
+ type: 'modal',
304
+ content: { title: 'First', message: 'Modal 1' },
305
+ });
306
+
307
+ await vi.waitFor(() => {
308
+ expect(sdk.modal.isShowing('modal1')).toBe(true);
309
+ });
310
+
311
+ // Show second modal (should replace first)
312
+ sdk.modal.show({
313
+ id: 'modal2',
314
+ type: 'modal',
315
+ content: { title: 'Second', message: 'Modal 2' },
316
+ });
317
+
318
+ await vi.waitFor(() => {
319
+ expect(sdk.modal.isShowing('modal2')).toBe(true);
320
+ });
321
+
322
+ // Only second modal should be showing
323
+ expect(sdk.modal.isShowing('modal1')).toBe(false);
324
+ expect(sdk.modal.isShowing('modal2')).toBe(true);
325
+ expect(document.querySelectorAll('.xp-modal').length).toBe(1);
326
+ });
327
+
328
+ it('should prevent showing the same modal twice', async () => {
329
+ const shownHandler = vi.fn();
330
+ sdk.on('experiences:shown', shownHandler);
331
+
332
+ const experience = {
333
+ id: 'duplicate-test',
334
+ type: 'modal',
335
+ content: { title: 'Test', message: 'Cannot show twice' },
336
+ };
337
+
338
+ sdk.modal.show(experience);
339
+ sdk.modal.show(experience); // Try to show again
340
+
341
+ await vi.waitFor(() => {
342
+ expect(shownHandler).toHaveBeenCalledTimes(1);
343
+ });
344
+
345
+ // Only one modal in DOM
346
+ expect(document.querySelectorAll('[data-xp-id="duplicate-test"]').length).toBe(1);
347
+ });
348
+ });
349
+
350
+ describe('Cleanup', () => {
351
+ it('should clean up all experiences on destroy', async () => {
352
+ const target = document.createElement('div');
353
+ target.id = 'content';
354
+ document.body.appendChild(target);
355
+
356
+ sdk.modal.show({
357
+ id: 'modal',
358
+ type: 'modal',
359
+ content: { title: 'Modal', message: 'Content' },
360
+ });
361
+
362
+ sdk.inline.show({
363
+ id: 'inline',
364
+ type: 'inline',
365
+ content: { selector: '#content', message: '<p>Inline</p>' },
366
+ });
367
+
368
+ await vi.waitFor(() => {
369
+ expect(document.querySelector('.xp-modal')).toBeTruthy();
370
+ expect(document.querySelector('.xp-inline')).toBeTruthy();
371
+ });
372
+
373
+ await sdk.destroy();
374
+
375
+ expect(document.querySelector('.xp-modal')).toBeFalsy();
376
+ expect(document.querySelector('.xp-inline')).toBeFalsy();
377
+ });
378
+ });
379
+
380
+ describe('Event Flow', () => {
381
+ it('should emit events in correct order for modal', async () => {
382
+ const events: string[] = [];
383
+
384
+ sdk.on('experiences:shown', () => events.push('shown'));
385
+ sdk.on('experiences:action', () => events.push('action'));
386
+ sdk.on('experiences:dismissed', () => events.push('dismissed'));
387
+
388
+ sdk.modal.show({
389
+ id: 'event-test',
390
+ type: 'modal',
391
+ content: {
392
+ title: 'Test',
393
+ message: 'Testing events',
394
+ buttons: [{ text: 'Click Me', variant: 'primary', action: 'test' }],
395
+ dismissable: true,
396
+ },
397
+ });
398
+
399
+ await vi.waitFor(() => {
400
+ expect(document.querySelector('.xp-modal')).toBeTruthy();
401
+ });
402
+
403
+ // Click button
404
+ const button = document.querySelector('.xp-modal__button') as HTMLElement;
405
+ button.click();
406
+
407
+ await vi.waitFor(() => {
408
+ expect(events).toContain('action');
409
+ });
410
+
411
+ // Dismiss modal
412
+ sdk.modal.remove('event-test');
413
+
414
+ await vi.waitFor(() => {
415
+ expect(events).toContain('dismissed');
416
+ });
417
+
418
+ expect(events).toEqual(['shown', 'action', 'dismissed']);
419
+ });
420
+ });
421
+ });
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Pure form rendering functions
3
+ *
4
+ * These functions are intentionally pure (return DOM elements, no side effects) to make them:
5
+ * - Easy to test
6
+ * - Easy to extract into a separate form plugin later
7
+ * - Reusable across different contexts
8
+ */
9
+
10
+ import type { ExperienceButton } from '../types';
11
+ import {
12
+ getErrorMessageStyles,
13
+ getErrorStateStyles,
14
+ getFieldStyles,
15
+ getFormStateStyles,
16
+ getFormStyles,
17
+ getInputStyles,
18
+ getLabelStyles,
19
+ getRequiredStyles,
20
+ getStateButtonsStyles,
21
+ getStateMessageStyles,
22
+ getStateTitleStyles,
23
+ getSubmitButtonHoverBg,
24
+ getSubmitButtonStyles,
25
+ getSuccessStateStyles,
26
+ } from './form-styles';
27
+ import type { FormConfig, FormField } from './types';
28
+
29
+ /**
30
+ * Render complete form element
31
+ *
32
+ * @param experienceId - Experience ID for namespacing field IDs
33
+ * @param config - Form configuration
34
+ * @returns Form HTML element
35
+ */
36
+ export function renderForm(experienceId: string, config: FormConfig): HTMLFormElement {
37
+ const form = document.createElement('form');
38
+ form.className = 'xp-modal__form';
39
+ form.style.cssText = getFormStyles();
40
+ form.dataset.xpExperienceId = experienceId;
41
+ form.setAttribute('novalidate', ''); // Use custom validation instead of browser default
42
+
43
+ // Render each field
44
+ config.fields.forEach((field) => {
45
+ const fieldElement = renderFormField(experienceId, field);
46
+ form.appendChild(fieldElement);
47
+ });
48
+
49
+ // Render submit button
50
+ const submitButton = renderSubmitButton(config.submitButton);
51
+ form.appendChild(submitButton);
52
+
53
+ return form;
54
+ }
55
+
56
+ /**
57
+ * Render a single form field with label and error container
58
+ *
59
+ * @param experienceId - Experience ID for namespacing field ID
60
+ * @param field - Field configuration
61
+ * @returns Field wrapper HTML element
62
+ */
63
+ export function renderFormField(experienceId: string, field: FormField): HTMLElement {
64
+ const wrapper = document.createElement('div');
65
+ wrapper.className = 'xp-form__field';
66
+ wrapper.style.cssText = getFieldStyles();
67
+
68
+ // Label (optional)
69
+ if (field.label) {
70
+ const label = document.createElement('label');
71
+ label.className = 'xp-form__label';
72
+ label.style.cssText = getLabelStyles();
73
+ label.htmlFor = `${experienceId}-${field.name}`;
74
+ label.textContent = field.label;
75
+
76
+ // Required indicator
77
+ if (field.required) {
78
+ const required = document.createElement('span');
79
+ required.className = 'xp-form__required';
80
+ required.style.cssText = getRequiredStyles();
81
+ required.textContent = ' *';
82
+ required.setAttribute('aria-label', 'required');
83
+ label.appendChild(required);
84
+ }
85
+
86
+ wrapper.appendChild(label);
87
+ }
88
+
89
+ // Input element (input or textarea)
90
+ const input =
91
+ field.type === 'textarea'
92
+ ? document.createElement('textarea')
93
+ : document.createElement('input');
94
+
95
+ input.className = 'xp-form__input';
96
+ input.style.cssText = getInputStyles();
97
+ input.id = `${experienceId}-${field.name}`;
98
+ input.name = field.name;
99
+
100
+ // Set type for input elements
101
+ if (input instanceof HTMLInputElement) {
102
+ input.type = field.type;
103
+ }
104
+
105
+ // Placeholder
106
+ if (field.placeholder) {
107
+ input.placeholder = field.placeholder;
108
+ }
109
+
110
+ // Required attribute (for screen readers)
111
+ if (field.required) {
112
+ input.required = true;
113
+ }
114
+
115
+ // Pattern attribute (for HTML5 validation as fallback)
116
+ if (field.pattern && input instanceof HTMLInputElement) {
117
+ input.setAttribute('pattern', field.pattern);
118
+ }
119
+
120
+ // Accessibility attributes
121
+ input.setAttribute('aria-invalid', 'false');
122
+ input.setAttribute('aria-describedby', `${experienceId}-${field.name}-error`);
123
+
124
+ // Custom styling
125
+ if (field.className) {
126
+ input.className += ` ${field.className}`;
127
+ }
128
+ if (field.style) {
129
+ Object.assign(input.style, field.style);
130
+ }
131
+
132
+ wrapper.appendChild(input);
133
+
134
+ // Error message container
135
+ const error = document.createElement('div');
136
+ error.className = 'xp-form__error';
137
+ error.style.cssText = getErrorMessageStyles();
138
+ error.id = `${experienceId}-${field.name}-error`;
139
+ error.setAttribute('role', 'alert');
140
+ error.setAttribute('aria-live', 'polite');
141
+ wrapper.appendChild(error);
142
+
143
+ return wrapper;
144
+ }
145
+
146
+ /**
147
+ * Render submit button
148
+ *
149
+ * @param buttonConfig - Button configuration
150
+ * @returns Button HTML element
151
+ */
152
+ export function renderSubmitButton(buttonConfig: ExperienceButton): HTMLButtonElement {
153
+ const button = document.createElement('button');
154
+ button.type = 'submit';
155
+ button.className = 'xp-form__submit xp-modal__button';
156
+ button.style.cssText = getSubmitButtonStyles();
157
+
158
+ // Variant styling
159
+ if (buttonConfig.variant) {
160
+ button.className += ` xp-modal__button--${buttonConfig.variant}`;
161
+ }
162
+
163
+ // Custom class
164
+ if (buttonConfig.className) {
165
+ button.className += ` ${buttonConfig.className}`;
166
+ }
167
+
168
+ // Button text
169
+ button.textContent = buttonConfig.text;
170
+
171
+ // Hover effect using CSS variable
172
+ const hoverBg = getSubmitButtonHoverBg();
173
+ button.onmouseover = () => {
174
+ button.style.backgroundColor = hoverBg;
175
+ };
176
+ button.onmouseout = () => {
177
+ button.style.backgroundColor = ''; // Reset to CSS variable default
178
+ };
179
+
180
+ // Custom styling (applied last to allow overrides)
181
+ if (buttonConfig.style) {
182
+ Object.assign(button.style, buttonConfig.style);
183
+ }
184
+
185
+ return button;
186
+ }
187
+
188
+ /**
189
+ * Render form state (success or error)
190
+ *
191
+ * @param state - State configuration ('success' or 'error')
192
+ * @param stateConfig - State content configuration
193
+ * @returns State HTML element
194
+ */
195
+ export function renderFormState(
196
+ state: 'success' | 'error',
197
+ stateConfig: { title?: string; message: string; buttons?: ExperienceButton[] }
198
+ ): HTMLElement {
199
+ const stateEl = document.createElement('div');
200
+ stateEl.className = `xp-form__state xp-form__state--${state}`;
201
+
202
+ // Base styles + state-specific styles
203
+ const baseStyles = getFormStateStyles();
204
+ const stateStyles = state === 'success' ? getSuccessStateStyles() : getErrorStateStyles();
205
+ stateEl.style.cssText = `${baseStyles}; ${stateStyles}`;
206
+
207
+ // Title (optional)
208
+ if (stateConfig.title) {
209
+ const title = document.createElement('h3');
210
+ title.className = 'xp-form__state-title';
211
+ title.style.cssText = getStateTitleStyles();
212
+ title.textContent = stateConfig.title;
213
+ stateEl.appendChild(title);
214
+ }
215
+
216
+ // Message
217
+ const message = document.createElement('div');
218
+ message.className = 'xp-form__state-message';
219
+ message.style.cssText = getStateMessageStyles();
220
+ message.textContent = stateConfig.message;
221
+ stateEl.appendChild(message);
222
+
223
+ // Buttons (optional)
224
+ if (stateConfig.buttons && stateConfig.buttons.length > 0) {
225
+ const buttonContainer = document.createElement('div');
226
+ buttonContainer.className = 'xp-form__state-buttons';
227
+ buttonContainer.style.cssText = getStateButtonsStyles();
228
+
229
+ stateConfig.buttons.forEach((btnConfig) => {
230
+ const btn = document.createElement('button');
231
+ btn.type = 'button';
232
+ btn.className = 'xp-modal__button';
233
+
234
+ if (btnConfig.variant) {
235
+ btn.className += ` xp-modal__button--${btnConfig.variant}`;
236
+ }
237
+ if (btnConfig.className) {
238
+ btn.className += ` ${btnConfig.className}`;
239
+ }
240
+
241
+ btn.textContent = btnConfig.text;
242
+
243
+ if (btnConfig.style) {
244
+ Object.assign(btn.style, btnConfig.style);
245
+ }
246
+
247
+ // Store action/dismiss metadata for event handlers
248
+ if (btnConfig.action) {
249
+ btn.dataset.action = btnConfig.action;
250
+ }
251
+ if (btnConfig.dismiss) {
252
+ btn.dataset.dismiss = 'true';
253
+ }
254
+
255
+ buttonContainer.appendChild(btn);
256
+ });
257
+
258
+ stateEl.appendChild(buttonContainer);
259
+ }
260
+
261
+ return stateEl;
262
+ }