@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.
@@ -0,0 +1,400 @@
1
+ /** @module scrollDepthPlugin */
2
+
3
+ import type { PluginFunction } from '@lytics/sdk-kit';
4
+ import type { ScrollDepthEvent, ScrollDepthPluginConfig } from './types';
5
+
6
+ /**
7
+ * Pure function: Detect device type based on user agent and screen size
8
+ */
9
+ export function detectDevice(): 'mobile' | 'tablet' | 'desktop' {
10
+ if (typeof window === 'undefined') return 'desktop';
11
+
12
+ const ua = navigator.userAgent;
13
+ const isMobile = /Android|webOS|iPhone|iPod|BlackBerry|IEMobile|Opera Mini/i.test(ua);
14
+ const isTablet = /iPad|Android(?!.*Mobile)/i.test(ua);
15
+
16
+ // Also check screen size as fallback
17
+ const width = window.innerWidth;
18
+ if (width < 768) return 'mobile';
19
+ if (width < 1024) return 'tablet';
20
+ if (isMobile) return 'mobile';
21
+ if (isTablet) return 'tablet';
22
+
23
+ return 'desktop';
24
+ }
25
+
26
+ /**
27
+ * Pure function: Throttle helper
28
+ * @param func Function to throttle
29
+ * @param wait Wait time in milliseconds
30
+ * @returns Throttled function
31
+ */
32
+ export function throttle<T extends (...args: any[]) => void>(
33
+ func: T,
34
+ wait: number
35
+ ): (...args: Parameters<T>) => void {
36
+ let timeout: ReturnType<typeof setTimeout> | null = null;
37
+ let previous = 0;
38
+
39
+ return function throttled(...args: Parameters<T>) {
40
+ const now = Date.now();
41
+ const remaining = wait - (now - previous);
42
+
43
+ if (remaining <= 0 || remaining > wait) {
44
+ if (timeout) {
45
+ clearTimeout(timeout);
46
+ timeout = null;
47
+ }
48
+ previous = now;
49
+ func(...args);
50
+ } else if (!timeout) {
51
+ timeout = setTimeout(() => {
52
+ previous = Date.now();
53
+ timeout = null;
54
+ func(...args);
55
+ }, remaining);
56
+ }
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Pure function: Calculate scroll percentage
62
+ */
63
+ export function calculateScrollPercent(includeViewportHeight: boolean): number {
64
+ if (typeof document === 'undefined') return 0;
65
+
66
+ // Browser compatibility: Use scrollingElement or fallback
67
+ const scrollingElement = document.scrollingElement || document.documentElement;
68
+
69
+ const scrollTop = scrollingElement.scrollTop;
70
+ const scrollHeight = scrollingElement.scrollHeight;
71
+ const clientHeight = scrollingElement.clientHeight;
72
+
73
+ // Handle edge case: content shorter than viewport
74
+ if (scrollHeight <= clientHeight) {
75
+ return 100; // Treat as fully scrolled
76
+ }
77
+
78
+ if (includeViewportHeight) {
79
+ // Include viewport: more intuitive for users
80
+ // 100% when bottom of viewport reaches end of content
81
+ return Math.min(((scrollTop + clientHeight) / scrollHeight) * 100, 100);
82
+ }
83
+
84
+ // Exclude viewport: traditional method
85
+ // 100% when top of viewport reaches scrollable end
86
+ return Math.min((scrollTop / (scrollHeight - clientHeight)) * 100, 100);
87
+ }
88
+
89
+ /**
90
+ * Pure function: Calculate engagement score from metrics
91
+ */
92
+ export function calculateEngagementScore(
93
+ velocity: number,
94
+ fastScrollThreshold: number,
95
+ directionChanges: number,
96
+ timeScrollingUp: number,
97
+ totalTime: number
98
+ ): number {
99
+ // Lower score = more engaged (slower scrolling, fewer direction changes)
100
+ // Higher score = skimming (fast scrolling, lots of seeking)
101
+ const velocityScore = Math.min((velocity / fastScrollThreshold) * 50, 50);
102
+ const directionScore = Math.min((directionChanges / 5) * 30, 30);
103
+ const seekingScore = Math.min((timeScrollingUp / totalTime) * 20, 20);
104
+
105
+ return Math.max(0, 100 - (velocityScore + directionScore + seekingScore));
106
+ }
107
+
108
+ /**
109
+ * Scroll Depth Plugin
110
+ *
111
+ * Tracks scroll depth and emits `trigger:scrollDepth` events when thresholds are crossed.
112
+ *
113
+ * ## How It Works
114
+ *
115
+ * 1. **Detection**: Listens to `scroll` events (throttled)
116
+ * 2. **Calculation**: Calculates current scroll percentage
117
+ * 3. **Tracking**: Tracks maximum scroll depth and threshold crossings
118
+ * 4. **Emission**: Emits `trigger:scrollDepth` events when thresholds are crossed
119
+ *
120
+ * ## Configuration
121
+ *
122
+ * ```typescript
123
+ * init({
124
+ * scrollDepth: {
125
+ * thresholds: [25, 50, 75, 100], // Percentages to track
126
+ * throttle: 100, // Throttle interval (ms)
127
+ * includeViewportHeight: true, // Calculation method
128
+ * recalculateOnResize: true // Recalculate on resize
129
+ * }
130
+ * });
131
+ * ```
132
+ *
133
+ * ## Experience Targeting
134
+ *
135
+ * ```typescript
136
+ * register('mid-article-cta', {
137
+ * type: 'banner',
138
+ * content: { message: 'Enjoying the article?' },
139
+ * targeting: {
140
+ * custom: (ctx) => (ctx.triggers?.scrollDepth?.percent || 0) >= 50
141
+ * }
142
+ * });
143
+ * ```
144
+ *
145
+ * ## API Methods
146
+ *
147
+ * ```typescript
148
+ * // Get maximum scroll percentage reached
149
+ * instance.scrollDepth.getMaxPercent(); // 73
150
+ *
151
+ * // Get current scroll percentage
152
+ * instance.scrollDepth.getCurrentPercent(); // 50
153
+ *
154
+ * // Get all crossed thresholds
155
+ * instance.scrollDepth.getThresholdsCrossed(); // [25, 50]
156
+ *
157
+ * // Reset tracking (useful for testing)
158
+ * instance.scrollDepth.reset();
159
+ * ```
160
+ *
161
+ * @param plugin Plugin interface from sdk-kit
162
+ * @param instance SDK instance
163
+ * @param config SDK configuration
164
+ */
165
+ export const scrollDepthPlugin: PluginFunction = (plugin, instance, config) => {
166
+ plugin.ns('experiences.scrollDepth');
167
+
168
+ // Set defaults
169
+ plugin.defaults({
170
+ scrollDepth: {
171
+ thresholds: [25, 50, 75, 100],
172
+ throttle: 100,
173
+ includeViewportHeight: true,
174
+ recalculateOnResize: true,
175
+ trackAdvancedMetrics: false,
176
+ fastScrollVelocityThreshold: 3,
177
+ disableOnMobile: false,
178
+ },
179
+ });
180
+
181
+ // Get config
182
+ const scrollConfig = config.get('scrollDepth') as ScrollDepthPluginConfig['scrollDepth'];
183
+ if (!scrollConfig) return;
184
+
185
+ // TypeScript guard: scrollConfig is now guaranteed to be defined
186
+ const cfg = scrollConfig;
187
+
188
+ // Check device and disable if needed (using pure function)
189
+ const device = detectDevice();
190
+ if (cfg.disableOnMobile && device === 'mobile') {
191
+ return; // Skip initialization on mobile
192
+ }
193
+
194
+ // State
195
+ let maxScrollPercent = 0;
196
+ const triggeredThresholds = new Set<number>();
197
+
198
+ // Advanced metrics state
199
+ const pageLoadTime = Date.now();
200
+ let lastScrollPosition = 0;
201
+ let lastScrollTime = Date.now();
202
+ let lastScrollDirection: 'up' | 'down' | null = null;
203
+ let directionChangesSinceLastThreshold = 0;
204
+ let timeScrollingUp = 0;
205
+ const thresholdTimes = new Map<number, number>(); // threshold -> time reached
206
+
207
+ /**
208
+ * Handle scroll event
209
+ */
210
+ function handleScroll() {
211
+ // Use pure function for calculation
212
+ const currentPercent = calculateScrollPercent(cfg.includeViewportHeight ?? true);
213
+ const now = Date.now();
214
+ const scrollingElement = document.scrollingElement || document.documentElement;
215
+ const currentPosition = scrollingElement.scrollTop;
216
+
217
+ // Track advanced metrics if enabled
218
+ let velocity = 0;
219
+ let _directionChange = false;
220
+
221
+ if (cfg.trackAdvancedMetrics) {
222
+ // Calculate velocity (pixels per millisecond)
223
+ const timeDelta = now - lastScrollTime;
224
+ const positionDelta = currentPosition - lastScrollPosition;
225
+ velocity = timeDelta > 0 ? Math.abs(positionDelta) / timeDelta : 0;
226
+
227
+ // Detect direction changes
228
+ const currentDirection =
229
+ positionDelta > 0 ? 'down' : positionDelta < 0 ? 'up' : lastScrollDirection;
230
+ if (currentDirection && lastScrollDirection && currentDirection !== lastScrollDirection) {
231
+ directionChangesSinceLastThreshold++;
232
+ _directionChange = true;
233
+ }
234
+
235
+ // Track time spent scrolling up (seeking behavior)
236
+ if (currentDirection === 'up' && timeDelta > 0) {
237
+ timeScrollingUp += timeDelta;
238
+ }
239
+
240
+ lastScrollDirection = currentDirection;
241
+ lastScrollPosition = currentPosition;
242
+ lastScrollTime = now;
243
+ }
244
+
245
+ // Update max scroll
246
+ maxScrollPercent = Math.max(maxScrollPercent, currentPercent);
247
+
248
+ // Check thresholds
249
+ for (const threshold of cfg.thresholds || []) {
250
+ if (currentPercent >= threshold && !triggeredThresholds.has(threshold)) {
251
+ triggeredThresholds.add(threshold);
252
+
253
+ // Record time to threshold
254
+ if (cfg.trackAdvancedMetrics) {
255
+ thresholdTimes.set(threshold, now - pageLoadTime);
256
+ }
257
+
258
+ // Build event payload
259
+ const eventPayload: ScrollDepthEvent = {
260
+ triggered: true,
261
+ timestamp: now,
262
+ percent: Math.round(currentPercent * 100) / 100,
263
+ maxPercent: Math.round(maxScrollPercent * 100) / 100,
264
+ threshold,
265
+ thresholdsCrossed: Array.from(triggeredThresholds).sort((a, b) => a - b),
266
+ device,
267
+ };
268
+
269
+ // Add advanced metrics if enabled
270
+ if (cfg.trackAdvancedMetrics) {
271
+ const fastScrollThreshold = cfg.fastScrollVelocityThreshold || 3;
272
+ const isFastScrolling = velocity > fastScrollThreshold;
273
+
274
+ // Calculate engagement score using pure function
275
+ const engagementScore = calculateEngagementScore(
276
+ velocity,
277
+ fastScrollThreshold,
278
+ directionChangesSinceLastThreshold,
279
+ timeScrollingUp,
280
+ now - pageLoadTime
281
+ );
282
+
283
+ eventPayload.advanced = {
284
+ timeToThreshold: now - pageLoadTime,
285
+ velocity: Math.round(velocity * 1000) / 1000, // Round to 3 decimals
286
+ isFastScrolling,
287
+ directionChanges: directionChangesSinceLastThreshold,
288
+ timeScrollingUp,
289
+ engagementScore: Math.round(engagementScore),
290
+ };
291
+
292
+ // Reset direction changes counter after threshold
293
+ directionChangesSinceLastThreshold = 0;
294
+ }
295
+
296
+ instance.emit('trigger:scrollDepth', eventPayload);
297
+ }
298
+ }
299
+ }
300
+
301
+ // Throttle scroll handler (using pure function)
302
+ const throttledScrollHandler = throttle(handleScroll, cfg.throttle || 100);
303
+
304
+ // Throttle resize handler (using pure function)
305
+ const throttledResizeHandler = throttle(handleScroll, cfg.throttle || 100);
306
+
307
+ // Initialize
308
+ function initialize() {
309
+ if (typeof window === 'undefined' || typeof document === 'undefined') {
310
+ return; // Not in browser environment
311
+ }
312
+
313
+ // Add scroll listener
314
+ window.addEventListener('scroll', throttledScrollHandler, { passive: true });
315
+
316
+ // Add resize listener (optional)
317
+ if (cfg.recalculateOnResize) {
318
+ window.addEventListener('resize', throttledResizeHandler, { passive: true });
319
+ }
320
+
321
+ // Don't check initial scroll position - wait for first user interaction
322
+ // This avoids triggering all thresholds immediately on pages that start scrolled
323
+ }
324
+
325
+ // Cleanup function
326
+ function cleanup() {
327
+ window.removeEventListener('scroll', throttledScrollHandler);
328
+ window.removeEventListener('resize', throttledResizeHandler);
329
+ }
330
+
331
+ // Setup destroy handler
332
+ const destroyHandler = () => {
333
+ cleanup();
334
+ };
335
+ instance.on('destroy', destroyHandler);
336
+
337
+ // Expose API
338
+ plugin.expose({
339
+ scrollDepth: {
340
+ /**
341
+ * Get the maximum scroll percentage reached during the session
342
+ */
343
+ getMaxPercent: () => maxScrollPercent,
344
+
345
+ /**
346
+ * Get the current scroll percentage
347
+ */
348
+ getCurrentPercent: () => calculateScrollPercent(cfg.includeViewportHeight ?? true),
349
+
350
+ /**
351
+ * Get all thresholds that have been crossed
352
+ */
353
+ getThresholdsCrossed: () => Array.from(triggeredThresholds).sort((a, b) => a - b),
354
+
355
+ /**
356
+ * Get the detected device type
357
+ */
358
+ getDevice: () => device,
359
+
360
+ /**
361
+ * Get advanced metrics (only available when trackAdvancedMetrics is enabled)
362
+ */
363
+ getAdvancedMetrics: () => {
364
+ if (!cfg.trackAdvancedMetrics) return null;
365
+
366
+ const now = Date.now();
367
+ return {
368
+ timeOnPage: now - pageLoadTime,
369
+ directionChanges: directionChangesSinceLastThreshold,
370
+ timeScrollingUp,
371
+ thresholdTimes: Object.fromEntries(thresholdTimes),
372
+ };
373
+ },
374
+
375
+ /**
376
+ * Reset scroll depth tracking
377
+ * Clears all triggered thresholds, max scroll, and advanced metrics
378
+ */
379
+ reset: () => {
380
+ maxScrollPercent = 0;
381
+ triggeredThresholds.clear();
382
+ directionChangesSinceLastThreshold = 0;
383
+ timeScrollingUp = 0;
384
+ thresholdTimes.clear();
385
+ lastScrollDirection = null;
386
+ },
387
+ },
388
+ });
389
+
390
+ // Initialize on next tick to ensure DOM is ready
391
+ if (typeof window !== 'undefined') {
392
+ setTimeout(initialize, 0);
393
+ }
394
+
395
+ // Return cleanup function
396
+ return () => {
397
+ cleanup();
398
+ instance.off('destroy', destroyHandler);
399
+ };
400
+ };
@@ -0,0 +1,122 @@
1
+ /** @module scrollDepthPlugin */
2
+
3
+ /**
4
+ * Scroll Depth Plugin API
5
+ */
6
+ export interface ScrollDepthPlugin {
7
+ getMaxPercent(): number;
8
+ getCurrentPercent(): number;
9
+ getThresholdsCrossed(): number[];
10
+ getDevice(): 'mobile' | 'tablet' | 'desktop';
11
+ getAdvancedMetrics(): {
12
+ timeOnPage: number;
13
+ directionChanges: number;
14
+ timeScrollingUp: number;
15
+ thresholdTimes: Record<number, number>;
16
+ } | null;
17
+ reset(): void;
18
+ }
19
+
20
+ /**
21
+ * Scroll Depth Plugin Configuration
22
+ *
23
+ * Tracks scroll depth and emits trigger:scrollDepth events when thresholds are crossed.
24
+ */
25
+ export interface ScrollDepthPluginConfig {
26
+ scrollDepth?: {
27
+ /**
28
+ * Array of scroll percentage thresholds to track (0-100).
29
+ * When user scrolls past a threshold, a trigger:scrollDepth event is emitted.
30
+ * @default [25, 50, 75, 100]
31
+ * @example [50, 100]
32
+ */
33
+ thresholds?: number[];
34
+
35
+ /**
36
+ * Throttle interval in milliseconds for scroll event handler.
37
+ * Lower values are more responsive but impact performance.
38
+ * @default 100
39
+ * @example 200
40
+ */
41
+ throttle?: number;
42
+
43
+ /**
44
+ * Include viewport height in scroll percentage calculation.
45
+ *
46
+ * - true: (scrollTop + viewportHeight) / totalHeight
47
+ * More intuitive: 100% when bottom of viewport reaches end
48
+ * - false: scrollTop / (totalHeight - viewportHeight)
49
+ * Pathfora's method: 100% when top of viewport reaches end
50
+ *
51
+ * @default true
52
+ */
53
+ includeViewportHeight?: boolean;
54
+
55
+ /**
56
+ * Recalculate scroll on window resize.
57
+ * Useful for responsive layouts where content height changes.
58
+ * @default true
59
+ */
60
+ recalculateOnResize?: boolean;
61
+
62
+ /**
63
+ * Track advanced metrics (velocity, direction, time-to-threshold).
64
+ * Enables advanced engagement quality analysis.
65
+ * Slight performance overhead but provides rich insights.
66
+ * @default false
67
+ */
68
+ trackAdvancedMetrics?: boolean;
69
+
70
+ /**
71
+ * Velocity threshold (px/ms) to consider "fast scrolling".
72
+ * Fast scrolling often indicates skimming rather than reading.
73
+ * Only used when trackAdvancedMetrics is true.
74
+ * @default 3
75
+ */
76
+ fastScrollVelocityThreshold?: number;
77
+
78
+ /**
79
+ * Disable scroll tracking on mobile devices.
80
+ * Useful since mobile scroll behavior differs significantly from desktop.
81
+ * @default false
82
+ */
83
+ disableOnMobile?: boolean;
84
+ };
85
+ }
86
+
87
+ /**
88
+ * Scroll Depth Event Payload
89
+ *
90
+ * Emitted as `trigger:scrollDepth` when a threshold is crossed.
91
+ */
92
+ export interface ScrollDepthEvent {
93
+ /** Whether the trigger has fired */
94
+ triggered: boolean;
95
+ /** Timestamp when the event was emitted */
96
+ timestamp: number;
97
+ /** Current scroll percentage (0-100) */
98
+ percent: number;
99
+ /** Maximum scroll percentage reached during session */
100
+ maxPercent: number;
101
+ /** The threshold that was just crossed */
102
+ threshold: number;
103
+ /** All thresholds that have been triggered */
104
+ thresholdsCrossed: number[];
105
+ /** Device type (mobile, tablet, desktop) */
106
+ device: 'mobile' | 'tablet' | 'desktop';
107
+ /** Advanced metrics (only present when trackAdvancedMetrics is enabled) */
108
+ advanced?: {
109
+ /** Time in milliseconds to reach this threshold from page load */
110
+ timeToThreshold: number;
111
+ /** Current scroll velocity in pixels per millisecond */
112
+ velocity: number;
113
+ /** Whether user is scrolling fast (indicates skimming) */
114
+ isFastScrolling: boolean;
115
+ /** Number of direction changes (up/down) since last threshold */
116
+ directionChanges: number;
117
+ /** Total time spent scrolling up (indicates seeking behavior) */
118
+ timeScrollingUp: number;
119
+ /** Scroll quality score (0-100, higher = more engaged) */
120
+ engagementScore: number;
121
+ };
122
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Time Delay Plugin - Barrel Export
3
+ */
4
+
5
+ export { timeDelayPlugin } from './time-delay';
6
+ export type { TimeDelayEvent, TimeDelayPlugin, TimeDelayPluginConfig } from './types';