@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,212 @@
1
+ /**
2
+ * Form styling with CSS variables for theming
3
+ *
4
+ * Design tokens inspired by Tailwind CSS, 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-form-input-border: #3b82f6;
11
+ * --xp-form-input-focus-ring: rgba(59, 130, 246, 0.2);
12
+ * }
13
+ * ```
14
+ */
15
+
16
+ /**
17
+ * Get CSS for form container
18
+ */
19
+ export function getFormStyles(): string {
20
+ return `
21
+ margin-top: var(--xp-form-spacing, 16px);
22
+ display: flex;
23
+ flex-direction: column;
24
+ gap: var(--xp-form-gap, 16px);
25
+ `.trim();
26
+ }
27
+
28
+ /**
29
+ * Get CSS for form field wrapper
30
+ */
31
+ export function getFieldStyles(): string {
32
+ return `
33
+ display: flex;
34
+ flex-direction: column;
35
+ gap: var(--xp-field-gap, 6px);
36
+ `.trim();
37
+ }
38
+
39
+ /**
40
+ * Get CSS for form label
41
+ */
42
+ export function getLabelStyles(): string {
43
+ return `
44
+ font-size: var(--xp-label-font-size, 14px);
45
+ font-weight: var(--xp-label-font-weight, 500);
46
+ color: var(--xp-label-color, #374151);
47
+ line-height: 1.5;
48
+ `.trim();
49
+ }
50
+
51
+ /**
52
+ * Get CSS for required indicator
53
+ */
54
+ export function getRequiredStyles(): string {
55
+ return `
56
+ color: var(--xp-required-color, #ef4444);
57
+ `.trim();
58
+ }
59
+
60
+ /**
61
+ * Get CSS for form input/textarea
62
+ */
63
+ export function getInputStyles(): string {
64
+ return `
65
+ padding: var(--xp-input-padding, 8px 12px);
66
+ font-size: var(--xp-input-font-size, 14px);
67
+ line-height: 1.5;
68
+ color: var(--xp-input-color, #111827);
69
+ background-color: var(--xp-input-bg, white);
70
+ border: var(--xp-input-border-width, 1px) solid var(--xp-input-border-color, #d1d5db);
71
+ border-radius: var(--xp-input-radius, 6px);
72
+ transition: all 0.15s ease-in-out;
73
+ outline: none;
74
+ width: 100%;
75
+ box-sizing: border-box;
76
+ `.trim();
77
+ }
78
+
79
+ /**
80
+ * Get CSS for input focus state (applies via :focus)
81
+ */
82
+ export function getInputFocusStyles(): string {
83
+ return `
84
+ border-color: var(--xp-input-focus-border, #3b82f6);
85
+ box-shadow: 0 0 0 var(--xp-input-focus-ring-width, 3px) var(--xp-input-focus-ring, rgba(59, 130, 246, 0.1));
86
+ `.trim();
87
+ }
88
+
89
+ /**
90
+ * Get CSS for input error state
91
+ */
92
+ export function getInputErrorStyles(): string {
93
+ return `
94
+ border-color: var(--xp-input-error-border, #ef4444);
95
+ `.trim();
96
+ }
97
+
98
+ /**
99
+ * Get CSS for error message
100
+ */
101
+ export function getErrorMessageStyles(): string {
102
+ return `
103
+ font-size: var(--xp-error-font-size, 13px);
104
+ color: var(--xp-error-color, #ef4444);
105
+ line-height: 1.4;
106
+ min-height: 18px;
107
+ `.trim();
108
+ }
109
+
110
+ /**
111
+ * Get CSS for submit button
112
+ */
113
+ export function getSubmitButtonStyles(): string {
114
+ return `
115
+ margin-top: var(--xp-submit-margin-top, 8px);
116
+ padding: var(--xp-submit-padding, 10px 20px);
117
+ font-size: var(--xp-submit-font-size, 14px);
118
+ font-weight: var(--xp-submit-font-weight, 500);
119
+ color: var(--xp-submit-color, white);
120
+ background-color: var(--xp-submit-bg, #2563eb);
121
+ border: none;
122
+ border-radius: var(--xp-submit-radius, 6px);
123
+ cursor: pointer;
124
+ transition: all 0.2s;
125
+ width: 100%;
126
+ `.trim();
127
+ }
128
+
129
+ /**
130
+ * Get CSS for submit button hover background
131
+ */
132
+ export function getSubmitButtonHoverBg(): string {
133
+ return 'var(--xp-submit-bg-hover, #1d4ed8)';
134
+ }
135
+
136
+ /**
137
+ * Get CSS for submit button disabled state
138
+ */
139
+ export function getSubmitButtonDisabledStyles(): string {
140
+ return `
141
+ opacity: var(--xp-submit-disabled-opacity, 0.6);
142
+ cursor: not-allowed;
143
+ `.trim();
144
+ }
145
+
146
+ /**
147
+ * Get CSS for form state container (success/error)
148
+ */
149
+ export function getFormStateStyles(): string {
150
+ return `
151
+ padding: var(--xp-state-padding, 16px);
152
+ border-radius: var(--xp-state-radius, 8px);
153
+ text-align: center;
154
+ `.trim();
155
+ }
156
+
157
+ /**
158
+ * Get CSS for success state
159
+ */
160
+ export function getSuccessStateStyles(): string {
161
+ return `
162
+ background-color: var(--xp-success-bg, #f0fdf4);
163
+ border: var(--xp-state-border-width, 1px) solid var(--xp-success-border, #86efac);
164
+ `.trim();
165
+ }
166
+
167
+ /**
168
+ * Get CSS for error state
169
+ */
170
+ export function getErrorStateStyles(): string {
171
+ return `
172
+ background-color: var(--xp-error-bg, #fef2f2);
173
+ border: var(--xp-state-border-width, 1px) solid var(--xp-error-border, #fca5a5);
174
+ `.trim();
175
+ }
176
+
177
+ /**
178
+ * Get CSS for state title
179
+ */
180
+ export function getStateTitleStyles(): string {
181
+ return `
182
+ font-size: var(--xp-state-title-font-size, 16px);
183
+ font-weight: var(--xp-state-title-font-weight, 600);
184
+ margin: 0 0 var(--xp-state-title-margin-bottom, 8px) 0;
185
+ color: var(--xp-state-title-color, #111827);
186
+ `.trim();
187
+ }
188
+
189
+ /**
190
+ * Get CSS for state message
191
+ */
192
+ export function getStateMessageStyles(): string {
193
+ return `
194
+ font-size: var(--xp-state-message-font-size, 14px);
195
+ line-height: 1.5;
196
+ color: var(--xp-state-message-color, #374151);
197
+ margin: 0;
198
+ `.trim();
199
+ }
200
+
201
+ /**
202
+ * Get CSS for state buttons container
203
+ */
204
+ export function getStateButtonsStyles(): string {
205
+ return `
206
+ margin-top: var(--xp-state-buttons-margin-top, 16px);
207
+ display: flex;
208
+ gap: var(--xp-state-buttons-gap, 8px);
209
+ justify-content: center;
210
+ flex-wrap: wrap;
211
+ `.trim();
212
+ }
@@ -0,0 +1,413 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { validateField, validateForm } from './form-validation';
3
+ import type { FormConfig, FormField } from './types';
4
+
5
+ describe('validateField', () => {
6
+ describe('required validation', () => {
7
+ it('should pass when required field has value', () => {
8
+ const field: FormField = {
9
+ name: 'email',
10
+ type: 'email',
11
+ required: true,
12
+ };
13
+
14
+ const result = validateField(field, 'user@example.com');
15
+
16
+ expect(result.valid).toBe(true);
17
+ expect(result.errors).toBeUndefined();
18
+ });
19
+
20
+ it('should fail when required field is empty', () => {
21
+ const field: FormField = {
22
+ name: 'email',
23
+ type: 'email',
24
+ required: true,
25
+ };
26
+
27
+ const result = validateField(field, '');
28
+
29
+ expect(result.valid).toBe(false);
30
+ expect(result.errors).toEqual({ email: 'email is required' });
31
+ });
32
+
33
+ it('should fail when required field is whitespace', () => {
34
+ const field: FormField = {
35
+ name: 'name',
36
+ type: 'text',
37
+ label: 'Name',
38
+ required: true,
39
+ };
40
+
41
+ const result = validateField(field, ' ');
42
+
43
+ expect(result.valid).toBe(false);
44
+ expect(result.errors).toEqual({ name: 'Name is required' });
45
+ });
46
+
47
+ it('should use custom error message for required field', () => {
48
+ const field: FormField = {
49
+ name: 'email',
50
+ type: 'email',
51
+ required: true,
52
+ errorMessage: 'Email address is required',
53
+ };
54
+
55
+ const result = validateField(field, '');
56
+
57
+ expect(result.valid).toBe(false);
58
+ expect(result.errors).toEqual({ email: 'Email address is required' });
59
+ });
60
+
61
+ it('should pass when optional field is empty', () => {
62
+ const field: FormField = {
63
+ name: 'phone',
64
+ type: 'tel',
65
+ required: false,
66
+ };
67
+
68
+ const result = validateField(field, '');
69
+
70
+ expect(result.valid).toBe(true);
71
+ expect(result.errors).toBeUndefined();
72
+ });
73
+ });
74
+
75
+ describe('email validation', () => {
76
+ const field: FormField = {
77
+ name: 'email',
78
+ type: 'email',
79
+ required: false,
80
+ };
81
+
82
+ it('should pass with valid email', () => {
83
+ const result = validateField(field, 'user@example.com');
84
+ expect(result.valid).toBe(true);
85
+ });
86
+
87
+ it('should pass with subdomain email', () => {
88
+ const result = validateField(field, 'user@mail.example.com');
89
+ expect(result.valid).toBe(true);
90
+ });
91
+
92
+ it('should fail with invalid email (no @)', () => {
93
+ const result = validateField(field, 'userexample.com');
94
+ expect(result.valid).toBe(false);
95
+ expect(result.errors).toEqual({ email: 'Please enter a valid email address' });
96
+ });
97
+
98
+ it('should fail with invalid email (no domain)', () => {
99
+ const result = validateField(field, 'user@');
100
+ expect(result.valid).toBe(false);
101
+ });
102
+
103
+ it('should fail with invalid email (no TLD)', () => {
104
+ const result = validateField(field, 'user@example');
105
+ expect(result.valid).toBe(false);
106
+ });
107
+
108
+ it('should use custom error message for invalid email', () => {
109
+ const customField: FormField = {
110
+ ...field,
111
+ errorMessage: 'Invalid email format',
112
+ };
113
+
114
+ const result = validateField(customField, 'invalid');
115
+ expect(result.errors).toEqual({ email: 'Invalid email format' });
116
+ });
117
+ });
118
+
119
+ describe('url validation', () => {
120
+ const field: FormField = {
121
+ name: 'website',
122
+ type: 'url',
123
+ required: false,
124
+ };
125
+
126
+ it('should pass with valid HTTP URL', () => {
127
+ const result = validateField(field, 'http://example.com');
128
+ expect(result.valid).toBe(true);
129
+ });
130
+
131
+ it('should pass with valid HTTPS URL', () => {
132
+ const result = validateField(field, 'https://example.com/path?query=1');
133
+ expect(result.valid).toBe(true);
134
+ });
135
+
136
+ it('should fail with invalid URL (no protocol)', () => {
137
+ const result = validateField(field, 'example.com');
138
+ expect(result.valid).toBe(false);
139
+ expect(result.errors).toEqual({ website: 'Please enter a valid URL' });
140
+ });
141
+
142
+ it('should fail with invalid URL format', () => {
143
+ const result = validateField(field, 'not a url');
144
+ expect(result.valid).toBe(false);
145
+ });
146
+ });
147
+
148
+ describe('tel validation', () => {
149
+ const field: FormField = {
150
+ name: 'phone',
151
+ type: 'tel',
152
+ required: false,
153
+ };
154
+
155
+ it('should pass with digits only', () => {
156
+ const result = validateField(field, '5551234567');
157
+ expect(result.valid).toBe(true);
158
+ });
159
+
160
+ it('should pass with dashes', () => {
161
+ const result = validateField(field, '555-123-4567');
162
+ expect(result.valid).toBe(true);
163
+ });
164
+
165
+ it('should pass with parentheses and spaces', () => {
166
+ const result = validateField(field, '(555) 123-4567');
167
+ expect(result.valid).toBe(true);
168
+ });
169
+
170
+ it('should pass with plus and country code', () => {
171
+ const result = validateField(field, '+1 555-123-4567');
172
+ expect(result.valid).toBe(true);
173
+ });
174
+
175
+ it('should fail with letters', () => {
176
+ const result = validateField(field, '555-CALL-NOW');
177
+ expect(result.valid).toBe(false);
178
+ expect(result.errors).toEqual({ phone: 'Please enter a valid phone number' });
179
+ });
180
+ });
181
+
182
+ describe('number validation', () => {
183
+ const field: FormField = {
184
+ name: 'age',
185
+ type: 'number',
186
+ required: false,
187
+ };
188
+
189
+ it('should pass with integer', () => {
190
+ const result = validateField(field, '25');
191
+ expect(result.valid).toBe(true);
192
+ });
193
+
194
+ it('should pass with decimal', () => {
195
+ const result = validateField(field, '25.5');
196
+ expect(result.valid).toBe(true);
197
+ });
198
+
199
+ it('should pass with negative number', () => {
200
+ const result = validateField(field, '-10');
201
+ expect(result.valid).toBe(true);
202
+ });
203
+
204
+ it('should fail with non-numeric value', () => {
205
+ const result = validateField(field, 'twenty');
206
+ expect(result.valid).toBe(false);
207
+ expect(result.errors).toEqual({ age: 'Please enter a valid number' });
208
+ });
209
+ });
210
+
211
+ describe('pattern validation', () => {
212
+ it('should pass when value matches pattern', () => {
213
+ const field: FormField = {
214
+ name: 'zipcode',
215
+ type: 'text',
216
+ pattern: '^\\d{5}$',
217
+ required: false,
218
+ };
219
+
220
+ const result = validateField(field, '12345');
221
+ expect(result.valid).toBe(true);
222
+ });
223
+
224
+ it('should fail when value does not match pattern', () => {
225
+ const field: FormField = {
226
+ name: 'zipcode',
227
+ type: 'text',
228
+ label: 'ZIP Code',
229
+ pattern: '^\\d{5}$',
230
+ required: false,
231
+ };
232
+
233
+ const result = validateField(field, '1234');
234
+ expect(result.valid).toBe(false);
235
+ expect(result.errors).toEqual({ zipcode: 'Invalid format for ZIP Code' });
236
+ });
237
+
238
+ it('should use custom error message for pattern mismatch', () => {
239
+ const field: FormField = {
240
+ name: 'zipcode',
241
+ type: 'text',
242
+ pattern: '^\\d{5}$',
243
+ errorMessage: 'ZIP code must be 5 digits',
244
+ required: false,
245
+ };
246
+
247
+ const result = validateField(field, 'ABCDE');
248
+ expect(result.errors).toEqual({ zipcode: 'ZIP code must be 5 digits' });
249
+ });
250
+
251
+ it('should handle invalid regex pattern gracefully', () => {
252
+ const field: FormField = {
253
+ name: 'test',
254
+ type: 'text',
255
+ pattern: '[invalid(regex',
256
+ required: false,
257
+ };
258
+
259
+ // Should not throw, just pass validation
260
+ const result = validateField(field, 'anything');
261
+ expect(result.valid).toBe(true);
262
+ });
263
+ });
264
+ });
265
+
266
+ describe('validateForm', () => {
267
+ it('should pass when all fields are valid', () => {
268
+ const config: FormConfig = {
269
+ fields: [
270
+ { name: 'email', type: 'email', required: true },
271
+ { name: 'name', type: 'text', required: true },
272
+ ],
273
+ submitButton: { text: 'Submit', action: 'submit' },
274
+ };
275
+
276
+ const data = {
277
+ email: 'user@example.com',
278
+ name: 'John Doe',
279
+ };
280
+
281
+ const result = validateForm(config, data);
282
+ expect(result.valid).toBe(true);
283
+ expect(result.errors).toBeUndefined();
284
+ });
285
+
286
+ it('should fail when any field is invalid', () => {
287
+ const config: FormConfig = {
288
+ fields: [
289
+ { name: 'email', type: 'email', required: true },
290
+ { name: 'name', type: 'text', required: true },
291
+ ],
292
+ submitButton: { text: 'Submit', action: 'submit' },
293
+ };
294
+
295
+ const data = {
296
+ email: 'invalid-email',
297
+ name: 'John Doe',
298
+ };
299
+
300
+ const result = validateForm(config, data);
301
+ expect(result.valid).toBe(false);
302
+ expect(result.errors).toHaveProperty('email');
303
+ });
304
+
305
+ it('should collect errors from multiple fields', () => {
306
+ const config: FormConfig = {
307
+ fields: [
308
+ { name: 'email', type: 'email', required: true },
309
+ { name: 'name', type: 'text', required: true },
310
+ { name: 'phone', type: 'tel', required: true },
311
+ ],
312
+ submitButton: { text: 'Submit', action: 'submit' },
313
+ };
314
+
315
+ const data = {
316
+ email: '',
317
+ name: '',
318
+ phone: '',
319
+ };
320
+
321
+ const result = validateForm(config, data);
322
+ expect(result.valid).toBe(false);
323
+ expect(Object.keys(result.errors || {})).toHaveLength(3);
324
+ expect(result.errors).toHaveProperty('email');
325
+ expect(result.errors).toHaveProperty('name');
326
+ expect(result.errors).toHaveProperty('phone');
327
+ });
328
+
329
+ it('should run custom validation function', () => {
330
+ const config: FormConfig = {
331
+ fields: [{ name: 'email', type: 'email', required: true }],
332
+ submitButton: { text: 'Submit', action: 'submit' },
333
+ validate: (data) => {
334
+ if (data.email.endsWith('@competitor.com')) {
335
+ return {
336
+ valid: false,
337
+ errors: { email: 'Competitor emails not allowed' },
338
+ };
339
+ }
340
+ return { valid: true };
341
+ },
342
+ };
343
+
344
+ const data = { email: 'user@competitor.com' };
345
+
346
+ const result = validateForm(config, data);
347
+ expect(result.valid).toBe(false);
348
+ expect(result.errors).toEqual({ email: 'Competitor emails not allowed' });
349
+ });
350
+
351
+ it('should merge custom validation errors with field errors', () => {
352
+ const config: FormConfig = {
353
+ fields: [
354
+ { name: 'email', type: 'email', required: true },
355
+ { name: 'password', type: 'text', required: true },
356
+ ],
357
+ submitButton: { text: 'Submit', action: 'submit' },
358
+ validate: (data) => {
359
+ if (data.password.length < 8) {
360
+ return {
361
+ valid: false,
362
+ errors: { password: 'Password must be at least 8 characters' },
363
+ };
364
+ }
365
+ return { valid: true };
366
+ },
367
+ };
368
+
369
+ const data = {
370
+ email: 'invalid',
371
+ password: '123',
372
+ };
373
+
374
+ const result = validateForm(config, data);
375
+ expect(result.valid).toBe(false);
376
+ expect(result.errors).toHaveProperty('email'); // Field validation error
377
+ expect(result.errors).toHaveProperty('password'); // Custom validation error
378
+ });
379
+
380
+ it('should handle custom validation function errors gracefully', () => {
381
+ const config: FormConfig = {
382
+ fields: [{ name: 'email', type: 'email', required: true }],
383
+ submitButton: { text: 'Submit', action: 'submit' },
384
+ validate: () => {
385
+ throw new Error('Custom validation broke!');
386
+ },
387
+ };
388
+
389
+ const data = { email: 'user@example.com' };
390
+
391
+ // Should not throw, should pass validation
392
+ const result = validateForm(config, data);
393
+ expect(result.valid).toBe(true);
394
+ });
395
+
396
+ it('should pass when optional fields are empty', () => {
397
+ const config: FormConfig = {
398
+ fields: [
399
+ { name: 'email', type: 'email', required: true },
400
+ { name: 'phone', type: 'tel', required: false },
401
+ ],
402
+ submitButton: { text: 'Submit', action: 'submit' },
403
+ };
404
+
405
+ const data = {
406
+ email: 'user@example.com',
407
+ phone: '',
408
+ };
409
+
410
+ const result = validateForm(config, data);
411
+ expect(result.valid).toBe(true);
412
+ });
413
+ });