@cognior/iap-sdk 0.1.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.

Potentially problematic release.


This version of @cognior/iap-sdk might be problematic. Click here for more details.

Files changed (60) hide show
  1. package/.github/copilot-instructions.md +95 -0
  2. package/README.md +79 -0
  3. package/TRACKING.md +105 -0
  4. package/USER_CONTEXT_README.md +284 -0
  5. package/package.json +154 -0
  6. package/src/config.ts +25 -0
  7. package/src/core/flowEngine.ts +1833 -0
  8. package/src/core/triggerManager.ts +1011 -0
  9. package/src/experiences/banner.ts +366 -0
  10. package/src/experiences/beacon.ts +668 -0
  11. package/src/experiences/hotspotTour.ts +654 -0
  12. package/src/experiences/hotspots.ts +566 -0
  13. package/src/experiences/modal.ts +1337 -0
  14. package/src/experiences/modalSequence.ts +1247 -0
  15. package/src/experiences/popover.ts +652 -0
  16. package/src/experiences/registry.ts +21 -0
  17. package/src/experiences/survey.ts +1639 -0
  18. package/src/experiences/taskList.ts +625 -0
  19. package/src/experiences/tooltip.ts +740 -0
  20. package/src/experiences/types.ts +395 -0
  21. package/src/experiences/walkthrough.ts +670 -0
  22. package/src/flow-sequence.ts +177 -0
  23. package/src/flows.ts +512 -0
  24. package/src/http.ts +61 -0
  25. package/src/index.ts +355 -0
  26. package/src/services/flowManager.ts +905 -0
  27. package/src/services/flowNormalizer.ts +74 -0
  28. package/src/services/locationContextService.ts +189 -0
  29. package/src/services/pageContextService.ts +221 -0
  30. package/src/services/userContextService.ts +286 -0
  31. package/src/state/appState.ts +0 -0
  32. package/src/state/hooks.ts +0 -0
  33. package/src/state/index.ts +0 -0
  34. package/src/state/migration.ts +0 -0
  35. package/src/state/store.ts +0 -0
  36. package/src/styles/banner.css.ts +0 -0
  37. package/src/styles/hotspot.css.ts +0 -0
  38. package/src/styles/hotspotTour.css.ts +0 -0
  39. package/src/styles/modal.css.ts +564 -0
  40. package/src/styles/survey.css.ts +1013 -0
  41. package/src/styles/taskList.css.ts +0 -0
  42. package/src/styles/tooltip.css.ts +149 -0
  43. package/src/styles/walkthrough.css.ts +0 -0
  44. package/src/tourUtils.ts +0 -0
  45. package/src/tracking.ts +223 -0
  46. package/src/utils/debounce.ts +66 -0
  47. package/src/utils/eventSequenceValidator.ts +124 -0
  48. package/src/utils/flowTrackingSystem.ts +524 -0
  49. package/src/utils/idGenerator.ts +155 -0
  50. package/src/utils/immediateValidationPrevention.ts +184 -0
  51. package/src/utils/normalize.ts +50 -0
  52. package/src/utils/privacyManager.ts +166 -0
  53. package/src/utils/ruleEvaluator.ts +199 -0
  54. package/src/utils/sanitize.ts +79 -0
  55. package/src/utils/selectors.ts +107 -0
  56. package/src/utils/stepExecutor.ts +345 -0
  57. package/src/utils/triggerNormalizer.ts +149 -0
  58. package/src/utils/validationInterceptor.ts +650 -0
  59. package/tsconfig.json +13 -0
  60. package/tsup.config.ts +13 -0
@@ -0,0 +1,905 @@
1
+ // src/services/flowManager.ts
2
+ // Service for managing contextual flows and experience indicators
3
+ // Supports both CSS and XPath selectors for targeting elements
4
+
5
+ import { LocationContextService } from './locationContextService';
6
+ import type { ModalSequencePayload } from '../experiences/types';
7
+ import { resolveSelector } from '../utils/selectors';
8
+
9
+ /**
10
+ * Interface for flows with contextual triggering properties
11
+ */
12
+ export interface ContextualFlow {
13
+ id: string;
14
+ type: string;
15
+ payload: any;
16
+ elementSelector?: string;
17
+ elementTrigger?: string;
18
+ elementLocation?: string;
19
+ originalData?: any; // Store original flow data for reference
20
+ }
21
+
22
+ /**
23
+ * Manager for contextual flows and experience indicators
24
+ */
25
+ export class FlowManager {
26
+ private static _instance: FlowManager;
27
+ private _flows: ContextualFlow[] = [];
28
+ private _pendingFlows: ContextualFlow[] = []; // Flows with selectors not found in current page
29
+ private _locationService = LocationContextService.getInstance();
30
+ private _activeIndicators: Map<string, HTMLElement> = new Map();
31
+ private _activeListeners: Map<string, { element: HTMLElement, eventType: string, handler: EventListener }[]> = new Map();
32
+ private _indicatorStyle: HTMLStyleElement | null = null;
33
+ private _previousScreenId: string | undefined;
34
+
35
+ // Track active flow to prevent multiple flows from running simultaneously
36
+ private _activeFlowId: string | null = null;
37
+ private _flowInProgress: boolean = false;
38
+
39
+ // Step-level execution guards (NEW)
40
+ private _activeStepTriggered: boolean = false;
41
+ private _currentActiveStep: string | null = null;
42
+
43
+ // Track retry attempts to avoid infinite loops
44
+ private _retryAttempts: Map<string, number> = new Map();
45
+ private _maxRetryAttempts: number = 5; // Maximum number of retries per flow
46
+ private _domObserver: MutationObserver | null = null;
47
+ private _domChangeTimer: ReturnType<typeof setTimeout> | null = null;
48
+
49
+ private constructor() {
50
+ console.debug('[DAP] Initializing FlowManager');
51
+
52
+ // Subscribe to location changes
53
+ this._locationService.subscribe(this.handleLocationChange.bind(this));
54
+
55
+ // Add indicator styles
56
+ this.injectIndicatorStyles();
57
+ console.debug('[DAP] Indicator styles injected');
58
+
59
+ // Ensure we re-evaluate flows when DOM is fully ready
60
+ if (document.readyState !== 'complete') {
61
+ console.debug('[DAP] Document not complete, adding load event listener');
62
+ window.addEventListener('load', () => {
63
+ console.debug('[DAP] Window fully loaded, refreshing flow visibility');
64
+ this.refreshFlowVisibility(true);
65
+ });
66
+ } else {
67
+ console.debug('[DAP] Document already complete');
68
+ }
69
+
70
+ // Set up DOM observer for dynamic content changes
71
+ this.setupDomObserver();
72
+
73
+ console.debug('[DAP] FlowManager initialized');
74
+ }
75
+
76
+ /**
77
+ * Set up an observer to watch for DOM changes that might affect flow indicators
78
+ */
79
+ private setupDomObserver(): void {
80
+ try {
81
+ this._domObserver = new MutationObserver((mutations) => {
82
+ // Only process significant DOM changes when we have pending flows
83
+ if (this._pendingFlows.length === 0) return;
84
+
85
+ let significantChanges = false;
86
+
87
+ // Check if these mutations are significant enough to re-evaluate flows
88
+ for (const mutation of mutations) {
89
+ // Added or removed nodes might affect our selectors
90
+ if (mutation.type === 'childList' &&
91
+ (mutation.addedNodes.length > 0 || mutation.removedNodes.length > 0)) {
92
+ significantChanges = true;
93
+ break;
94
+ }
95
+ }
96
+
97
+ if (significantChanges) {
98
+ // Debounce the refresh to avoid excessive processing
99
+ if (this._domChangeTimer !== null) {
100
+ clearTimeout(this._domChangeTimer);
101
+ }
102
+
103
+ this._domChangeTimer = setTimeout(() => {
104
+ console.debug('[DAP] Significant DOM changes detected, rechecking pending flows');
105
+ this.checkPendingFlows();
106
+ }, 200);
107
+ }
108
+ });
109
+
110
+ // Start observing the document body
111
+ if (document.body) {
112
+ this._domObserver.observe(document.body, {
113
+ childList: true,
114
+ subtree: true
115
+ });
116
+ }
117
+ } catch (err) {
118
+ console.warn('[DAP] Unable to setup DOM observer:', err);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Check if any pending flows can now be displayed after DOM changes
124
+ */
125
+ private checkPendingFlows(): void {
126
+ if (this._pendingFlows.length === 0) return;
127
+
128
+ console.debug(`[DAP] Checking ${this._pendingFlows.length} pending flows after DOM changes`);
129
+
130
+ // Copy the array to avoid modification issues during iteration
131
+ const pendingFlows = [...this._pendingFlows];
132
+
133
+ // Clear the pending flows list before processing
134
+ this._pendingFlows = [];
135
+
136
+ // Try to display each pending flow with the updated DOM
137
+ pendingFlows.forEach(flow => {
138
+ console.debug(`[DAP] Rechecking flow after DOM changes: ${flow.id}`);
139
+ this.evaluateFlowVisibility(flow);
140
+ });
141
+ }
142
+
143
+ /**
144
+ * Get the singleton instance of the FlowManager
145
+ */
146
+ public static getInstance(): FlowManager {
147
+ if (!this._instance) {
148
+ this._instance = new FlowManager();
149
+ }
150
+ return this._instance;
151
+ }
152
+
153
+ /**
154
+ * Register a single flow with the manager
155
+ * @param flow The flow to register
156
+ */
157
+ public registerFlow(flow: ContextualFlow): void {
158
+ this._flows.push(flow);
159
+ this.evaluateFlowVisibility(flow);
160
+ }
161
+
162
+ /**
163
+ * Register multiple flows with the manager
164
+ * @param flows The flows to register
165
+ */
166
+ public registerFlows(flows: ContextualFlow[]): void {
167
+ this._flows.push(...flows);
168
+ this.refreshFlowVisibility();
169
+ }
170
+
171
+ /**
172
+ * Get flows that match the specified context
173
+ * @param context Context to match against
174
+ * @returns Array of matching flows
175
+ */
176
+ public getFlowsForContext(context: { screenId?: string; elementSelector?: string }): ContextualFlow[] {
177
+ return this._flows.filter(flow => {
178
+ // Match by screen ID if specified, normalizing paths by removing leading slashes
179
+ if (context.screenId && flow.elementLocation) {
180
+ const normalizedContextId = context.screenId.replace(/^\/+/, '');
181
+ const normalizedFlowLocation = flow.elementLocation.replace(/^\/+/, '');
182
+
183
+ if (normalizedFlowLocation !== normalizedContextId && normalizedFlowLocation !== '*') {
184
+ return false;
185
+ }
186
+ }
187
+
188
+ // Match by element selector if specified
189
+ if (context.elementSelector && flow.elementSelector) {
190
+ return flow.elementSelector === context.elementSelector;
191
+ }
192
+
193
+ return true;
194
+ });
195
+ }
196
+
197
+ /**
198
+ * Get all registered flows
199
+ */
200
+ public getAllFlows(): ContextualFlow[] {
201
+ return [...this._flows];
202
+ }
203
+
204
+ /**
205
+ * Get all pending flows (flows with selectors not found in the current page)
206
+ */
207
+ public getPendingFlows(): ContextualFlow[] {
208
+ return [...this._pendingFlows];
209
+ }
210
+
211
+ /**
212
+ * Clear all registered flows
213
+ */
214
+ public clearFlows(): void {
215
+ this._flows = [];
216
+ this._pendingFlows = [];
217
+ this.clearAllIndicators();
218
+ }
219
+
220
+ /**
221
+ * Manually add a flow to the pending list
222
+ * @param flow The flow to mark as pending
223
+ */
224
+ public addPendingFlow(flow: ContextualFlow): void {
225
+ if (!this._pendingFlows.some(pFlow => pFlow.id === flow.id)) {
226
+ this._pendingFlows.push(flow);
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Handle location context change
232
+ */
233
+ private handleLocationChange(context: { currentPath: string; screenId?: string }): void {
234
+ console.debug('[DAP] Location context changed:', context);
235
+
236
+ // Store the previous screen ID for context tracking
237
+ this._previousScreenId = context.screenId;
238
+
239
+ // Clear all current indicators since we're potentially on a new page
240
+ this.clearAllIndicators();
241
+
242
+ // Give the DOM a moment to update before evaluating flows
243
+ // This helps with single page applications where route changes don't fully reload the page
244
+ setTimeout(() => {
245
+ console.debug(`[DAP] Evaluating flows for new location context`);
246
+
247
+ // Re-evaluate all flows for the new context (both registered and pending)
248
+ const allFlows = [...this._flows, ...this._pendingFlows];
249
+
250
+ // Clear pending flows before re-evaluation
251
+ this._pendingFlows = [];
252
+
253
+ // Process each flow for the new location
254
+ allFlows.forEach(flow => {
255
+ console.debug(`[DAP] Re-evaluating flow for new location: ${flow.id}`);
256
+ this.evaluateFlowVisibility(flow);
257
+ });
258
+
259
+ console.debug(`[DAP] Location change evaluation complete. Pending flows: ${this._pendingFlows.length}`);
260
+ }, 100); // Small delay to allow DOM updates
261
+ }
262
+
263
+ /**
264
+ * Refresh visibility for all registered flows
265
+ * @param forceRetry Force retry even for indicators that were previously not found
266
+ */
267
+ private refreshFlowVisibility(forceRetry: boolean = false): void {
268
+ // Remove all indicators
269
+ this.clearAllIndicators();
270
+
271
+ // Reset retry counters if forcing retry
272
+ if (forceRetry) {
273
+ this._retryAttempts.clear();
274
+ }
275
+
276
+ // Re-evaluate all flows for the current context
277
+ this._flows.forEach(flow => this.evaluateFlowVisibility(flow));
278
+
279
+ // If we want to force retry for all flows that weren't found
280
+ if (forceRetry && document.readyState === 'complete') {
281
+ // Schedule another check after everything is loaded
282
+ setTimeout(() => {
283
+ console.debug('[DAP] Running secondary flow visibility check after page load');
284
+
285
+ // Try again for any flows that might have been missed
286
+ if (this._pendingFlows.length > 0) {
287
+ const pendingFlows = [...this._pendingFlows];
288
+ this._pendingFlows = [];
289
+
290
+ pendingFlows.forEach(flow => {
291
+ console.debug(`[DAP] Re-attempting to display pending flow: ${flow.id}`);
292
+ this.evaluateFlowVisibility(flow);
293
+ });
294
+ }
295
+ }, 500);
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Evaluate whether a flow should be visible in the current context
301
+ */
302
+ private evaluateFlowVisibility(flow: ContextualFlow): void {
303
+ const currentContext = this._locationService.getContext();
304
+
305
+ console.debug(`[DAP] Evaluating flow visibility:`, {
306
+ flowId: flow.id,
307
+ elementSelector: flow.elementSelector,
308
+ elementLocation: flow.elementLocation,
309
+ currentLocation: currentContext
310
+ });
311
+
312
+ // Handle flows without element selectors (immediate trigger flows)
313
+ if (!flow.elementSelector) {
314
+ console.debug(`[DAP] Flow ${flow.id} has no elementSelector - checking location constraints`);
315
+
316
+ // Check location constraints for immediate flows
317
+ if (this.matchesLocationConstraint(flow, currentContext)) {
318
+ console.debug(`[DAP] Immediate flow ${flow.id} matches location, triggering`);
319
+ // Trigger immediately if location matches
320
+ this.triggerFlow(flow);
321
+ } else {
322
+ console.debug(`[DAP] Immediate flow ${flow.id} doesn't match location, adding to pending`);
323
+ this.addToPendingIfNotExists(flow);
324
+ }
325
+ return;
326
+ }
327
+
328
+ // Handle flows with element selectors
329
+ const matchesLocation = this.matchesLocationConstraint(flow, currentContext);
330
+
331
+ if (matchesLocation) {
332
+ console.debug(`[DAP] Flow ${flow.id} matches location, attempting to add indicator`);
333
+ // Try to add the flow indicator
334
+ const wasAdded = this.addFlowIndicator(flow);
335
+
336
+ // If the flow couldn't be added (selector not found),
337
+ // add it to pending flows for when the element appears
338
+ if (!wasAdded) {
339
+ console.debug(`[DAP] Indicator not added for flow ${flow.id}, adding to pending`);
340
+ this.addToPendingIfNotExists(flow);
341
+ }
342
+ } else {
343
+ console.debug(`[DAP] Flow ${flow.id} doesn't match current location, adding to pending for future pages`);
344
+ // Flow doesn't match current location - add to pending for other pages
345
+ this.addToPendingIfNotExists(flow);
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Check if flow matches location constraint
351
+ */
352
+ private matchesLocationConstraint(flow: ContextualFlow, currentContext: any): boolean {
353
+ // If flow has no location constraint, always consider it for current screen
354
+ if (!flow.elementLocation) {
355
+ return true;
356
+ }
357
+
358
+ // Normalize paths by removing leading forward slashes
359
+ const normalizedFlowLocation = flow.elementLocation.replace(/^\/+/, '');
360
+ const normalizedScreenId = currentContext.screenId ? currentContext.screenId.replace(/^\/+/, '') : '';
361
+ const normalizedCurrentPath = currentContext.currentPath ? currentContext.currentPath.replace(/^\/+/, '') : '';
362
+
363
+ // Check if flow should be visible on current screen
364
+ return normalizedFlowLocation === normalizedScreenId ||
365
+ normalizedFlowLocation === '*' ||
366
+ normalizedFlowLocation === normalizedCurrentPath;
367
+ }
368
+
369
+ /**
370
+ * Add flow to pending list if it doesn't already exist
371
+ */
372
+ private addToPendingIfNotExists(flow: ContextualFlow): void {
373
+ if (!this._pendingFlows.some(pFlow => pFlow.id === flow.id)) {
374
+ this._pendingFlows.push(flow);
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Add a visual indicator for an available flow
380
+ * @returns boolean indicating whether the indicator was successfully added
381
+ */
382
+ private addFlowIndicator(flow: ContextualFlow): boolean {
383
+ if (!flow.elementSelector) return false;
384
+
385
+ try {
386
+ // Check if the selector looks like XPath
387
+ const isXPath = flow.elementSelector.startsWith('/') ||
388
+ flow.elementSelector.startsWith('./') ||
389
+ flow.elementSelector.startsWith('//') ||
390
+ flow.elementSelector.includes('@') ||
391
+ /\[\d+\]/.test(flow.elementSelector);
392
+
393
+ if (isXPath) {
394
+ console.debug(`[DAP] Using XPath selector: ${flow.elementSelector}`);
395
+ }
396
+
397
+ // Use resolveSelector instead of document.querySelector to support XPath
398
+ const element = resolveSelector(flow.elementSelector);
399
+ if (!element) {
400
+ console.debug(`[DAP] Element not found for selector: ${flow.elementSelector}`);
401
+
402
+ // Page is still loading or element might be part of dynamic content
403
+ // Use retry logic instead of immediately failing
404
+ if (!flow.id) {
405
+ console.debug(`[DAP] Cannot schedule retry for flow without ID`);
406
+ return false;
407
+ }
408
+
409
+ // Get current retry count
410
+ const retryCount = this._retryAttempts.get(flow.id) || 0;
411
+
412
+ // Check if we've hit the max retry count
413
+ if (retryCount >= this._maxRetryAttempts) {
414
+ console.debug(`[DAP] Maximum retry attempts (${this._maxRetryAttempts}) reached for flow: ${flow.id}`);
415
+ this._retryAttempts.delete(flow.id); // Reset for future attempts
416
+ return false;
417
+ }
418
+
419
+ // Schedule a retry with exponential backoff
420
+ const delay = Math.min(300 * Math.pow(2, retryCount), 5000); // Cap at 5 seconds
421
+ console.debug(`[DAP] Scheduling retry ${retryCount + 1}/${this._maxRetryAttempts} in ${delay}ms for flow: ${flow.id}`);
422
+
423
+ // Update retry counter
424
+ this._retryAttempts.set(flow.id, retryCount + 1);
425
+
426
+ // Schedule retry
427
+ setTimeout(() => {
428
+ // Try again
429
+ const retryResult = this.addFlowIndicator(flow);
430
+
431
+ // If we've successfully added the flow on retry, remove it from pending
432
+ if (retryResult && flow.id && this._pendingFlows.some(pFlow => pFlow.id === flow.id)) {
433
+ this._pendingFlows = this._pendingFlows.filter(pFlow => pFlow.id !== flow.id);
434
+ }
435
+ }, delay);
436
+
437
+ // Return true so we don't immediately mark as pending
438
+ return true;
439
+ }
440
+
441
+ // Create indicator if it doesn't exist for this element
442
+ if (!this._activeIndicators.has(flow.elementSelector)) {
443
+ // Get the parent element position and style
444
+ const targetElement = element as HTMLElement;
445
+
446
+ // Create container for positioning - this will be relative to the parent
447
+ const container = document.createElement('div');
448
+ container.className = 'dap-indicator-container';
449
+ container.style.position = 'relative';
450
+ container.style.zIndex = '9999';
451
+ container.style.width = '0';
452
+ container.style.height = '0';
453
+ container.style.overflow = 'visible';
454
+
455
+ // Create the indicator
456
+ const indicator = document.createElement('div');
457
+ indicator.className = 'dap-experience-indicator';
458
+ indicator.setAttribute('role', 'button');
459
+ indicator.setAttribute('aria-label', 'View available content');
460
+ indicator.setAttribute('tabindex', '0');
461
+ indicator.dataset.selector = flow.elementSelector;
462
+
463
+ // SVG light bulb icon (beautified)
464
+ indicator.innerHTML = `
465
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
466
+ <path d="M12 2C7.58172 2 4 5.58172 4 10C4 12.8492 5.62545 15.3373 8 16.584V19C8 19.5523 8.44772 20 9 20H15C15.5523 20 16 19.5523 16 19V16.584C18.3745 15.3373 20 12.8492 20 10C20 5.58172 16.4183 2 12 2Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/>
467
+ <path d="M10 22H14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
468
+ </svg>
469
+ `;
470
+
471
+ // Add the indicator to the container
472
+ container.appendChild(indicator);
473
+
474
+ // The magic: insert the container directly after the target element in the DOM
475
+ // This ensures the indicator will always follow its parent during scroll/layout changes
476
+ if (targetElement.parentNode) {
477
+ targetElement.parentNode.insertBefore(container, targetElement.nextSibling);
478
+ console.debug(`[DAP] Added indicator container to DOM after target element`);
479
+
480
+ // Position the indicator relative to its parent
481
+ this.positionIndicatorRelative(indicator, targetElement);
482
+
483
+ // Watch for resize changes to reposition
484
+ const resizeObserver = new ResizeObserver(() => {
485
+ this.positionIndicatorRelative(indicator, targetElement);
486
+ });
487
+
488
+ try {
489
+ resizeObserver.observe(targetElement);
490
+ } catch (err) {
491
+ console.warn('[DAP] ResizeObserver not supported, indicator may not reposition correctly');
492
+ }
493
+
494
+ // Add mutation observer to detect style/attribute changes
495
+ try {
496
+ const mutationObserver = new MutationObserver((mutations) => {
497
+ // Only check for style or class changes that might affect positioning
498
+ const shouldUpdate = mutations.some(m =>
499
+ m.type === 'attributes' &&
500
+ (m.attributeName === 'style' || m.attributeName === 'class')
501
+ );
502
+
503
+ if (shouldUpdate) {
504
+ this.positionIndicatorRelative(indicator, targetElement);
505
+ }
506
+ });
507
+
508
+ mutationObserver.observe(targetElement, {
509
+ attributes: true,
510
+ attributeFilter: ['style', 'class']
511
+ });
512
+ } catch (err) {
513
+ console.warn('[DAP] MutationObserver not supported, indicator may not update on style changes');
514
+ }
515
+
516
+ // Attach event listeners for the flow
517
+ this.attachTriggerHandler(indicator, targetElement, flow);
518
+
519
+ // Store the container for later cleanup
520
+ this._activeIndicators.set(flow.elementSelector, container);
521
+
522
+ // Add window resize handler to ensure positioning stays correct
523
+ const windowResizeHandler = () => this.positionIndicatorRelative(indicator, targetElement);
524
+ window.addEventListener('resize', windowResizeHandler);
525
+
526
+ if (!this._activeListeners.has('resize')) {
527
+ this._activeListeners.set('resize', []);
528
+ }
529
+ this._activeListeners.get('resize')!.push({
530
+ element: window as any as HTMLElement,
531
+ eventType: 'resize',
532
+ handler: windowResizeHandler
533
+ });
534
+ } else {
535
+ console.warn(`[DAP] Target element has no parent, cannot attach indicator`);
536
+ return false;
537
+ }
538
+
539
+ console.debug(`[DAP] Added indicator for flow: ${flow.id} at selector: ${flow.elementSelector}`);
540
+ }
541
+
542
+ // Successfully added indicator
543
+ return true;
544
+ } catch (err) {
545
+ console.error('[DAP] Error adding flow indicator:', err);
546
+ return false;
547
+ }
548
+ }
549
+
550
+ // This method has been incorporated directly into addFlowIndicator
551
+
552
+ /**
553
+ * Position indicator directly relative to its target element
554
+ * This uses absolute positioning relative to the target's box model
555
+ */
556
+ private positionIndicatorRelative(indicator: HTMLElement, targetElement: HTMLElement): void {
557
+ // Skip if elements are no longer in the DOM
558
+ if (!document.body.contains(indicator) || !document.body.contains(targetElement)) {
559
+ return;
560
+ }
561
+
562
+ // Use requestAnimationFrame for smoother positioning
563
+ requestAnimationFrame(() => {
564
+ try {
565
+ // Get target element dimensions
566
+ const rect = targetElement.getBoundingClientRect();
567
+ const indicatorWidth = indicator.offsetWidth || 36;
568
+ const indicatorHeight = indicator.offsetHeight || 36;
569
+
570
+ // Position at the top-right corner of the target element
571
+ indicator.style.position = 'absolute';
572
+ indicator.style.top = `-${indicatorHeight / 2}px`;
573
+ indicator.style.left = `${rect.width - (indicatorWidth / 2)}px`;
574
+ indicator.style.zIndex = '9999';
575
+
576
+ // Add debug logging
577
+ console.debug(`[DAP] Positioned indicator relative to target element`);
578
+ } catch (err) {
579
+ console.error('[DAP] Error positioning indicator:', err);
580
+ }
581
+ });
582
+ }
583
+
584
+ /**
585
+ * Attach event handlers based on trigger type
586
+ */
587
+ private attachTriggerHandler(
588
+ indicator: HTMLElement,
589
+ targetElement: HTMLElement,
590
+ flow: ContextualFlow
591
+ ): void {
592
+ const trigger = flow.elementTrigger?.toLowerCase() || '';
593
+ let eventType: string;
594
+
595
+ // Map trigger to event type
596
+ if (trigger.includes('click')) {
597
+ eventType = 'click';
598
+ } else if (trigger.includes('hover')) {
599
+ eventType = 'mouseenter';
600
+ } else if (trigger.includes('focus')) {
601
+ eventType = 'focus';
602
+ } else {
603
+ // Default to click
604
+ eventType = 'click';
605
+ }
606
+
607
+ // Attach event listener to both indicator and target if needed
608
+ const triggerHandler = () => this.triggerFlow(flow);
609
+
610
+ // Indicator always uses click and keydown (for accessibility)
611
+ indicator.addEventListener('click', triggerHandler);
612
+ indicator.addEventListener('keydown', (e) => {
613
+ if (e.key === 'Enter' || e.key === ' ') {
614
+ e.preventDefault();
615
+ triggerHandler();
616
+ }
617
+ });
618
+
619
+ // For hover/focus triggers, also attach to the target element
620
+ if (eventType !== 'click') {
621
+ targetElement.addEventListener(eventType, triggerHandler);
622
+
623
+ // Track this additional handler for cleanup
624
+ if (!this._activeListeners.has(flow.elementSelector!)) {
625
+ this._activeListeners.set(flow.elementSelector!, []);
626
+ }
627
+
628
+ this._activeListeners.get(flow.elementSelector!)!.push({
629
+ element: targetElement,
630
+ eventType,
631
+ handler: triggerHandler
632
+ });
633
+ }
634
+ }
635
+
636
+ /**
637
+ * Trigger a flow
638
+ */
639
+ private triggerFlow(flow: ContextualFlow): void {
640
+ console.debug(`[DAP] triggerFlow called for flow: ${flow.id}, _flowInProgress: ${this._flowInProgress}`);
641
+
642
+ // Special handling for flows without elementSelector (immediate flows)
643
+ if (!flow.elementSelector) {
644
+ console.debug(`[DAP] Triggering immediate flow (no elementSelector): ${flow.id}`);
645
+ this.renderFlow(flow, true); // Always treat as new flow
646
+ return;
647
+ }
648
+
649
+ // If no active flow, this is a new flow start
650
+ if (!this._flowInProgress) {
651
+ this.startNewFlow(flow);
652
+ return;
653
+ }
654
+
655
+ // If a flow is already active, this should be a step trigger within that flow
656
+ // Check if this trigger belongs to the current active step
657
+ const stepId = this.extractStepIdFromFlow(flow);
658
+
659
+ if (!stepId) {
660
+ console.debug(`[DAP] Ignoring trigger - no step ID found for flow: ${flow.id}`);
661
+ return;
662
+ }
663
+
664
+ // Check if this is the current active step
665
+ if (this._currentActiveStep && this._currentActiveStep !== stepId) {
666
+ console.debug(`[DAP] Trigger ignored - not for current active step. Current: ${this._currentActiveStep}, Trigger: ${stepId}`);
667
+ return;
668
+ }
669
+
670
+ // Check if current step has already been triggered
671
+ if (this._activeStepTriggered) {
672
+ console.debug(`[DAP] Trigger ignored because step already triggered: ${stepId}`);
673
+ return;
674
+ }
675
+
676
+ // Valid step trigger - execute it
677
+ this.executeStepTrigger(flow, stepId);
678
+ }
679
+
680
+ /**
681
+ * Start a new flow
682
+ */
683
+ private startNewFlow(flow: ContextualFlow): void {
684
+ console.debug(`[DAP] Starting new flow: ${flow.id}, setting _flowInProgress = true`);
685
+
686
+ // Mark flow as in progress
687
+ this._flowInProgress = true;
688
+ this._activeFlowId = flow.id;
689
+ this._activeStepTriggered = false;
690
+ this._currentActiveStep = this.extractStepIdFromFlow(flow);
691
+
692
+ console.debug(`[DAP] Flow state: _flowInProgress=${this._flowInProgress}, _activeFlowId=${this._activeFlowId}`);
693
+
694
+ this.renderFlow(flow, true); // true = new flow
695
+ }
696
+
697
+ /**
698
+ * Execute a step trigger within an active flow
699
+ */
700
+ private executeStepTrigger(flow: ContextualFlow, stepId: string): void {
701
+ // Mark this step as triggered
702
+ this._activeStepTriggered = true;
703
+
704
+ console.debug(`[DAP] Executing step trigger: ${stepId} in flow: ${flow.id}`);
705
+
706
+ this.renderFlow(flow, false); // false = step trigger, not new flow
707
+ }
708
+
709
+ /**
710
+ * Extract step ID from flow (helper method)
711
+ */
712
+ private extractStepIdFromFlow(flow: ContextualFlow): string | null {
713
+ // Try to get step ID from payload or flow structure
714
+ if (flow.payload?.steps && Array.isArray(flow.payload.steps)) {
715
+ // For multi-step flows, get the current step ID
716
+ const currentStep = flow.payload.steps[0]; // Assuming first step for triggers
717
+ return currentStep?.stepId || null;
718
+ }
719
+
720
+ // For single-step flows, use flow ID as step ID
721
+ return flow.id;
722
+ }
723
+
724
+ /**
725
+ * Render a flow (common logic for new flows and step triggers)
726
+ */
727
+ private renderFlow(flow: ContextualFlow, isNewFlow: boolean): void {
728
+ // Import dynamically to avoid circular dependencies
729
+ import('../experiences/registry').then(({ getRenderer }) => {
730
+ const renderer = getRenderer(flow.type);
731
+ if (renderer) {
732
+ // Create a wrapped renderer that handles flow completion
733
+ const completionTracker = {
734
+ onComplete: () => {
735
+ console.debug(`[DAP] Flow ${flow.id} completed`);
736
+ this.onFlowComplete();
737
+ },
738
+ onStepAdvance: (stepId: string) => {
739
+ // Called when advancing to next step - reset step-level trigger state
740
+ console.debug(`[DAP] Advancing to step: ${stepId}`);
741
+ this.onStepAdvance(stepId);
742
+ }
743
+ };
744
+
745
+ // Pass the flow and completion tracker to the renderer
746
+ renderer({
747
+ id: flow.id,
748
+ type: flow.type,
749
+ payload: {
750
+ ...flow.payload,
751
+ _completionTracker: completionTracker
752
+ }
753
+ });
754
+
755
+ // Reset step trigger state after successful rendering
756
+ // This allows subsequent step triggers to execute properly
757
+ if (!isNewFlow) {
758
+ this._activeStepTriggered = false;
759
+ }
760
+ } else {
761
+ console.warn(`[DAP] No renderer found for flow type: ${flow.type}`);
762
+ // Reset the flow state if no renderer found
763
+ if (isNewFlow) {
764
+ this.onFlowComplete();
765
+ } else {
766
+ // Reset step trigger state for step triggers with no renderer
767
+ this._activeStepTriggered = false;
768
+ }
769
+ }
770
+ }).catch(err => {
771
+ console.error('[DAP] Error triggering flow:', err);
772
+ // Reset the flow state on error
773
+ if (isNewFlow) {
774
+ this.onFlowComplete();
775
+ } else {
776
+ // Reset step trigger state on error
777
+ this._activeStepTriggered = false;
778
+ }
779
+ });
780
+ }
781
+
782
+ /**
783
+ * Handle flow completion
784
+ */
785
+ private onFlowComplete(): void {
786
+ console.debug(`[DAP] Flow completed: ${this._activeFlowId}, setting _flowInProgress = false`);
787
+ this._flowInProgress = false;
788
+ this._activeFlowId = null;
789
+ this._activeStepTriggered = false;
790
+ this._currentActiveStep = null;
791
+ console.debug(`[DAP] Flow state reset: _flowInProgress=${this._flowInProgress}`);
792
+ }
793
+
794
+ /**
795
+ * Handle step advancement
796
+ */
797
+ private onStepAdvance(stepId: string): void {
798
+ this._activeStepTriggered = false; // Reset for new step
799
+ this._currentActiveStep = stepId;
800
+ }
801
+
802
+ /**
803
+ * Clear all indicators and event listeners
804
+ */
805
+ private clearAllIndicators(): void {
806
+ // Remove all indicator container elements
807
+ this._activeIndicators.forEach((element, key) => {
808
+ if (element.tagName) {
809
+ // Remove any type of container/indicator element
810
+ if (element.className === 'dap-indicator-container' ||
811
+ element.className === 'dap-indicator-wrapper' ||
812
+ element.className === 'dap-experience-indicator') {
813
+ element.remove();
814
+ }
815
+ }
816
+ });
817
+ this._activeIndicators.clear();
818
+
819
+ // Remove all attached event listeners
820
+ this._activeListeners.forEach((listeners, selector) => {
821
+ listeners.forEach(({ element, eventType, handler }) => {
822
+ element.removeEventListener(eventType, handler);
823
+ });
824
+ });
825
+ this._activeListeners.clear();
826
+
827
+ console.debug('[DAP] Cleared all indicators and event listeners');
828
+ }
829
+
830
+ /**
831
+ * Inject indicator styles into the document
832
+ */
833
+ private injectIndicatorStyles(): void {
834
+ if (this._indicatorStyle) return;
835
+
836
+ this._indicatorStyle = document.createElement('style');
837
+ this._indicatorStyle.id = 'dap-experience-indicator-style';
838
+ this._indicatorStyle.type = 'text/css';
839
+ this._indicatorStyle.textContent = `
840
+ .dap-indicator-container {
841
+ pointer-events: none;
842
+ z-index: 9999;
843
+ position: relative;
844
+ display: block;
845
+ }
846
+
847
+ .dap-experience-indicator {
848
+ width: 36px;
849
+ height: 36px;
850
+ cursor: pointer;
851
+ background-color: rgba(255, 196, 0, 0.95); /* Yellow color */
852
+ border-radius: 50%;
853
+ padding: 6px;
854
+ display: flex;
855
+ align-items: center;
856
+ justify-content: center;
857
+ color: #000000;
858
+ box-shadow: 0 2px 6px rgba(0,0,0,0.25);
859
+ transition: all 0.2s ease-in-out;
860
+ animation: dap-indicator-pulse 2s infinite;
861
+ pointer-events: auto;
862
+ }
863
+
864
+ .dap-experience-indicator:hover,
865
+ .dap-experience-indicator:focus {
866
+ transform: scale(1.1);
867
+ background-color: rgba(255, 196, 0, 1);
868
+ outline: none;
869
+ box-shadow: 0 4px 8px rgba(0,0,0,0.3);
870
+ }
871
+
872
+ .dap-experience-indicator svg {
873
+ width: 20px;
874
+ height: 20px;
875
+ fill: none;
876
+ stroke: currentColor;
877
+ filter: drop-shadow(0 1px 1px rgba(0,0,0,0.2));
878
+ }
879
+
880
+ @keyframes dap-indicator-pulse {
881
+ 0% { box-shadow: 0 0 0 0 rgba(255, 196, 0, 0.4); }
882
+ 70% { box-shadow: 0 0 0 8px rgba(255, 196, 0, 0); }
883
+ 100% { box-shadow: 0 0 0 0 rgba(255, 196, 0, 0); }
884
+ }
885
+
886
+ @media (max-width: 768px) {
887
+ .dap-experience-indicator {
888
+ width: 42px;
889
+ height: 42px;
890
+ padding: 8px;
891
+ }
892
+
893
+ .dap-experience-indicator svg {
894
+ width: 24px;
895
+ height: 24px;
896
+ }
897
+ }
898
+ `;
899
+
900
+ document.head.appendChild(this._indicatorStyle);
901
+ }
902
+ }
903
+
904
+ // Export singleton instance
905
+ export const flowManager = FlowManager.getInstance();