@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,126 @@
1
+ /**
2
+ * Pure validation functions for form fields
3
+ *
4
+ * These functions are intentionally pure (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 { FormConfig, FormField, ValidationResult } from './types';
11
+
12
+ /**
13
+ * Validate a single form field
14
+ *
15
+ * @param field - Field configuration
16
+ * @param value - Current field value
17
+ * @returns Validation result with errors if invalid
18
+ */
19
+ export function validateField(field: FormField, value: string): ValidationResult {
20
+ const errors: Record<string, string> = {};
21
+
22
+ // Required field validation
23
+ if (field.required && (!value || value.trim() === '')) {
24
+ errors[field.name] = field.errorMessage || `${field.label || field.name} is required`;
25
+ return { valid: false, errors };
26
+ }
27
+
28
+ // Skip further validation if field is empty and not required
29
+ if (!value || value.trim() === '') {
30
+ return { valid: true };
31
+ }
32
+
33
+ // Type-specific validation
34
+ switch (field.type) {
35
+ case 'email': {
36
+ const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
37
+ if (!emailRegex.test(value)) {
38
+ errors[field.name] = field.errorMessage || 'Please enter a valid email address';
39
+ }
40
+ break;
41
+ }
42
+
43
+ case 'url': {
44
+ try {
45
+ new URL(value);
46
+ } catch {
47
+ errors[field.name] = field.errorMessage || 'Please enter a valid URL';
48
+ }
49
+ break;
50
+ }
51
+
52
+ case 'tel': {
53
+ // Basic phone validation (allows digits, spaces, dashes, parentheses, plus)
54
+ const phoneRegex = /^[\d\s\-()+]+$/;
55
+ if (!phoneRegex.test(value)) {
56
+ errors[field.name] = field.errorMessage || 'Please enter a valid phone number';
57
+ }
58
+ break;
59
+ }
60
+
61
+ case 'number': {
62
+ if (Number.isNaN(Number(value))) {
63
+ errors[field.name] = field.errorMessage || 'Please enter a valid number';
64
+ }
65
+ break;
66
+ }
67
+ }
68
+
69
+ // Custom pattern validation (regex)
70
+ if (field.pattern && value) {
71
+ try {
72
+ const regex = new RegExp(field.pattern);
73
+ if (!regex.test(value)) {
74
+ errors[field.name] =
75
+ field.errorMessage || `Invalid format for ${field.label || field.name}`;
76
+ }
77
+ } catch (_error) {
78
+ // Invalid regex pattern - log warning but don't break validation
79
+ console.warn(`Invalid regex pattern for field ${field.name}:`, field.pattern);
80
+ }
81
+ }
82
+
83
+ return {
84
+ valid: Object.keys(errors).length === 0,
85
+ errors: Object.keys(errors).length > 0 ? errors : undefined,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Validate entire form
91
+ *
92
+ * @param config - Form configuration
93
+ * @param data - Current form data
94
+ * @returns Validation result with all field errors if invalid
95
+ */
96
+ export function validateForm(config: FormConfig, data: Record<string, string>): ValidationResult {
97
+ const errors: Record<string, string> = {};
98
+
99
+ // Validate each field
100
+ config.fields.forEach((field) => {
101
+ const value = data[field.name] || '';
102
+ const result = validateField(field, value);
103
+
104
+ if (!result.valid && result.errors) {
105
+ Object.assign(errors, result.errors);
106
+ }
107
+ });
108
+
109
+ // Custom validation function
110
+ if (config.validate) {
111
+ try {
112
+ const customResult = config.validate(data);
113
+ if (!customResult.valid && customResult.errors) {
114
+ Object.assign(errors, customResult.errors);
115
+ }
116
+ } catch (error) {
117
+ console.error('Custom validation function threw an error:', error);
118
+ // Don't prevent submission if custom validation has a bug
119
+ }
120
+ }
121
+
122
+ return {
123
+ valid: Object.keys(errors).length === 0,
124
+ errors: Object.keys(errors).length > 0 ? errors : undefined,
125
+ };
126
+ }
@@ -0,0 +1,3 @@
1
+ export { modalPlugin } from './modal';
2
+ export * from './types';
3
+ export * from './types';
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Modal styling with CSS variables for theming
3
+ *
4
+ * Design tokens for modal dialogs, fully customizable via CSS variables.
5
+ * Users can override by setting CSS variables in their stylesheet.
6
+ *
7
+ * @example
8
+ * ```css
9
+ * :root {
10
+ * --xp-modal-backdrop-bg: rgba(0, 0, 0, 0.7);
11
+ * --xp-modal-dialog-bg: #ffffff;
12
+ * --xp-modal-dialog-radius: 12px;
13
+ * }
14
+ * ```
15
+ */
16
+
17
+ /**
18
+ * Get CSS for modal backdrop
19
+ */
20
+ export function getBackdropStyles(): string {
21
+ return `
22
+ position: absolute;
23
+ inset: 0;
24
+ background-color: var(--xp-modal-backdrop-bg, rgba(0, 0, 0, 0.5));
25
+ `.trim();
26
+ }
27
+
28
+ /**
29
+ * Get CSS for modal dialog
30
+ */
31
+ export function getDialogStyles(params: {
32
+ width: string;
33
+ maxWidth: string;
34
+ height: string;
35
+ maxHeight: string;
36
+ borderRadius: string;
37
+ padding: string;
38
+ }): string {
39
+ return `
40
+ position: relative;
41
+ background: var(--xp-modal-dialog-bg, white);
42
+ border-radius: var(--xp-modal-dialog-radius, ${params.borderRadius});
43
+ box-shadow: var(--xp-modal-dialog-shadow, 0 4px 6px rgba(0, 0, 0, 0.1));
44
+ max-width: ${params.width};
45
+ width: ${params.maxWidth};
46
+ height: ${params.height};
47
+ max-height: ${params.maxHeight};
48
+ overflow-y: auto;
49
+ padding: ${params.padding};
50
+ `.trim();
51
+ }
52
+
53
+ /**
54
+ * Get CSS for hero image
55
+ */
56
+ export function getHeroImageStyles(params: { maxHeight: number; borderRadius: string }): string {
57
+ return `
58
+ width: 100%;
59
+ height: auto;
60
+ max-height: ${params.maxHeight}px;
61
+ object-fit: cover;
62
+ border-radius: ${params.borderRadius};
63
+ display: block;
64
+ margin: 0;
65
+ `.trim();
66
+ }
67
+
68
+ /**
69
+ * Get CSS for close button
70
+ */
71
+ export function getCloseButtonStyles(): string {
72
+ return `
73
+ position: absolute;
74
+ top: var(--xp-modal-close-top, 16px);
75
+ right: var(--xp-modal-close-right, 16px);
76
+ background: none;
77
+ border: none;
78
+ font-size: var(--xp-modal-close-size, 24px);
79
+ line-height: 1;
80
+ cursor: pointer;
81
+ padding: var(--xp-modal-close-padding, 4px 8px);
82
+ color: var(--xp-modal-close-color, #666);
83
+ opacity: var(--xp-modal-close-opacity, 0.7);
84
+ transition: opacity 0.2s;
85
+ `.trim();
86
+ }
87
+
88
+ /**
89
+ * Get close button hover opacity
90
+ */
91
+ export function getCloseButtonHoverOpacity(): string {
92
+ return 'var(--xp-modal-close-hover-opacity, 1)';
93
+ }
94
+
95
+ /**
96
+ * Get close button default opacity
97
+ */
98
+ export function getCloseButtonDefaultOpacity(): string {
99
+ return 'var(--xp-modal-close-opacity, 0.7)';
100
+ }
101
+
102
+ /**
103
+ * Get CSS for content wrapper
104
+ */
105
+ export function getContentWrapperStyles(padding: string): string {
106
+ return `padding: ${padding};`;
107
+ }
108
+
109
+ /**
110
+ * Get CSS for modal title
111
+ */
112
+ export function getTitleStyles(): string {
113
+ return `
114
+ margin: 0 0 var(--xp-modal-title-margin-bottom, 16px) 0;
115
+ font-size: var(--xp-modal-title-size, 20px);
116
+ font-weight: var(--xp-modal-title-weight, 600);
117
+ color: var(--xp-modal-title-color, #111);
118
+ `.trim();
119
+ }
120
+
121
+ /**
122
+ * Get CSS for modal message
123
+ */
124
+ export function getMessageStyles(): string {
125
+ return `
126
+ margin: 0 0 var(--xp-modal-message-margin-bottom, 20px) 0;
127
+ font-size: var(--xp-modal-message-size, 14px);
128
+ line-height: var(--xp-modal-message-line-height, 1.5);
129
+ color: var(--xp-modal-message-color, #444);
130
+ `.trim();
131
+ }
132
+
133
+ /**
134
+ * Get CSS for button container
135
+ */
136
+ export function getButtonContainerStyles(): string {
137
+ return `
138
+ display: flex;
139
+ gap: var(--xp-modal-buttons-gap, 8px);
140
+ flex-wrap: wrap;
141
+ `.trim();
142
+ }
143
+
144
+ /**
145
+ * Get CSS for primary button
146
+ */
147
+ export function getPrimaryButtonStyles(): string {
148
+ return `
149
+ padding: var(--xp-button-padding, 10px 20px);
150
+ font-size: var(--xp-button-font-size, 14px);
151
+ font-weight: var(--xp-button-font-weight, 500);
152
+ border-radius: var(--xp-button-radius, 6px);
153
+ cursor: pointer;
154
+ transition: all 0.2s;
155
+ border: none;
156
+ background: var(--xp-button-primary-bg, #2563eb);
157
+ color: var(--xp-button-primary-color, white);
158
+ `.trim();
159
+ }
160
+
161
+ /**
162
+ * Get primary button hover background
163
+ */
164
+ export function getPrimaryButtonHoverBg(): string {
165
+ return 'var(--xp-button-primary-bg-hover, #1d4ed8)';
166
+ }
167
+
168
+ /**
169
+ * Get primary button default background
170
+ */
171
+ export function getPrimaryButtonDefaultBg(): string {
172
+ return 'var(--xp-button-primary-bg, #2563eb)';
173
+ }
174
+
175
+ /**
176
+ * Get CSS for secondary button
177
+ */
178
+ export function getSecondaryButtonStyles(): string {
179
+ return `
180
+ padding: var(--xp-button-padding, 10px 20px);
181
+ font-size: var(--xp-button-font-size, 14px);
182
+ font-weight: var(--xp-button-font-weight, 500);
183
+ border-radius: var(--xp-button-radius, 6px);
184
+ cursor: pointer;
185
+ transition: all 0.2s;
186
+ border: none;
187
+ background: var(--xp-button-secondary-bg, #f3f4f6);
188
+ color: var(--xp-button-secondary-color, #374151);
189
+ `.trim();
190
+ }
191
+
192
+ /**
193
+ * Get secondary button hover background
194
+ */
195
+ export function getSecondaryButtonHoverBg(): string {
196
+ return 'var(--xp-button-secondary-bg-hover, #e5e7eb)';
197
+ }
198
+
199
+ /**
200
+ * Get secondary button default background
201
+ */
202
+ export function getSecondaryButtonDefaultBg(): string {
203
+ return 'var(--xp-button-secondary-bg, #f3f4f6)';
204
+ }
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Browser-specific tests for modal plugin
3
+ * Run with: pnpm test:browser
4
+ *
5
+ * These tests run in a real browser (Chromium via Playwright) to test
6
+ * features that depend on actual browser APIs like window.innerWidth.
7
+ */
8
+
9
+ import { SDK } from '@lytics/sdk-kit';
10
+ import { beforeEach, describe, expect, it } from 'vitest';
11
+ import { modalPlugin } from './modal';
12
+
13
+ // Helper to initialize SDK with modal plugin
14
+ function initPlugin(config = {}) {
15
+ const sdk = new SDK({
16
+ name: 'test-sdk',
17
+ ...config,
18
+ });
19
+
20
+ sdk.use(modalPlugin);
21
+ return sdk;
22
+ }
23
+
24
+ describe('Modal Plugin - Browser Tests', () => {
25
+ beforeEach(() => {
26
+ // Clean up any existing modals
27
+ document.querySelectorAll('.xp-modal').forEach((el) => {
28
+ el.remove();
29
+ });
30
+ });
31
+
32
+ describe('Mobile Viewport Detection', () => {
33
+ it('should detect mobile viewport (width < 640px)', () => {
34
+ // This test verifies that window.innerWidth works in the browser environment
35
+ // The actual viewport size is set by the test runner config
36
+ expect(window.innerWidth).toBeGreaterThan(0);
37
+ expect(typeof window.innerWidth).toBe('number');
38
+ });
39
+
40
+ it('should auto-enable fullscreen for lg size on mobile viewport @mobile', async () => {
41
+ // Note: @mobile tag would be used with test.describe.each for different viewports
42
+ // For now, we'll test the logic assuming the viewport is set externally
43
+
44
+ // Mock small viewport
45
+ Object.defineProperty(window, 'innerWidth', {
46
+ writable: true,
47
+ configurable: true,
48
+ value: 375,
49
+ });
50
+
51
+ const sdk = initPlugin({
52
+ modal: {
53
+ size: 'lg',
54
+ },
55
+ });
56
+ await sdk.init();
57
+
58
+ const experience = {
59
+ id: 'lg-mobile',
60
+ type: 'modal' as const,
61
+ targeting: {},
62
+ content: {
63
+ message: 'Large on mobile',
64
+ },
65
+ };
66
+
67
+ sdk.modal.show(experience);
68
+
69
+ // In a real mobile viewport, lg should become fullscreen
70
+ const modal = document.querySelector('.xp-modal');
71
+ const hasFullscreenClass = modal?.classList.contains('xp-modal--fullscreen');
72
+ const hasLgClass = modal?.classList.contains('xp-modal--lg');
73
+
74
+ // Since we're mocking innerWidth, this tests the check logic
75
+ // With innerWidth=375, isMobile() should return true
76
+ expect(modal).toBeTruthy();
77
+ expect(hasFullscreenClass || hasLgClass).toBe(true);
78
+
79
+ // Cleanup
80
+ await sdk.destroy();
81
+
82
+ // Restore
83
+ Object.defineProperty(window, 'innerWidth', {
84
+ writable: true,
85
+ configurable: true,
86
+ value: 1024,
87
+ });
88
+ });
89
+
90
+ it('should respect mobileFullscreen: false configuration', async () => {
91
+ // Mock mobile viewport
92
+ Object.defineProperty(window, 'innerWidth', {
93
+ writable: true,
94
+ configurable: true,
95
+ value: 375,
96
+ });
97
+
98
+ const sdk = initPlugin({
99
+ modal: {
100
+ size: 'lg',
101
+ mobileFullscreen: false,
102
+ },
103
+ });
104
+ await sdk.init();
105
+
106
+ const experience = {
107
+ id: 'lg-no-fullscreen',
108
+ type: 'modal' as const,
109
+ targeting: {},
110
+ content: {
111
+ message: 'Large without fullscreen',
112
+ },
113
+ };
114
+
115
+ sdk.modal.show(experience);
116
+
117
+ const modal = document.querySelector('.xp-modal');
118
+ expect(modal?.classList.contains('xp-modal--lg')).toBe(true);
119
+
120
+ // Cleanup
121
+ await sdk.destroy();
122
+
123
+ // Restore
124
+ Object.defineProperty(window, 'innerWidth', {
125
+ writable: true,
126
+ configurable: true,
127
+ value: 1024,
128
+ });
129
+ });
130
+ });
131
+
132
+ describe('Real Browser APIs', () => {
133
+ it('should access window object', () => {
134
+ expect(window).toBeDefined();
135
+ expect(window.document).toBeDefined();
136
+ expect(window.innerWidth).toBeGreaterThan(0);
137
+ expect(window.innerHeight).toBeGreaterThan(0);
138
+ });
139
+
140
+ it('should manipulate DOM', async () => {
141
+ const sdk = initPlugin();
142
+ await sdk.init();
143
+
144
+ const experience = {
145
+ id: 'dom-test',
146
+ type: 'modal' as const,
147
+ targeting: {},
148
+ content: {
149
+ message: 'DOM test',
150
+ },
151
+ };
152
+
153
+ sdk.modal.show(experience);
154
+
155
+ // Modal should be in the DOM
156
+ const modal = document.querySelector('.xp-modal');
157
+ expect(modal).toBeInstanceOf(HTMLElement);
158
+ expect(modal?.getAttribute('data-xp-id')).toBe('dom-test');
159
+
160
+ // Cleanup
161
+ await sdk.destroy();
162
+ });
163
+ });
164
+ });