@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,297 @@
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
+ const destroyHandler = () => {
294
+ cleanup();
295
+ };
296
+ instance.on('destroy', destroyHandler);
297
+ };
@@ -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
+ }
@@ -92,7 +92,7 @@ export function sanitizeHTML(html: string): string {
92
92
  }
93
93
  }
94
94
 
95
- const attrString = attrs.length > 0 ? ' ' + attrs.join(' ') : '';
95
+ const attrString = attrs.length > 0 ? ` ${attrs.join(' ')}` : '';
96
96
 
97
97
  // Process child nodes
98
98
  let innerHTML = '';