@prosdevlab/experience-sdk-plugins 0.1.3 → 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.
@@ -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,6 @@
1
+ /**
2
+ * Exit Intent Plugin - Barrel Export
3
+ */
4
+
5
+ export { exitIntentPlugin } from './exit-intent';
6
+ export type { ExitIntentEvent, ExitIntentPlugin, ExitIntentPluginConfig } from './types';
@@ -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,