@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.
- package/.github/copilot-instructions.md +95 -0
- package/README.md +79 -0
- package/TRACKING.md +105 -0
- package/USER_CONTEXT_README.md +284 -0
- package/package.json +154 -0
- package/src/config.ts +25 -0
- package/src/core/flowEngine.ts +1833 -0
- package/src/core/triggerManager.ts +1011 -0
- package/src/experiences/banner.ts +366 -0
- package/src/experiences/beacon.ts +668 -0
- package/src/experiences/hotspotTour.ts +654 -0
- package/src/experiences/hotspots.ts +566 -0
- package/src/experiences/modal.ts +1337 -0
- package/src/experiences/modalSequence.ts +1247 -0
- package/src/experiences/popover.ts +652 -0
- package/src/experiences/registry.ts +21 -0
- package/src/experiences/survey.ts +1639 -0
- package/src/experiences/taskList.ts +625 -0
- package/src/experiences/tooltip.ts +740 -0
- package/src/experiences/types.ts +395 -0
- package/src/experiences/walkthrough.ts +670 -0
- package/src/flow-sequence.ts +177 -0
- package/src/flows.ts +512 -0
- package/src/http.ts +61 -0
- package/src/index.ts +355 -0
- package/src/services/flowManager.ts +905 -0
- package/src/services/flowNormalizer.ts +74 -0
- package/src/services/locationContextService.ts +189 -0
- package/src/services/pageContextService.ts +221 -0
- package/src/services/userContextService.ts +286 -0
- package/src/state/appState.ts +0 -0
- package/src/state/hooks.ts +0 -0
- package/src/state/index.ts +0 -0
- package/src/state/migration.ts +0 -0
- package/src/state/store.ts +0 -0
- package/src/styles/banner.css.ts +0 -0
- package/src/styles/hotspot.css.ts +0 -0
- package/src/styles/hotspotTour.css.ts +0 -0
- package/src/styles/modal.css.ts +564 -0
- package/src/styles/survey.css.ts +1013 -0
- package/src/styles/taskList.css.ts +0 -0
- package/src/styles/tooltip.css.ts +149 -0
- package/src/styles/walkthrough.css.ts +0 -0
- package/src/tourUtils.ts +0 -0
- package/src/tracking.ts +223 -0
- package/src/utils/debounce.ts +66 -0
- package/src/utils/eventSequenceValidator.ts +124 -0
- package/src/utils/flowTrackingSystem.ts +524 -0
- package/src/utils/idGenerator.ts +155 -0
- package/src/utils/immediateValidationPrevention.ts +184 -0
- package/src/utils/normalize.ts +50 -0
- package/src/utils/privacyManager.ts +166 -0
- package/src/utils/ruleEvaluator.ts +199 -0
- package/src/utils/sanitize.ts +79 -0
- package/src/utils/selectors.ts +107 -0
- package/src/utils/stepExecutor.ts +345 -0
- package/src/utils/triggerNormalizer.ts +149 -0
- package/src/utils/validationInterceptor.ts +650 -0
- package/tsconfig.json +13 -0
- package/tsup.config.ts +13 -0
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Immediate Validation Prevention
|
|
3
|
+
* This script runs immediately to prevent ANY browser validation tooltips
|
|
4
|
+
* Must be loaded BEFORE any forms are rendered
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Immediate global validation suppression
|
|
8
|
+
(function immediateValidationPrevention() {
|
|
9
|
+
'use strict';
|
|
10
|
+
|
|
11
|
+
console.debug('[DAP] Immediate validation prevention activated');
|
|
12
|
+
|
|
13
|
+
// 1. Override native validation methods
|
|
14
|
+
if (typeof HTMLFormElement !== 'undefined') {
|
|
15
|
+
const originalCheckValidity = HTMLFormElement.prototype.checkValidity;
|
|
16
|
+
const originalReportValidity = HTMLFormElement.prototype.reportValidity;
|
|
17
|
+
|
|
18
|
+
HTMLFormElement.prototype.checkValidity = function() {
|
|
19
|
+
console.debug('[DAP] Form.checkValidity() intercepted');
|
|
20
|
+
return true; // Always return true to prevent validation UI
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
HTMLFormElement.prototype.reportValidity = function() {
|
|
24
|
+
console.debug('[DAP] Form.reportValidity() intercepted');
|
|
25
|
+
return true; // Always return true to prevent validation UI
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// 2. Override input validation methods
|
|
30
|
+
if (typeof HTMLInputElement !== 'undefined') {
|
|
31
|
+
const originalCheckValidity = HTMLInputElement.prototype.checkValidity;
|
|
32
|
+
const originalReportValidity = HTMLInputElement.prototype.reportValidity;
|
|
33
|
+
|
|
34
|
+
HTMLInputElement.prototype.checkValidity = function() {
|
|
35
|
+
console.debug('[DAP] Input.checkValidity() intercepted');
|
|
36
|
+
return true;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
HTMLInputElement.prototype.reportValidity = function() {
|
|
40
|
+
console.debug('[DAP] Input.reportValidity() intercepted');
|
|
41
|
+
return true;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 3. Immediate DOM event prevention with deduplication
|
|
46
|
+
let lastPreventedTarget: Element | null = null;
|
|
47
|
+
let lastPreventedTime = 0;
|
|
48
|
+
|
|
49
|
+
const preventValidation = (event: Event) => {
|
|
50
|
+
const now = Date.now();
|
|
51
|
+
// Prevent duplicate prevention calls within 100ms for same element
|
|
52
|
+
if (event.target === lastPreventedTarget && now - lastPreventedTime < 100) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
lastPreventedTarget = event.target as Element;
|
|
57
|
+
lastPreventedTime = now;
|
|
58
|
+
|
|
59
|
+
console.debug('[DAP] Validation event prevented:', event.type, event.target);
|
|
60
|
+
event.preventDefault();
|
|
61
|
+
event.stopImmediatePropagation();
|
|
62
|
+
|
|
63
|
+
// Clear any validation state
|
|
64
|
+
const target = event.target as HTMLInputElement;
|
|
65
|
+
if (target && typeof target.setCustomValidity === 'function') {
|
|
66
|
+
target.setCustomValidity('');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return false;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// Add listeners immediately
|
|
73
|
+
document.addEventListener('invalid', preventValidation, { capture: true, passive: false });
|
|
74
|
+
document.addEventListener('submit', (event) => {
|
|
75
|
+
const form = event.target as HTMLFormElement;
|
|
76
|
+
if (form && form.tagName === 'FORM') {
|
|
77
|
+
// Force disable validation
|
|
78
|
+
form.setAttribute('novalidate', '');
|
|
79
|
+
form.noValidate = true;
|
|
80
|
+
console.debug('[DAP] Form novalidate applied during submit');
|
|
81
|
+
}
|
|
82
|
+
}, { capture: true });
|
|
83
|
+
|
|
84
|
+
// 4. CSS to hide any validation bubbles that might slip through
|
|
85
|
+
const style = document.createElement('style');
|
|
86
|
+
style.id = 'dap-validation-override';
|
|
87
|
+
style.textContent = `
|
|
88
|
+
/* Hide all browser validation UI */
|
|
89
|
+
input:invalid,
|
|
90
|
+
input:-webkit-any(invalid),
|
|
91
|
+
input:-moz-ui-invalid {
|
|
92
|
+
box-shadow: none !important;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* Hide validation pseudo-elements */
|
|
96
|
+
input::-webkit-validation-bubble,
|
|
97
|
+
input::-webkit-validation-bubble-message,
|
|
98
|
+
input::-webkit-validation-bubble-arrow,
|
|
99
|
+
input::-webkit-validation-bubble-arrow-clipper {
|
|
100
|
+
display: none !important;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/* Firefox validation hiding */
|
|
104
|
+
input:-moz-ui-invalid {
|
|
105
|
+
box-shadow: none !important;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/* Hide any tooltips or popups */
|
|
109
|
+
[role="tooltip"]:not(.dap-tooltip):not(.dap-tip-bubble) {
|
|
110
|
+
display: none !important;
|
|
111
|
+
}
|
|
112
|
+
`;
|
|
113
|
+
|
|
114
|
+
// Insert styles immediately
|
|
115
|
+
if (document.head) {
|
|
116
|
+
document.head.appendChild(style);
|
|
117
|
+
} else {
|
|
118
|
+
// If head doesn't exist yet, wait for it
|
|
119
|
+
const observer = new MutationObserver(() => {
|
|
120
|
+
if (document.head) {
|
|
121
|
+
document.head.appendChild(style);
|
|
122
|
+
observer.disconnect();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
observer.observe(document.documentElement, { childList: true, subtree: true });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 5. Process existing forms immediately
|
|
129
|
+
const processExistingForms = () => {
|
|
130
|
+
const forms = document.querySelectorAll('form');
|
|
131
|
+
forms.forEach(form => {
|
|
132
|
+
form.setAttribute('novalidate', '');
|
|
133
|
+
(form as HTMLFormElement).noValidate = true;
|
|
134
|
+
console.debug('[DAP] Existing form processed:', form);
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// Process immediately if DOM is ready
|
|
139
|
+
if (document.readyState !== 'loading') {
|
|
140
|
+
processExistingForms();
|
|
141
|
+
} else {
|
|
142
|
+
document.addEventListener('DOMContentLoaded', processExistingForms);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// 6. Monitor for new forms
|
|
146
|
+
const formObserver = new MutationObserver((mutations) => {
|
|
147
|
+
mutations.forEach((mutation) => {
|
|
148
|
+
mutation.addedNodes.forEach((node) => {
|
|
149
|
+
if (node.nodeType === Node.ELEMENT_NODE) {
|
|
150
|
+
const element = node as Element;
|
|
151
|
+
|
|
152
|
+
// Check if it's a form
|
|
153
|
+
if (element.tagName === 'FORM') {
|
|
154
|
+
const form = element as HTMLFormElement;
|
|
155
|
+
form.setAttribute('novalidate', '');
|
|
156
|
+
form.noValidate = true;
|
|
157
|
+
console.debug('[DAP] New form processed:', form);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Check for forms within the element
|
|
161
|
+
const forms = element.querySelectorAll?.('form');
|
|
162
|
+
forms?.forEach(form => {
|
|
163
|
+
form.setAttribute('novalidate', '');
|
|
164
|
+
(form as HTMLFormElement).noValidate = true;
|
|
165
|
+
console.debug('[DAP] Nested form processed:', form);
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// Start observing
|
|
173
|
+
if (document.body) {
|
|
174
|
+
formObserver.observe(document.body, { childList: true, subtree: true });
|
|
175
|
+
} else {
|
|
176
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
177
|
+
formObserver.observe(document.body, { childList: true, subtree: true });
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
console.debug('[DAP] Immediate validation prevention setup complete');
|
|
182
|
+
})();
|
|
183
|
+
|
|
184
|
+
export {}; // Make this a module
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// src/utils/normalize.ts
|
|
2
|
+
/**
|
|
3
|
+
* Normalization utilities for DAP SDK
|
|
4
|
+
*
|
|
5
|
+
* This module provides central normalization functions to ensure consistent behavior across the SDK:
|
|
6
|
+
* 1. Trigger normalization: Converting variations like "on hover", "hover", "onhover" to standard values
|
|
7
|
+
* 2. Placement normalization: Handling casing variations like "Top", "TOP", "top"
|
|
8
|
+
*
|
|
9
|
+
* These utilities ensure that regardless of how data is provided from the server,
|
|
10
|
+
* the SDK processes it consistently.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Normalizes trigger strings to standard format regardless of input format
|
|
15
|
+
* Examples: "on hover", "hover", "onHover" -> "hover"
|
|
16
|
+
*/
|
|
17
|
+
export function normalizeTrigger(t: string | undefined): "hover" | "focus" | "click" {
|
|
18
|
+
if (!t) return "hover";
|
|
19
|
+
|
|
20
|
+
const s = t.trim().toLowerCase();
|
|
21
|
+
if (s.includes("hover")) return "hover";
|
|
22
|
+
if (s.includes("focus")) return "focus";
|
|
23
|
+
if (s.includes("click")) return "click";
|
|
24
|
+
if (s.includes("load")) return "hover";
|
|
25
|
+
return "hover";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Normalizes placement strings to standard format regardless of casing
|
|
30
|
+
* Examples: "Top", "TOP", "top" -> "top"
|
|
31
|
+
*/
|
|
32
|
+
export function normalizePlacement(p: string | undefined): "top" | "bottom" | "left" | "right" | "auto" {
|
|
33
|
+
if (!p) return "top";
|
|
34
|
+
|
|
35
|
+
const s = p.trim().toLowerCase();
|
|
36
|
+
if (s.startsWith("top")) return "top";
|
|
37
|
+
if (s.startsWith("bottom")) return "bottom";
|
|
38
|
+
if (s.startsWith("left")) return "left";
|
|
39
|
+
if (s.startsWith("right")) return "right";
|
|
40
|
+
if (s === "auto") return "auto";
|
|
41
|
+
return "top";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Convert server-side trigger values to client-side normalized triggers
|
|
46
|
+
* For backward compatibility with existing code
|
|
47
|
+
*/
|
|
48
|
+
export function toTrigger(input: string | undefined): "hover" | "focus" | "click" {
|
|
49
|
+
return normalizeTrigger(input);
|
|
50
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// src/utils/privacyManager.ts
|
|
2
|
+
// Provides tools for managing user consent and privacy preferences for tracking
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Privacy consent levels for tracking
|
|
6
|
+
*/
|
|
7
|
+
export enum TrackingConsentLevel {
|
|
8
|
+
NONE = 'none', // No tracking allowed
|
|
9
|
+
ESSENTIAL = 'essential', // Only essential tracking (errors, basic flow usage)
|
|
10
|
+
FUNCTIONAL = 'functional', // Functional tracking (user interactions, completion rates)
|
|
11
|
+
ANALYTICS = 'analytics', // Full analytics tracking (detailed user behavior)
|
|
12
|
+
ALL = 'all' // All tracking including external analytics integration
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* User data categories that may be collected
|
|
17
|
+
*/
|
|
18
|
+
export enum UserDataCategory {
|
|
19
|
+
DEVICE_INFO = 'device_info', // Browser, OS, screen size
|
|
20
|
+
USER_ID = 'user_id', // Anonymous user identifier
|
|
21
|
+
INTERACTION = 'interaction', // Clicks, form inputs, etc.
|
|
22
|
+
TIMING = 'timing', // Time spent on pages/steps
|
|
23
|
+
LOCATION = 'location', // Geographic location
|
|
24
|
+
CONTEXT = 'context', // Page URL, referrer
|
|
25
|
+
CUSTOM_ATTRIBUTES = 'custom_attributes' // Any custom attributes
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Storage key for privacy preferences
|
|
29
|
+
const PRIVACY_PREFS_KEY = 'dap_privacy_preferences';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* User privacy preferences
|
|
33
|
+
*/
|
|
34
|
+
interface PrivacyPreferences {
|
|
35
|
+
consentLevel: TrackingConsentLevel;
|
|
36
|
+
allowedDataCategories: UserDataCategory[];
|
|
37
|
+
lastUpdated: number; // timestamp
|
|
38
|
+
expiresAt: number; // timestamp
|
|
39
|
+
hasExplicitConsent: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Default preferences - minimal tracking only
|
|
43
|
+
const DEFAULT_PREFERENCES: PrivacyPreferences = {
|
|
44
|
+
consentLevel: TrackingConsentLevel.ESSENTIAL,
|
|
45
|
+
allowedDataCategories: [
|
|
46
|
+
UserDataCategory.DEVICE_INFO,
|
|
47
|
+
UserDataCategory.USER_ID
|
|
48
|
+
],
|
|
49
|
+
lastUpdated: Date.now(),
|
|
50
|
+
expiresAt: Date.now() + (180 * 24 * 60 * 60 * 1000), // 180 days
|
|
51
|
+
hasExplicitConsent: false
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get the current privacy preferences
|
|
56
|
+
* @returns The current privacy preferences
|
|
57
|
+
*/
|
|
58
|
+
export function getPrivacyPreferences(): PrivacyPreferences {
|
|
59
|
+
try {
|
|
60
|
+
const storedPrefs = localStorage.getItem(PRIVACY_PREFS_KEY);
|
|
61
|
+
if (!storedPrefs) {
|
|
62
|
+
return DEFAULT_PREFERENCES;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const parsedPrefs = JSON.parse(storedPrefs);
|
|
66
|
+
|
|
67
|
+
// Check if preferences have expired
|
|
68
|
+
if (parsedPrefs.expiresAt < Date.now()) {
|
|
69
|
+
// Reset to default if expired
|
|
70
|
+
return DEFAULT_PREFERENCES;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return parsedPrefs;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
console.error('[DAP] Error reading privacy preferences:', error);
|
|
76
|
+
return DEFAULT_PREFERENCES;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Set privacy preferences
|
|
82
|
+
* @param preferences The privacy preferences to set
|
|
83
|
+
*/
|
|
84
|
+
export function setPrivacyPreferences(preferences: Partial<PrivacyPreferences>): void {
|
|
85
|
+
try {
|
|
86
|
+
const currentPrefs = getPrivacyPreferences();
|
|
87
|
+
const updatedPrefs = {
|
|
88
|
+
...currentPrefs,
|
|
89
|
+
...preferences,
|
|
90
|
+
lastUpdated: Date.now(),
|
|
91
|
+
hasExplicitConsent: true
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
localStorage.setItem(PRIVACY_PREFS_KEY, JSON.stringify(updatedPrefs));
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.error('[DAP] Error saving privacy preferences:', error);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check if a specific data category can be collected
|
|
102
|
+
* @param category The data category to check
|
|
103
|
+
* @returns Whether the data category can be collected
|
|
104
|
+
*/
|
|
105
|
+
export function canCollectDataCategory(category: UserDataCategory): boolean {
|
|
106
|
+
const prefs = getPrivacyPreferences();
|
|
107
|
+
return prefs.allowedDataCategories.includes(category);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check if a specific consent level is granted
|
|
112
|
+
* @param level The consent level to check
|
|
113
|
+
* @returns Whether the consent level is granted
|
|
114
|
+
*/
|
|
115
|
+
export function hasConsentLevel(level: TrackingConsentLevel): boolean {
|
|
116
|
+
const prefs = getPrivacyPreferences();
|
|
117
|
+
const levels = [
|
|
118
|
+
TrackingConsentLevel.NONE,
|
|
119
|
+
TrackingConsentLevel.ESSENTIAL,
|
|
120
|
+
TrackingConsentLevel.FUNCTIONAL,
|
|
121
|
+
TrackingConsentLevel.ANALYTICS,
|
|
122
|
+
TrackingConsentLevel.ALL
|
|
123
|
+
];
|
|
124
|
+
|
|
125
|
+
const currentLevelIndex = levels.indexOf(prefs.consentLevel);
|
|
126
|
+
const requestedLevelIndex = levels.indexOf(level);
|
|
127
|
+
|
|
128
|
+
return currentLevelIndex >= requestedLevelIndex;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Reset privacy preferences to default
|
|
133
|
+
*/
|
|
134
|
+
export function resetPrivacyPreferences(): void {
|
|
135
|
+
localStorage.removeItem(PRIVACY_PREFS_KEY);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Get a sanitized version of tracking data based on privacy preferences
|
|
140
|
+
* @param data The tracking data to sanitize
|
|
141
|
+
* @returns Sanitized tracking data
|
|
142
|
+
*/
|
|
143
|
+
export function sanitizeTrackingData(data: any): any {
|
|
144
|
+
const prefs = getPrivacyPreferences();
|
|
145
|
+
const result = { ...data };
|
|
146
|
+
|
|
147
|
+
// Remove user ID if not allowed
|
|
148
|
+
if (!canCollectDataCategory(UserDataCategory.USER_ID) && result.clientInfo) {
|
|
149
|
+
result.clientInfo = { ...result.clientInfo, userId: undefined };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Remove interaction data if not allowed
|
|
153
|
+
if (!canCollectDataCategory(UserDataCategory.INTERACTION)) {
|
|
154
|
+
result.elementSelector = undefined;
|
|
155
|
+
result.elementContext = undefined;
|
|
156
|
+
result.interactionDepth = undefined;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Remove timing data if not allowed
|
|
160
|
+
if (!canCollectDataCategory(UserDataCategory.TIMING)) {
|
|
161
|
+
result.timeOnStep = undefined;
|
|
162
|
+
result.totalFlowTime = undefined;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return result;
|
|
166
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// src/utils/ruleEvaluator.ts
|
|
2
|
+
// Rule evaluation engine for dynamic flow switching
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
ConditionRuleBlock,
|
|
6
|
+
RuleCondition,
|
|
7
|
+
ConditionOperator,
|
|
8
|
+
LogicalOperator,
|
|
9
|
+
ConditionValueType
|
|
10
|
+
} from '../experiences/types';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Evaluates a single condition against a given value
|
|
14
|
+
*/
|
|
15
|
+
export function evaluateCondition(condition: RuleCondition, inputValue: string | number | boolean): boolean {
|
|
16
|
+
try {
|
|
17
|
+
let targetValue = inputValue;
|
|
18
|
+
let conditionValue = condition.value;
|
|
19
|
+
|
|
20
|
+
// Type coercion based on valueType
|
|
21
|
+
if (condition.valueType === 'Number') {
|
|
22
|
+
targetValue = typeof inputValue === 'string' ? parseFloat(inputValue) : Number(inputValue);
|
|
23
|
+
conditionValue = Number(condition.value);
|
|
24
|
+
|
|
25
|
+
if (isNaN(targetValue as number) || isNaN(conditionValue as number)) {
|
|
26
|
+
console.warn(`[DAP] Invalid number comparison: ${inputValue} vs ${condition.value}`);
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
} else if (condition.valueType === 'Boolean') {
|
|
30
|
+
targetValue = typeof inputValue === 'string' ?
|
|
31
|
+
inputValue.toLowerCase() === 'true' : Boolean(inputValue);
|
|
32
|
+
conditionValue = typeof condition.value === 'string' ?
|
|
33
|
+
condition.value.toLowerCase() === 'true' : Boolean(condition.value);
|
|
34
|
+
} else {
|
|
35
|
+
// String comparison - convert both to strings
|
|
36
|
+
targetValue = String(inputValue);
|
|
37
|
+
conditionValue = String(condition.value);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
switch (condition.operator) {
|
|
41
|
+
case 'Equals':
|
|
42
|
+
return targetValue === conditionValue;
|
|
43
|
+
|
|
44
|
+
case 'NotEquals':
|
|
45
|
+
return targetValue !== conditionValue;
|
|
46
|
+
|
|
47
|
+
case 'Contains':
|
|
48
|
+
return String(targetValue).toLowerCase().includes(String(conditionValue).toLowerCase());
|
|
49
|
+
|
|
50
|
+
case 'NotContains':
|
|
51
|
+
return !String(targetValue).toLowerCase().includes(String(conditionValue).toLowerCase());
|
|
52
|
+
|
|
53
|
+
case 'GreaterThan':
|
|
54
|
+
if (condition.valueType === 'Number') {
|
|
55
|
+
return (targetValue as number) > (conditionValue as number);
|
|
56
|
+
}
|
|
57
|
+
// For strings, compare lexicographically
|
|
58
|
+
return String(targetValue) > String(conditionValue);
|
|
59
|
+
|
|
60
|
+
case 'LessThan':
|
|
61
|
+
if (condition.valueType === 'Number') {
|
|
62
|
+
return (targetValue as number) < (conditionValue as number);
|
|
63
|
+
}
|
|
64
|
+
// For strings, compare lexicographically
|
|
65
|
+
return String(targetValue) < String(conditionValue);
|
|
66
|
+
|
|
67
|
+
default:
|
|
68
|
+
console.warn(`[DAP] Unknown condition operator: ${condition.operator}`);
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
} catch (error) {
|
|
72
|
+
console.error(`[DAP] Error evaluating condition:`, error, condition);
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Evaluates a rule block with multiple conditions
|
|
79
|
+
*/
|
|
80
|
+
export function evaluateRuleBlock(ruleBlock: ConditionRuleBlock, inputValue: string | number | boolean): boolean {
|
|
81
|
+
try {
|
|
82
|
+
if (!ruleBlock.conditions || ruleBlock.conditions.length === 0) {
|
|
83
|
+
console.warn(`[DAP] Rule block ${ruleBlock.ruleBlockId} has no conditions`);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const results = ruleBlock.conditions.map(condition => evaluateCondition(condition, inputValue));
|
|
88
|
+
|
|
89
|
+
if (ruleBlock.logicalOperator === 'And') {
|
|
90
|
+
return results.every(result => result === true);
|
|
91
|
+
} else if (ruleBlock.logicalOperator === 'Or') {
|
|
92
|
+
return results.some(result => result === true);
|
|
93
|
+
} else {
|
|
94
|
+
console.warn(`[DAP] Unknown logical operator: ${ruleBlock.logicalOperator}`);
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
} catch (error) {
|
|
98
|
+
console.error(`[DAP] Error evaluating rule block:`, error, ruleBlock);
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Evaluates multiple rule blocks and returns the first matching nextFlowId
|
|
105
|
+
*/
|
|
106
|
+
export function evaluateRules(ruleBlocks: ConditionRuleBlock[], inputValue: string | number | boolean): string | null {
|
|
107
|
+
try {
|
|
108
|
+
for (const ruleBlock of ruleBlocks) {
|
|
109
|
+
if (evaluateRuleBlock(ruleBlock, inputValue)) {
|
|
110
|
+
console.debug(`[DAP] Rule block ${ruleBlock.ruleBlockId} matched, nextFlowId: ${ruleBlock.nextFlowId}`);
|
|
111
|
+
return ruleBlock.nextFlowId;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
console.debug(`[DAP] No rule blocks matched for value: ${inputValue}`);
|
|
116
|
+
return null;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.error(`[DAP] Error evaluating rules:`, error);
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Gets the current value from a DOM element based on its type
|
|
125
|
+
*/
|
|
126
|
+
export function getElementValue(element: HTMLElement): string | number | boolean {
|
|
127
|
+
try {
|
|
128
|
+
if (element instanceof HTMLInputElement) {
|
|
129
|
+
switch (element.type) {
|
|
130
|
+
case 'checkbox':
|
|
131
|
+
case 'radio':
|
|
132
|
+
return element.checked;
|
|
133
|
+
case 'number':
|
|
134
|
+
return element.valueAsNumber || 0;
|
|
135
|
+
default:
|
|
136
|
+
return element.value;
|
|
137
|
+
}
|
|
138
|
+
} else if (element instanceof HTMLSelectElement) {
|
|
139
|
+
return element.value;
|
|
140
|
+
} else if (element instanceof HTMLTextAreaElement) {
|
|
141
|
+
return element.value;
|
|
142
|
+
} else {
|
|
143
|
+
// For other elements, return text content
|
|
144
|
+
return element.textContent || element.innerText || '';
|
|
145
|
+
}
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.error(`[DAP] Error getting element value:`, error);
|
|
148
|
+
return '';
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Creates an input event listener for rule evaluation with comprehensive error handling
|
|
154
|
+
*/
|
|
155
|
+
export function createRuleEvaluationListener(
|
|
156
|
+
ruleBlocks: ConditionRuleBlock[],
|
|
157
|
+
onRuleMatch: (nextFlowId: string) => void
|
|
158
|
+
): (event: Event) => void {
|
|
159
|
+
return (event: Event) => {
|
|
160
|
+
try {
|
|
161
|
+
const target = event.target as HTMLElement;
|
|
162
|
+
if (!target) {
|
|
163
|
+
console.warn('[DAP] Rule evaluation: No target element');
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Validate rule blocks
|
|
168
|
+
if (!Array.isArray(ruleBlocks) || ruleBlocks.length === 0) {
|
|
169
|
+
console.warn('[DAP] Rule evaluation: No valid rule blocks');
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Validate callback
|
|
174
|
+
if (typeof onRuleMatch !== 'function') {
|
|
175
|
+
console.error('[DAP] Rule evaluation: Invalid callback function');
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const inputValue = getElementValue(target);
|
|
180
|
+
console.debug(`[DAP] Evaluating rules for value:`, inputValue);
|
|
181
|
+
|
|
182
|
+
const nextFlowId = evaluateRules(ruleBlocks, inputValue);
|
|
183
|
+
|
|
184
|
+
if (nextFlowId) {
|
|
185
|
+
console.debug(`[DAP] Rule evaluation triggered flow transition to: ${nextFlowId}`);
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
onRuleMatch(nextFlowId);
|
|
189
|
+
} catch (callbackError) {
|
|
190
|
+
console.error(`[DAP] Error in rule match callback:`, callbackError);
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
console.debug(`[DAP] No rules matched for value:`, inputValue);
|
|
194
|
+
}
|
|
195
|
+
} catch (error) {
|
|
196
|
+
console.error(`[DAP] Error in rule evaluation listener:`, error);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal allowlist-based HTML sanitizer.
|
|
3
|
+
* - Keeps only allowed tags/attributes
|
|
4
|
+
* - Validates href/src as http(s)
|
|
5
|
+
* - No dependency on dom.iterable (index loops only)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export function sanitizeHtml(unsafe: string): string {
|
|
9
|
+
const tmp = document.createElement("div");
|
|
10
|
+
tmp.innerHTML = unsafe || "";
|
|
11
|
+
|
|
12
|
+
const elements = tmp.querySelectorAll("*");
|
|
13
|
+
for (let i = 0; i < elements.length; i++) {
|
|
14
|
+
const el = elements[i] as HTMLElement;
|
|
15
|
+
const name = el.nodeName.toLowerCase();
|
|
16
|
+
|
|
17
|
+
if (!ALLOW.has(name)) {
|
|
18
|
+
const text = document.createTextNode(el.textContent || "");
|
|
19
|
+
const parent = el.parentNode;
|
|
20
|
+
if (parent) parent.replaceChild(text, el);
|
|
21
|
+
continue;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const attrs = el.attributes;
|
|
25
|
+
for (let j = attrs.length - 1; j >= 0; j--) {
|
|
26
|
+
const attr = attrs[j];
|
|
27
|
+
const an = attr.name.toLowerCase();
|
|
28
|
+
const av = attr.value;
|
|
29
|
+
|
|
30
|
+
if (!ATTR_ALLOW.has(an)) {
|
|
31
|
+
el.removeAttribute(attr.name);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (an === "href" || an === "src") {
|
|
36
|
+
if (!isSafeHttpUrl(av)) {
|
|
37
|
+
el.removeAttribute(attr.name);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (an === "href" && isHttpUrl(av)) {
|
|
41
|
+
if (!el.getAttribute("rel")) el.setAttribute("rel", "noopener noreferrer");
|
|
42
|
+
if (!el.getAttribute("target")) el.setAttribute("target", "_blank");
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return tmp.innerHTML;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const ALLOW = new Set<string>([
|
|
52
|
+
"b","strong","i","em","u",
|
|
53
|
+
"span","p","br","ul","ol","li",
|
|
54
|
+
"a","code","pre","small","div",
|
|
55
|
+
// for DOCX previews (Mammoth)
|
|
56
|
+
"h1","h2","h3","h4","h5","h6",
|
|
57
|
+
"table","thead","tbody","tr","td","th"
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const ATTR_ALLOW = new Set<string>([
|
|
61
|
+
"href","target","rel","class","style",
|
|
62
|
+
"src","alt","title","aria-label","colspan","rowspan","scope"
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
export function isSafeHttpUrl(u?: string | null): boolean {
|
|
66
|
+
if (!u) return false;
|
|
67
|
+
try {
|
|
68
|
+
const url = new URL(u, location.origin);
|
|
69
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function isHttpUrl(u: string): boolean {
|
|
75
|
+
try {
|
|
76
|
+
const url = new URL(u, location.origin);
|
|
77
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
78
|
+
} catch { return false; }
|
|
79
|
+
}
|