@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,296 @@
1
+ /** @module timeDelayPlugin */
2
+
3
+ import type { PluginFunction } from '@lytics/sdk-kit';
4
+ import type { TimeDelayEvent, TimeDelayPlugin, TimeDelayPluginConfig } from './types';
5
+
6
+ /**
7
+ * Pure function: Calculate elapsed time from start
8
+ */
9
+ export function calculateElapsed(startTime: number, pausedDuration: number): number {
10
+ return Date.now() - startTime - pausedDuration;
11
+ }
12
+
13
+ /**
14
+ * Pure function: Check if document is hidden (Page Visibility API)
15
+ */
16
+ export function isDocumentHidden(): boolean {
17
+ if (typeof document === 'undefined') return false;
18
+ return document.hidden || false;
19
+ }
20
+
21
+ /**
22
+ * Pure function: Create time delay event payload
23
+ */
24
+ export function createTimeDelayEvent(
25
+ startTime: number,
26
+ pausedDuration: number,
27
+ wasPaused: boolean,
28
+ visibilityChanges: number
29
+ ): TimeDelayEvent {
30
+ const timestamp = Date.now();
31
+ const elapsed = timestamp - startTime;
32
+ const activeElapsed = elapsed - pausedDuration;
33
+
34
+ return {
35
+ timestamp,
36
+ elapsed,
37
+ activeElapsed,
38
+ wasPaused,
39
+ visibilityChanges,
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Time Delay Plugin
45
+ *
46
+ * Tracks time elapsed since SDK initialization and emits trigger:timeDelay events
47
+ * when the configured delay is reached.
48
+ *
49
+ * **Features:**
50
+ * - Millisecond precision timing
51
+ * - Pause/resume on tab visibility change (optional)
52
+ * - Tracks active vs total elapsed time
53
+ * - Full timer lifecycle management
54
+ *
55
+ * **Event-Driven Architecture:**
56
+ * This plugin emits `trigger:timeDelay` events when the delay threshold is reached.
57
+ * The core runtime listens for these events and automatically re-evaluates experiences.
58
+ *
59
+ * **Usage Pattern:**
60
+ * Use `targeting.custom` to check if time delay has triggered:
61
+ *
62
+ * @example Basic usage
63
+ * ```typescript
64
+ * import { init, register } from '@prosdevlab/experience-sdk';
65
+ *
66
+ * init({
67
+ * timeDelay: {
68
+ * delay: 5000, // 5 seconds
69
+ * pauseWhenHidden: true // Pause when tab hidden (default)
70
+ * }
71
+ * });
72
+ *
73
+ * // Show banner after 5 seconds of active viewing time
74
+ * register('timed-offer', {
75
+ * type: 'banner',
76
+ * content: {
77
+ * message: 'Limited time offer!',
78
+ * buttons: [{ text: 'Claim Now', variant: 'primary' }]
79
+ * },
80
+ * targeting: {
81
+ * custom: (context) => {
82
+ * const active = context.triggers?.timeDelay?.activeElapsed || 0;
83
+ * return active >= 5000;
84
+ * }
85
+ * }
86
+ * });
87
+ * ```
88
+ *
89
+ * @example Combining with other triggers
90
+ * ```typescript
91
+ * // Show after 10s OR on exit intent (whichever comes first)
92
+ * register('engaged-offer', {
93
+ * type: 'banner',
94
+ * content: { message: 'Special offer for engaged users!' },
95
+ * targeting: {
96
+ * custom: (context) => {
97
+ * const timeElapsed = (context.triggers?.timeDelay?.activeElapsed || 0) >= 10000;
98
+ * const exitIntent = context.triggers?.exitIntent?.triggered;
99
+ * return timeElapsed || exitIntent;
100
+ * }
101
+ * }
102
+ * });
103
+ * ```
104
+ *
105
+ * @param plugin Plugin interface from sdk-kit
106
+ * @param instance SDK instance
107
+ * @param config SDK configuration
108
+ */
109
+ export const timeDelayPlugin: PluginFunction = (plugin, instance, config) => {
110
+ plugin.ns('experiences.timeDelay');
111
+
112
+ // Set defaults
113
+ plugin.defaults({
114
+ timeDelay: {
115
+ delay: 0,
116
+ pauseWhenHidden: true,
117
+ },
118
+ });
119
+
120
+ // Get config
121
+ const timeDelayConfig = config.get('timeDelay') as TimeDelayPluginConfig['timeDelay'];
122
+ if (!timeDelayConfig) return;
123
+
124
+ const delay = timeDelayConfig.delay ?? 0;
125
+ const pauseWhenHidden = timeDelayConfig.pauseWhenHidden ?? true;
126
+
127
+ // Skip if delay is 0 (disabled)
128
+ if (delay <= 0) return;
129
+
130
+ // State
131
+ const startTime = Date.now();
132
+ let triggered = false;
133
+ let paused = false;
134
+ let pausedDuration = 0;
135
+ let lastPauseTime = 0;
136
+ let visibilityChanges = 0;
137
+ let timer: ReturnType<typeof setTimeout> | null = null;
138
+ let visibilityListener: (() => void) | null = null;
139
+
140
+ /**
141
+ * Trigger the time delay event
142
+ */
143
+ function trigger(): void {
144
+ if (triggered) return;
145
+
146
+ triggered = true;
147
+
148
+ // Create event payload using pure function
149
+ const eventPayload = createTimeDelayEvent(
150
+ startTime,
151
+ pausedDuration,
152
+ visibilityChanges > 0,
153
+ visibilityChanges
154
+ );
155
+
156
+ // Emit trigger event
157
+ instance.emit('trigger:timeDelay', eventPayload);
158
+
159
+ // Cleanup
160
+ cleanup();
161
+ }
162
+
163
+ /**
164
+ * Schedule timer with remaining delay
165
+ */
166
+ function scheduleTimer(remainingDelay: number): void {
167
+ if (timer) {
168
+ clearTimeout(timer);
169
+ }
170
+
171
+ timer = setTimeout(() => {
172
+ trigger();
173
+ }, remainingDelay);
174
+ }
175
+
176
+ /**
177
+ * Handle visibility change
178
+ */
179
+ function handleVisibilityChange(): void {
180
+ const hidden = isDocumentHidden();
181
+
182
+ if (hidden && !paused) {
183
+ // Tab just became hidden - pause timer
184
+ paused = true;
185
+ lastPauseTime = Date.now();
186
+ visibilityChanges++;
187
+
188
+ // Clear existing timer
189
+ if (timer) {
190
+ clearTimeout(timer);
191
+ timer = null;
192
+ }
193
+ } else if (!hidden && paused) {
194
+ // Tab just became visible - resume timer
195
+ paused = false;
196
+ const pauseDuration = Date.now() - lastPauseTime;
197
+ pausedDuration += pauseDuration;
198
+ visibilityChanges++;
199
+
200
+ // Calculate remaining delay
201
+ const elapsed = calculateElapsed(startTime, pausedDuration);
202
+ const remaining = delay - elapsed;
203
+
204
+ if (remaining > 0) {
205
+ scheduleTimer(remaining);
206
+ } else {
207
+ // Delay already elapsed during pause
208
+ trigger();
209
+ }
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Cleanup listeners and timers
215
+ */
216
+ function cleanup(): void {
217
+ if (timer) {
218
+ clearTimeout(timer);
219
+ timer = null;
220
+ }
221
+ if (visibilityListener && typeof document !== 'undefined') {
222
+ document.removeEventListener('visibilitychange', visibilityListener);
223
+ visibilityListener = null;
224
+ }
225
+ }
226
+
227
+ /**
228
+ * Initialize timer and visibility listener
229
+ */
230
+ function initialize(): void {
231
+ // Check if already hidden on init
232
+ if (pauseWhenHidden && isDocumentHidden()) {
233
+ paused = true;
234
+ lastPauseTime = Date.now();
235
+ visibilityChanges++;
236
+ } else {
237
+ // Start timer
238
+ scheduleTimer(delay);
239
+ }
240
+
241
+ // Setup visibility listener if pause is enabled
242
+ if (pauseWhenHidden && typeof document !== 'undefined') {
243
+ visibilityListener = handleVisibilityChange;
244
+ document.addEventListener('visibilitychange', visibilityListener);
245
+ }
246
+ }
247
+
248
+ // Expose API
249
+ plugin.expose({
250
+ timeDelay: {
251
+ getElapsed: () => {
252
+ return Date.now() - startTime;
253
+ },
254
+
255
+ getActiveElapsed: () => {
256
+ let currentPausedDuration = pausedDuration;
257
+ if (paused) {
258
+ // Add current pause duration
259
+ currentPausedDuration += Date.now() - lastPauseTime;
260
+ }
261
+ return calculateElapsed(startTime, currentPausedDuration);
262
+ },
263
+
264
+ getRemaining: () => {
265
+ if (triggered) return 0;
266
+
267
+ const elapsed = calculateElapsed(startTime, pausedDuration);
268
+ const remaining = delay - elapsed;
269
+ return Math.max(0, remaining);
270
+ },
271
+
272
+ isPaused: () => paused,
273
+
274
+ isTriggered: () => triggered,
275
+
276
+ reset: () => {
277
+ triggered = false;
278
+ paused = false;
279
+ pausedDuration = 0;
280
+ lastPauseTime = 0;
281
+ visibilityChanges = 0;
282
+
283
+ cleanup();
284
+ initialize();
285
+ },
286
+ } satisfies TimeDelayPlugin,
287
+ });
288
+
289
+ // Initialize on plugin load
290
+ initialize();
291
+
292
+ // Cleanup on instance destroy
293
+ instance.on('sdk:destroy', () => {
294
+ cleanup();
295
+ });
296
+ };
@@ -0,0 +1,89 @@
1
+ /** @module timeDelayPlugin */
2
+
3
+ /**
4
+ * Time Delay Plugin Configuration
5
+ *
6
+ * Tracks time elapsed since SDK initialization and emits trigger:timeDelay events.
7
+ */
8
+ export interface TimeDelayPluginConfig {
9
+ timeDelay?: {
10
+ /**
11
+ * Delay before emitting trigger event (milliseconds).
12
+ * Set to 0 to disable (immediate trigger on init).
13
+ * @default 0
14
+ * @example 5000 // 5 seconds
15
+ */
16
+ delay?: number;
17
+
18
+ /**
19
+ * Pause timer when tab is hidden (Page Visibility API).
20
+ * When true, only counts "active viewing time".
21
+ * When false, timer runs even when tab is hidden.
22
+ * @default true
23
+ */
24
+ pauseWhenHidden?: boolean;
25
+ };
26
+ }
27
+
28
+ /**
29
+ * Time Delay Event Payload
30
+ *
31
+ * Emitted via 'trigger:timeDelay' when the configured delay is reached.
32
+ */
33
+ export interface TimeDelayEvent {
34
+ /** Timestamp when the trigger event was emitted */
35
+ timestamp: number;
36
+
37
+ /** Total elapsed time since init (milliseconds, includes paused time) */
38
+ elapsed: number;
39
+
40
+ /** Active elapsed time (milliseconds, excludes time when tab was hidden) */
41
+ activeElapsed: number;
42
+
43
+ /** Whether the timer was paused at any point */
44
+ wasPaused: boolean;
45
+
46
+ /** Number of times visibility changed (hidden/visible) */
47
+ visibilityChanges: number;
48
+ }
49
+
50
+ /**
51
+ * Time Delay Plugin API
52
+ */
53
+ export interface TimeDelayPlugin {
54
+ /**
55
+ * Get total elapsed time since init (includes paused time)
56
+ * @returns Time in milliseconds
57
+ */
58
+ getElapsed(): number;
59
+
60
+ /**
61
+ * Get active elapsed time (excludes paused time)
62
+ * @returns Time in milliseconds
63
+ */
64
+ getActiveElapsed(): number;
65
+
66
+ /**
67
+ * Get remaining time until trigger
68
+ * @returns Time in milliseconds, or 0 if already triggered
69
+ */
70
+ getRemaining(): number;
71
+
72
+ /**
73
+ * Check if timer is currently paused (tab hidden)
74
+ * @returns True if paused
75
+ */
76
+ isPaused(): boolean;
77
+
78
+ /**
79
+ * Check if trigger has fired
80
+ * @returns True if triggered
81
+ */
82
+ isTriggered(): boolean;
83
+
84
+ /**
85
+ * Reset timer to initial state
86
+ * Clears trigger flag and restarts timing
87
+ */
88
+ reset(): void;
89
+ }
package/src/types.ts CHANGED
@@ -3,10 +3,25 @@
3
3
  * These types are re-exported by core for user convenience
4
4
  */
5
5
 
6
+ import type { InlineContent as _InlineContent } from './inline/types';
7
+ // Import modal and inline content types from their plugins
8
+ import type { ModalContent as _ModalContent } from './modal/types';
9
+ export type ModalContent = _ModalContent;
10
+ export type InlineContent = _InlineContent;
11
+
6
12
  /**
7
- * Experience content - varies by type
13
+ * Experience button configuration (used across all experience types)
8
14
  */
9
- export type ExperienceContent = BannerContent | ModalContent | TooltipContent;
15
+ export interface ExperienceButton {
16
+ text: string;
17
+ action?: string;
18
+ url?: string;
19
+ variant?: 'primary' | 'secondary' | 'link';
20
+ dismiss?: boolean;
21
+ metadata?: Record<string, any>;
22
+ className?: string;
23
+ style?: Record<string, string>;
24
+ }
10
25
 
11
26
  /**
12
27
  * Banner content configuration
@@ -14,31 +29,13 @@ export type ExperienceContent = BannerContent | ModalContent | TooltipContent;
14
29
  export interface BannerContent {
15
30
  title?: string;
16
31
  message: string;
17
- buttons?: Array<{
18
- text: string;
19
- action?: string;
20
- url?: string;
21
- variant?: 'primary' | 'secondary' | 'link';
22
- metadata?: Record<string, any>;
23
- className?: string;
24
- style?: Record<string, string>;
25
- }>;
32
+ buttons?: ExperienceButton[];
26
33
  dismissable?: boolean;
27
34
  position?: 'top' | 'bottom';
28
35
  className?: string;
29
36
  style?: Record<string, string>;
30
37
  }
31
38
 
32
- /**
33
- * Modal content configuration
34
- */
35
- export interface ModalContent {
36
- title: string;
37
- message: string;
38
- confirmText?: string;
39
- cancelText?: string;
40
- }
41
-
42
39
  /**
43
40
  * Tooltip content configuration
44
41
  */
@@ -48,22 +45,9 @@ export interface TooltipContent {
48
45
  }
49
46
 
50
47
  /**
51
- * Modal content configuration
52
- */
53
- export interface ModalContent {
54
- title: string;
55
- message: string;
56
- confirmText?: string;
57
- cancelText?: string;
58
- }
59
-
60
- /**
61
- * Tooltip content configuration
48
+ * Experience content - varies by type
62
49
  */
63
- export interface TooltipContent {
64
- message: string;
65
- position?: 'top' | 'bottom' | 'left' | 'right';
66
- }
50
+ export type ExperienceContent = BannerContent | ModalContent | InlineContent | TooltipContent;
67
51
 
68
52
  /**
69
53
  * Experience definition
@@ -11,7 +11,7 @@
11
11
  * Allowed HTML tags for sanitization
12
12
  * Only safe formatting tags are permitted
13
13
  */
14
- const ALLOWED_TAGS = ['strong', 'em', 'a', 'br', 'span', 'b', 'i', 'p'] as const;
14
+ const ALLOWED_TAGS = ['strong', 'em', 'a', 'br', 'span', 'b', 'i', 'p', 'div', 'ul', 'li'] as const;
15
15
 
16
16
  /**
17
17
  * Allowed attributes per tag
@@ -20,6 +20,9 @@ const ALLOWED_ATTRIBUTES: Record<string, string[]> = {
20
20
  a: ['href', 'class', 'style', 'title'],
21
21
  span: ['class', 'style'],
22
22
  p: ['class', 'style'],
23
+ div: ['class', 'style'],
24
+ ul: ['class', 'style'],
25
+ li: ['class', 'style'],
23
26
  // Other tags have no attributes allowed
24
27
  };
25
28
 
@@ -92,7 +95,7 @@ export function sanitizeHTML(html: string): string {
92
95
  }
93
96
  }
94
97
 
95
- const attrString = attrs.length > 0 ? ' ' + attrs.join(' ') : '';
98
+ const attrString = attrs.length > 0 ? ` ${attrs.join(' ')}` : '';
96
99
 
97
100
  // Process child nodes
98
101
  let innerHTML = '';