@prosdevlab/experience-sdk-plugins 0.1.4 → 0.2.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 +30 -0
- package/dist/index.d.ts +608 -1
- package/dist/index.js +692 -2
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/exit-intent/exit-intent.test.ts +423 -0
- package/src/exit-intent/exit-intent.ts +372 -0
- package/src/exit-intent/index.ts +6 -0
- package/src/exit-intent/types.ts +59 -0
- package/src/index.ts +5 -0
- package/src/integration.test.ts +362 -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 +545 -0
- package/src/scroll-depth/scroll-depth.ts +400 -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 +297 -0
- package/src/time-delay/types.ts +89 -0
- package/src/utils/sanitize.ts +1 -1
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
// packages/plugins/src/exit-intent/exit-intent.ts
|
|
2
|
+
|
|
3
|
+
import type { PluginFunction } from '@lytics/sdk-kit';
|
|
4
|
+
import type { ExitIntentEvent, ExitIntentPlugin, ExitIntentPluginConfig } from './types';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Position in history
|
|
8
|
+
*/
|
|
9
|
+
interface Position {
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Pure function: Check if device is mobile
|
|
16
|
+
*/
|
|
17
|
+
export function isMobileDevice(userAgent: string): boolean {
|
|
18
|
+
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(userAgent);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Pure function: Check if minimum time has elapsed
|
|
23
|
+
*/
|
|
24
|
+
export function hasMinTimeElapsed(
|
|
25
|
+
pageLoadTime: number,
|
|
26
|
+
minTime: number,
|
|
27
|
+
currentTime: number
|
|
28
|
+
): boolean {
|
|
29
|
+
return currentTime - pageLoadTime >= minTime;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Pure function: Add position to history (immutable)
|
|
34
|
+
*/
|
|
35
|
+
export function addPositionToHistory(
|
|
36
|
+
positions: Position[],
|
|
37
|
+
newPosition: Position,
|
|
38
|
+
maxSize: number
|
|
39
|
+
): Position[] {
|
|
40
|
+
const updated = [...positions, newPosition];
|
|
41
|
+
if (updated.length > maxSize) {
|
|
42
|
+
return updated.slice(1); // Remove oldest
|
|
43
|
+
}
|
|
44
|
+
return updated;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Pure function: Calculate velocity from two Y positions
|
|
49
|
+
*/
|
|
50
|
+
export function calculateVelocity(lastY: number, previousY: number): number {
|
|
51
|
+
return Math.abs(lastY - previousY);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Pure function: Check if exit intent should trigger based on Pathfora algorithm
|
|
56
|
+
*/
|
|
57
|
+
export function shouldTriggerExitIntent(
|
|
58
|
+
positions: Position[],
|
|
59
|
+
sensitivity: number,
|
|
60
|
+
relatedTarget: EventTarget | null
|
|
61
|
+
): { shouldTrigger: boolean; lastY: number; previousY: number; velocity: number } {
|
|
62
|
+
// Must have movement history
|
|
63
|
+
if (positions.length < 2) {
|
|
64
|
+
return { shouldTrigger: false, lastY: 0, previousY: 0, velocity: 0 };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Check if leaving the document (mouse entering browser chrome)
|
|
68
|
+
// relatedTarget is null when leaving to browser UI
|
|
69
|
+
if (relatedTarget && (relatedTarget as any).nodeName !== 'HTML') {
|
|
70
|
+
return { shouldTrigger: false, lastY: 0, previousY: 0, velocity: 0 };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Get last two positions
|
|
74
|
+
const lastY = positions[positions.length - 1].y;
|
|
75
|
+
const previousY = positions[positions.length - 2].y;
|
|
76
|
+
|
|
77
|
+
// Calculate velocity (speed of upward movement)
|
|
78
|
+
const velocity = calculateVelocity(lastY, previousY);
|
|
79
|
+
|
|
80
|
+
// Check if moving up and near top (Pathfora algorithm)
|
|
81
|
+
const isMovingUp = lastY < previousY;
|
|
82
|
+
const isNearTop = lastY - velocity <= sensitivity;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
shouldTrigger: isMovingUp && isNearTop,
|
|
86
|
+
lastY,
|
|
87
|
+
previousY,
|
|
88
|
+
velocity,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Pure function: Create exit intent event payload
|
|
94
|
+
*/
|
|
95
|
+
export function createExitIntentEvent(
|
|
96
|
+
lastY: number,
|
|
97
|
+
previousY: number,
|
|
98
|
+
velocity: number,
|
|
99
|
+
pageLoadTime: number,
|
|
100
|
+
timestamp: number
|
|
101
|
+
): ExitIntentEvent {
|
|
102
|
+
return {
|
|
103
|
+
timestamp,
|
|
104
|
+
lastY,
|
|
105
|
+
previousY,
|
|
106
|
+
velocity,
|
|
107
|
+
timeOnPage: timestamp - pageLoadTime,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Exit Intent Plugin
|
|
113
|
+
*
|
|
114
|
+
* Detects when users are about to leave the page by tracking upward mouse movement
|
|
115
|
+
* near the top of the viewport. Inspired by Pathfora's showOnExitIntent.
|
|
116
|
+
*
|
|
117
|
+
* **Event-Driven Architecture:**
|
|
118
|
+
* This plugin emits `trigger:exitIntent` events when exit intent is detected.
|
|
119
|
+
* The core runtime listens for these events and automatically re-evaluates experiences.
|
|
120
|
+
*
|
|
121
|
+
* **Usage Pattern:**
|
|
122
|
+
* Use `targeting.custom` to check if exit intent has triggered:
|
|
123
|
+
*
|
|
124
|
+
* @example Basic usage
|
|
125
|
+
* ```typescript
|
|
126
|
+
* import { init, register } from '@prosdevlab/experience-sdk';
|
|
127
|
+
* import { exitIntentPlugin } from '@prosdevlab/experience-sdk-plugins';
|
|
128
|
+
*
|
|
129
|
+
* init({
|
|
130
|
+
* plugins: [exitIntentPlugin],
|
|
131
|
+
* exitIntent: {
|
|
132
|
+
* sensitivity: 20, // Trigger within 20px of top (default: 50)
|
|
133
|
+
* minTimeOnPage: 2000, // Wait 2s before enabling (default: 2000)
|
|
134
|
+
* delay: 0, // Delay after trigger (default: 0)
|
|
135
|
+
* disableOnMobile: true // Disable on mobile (default: true)
|
|
136
|
+
* }
|
|
137
|
+
* });
|
|
138
|
+
*
|
|
139
|
+
* // Show banner only when exit intent is detected
|
|
140
|
+
* register('exit-offer', {
|
|
141
|
+
* type: 'banner',
|
|
142
|
+
* content: {
|
|
143
|
+
* title: 'Wait! Don't leave yet!',
|
|
144
|
+
* message: 'Get 15% off your first order',
|
|
145
|
+
* buttons: [{ text: 'Claim Offer', variant: 'primary' }]
|
|
146
|
+
* },
|
|
147
|
+
* targeting: {
|
|
148
|
+
* custom: (context) => context.triggers?.exitIntent?.triggered === true
|
|
149
|
+
* },
|
|
150
|
+
* frequency: { max: 1, per: 'session' } // Only show once per session
|
|
151
|
+
* });
|
|
152
|
+
* ```
|
|
153
|
+
*
|
|
154
|
+
* @example Combining with other conditions
|
|
155
|
+
* ```typescript
|
|
156
|
+
* // Show exit offer only on shop pages with items in cart
|
|
157
|
+
* register('cart-recovery', {
|
|
158
|
+
* type: 'banner',
|
|
159
|
+
* content: { message: 'Complete your purchase and save!' },
|
|
160
|
+
* targeting: {
|
|
161
|
+
* url: { contains: '/shop' },
|
|
162
|
+
* custom: (context) => {
|
|
163
|
+
* return (
|
|
164
|
+
* context.triggers?.exitIntent?.triggered === true &&
|
|
165
|
+
* getCart().items.length > 0
|
|
166
|
+
* );
|
|
167
|
+
* }
|
|
168
|
+
* }
|
|
169
|
+
* });
|
|
170
|
+
* ```
|
|
171
|
+
*
|
|
172
|
+
* @example Combining multiple triggers (exit intent + scroll depth)
|
|
173
|
+
* ```typescript
|
|
174
|
+
* // Show offer on exit intent OR after 70% scroll
|
|
175
|
+
* register('engaged-exit', {
|
|
176
|
+
* type: 'banner',
|
|
177
|
+
* content: { message: 'You're almost there!' },
|
|
178
|
+
* targeting: {
|
|
179
|
+
* custom: (context) => {
|
|
180
|
+
* const exitIntent = context.triggers?.exitIntent?.triggered;
|
|
181
|
+
* const scrolled = (context.triggers?.scrollDepth?.percent || 0) >= 70;
|
|
182
|
+
* return exitIntent || scrolled;
|
|
183
|
+
* }
|
|
184
|
+
* }
|
|
185
|
+
* });
|
|
186
|
+
* ```
|
|
187
|
+
*/
|
|
188
|
+
export const exitIntentPlugin: PluginFunction = (plugin, instance, config) => {
|
|
189
|
+
plugin.ns('experiences.exitIntent');
|
|
190
|
+
|
|
191
|
+
// Default configuration
|
|
192
|
+
plugin.defaults({
|
|
193
|
+
exitIntent: {
|
|
194
|
+
sensitivity: 50,
|
|
195
|
+
minTimeOnPage: 2000,
|
|
196
|
+
delay: 0,
|
|
197
|
+
positionHistorySize: 30,
|
|
198
|
+
disableOnMobile: true,
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
const exitIntentConfig = config.get<ExitIntentPluginConfig['exitIntent']>('exitIntent');
|
|
203
|
+
|
|
204
|
+
if (!exitIntentConfig) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// State
|
|
209
|
+
let positions: Position[] = [];
|
|
210
|
+
let triggered = false;
|
|
211
|
+
const pageLoadTime = Date.now();
|
|
212
|
+
let mouseMoveListener: ((e: MouseEvent) => void) | null = null;
|
|
213
|
+
let mouseOutListener: ((e: MouseEvent) => void) | null = null;
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Check if exit intent should be disabled (uses pure function)
|
|
217
|
+
*/
|
|
218
|
+
function shouldDisable(): boolean {
|
|
219
|
+
if (!exitIntentConfig?.disableOnMobile) {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
return isMobileDevice(navigator.userAgent);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Track mouse position (updates state immutably using pure function)
|
|
227
|
+
*/
|
|
228
|
+
function trackPosition(e: MouseEvent): void {
|
|
229
|
+
const newPosition = { x: e.clientX, y: e.clientY };
|
|
230
|
+
const maxSize = exitIntentConfig?.positionHistorySize ?? 30;
|
|
231
|
+
positions = addPositionToHistory(positions, newPosition, maxSize);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Handle exit intent trigger
|
|
236
|
+
*/
|
|
237
|
+
function handleExitIntent(e: MouseEvent): void {
|
|
238
|
+
// Check if already triggered
|
|
239
|
+
if (triggered) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Check minimum time on page using pure function
|
|
244
|
+
const minTime = exitIntentConfig?.minTimeOnPage ?? 2000;
|
|
245
|
+
if (!hasMinTimeElapsed(pageLoadTime, minTime, Date.now())) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Check if should trigger using pure function
|
|
250
|
+
const sensitivity = exitIntentConfig?.sensitivity ?? 50;
|
|
251
|
+
const relatedTarget = (e as any).relatedTarget || (e as any).toElement;
|
|
252
|
+
const result = shouldTriggerExitIntent(positions, sensitivity, relatedTarget);
|
|
253
|
+
|
|
254
|
+
if (result.shouldTrigger) {
|
|
255
|
+
triggered = true;
|
|
256
|
+
|
|
257
|
+
// Create event payload using pure function
|
|
258
|
+
const eventPayload = createExitIntentEvent(
|
|
259
|
+
result.lastY,
|
|
260
|
+
result.previousY,
|
|
261
|
+
result.velocity,
|
|
262
|
+
pageLoadTime,
|
|
263
|
+
Date.now()
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
// Apply delay if configured
|
|
267
|
+
const delay = exitIntentConfig?.delay ?? 0;
|
|
268
|
+
|
|
269
|
+
if (delay > 0) {
|
|
270
|
+
setTimeout(() => {
|
|
271
|
+
// Emit trigger event (Core will handle evaluation)
|
|
272
|
+
instance.emit('trigger:exitIntent', eventPayload);
|
|
273
|
+
}, delay);
|
|
274
|
+
} else {
|
|
275
|
+
// Emit trigger event (Core will handle evaluation)
|
|
276
|
+
instance.emit('trigger:exitIntent', eventPayload);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Store in sessionStorage to prevent re-triggering
|
|
280
|
+
try {
|
|
281
|
+
sessionStorage.setItem('xp:exitIntent:triggered', Date.now().toString());
|
|
282
|
+
} catch (_e) {
|
|
283
|
+
// Ignore sessionStorage errors (e.g., in incognito mode)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Cleanup listeners after trigger (one-time event)
|
|
287
|
+
cleanup();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Cleanup event listeners
|
|
293
|
+
*/
|
|
294
|
+
function cleanup(): void {
|
|
295
|
+
if (mouseMoveListener) {
|
|
296
|
+
document.removeEventListener('mousemove', mouseMoveListener);
|
|
297
|
+
mouseMoveListener = null;
|
|
298
|
+
}
|
|
299
|
+
if (mouseOutListener) {
|
|
300
|
+
document.removeEventListener('mouseout', mouseOutListener);
|
|
301
|
+
mouseOutListener = null;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Initialize exit intent detection
|
|
307
|
+
*/
|
|
308
|
+
function initialize(): void {
|
|
309
|
+
if (shouldDisable()) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Check if exit intent was already triggered this session
|
|
314
|
+
try {
|
|
315
|
+
const storedTrigger = sessionStorage.getItem('xp:exitIntent:triggered');
|
|
316
|
+
if (storedTrigger) {
|
|
317
|
+
triggered = true;
|
|
318
|
+
return; // Don't set up listeners if already triggered
|
|
319
|
+
}
|
|
320
|
+
} catch (_e) {
|
|
321
|
+
// Ignore sessionStorage errors (e.g., in incognito mode)
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
mouseMoveListener = trackPosition;
|
|
325
|
+
mouseOutListener = handleExitIntent;
|
|
326
|
+
|
|
327
|
+
document.addEventListener('mousemove', mouseMoveListener);
|
|
328
|
+
document.addEventListener('mouseout', mouseOutListener);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Expose API
|
|
332
|
+
plugin.expose({
|
|
333
|
+
exitIntent: {
|
|
334
|
+
/**
|
|
335
|
+
* Check if exit intent has been triggered
|
|
336
|
+
*/
|
|
337
|
+
isTriggered: () => triggered,
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Reset exit intent state (useful for testing)
|
|
341
|
+
*/
|
|
342
|
+
reset: () => {
|
|
343
|
+
triggered = false;
|
|
344
|
+
positions = [];
|
|
345
|
+
|
|
346
|
+
// Clear sessionStorage
|
|
347
|
+
try {
|
|
348
|
+
sessionStorage.removeItem('xp:exitIntent:triggered');
|
|
349
|
+
} catch (_e) {
|
|
350
|
+
// Ignore sessionStorage errors
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
cleanup();
|
|
354
|
+
initialize();
|
|
355
|
+
},
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Get current position history
|
|
359
|
+
*/
|
|
360
|
+
getPositions: () => [...positions],
|
|
361
|
+
} satisfies ExitIntentPlugin,
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// Initialize on plugin load
|
|
365
|
+
initialize();
|
|
366
|
+
|
|
367
|
+
// Cleanup on instance destroy
|
|
368
|
+
const destroyHandler = () => {
|
|
369
|
+
cleanup();
|
|
370
|
+
};
|
|
371
|
+
instance.on('destroy', destroyHandler);
|
|
372
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// packages/plugins/src/exit-intent/types.ts
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Exit Intent Plugin Configuration
|
|
5
|
+
*/
|
|
6
|
+
export interface ExitIntentPluginConfig {
|
|
7
|
+
exitIntent?: {
|
|
8
|
+
/**
|
|
9
|
+
* Maximum Y position (px) where exit intent can trigger
|
|
10
|
+
* @default 50
|
|
11
|
+
*/
|
|
12
|
+
sensitivity?: number;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Minimum time on page (ms) before exit intent is active
|
|
16
|
+
* Prevents immediate triggers on page load
|
|
17
|
+
* @default 2000
|
|
18
|
+
*/
|
|
19
|
+
minTimeOnPage?: number;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Delay (ms) between detection and trigger
|
|
23
|
+
* @default 0
|
|
24
|
+
*/
|
|
25
|
+
delay?: number;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Number of mouse positions to track for velocity calculation
|
|
29
|
+
* @default 30
|
|
30
|
+
*/
|
|
31
|
+
positionHistorySize?: number;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Disable exit intent on mobile devices
|
|
35
|
+
* @default true
|
|
36
|
+
*/
|
|
37
|
+
disableOnMobile?: boolean;
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Exit Intent Event Payload
|
|
43
|
+
*/
|
|
44
|
+
export interface ExitIntentEvent {
|
|
45
|
+
timestamp: number;
|
|
46
|
+
lastY: number; // Last Y position before exit
|
|
47
|
+
previousY: number; // Previous Y position
|
|
48
|
+
velocity: number; // Calculated Y velocity
|
|
49
|
+
timeOnPage: number; // Ms user was on page
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Exit Intent Plugin API
|
|
54
|
+
*/
|
|
55
|
+
export interface ExitIntentPlugin {
|
|
56
|
+
isTriggered(): boolean;
|
|
57
|
+
reset(): void;
|
|
58
|
+
getPositions(): Array<{ x: number; y: number }>;
|
|
59
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -8,7 +8,12 @@ export * from './banner';
|
|
|
8
8
|
|
|
9
9
|
// Export plugins
|
|
10
10
|
export * from './debug';
|
|
11
|
+
export * from './exit-intent';
|
|
11
12
|
export * from './frequency';
|
|
13
|
+
export * from './page-visits';
|
|
14
|
+
export * from './scroll-depth';
|
|
15
|
+
export * from './time-delay';
|
|
16
|
+
|
|
12
17
|
// Export shared types
|
|
13
18
|
export type {
|
|
14
19
|
BannerContent,
|