@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,1639 @@
1
+ // src/experiences/survey.ts
2
+ // Unified Survey component implementation for collecting user feedback
3
+ // Supports both full-featured modal surveys and lightweight micro surveys
4
+
5
+ import { sanitizeHtml } from "../utils/sanitize";
6
+ import { register } from "./registry";
7
+ import { surveyCssText } from "../styles/survey.css";
8
+ import { http } from "../http";
9
+ import { resolveSelector } from "../utils/selectors";
10
+ import type { DapConfig } from "../config";
11
+ import type { CompletionTracker } from "./types";
12
+
13
+ // Inline modal CSS for surveys
14
+ const modalCssText = `
15
+ :root {
16
+ --dap-z: 2147483640;
17
+ --dap-overlay: rgba(15, 23, 42, 0.5);
18
+ --dap-modal-bg: #f8fafc;
19
+ --dap-modal-header-bg: #f1f5f9;
20
+ --dap-modal-border: #e2e8f0;
21
+ --dap-modal-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
22
+ --dap-text-primary: #1e293b;
23
+ --dap-text-secondary: #64748b;
24
+ --dap-text-muted: #94a3b8;
25
+ --dap-btn-primary: #3b82f6;
26
+ --dap-btn-primary-hover: #2563eb;
27
+ --dap-btn-secondary: #e2e8f0;
28
+ --dap-btn-secondary-hover: #cbd5e1;
29
+ --dap-radius: 12px;
30
+ --dap-spacing: 16px;
31
+ }
32
+
33
+ .dap-modal-wrap {
34
+ position: fixed;
35
+ inset: 0;
36
+ background: var(--dap-overlay);
37
+ z-index: var(--dap-z);
38
+ display: flex;
39
+ align-items: center;
40
+ justify-content: center;
41
+ padding: 24px;
42
+ backdrop-filter: blur(4px);
43
+ }
44
+
45
+ .dap-modal {
46
+ background: var(--dap-modal-bg);
47
+ border: 1px solid var(--dap-modal-border);
48
+ border-radius: var(--dap-radius);
49
+ box-shadow: var(--dap-modal-shadow);
50
+ width: 100%;
51
+ max-width: min(90vw, 500px);
52
+ max-height: min(90vh, 600px);
53
+ display: flex;
54
+ flex-direction: column;
55
+ overflow: hidden;
56
+ }
57
+
58
+ /* Content-adaptive size classes */
59
+ .dap-modal.dap-size-small {
60
+ max-width: min(90vw, 400px);
61
+ max-height: min(90vh, 500px);
62
+ }
63
+
64
+ .dap-modal.dap-size-medium {
65
+ max-width: min(90vw, 600px);
66
+ max-height: min(90vh, 650px);
67
+ }
68
+
69
+ .dap-modal.dap-size-large {
70
+ max-width: min(90vw, 900px);
71
+ max-height: min(90vh, 800px);
72
+ }
73
+
74
+ /* Scrollable class when content overflows */
75
+ .dap-modal.dap-scrollable .dap-modal-body {
76
+ overflow-y: auto;
77
+ overflow-x: auto;
78
+ }
79
+
80
+ /* Default: no scroll unless needed */
81
+ .dap-modal .dap-modal-body {
82
+ overflow: visible;
83
+ }
84
+
85
+ .dap-header-bar {
86
+ background: var(--dap-modal-header-bg);
87
+ border-bottom: 1px solid var(--dap-modal-border);
88
+ padding: var(--dap-spacing);
89
+ display: flex;
90
+ align-items: center;
91
+ justify-content: space-between;
92
+ min-height: 60px;
93
+ }
94
+
95
+ .dap-modal-header {
96
+ color: var(--dap-text-primary);
97
+ font-size: 18px;
98
+ font-weight: 600;
99
+ margin: 0;
100
+ flex: 1;
101
+ }
102
+
103
+ .dap-close {
104
+ background: transparent;
105
+ border: none;
106
+ color: var(--dap-text-secondary);
107
+ cursor: pointer;
108
+ padding: 8px;
109
+ border-radius: 6px;
110
+ width: 32px;
111
+ height: 32px;
112
+ font-size: 18px;
113
+ }
114
+
115
+ .dap-close:hover {
116
+ background: var(--dap-btn-secondary);
117
+ }
118
+
119
+ .dap-modal-body {
120
+ flex: 1;
121
+ overflow-y: auto;
122
+ padding: 32px;
123
+ background: var(--dap-modal-bg);
124
+ }
125
+
126
+ .dap-footer {
127
+ background: var(--dap-modal-bg);
128
+ border-top: 1px solid var(--dap-modal-border);
129
+ padding: var(--dap-spacing);
130
+ display: flex;
131
+ gap: 12px;
132
+ justify-content: flex-end;
133
+ }
134
+
135
+ .dap-cta {
136
+ background: var(--dap-btn-primary);
137
+ color: white;
138
+ border: 1px solid var(--dap-btn-primary);
139
+ cursor: pointer;
140
+ padding: 12px 24px;
141
+ border-radius: 8px;
142
+ font-size: 14px;
143
+ font-weight: 500;
144
+ transition: all 0.15s ease;
145
+ }
146
+
147
+ .dap-cta:hover {
148
+ background: var(--dap-btn-primary-hover);
149
+ transform: translateY(-1px);
150
+ box-shadow: 0 4px 12px rgba(59, 130, 246, 0.25);
151
+ }
152
+ `;
153
+
154
+ export type SurveyQuestion = {
155
+ questionId: string;
156
+ question: string;
157
+ type: SurveyQuestionType;
158
+ options?: string[];
159
+ scaleMin?: number;
160
+ scaleMax?: number;
161
+ labelMin?: string;
162
+ labelMax?: string;
163
+ criteria?: string[];
164
+ };
165
+
166
+ export type SurveyQuestionType =
167
+ | "SingleChoice"
168
+ | "MultipleChoice"
169
+ | "Dropdown"
170
+ | "TextSingle"
171
+ | "TextMulti"
172
+ | "OpinionScale"
173
+ | "OpinionScaleChoice"
174
+ | "NpsScale"
175
+ | "NpsOptions"
176
+ | "StarRating"
177
+ | "StarChoice";
178
+
179
+ export type SurveyPayload = {
180
+ // Standard survey fields
181
+ header?: string;
182
+ body?: string;
183
+ questions?: SurveyQuestion[]; // Made optional to support micro surveys
184
+ theme?: Record<string, string>;
185
+ flowId?: string;
186
+ organizationId?: string;
187
+ siteId?: string;
188
+ stepId?: string;
189
+
190
+ // Micro survey fields (for lightweight single-question surveys)
191
+ question?: string; // Single question for micro surveys
192
+ options?: Array<{ label: string; value: string }>; // Simple options for micro surveys
193
+ placeholder?: string;
194
+ submitText?: string;
195
+ cancelText?: string;
196
+ type?: 'rating' | 'choice' | 'text' | 'modal'; // 'modal' = traditional full survey
197
+ rating?: {
198
+ min?: number;
199
+ max?: number;
200
+ labels?: { min?: string; max?: string };
201
+ };
202
+ targetSelector?: string; // For micro survey positioning
203
+ position?: 'top' | 'bottom' | 'right' | 'left' | 'center';
204
+ mode?: 'modal' | 'inline'; // Explicit mode selection
205
+ _completionTracker?: CompletionTracker;
206
+ };
207
+
208
+ type SurveyFlow = {
209
+ id: string;
210
+ type: "survey";
211
+ payload: SurveyPayload;
212
+ config?: DapConfig;
213
+ };
214
+
215
+ export function registerSurvey() {
216
+ register("survey", renderSurvey);
217
+ // Also register microsurvey as an alias for backward compatibility
218
+ register("microsurvey", renderSurvey);
219
+ }
220
+
221
+ export async function renderSurvey(flow: SurveyFlow): Promise<void> {
222
+ const { payload } = flow;
223
+
224
+ console.debug('[DAP] renderSurvey called with payload:', {
225
+ hasHeader: !!payload.header,
226
+ hasBody: !!payload.body,
227
+ hasQuestion: !!payload.question,
228
+ questionsCount: payload.questions?.length || 0,
229
+ targetSelector: payload.targetSelector,
230
+ mode: payload.mode,
231
+ type: payload.type
232
+ });
233
+
234
+ // Determine survey mode
235
+ const surveyMode = determineSurveyMode(payload);
236
+
237
+ console.debug('[DAP] Survey mode determined:', surveyMode);
238
+
239
+ if (surveyMode === 'inline') {
240
+ return renderMicroSurvey(flow);
241
+ } else {
242
+ return renderModalSurvey(flow);
243
+ }
244
+ }
245
+
246
+ function determineSurveyMode(payload: SurveyPayload): 'modal' | 'inline' {
247
+ console.debug('[DAP] Survey mode detection:', {
248
+ mode: payload.mode,
249
+ question: payload.question,
250
+ questionsArray: payload.questions?.length || 0,
251
+ targetSelector: payload.targetSelector,
252
+ type: payload.type
253
+ });
254
+
255
+ // Explicit mode setting takes precedence
256
+ if (payload.mode) {
257
+ console.debug('[DAP] Using explicit mode:', payload.mode);
258
+ return payload.mode;
259
+ }
260
+
261
+ // If we have multiple questions, always use modal (even with targetSelector)
262
+ if (payload.questions && payload.questions.length > 1) {
263
+ console.debug('[DAP] Multiple questions - using modal mode');
264
+ return 'modal';
265
+ }
266
+
267
+ // If there's a single question field and no questions array, it's a micro survey
268
+ if (payload.question && !payload.questions?.length) {
269
+ console.debug('[DAP] Single question micro survey - using inline mode');
270
+ return 'inline';
271
+ }
272
+
273
+ // If there's a targetSelector, it's probably meant to be inline
274
+ if (payload.targetSelector) {
275
+ console.debug('[DAP] Has targetSelector - using inline mode');
276
+ return 'inline';
277
+ }
278
+
279
+ // If type is one of the micro survey types, it's inline
280
+ if (payload.type && ['rating', 'choice', 'text'].includes(payload.type)) {
281
+ console.debug('[DAP] Simple survey type - using inline mode');
282
+ return 'inline';
283
+ }
284
+
285
+ // Default to modal for traditional surveys
286
+ console.debug('[DAP] Defaulting to modal mode');
287
+ return 'modal';
288
+ }
289
+
290
+ async function renderModalSurvey(flow: SurveyFlow): Promise<void> {
291
+ const { payload } = flow;
292
+
293
+ // Validate that we have questions for modal surveys
294
+ if (!payload.questions || payload.questions.length === 0) {
295
+ console.error("[DAP] Modal survey requires questions array");
296
+ payload._completionTracker?.onComplete?.();
297
+ return;
298
+ }
299
+
300
+ console.debug('[DAP] === SURVEY DEBUG: Rendering modal survey ===');
301
+ console.debug('[DAP] Survey payload:', payload);
302
+ console.debug('[DAP] Has completion tracker:', !!payload._completionTracker);
303
+ console.debug('[DAP] Has onComplete callback:', !!payload._completionTracker?.onComplete);
304
+
305
+ // TEMPORARY: Auto-advance after 3 seconds for rule testing
306
+ setTimeout(() => {
307
+ console.debug('[DAP] === SURVEY AUTO-ADVANCE: Simulating completion ===');
308
+ alert('🚀 Survey Auto-Advance!\n\nSimulating survey completion to test rule steps...');
309
+ payload._completionTracker?.onComplete?.();
310
+ }, 3000);
311
+
312
+ const prevActive = document.activeElement as HTMLElement | null;
313
+
314
+ const shell = createShell(payload.theme);
315
+
316
+ const onKey = (e: KeyboardEvent) => {
317
+ if (e.key === "Escape") closeAll();
318
+ else if (e.key === "Tab") trapTab(e, shell.dlg);
319
+ };
320
+ document.addEventListener("keydown", onKey, true);
321
+
322
+ // header
323
+ shell.titleEl.textContent = payload.header ?? "Survey";
324
+
325
+ // body
326
+ shell.body.replaceChildren();
327
+ if (payload.body) {
328
+ const bodyText = document.createElement("div");
329
+ bodyText.className = "dap-survey-intro";
330
+ bodyText.innerHTML = sanitizeHtml(payload.body);
331
+ shell.body.appendChild(bodyText);
332
+ }
333
+
334
+ // Survey form
335
+ const form = document.createElement("form");
336
+ form.className = "dap-survey-form";
337
+ form.addEventListener("submit", async (e) => {
338
+ e.preventDefault();
339
+
340
+ try {
341
+ // Get all form elements and extract values manually
342
+ const responses: Array<{
343
+ question: string;
344
+ type: SurveyQuestionType;
345
+ answer: any;
346
+ }> = [];
347
+
348
+ // Process each question based on its type (already validated above)
349
+ for (const q of payload.questions!) {
350
+ const questionData = {
351
+ question: q.question,
352
+ type: q.type,
353
+ answer: null as any
354
+ };
355
+
356
+ switch (q.type) {
357
+ case "SingleChoice":
358
+ case "Dropdown": {
359
+ const radio = form.querySelector(`input[name="${q.questionId}"]:checked`) as HTMLInputElement;
360
+ const select = form.querySelector(`select[name="${q.questionId}"]`) as HTMLSelectElement;
361
+ questionData.answer = radio?.value || select?.value || null;
362
+ break;
363
+ }
364
+
365
+ case "MultipleChoice": {
366
+ const checkboxes = Array.from(form.querySelectorAll(`input[name="${q.questionId}[]"]:checked`)) as HTMLInputElement[];
367
+ questionData.answer = checkboxes.map(cb => cb.value);
368
+ break;
369
+ }
370
+
371
+ case "TextSingle": {
372
+ const input = form.querySelector(`input[name="${q.questionId}"]`) as HTMLInputElement;
373
+ questionData.answer = input?.value || "";
374
+ break;
375
+ }
376
+
377
+ case "TextMulti": {
378
+ const textarea = form.querySelector(`textarea[name="${q.questionId}"]`) as HTMLTextAreaElement;
379
+ questionData.answer = textarea?.value || "";
380
+ break;
381
+ }
382
+
383
+ case "OpinionScale":
384
+ case "StarRating":
385
+ case "NpsScale": {
386
+ const radio = form.querySelector(`input[name="${q.questionId}"]:checked`) as HTMLInputElement;
387
+ questionData.answer = radio?.value ? parseInt(radio.value) : null;
388
+ break;
389
+ }
390
+
391
+ case "OpinionScaleChoice": {
392
+ const items = q.criteria || [];
393
+
394
+ // If no criteria provided, skip this question
395
+ if (items.length === 0) break;
396
+
397
+ const ratings: Record<string, number> = {};
398
+
399
+ items.forEach((item, idx) => {
400
+ const radio = form.querySelector(`input[name="${q.questionId}_${idx}"]:checked`) as HTMLInputElement;
401
+ if (radio?.value) {
402
+ ratings[item] = parseInt(radio.value);
403
+ }
404
+ });
405
+
406
+ questionData.answer = Object.keys(ratings).length > 0 ? ratings : null;
407
+ break;
408
+ }
409
+
410
+ case "StarChoice": {
411
+ // For the updated StarChoice, get the single selected rating value
412
+ const radio = form.querySelector(`input[name="${q.questionId}"]:checked`) as HTMLInputElement;
413
+
414
+ // Define default options from Poor to Excellent for mapping
415
+ const defaultLabels = ["Poor", "Fair", "Good", "Very Good", "Excellent"];
416
+ const max = q.scaleMax || 5;
417
+
418
+ // Use options if provided, otherwise use default labels (limited to max)
419
+ const starLabels = q.options && q.options.length > 0
420
+ ? q.options
421
+ : defaultLabels.slice(0, max);
422
+
423
+ if (radio?.value) {
424
+ const ratingValue = parseInt(radio.value);
425
+ const labelIndex = Math.min(starLabels.length, ratingValue) - 1;
426
+ const label = starLabels[labelIndex];
427
+
428
+ questionData.answer = {
429
+ value: ratingValue,
430
+ label: label
431
+ };
432
+ } else {
433
+ questionData.answer = null;
434
+ }
435
+
436
+ break;
437
+ }
438
+
439
+ case "NpsOptions": {
440
+ const category = form.querySelector(`input[name="${q.questionId}"]:checked`) as HTMLInputElement;
441
+
442
+ questionData.answer = category?.value || null;
443
+ break;
444
+ }
445
+ }
446
+
447
+ if (questionData.answer !== null) {
448
+ responses.push(questionData);
449
+ }
450
+ }
451
+
452
+ // Create the survey submission payload
453
+ const submissionData = {
454
+ stepId: payload.stepId,
455
+ sessionId: `user-session-${Date.now()}`,
456
+ submittedAt: new Date().toISOString(),
457
+ responses,
458
+ client: {
459
+ userId: "",
460
+ clientIP: "",
461
+ userAgent: navigator.userAgent,
462
+ locale: navigator.language
463
+ }
464
+ };
465
+
466
+ console.log("[DAP] Survey submission payload:", submissionData);
467
+
468
+ // Submit to API if configuration is available
469
+ if (flow.config && payload.flowId && payload.organizationId && payload.siteId) {
470
+ const url = flow.config?.apiurl + `/iap-experience/${payload.organizationId}/${payload.siteId}/survey-responses/${payload.flowId}`;
471
+ const hostBase = location.origin;
472
+
473
+ // Log the API request details
474
+ console.log("[DAP] Submitting survey to API:", url);
475
+ console.log("[DAP] Request will include X-Host-Url header:", hostBase);
476
+
477
+ try {
478
+ await http(flow.config, url, {
479
+ method: "POST",
480
+ body: submissionData,
481
+ hostBase,
482
+ includeHostHeader: true
483
+ });
484
+ console.log("[DAP] Survey successfully submitted to API");
485
+
486
+ // Track the survey submission
487
+ try {
488
+ // Legacy: Survey submission tracking - now handled by step view system
489
+ console.debug('[DAP Survey] Survey submission - tracking handled by step view system');
490
+ } catch (trackingError) {
491
+ console.error("[DAP] Survey submission tracking error:", trackingError);
492
+ // Don't re-throw tracking errors - we still want the submission to be considered successful
493
+ }
494
+ } catch (error) {
495
+ console.error("[DAP] Survey submission API error:", error);
496
+ throw error; // Re-throw to be caught by the outer try/catch
497
+ }
498
+ } else {
499
+ console.warn("[DAP] Survey API submission skipped - missing configuration");
500
+ }
501
+
502
+ // Close the survey
503
+ closeAll();
504
+ } catch (err) {
505
+ console.error("[DAP] Survey submission error:", err);
506
+
507
+ // Show error message
508
+ const errorMsg = document.createElement("div");
509
+ errorMsg.className = "dap-survey-error";
510
+ errorMsg.textContent = "An error occurred while submitting your responses. Please try again.";
511
+ form.prepend(errorMsg);
512
+
513
+ // Remove error message after 5 seconds
514
+ setTimeout(() => {
515
+ errorMsg.remove();
516
+ }, 5000);
517
+ }
518
+ });
519
+
520
+ // Add questions (already validated above)
521
+ payload.questions!.forEach((q, index) => {
522
+ const questionEl = renderQuestion(q, index);
523
+ form.appendChild(questionEl);
524
+ });
525
+
526
+ shell.body.appendChild(form);
527
+
528
+ // Apply adaptive modal sizing based on content
529
+ setTimeout(() => {
530
+ adjustSurveyModalSize(shell.dlg, shell.body);
531
+ }, 0);
532
+
533
+ // footer buttons
534
+ shell.prevBtn.textContent = "Cancel";
535
+ shell.nextBtn.textContent = "Submit";
536
+ shell.prevBtn.style.display = "inline-block";
537
+
538
+ const closeAll = () => {
539
+ document.removeEventListener("keydown", onKey, true);
540
+ shell.wrap.remove();
541
+ if (prevActive?.focus) prevActive.focus();
542
+ };
543
+
544
+ shell.wrap.addEventListener("click", (e) => { if (e.target === shell.wrap) closeAll(); });
545
+ shell.closeBtn.addEventListener("click", closeAll);
546
+ shell.prevBtn.addEventListener("click", closeAll);
547
+ shell.nextBtn.addEventListener("click", () => {
548
+ form.requestSubmit();
549
+ });
550
+
551
+ setTimeout(() => shell.dlg.focus(), 0);
552
+ }
553
+
554
+ /* ===================== Micro Survey Implementation ===================== */
555
+
556
+ interface MicroSurveyState {
557
+ id: string;
558
+ element: HTMLElement;
559
+ targetElement?: HTMLElement;
560
+ cleanup: (() => void)[];
561
+ isActive: boolean;
562
+ }
563
+
564
+ const activeMicroSurveys = new Map<string, MicroSurveyState>();
565
+
566
+ async function renderMicroSurvey(flow: SurveyFlow): Promise<void> {
567
+ const { payload, id } = flow;
568
+
569
+ console.debug("[DAP] MicroSurvey initialized", { id, payload });
570
+
571
+ if (!payload.question) {
572
+ console.error("[DAP] MicroSurvey missing required question");
573
+ payload._completionTracker?.onComplete?.();
574
+ return;
575
+ }
576
+
577
+ // Clean up any existing micro survey with same ID
578
+ if (activeMicroSurveys.has(id)) {
579
+ cleanupMicroSurvey(id);
580
+ }
581
+
582
+ // Find target element if selector provided
583
+ let targetElement: HTMLElement | undefined;
584
+ if (payload.targetSelector) {
585
+ const element = resolveSelector(payload.targetSelector);
586
+ if (element instanceof HTMLElement) {
587
+ targetElement = element;
588
+ } else {
589
+ console.warn(`[DAP] MicroSurvey: Target element not found for selector: ${payload.targetSelector}`);
590
+ }
591
+ }
592
+
593
+ // Create micro survey element
594
+ const microSurveyElement = createMicroSurveyElement(payload, id, flow);
595
+
596
+ const microSurveyState: MicroSurveyState = {
597
+ id,
598
+ element: microSurveyElement,
599
+ targetElement,
600
+ cleanup: [],
601
+ isActive: false
602
+ };
603
+
604
+ activeMicroSurveys.set(id, microSurveyState);
605
+
606
+ // Show micro survey
607
+ showMicroSurvey(microSurveyState, payload);
608
+
609
+ console.debug("[DAP] MicroSurvey setup complete", { id });
610
+ }
611
+
612
+ function createMicroSurveyElement(payload: SurveyPayload, id: string, flow: SurveyFlow): HTMLElement {
613
+ const microSurvey = document.createElement('div');
614
+ microSurvey.className = 'dap-microsurvey';
615
+ microSurvey.id = `dap-microsurvey-${id}`;
616
+ microSurvey.setAttribute('role', 'dialog');
617
+ microSurvey.setAttribute('aria-label', 'Quick Survey');
618
+
619
+ // Base styling
620
+ Object.assign(microSurvey.style, {
621
+ position: 'fixed',
622
+ zIndex: '10000',
623
+ backgroundColor: '#ffffff',
624
+ border: '1px solid #e2e8f0',
625
+ borderRadius: '12px',
626
+ boxShadow: '0 10px 25px rgba(0, 0, 0, 0.1), 0 4px 12px rgba(0, 0, 0, 0.05)',
627
+ padding: '20px',
628
+ maxWidth: '320px',
629
+ minWidth: '280px',
630
+ fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
631
+ fontSize: '14px',
632
+ lineHeight: '1.5',
633
+ color: '#1e293b',
634
+ opacity: '0',
635
+ transform: 'scale(0.95) translateY(10px)',
636
+ transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
637
+ backdropFilter: 'blur(8px)'
638
+ });
639
+
640
+ // Add question
641
+ const questionEl = document.createElement('div');
642
+ questionEl.style.cssText = `
643
+ font-weight: 600;
644
+ margin-bottom: 16px;
645
+ color: #1e293b;
646
+ line-height: 1.4;
647
+ `;
648
+ questionEl.innerHTML = sanitizeHtml(payload.question || '');
649
+ microSurvey.appendChild(questionEl);
650
+
651
+ // Add content based on type
652
+ const contentEl = document.createElement('div');
653
+ contentEl.style.marginBottom = '16px';
654
+
655
+ const surveyType = payload.type || 'choice';
656
+
657
+ if (surveyType === 'rating') {
658
+ createRatingContent(contentEl, payload, id);
659
+ } else if (surveyType === 'choice') {
660
+ createChoiceContent(contentEl, payload, id);
661
+ } else if (surveyType === 'text') {
662
+ createTextContent(contentEl, payload, id);
663
+ }
664
+
665
+ microSurvey.appendChild(contentEl);
666
+
667
+ // Add buttons
668
+ const buttonsEl = document.createElement('div');
669
+ buttonsEl.style.cssText = `
670
+ display: flex;
671
+ gap: 12px;
672
+ justify-content: flex-end;
673
+ `;
674
+
675
+ // Cancel button
676
+ const cancelBtn = document.createElement('button');
677
+ cancelBtn.textContent = payload.cancelText || 'Cancel';
678
+ cancelBtn.style.cssText = `
679
+ padding: 8px 16px;
680
+ border: 1px solid #d1d5db;
681
+ border-radius: 6px;
682
+ background: #ffffff;
683
+ color: #374151;
684
+ cursor: pointer;
685
+ font-size: 14px;
686
+ `;
687
+ cancelBtn.addEventListener('click', () => {
688
+ cleanupMicroSurvey(id);
689
+ payload._completionTracker?.onComplete?.();
690
+ });
691
+
692
+ // Submit button
693
+ const submitBtn = document.createElement('button');
694
+ submitBtn.textContent = payload.submitText || 'Submit';
695
+ submitBtn.style.cssText = `
696
+ padding: 8px 16px;
697
+ border: none;
698
+ border-radius: 6px;
699
+ background: #3b82f6;
700
+ color: #ffffff;
701
+ cursor: pointer;
702
+ font-size: 14px;
703
+ font-weight: 500;
704
+ `;
705
+
706
+ submitBtn.addEventListener('click', async () => {
707
+ const formData = extractMicroSurveyData(microSurvey, payload);
708
+ if (formData) {
709
+ try {
710
+ await submitMicroSurveyData(formData, payload, flow);
711
+ cleanupMicroSurvey(id);
712
+ payload._completionTracker?.onComplete?.();
713
+ } catch (error) {
714
+ console.error('[DAP] Micro survey submission failed:', error);
715
+ }
716
+ }
717
+ });
718
+
719
+ buttonsEl.appendChild(cancelBtn);
720
+ buttonsEl.appendChild(submitBtn);
721
+ microSurvey.appendChild(buttonsEl);
722
+
723
+ return microSurvey;
724
+ }
725
+
726
+ function showMicroSurvey(state: MicroSurveyState, payload: SurveyPayload): void {
727
+ document.body.appendChild(state.element);
728
+
729
+ // Position micro survey
730
+ positionMicroSurvey(state.element, state.targetElement, payload.position || 'center');
731
+
732
+ // Animate in
733
+ requestAnimationFrame(() => {
734
+ state.element.style.opacity = '1';
735
+ state.element.style.transform = 'scale(1) translateY(0)';
736
+ });
737
+
738
+ state.isActive = true;
739
+
740
+ // Set up cleanup handlers
741
+ const cleanup = () => cleanupMicroSurvey(state.id);
742
+ state.cleanup.push(cleanup);
743
+ }
744
+
745
+ function positionMicroSurvey(element: HTMLElement, targetElement?: HTMLElement, position: string = 'center'): void {
746
+ if (targetElement) {
747
+ // Position relative to target element
748
+ const targetRect = targetElement.getBoundingClientRect();
749
+ const elementRect = element.getBoundingClientRect();
750
+ const viewport = { width: window.innerWidth, height: window.innerHeight };
751
+
752
+ switch (position) {
753
+ case 'top':
754
+ element.style.left = `${targetRect.left + (targetRect.width - elementRect.width) / 2}px`;
755
+ element.style.top = `${targetRect.top - elementRect.height - 10}px`;
756
+ break;
757
+ case 'bottom':
758
+ element.style.left = `${targetRect.left + (targetRect.width - elementRect.width) / 2}px`;
759
+ element.style.top = `${targetRect.bottom + 10}px`;
760
+ break;
761
+ case 'left':
762
+ element.style.left = `${targetRect.left - elementRect.width - 10}px`;
763
+ element.style.top = `${targetRect.top + (targetRect.height - elementRect.height) / 2}px`;
764
+ break;
765
+ case 'right':
766
+ element.style.left = `${targetRect.right + 10}px`;
767
+ element.style.top = `${targetRect.top + (targetRect.height - elementRect.height) / 2}px`;
768
+ break;
769
+ default: // center
770
+ element.style.left = `${(viewport.width - elementRect.width) / 2}px`;
771
+ element.style.top = `${(viewport.height - elementRect.height) / 2}px`;
772
+ }
773
+
774
+ // Ensure element stays within viewport bounds
775
+ const rect = element.getBoundingClientRect();
776
+ if (rect.right > viewport.width) {
777
+ element.style.left = `${viewport.width - elementRect.width - 10}px`;
778
+ }
779
+ if (rect.bottom > viewport.height) {
780
+ element.style.top = `${viewport.height - elementRect.height - 10}px`;
781
+ }
782
+ if (rect.left < 0) {
783
+ element.style.left = '10px';
784
+ }
785
+ if (rect.top < 0) {
786
+ element.style.top = '10px';
787
+ }
788
+ } else {
789
+ // Center on screen
790
+ element.style.left = '50%';
791
+ element.style.top = '50%';
792
+ element.style.transform = 'translate(-50%, -50%) scale(0.95)';
793
+ }
794
+ }
795
+
796
+ function createRatingContent(container: HTMLElement, payload: SurveyPayload, id: string): void {
797
+ const min = payload.rating?.min || 1;
798
+ const max = payload.rating?.max || 5;
799
+
800
+ const ratingContainer = document.createElement('div');
801
+ ratingContainer.style.cssText = `
802
+ display: flex;
803
+ gap: 8px;
804
+ align-items: center;
805
+ justify-content: center;
806
+ `;
807
+
808
+ for (let i = min; i <= max; i++) {
809
+ const star = document.createElement('button');
810
+ star.type = 'button';
811
+ star.innerHTML = '★';
812
+ star.dataset.value = i.toString();
813
+ star.style.cssText = `
814
+ background: none;
815
+ border: none;
816
+ font-size: 24px;
817
+ color: #d1d5db;
818
+ cursor: pointer;
819
+ transition: color 0.2s;
820
+ `;
821
+
822
+ star.addEventListener('click', () => {
823
+ // Update visual state
824
+ ratingContainer.querySelectorAll('button').forEach((btn, idx) => {
825
+ btn.style.color = idx < i ? '#fbbf24' : '#d1d5db';
826
+ });
827
+ // Store value
828
+ ratingContainer.dataset.value = i.toString();
829
+ });
830
+
831
+ ratingContainer.appendChild(star);
832
+ }
833
+
834
+ container.appendChild(ratingContainer);
835
+ }
836
+
837
+ function createChoiceContent(container: HTMLElement, payload: SurveyPayload, id: string): void {
838
+ if (!payload.options?.length) return;
839
+
840
+ const choiceContainer = document.createElement('div');
841
+ choiceContainer.style.cssText = `
842
+ display: flex;
843
+ flex-direction: column;
844
+ gap: 8px;
845
+ `;
846
+
847
+ payload.options.forEach((option, index) => {
848
+ const optionEl = document.createElement('button');
849
+ optionEl.type = 'button';
850
+ optionEl.textContent = option.label;
851
+ optionEl.dataset.value = option.value;
852
+ optionEl.style.cssText = `
853
+ padding: 12px 16px;
854
+ border: 1px solid #d1d5db;
855
+ border-radius: 6px;
856
+ background: #ffffff;
857
+ color: #374151;
858
+ cursor: pointer;
859
+ text-align: left;
860
+ transition: all 0.2s;
861
+ `;
862
+
863
+ optionEl.addEventListener('click', () => {
864
+ // Clear previous selection
865
+ choiceContainer.querySelectorAll('button').forEach(btn => {
866
+ btn.style.background = '#ffffff';
867
+ btn.style.borderColor = '#d1d5db';
868
+ });
869
+ // Select this option
870
+ optionEl.style.background = '#eff6ff';
871
+ optionEl.style.borderColor = '#3b82f6';
872
+ choiceContainer.dataset.value = option.value;
873
+ });
874
+
875
+ choiceContainer.appendChild(optionEl);
876
+ });
877
+
878
+ container.appendChild(choiceContainer);
879
+ }
880
+
881
+ function createTextContent(container: HTMLElement, payload: SurveyPayload, id: string): void {
882
+ const textarea = document.createElement('textarea');
883
+ textarea.placeholder = payload.placeholder || 'Your feedback...';
884
+ textarea.style.cssText = `
885
+ width: 100%;
886
+ min-height: 80px;
887
+ padding: 12px;
888
+ border: 1px solid #d1d5db;
889
+ border-radius: 6px;
890
+ font-family: inherit;
891
+ font-size: 14px;
892
+ resize: vertical;
893
+ `;
894
+
895
+ container.appendChild(textarea);
896
+ }
897
+
898
+ function extractMicroSurveyData(element: HTMLElement, payload: SurveyPayload): any {
899
+ const surveyType = payload.type || 'choice';
900
+
901
+ switch (surveyType) {
902
+ case 'rating': {
903
+ const ratingContainer = element.querySelector('[data-value]') as HTMLElement;
904
+ return ratingContainer?.dataset.value ? parseInt(ratingContainer.dataset.value) : null;
905
+ }
906
+ case 'choice': {
907
+ const choiceContainer = element.querySelector('[data-value]') as HTMLElement;
908
+ return choiceContainer?.dataset.value || null;
909
+ }
910
+ case 'text': {
911
+ const textarea = element.querySelector('textarea') as HTMLTextAreaElement;
912
+ return textarea?.value || '';
913
+ }
914
+ default:
915
+ return null;
916
+ }
917
+ }
918
+
919
+ async function submitMicroSurveyData(data: any, payload: SurveyPayload, flow: SurveyFlow): Promise<void> {
920
+ const submissionData = {
921
+ stepId: payload.stepId,
922
+ sessionId: `user-session-${Date.now()}`,
923
+ submittedAt: new Date().toISOString(),
924
+ response: data,
925
+ question: payload.question,
926
+ type: payload.type,
927
+ client: {
928
+ userId: "",
929
+ clientIP: "",
930
+ userAgent: navigator.userAgent,
931
+ locale: navigator.language
932
+ }
933
+ };
934
+
935
+ console.log("[DAP] MicroSurvey submission payload:", submissionData);
936
+
937
+ // Submit to API if configuration is available
938
+ if (flow.config && payload.flowId && payload.organizationId && payload.siteId) {
939
+ const url = flow.config?.apiurl + `/iap-experience/${payload.organizationId}/${payload.siteId}/survey-responses/${payload.flowId}`;
940
+ const hostBase = location.origin;
941
+
942
+ console.log("[DAP] Submitting micro survey to API:", url);
943
+
944
+ await http(flow.config, url, {
945
+ method: "POST",
946
+ body: submissionData,
947
+ hostBase,
948
+ includeHostHeader: true
949
+ });
950
+
951
+ console.log("[DAP] MicroSurvey successfully submitted to API");
952
+ } else {
953
+ console.warn("[DAP] MicroSurvey API submission skipped - missing configuration");
954
+ }
955
+ }
956
+
957
+ function cleanupMicroSurvey(id: string): void {
958
+ const state = activeMicroSurveys.get(id);
959
+ if (!state) return;
960
+
961
+ // Run cleanup functions
962
+ state.cleanup.forEach(fn => {
963
+ try {
964
+ fn();
965
+ } catch (error) {
966
+ console.error('[DAP] Cleanup error:', error);
967
+ }
968
+ });
969
+
970
+ // Remove element
971
+ if (state.element.parentElement) {
972
+ state.element.style.opacity = '0';
973
+ state.element.style.transform = 'scale(0.95) translateY(10px)';
974
+ setTimeout(() => {
975
+ if (state.element.parentElement) {
976
+ state.element.parentElement.removeChild(state.element);
977
+ }
978
+ }, 300);
979
+ }
980
+
981
+ // Remove from active surveys
982
+ activeMicroSurveys.delete(id);
983
+ }
984
+
985
+ /* ===================== End Micro Survey Implementation ===================== */
986
+
987
+ function renderQuestion(question: SurveyQuestion, index: number): HTMLElement {
988
+ const wrapper = document.createElement("div");
989
+ wrapper.className = "dap-survey-question";
990
+ wrapper.dataset.type = question.type;
991
+
992
+ // Add full-width class to complex question types that need more space
993
+ if (["TextMulti", "NpsScale", "NpsOptions", "OpinionScaleChoice", "StarChoice"].includes(question.type)) {
994
+ wrapper.classList.add("dap-full-width");
995
+ }
996
+
997
+ const label = document.createElement("label");
998
+ label.className = "dap-question-label";
999
+ label.textContent = `${index + 1}. ${question.question}`;
1000
+ wrapper.appendChild(label);
1001
+
1002
+ const inputContainer = document.createElement("div");
1003
+ inputContainer.className = "dap-question-input";
1004
+
1005
+ switch (question.type) {
1006
+ case "SingleChoice":
1007
+ renderSingleChoice(inputContainer, question);
1008
+ break;
1009
+ case "MultipleChoice":
1010
+ renderMultipleChoice(inputContainer, question);
1011
+ break;
1012
+ case "Dropdown":
1013
+ renderDropdown(inputContainer, question);
1014
+ break;
1015
+ case "TextSingle":
1016
+ renderTextSingle(inputContainer, question);
1017
+ break;
1018
+ case "TextMulti":
1019
+ renderTextMulti(inputContainer, question);
1020
+ break;
1021
+ case "OpinionScale":
1022
+ renderOpinionScale(inputContainer, question);
1023
+ break;
1024
+ case "OpinionScaleChoice":
1025
+ renderOpinionScaleChoice(inputContainer, question);
1026
+ break;
1027
+ case "NpsScale":
1028
+ renderNpsScale(inputContainer, question);
1029
+ break;
1030
+ case "NpsOptions":
1031
+ renderNpsOptions(inputContainer, question);
1032
+ break;
1033
+ case "StarRating":
1034
+ renderStarRating(inputContainer, question);
1035
+ break;
1036
+ case "StarChoice":
1037
+ renderStarChoice(inputContainer, question);
1038
+ break;
1039
+ }
1040
+
1041
+ wrapper.appendChild(inputContainer);
1042
+ return wrapper;
1043
+ }
1044
+
1045
+ function renderSingleChoice(container: HTMLElement, question: SurveyQuestion) {
1046
+ if (!question.options?.length) return;
1047
+
1048
+ question.options.forEach((option, i) => {
1049
+ const wrapper = document.createElement("div");
1050
+ wrapper.className = "dap-radio-wrapper";
1051
+
1052
+ const input = document.createElement("input");
1053
+ input.type = "radio";
1054
+ input.name = question.questionId;
1055
+ input.id = `${question.questionId}_${i}`;
1056
+ input.value = option;
1057
+
1058
+ const label = document.createElement("label");
1059
+ label.htmlFor = input.id;
1060
+ label.textContent = option;
1061
+
1062
+ wrapper.appendChild(input);
1063
+ wrapper.appendChild(label);
1064
+ container.appendChild(wrapper);
1065
+ });
1066
+ }
1067
+
1068
+ function renderMultipleChoice(container: HTMLElement, question: SurveyQuestion) {
1069
+ if (!question.options?.length) return;
1070
+
1071
+ question.options.forEach((option, i) => {
1072
+ const wrapper = document.createElement("div");
1073
+ wrapper.className = "dap-checkbox-wrapper";
1074
+
1075
+ const input = document.createElement("input");
1076
+ input.type = "checkbox";
1077
+ input.name = `${question.questionId}[]`;
1078
+ input.id = `${question.questionId}_${i}`;
1079
+ input.value = option;
1080
+
1081
+ const label = document.createElement("label");
1082
+ label.htmlFor = input.id;
1083
+ label.textContent = option;
1084
+
1085
+ wrapper.appendChild(input);
1086
+ wrapper.appendChild(label);
1087
+ container.appendChild(wrapper);
1088
+ });
1089
+ }
1090
+
1091
+ function renderDropdown(container: HTMLElement, question: SurveyQuestion) {
1092
+ if (!question.options?.length) return;
1093
+
1094
+ const select = document.createElement("select");
1095
+ select.name = question.questionId;
1096
+
1097
+ const defaultOption = document.createElement("option");
1098
+ defaultOption.value = "";
1099
+ defaultOption.textContent = "-- Select an option --";
1100
+ defaultOption.selected = true;
1101
+ defaultOption.disabled = true;
1102
+ select.appendChild(defaultOption);
1103
+
1104
+ question.options.forEach((option, i) => {
1105
+ const optionEl = document.createElement("option");
1106
+ optionEl.value = option;
1107
+ optionEl.textContent = option;
1108
+ select.appendChild(optionEl);
1109
+ });
1110
+
1111
+ container.appendChild(select);
1112
+ }
1113
+
1114
+ function renderTextSingle(container: HTMLElement, question: SurveyQuestion) {
1115
+ const input = document.createElement("input");
1116
+ input.type = "text";
1117
+ input.name = question.questionId;
1118
+ input.placeholder = "Your answer...";
1119
+ container.appendChild(input);
1120
+ }
1121
+
1122
+ function renderTextMulti(container: HTMLElement, question: SurveyQuestion) {
1123
+ const textarea = document.createElement("textarea");
1124
+ textarea.name = question.questionId;
1125
+ textarea.placeholder = "Your answer...";
1126
+ textarea.rows = 4;
1127
+ container.appendChild(textarea);
1128
+ }
1129
+
1130
+ function renderOpinionScale(container: HTMLElement, question: SurveyQuestion) {
1131
+ const min = question.scaleMin || 1;
1132
+ const max = question.scaleMax || 5;
1133
+
1134
+ const scaleContainer = document.createElement("div");
1135
+ scaleContainer.className = "dap-scale-container";
1136
+
1137
+ if (question.labelMin) {
1138
+ const minLabel = document.createElement("div");
1139
+ minLabel.className = "dap-scale-label";
1140
+ minLabel.textContent = question.labelMin;
1141
+ scaleContainer.appendChild(minLabel);
1142
+ }
1143
+
1144
+ const scaleOptions = document.createElement("div");
1145
+ scaleOptions.className = "dap-scale-options";
1146
+
1147
+ for (let i = min; i <= max; i++) {
1148
+ const option = document.createElement("div");
1149
+ option.className = "dap-scale-option";
1150
+
1151
+ const input = document.createElement("input");
1152
+ input.type = "radio";
1153
+ input.name = question.questionId;
1154
+ input.id = `${question.questionId}_${i}`;
1155
+ input.value = i.toString();
1156
+
1157
+ const label = document.createElement("label");
1158
+ label.htmlFor = input.id;
1159
+ label.textContent = i.toString();
1160
+
1161
+ option.appendChild(input);
1162
+ option.appendChild(label);
1163
+ scaleOptions.appendChild(option);
1164
+ }
1165
+
1166
+ scaleContainer.appendChild(scaleOptions);
1167
+
1168
+ if (question.labelMax) {
1169
+ const maxLabel = document.createElement("div");
1170
+ maxLabel.className = "dap-scale-label";
1171
+ maxLabel.textContent = question.labelMax;
1172
+ scaleContainer.appendChild(maxLabel);
1173
+ }
1174
+
1175
+ container.appendChild(scaleContainer);
1176
+ }
1177
+
1178
+ function renderOpinionScaleChoice(container: HTMLElement, question: SurveyQuestion) {
1179
+ const min = question.scaleMin || 1;
1180
+ const max = question.scaleMax || 5;
1181
+ const scaleSize = max - min + 1;
1182
+
1183
+ // Define emoji faces based on a 5-point scale
1184
+ const faces = ["😣", "😕", "😐", "🙂", "😄"];
1185
+
1186
+ // Container for face options
1187
+ const scaleContainer = document.createElement("div");
1188
+ scaleContainer.className = "dap-scale-faces";
1189
+
1190
+ // Create options for each point on the scale
1191
+ for (let i = min; i <= max; i++) {
1192
+ const faceIndex = Math.min(scaleSize - 1, Math.floor((i - min) / (max - min) * (faces.length - 1)));
1193
+
1194
+ const option = document.createElement("div");
1195
+ option.className = "dap-face-option";
1196
+
1197
+ const input = document.createElement("input");
1198
+ input.type = "radio";
1199
+ input.className = "dap-face-radio";
1200
+ input.name = question.questionId;
1201
+ input.id = `${question.questionId}_${i}`;
1202
+ input.value = i.toString();
1203
+
1204
+ const label = document.createElement("label");
1205
+ label.className = "dap-face-label";
1206
+ label.htmlFor = `${question.questionId}_${i}`;
1207
+ label.textContent = faces[faceIndex];
1208
+ label.title = `Rating: ${i}`;
1209
+
1210
+ option.appendChild(input);
1211
+ option.appendChild(label);
1212
+ scaleContainer.appendChild(option);
1213
+ }
1214
+
1215
+ container.appendChild(scaleContainer);
1216
+ }
1217
+
1218
+ function renderNpsScale(container: HTMLElement, question: SurveyQuestion) {
1219
+ const min = question.scaleMin || 0;
1220
+ const max = question.scaleMax || 10;
1221
+
1222
+ const npsContainer = document.createElement("div");
1223
+ npsContainer.className = "dap-nps-container";
1224
+
1225
+ const scaleOptions = document.createElement("div");
1226
+ scaleOptions.className = "dap-nps-scale";
1227
+
1228
+ for (let i = min; i <= max; i++) {
1229
+ const option = document.createElement("div");
1230
+ option.className = "dap-nps-option";
1231
+
1232
+ const input = document.createElement("input");
1233
+ input.type = "radio";
1234
+ input.name = question.questionId;
1235
+ input.id = `${question.questionId}_${i}`;
1236
+ input.value = i.toString();
1237
+
1238
+ const label = document.createElement("label");
1239
+ label.htmlFor = input.id;
1240
+ label.textContent = i.toString();
1241
+
1242
+ option.appendChild(input);
1243
+ option.appendChild(label);
1244
+ scaleOptions.appendChild(option);
1245
+ }
1246
+
1247
+ npsContainer.appendChild(scaleOptions);
1248
+
1249
+ // Labels under the scale
1250
+ const labelContainer = document.createElement("div");
1251
+ labelContainer.className = "dap-nps-labels";
1252
+
1253
+ if (question.labelMin) {
1254
+ const minLabel = document.createElement("div");
1255
+ minLabel.className = "dap-nps-label-min";
1256
+ minLabel.textContent = question.labelMin;
1257
+ labelContainer.appendChild(minLabel);
1258
+ }
1259
+
1260
+ if (question.labelMax) {
1261
+ const maxLabel = document.createElement("div");
1262
+ maxLabel.className = "dap-nps-label-max";
1263
+ maxLabel.textContent = question.labelMax;
1264
+ labelContainer.appendChild(maxLabel);
1265
+ }
1266
+
1267
+ npsContainer.appendChild(labelContainer);
1268
+ container.appendChild(npsContainer);
1269
+ }
1270
+
1271
+ function renderNpsOptions(container: HTMLElement, question: SurveyQuestion) {
1272
+ // Create predefined NPS categories
1273
+ const npsCategories = [
1274
+ { key: "not_likely", label: "Not Likely (0-2)" },
1275
+ { key: "somewhat_likely", label: "Somewhat Likely (3-8)" },
1276
+ { key: "very_likely", label: "Very Likely (9-10)" }
1277
+ ];
1278
+
1279
+ const optionsContainer = document.createElement("div");
1280
+ optionsContainer.className = "dap-nps-options";
1281
+
1282
+ npsCategories.forEach((category) => {
1283
+ const wrapper = document.createElement("div");
1284
+ wrapper.className = "dap-nps-category";
1285
+
1286
+ const input = document.createElement("input");
1287
+ input.type = "radio";
1288
+ input.name = question.questionId;
1289
+ input.id = `${question.questionId}_${category.key}`;
1290
+ input.value = category.key;
1291
+
1292
+ const label = document.createElement("label");
1293
+ label.htmlFor = input.id;
1294
+ label.textContent = category.label;
1295
+
1296
+ wrapper.appendChild(input);
1297
+ wrapper.appendChild(label);
1298
+ optionsContainer.appendChild(wrapper);
1299
+ });
1300
+
1301
+ container.appendChild(optionsContainer);
1302
+ }
1303
+
1304
+ function renderStarRating(container: HTMLElement, question: SurveyQuestion) {
1305
+ const max = question.scaleMax || 5;
1306
+
1307
+ // Define star labels for tooltips based on options if provided
1308
+ // Otherwise use default labels
1309
+ const defaultStarLabels = {
1310
+ 5: "Excellent",
1311
+ 4: "Very Good",
1312
+ 3: "Good",
1313
+ 2: "Fair",
1314
+ 1: "Poor"
1315
+ };
1316
+
1317
+ // Create wrapper for stars and clear button
1318
+ const ratingWrapper = document.createElement("div");
1319
+ ratingWrapper.className = "dap-rating-wrapper";
1320
+
1321
+ // Create radio button-based star rating
1322
+ const starContainer = document.createElement("div");
1323
+ starContainer.className = "dap-star-rating";
1324
+
1325
+ // Add a hidden input to track if any star is selected
1326
+ const hiddenStatusInput = document.createElement("input");
1327
+ hiddenStatusInput.type = "hidden";
1328
+ hiddenStatusInput.className = "dap-star-status";
1329
+ hiddenStatusInput.value = "0";
1330
+ starContainer.appendChild(hiddenStatusInput);
1331
+
1332
+ // Create stars
1333
+ for (let i = 1; i <= max; i++) {
1334
+ // Create input radio button (hidden visually)
1335
+ const input = document.createElement("input");
1336
+ input.type = "radio";
1337
+ input.name = question.questionId;
1338
+ input.id = `${question.questionId}_${i}`;
1339
+ // Calculate the actual rating value (from 1 to max) since we're using RTL
1340
+ const actualRating = max - i + 1;
1341
+ input.value = actualRating.toString();
1342
+ input.className = "dap-star-input";
1343
+
1344
+ // Update status when a star is selected
1345
+ input.addEventListener("change", () => {
1346
+ if (input.checked) {
1347
+ hiddenStatusInput.value = "1";
1348
+ clearButton.style.display = "inline-flex";
1349
+ }
1350
+ });
1351
+
1352
+ // Create star label (visible element)
1353
+ const label = document.createElement("label");
1354
+ label.htmlFor = input.id;
1355
+ label.className = "dap-star-label";
1356
+
1357
+ // Get the appropriate label for this star
1358
+ const starLabel = question.options && question.options.length === max
1359
+ ? question.options[actualRating - 1]
1360
+ : defaultStarLabels[actualRating as keyof typeof defaultStarLabels];
1361
+
1362
+ label.setAttribute("aria-label", `${actualRating} star${actualRating > 1 ? 's' : ''}`);
1363
+ label.setAttribute("title", `${actualRating} star${actualRating > 1 ? 's' : ''}: ${starLabel}`);
1364
+
1365
+ // Add both to container
1366
+ starContainer.appendChild(input);
1367
+ starContainer.appendChild(label);
1368
+ }
1369
+
1370
+ // Create clear button
1371
+ const clearButton = document.createElement("button");
1372
+ clearButton.type = "button";
1373
+ clearButton.className = "dap-clear-rating";
1374
+ clearButton.textContent = "Clear";
1375
+ clearButton.title = "Clear rating";
1376
+ clearButton.style.display = "none"; // Hidden by default
1377
+
1378
+ // Clear selection when clicked
1379
+ clearButton.addEventListener("click", () => {
1380
+ // Uncheck all inputs
1381
+ starContainer.querySelectorAll("input[type='radio']").forEach((input) => {
1382
+ (input as HTMLInputElement).checked = false;
1383
+ });
1384
+
1385
+ // Update status
1386
+ hiddenStatusInput.value = "0";
1387
+
1388
+ // Hide clear button
1389
+ clearButton.style.display = "none";
1390
+ });
1391
+
1392
+ ratingWrapper.appendChild(starContainer);
1393
+ ratingWrapper.appendChild(clearButton);
1394
+ container.appendChild(ratingWrapper);
1395
+ }
1396
+
1397
+ function renderStarChoice(container: HTMLElement, question: SurveyQuestion) {
1398
+ // Set sensible defaults according to requirements
1399
+ const min = question.scaleMin || 1;
1400
+ const max = question.scaleMax || 5;
1401
+
1402
+ // Default star labels if options are not provided
1403
+ const defaultLabels = ["Poor", "Fair", "Good", "Very Good", "Excellent"];
1404
+
1405
+ // Use options if provided, otherwise use default labels (limited to max)
1406
+ const starLabels = question.options && question.options.length > 0
1407
+ ? question.options
1408
+ : defaultLabels.slice(0, max);
1409
+
1410
+ // Create main container
1411
+ const choiceContainer = document.createElement("div");
1412
+ choiceContainer.className = "dap-star-choice-container";
1413
+ choiceContainer.setAttribute("role", "radiogroup");
1414
+ choiceContainer.setAttribute("aria-labelledby", `${question.questionId}-heading`);
1415
+
1416
+ // Create options list
1417
+ const optionsList = document.createElement("div");
1418
+ optionsList.className = "dap-star-choice-options";
1419
+
1420
+ // Create each star choice option
1421
+ for (let i = 1; i <= max; i++) {
1422
+ // Create option container
1423
+ const optionItem = document.createElement("div");
1424
+ optionItem.className = "dap-star-choice-option";
1425
+
1426
+ // Create radio input
1427
+ const input = document.createElement("input");
1428
+ input.type = "radio";
1429
+ input.className = "dap-star-choice-input";
1430
+ input.name = question.questionId;
1431
+ input.id = `${question.questionId}_${i}`;
1432
+ input.value = i.toString();
1433
+
1434
+ // Create label that contains stars and text
1435
+ const label = document.createElement("label");
1436
+ label.className = "dap-star-choice-label";
1437
+ label.htmlFor = input.id;
1438
+
1439
+ // Create stars display
1440
+ const starsDisplay = document.createElement("div");
1441
+ starsDisplay.className = "dap-star-choice-stars";
1442
+
1443
+ // Add filled and empty stars based on the rating
1444
+ for (let j = 1; j <= max; j++) {
1445
+ const starSpan = document.createElement("span");
1446
+ starSpan.className = j <= i
1447
+ ? "dap-star-choice-star filled"
1448
+ : "dap-star-choice-star";
1449
+ starSpan.innerHTML = "★"; // Unicode star character
1450
+ starsDisplay.appendChild(starSpan);
1451
+ }
1452
+
1453
+ // Create text label
1454
+ const textLabel = document.createElement("span");
1455
+ textLabel.className = "dap-star-choice-text";
1456
+ textLabel.textContent = starLabels[i-1];
1457
+
1458
+ // Add stars and text to the label
1459
+ label.appendChild(starsDisplay);
1460
+ label.appendChild(textLabel);
1461
+
1462
+ // Add input and label to the option container
1463
+ optionItem.appendChild(input);
1464
+ optionItem.appendChild(label);
1465
+
1466
+ // Add option to the list
1467
+ optionsList.appendChild(optionItem);
1468
+ }
1469
+
1470
+ // Add options list to the main container
1471
+ choiceContainer.appendChild(optionsList);
1472
+
1473
+ // Add the container to the parent element
1474
+ container.appendChild(choiceContainer);
1475
+ }
1476
+
1477
+ /* ===================== Survey Adaptive Sizing ===================== */
1478
+
1479
+ /**
1480
+ * Adjusts survey modal size based on content to minimize scrollbars
1481
+ * @param modal The modal element
1482
+ * @param body The modal body element
1483
+ */
1484
+ function adjustSurveyModalSize(modal: HTMLElement, body: HTMLElement): void {
1485
+ console.debug('[DAP] Adjusting survey modal size based on content');
1486
+
1487
+ try {
1488
+ // Get content dimensions
1489
+ const bodyRect = body.getBoundingClientRect();
1490
+ const modalRect = modal.getBoundingClientRect();
1491
+
1492
+ console.debug('[DAP] Content dimensions:', {
1493
+ bodyWidth: bodyRect.width,
1494
+ bodyHeight: bodyRect.height,
1495
+ modalWidth: modalRect.width,
1496
+ modalHeight: modalRect.height
1497
+ });
1498
+
1499
+ // Remove existing size classes
1500
+ modal.classList.remove('dap-size-small', 'dap-size-medium', 'dap-size-large', 'dap-scrollable');
1501
+
1502
+ // Determine optimal size based on content width
1503
+ let sizeClass = 'dap-size-medium'; // default
1504
+
1505
+ if (bodyRect.width <= 480) {
1506
+ sizeClass = 'dap-size-small';
1507
+ } else if (bodyRect.width <= 700) {
1508
+ sizeClass = 'dap-size-medium';
1509
+ } else if (bodyRect.width <= 1000) {
1510
+ sizeClass = 'dap-size-large';
1511
+ } else {
1512
+ sizeClass = 'dap-size-large'; // max size for very wide content
1513
+ }
1514
+
1515
+ modal.classList.add(sizeClass);
1516
+
1517
+ // Check if content needs scrolling after applying size class
1518
+ requestAnimationFrame(() => {
1519
+ const updatedBodyRect = body.getBoundingClientRect();
1520
+ const updatedModalRect = modal.getBoundingClientRect();
1521
+
1522
+ // Check for overflow
1523
+ const needsHorizontalScroll = body.scrollWidth > updatedBodyRect.width;
1524
+ const needsVerticalScroll = body.scrollHeight > updatedBodyRect.height;
1525
+
1526
+ // Check for viewport overflow
1527
+ const viewportWidth = window.innerWidth;
1528
+ const viewportHeight = window.innerHeight;
1529
+ const wouldOverflowViewport = updatedModalRect.width > viewportWidth * 0.9 ||
1530
+ updatedModalRect.height > viewportHeight * 0.9;
1531
+
1532
+ if (needsHorizontalScroll || needsVerticalScroll || wouldOverflowViewport) {
1533
+ modal.classList.add('dap-scrollable');
1534
+ console.debug('[DAP] Added scrollable class due to overflow:', {
1535
+ needsHorizontalScroll,
1536
+ needsVerticalScroll,
1537
+ wouldOverflowViewport
1538
+ });
1539
+ }
1540
+
1541
+ console.debug('[DAP] Final survey modal size class:', sizeClass, {
1542
+ hasScrollable: modal.classList.contains('dap-scrollable'),
1543
+ finalWidth: updatedModalRect.width,
1544
+ finalHeight: updatedModalRect.height
1545
+ });
1546
+ });
1547
+
1548
+ } catch (error) {
1549
+ console.warn('[DAP] Error adjusting survey modal size:', error);
1550
+ }
1551
+ }
1552
+
1553
+ /* ===================== Modal Shell (reused from modal.ts) ===================== */
1554
+
1555
+ function createShell(theme?: Record<string, string>) {
1556
+ const root = ensureRoot();
1557
+
1558
+ const wrap = document.createElement("div");
1559
+ wrap.className = "dap-modal-wrap";
1560
+ wrap.setAttribute("role", "dialog");
1561
+ wrap.setAttribute("aria-modal", "true");
1562
+ wrap.style.pointerEvents = "auto";
1563
+ wrap.style.zIndex = "2147483647";
1564
+
1565
+ const dlg = document.createElement("div");
1566
+ dlg.className = "dap-modal dap-survey-modal";
1567
+ dlg.tabIndex = -1;
1568
+ if (theme) for (const [k, v] of Object.entries(theme)) (dlg.style as any).setProperty(k, v as string);
1569
+
1570
+ const headerBar = document.createElement("div");
1571
+ headerBar.className = "dap-header-bar";
1572
+
1573
+ const titleEl = document.createElement("div");
1574
+ titleEl.className = "dap-modal-header";
1575
+
1576
+ const closeBtn = document.createElement("button");
1577
+ closeBtn.className = "dap-close";
1578
+ closeBtn.setAttribute("aria-label", "Close");
1579
+ closeBtn.innerHTML = "×";
1580
+
1581
+ headerBar.appendChild(titleEl); headerBar.appendChild(closeBtn);
1582
+
1583
+ const body = document.createElement("div"); body.className = "dap-modal-body dap-survey-body";
1584
+ const footer = document.createElement("div"); footer.className = "dap-footer dap-nav";
1585
+ const prevBtn = document.createElement("button"); prevBtn.className = "dap-secondary"; prevBtn.type = "button"; prevBtn.textContent = "Cancel";
1586
+ const nextBtn = document.createElement("button"); nextBtn.className = "dap-cta"; nextBtn.type = "button"; nextBtn.textContent = "Submit";
1587
+ footer.appendChild(prevBtn); footer.appendChild(nextBtn);
1588
+
1589
+ dlg.appendChild(headerBar); dlg.appendChild(body); dlg.appendChild(footer);
1590
+
1591
+ (root as unknown as Node).appendChild(wrap);
1592
+ wrap.appendChild(dlg);
1593
+
1594
+ return { wrap, dlg, headerBar, titleEl, body, footer, prevBtn, nextBtn, closeBtn };
1595
+ }
1596
+
1597
+ function ensureRoot(): ShadowRoot {
1598
+ let host = document.querySelector("dap-root") as HTMLElement | null;
1599
+ if (!host) {
1600
+ host = document.createElement("dap-root");
1601
+ host.style.position = "fixed";
1602
+ host.style.zIndex = "2147483647"; // Maximum z-index
1603
+ host.style.inset = "0";
1604
+ host.style.pointerEvents = "none";
1605
+ host.style.width = "100vw";
1606
+ host.style.height = "100vh";
1607
+ host.style.display = "flex";
1608
+ host.style.alignItems = "center";
1609
+ host.style.justifyContent = "center";
1610
+ document.documentElement.appendChild(host);
1611
+ }
1612
+ const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" });
1613
+
1614
+ if (!shadow.getElementById("dap-modal-style")) {
1615
+ const style = document.createElement("style");
1616
+ style.id = "dap-modal-style";
1617
+ style.textContent = modalCssText;
1618
+ shadow.appendChild(style);
1619
+ }
1620
+
1621
+ if (!shadow.getElementById("dap-survey-style")) {
1622
+ const style = document.createElement("style");
1623
+ style.id = "dap-survey-style";
1624
+ style.textContent = surveyCssText;
1625
+ shadow.appendChild(style);
1626
+ }
1627
+
1628
+ return shadow;
1629
+ }
1630
+
1631
+ /* ===================== Utilities ===================== */
1632
+
1633
+ function trapTab(e: KeyboardEvent, root: HTMLElement) {
1634
+ const focusables = Array.from(root.querySelectorAll<HTMLElement>('a,button,input,textarea,select,details,[tabindex]:not([tabindex="-1"])')).filter(el => !el.hasAttribute("disabled"));
1635
+ if (!focusables.length) return;
1636
+ const first = focusables[0], last = focusables[focusables.length - 1];
1637
+ if (e.shiftKey && document.activeElement === first) { last.focus(); e.preventDefault(); }
1638
+ else if (!e.shiftKey && document.activeElement === last) { first.focus(); e.preventDefault(); }
1639
+ }