@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.
- package/.turbo/turbo-build.log +6 -6
- package/CHANGELOG.md +150 -0
- package/README.md +141 -79
- package/dist/index.d.ts +813 -35
- package/dist/index.js +1910 -66
- 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.test.ts +423 -0
- package/src/exit-intent/exit-intent.ts +371 -0
- package/src/exit-intent/index.ts +6 -0
- package/src/exit-intent/types.ts +59 -0
- package/src/index.ts +7 -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 +421 -0
- 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/page-visits/index.ts +6 -0
- package/src/page-visits/page-visits.test.ts +562 -0
- package/src/page-visits/page-visits.ts +314 -0
- package/src/page-visits/types.ts +119 -0
- package/src/scroll-depth/index.ts +6 -0
- package/src/scroll-depth/scroll-depth.test.ts +580 -0
- package/src/scroll-depth/scroll-depth.ts +398 -0
- package/src/scroll-depth/types.ts +122 -0
- package/src/time-delay/index.ts +6 -0
- package/src/time-delay/time-delay.test.ts +477 -0
- package/src/time-delay/time-delay.ts +296 -0
- package/src/time-delay/types.ts +89 -0
- package/src/types.ts +20 -36
- package/src/utils/sanitize.ts +5 -2
package/package.json
CHANGED
package/src/banner/banner.ts
CHANGED
|
@@ -86,15 +86,15 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
|
|
|
86
86
|
left: 0;
|
|
87
87
|
right: 0;
|
|
88
88
|
width: 100%;
|
|
89
|
-
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
90
|
-
font-size: 14px;
|
|
91
|
-
line-height: 1.5;
|
|
89
|
+
font-family: var(--xp-banner-font-family, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
|
|
90
|
+
font-size: var(--xp-banner-font-size, 14px);
|
|
91
|
+
line-height: var(--xp-banner-line-height, 1.5);
|
|
92
92
|
box-sizing: border-box;
|
|
93
|
-
z-index: 10000;
|
|
94
|
-
background: #ffffff;
|
|
95
|
-
color: #111827;
|
|
96
|
-
border-bottom: 1px solid #e5e7eb;
|
|
97
|
-
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.05);
|
|
93
|
+
z-index: var(--xp-banner-z-index, 10000);
|
|
94
|
+
background: var(--xp-banner-bg, #ffffff);
|
|
95
|
+
color: var(--xp-banner-color, #111827);
|
|
96
|
+
border-bottom: var(--xp-banner-border-width, 1px) solid var(--xp-banner-border-color, #e5e7eb);
|
|
97
|
+
box-shadow: var(--xp-banner-shadow, 0 1px 3px 0 rgba(0, 0, 0, 0.05));
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
.xp-banner--top {
|
|
@@ -104,17 +104,17 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
|
|
|
104
104
|
.xp-banner--bottom {
|
|
105
105
|
bottom: 0;
|
|
106
106
|
border-bottom: none;
|
|
107
|
-
border-top: 1px solid #e5e7eb;
|
|
108
|
-
box-shadow: 0 -1px 3px 0 rgba(0, 0, 0, 0.05);
|
|
107
|
+
border-top: var(--xp-banner-border-width, 1px) solid var(--xp-banner-border-color, #e5e7eb);
|
|
108
|
+
box-shadow: var(--xp-banner-shadow-bottom, 0 -1px 3px 0 rgba(0, 0, 0, 0.05));
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
.xp-banner__container {
|
|
112
112
|
display: flex;
|
|
113
113
|
align-items: center;
|
|
114
|
-
gap: 16px;
|
|
115
|
-
max-width: 1280px;
|
|
114
|
+
gap: var(--xp-banner-gap, 16px);
|
|
115
|
+
max-width: var(--xp-banner-max-width, 1280px);
|
|
116
116
|
margin: 0 auto;
|
|
117
|
-
padding: 14px 24px;
|
|
117
|
+
padding: var(--xp-banner-padding, 14px 24px);
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
.xp-banner__content {
|
|
@@ -122,36 +122,37 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
|
|
|
122
122
|
min-width: 0;
|
|
123
123
|
display: flex;
|
|
124
124
|
flex-direction: column;
|
|
125
|
-
gap: 4px;
|
|
125
|
+
gap: var(--xp-banner-content-gap, 4px);
|
|
126
126
|
}
|
|
127
127
|
|
|
128
128
|
.xp-banner__title {
|
|
129
|
-
font-weight: 600;
|
|
129
|
+
font-weight: var(--xp-banner-title-weight, 600);
|
|
130
130
|
margin: 0;
|
|
131
|
-
font-size: 15px;
|
|
132
|
-
line-height: 1.4;
|
|
131
|
+
font-size: var(--xp-banner-title-size, 15px);
|
|
132
|
+
line-height: var(--xp-banner-title-line-height, 1.4);
|
|
133
|
+
color: var(--xp-banner-title-color, inherit);
|
|
133
134
|
}
|
|
134
135
|
|
|
135
136
|
.xp-banner__message {
|
|
136
137
|
margin: 0;
|
|
137
|
-
font-size: 14px;
|
|
138
|
-
line-height: 1.5;
|
|
139
|
-
color: #6b7280;
|
|
138
|
+
font-size: var(--xp-banner-message-size, 14px);
|
|
139
|
+
line-height: var(--xp-banner-message-line-height, 1.5);
|
|
140
|
+
color: var(--xp-banner-message-color, #6b7280);
|
|
140
141
|
}
|
|
141
142
|
|
|
142
143
|
.xp-banner__buttons {
|
|
143
144
|
display: flex;
|
|
144
145
|
align-items: center;
|
|
145
|
-
gap: 8px;
|
|
146
|
+
gap: var(--xp-banner-buttons-gap, 8px);
|
|
146
147
|
flex-shrink: 0;
|
|
147
148
|
}
|
|
148
149
|
|
|
149
150
|
.xp-banner__button {
|
|
150
|
-
padding: 8px 16px;
|
|
151
|
+
padding: var(--xp-banner-button-padding, 8px 16px);
|
|
151
152
|
border: none;
|
|
152
|
-
border-radius: 6px;
|
|
153
|
-
font-size: 14px;
|
|
154
|
-
font-weight: 500;
|
|
153
|
+
border-radius: var(--xp-banner-button-radius, 6px);
|
|
154
|
+
font-size: var(--xp-banner-button-font-size, 14px);
|
|
155
|
+
font-weight: var(--xp-banner-button-font-weight, 500);
|
|
155
156
|
cursor: pointer;
|
|
156
157
|
transition: all 0.2s;
|
|
157
158
|
text-decoration: none;
|
|
@@ -162,64 +163,64 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
|
|
|
162
163
|
}
|
|
163
164
|
|
|
164
165
|
.xp-banner__button--primary {
|
|
165
|
-
background: #2563eb;
|
|
166
|
-
color: #ffffff;
|
|
166
|
+
background: var(--xp-banner-button-primary-bg, #2563eb);
|
|
167
|
+
color: var(--xp-banner-button-primary-color, #ffffff);
|
|
167
168
|
}
|
|
168
169
|
|
|
169
170
|
.xp-banner__button--primary:hover {
|
|
170
|
-
background: #1d4ed8;
|
|
171
|
+
background: var(--xp-banner-button-primary-bg-hover, #1d4ed8);
|
|
171
172
|
}
|
|
172
173
|
|
|
173
174
|
.xp-banner__button--secondary {
|
|
174
|
-
background: #f3f4f6;
|
|
175
|
-
color: #374151;
|
|
176
|
-
border: 1px solid #e5e7eb;
|
|
175
|
+
background: var(--xp-banner-button-secondary-bg, #f3f4f6);
|
|
176
|
+
color: var(--xp-banner-button-secondary-color, #374151);
|
|
177
|
+
border: var(--xp-banner-border-width, 1px) solid var(--xp-banner-button-secondary-border, #e5e7eb);
|
|
177
178
|
}
|
|
178
179
|
|
|
179
180
|
.xp-banner__button--secondary:hover {
|
|
180
|
-
background: #e5e7eb;
|
|
181
|
+
background: var(--xp-banner-button-secondary-bg-hover, #e5e7eb);
|
|
181
182
|
}
|
|
182
183
|
|
|
183
184
|
.xp-banner__button--link {
|
|
184
185
|
background: transparent;
|
|
185
|
-
color: #2563eb;
|
|
186
|
-
padding: 6px 12px;
|
|
187
|
-
font-weight: 400;
|
|
186
|
+
color: var(--xp-banner-button-link-color, #2563eb);
|
|
187
|
+
padding: var(--xp-banner-button-link-padding, 6px 12px);
|
|
188
|
+
font-weight: var(--xp-banner-button-link-font-weight, 400);
|
|
188
189
|
}
|
|
189
190
|
|
|
190
191
|
.xp-banner__button--link:hover {
|
|
191
|
-
background: #f3f4f6;
|
|
192
|
+
background: var(--xp-banner-button-link-bg-hover, #f3f4f6);
|
|
192
193
|
text-decoration: underline;
|
|
193
194
|
}
|
|
194
195
|
|
|
195
196
|
.xp-banner__close {
|
|
196
197
|
background: transparent;
|
|
197
198
|
border: none;
|
|
198
|
-
color: #9ca3af;
|
|
199
|
-
font-size: 20px;
|
|
199
|
+
color: var(--xp-banner-close-color, #9ca3af);
|
|
200
|
+
font-size: var(--xp-banner-close-size, 20px);
|
|
200
201
|
line-height: 1;
|
|
201
202
|
cursor: pointer;
|
|
202
|
-
padding: 4px;
|
|
203
|
+
padding: var(--xp-banner-close-padding, 4px);
|
|
203
204
|
margin: 0;
|
|
204
205
|
transition: color 0.2s;
|
|
205
206
|
flex-shrink: 0;
|
|
206
|
-
width: 28px;
|
|
207
|
-
height: 28px;
|
|
207
|
+
width: var(--xp-banner-close-width, 28px);
|
|
208
|
+
height: var(--xp-banner-close-height, 28px);
|
|
208
209
|
display: flex;
|
|
209
210
|
align-items: center;
|
|
210
211
|
justify-content: center;
|
|
211
|
-
border-radius: 4px;
|
|
212
|
+
border-radius: var(--xp-banner-close-radius, 4px);
|
|
212
213
|
}
|
|
213
214
|
|
|
214
215
|
.xp-banner__close:hover {
|
|
215
|
-
color: #111827;
|
|
216
|
-
background: #f3f4f6;
|
|
216
|
+
color: var(--xp-banner-close-color-hover, #111827);
|
|
217
|
+
background: var(--xp-banner-close-bg-hover, #f3f4f6);
|
|
217
218
|
}
|
|
218
219
|
|
|
219
220
|
@media (max-width: 640px) {
|
|
220
221
|
.xp-banner__container {
|
|
221
222
|
flex-wrap: wrap;
|
|
222
|
-
padding: 14px 16px;
|
|
223
|
+
padding: var(--xp-banner-padding-mobile, 14px 16px);
|
|
223
224
|
position: relative;
|
|
224
225
|
}
|
|
225
226
|
|
|
@@ -244,55 +245,55 @@ export const bannerPlugin: PluginFunction = (plugin, instance, config) => {
|
|
|
244
245
|
}
|
|
245
246
|
}
|
|
246
247
|
|
|
247
|
-
/* Dark mode support */
|
|
248
|
+
/* Dark mode support - override CSS variables */
|
|
248
249
|
@media (prefers-color-scheme: dark) {
|
|
249
250
|
.xp-banner {
|
|
250
|
-
background: #111827;
|
|
251
|
-
color: #f9fafb;
|
|
252
|
-
border-bottom-color: #1f2937;
|
|
251
|
+
background: var(--xp-banner-bg-dark, #111827);
|
|
252
|
+
color: var(--xp-banner-color-dark, #f9fafb);
|
|
253
|
+
border-bottom-color: var(--xp-banner-border-color-dark, #1f2937);
|
|
253
254
|
}
|
|
254
255
|
|
|
255
256
|
.xp-banner--bottom {
|
|
256
|
-
border-top-color: #1f2937;
|
|
257
|
+
border-top-color: var(--xp-banner-border-color-dark, #1f2937);
|
|
257
258
|
}
|
|
258
259
|
|
|
259
260
|
.xp-banner__message {
|
|
260
|
-
color: #9ca3af;
|
|
261
|
+
color: var(--xp-banner-message-color-dark, #9ca3af);
|
|
261
262
|
}
|
|
262
263
|
|
|
263
264
|
.xp-banner__button--primary {
|
|
264
|
-
background: #3b82f6;
|
|
265
|
+
background: var(--xp-banner-button-primary-bg-dark, #3b82f6);
|
|
265
266
|
}
|
|
266
267
|
|
|
267
268
|
.xp-banner__button--primary:hover {
|
|
268
|
-
background: #2563eb;
|
|
269
|
+
background: var(--xp-banner-button-primary-bg-hover-dark, #2563eb);
|
|
269
270
|
}
|
|
270
271
|
|
|
271
272
|
.xp-banner__button--secondary {
|
|
272
|
-
background: #1f2937;
|
|
273
|
-
color: #f9fafb;
|
|
274
|
-
border-color: #374151;
|
|
273
|
+
background: var(--xp-banner-button-secondary-bg-dark, #1f2937);
|
|
274
|
+
color: var(--xp-banner-button-secondary-color-dark, #f9fafb);
|
|
275
|
+
border-color: var(--xp-banner-button-secondary-border-dark, #374151);
|
|
275
276
|
}
|
|
276
277
|
|
|
277
278
|
.xp-banner__button--secondary:hover {
|
|
278
|
-
background: #374151;
|
|
279
|
+
background: var(--xp-banner-button-secondary-bg-hover-dark, #374151);
|
|
279
280
|
}
|
|
280
281
|
|
|
281
282
|
.xp-banner__button--link {
|
|
282
|
-
color: #60a5fa;
|
|
283
|
+
color: var(--xp-banner-button-link-color-dark, #60a5fa);
|
|
283
284
|
}
|
|
284
285
|
|
|
285
286
|
.xp-banner__button--link:hover {
|
|
286
|
-
background: #1f2937;
|
|
287
|
+
background: var(--xp-banner-button-link-bg-hover-dark, #1f2937);
|
|
287
288
|
}
|
|
288
289
|
|
|
289
290
|
.xp-banner__close {
|
|
290
|
-
color: #6b7280;
|
|
291
|
+
color: var(--xp-banner-close-color-dark, #6b7280);
|
|
291
292
|
}
|
|
292
293
|
|
|
293
294
|
.xp-banner__close:hover {
|
|
294
|
-
color: #f9fafb;
|
|
295
|
-
background: #1f2937;
|
|
295
|
+
color: var(--xp-banner-close-color-hover-dark, #f9fafb);
|
|
296
|
+
background: var(--xp-banner-close-bg-hover-dark, #1f2937);
|
|
296
297
|
}
|
|
297
298
|
}
|
|
298
299
|
`;
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// packages/plugins/src/exit-intent/exit-intent.test.ts
|
|
2
|
+
|
|
3
|
+
import { SDK } from '@lytics/sdk-kit';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { exitIntentPlugin } from './index';
|
|
6
|
+
|
|
7
|
+
describe('Exit Intent Plugin', () => {
|
|
8
|
+
let sdk: SDK;
|
|
9
|
+
let mouseEventListeners: Record<string, EventListener> = {};
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
// Clear sessionStorage
|
|
13
|
+
sessionStorage.clear();
|
|
14
|
+
|
|
15
|
+
// Create fresh SDK instance
|
|
16
|
+
sdk = new SDK({ name: 'test-sdk' });
|
|
17
|
+
|
|
18
|
+
// Mock document event listeners
|
|
19
|
+
mouseEventListeners = {};
|
|
20
|
+
vi.spyOn(document, 'addEventListener').mockImplementation((event: string, handler: any) => {
|
|
21
|
+
mouseEventListeners[event] = handler;
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
vi.spyOn(document, 'removeEventListener').mockImplementation((event: string) => {
|
|
25
|
+
delete mouseEventListeners[event];
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.restoreAllMocks();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Helper to initialize plugin with config
|
|
34
|
+
async function initPlugin(config: any = { sensitivity: 50, minTimeOnPage: 0 }) {
|
|
35
|
+
sdk.set('exitIntent', config);
|
|
36
|
+
sdk.use(exitIntentPlugin);
|
|
37
|
+
await sdk.init();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('Plugin Initialization', () => {
|
|
41
|
+
it('should register exitIntent namespace', async () => {
|
|
42
|
+
await initPlugin();
|
|
43
|
+
expect((sdk as any).exitIntent).toBeDefined();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('should set up mouse event listeners', async () => {
|
|
47
|
+
await initPlugin({ sensitivity: 20 });
|
|
48
|
+
expect(mouseEventListeners.mousemove).toBeDefined();
|
|
49
|
+
expect(mouseEventListeners.mouseout).toBeDefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('should not initialize on mobile devices', async () => {
|
|
53
|
+
const originalUserAgent = navigator.userAgent;
|
|
54
|
+
Object.defineProperty(navigator, 'userAgent', {
|
|
55
|
+
value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)',
|
|
56
|
+
configurable: true,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await initPlugin({ disableOnMobile: true });
|
|
60
|
+
|
|
61
|
+
expect(mouseEventListeners.mousemove).toBeUndefined();
|
|
62
|
+
expect(mouseEventListeners.mouseout).toBeUndefined();
|
|
63
|
+
|
|
64
|
+
Object.defineProperty(navigator, 'userAgent', {
|
|
65
|
+
value: originalUserAgent,
|
|
66
|
+
configurable: true,
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('should initialize on mobile if disableOnMobile is false', async () => {
|
|
71
|
+
const originalUserAgent = navigator.userAgent;
|
|
72
|
+
Object.defineProperty(navigator, 'userAgent', {
|
|
73
|
+
value: 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)',
|
|
74
|
+
configurable: true,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await initPlugin({ disableOnMobile: false });
|
|
78
|
+
|
|
79
|
+
expect(mouseEventListeners.mousemove).toBeDefined();
|
|
80
|
+
expect(mouseEventListeners.mouseout).toBeDefined();
|
|
81
|
+
|
|
82
|
+
Object.defineProperty(navigator, 'userAgent', {
|
|
83
|
+
value: originalUserAgent,
|
|
84
|
+
configurable: true,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should not initialize if already triggered this session', async () => {
|
|
89
|
+
sessionStorage.setItem('xp:exitIntent:triggered', Date.now().toString());
|
|
90
|
+
|
|
91
|
+
await initPlugin();
|
|
92
|
+
|
|
93
|
+
expect(mouseEventListeners.mousemove).toBeUndefined();
|
|
94
|
+
expect(mouseEventListeners.mouseout).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe('Mouse Position Tracking', () => {
|
|
99
|
+
it('should track mouse positions', async () => {
|
|
100
|
+
await initPlugin();
|
|
101
|
+
|
|
102
|
+
const mouseMoveHandler = mouseEventListeners.mousemove;
|
|
103
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 100, clientY: 200 }));
|
|
104
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 150, clientY: 250 }));
|
|
105
|
+
|
|
106
|
+
const positions = (sdk as any).exitIntent.getPositions();
|
|
107
|
+
expect(positions).toHaveLength(2);
|
|
108
|
+
expect(positions[0]).toEqual({ x: 100, y: 200 });
|
|
109
|
+
expect(positions[1]).toEqual({ x: 150, y: 250 });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('should limit position history to configured size', async () => {
|
|
113
|
+
await initPlugin({ positionHistorySize: 3 });
|
|
114
|
+
|
|
115
|
+
const mouseMoveHandler = mouseEventListeners.mousemove;
|
|
116
|
+
for (let i = 0; i < 5; i++) {
|
|
117
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: i * 10, clientY: i * 10 }));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const positions = (sdk as any).exitIntent.getPositions();
|
|
121
|
+
expect(positions).toHaveLength(3);
|
|
122
|
+
expect(positions[0]).toEqual({ x: 20, y: 20 });
|
|
123
|
+
expect(positions[1]).toEqual({ x: 30, y: 30 });
|
|
124
|
+
expect(positions[2]).toEqual({ x: 40, y: 40 });
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('Exit Intent Detection (Pathfora Test Cases)', () => {
|
|
129
|
+
it('should NOT trigger immediately on page load', async () => {
|
|
130
|
+
const triggerSpy = vi.fn();
|
|
131
|
+
sdk.on('trigger:exitIntent', triggerSpy);
|
|
132
|
+
|
|
133
|
+
await initPlugin();
|
|
134
|
+
|
|
135
|
+
expect(triggerSpy).not.toHaveBeenCalled();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should NOT trigger when exiting from left edge', async () => {
|
|
139
|
+
const triggerSpy = vi.fn();
|
|
140
|
+
sdk.on('trigger:exitIntent', triggerSpy);
|
|
141
|
+
|
|
142
|
+
await initPlugin();
|
|
143
|
+
|
|
144
|
+
const mouseMoveHandler = mouseEventListeners.mousemove;
|
|
145
|
+
const mouseOutHandler = mouseEventListeners.mouseout;
|
|
146
|
+
|
|
147
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 100, clientY: 300 }));
|
|
148
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 50, clientY: 300 }));
|
|
149
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 10, clientY: 300 }));
|
|
150
|
+
|
|
151
|
+
mouseOutHandler(
|
|
152
|
+
new MouseEvent('mouseout', { clientX: 0, clientY: 300, relatedTarget: null })
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
expect(triggerSpy).not.toHaveBeenCalled();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should NOT trigger when exiting from bottom', async () => {
|
|
159
|
+
const triggerSpy = vi.fn();
|
|
160
|
+
sdk.on('trigger:exitIntent', triggerSpy);
|
|
161
|
+
|
|
162
|
+
await initPlugin();
|
|
163
|
+
|
|
164
|
+
const mouseMoveHandler = mouseEventListeners.mousemove;
|
|
165
|
+
const mouseOutHandler = mouseEventListeners.mouseout;
|
|
166
|
+
|
|
167
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 300 }));
|
|
168
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 500 }));
|
|
169
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 700 }));
|
|
170
|
+
|
|
171
|
+
mouseOutHandler(
|
|
172
|
+
new MouseEvent('mouseout', { clientX: 500, clientY: 800, relatedTarget: null })
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
expect(triggerSpy).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should NOT trigger on downward movement', async () => {
|
|
179
|
+
const triggerSpy = vi.fn();
|
|
180
|
+
sdk.on('trigger:exitIntent', triggerSpy);
|
|
181
|
+
|
|
182
|
+
await initPlugin();
|
|
183
|
+
|
|
184
|
+
const mouseMoveHandler = mouseEventListeners.mousemove;
|
|
185
|
+
const mouseOutHandler = mouseEventListeners.mouseout;
|
|
186
|
+
|
|
187
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 10 }));
|
|
188
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 30 }));
|
|
189
|
+
|
|
190
|
+
mouseOutHandler(
|
|
191
|
+
new MouseEvent('mouseout', { clientX: 500, clientY: 50, relatedTarget: null })
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
expect(triggerSpy).not.toHaveBeenCalled();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('SHOULD trigger on upward movement + top exit (Pathfora algorithm)', async () => {
|
|
198
|
+
const triggerSpy = vi.fn();
|
|
199
|
+
sdk.on('trigger:exitIntent', triggerSpy);
|
|
200
|
+
|
|
201
|
+
await initPlugin();
|
|
202
|
+
|
|
203
|
+
const mouseMoveHandler = mouseEventListeners.mousemove;
|
|
204
|
+
const mouseOutHandler = mouseEventListeners.mouseout;
|
|
205
|
+
|
|
206
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 200 }));
|
|
207
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 }));
|
|
208
|
+
|
|
209
|
+
mouseOutHandler(
|
|
210
|
+
new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null })
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
expect(triggerSpy).toHaveBeenCalledTimes(1);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should respect min time on page setting', async () => {
|
|
217
|
+
const triggerSpy = vi.fn();
|
|
218
|
+
sdk.on('trigger:exitIntent', triggerSpy);
|
|
219
|
+
|
|
220
|
+
await initPlugin({ sensitivity: 50, minTimeOnPage: 2000 });
|
|
221
|
+
|
|
222
|
+
const mouseMoveHandler = mouseEventListeners.mousemove;
|
|
223
|
+
const mouseOutHandler = mouseEventListeners.mouseout;
|
|
224
|
+
|
|
225
|
+
// Try immediately (should fail)
|
|
226
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 }));
|
|
227
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 }));
|
|
228
|
+
mouseOutHandler(
|
|
229
|
+
new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null })
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
expect(triggerSpy).not.toHaveBeenCalled();
|
|
233
|
+
|
|
234
|
+
// Wait and try again
|
|
235
|
+
await new Promise((resolve) => setTimeout(resolve, 2100));
|
|
236
|
+
|
|
237
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 }));
|
|
238
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 }));
|
|
239
|
+
mouseOutHandler(
|
|
240
|
+
new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null })
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
expect(triggerSpy).toHaveBeenCalledTimes(1);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should trigger only once per session', async () => {
|
|
247
|
+
const triggerSpy = vi.fn();
|
|
248
|
+
sdk.on('trigger:exitIntent', triggerSpy);
|
|
249
|
+
|
|
250
|
+
await initPlugin();
|
|
251
|
+
|
|
252
|
+
const mouseMoveHandler = mouseEventListeners.mousemove;
|
|
253
|
+
const mouseOutHandler = mouseEventListeners.mouseout;
|
|
254
|
+
|
|
255
|
+
// First trigger
|
|
256
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 }));
|
|
257
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 }));
|
|
258
|
+
mouseOutHandler(
|
|
259
|
+
new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null })
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
expect(triggerSpy).toHaveBeenCalledTimes(1);
|
|
263
|
+
|
|
264
|
+
// Try again
|
|
265
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 }));
|
|
266
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 }));
|
|
267
|
+
mouseOutHandler(
|
|
268
|
+
new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null })
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
expect(triggerSpy).toHaveBeenCalledTimes(1); // Still 1
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('should respect configurable sensitivity threshold', async () => {
|
|
275
|
+
const triggerSpy = vi.fn();
|
|
276
|
+
sdk.on('trigger:exitIntent', triggerSpy);
|
|
277
|
+
|
|
278
|
+
await initPlugin({ sensitivity: 20, minTimeOnPage: 0 });
|
|
279
|
+
|
|
280
|
+
const mouseMoveHandler = mouseEventListeners.mousemove;
|
|
281
|
+
const mouseOutHandler = mouseEventListeners.mouseout;
|
|
282
|
+
|
|
283
|
+
// Far from top with slow movement - should NOT trigger
|
|
284
|
+
// y=100, py=110, velocity=10
|
|
285
|
+
// 100 - 10 = 90, which is > 20, so won't trigger
|
|
286
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 110 }));
|
|
287
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 }));
|
|
288
|
+
mouseOutHandler(
|
|
289
|
+
new MouseEvent('mouseout', { clientX: 500, clientY: 95, relatedTarget: null })
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
expect(triggerSpy).not.toHaveBeenCalled();
|
|
293
|
+
|
|
294
|
+
// Near top with upward movement - SHOULD trigger
|
|
295
|
+
// y=10, py=30, velocity=20
|
|
296
|
+
// 10 - 20 = -10, which is <= 20, so will trigger
|
|
297
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 30 }));
|
|
298
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 10 }));
|
|
299
|
+
mouseOutHandler(
|
|
300
|
+
new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null })
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
expect(triggerSpy).toHaveBeenCalledTimes(1);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should clean up event listeners after trigger', async () => {
|
|
307
|
+
await initPlugin();
|
|
308
|
+
|
|
309
|
+
const mouseMoveHandler = mouseEventListeners.mousemove;
|
|
310
|
+
const mouseOutHandler = mouseEventListeners.mouseout;
|
|
311
|
+
|
|
312
|
+
expect(mouseMoveHandler).toBeDefined();
|
|
313
|
+
expect(mouseOutHandler).toBeDefined();
|
|
314
|
+
|
|
315
|
+
// Trigger exit intent
|
|
316
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 }));
|
|
317
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 }));
|
|
318
|
+
mouseOutHandler(
|
|
319
|
+
new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null })
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
// Listeners should be removed
|
|
323
|
+
expect(mouseEventListeners.mousemove).toBeUndefined();
|
|
324
|
+
expect(mouseEventListeners.mouseout).toBeUndefined();
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('should apply delay before emitting trigger event', async () => {
|
|
328
|
+
const triggerSpy = vi.fn();
|
|
329
|
+
sdk.on('trigger:exitIntent', triggerSpy);
|
|
330
|
+
|
|
331
|
+
await initPlugin({ sensitivity: 50, minTimeOnPage: 0, delay: 1000 });
|
|
332
|
+
|
|
333
|
+
const mouseMoveHandler = mouseEventListeners.mousemove;
|
|
334
|
+
const mouseOutHandler = mouseEventListeners.mouseout;
|
|
335
|
+
|
|
336
|
+
// Trigger exit intent
|
|
337
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 }));
|
|
338
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 }));
|
|
339
|
+
mouseOutHandler(
|
|
340
|
+
new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null })
|
|
341
|
+
);
|
|
342
|
+
|
|
343
|
+
// Should not trigger immediately
|
|
344
|
+
expect(triggerSpy).not.toHaveBeenCalled();
|
|
345
|
+
|
|
346
|
+
// Wait for delay
|
|
347
|
+
await new Promise((resolve) => setTimeout(resolve, 1100));
|
|
348
|
+
|
|
349
|
+
expect(triggerSpy).toHaveBeenCalledTimes(1);
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
describe('API Methods', () => {
|
|
354
|
+
it('should expose isTriggered() method', async () => {
|
|
355
|
+
await initPlugin();
|
|
356
|
+
|
|
357
|
+
expect((sdk as any).exitIntent.isTriggered()).toBe(false);
|
|
358
|
+
|
|
359
|
+
const mouseMoveHandler = mouseEventListeners.mousemove;
|
|
360
|
+
const mouseOutHandler = mouseEventListeners.mouseout;
|
|
361
|
+
|
|
362
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 }));
|
|
363
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 }));
|
|
364
|
+
mouseOutHandler(
|
|
365
|
+
new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null })
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
expect((sdk as any).exitIntent.isTriggered()).toBe(true);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it('should expose reset() method for testing', async () => {
|
|
372
|
+
await initPlugin();
|
|
373
|
+
|
|
374
|
+
const mouseMoveHandler = mouseEventListeners.mousemove;
|
|
375
|
+
const mouseOutHandler = mouseEventListeners.mouseout;
|
|
376
|
+
|
|
377
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 }));
|
|
378
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 }));
|
|
379
|
+
mouseOutHandler(
|
|
380
|
+
new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null })
|
|
381
|
+
);
|
|
382
|
+
|
|
383
|
+
expect((sdk as any).exitIntent.isTriggered()).toBe(true);
|
|
384
|
+
|
|
385
|
+
// Reset
|
|
386
|
+
(sdk as any).exitIntent.reset();
|
|
387
|
+
|
|
388
|
+
expect((sdk as any).exitIntent.isTriggered()).toBe(false);
|
|
389
|
+
expect((sdk as any).exitIntent.getPositions()).toHaveLength(0);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('should expose getPositions() method', async () => {
|
|
393
|
+
await initPlugin();
|
|
394
|
+
|
|
395
|
+
const mouseMoveHandler = mouseEventListeners.mousemove;
|
|
396
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 100, clientY: 200 }));
|
|
397
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 150, clientY: 250 }));
|
|
398
|
+
|
|
399
|
+
const positions = (sdk as any).exitIntent.getPositions();
|
|
400
|
+
expect(positions).toEqual([
|
|
401
|
+
{ x: 100, y: 200 },
|
|
402
|
+
{ x: 150, y: 250 },
|
|
403
|
+
]);
|
|
404
|
+
});
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
describe('SessionStorage Persistence', () => {
|
|
408
|
+
it('should store trigger state in sessionStorage', async () => {
|
|
409
|
+
await initPlugin();
|
|
410
|
+
|
|
411
|
+
const mouseMoveHandler = mouseEventListeners.mousemove;
|
|
412
|
+
const mouseOutHandler = mouseEventListeners.mouseout;
|
|
413
|
+
|
|
414
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 100 }));
|
|
415
|
+
mouseMoveHandler(new MouseEvent('mousemove', { clientX: 500, clientY: 40 }));
|
|
416
|
+
mouseOutHandler(
|
|
417
|
+
new MouseEvent('mouseout', { clientX: 500, clientY: 5, relatedTarget: null })
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
expect(sessionStorage.getItem('xp:exitIntent:triggered')).toBeTruthy();
|
|
421
|
+
});
|
|
422
|
+
});
|
|
423
|
+
});
|