@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,668 @@
|
|
|
1
|
+
// src/experiences/beacon.ts
|
|
2
|
+
// Beacon experience renderer - creates pulsing alert notifications
|
|
3
|
+
|
|
4
|
+
import { sanitizeHtml } from "../utils/sanitize";
|
|
5
|
+
import { register } from "./registry";
|
|
6
|
+
import type { BeaconPayload } from "./types";
|
|
7
|
+
import { resolveSelector } from "../utils/selectors";
|
|
8
|
+
|
|
9
|
+
type BeaconFlow = { id: string; type: "beacon"; payload: BeaconPayload };
|
|
10
|
+
|
|
11
|
+
interface BeaconState {
|
|
12
|
+
id: string;
|
|
13
|
+
element: HTMLElement;
|
|
14
|
+
targetElement?: HTMLElement;
|
|
15
|
+
cleanup: (() => void)[];
|
|
16
|
+
isActive: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const activeBeacons = new Map<string, BeaconState>();
|
|
20
|
+
|
|
21
|
+
export function registerBeacon() {
|
|
22
|
+
register("beacon", renderBeacon);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function renderBeacon(flow: BeaconFlow): Promise<void> {
|
|
26
|
+
const { payload, id } = flow;
|
|
27
|
+
|
|
28
|
+
console.debug("[DAP] Beacon initialized", { id, payload });
|
|
29
|
+
|
|
30
|
+
// Validate required data
|
|
31
|
+
if (!payload.title && !payload.body) {
|
|
32
|
+
console.error("[DAP] Beacon missing required content (title or body)");
|
|
33
|
+
payload._completionTracker?.onComplete?.();
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Clean up any existing beacon with same ID
|
|
38
|
+
if (activeBeacons.has(id)) {
|
|
39
|
+
cleanupBeacon(id);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Find target element if selector provided
|
|
43
|
+
let targetElement: HTMLElement | undefined;
|
|
44
|
+
if (payload.targetSelector) {
|
|
45
|
+
const element = resolveSelector(payload.targetSelector);
|
|
46
|
+
if (element instanceof HTMLElement) {
|
|
47
|
+
targetElement = element;
|
|
48
|
+
} else {
|
|
49
|
+
console.warn(`[DAP] Beacon: Target element not found for selector: ${payload.targetSelector}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Create beacon element
|
|
54
|
+
const beaconElement = createBeaconElement(payload, id);
|
|
55
|
+
|
|
56
|
+
const beaconState: BeaconState = {
|
|
57
|
+
id,
|
|
58
|
+
element: beaconElement,
|
|
59
|
+
targetElement,
|
|
60
|
+
cleanup: [],
|
|
61
|
+
isActive: false
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
activeBeacons.set(id, beaconState);
|
|
65
|
+
|
|
66
|
+
// Show beacon
|
|
67
|
+
showBeacon(beaconState, payload);
|
|
68
|
+
|
|
69
|
+
console.debug("[DAP] Beacon setup complete", { id });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function createBeaconElement(payload: BeaconPayload, id: string): HTMLElement {
|
|
73
|
+
const beacon = document.createElement('div');
|
|
74
|
+
beacon.className = 'dap-beacon';
|
|
75
|
+
beacon.id = `dap-beacon-${id}`;
|
|
76
|
+
beacon.setAttribute('role', 'alert');
|
|
77
|
+
beacon.setAttribute('aria-live', 'assertive');
|
|
78
|
+
|
|
79
|
+
// Position and base styling
|
|
80
|
+
const position = payload.position || 'top-right';
|
|
81
|
+
const positionStyles = getPositionStyles(position);
|
|
82
|
+
|
|
83
|
+
Object.assign(beacon.style, {
|
|
84
|
+
position: 'fixed',
|
|
85
|
+
zIndex: '10000',
|
|
86
|
+
padding: '12px 16px',
|
|
87
|
+
borderRadius: '16px',
|
|
88
|
+
background: 'rgba(255, 255, 255, 0.95)',
|
|
89
|
+
border: '2px solid #3b82f6',
|
|
90
|
+
boxShadow: '0 8px 32px rgba(59, 130, 246, 0.15), 0 4px 16px rgba(0, 0, 0, 0.08)',
|
|
91
|
+
backdropFilter: 'blur(12px)',
|
|
92
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
|
93
|
+
fontSize: '13px',
|
|
94
|
+
lineHeight: '1.4',
|
|
95
|
+
color: '#1e40af',
|
|
96
|
+
maxWidth: '280px',
|
|
97
|
+
minWidth: '200px',
|
|
98
|
+
opacity: '0',
|
|
99
|
+
transform: 'translateY(-10px) scale(0.95)',
|
|
100
|
+
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
|
101
|
+
cursor: 'pointer',
|
|
102
|
+
pointerEvents: 'auto',
|
|
103
|
+
userSelect: 'none',
|
|
104
|
+
...positionStyles
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Add subtle pulsing animation
|
|
108
|
+
beacon.style.animation = 'dap-beacon-pulse 2s ease-in-out infinite';
|
|
109
|
+
|
|
110
|
+
// Add beacon pulse animation styles if not already present
|
|
111
|
+
if (!document.querySelector('#dap-beacon-pulse-styles')) {
|
|
112
|
+
const pulseStyles = document.createElement('style');
|
|
113
|
+
pulseStyles.id = 'dap-beacon-pulse-styles';
|
|
114
|
+
pulseStyles.textContent = `
|
|
115
|
+
@keyframes dap-beacon-pulse {
|
|
116
|
+
0%, 100% {
|
|
117
|
+
box-shadow: 0 8px 32px rgba(59, 130, 246, 0.15), 0 4px 16px rgba(0, 0, 0, 0.08), 0 0 0 0 rgba(59, 130, 246, 0.4);
|
|
118
|
+
}
|
|
119
|
+
50% {
|
|
120
|
+
box-shadow: 0 8px 32px rgba(59, 130, 246, 0.25), 0 4px 16px rgba(0, 0, 0, 0.12), 0 0 0 8px rgba(59, 130, 246, 0.1);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.dap-beacon:hover {
|
|
125
|
+
transform: translateY(-2px) scale(1.02) !important;
|
|
126
|
+
box-shadow: 0 12px 40px rgba(59, 130, 246, 0.25), 0 6px 20px rgba(0, 0, 0, 0.12) !important;
|
|
127
|
+
animation: none !important;
|
|
128
|
+
}
|
|
129
|
+
`;
|
|
130
|
+
document.head.appendChild(pulseStyles);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Add icon if specified
|
|
134
|
+
if (payload.icon) {
|
|
135
|
+
const icon = document.createElement('span');
|
|
136
|
+
icon.style.cssText = `
|
|
137
|
+
display: inline-block;
|
|
138
|
+
margin-right: 8px;
|
|
139
|
+
font-size: 18px;
|
|
140
|
+
vertical-align: middle;
|
|
141
|
+
`;
|
|
142
|
+
icon.textContent = payload.icon;
|
|
143
|
+
beacon.appendChild(icon);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Add title if provided
|
|
147
|
+
if (payload.title) {
|
|
148
|
+
const title = document.createElement('div');
|
|
149
|
+
title.style.cssText = `
|
|
150
|
+
font-weight: 600;
|
|
151
|
+
font-size: 15px;
|
|
152
|
+
color: #78350f;
|
|
153
|
+
margin-bottom: ${payload.body ? '6px' : '0'};
|
|
154
|
+
line-height: 1.3;
|
|
155
|
+
`;
|
|
156
|
+
title.textContent = payload.title;
|
|
157
|
+
beacon.appendChild(title);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Add body content if provided
|
|
161
|
+
if (payload.body) {
|
|
162
|
+
const body = document.createElement('div');
|
|
163
|
+
body.style.cssText = `
|
|
164
|
+
color: #a16207;
|
|
165
|
+
line-height: 1.4;
|
|
166
|
+
font-size: 13px;
|
|
167
|
+
`;
|
|
168
|
+
body.innerHTML = sanitizeHtml(payload.body);
|
|
169
|
+
beacon.appendChild(body);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Add close button
|
|
173
|
+
const closeButton = document.createElement('button');
|
|
174
|
+
closeButton.style.cssText = `
|
|
175
|
+
position: absolute;
|
|
176
|
+
top: 6px;
|
|
177
|
+
right: 6px;
|
|
178
|
+
background: rgba(59, 130, 246, 0.1);
|
|
179
|
+
border: none;
|
|
180
|
+
font-size: 14px;
|
|
181
|
+
color: #3b82f6;
|
|
182
|
+
cursor: pointer;
|
|
183
|
+
padding: 4px 6px;
|
|
184
|
+
border-radius: 8px;
|
|
185
|
+
display: flex;
|
|
186
|
+
align-items: center;
|
|
187
|
+
justify-content: center;
|
|
188
|
+
transition: all 0.15s ease;
|
|
189
|
+
font-weight: 500;
|
|
190
|
+
width: 24px;
|
|
191
|
+
height: 24px;
|
|
192
|
+
`;
|
|
193
|
+
closeButton.innerHTML = '×';
|
|
194
|
+
closeButton.title = 'Close beacon';
|
|
195
|
+
closeButton.setAttribute('aria-label', 'Close beacon');
|
|
196
|
+
|
|
197
|
+
closeButton.addEventListener('mouseenter', () => {
|
|
198
|
+
closeButton.style.backgroundColor = 'rgba(59, 130, 246, 0.2)';
|
|
199
|
+
closeButton.style.transform = 'scale(1.1)';
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
closeButton.addEventListener('mouseleave', () => {
|
|
203
|
+
closeButton.style.backgroundColor = 'rgba(59, 130, 246, 0.1)';
|
|
204
|
+
closeButton.style.transform = 'scale(1)';
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
closeButton.addEventListener('click', (e) => {
|
|
208
|
+
e.stopPropagation();
|
|
209
|
+
dismissBeacon(id);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
beacon.appendChild(closeButton);
|
|
213
|
+
|
|
214
|
+
// Store payload for later access
|
|
215
|
+
(beacon as any).__beaconPayload = payload;
|
|
216
|
+
|
|
217
|
+
return beacon;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function getPositionStyles(position: string) {
|
|
221
|
+
const margin = '20px';
|
|
222
|
+
|
|
223
|
+
switch (position) {
|
|
224
|
+
case 'top-left':
|
|
225
|
+
return { top: margin, left: margin };
|
|
226
|
+
case 'top-center':
|
|
227
|
+
return { top: margin, left: '50%', transform: 'translateX(-50%) translateY(-20px) scale(0.9)' };
|
|
228
|
+
case 'top-right':
|
|
229
|
+
default:
|
|
230
|
+
return { top: margin, right: margin };
|
|
231
|
+
case 'bottom-left':
|
|
232
|
+
return { bottom: margin, left: margin };
|
|
233
|
+
case 'bottom-center':
|
|
234
|
+
return { bottom: margin, left: '50%', transform: 'translateX(-50%) translateY(20px) scale(0.9)' };
|
|
235
|
+
case 'bottom-right':
|
|
236
|
+
return { bottom: margin, right: margin };
|
|
237
|
+
case 'center':
|
|
238
|
+
return {
|
|
239
|
+
top: '50%',
|
|
240
|
+
left: '50%',
|
|
241
|
+
transform: 'translate(-50%, -50%) translateY(-20px) scale(0.9)'
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function showBeacon(state: BeaconState, payload: BeaconPayload): void {
|
|
247
|
+
if (state.isActive) return;
|
|
248
|
+
|
|
249
|
+
state.isActive = true;
|
|
250
|
+
|
|
251
|
+
console.debug("[DAP] Beacon shown", { id: state.id, hasTarget: !!state.targetElement });
|
|
252
|
+
|
|
253
|
+
// Add to DOM first
|
|
254
|
+
document.body.appendChild(state.element);
|
|
255
|
+
|
|
256
|
+
// Wait for DOM to settle, then position
|
|
257
|
+
requestAnimationFrame(() => {
|
|
258
|
+
// Always position beacon relative to target element if available
|
|
259
|
+
if (state.targetElement) {
|
|
260
|
+
// Use default position if not specified
|
|
261
|
+
const position = payload.position ? parsePosition(payload.position) : { x: 'right', y: 'center' };
|
|
262
|
+
console.debug("[DAP] Positioning beacon with position:", position);
|
|
263
|
+
if (position) {
|
|
264
|
+
positionBeaconRelativeToElement(state.element, state.targetElement, position);
|
|
265
|
+
}
|
|
266
|
+
} else {
|
|
267
|
+
// Fallback positioning without target element
|
|
268
|
+
console.debug("[DAP] No target element, using fallback positioning");
|
|
269
|
+
const positionStyles = getPositionStyles(payload.position || 'top-right');
|
|
270
|
+
Object.assign(state.element.style, positionStyles);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Apply beacon pulsing animation
|
|
274
|
+
applyBeaconAnimation(state.element, payload.beaconStyles);
|
|
275
|
+
|
|
276
|
+
// Show with smooth entrance animation after positioning
|
|
277
|
+
setTimeout(() => {
|
|
278
|
+
state.element.style.opacity = '1';
|
|
279
|
+
state.element.style.transform = 'scale(1)';
|
|
280
|
+
console.debug("[DAP] Beacon animation complete");
|
|
281
|
+
}, 50);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// Setup click handler for main content (not close button)
|
|
285
|
+
const clickHandler = (e: Event) => {
|
|
286
|
+
const target = e.target as HTMLElement;
|
|
287
|
+
if (!target.closest('button')) {
|
|
288
|
+
console.debug("[DAP] Beacon clicked", { id: state.id });
|
|
289
|
+
|
|
290
|
+
// Execute custom action if defined
|
|
291
|
+
if (payload.action) {
|
|
292
|
+
console.debug("[DAP] Executing beacon action", { action: payload.action });
|
|
293
|
+
// Custom actions can be handled by completion tracker
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
dismissBeacon(state.id);
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
state.element.addEventListener('click', clickHandler);
|
|
301
|
+
state.cleanup.push(() => state.element.removeEventListener('click', clickHandler));
|
|
302
|
+
|
|
303
|
+
// Setup global event handlers
|
|
304
|
+
setupGlobalEventHandlers(state, payload);
|
|
305
|
+
|
|
306
|
+
// Setup position observer for target element
|
|
307
|
+
if (state.targetElement) {
|
|
308
|
+
setupPositionObserver(state, payload);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Auto-dismiss if specified
|
|
312
|
+
if (payload.autoDismiss && payload.autoDismiss > 0) {
|
|
313
|
+
setTimeout(() => {
|
|
314
|
+
dismissBeacon(state.id);
|
|
315
|
+
}, payload.autoDismiss * 1000);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function applyBeaconAnimation(element: HTMLElement, beaconStyles?: any): void {
|
|
320
|
+
const styles = {
|
|
321
|
+
enabled: true,
|
|
322
|
+
color1: '#f59e0b',
|
|
323
|
+
color2: '#eab308',
|
|
324
|
+
duration: '2s',
|
|
325
|
+
padding: '8px',
|
|
326
|
+
borderWidth: '3px',
|
|
327
|
+
borderRadius: '16px',
|
|
328
|
+
shadowSize: '20px',
|
|
329
|
+
...beaconStyles
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
if (!styles.enabled) return;
|
|
333
|
+
|
|
334
|
+
const animationId = `beacon-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
335
|
+
|
|
336
|
+
const style = document.createElement('style');
|
|
337
|
+
style.dataset.beaconAnimation = animationId;
|
|
338
|
+
style.textContent = `
|
|
339
|
+
.dap-beacon[data-beacon-id="${animationId}"]::before {
|
|
340
|
+
content: '';
|
|
341
|
+
position: absolute;
|
|
342
|
+
top: -${styles.padding};
|
|
343
|
+
left: -${styles.padding};
|
|
344
|
+
right: -${styles.padding};
|
|
345
|
+
bottom: -${styles.padding};
|
|
346
|
+
border: ${styles.borderWidth} solid ${styles.color1};
|
|
347
|
+
border-radius: ${styles.borderRadius};
|
|
348
|
+
animation: beaconPulse-${animationId} ${styles.duration} ease-in-out infinite;
|
|
349
|
+
pointer-events: none;
|
|
350
|
+
z-index: -1;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
@keyframes beaconPulse-${animationId} {
|
|
354
|
+
0% {
|
|
355
|
+
border-color: ${styles.color1};
|
|
356
|
+
box-shadow: 0 0 0 0 ${styles.color1}40, 0 0 ${styles.shadowSize} ${styles.color1}30;
|
|
357
|
+
transform: scale(1);
|
|
358
|
+
}
|
|
359
|
+
50% {
|
|
360
|
+
border-color: ${styles.color2};
|
|
361
|
+
box-shadow: 0 0 0 10px ${styles.color2}20, 0 0 ${styles.shadowSize} ${styles.color2}40;
|
|
362
|
+
transform: scale(1.05);
|
|
363
|
+
}
|
|
364
|
+
100% {
|
|
365
|
+
border-color: ${styles.color1};
|
|
366
|
+
box-shadow: 0 0 0 0 ${styles.color1}40, 0 0 ${styles.shadowSize} ${styles.color1}30;
|
|
367
|
+
transform: scale(1);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
`;
|
|
371
|
+
|
|
372
|
+
document.head.appendChild(style);
|
|
373
|
+
element.setAttribute('data-beacon-id', animationId);
|
|
374
|
+
|
|
375
|
+
// Cleanup function
|
|
376
|
+
const cleanup = () => {
|
|
377
|
+
if (style.parentNode) {
|
|
378
|
+
style.parentNode.removeChild(style);
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
// Store cleanup in beacon state if available
|
|
383
|
+
const beaconId = element.id.replace('dap-beacon-', '');
|
|
384
|
+
const state = activeBeacons.get(beaconId);
|
|
385
|
+
if (state) {
|
|
386
|
+
state.cleanup.push(cleanup);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function setupGlobalEventHandlers(state: BeaconState, payload: BeaconPayload): void {
|
|
391
|
+
// ESC key handler
|
|
392
|
+
const keyHandler = (e: KeyboardEvent) => {
|
|
393
|
+
if (e.key === 'Escape') {
|
|
394
|
+
e.preventDefault();
|
|
395
|
+
dismissBeacon(state.id);
|
|
396
|
+
}
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
// Navigation handler
|
|
400
|
+
const navigationHandler = () => {
|
|
401
|
+
dismissBeacon(state.id);
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
document.addEventListener('keydown', keyHandler);
|
|
405
|
+
window.addEventListener('beforeunload', navigationHandler);
|
|
406
|
+
window.addEventListener('popstate', navigationHandler);
|
|
407
|
+
|
|
408
|
+
state.cleanup.push(() => {
|
|
409
|
+
document.removeEventListener('keydown', keyHandler);
|
|
410
|
+
window.removeEventListener('beforeunload', navigationHandler);
|
|
411
|
+
window.removeEventListener('popstate', navigationHandler);
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function dismissBeacon(id: string): void {
|
|
416
|
+
const state = activeBeacons.get(id);
|
|
417
|
+
if (!state || !state.isActive) return;
|
|
418
|
+
|
|
419
|
+
state.isActive = false;
|
|
420
|
+
|
|
421
|
+
console.debug("[DAP] Beacon dismissed", { id });
|
|
422
|
+
|
|
423
|
+
// Animate out
|
|
424
|
+
state.element.style.opacity = '0';
|
|
425
|
+
state.element.style.transform = state.element.style.transform.replace(/translateY\([^)]+\)/, 'translateY(-20px)').replace(/scale\([^)]+\)/, 'scale(0.9)');
|
|
426
|
+
|
|
427
|
+
// Complete the step
|
|
428
|
+
const beaconElement = state.element;
|
|
429
|
+
const payloadData = (beaconElement as any).__beaconPayload;
|
|
430
|
+
if (payloadData?._completionTracker?.onComplete) {
|
|
431
|
+
payloadData._completionTracker.onComplete();
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Remove from DOM after animation
|
|
435
|
+
setTimeout(() => {
|
|
436
|
+
if (state.element.parentNode) {
|
|
437
|
+
state.element.parentNode.removeChild(state.element);
|
|
438
|
+
}
|
|
439
|
+
cleanupBeacon(id);
|
|
440
|
+
}, 300);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function cleanupBeacon(id: string): void {
|
|
444
|
+
console.debug("[DAP] Beacon destroyed", { id });
|
|
445
|
+
|
|
446
|
+
const state = activeBeacons.get(id);
|
|
447
|
+
if (!state) return;
|
|
448
|
+
|
|
449
|
+
// Run all cleanup functions
|
|
450
|
+
state.cleanup.forEach(cleanup => {
|
|
451
|
+
try {
|
|
452
|
+
cleanup();
|
|
453
|
+
} catch (error) {
|
|
454
|
+
console.warn("[DAP] Error during beacon cleanup:", error);
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
// Remove from DOM if still attached
|
|
459
|
+
if (state.element && state.element.parentNode) {
|
|
460
|
+
state.element.parentNode.removeChild(state.element);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Signal completion
|
|
464
|
+
// Note: We don't call completion tracker here since dismiss already handles it
|
|
465
|
+
|
|
466
|
+
// Remove from active beacons
|
|
467
|
+
activeBeacons.delete(id);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function parsePosition(position: string | { x: string; y: string }): { x: string; y: string } | null {
|
|
471
|
+
// If already an object, return it
|
|
472
|
+
if (typeof position === 'object' && position.x && position.y) {
|
|
473
|
+
return { x: position.x, y: position.y };
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Parse string position into x,y coordinates
|
|
477
|
+
if (typeof position === 'string') {
|
|
478
|
+
switch (position) {
|
|
479
|
+
case 'top-left':
|
|
480
|
+
return { x: 'left', y: 'top' };
|
|
481
|
+
case 'top-center':
|
|
482
|
+
return { x: 'center', y: 'top' };
|
|
483
|
+
case 'top-right':
|
|
484
|
+
return { x: 'right', y: 'top' };
|
|
485
|
+
case 'bottom-left':
|
|
486
|
+
return { x: 'left', y: 'bottom' };
|
|
487
|
+
case 'bottom-center':
|
|
488
|
+
return { x: 'center', y: 'bottom' };
|
|
489
|
+
case 'bottom-right':
|
|
490
|
+
return { x: 'right', y: 'bottom' };
|
|
491
|
+
case 'center':
|
|
492
|
+
return { x: 'center', y: 'center' };
|
|
493
|
+
default:
|
|
494
|
+
return { x: 'center', y: 'center' };
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
return null;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function positionBeaconRelativeToElement(
|
|
502
|
+
beaconElement: HTMLElement,
|
|
503
|
+
targetElement: HTMLElement,
|
|
504
|
+
position: { x: string; y: string }
|
|
505
|
+
): void {
|
|
506
|
+
console.debug("[DAP] Starting beacon positioning", {
|
|
507
|
+
targetElement: targetElement.tagName,
|
|
508
|
+
targetSelector: targetElement.id || targetElement.className,
|
|
509
|
+
position
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
const targetRect = targetElement.getBoundingClientRect();
|
|
513
|
+
console.debug("[DAP] Target element bounds:", targetRect);
|
|
514
|
+
|
|
515
|
+
// Ensure beacon is properly styled and in DOM for measurements
|
|
516
|
+
beaconElement.style.position = 'fixed';
|
|
517
|
+
beaconElement.style.display = 'block';
|
|
518
|
+
beaconElement.style.visibility = 'visible';
|
|
519
|
+
beaconElement.style.opacity = '0'; // Hidden but measurable
|
|
520
|
+
|
|
521
|
+
if (!beaconElement.parentNode) {
|
|
522
|
+
document.body.appendChild(beaconElement);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Force a layout to get accurate measurements
|
|
526
|
+
beaconElement.offsetHeight; // Trigger layout
|
|
527
|
+
const beaconRect = beaconElement.getBoundingClientRect();
|
|
528
|
+
console.debug("[DAP] Beacon element bounds:", beaconRect);
|
|
529
|
+
|
|
530
|
+
const spacing = 30; // Even more generous spacing
|
|
531
|
+
const viewportWidth = window.innerWidth;
|
|
532
|
+
const viewportHeight = window.innerHeight;
|
|
533
|
+
|
|
534
|
+
let left = 0;
|
|
535
|
+
let top = 0;
|
|
536
|
+
let placement = 'right'; // Default
|
|
537
|
+
|
|
538
|
+
// Try positioning to the right first (best for form elements)
|
|
539
|
+
left = targetRect.right + spacing;
|
|
540
|
+
top = targetRect.top + (targetRect.height - beaconRect.height) / 2;
|
|
541
|
+
|
|
542
|
+
// Check if right position fits
|
|
543
|
+
if (left + beaconRect.width > viewportWidth - 10) {
|
|
544
|
+
// Try left position
|
|
545
|
+
left = targetRect.left - beaconRect.width - spacing;
|
|
546
|
+
placement = 'left';
|
|
547
|
+
|
|
548
|
+
// If left doesn't fit either, try bottom
|
|
549
|
+
if (left < 10) {
|
|
550
|
+
left = targetRect.left + (targetRect.width - beaconRect.width) / 2;
|
|
551
|
+
top = targetRect.bottom + spacing;
|
|
552
|
+
placement = 'bottom';
|
|
553
|
+
|
|
554
|
+
// If bottom doesn't fit, try top
|
|
555
|
+
if (top + beaconRect.height > viewportHeight - 10) {
|
|
556
|
+
top = targetRect.top - beaconRect.height - spacing;
|
|
557
|
+
placement = 'top';
|
|
558
|
+
|
|
559
|
+
// If top doesn't fit, force right with viewport constraints
|
|
560
|
+
if (top < 10) {
|
|
561
|
+
left = Math.min(targetRect.right + spacing, viewportWidth - beaconRect.width - 10);
|
|
562
|
+
top = Math.max(10, Math.min(targetRect.top, viewportHeight - beaconRect.height - 10));
|
|
563
|
+
placement = 'right-constrained';
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Apply final constraints
|
|
570
|
+
left = Math.max(10, Math.min(left, viewportWidth - beaconRect.width - 10));
|
|
571
|
+
top = Math.max(10, Math.min(top, viewportHeight - beaconRect.height - 10));
|
|
572
|
+
|
|
573
|
+
console.debug("[DAP] Final beacon position:", {
|
|
574
|
+
left,
|
|
575
|
+
top,
|
|
576
|
+
placement,
|
|
577
|
+
beaconWidth: beaconRect.width,
|
|
578
|
+
beaconHeight: beaconRect.height,
|
|
579
|
+
viewportWidth,
|
|
580
|
+
viewportHeight
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
// Apply the position
|
|
584
|
+
beaconElement.style.left = `${Math.round(left)}px`;
|
|
585
|
+
beaconElement.style.top = `${Math.round(top)}px`;
|
|
586
|
+
beaconElement.style.transform = 'none';
|
|
587
|
+
beaconElement.style.zIndex = '10000';
|
|
588
|
+
|
|
589
|
+
// Store placement info
|
|
590
|
+
beaconElement.setAttribute('data-placement', placement);
|
|
591
|
+
|
|
592
|
+
console.debug("[DAP] Beacon positioned successfully");
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function setupPositionObserver(state: BeaconState, payload: BeaconPayload): void {
|
|
596
|
+
if (!state.targetElement) return;
|
|
597
|
+
|
|
598
|
+
console.debug("[DAP] Setting up position observer for beacon", { id: state.id });
|
|
599
|
+
|
|
600
|
+
// More frequent updates for better responsiveness
|
|
601
|
+
let updateTimeout: number | null = null;
|
|
602
|
+
|
|
603
|
+
const updatePosition = () => {
|
|
604
|
+
if (updateTimeout) clearTimeout(updateTimeout);
|
|
605
|
+
updateTimeout = setTimeout(() => {
|
|
606
|
+
if (state.targetElement && state.isActive) {
|
|
607
|
+
const position = payload.position ? parsePosition(payload.position) : { x: 'right', y: 'center' };
|
|
608
|
+
console.debug("[DAP] Updating beacon position on scroll");
|
|
609
|
+
if (position) {
|
|
610
|
+
positionBeaconRelativeToElement(state.element, state.targetElement, position);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
}, 8) as any; // More frequent updates for smoother scrolling
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
// Setup intersection observer to detect if target becomes visible/hidden
|
|
617
|
+
const intersectionObserver = new IntersectionObserver((entries) => {
|
|
618
|
+
entries.forEach(entry => {
|
|
619
|
+
if (entry.isIntersecting) {
|
|
620
|
+
state.element.style.display = 'block';
|
|
621
|
+
updatePosition();
|
|
622
|
+
} else {
|
|
623
|
+
state.element.style.display = 'none';
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
}, { threshold: 0.1 });
|
|
627
|
+
|
|
628
|
+
intersectionObserver.observe(state.targetElement);
|
|
629
|
+
|
|
630
|
+
// Add multiple scroll listeners for better coverage
|
|
631
|
+
const handleScroll = () => updatePosition();
|
|
632
|
+
const handleResize = () => updatePosition();
|
|
633
|
+
|
|
634
|
+
// Listen to different scroll contexts
|
|
635
|
+
window.addEventListener('scroll', handleScroll, { passive: true });
|
|
636
|
+
window.addEventListener('resize', handleResize, { passive: true });
|
|
637
|
+
document.addEventListener('scroll', handleScroll, { passive: true, capture: true });
|
|
638
|
+
|
|
639
|
+
// Also listen for any scroll on scrollable containers
|
|
640
|
+
let scrollableParent = state.targetElement.parentElement;
|
|
641
|
+
const scrollListeners: Array<{ element: Element; listener: () => void }> = [];
|
|
642
|
+
|
|
643
|
+
while (scrollableParent) {
|
|
644
|
+
const style = window.getComputedStyle(scrollableParent);
|
|
645
|
+
if (style.overflow === 'auto' || style.overflow === 'scroll' ||
|
|
646
|
+
style.overflowY === 'auto' || style.overflowY === 'scroll') {
|
|
647
|
+
scrollableParent.addEventListener('scroll', handleScroll, { passive: true });
|
|
648
|
+
scrollListeners.push({ element: scrollableParent, listener: handleScroll });
|
|
649
|
+
}
|
|
650
|
+
scrollableParent = scrollableParent.parentElement;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
state.cleanup.push(() => {
|
|
654
|
+
intersectionObserver.disconnect();
|
|
655
|
+
window.removeEventListener('scroll', handleScroll);
|
|
656
|
+
window.removeEventListener('resize', handleResize);
|
|
657
|
+
document.removeEventListener('scroll', handleScroll, true);
|
|
658
|
+
|
|
659
|
+
// Clean up scrollable parent listeners
|
|
660
|
+
scrollListeners.forEach(({ element, listener }) => {
|
|
661
|
+
element.removeEventListener('scroll', listener);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
if (updateTimeout) clearTimeout(updateTimeout);
|
|
665
|
+
});
|
|
666
|
+
|
|
667
|
+
console.debug("[DAP] Position observer setup complete");
|
|
668
|
+
}
|