1/** 2 * Annsa Collection Surfaces v2.0.0 3 * Unified feedback collection across multiple UI surfaces 4 * 5 * PLACEMENTS -- all four are triggers for the SAME modal. The placement decides 6 * how the survey is reached; the instrument config decides what it asks. 7 * float - Floating action button 8 * inline - Embedded trigger inside your page 9 * bar - Thin prompt bar (top/bottom) with delay 10 * page - CTA link 11 * 12 * INSTRUMENT (not a placement): 13 * thumbs - Approval: minimal inline thumbs up/down, answered in place 14 * 15 * BASIC USAGE: 16 * <script async src="https://app.annsa.ai/survey.js" data-api-key="your-key"></script> 17 * 18 * SURFACE SELECTION: 19 * <script async src="survey.js" data-api-key="xxx" data-survey="float"></script> 20 * <script async src="survey.js" data-api-key="xxx" data-survey="thumbs"></script> 21 * 22 * INLINE/THUMBS (place element, then script): 23 * <div data-circuit-inline data-context="help:api-docs" data-prompt="Enjoying the docs?"></div> 24 * <span data-circuit-thumbs data-context="feature:dark-mode"></span> 25 * 26 * NOTE: data-circuit-inline / data-circuit-thumbs are READ BY DEPLOYED EMBEDS. 27 * Renaming them breaks every install already in the wild. 28 * 29 * HEADLESS MODE: 30 * <script async src="survey.js" data-api-key="xxx" data-headless="true"></script> 31 * <button onclick="window.AnnsaSurvey.open()">Feedback</button> 32 * 33 * WITH USER IDENTITY: 34 * window.AnnsaSurveyConfig = { user: { email: '[email protected]', name: 'Jane', plan: 'pro' } }; 35 * 36 * CSP NONCE (if your site uses Content-Security-Policy): 37 * <script async src="widget.js" data-api-key="xxx" data-csp-nonce="your-nonce"></script> 38 * 39 * COMMAND QUEUE (call methods before widget loads): 40 * window.AnnsaSurvey = window.AnnsaSurvey || { _q: [] }; 41 * window.AnnsaSurvey._q.push(['identify', { email: '[email protected]' }]); 42 * window.AnnsaSurvey._q.push(['open']); 43 * 44 * API: 45 * window.AnnsaSurvey.open([surface]) - Open feedback modal 46 * window.AnnsaSurvey.close() - Close feedback modal 47 * window.AnnsaSurvey.identify({}) - Set user identity 48 * window.AnnsaSurvey.reset() - Clear identity 49 * window.AnnsaSurvey.destroy() - Remove widget and clean up 50 * window.AnnsaSurvey.version - Current version string 51 */ 52(function() { 53 'use strict'; 54 55 // ========================================================================= 56 // DUPLICATE INCLUSION GUARD 57 // ========================================================================= 58 59 if (window.__circuitWidgetLoaded) return; 60 window.__circuitWidgetLoaded = true; 61 62 // Capture any queued commands made before this script loaded 63 var _pendingQueue = (window.AnnsaSurvey && window.AnnsaSurvey._q) || []; 64 // Deferred open: set to true when open() is called before floatInstance exists 65 // (simplified embed path â floatInstance created after async config fetch). 66 // Drained at the end of _initSurfaceRouting() once floatInstance is ready. 67 var _deferredOpen = false; 68 69 // Skip on login/signup pages 70 var pathname = window.location.pathname.toLowerCase(); 71 if (pathname.indexOf('/login') !== -1 || pathname.indexOf('/signup') !== -1) return; 72 73 // Skip in automated test environments (Playwright, Selenium, all WebDriver runners). 74 // navigator.webdriver is true in every standards-compliant test runner and false in 75 // real user browsers â no configuration needed by the customer or Annsa. 76 if (navigator.webdriver) return; 77 78 // ========================================================================= 79 // CONFIGURATION 80 // ========================================================================= 81 82 var WIDGET_VERSION = '2.1.0'; 83 var DEFAULT_API_BASE = 'https://dq453vlx5pdh6.cloudfront.net'; 84 var currentScript = document.currentScript || document.querySelector('script[data-api-key]'); 85 if (!currentScript) return; 86 87 var apiKey = currentScript.getAttribute('data-api-key'); 88 if (!apiKey) { console.error('Annsa: data-api-key required'); return; } 89 90 var API_BASE = currentScript.getAttribute('data-api-base') || DEFAULT_API_BASE; 91 // Track whether the customer explicitly set data-survey in their embed tag. 92 // When absent, surface type is resolved from the server config so simplified 93 // embeds (data-api-key only) don't need to encode surface type in HTML. 94 var _surfaceAttr = currentScript.getAttribute('data-survey'); 95 var surfaceType = _surfaceAttr || 'float'; 96 var _VALID_SURFACES = ['float', 'inline', 'bar', 'thumbs', 'page']; 97 if (_VALID_SURFACES.indexOf(surfaceType) === -1) { 98 console.warn('[Annsa] Unknown surface type "' + surfaceType + '". Defaulting to "float".'); 99 surfaceType = 'float'; 100 _surfaceAttr = null; // treat invalid value same as absent â let server decide 101 } 102 var widgetId = currentScript.getAttribute('data-surface-id') || ''; 103 var _activeWidgetId = widgetId; // tracks the surface being submitted (updated per page-surface click) 104 // Context of the trigger that opened the modal (e.g. "help:api-docs"). The 105 // modal is shared across placements, so the trigger must hand its context over 106 // or the submission loses its attribution. 107 var _activeContext = ''; 108 var _surfaceConfigCache = {}; // widgetId â fetched config overrides (populated lazily) 109 // Track which color attributes were explicitly set in the embed tag. 110 // Explicit embed-tag values take priority over server config â this lets 111 // programmatic embeds (like Annsa's own app) pin the brand color without 112 // being overridden by whatever is stored in the DB. 113 var _explicitPrimaryColor = currentScript.getAttribute('data-primary-color'); 114 var _explicitTextColor = currentScript.getAttribute('data-text-color'); 115 var isHeadless = currentScript.getAttribute('data-headless') === 'true'; 116 var triggerSelector = currentScript.getAttribute('data-trigger-selector'); 117 var _cspNonce = currentScript.getAttribute('data-csp-nonce') || ''; 118 119 var defaultConfig = { 120 // Darkened from #A8667A so white button text clears WCAG AA (4.86:1 vs 4.33:1). 121 primaryColor: '#9F5E72',
122 textColor: '#FFFFFF', 123 position: 'bottom-right', 124 theme: 'auto', 125 buttonText: 'Feedback', 126 borderRadius: '6px', 127 fontFamily: 'system', 128 showBranding: true, 129 thankYouMessage: 'Thanks for your feedback.', 130 title: 'Help us improve', 131 placeholder: 'What can we do better?', 132 showTypeSelector: false, 133 showRating: false, 134 showWrittenFeedback: true, 135 captureUrl: true, 136 emojiStyle: 'icons-5', 137 supportUrl: null, 138 docsUrl: null, 139 // Survey templates â 'open' = default 5-emoji sentiment; 140 // 'nps' = 0â10 numeric grid, 'ces' = 1â5 effort, 'pmf' = 4-option PMF. 141 surveyTemplate: 'open', 142 scaleLowLabel: null, 143 scaleHighLabel: null, 144 followUpQuestion: null, 145 // Frequency / cadence rules (V1, localStorage-enforced). Absent or null 146 // means "no gating" â render whenever the surface is requested. 147 triggerRules: null 148 }; 149 150 var _VALID_TEMPLATES = ['open', 'nps', 'ces', 'pmf']; 151 152 // ========================================================================= 153 // UTILITIES 154 // ========================================================================= 155 156 function getThemeColors(theme) { 157 var effectiveTheme = theme === 'auto' 158 ? ((document.documentElement.classList.contains('dark') || (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)) ? 'dark' : 'light') 159 : theme; 160 161 // `placeholder` also drives scale endpoint labels + branding, so it must clear 162 // WCAG AA 4.5:1 on the card bg â solid warm-greys (dark 5.6:1, light 5.2:1) 163 // replace the old ~3â3.9:1 alpha values. 164 if (effectiveTheme === 'dark') { 165 return { bg: '#312F2D', text: '#E8E6E3', inputBg: '#2A2826', border: 'rgba(232,230,227,0.15)', placeholder: '#A89F94', hover: 'rgba(232,230,227,0.05)' }; 166 } 167 return { bg: '#FFFFFF', text: '#1C1A18', inputBg: '#F5F3F0', border: 'rgba(138,122,106,0.2)', placeholder: '#7A6A5A', hover: 'rgba(28,26,24,0.03)' }; 168 } 169 170 function createEl(tag, attrs, children) { 171 var el = document.createElement(tag); 172 if (attrs) { 173 Object.keys(attrs).forEach(function(key) { 174 if (key === 'className') el.className = attrs[key]; 175 else if (key === 'textContent') el.textContent = attrs[key]; 176 else if (key === 'innerHTML') el.innerHTML = attrs[key]; 177 else if (key.indexOf('on') === 0) el.addEventListener(key.slice(2).toLowerCase(), attrs[key]); 178 else if (key === 'style' && typeof attrs[key] === 'object') Object.assign(el.style, attrs[key]); 179 else el.setAttribute(key, attrs[key]); 180 }); 181 } 182 if (children) { 183 children.forEach(function(child) { 184 if (typeof child === 'string') el.appendChild(document.createTextNode(child)); 185 else if (child) el.appendChild(child); 186 }); 187 } 188 return el; 189 } 190 191 // Validate CSS color values to prevent injection via server config 192 function sanitizeColor(val) { 193 if (!val || typeof val !== 'string') return null; 194 if (/^#([0-9a-fA-F]{3,8})$/.test(val)) return val; 195 if (/^(rgb|hsl)a?\(\s*[\d.,\s%]+\)$/.test(val)) return val; 196 if (/^[a-zA-Z]{3,20}$/.test(val)) return val; 197 return null; 198 } 199 200 // Validate CSS dimension values to prevent injection 201 function sanitizeDimension(val) { 202 if (!val || typeof val !== 'string') return null; 203 if (/^\d+(px|em|rem|%)$/.test(val)) return val; 204 return null; 205 } 206 207 // Defer non-critical work until the browser is idle 208 function idleCallback(fn) { 209 if (window.requestIdleCallback) { 210 requestIdleCallback(fn, { timeout: 2000 }); 211 } else { 212 setTimeout(fn, 100); 213 } 214 } 215 216 // âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 217 // FREQUENCY / CADENCE â localStorage-enforced per-browser. V1 stays simple: 218 // each surface has its own per-widget state key; an aggregate "last shown 219 // anywhere" key gates the cross-survey quiet period. If user_metadata.signup_date 220 // is provided, the min_days_since_signup gate kicks in; otherwise it's skipped. 221 // Open-feedback surfaces (no trigger_rules) are unaffected. 222 // âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 223 224 var _SURFACE_STATE_PREFIX = 'circuit_surface_'; 225 var _CROSS_SURVEY_KEY = 'circuit_surface_last_shown'; 226 227 function readSurfaceState(widgetId) { 228 try { 229 var raw = localStorage.getItem(_SURFACE_STATE_PREFIX + widgetId); 230 if (!raw) return null; 231 return JSON.parse(raw); 232 } catch (e) { 233 return null; 234 } 235 } 236 237 function writeSurfaceState(widgetId, state) { 238 try { 239 localStorage.setItem(_SURFACE_STATE_PREFIX + widgetId, JSON.stringify(state)); 240 } catch (e) { 241 // Quota or disabled storage â fail quiet. The frequency gate degrades 242 // to "always eligible" in that case, which is the right failure mode. 243 } 244 } 245 246 function daysBetween(isoA, isoB) { 247 if (!isoA || !isoB) return Infinity; 248 var diffMs = new Date(isoB).getTime() - new Date(isoA).getTime(); 249 return diffMs / (1000 * 60 * 60 * 24); 250 } 251 252 // Returns { eligible: bool, reason?: string }. Caller hides the surface when 253 // eligible is false. Reasons are returned so they can be logged in DevTools 254 // for customer debugging â never block on absence of state. 255 function checkEligibility(triggerRules, widgetId, signupDate) { 256 if (!triggerRules || typeof triggerRules !== 'object') return { eligible: true }; 257 var now = new Date().toISOString(); 258 259 if (triggerRules.min_days_since_signup > 0 && signupDate) { 260 if (daysBetween(signupDate, now) < triggerRules.min_days_since_signup) { 261 return { eligible: false, reason: 'min_days_since_signup' }; 262 } 263 } 264 265 var state = readSurfaceState(widgetId); 266 267 if ( 268 state && 269 triggerRules.max_lifetime_asks > 0 && 270 (state.ask_count || 0) >= triggerRules.max_lifetime_asks 271 ) { 272 return { eligible: false, reason: 'max_lifetime_asks' }; 273 } 274 275 if (state) { 276 // Pick the most-recent event and apply its cadence. Order: response > 277 // dismissal > impression. If none, eligible (first-time ask). 278 var lastResponded = state.last_responded_at; 279 var lastDismissed = state.last_dismissed_at; 280 var lastAsked = state.last_asked_at; 281 var mostRecent = null; 282 var mostRecentKind = null; 283 [ 284 ['response', lastResponded], 285 ['dismissal', lastDismissed], 286 ['impression', lastAsked], 287 ].forEach(function (pair) { 288 if (pair[1] && (mostRecent === null || pair[1] > mostRecent)) { 289 mostRecent = pair[1]; 290 mostRecentKind = pair[0]; 291 } 292 }); 293 294 if (mostRecent && mostRecentKind) { 295 var cadence = 296 mostRecentKind === 'response' 297 ? triggerRules.cadence_after_response_days 298 : mostRecentKind === 'dismissal' 299 ? triggerRules.cadence_after_dismissal_days 300 : triggerRules.cadence_after_impression_days; 301 if (cadence > 0 && daysBetween(mostRecent, now) < cadence) { 302 return { eligible: false, reason: 'cadence_after_' + mostRecentKind }; 303 } 304 } 305 } 306
307 // Cross-survey quiet period â check the global last-shown timestamp. 308 if (triggerRules.cross_survey_quiet_days > 0) { 309 try { 310 var lastAny = localStorage.getItem(_CROSS_SURVEY_KEY); 311 if (lastAny && daysBetween(lastAny, now) < triggerRules.cross_survey_quiet_days) { 312 return { eligible: false, reason: 'cross_survey_quiet' }; 313 } 314 } catch (e) {} 315 } 316 317 return { eligible: true }; 318 } 319 320 function recordImpression(widgetId) { 321 var now = new Date().toISOString(); 322 var state = readSurfaceState(widgetId) || { 323 first_seen_at: now, 324 ask_count: 0, 325 response_count: 0, 326 dismissal_count: 0, 327 }; 328 state.last_asked_at = now; 329 state.ask_count = (state.ask_count || 0) + 1; 330 writeSurfaceState(widgetId, state); 331 try { 332 localStorage.setItem(_CROSS_SURVEY_KEY, now); 333 } catch (e) {} 334 } 335 336 function recordDismissal(widgetId) { 337 var now = new Date().toISOString(); 338 var state = readSurfaceState(widgetId) || { ask_count: 0, response_count: 0, dism
338issal_count: 0 }; 339 state.last_dismissed_at = now; 340 state.dismissal_count = (state.dismissal_count || 0) + 1; 341 writeSurfaceState(widgetId, state); 342 } 343 344 function recordResponse(widgetId) { 345 var now = new Date().toISOString(); 346 var state = readSurfaceState(widgetId) || { ask_count: 0, response_count: 0, dismissal_count: 0 }; 347 state.last_responded_at = now; 348 state.response_count = (state.response_count || 0) + 1; 349 writeSurfaceState(widgetId, state); 350 } 351 352 // Safe sessionStorage wrapper (private/incognito can throw) 353 function safeStorage(method, key, val) { 354 try { 355 if (method === 'get') return sessionStorage.getItem(key); 356 if (method === 'set') return sessionStorage.setItem(key, val); 357 } catch (e) { 358 return null; 359 } 360 } 361 362 // ========================================================================= 363 // OFFLINE QUEUE (localStorage-backed, drains on reconnect) 364 // ========================================================================= 365 366 var OFFLINE_KEY = 'circuit_offline_queue'; 367 368 function _scrubQueueBody(body) { 369 // Build a fresh object with only allowlisted, non-sensitive fields. 370 // Returns null when the body can't be parsed so the caller skips queueing. 371 // 372 // Each field is copied by an explicit static assignment rather than a loop 373 // over a name list. That is deliberate: a dynamic `safe[field] = parsed[field]` 374 // is opaque to static analysis, which then has to assume every property of 375 // `parsed` (including api_key) reaches localStorage â CodeQL flagged exactly 376 // that as js/clear-text-storage-of-sensitive-data. Spelling the fields out 377 // keeps the allowlist verifiable by a reader and by the analyser. 378 // 379 // Deliberately omitted: api_key (a credential â re-attached at drain time 380 // from the in-scope apiKey) and email / user_metadata (PII â never written 381 // to disk). Anything not assigned below is dropped before storage. 382 try { 383 var parsed = JSON.parse(body); 384 var safe = {}; 385 if (parsed.surface !== undefined) safe.surface = parsed.surface; 386 if (parsed.widget_id !== undefined) safe.widget_id = parsed.widget_id; 387 if (parsed.page_url !== undefined) safe.page_url = parsed.page_url; 388 if (parsed.text !== undefined) safe.text = parsed.text; 389 if (parsed.rating !== undefined) safe.rating = parsed.rating; 390 if (parsed.context !== undefined) safe.context = parsed.context; 391 if (parsed.feedback_type !== undefined) safe.feedback_type = parsed.feedback_type; 392 return JSON.stringify(safe); 393 } catch (e) { return null; } 394 } 395 396 function queueOffline(endpoint, body) { 397 try { 398 var scrubbed = _scrubQueueBody(body); 399 if (scrubbed === null) return; // unparseable â nothing safe to persist 400 var queue = JSON.parse(localStorage.getItem(OFFLINE_KEY) || '[]'); 401 if (queue.length >= 50) queue.shift(); // Cap at 50 entries 402 queue.push({ url: endpoint, body: scrubbed, ts: Date.now() }); 403 localStorage.setItem(OFFLINE_KEY, JSON.stringify(queue)); 404 } catch (e) { /* localStorage unavailable or full */ } 405 } 406 407 function drainOfflineQueue() { 408 try { 409 var raw = localStorage.getItem(OFFLINE_KEY); 410 if (!raw) return; 411 localStorage.removeItem(OFFLINE_KEY); 412 var queue = JSON.parse(raw); 413 var cutoff = Date.now() - 86400000; // 24h max age 414 queue.forEach(function(item) { 415 if (item.ts < cutoff) return; // Too old, discard 416 // Re-attach the api_key (kept out of localStorage) before replaying. 417 var sendBody; 418 try { 419 var parsed = JSON.parse(item.body); 420 parsed.api_key = apiKey; 421 sendBody = JSON.stringify(parsed); 422 } catch (e) { return; } // unparseable â drop it 423 fetch(item.url, { 424 method: 'POST', 425 headers: { 'Content-Type': 'application/json' }, 426 body: sendBody 427 }).then(function(resp) { 428 if (resp.ok) { 429 try { window.dispatchEvent(new CustomEvent('circuitFeedbackSent', { detail: { queued: true } })); } catch(e) {} 430 } else { 431 // Re-queue item.body, not sendBody: item.body is the already-scrubbed 432 // form with no api_key, so the credential is never written back to disk. 433 queueOffline(item.url, item.body); // re-queue on server error 434 } 435 }).catch(function() { 436 queueOffline(item.url, item.body); 437 }); 438 }); 439 } catch (e) { /* localStorage unavailable */ } 440 } 441 442 // ========================================================================= 443 // SHIPPED NOTIFICATIONS (localStorage-backed polling) 444 // ========================================================================= 445 446 var FEEDBACK_IDS_KEY = 'circuit_feedback_ids'; 447 var NOTIF_THROTTLE_KEY = 'circuit_notif_poll_ts'; 448 var NOTIF_POLL_INTERVAL = 60000; // 60s minimum between polls 449 450 function storeFeedbackId(feedbackId) { 451 try { 452 var ids = JSON.parse(localStorage.getItem(FEEDBACK_IDS_KEY) || '[]'); 453 if (ids.indexOf(feedbackId) === -1) { 454 ids.push(feedbackId); 455 // Keep max 100 IDs (oldest first) 456 if (ids.length > 100) ids = ids.slice(-100); 457 localStorage.setItem(FEEDBACK_IDS_KEY, JSON.stringify(ids)); 458 } 459 } catch (e) { /* localStorage unavailable */ } 460 } 461 462 function getStoredFeedbackIds() { 463 try { 464 return JSON.parse(localStorage.getItem(FEEDBACK_IDS_KEY) || '[]'); 465 } catch (e) { return []; } 466 } 467 468 function shouldPollNotifications() { 469 try { 470 if (!localStorage.getItem(FEEDBACK_IDS_KEY)) return false; // fast path â skip JSON.parse on first-time visitors 471 var ids = getStoredFeedbackIds(); 472 if (ids.length === 0) return false;
473 // Throttle: don't poll more than once per minute (sessionStorage) 474 var lastPoll = parseInt(sessionStorage.getItem(NOTIF_THROTTLE_KEY) || '0', 10); 475 if (Date.now() - lastPoll < NOTIF_POLL_INTERVAL) return false; 476 return true; 477 } catch (e) { return false; } 478 } 479 480 function pollNotifications(config, appendRoot) { 481 if (!shouldPollNotifications()) return; 482 483 try { 484 sessionStorage.setItem(NOTIF_THROTTLE_KEY, String(Date.now())); 485 } catch (e) { /* sessionStorage unavailable */ } 486 487 var feedbackIds = getStoredFeedbackIds(); 488 var userMeta = (window.AnnsaSurveyConfig && window.AnnsaSurveyConfig.user) || {}; 489 var email = userMeta.email || ''; 490 491 var params = 'api_key=' + encodeURIComponent(config.apiKey); 492 if (email) params += '&email=' + encodeURIComponent(email); 493 if (feedbackIds.length > 0) params += '&feedback_ids=' + encodeURIComponent(feedbackIds.join(',')); 494 495 fetch(API_BASE + '/surface/notifications?' + params, { 496 method: 'GET', 497 headers: { 'Accept': 'application/json' } 498 }).then(function(response) { 499 if (!response.ok) return; 500 return response.json(); 501 }).then(function(data) { 502 if (!data || !data.notifications || data.notifications.length === 0) return; 503 showNotificationBanner(config, data.notifications, appendRoot); 504 }).catch(function() { 505 // Silently fail â notifications are non-critical 506 }); 507 } 508 509 function showNotificationBanner(config, notifications, appendRoot) { 510 // Show the first notification (most recent) 511 var notif = notifications[0]; 512 var accentColor = config.primaryColor || '#A8667A'; 513 514 var banner = document.createElement('div'); 515 banner.className = 'circuit-notif-banner'; 516 banner.setAttribute('role', 'status'); 517 banner.setAttribute('aria-live', 'polite'); 518 banner.innerHTML = 519 '<div class="circuit-notif-icon" style="background-color:' + accentColor + '">' + 520 '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M9 12l2 2 4-4"/></svg>' + 521 '</div>' + 522 '<div class="circuit-notif-body">' + 523 '<div class="circuit-notif-title">We shipped it!</div>' + 524 '<div class="circuit-notif-text">' + escapeHtml(notif.priority_title || 'Your feedback led to a shipped feature') + '</div>' + 525 (notif.message ? '<div class="circuit-notif-msg">' + escapeHtml(notif.message) + '</div>' : '') + 526 '</div>' + 527 '<button class="circuit-notif-close" aria-label="Dismiss">×</button>'; 528 529 // Close handler 530 banner.querySelector('.circuit-notif-close').addEventListener('click', function() { 531 banner.classList.add('circuit-notif-exit'); 532 setTimeout(function() { 533 if (banner.parentNode) banner.parentNode.removeChild(banner); 534 }, 300); 535 // Acknowledge all notifications 536 acknowledgeNotifications(config, notifications); 537 }); 538 539 appendRoot.appendChild(banner); 540 541 // Auto-dismiss after 15 seconds 542 setTimeout(function() { 543 if (banner.parentNode) { 544 banner.classList.add('circuit-notif-exit'); 545 setTimeout(function() { 546 if (banner.parentNode) banner.parentNode.removeChild(banner); 547 }, 300); 548 acknowledgeNotifications(config, notifications); 549 } 550 }, 15000); 551 } 552 553 function acknowledgeNotifications(config, notifications) { 554 var ids = notifications.map(function(n) { return n.id; }); 555 fetch(API_BASE + '/surface/notifications/ack', { 556 method: 'POST', 557 headers: { 'Content-Type': 'application/json' }, 558 body: JSON.stringify({ api_key: config.apiKey, notification_ids: ids }) 559 }).catch(function() { 560 // Best-effort acknowledgement 561 }); 562 } 563 564 function escapeHtml(str) { 565 var div = document.createElement('div'); 566 div.appendChild(document.createTextNode(str)); 567 return div.innerHTML; 568 } 569 570 // Hide all rendered surfaces â called when config fetch returns 401/403 571 function _hideAllSurfaces() { 572 if (_shadowHost) _shadowHost.style.display = 'none'; 573 _inlineContainers.forEach(function(item) { 574 if (item.container) item.container.style.display = 'none'; 575 }); 576 } 577 578 // ========================================================================= 579 // SHADOW DOM + STYLE MANAGEMENT 580 // ========================================================================= 581 582 var _shadowHost = null; 583 var _shadowRoot = null; 584 var _styleRoots = []; // Roots that need style injection (shadow roots + document.head) 585 var _inlineContainers = []; // Track inline/thumbs containers for cleanup 586 var _onlineHandler = null; // Online event listener ref for cleanup 587 var _themeDebounceTimer = null; // Debounce timer for MutationObserver theme re-injection 588 var _widgetDisabled = false; // Set true on 401/403 config response â prevents rendering/submission
589 var _themeObserver = null; // MutationObserver ref for cleanup in destroy() 590 var _pageObserver = null; // MutationObserver watching for late [data-circuit-page] elements (SPA support) 591 var _inlineObserver = null; // MutationObserver watching for late [data-circuit-inline] elements (SPA support) 592 var _thumbsObserver = null; // MutationObserver watching for late [data-circuit-thumbs] elements (SPA support) 593 var _attachedPageTargets = typeof WeakSet !== 'undefined' ? new WeakSet() : null; // prevents double-attach 594 var _escHandler = null; // Escape key listener ref for cleanup in destroy() 595 var _cfgController = null; // AbortController for background config fetch (hoisted for destroy()) 596 var _triggerEl = null; // Element bound by data-trigger-selector (cleanup in destroy()) 597 var _triggerHandler = null; // Click handler for trigger selector (cleanup in destroy()) 598 599 // Append root: shadow DOM for float/bar/page, document.body fallback 600 function getAppendRoot() { 601 return _shadowRoot || document.body; 602 } 603 604 // ========================================================================= 605 // FONT HELPERS 606 // ========================================================================= 607 608 var FONT_FAMILIES = { 609 'system': '-apple-system,BlinkMacSystemFont,"SF Pro Display",system-ui,"Segoe UI",Roboto,sans-serif', 610 'inter': '"Inter",system-ui,sans-serif', 611 'roboto': '"Roboto",system-ui,sans-serif', 612 'opensans': '"Open Sans",system-ui,sans-serif', 613 'poppins': '"Poppins",system-ui,sans-serif' 614 }; 615 616 function resolveFontFamily(fontFamily) { 617 return FONT_FAMILIES[fontFamily] || FONT_FAMILIES['system']; 618 } 619 620 function injectFontFace(fontFamily) { 621 if (!fontFamily || fontFamily === 'system') return; 622 var fontMap = { 623 'inter': 'Inter:wght@400;500;600', 624 'roboto': 'Roboto:wght@400;500;600', 625 'opensans': 'Open+Sans:wght@400;500;600', 626 'poppins': 'Poppins:wght@400;500;600' 627 }; 628 var fontParam = fontMap[fontFamily]; 629 if (!fontParam) return; 630 if (document.getElementById('circuit-font-' + fontFamily)) return; 631 var link = document.createElement('link'); 632 link.id = 'circuit-font-' + fontFamily; 633 link.rel = 'stylesheet'; 634 link.href = 'https://fonts.googleapis.com/css2?family=' + fontParam + '&display=swap'; 635 document.head.appendChild(link); 636 } 637 638 // ========================================================================= 639 // STYLES 640 // ========================================================================= 641 642 function buildCSS(config) { 643 var colors = getThemeColors(config.theme); 644 var pos = parsePosition(config.position); 645 var reducedMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; 646 var transition = reducedMotion ? 'none' : 'background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease, opacity 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease'; 647 // Invert button colors in dark mode (matches Annsa's primary button pattern) 648 var isDark = colors.bg === '#312F2D'; 649 var btnBg = isDark ? '#E8E6E3' : config.primaryColor; 650 var btnText = isDark ? '#1C1A18' : config.textColor; 651 652 var isBar = surfaceType === 'bar'; 653 var isInline = surfaceType === 'inline' || !!document.querySelector('[data-circuit-inline]'); 654 var isThumbs = surfaceType === 'thumbs' || !!document.querySelector('[data-circuit-thumbs]'); 655 // Every placement is a trigger for the same modal, so every placement needs 656 // the modal CSS. The FAB rules ride along unused when no FAB is created -- 657 // exactly as the page surface has always done. 658 var isFloat = 659 surfaceType === 'float' || surfaceType === 'page' || isHeadless || isInline || isBar; 660 661 var css = []; 662 663 // Float / page / headless: FAB, overlay, modal, form, type selectors, escape, branding 664 if (isFloat) { 665 css.push( 666 // FAB button 667 '.circuit-fab{position:fixed;' + pos.h + ':24px;' + pos.v + ':24px;background:' + btnBg + ';color:' + btnText + ';border:none;border-radius:50%;width:56px;height:56px;padding:0;display:flex;align-items:center;justify-content:center;cursor:pointer;box-shadow:0 4px 14px rgba(0,0,0,0.15);transition:' + transition + ';z-index:999999;font-family:inherit}',
668 '.circuit-fab:hover{transform:scale(1.05);box-shadow:0 6px 20px rgba(0,0,0,0.2)}', 669 '.circuit-fab:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}', 670 '.circuit-fab svg{width:24px;height:24px;stroke:currentColor;stroke-width:2;fill:none}', 671 '.circuit-fab--text{border-radius:28px;width:auto;height:48px;padding:0 20px 0 14px;gap:8px;font-size:14px;font-weight:600;font-family:' + resolveFontFamily(config.fontFamily) + '}', 672 '.circuit-fab--text svg{width:20px;height:20px}', 673 // Overlay + Modal 674 '.circuit-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:1000000;opacity:0;transition:opacity 0.15s ease;pointer-events:none}', 675 '.circuit-overlay.active{opacity:1;pointer-events:all}', 676 '.circuit-modal{position:fixed;' + pos.h + ':24px;' + pos.v + ':96px;background:' + colors.bg + ';border:1px solid ' + colors.border + ';border-radius:' + config.borderRadius + ';padding:24px;width:360px;max-width:calc(100vw - 48px);box-shadow:0 25px 50px -12px rgba(0,0,0,0.25);transform:scale(0.96) translateY(8px);opacity:0;transition:transform 0.2s ease, opacity 0.2s ease;font-family:' + resolveFontFamily(config.fontFamily) + ';z-index:1000001}', 677 '.circuit-overlay.active .circuit-modal{transform:scale(1) translateY(0);opacity:1}', 678 // Header 679 '.circuit-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:16px}', 680 '.circuit-title{font-size:17px;font-weight:600;color:' + colors.text + ';margin:0}', 681 '.circuit-close{background:none;border:none;font-size:20px;cursor:pointer;color:' + colors.text + ';opacity:0.4;padding:0;width:44px;height:44px;min-width:44px;min-height:44px;display:flex;align-items:center;justify-content:center;border-radius:6px;transition:' + transition + '}', 682 '.circuit-close:hover{opacity:1;background:' + colors.hover + '}', 683 '.circuit-close:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}', 684 // Sentiment row (SVG icons) 685 '.circuit-sentiment{display:flex;gap:4px;justify-content:center;margin-bottom:16px}', 686 '.circuit-sentiment-btn{background:none;border:2px solid transparent;border-radius:50%;padding:6px;cursor:pointer;transition:' + transition + ';width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:' + colors.text + '}', 687 '.circuit-sentiment-btn:hover{background:' + colors.hover + '}', 688 '.circuit-sentiment-btn.selected{border-color:' + btnBg + ';background:' + btnBg + ';color:' + btnText + '}', 689 '.circuit-sentiment-btn:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}', 690 '.circuit-sentiment-btn svg{width:24px;height:24px}', 691 // Form 692 '.circuit-form{display:flex;flex-direction:column;gap:12px}', 693 '.circuit-form.hidden{display:none}', 694 '.circuit-textarea{width:100%;min-height:80px;max-height:300px;padding:12px;border:1px solid ' + colors.border + ';border-radius:6px;font-size:14px;font-family:inherit;resize:none;overflow-y:auto;background:' + colors.inputBg + ';color:' + colors.text + ';box-sizing:border-box;transition:border-color 0.15s ease}', 695 '.circuit-textarea::placeholder{color:' + colors.placeholder + '}', 696 '.circuit-textarea:focus{outline:none;border-color:' + colors.text + '}', 697 '.circuit-input{width:100%;padding:10px 12px;border:1px solid ' + colors.border + ';border-radius:6px;font-size:13px;font-family:inherit;background:' + colors.inputBg + ';color:' + colors.text + ';box-sizing:border-box;transition:border-color 0.15s ease}', 698 '.circuit-input::placeholder{color:' + colors.placeholder + '}', 699 '.circuit-input:focus{outline:none;border-color:' + colors.text + '}', 700 '.circuit-label{font-size:12px;font-weight:500;color:' + colors.placeholder + ';margin-bottom:4px;display:block}', 701 '.circuit-submit{background:' + btnBg + ';color:' + btnText + ';border:none;border-radius:6px;padding:12px 20px;font-size:14px;font-weight:600;cursor:pointer;transition:opacity 0.15s ease;font-family:inherit;min-height:44px}',
702 '.circuit-submit:hover{opacity:0.9}', 703 '.circuit-submit:disabled{opacity:0.5;cursor:not-allowed}', 704 '.circuit-submit:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}', 705 // Messages 706 '.circuit-msg{padding:10px 14px;border-radius:6px;font-size:13px;text-align:center;animation:circuit-in 0.15s ease}', 707 '.circuit-msg.success{background:rgba(28,26,24,0.05);color:#1C1A18}', 708 '.circuit-msg.error{background:rgba(214,69,69,0.08);color:#D4183D}', 709 '@keyframes circuit-in{from{opacity:0}to{opacity:1}}', 710 // Success state card (matches configurator preview: circle checkmark + message) 711 '.circuit-success-state{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:16px;padding:24px 0;text-align:center;animation:circuit-in 0.2s ease}', 712 '.circuit-success-icon{width:48px;height:48px;border-radius:50%;background:' + btnBg + ';display:flex;align-items:center;justify-content:center;flex-shrink:0}', 713 '.circuit-success-msg{font-size:14px;color:' + colors.text + ';margin:0;line-height:1.5}', 714 // Type selector (pill) 715 '.circuit-types{display:flex;gap:6px;flex-wrap:wrap}', 716 '.circuit-type-btn{padding:6px 14px;border:1px solid ' + colors.border + ';border-radius:6px;font-size:12px;cursor:pointer;background:none;color:' + colors.text + ';transition:' + transition + ';font-family:inherit}', 717 '.circuit-type-btn.selected{background:' + btnBg + ';color:' + btnText + ';border-color:' + btnBg + '}', 718 '.circuit-type-btn:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}', 719 // Type selector (icon-based) 720 '.circuit-type-icons{display:grid;grid-template-columns:repeat(4,1fr);gap:6px}', 721 '.circuit-type-icon-btn{display:flex;flex-direction:column;align-items:center;gap:5px;padding:10px 4px;border:1px solid ' + colors.border + ';border-radius:6px;cursor:pointer;background:none;color:' + colors.text + ';font-family:inherit;font-size:11px;font-weight:500;line-height:1.2;transition:' + transition + '}', 722 '.circuit-type-icon-btn:hover{background:' + colors.hover + '}', 723 '.circuit-type-icon-btn.selected{border-color:' + btnBg + ';background:' + btnBg + ';color:' + btnText + '}', 724 '.circuit-type-icon-btn:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}', 725 '.circuit-type-icon-btn svg{width:18px;height:18px;flex-shrink:0}', 726 '@media(max-width:360px){.circuit-type-icons{gap:4px}.circuit-type-icon-btn{padding:8px 2px;font-size:10px}}', 727 // Escape hatch 728 '.circuit-escape{margin-top:10px;padding-top:10px;border-top:1px solid ' + colors.border + ';text-align:center;font-size:12px;color:' + colors.placeholder + ';line-height:1.6}', 729 '.circuit-escape a{color:' + config.primaryColor + ';text-decoration:none}', 730 '.circuit-escape a:hover{text-decoration:underline}', 731 // Branding 732 '.circuit-branding{text-align:center;margin-top:12px;padding-top:12px;border-top:1px solid ' + colors.border + '}', 733 '.circuit-branding a{font-size:11px;color:' + colors.placeholder + ';text-decoration:none}', 734 '.circuit-branding a:hover{color:' + config.primaryColor + '}', 735 // Honeypot 736 '.circuit-hp{position:absolute;left:-9999px;width:1px;height:1px;opacity:0}', 737 // Mobile 738 '@media(max-width:480px){.circuit-modal{width:calc(100vw - 32px);' + pos.h + ':16px;' + pos.v + ':16px;padding:20px}.circuit-fab{width:52px;height:52px;' + pos.h + ':16px;' + pos.v + ':16px}}', 739 // Safe-area insets 740 (pos.v === 'bottom' ? '@supports(padding:max(0px)){.circuit-fab{' + pos.v + ':max(24px,calc(env(safe-area-inset-bottom,0px) + 16px))}.circuit-modal{' + pos.v + ':max(96px,calc(env(safe-area-inset-bottom,0px) + 84px))}}' : '') 741 ); 742 } 743 744 // Bar surface 745 if (isBar) { 746 css.push( 747 // Sentiment row (also used inside bar's expanded form) 748 '.circuit-sentiment{display:flex;gap:4px;justify-content:center;margin-bottom:16px}',
749 '.circuit-sentiment-btn{background:none;border:2px solid transparent;border-radius:50%;padding:6px;cursor:pointer;transition:' + transition + ';width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:' + colors.text + '}', 750 '.circuit-sentiment-btn:hover{background:' + colors.hover + '}', 751 '.circuit-sentiment-btn.selected{border-color:' + btnBg + ';background:' + btnBg + ';color:' + btnText + '}', 752 '.circuit-sentiment-btn:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}', 753 '.circuit-sentiment-btn svg{width:24px;height:24px}', 754 // Bar 755 '.circuit-bar{position:fixed;left:0;right:0;z-index:999998;background:' + colors.bg + ';border-' + (pos.v === 'top' ? 'bottom' : 'top') + ':1px solid ' + colors.border + ';padding:12px 24px;display:flex;align-items:center;justify-content:center;gap:16px;font-family:' + resolveFontFamily(config.fontFamily) + ';box-shadow:0 2px 8px rgba(0,0,0,0.08);transform:translateY(' + (pos.v === 'top' ? '-100%' : '100%') + ');transition:transform 0.2s ease}', 756 '.circuit-bar.' + pos.v + '{' + pos.v + ':0}', 757 '.circuit-bar.visible{transform:translateY(0)}', 758 '.circuit-bar-prompt{font-size:14px;color:' + colors.text + ';font-weight:500;white-space:nowrap}', 759 '.circuit-bar-actions{display:flex;align-items:center;gap:8px}', 760 '.circuit-bar-dismiss{background:none;border:none;color:' + colors.placeholder + ';cursor:pointer;padding:4px 8px;font-size:13px;border-radius:6px;transition:' + transition + ';min-width:44px;min-height:44px;display:flex;align-items:center;justify-content:center}', 761 '.circuit-bar-dismiss:hover{color:' + colors.text + ';background:' + colors.hover + '}', 762 // The banner is a trigger: this CTA opens the modal. 763 '.circuit-bar-cta{background:' + btnBg + ';color:' + btnText + ';border:none;border-radius:6px;padding:8px 16px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;white-space:nowrap;transition:' + transition + '}', 764 '.circuit-bar-cta:hover{background:' + colors.hover + ';color:' + colors.text + '}', 765 '.circuit-bar-cta:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}', 766 // Messages 767 '.circuit-msg{padding:10px 14px;border-radius:6px;font-size:13px;text-align:center;animation:circuit-in 0.15s ease}', 768 '.circuit-msg.success{background:rgba(28,26,24,0.05);color:#1C1A18}', 769 '.circuit-msg.error{background:rgba(214,69,69,0.08);color:#D4183D}', 770 '@keyframes circuit-in{from{opacity:0}to{opacity:1}}', 771 // Mobile 772 '@media(max-width:480px){.circuit-bar{padding:10px 16px;flex-wrap:wrap;gap:10px}}', 773 (pos.v === 'bottom' ? '@supports(padding:max(0px)){.circuit-bar{padding-bottom:max(12px,env(safe-area-inset-bottom,0px))}}' : '') 774 ); 775 } 776 777 // Inline surface â an embedded trigger, not a self-contained widget 778 if (isInline) { 779 css.push( 780 '.circuit-inline{display:inline-flex;align-items:center;gap:10px;font-family:' + resolveFontFamily(config.fontFamily) + '}', 781 '.circuit-inline-row{display:flex;align-items:center;gap:10px}', 782 '.circuit-inline-prompt{font-size:13px;color:' + colors.placeholder + ';font-weight:500}', 783 '.circuit-inline-trigger{background:' + btnBg + ';color:' + btnText + ';border:none;border-radius:6px;padding:8px 14px;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;white-space:nowrap;transition:' + transition + '}', 784 '.circuit-inline-trigger:hover{background:' + colors.hover + ';color:' + colors.text + '}', 785 '.circuit-inline-trigger:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}' 786 ); 787 } 788 789 // Thumbs surface 790 if (isThumbs) { 791 css.push( 792 // Owned by thumbs, not inline: a thumbs-only page never sets isInline. 793 '.circuit-inline-thanks{font-size:13px;color:' + config.primaryColor + ';font-weight:500}', 794 '.circuit-thumbs{display:inline-flex;align-items:center;flex-wrap:wrap;gap:6px;
794font-family:' + resolveFontFamily(config.fontFamily) + '}', 795 '.circuit-thumb-btn{background:none;border:1px solid ' + colors.border + ';border-radius:6px;padding:6px 10px;cursor:pointer;transition:' + transition + ';width:44px;height:44px;display:flex;align-items:center;justify-content:center;color:' + colors.text + '}', 796 '.circuit-thumb-btn svg{width:20px;height:20px}', 797 '.circuit-thumb-btn:hover{background:' + colors.hover + ';border-color:' + config.primaryColor + '60}', 798 '.circuit-thumb-btn.selected{border-color:' + btnBg + ';background:' + btnBg + ';color:' + btnText + '}', 799 '.circuit-thumb-btn:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}', 800 '.circuit-thumbs-followup{display:flex;gap:6px;align-items:center;margin-top:6px}', 801 '.circuit-thumbs-input{flex:1;padding:6px 10px;border:1px solid ' + colors.border + ';border-radius:6px;font-size:12px;font-family:inherit;background:' + colors.inputBg + ';color:' + colors.text + ';box-sizing:border-box;min-width:180px}', 802 '.circuit-thumbs-send{background:' + btnBg + ';color:' + btnText + ';border:none;border-radius:6px;padding:6px 12px;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit}', 803 '.circuit-thumbs-skip{background:none;border:none;padding:6px 4px;font-size:12px;color:' + colors.placeholder + ';cursor:pointer;font-family:inherit;text-underline-offset:3px}', 804 '.circuit-thumbs-skip:hover{text-decoration:underline;color:' + colors.text + '}', 805 '.circuit-thumbs-skip:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}', 806 '@media(max-width:480px){.circuit-thumbs-input{min-width:120px}}' 807 ); 808 } 809 810 // Always: survey scale rows (NPS / CES / PMF) â used by createScaleRow. 811 css.push( 812 // margin-bottom only inside the modal; in bar/inline the surface gap handles 813 // spacing so the scale doesn't sit mis-centred in a horizontal strip (a11y #5). 814 '.circuit-scale{display:flex;flex-direction:column;gap:8px}', 815 '.circuit-modal .circuit-scale{margin-bottom:16px}', 816 // Scales stay on ONE row â a 0â10 / 1â5 scale wrapped across two rows is not a 817 // valid instrument. Instruments are only offered where their scale fits one 818 // accessible row (NPS = modal only); see survey-instruments.ts. 819 '.circuit-scale-buttons{display:flex;gap:4px;width:100%}', 820 // NPS/CES buttons keep a >=24px WCAG 2.2 target + padding (a11y #4/#12). 821 '.circuit-scale-buttons--nps{gap:2px}', 822 '.circuit-scale-buttons--nps .circuit-scale-btn{flex:1 1 24px;min-width:24px;padding:0 2px;height:36px;font-size:13px}', 823 '.circuit-scale-buttons--ces .circuit-scale-btn{flex:1 1 40px;min-width:40px;height:40px;font-size:14px}', 824 '.circuit-scale-btn{display:flex;align-items:center;justify-content:center;border:1px solid ' + colors.border + ';border-radius:6px;background:none;color:' + colors.text + ';font-family:inherit;font-weight:500;cursor:pointer;font-variant-numeric:tabular-nums;transition:' + transition + '}', 825 '.circuit-scale-btn:hover{background:' + colors.hover + '}', 826 // selected number uses the readable text colour, not the low-contrast accent (a11y #10). 827 '.circuit-scale-btn.selected{border-color:' + btnBg + ';background:' + btnBg + ';color:' + btnText + '}', 828 '.circuit-scale-btn:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}', 829 '.circuit-scale-labels{display:flex;justify-content:space-between;gap:8px;font-size:11px;color:' + colors.placeholder + ';line-height:1.3;white-space:nowrap}', 830 '.circuit-scale-options{display:flex;flex-direction:column;gap:6px}', 831 '.circuit-scale-option{display:flex;align-items:center;gap:10px;padding:10px 12px;border:1px solid ' + colors.border + ';border-radius:6px;background:none;color:' + colors.text + ';font-family:inherit;font-size:13px;text-align:left;cursor:pointer;transition:' + transition + ';min-height:44px}', 832 '.circuit-scale-option:hover{background:' + colors.hover + '}', 833 '.circuit-scale-option.selected{border-color:' + btnBg + ';background:' + btnBg + ';color:' + btnText + '}',
834 '.circuit-scale-option:focus-visible{outline:2px solid ' + colors.text + ';outline-offset:2px}', 835 '.circuit-scale-radio{width:14px;height:14px;border-radius:50%;border:1.5px solid ' + colors.border + ';flex-shrink:0;transition:' + transition + '}', 836 '.circuit-scale-option.selected .circuit-scale-radio{border-color:' + btnText + ';background:' + btnText + '}', 837 // On narrow screens NPS 0â10 needs smaller buttons or it wraps awkwardly. 838 '@media(max-width:380px){.circuit-scale-buttons--nps .circuit-scale-btn{height:32px;font-size:11px;gap:2px}}' 839 ); 840 841 // Always: notification banner + reduced-motion 842 css.push( 843 '.circuit-notif-banner{position:fixed;' + pos.h + ':24px;' + pos.v + ':' + (surfaceType === 'float' ? '96px' : '24px') + ';background:' + colors.bg + ';border:1px solid ' + colors.border + ';border-radius:6px;padding:16px;width:320px;max-width:calc(100vw - 48px);box-shadow:0 8px 30px rgba(0,0,0,0.12);z-index:999998;display:flex;align-items:flex-start;gap:12px;font-family:' + resolveFontFamily(config.fontFamily) + ';animation:circuit-notif-in 0.3s ease}', 844 '.circuit-notif-exit{animation:circuit-notif-out 0.3s ease forwards}', 845 '@keyframes circuit-notif-in{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}', 846 '@keyframes circuit-notif-out{from{opacity:1;transform:translateY(0)}to{opacity:0;transform:translateY(8px)}}', 847 '.circuit-notif-icon{flex-shrink:0;width:32px;height:32px;border-radius:50%;display:flex;align-items:center;justify-content:center}', 848 '.circuit-notif-body{flex:1;min-width:0}', 849 '.circuit-notif-title{font-size:14px;font-weight:600;color:' + colors.text + ';margin:0 0 4px}', 850 '.circuit-notif-text{font-size:13px;color:' + colors.text + ';opacity:0.8;margin:0;line-height:1.4}', 851 '.circuit-notif-msg{font-size:12px;color:' + colors.placeholder + ';margin:6px 0 0;font-style:italic;line-height:1.4}', 852 '.circuit-notif-close{position:absolute;top:8px;right:8px;background:none;border:none;font-size:18px;cursor:pointer;color:' + colors.text + ';opacity:0.4;padding:0;width:28px;height:28px;display:flex;align-items:center;justify-content:center;border-radius:6px;transition:opacity 0.2s ease}', 853 '.circuit-notif-close:hover{opacity:1}', 854 '@media(prefers-reduced-motion:reduce){.circuit-fab,.circuit-modal,.circuit-overlay,.circuit-bar,.circuit-bar-cta,.circuit-msg,.circuit-sentiment-btn,.circuit-type-btn,.circuit-inline-trigger,.circuit-thumb-btn,.circuit-notif-banner{transition:none!important;animation:none!important}}' 855 ); 856 857 return css.join('\n'); 858 } 859 860 function injectStyles(config) { 861 var css = buildCSS(config); 862 _styleRoots.forEach(function(root) { 863 var el = root.querySelector ? root.querySelector('#circuit-styles') : null; 864 if (!el) { 865 el = document.createElement('style'); 866 el.id = 'circuit-styles'; 867 if (_cspNonce) el.setAttribute('nonce', _cspNonce); 868 root.appendChild(el); 869 } 870 el.textContent = css; 871 }); 872 } 873 874 function parsePosition(position) { 875 return { 876 v: position.indexOf('top') !== -1 ? 'top' : 'bottom', 877 h: position.indexOf('left') !== -1 ? 'left' : 'right' 878 }; 879 } 880 881 // ========================================================================= 882 // SENTIMENT ROW (shared by float, inline, bar) 883 // ========================================================================= 884 885 // SVG icon paths matching Lucide icon style (24x24, stroke-based) 886 var SVG_WRAP_START = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">'; 887 var SVG_WRAP_END = '</svg>'; 888 var SVG_FACE_CIRCLE = '<circle cx="12" cy="12" r="10"/>'; 889 var SVG_EYES = '<line x1="9" x2="9.01" y1="9" y2="9"/><line x1="15" x2="15.01" y1="9" y2="9"/>'; 890 891 var ICON_SETS = { 892 'icons-5': [ 893 { svg: SVG_WRAP_START + SVG_FACE_CIRCLE + '<path d="M16 16s-1.5-2-4-2-4 2-4 2"/><path d="M7.5 8 10 9"/><path d="m14 9 2.5-1"/><path d="M9 10h.01"/><path d="M15 10h.01"/>' + SVG_WRAP_END, label: 'Very unhappy', value: 1 }, 894 { svg: SVG_WRAP_START + SVG_FACE_CIRCLE + '<path d="M16 16s-1.5-2-4-2-4 2-4 2"/>
894' + SVG_EYES + SVG_WRAP_END, label: 'Unhappy', value: 2 }, 895 { svg: SVG_WRAP_START + SVG_FACE_CIRCLE + '<line x1="8" x2="16" y1="15" y2="15"/>' + SVG_EYES + SVG_WRAP_END, label: 'Neutral', value: 3 }, 896 { svg: SVG_WRAP_START + SVG_FACE_CIRCLE + '<path d="M8 14s1.5 2 4 2 4-2 4-2"/>' + SVG_EYES + SVG_WRAP_END, label: 'Happy', value: 4 }, 897 { svg: SVG_WRAP_START + SVG_FACE_CIRCLE + '<path d="M18 13a6 6 0 0 1-6 5 6 6 0 0 1-6-5h12Z"/>' + SVG_EYES + SVG_WRAP_END, label: 'Very happy', value: 5 } 898 ], 899 'icons-3': [ 900 { svg: SVG_WRAP_START + SVG_FACE_CIRCLE + '<path d="M16 16s-1.5-2-4-2-4 2-4 2"/>' + SVG_EYES + SVG_WRAP_END, label: 'Unhappy', value: 1 }, 901 { svg: SVG_WRAP_START + SVG_FACE_CIRCLE + '<line x1="8" x2="16" y1="15" y2="15"/>' + SVG_EYES + SVG_WRAP_END, label: 'Neutral', value: 3 }, 902 { svg: SVG_WRAP_START + SVG_FACE_CIRCLE + '<path d="M18 13a6 6 0 0 1-6 5 6 6 0 0 1-6-5h12Z"/>' + SVG_EYES + SVG_WRAP_END, label: 'Very happy', value: 5 } 903 ], 904 'thumbs': [ 905 { svg: SVG_WRAP_START + '<path d="M17 14V2"/><path d="M9 18.12 10 14H4.17a2 2 0 0 1-1.92-2.56l2.33-8A2 2 0 0 1 6.5 2H20a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2h-2.76a2 2 0 0 0-1.79 1.11L12 22a3.13 3.13 0 0 1-3-3.88Z"/>' + SVG_WRAP_END, label: 'Thumbs down', value: 1 }, 906 { svg: SVG_WRAP_START + '<path d="M7 10v12"/><path d="M15 5.88 14 10h5.83a2 2 0 0 1 1.92 2.56l-2.33 8A2 2 0 0 1 17.5 22H4a2 2 0 0 1-2-2v-8a2 2 0 0 1 2-2h2.76a2 2 0 0 0 1.79-1.11L12 2a3.13 3.13 0 0 1 3 3.88Z"/>' + SVG_WRAP_END, label: 'Thumbs up', value: 5 } 907 ] 908 }; 909 910 // Backward compatibility: map old emoji style names to new icon sets 911 var STYLE_MAP = { 'emoji-5': 'icons-5', 'emoji-3': 'icons-3', 'thumbs': 'thumbs' }; 912 913 function createSentimentRow(style, cssClass, onSelect) { 914 var mappedStyle = STYLE_MAP[style] || style; 915 var icons = ICON_SETS[mappedStyle] || ICON_SETS['icons-5']; 916 var container = createEl('div', { className: cssClass, role: 'radiogroup', 'aria-label': 'Rate your experience' }); 917 var selected = null; 918 var buttons = []; 919 920 icons.forEach(function(item, index) { 921 var btn = createEl('button', { 922 type: 'button', 923 className: cssClass.replace('circuit-sentiment', 'circuit-sentiment-btn'), 924 'aria-label': item.label, 925 role: 'radio', 926 'aria-checked': 'false', 927 tabIndex: index === 0 ? '0' : '-1', 928 onClick: function() { 929 selectBtn(btn, item.value); 930 } 931 }); 932 btn.innerHTML = item.svg; 933 buttons.push(btn); 934 container.appendChild(btn); 935 }); 936 937 function selectBtn(btn, value) { 938 if (selected) { 939 selected.classList.remove('selected'); 940 selected.setAttribute('aria-checked', 'false'); 941 selected.setAttribute('tabindex', '-1'); 942 } 943 btn.classList.add('selected'); 944 btn.setAttribute('aria-checked', 'true'); 945 btn.setAttribute('tabindex', '0'); 946 btn.focus(); 947 selected = btn; 948 onSelect(value); 949 } 950 951 container.addEventListener('keydown', function(e) { 952 var currentActive = (_shadowRoot && _shadowRoot.activeElement) || document.activeElement; 953 var idx = buttons.indexOf(currentActive); 954 if (idx === -1) return; 955 var next = -1; 956 if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { 957 next = (idx + 1) % buttons.length; 958 } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { 959 next = (idx - 1 + buttons.length) % buttons.length; 960 } 961 if (next !== -1) { 962 e.preventDefault(); 963 selectBtn(buttons[next], icons[next].value); 964 } 965 }); 966 967 return container; 968 } 969 970 // Survey scale row factory â NPS (0â10), CES (1â5 effort), PMF (4-option Sean Ellisâstyle). 971 // Returns a container with the appropriate buttons + low/high labels (or 972 // vertical radio list for PMF). Shares the same selection/keyboard contract 973 // as createSentimentRow so callers can swap without other changes. 974 function createScaleRow(template, config, onSelect) { 975 var container = createEl('div', { className: 'circuit-scale', role: 'radiogroup', 'aria-label': 'Rate your response' }); 976 var selected = null; 977 var buttons = []; 978 var items = []; 979 980 if (template === 'nps') { 981 for (var i = 0; i <= 10; i++) items.push({ value: i, label: String(i) }); 982 } else if (template === 'ces') { 983 for (var j = 1; j <= 5; j++) items.push({ value: j, label: String(j) }); 984 } else if (template === 'pmf') { 985 items = [ 986 { value: 4, label: 'Very disappointed' }, 987 { value: 3, label: 'Somewhat disappointed' }, 988 { value: 2, label: 'Not disappointed' }, 989 { value: 1, label: 'N/A â I no longer use it' } 990 ]; 991 } else { 992 return null; 993 } 994 995 var isVertical = template === 'pmf'; 996 // Endpoint meaning for the scale (a11y #15): screen-reader users hear 997 // "0, Not at all likely" / "10, Extremely likely" instead of a bare number. 998 var lowLabel = config.scaleLowLabel || (template === 'nps' ? 'Not at all likely' : 'Very difficult'); 999 var highLabel = config.scaleHighLabel || (template === 'nps' ? 'Extremely likely' : 'Very easy'); 1000 var btnsWrap = createEl('div', { 1001 className: isVertical ? 'circuit-scale-options' : 'circuit-scale-buttons circuit-scale-buttons--' + template 1002 }); 1003
1004 items.forEach(function(item, index) { 1005 var ariaLabel = item.label; 1006 if (!isVertical && index === 0) ariaLabel = item.label + ', ' + lowLabel; 1007 else if (!isVertical && index === items.length - 1) ariaLabel = item.label + ', ' + highLabel; 1008 var btn = createEl('button', { 1009 type: 'button', 1010 className: isVertical ? 'circuit-scale-option' : 'circuit-scale-btn', 1011 'aria-label': ariaLabel, 1012 role: 'radio', 1013 'aria-checked': 'false', 1014 tabIndex: index === 0 ? '0' : '-1', 1015 onClick: function() { selectBtn(btn, item.value); } 1016 }); 1017 if (isVertical) { 1018 var dot = createEl('span', { className: 'circuit-scale-radio' }); 1019 var lbl = createEl('span', { textContent: item.label }); 1020 btn.appendChild(dot); 1021 btn.appendChild(lbl); 1022 } else { 1023 btn.textContent = item.label; 1024 } 1025 buttons.push(btn); 1026 btnsWrap.appendChild(btn); 1027 }); 1028 1029 container.appendChild(btnsWrap); 1030 1031 if (!isVertical) { 1032 var labelsRow = createEl('div', { className: 'circuit-scale-labels' }); 1033 labelsRow.appendChild(createEl('span', { textContent: lowLabel })); 1034 labelsRow.appendChild(createEl('span', { textContent: highLabel })); 1035 container.appendChild(labelsRow); 1036 } 1037 1038 function selectBtn(btn, value) { 1039 if (selected) { 1040 selected.classList.remove('selected'); 1041 selected.setAttribute('aria-checked', 'false'); 1042 selected.setAttribute('tabindex', '-1'); 1043 } 1044 btn.classList.add('selected'); 1045 btn.setAttribute('aria-checked', 'true'); 1046 btn.setAttribute('tabindex', '0'); 1047 btn.focus(); 1048 selected = btn; 1049 onSelect(value); 1050 } 1051 1052 container.addEventListener('keydown', function(e) { 1053 var currentActive = (_shadowRoot && _shadowRoot.activeElement) || document.activeElement; 1054 var idx = buttons.indexOf(currentActive); 1055 if (idx === -1) return; 1056 var next = -1; 1057 if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { 1058 next = (idx + 1) % buttons.length; 1059 } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { 1060 next = (idx - 1 + buttons.length) % buttons.length; 1061 } 1062 if (next !== -1) { 1063 e.preventDefault(); 1064 selectBtn(buttons[next], items[next].value); 1065 } 1066 }); 1067 1068 return container; 1069 } 1070 1071 // Dispatcher â falls back to the 5-emoji sentiment row when no survey template. 1072 function createRatingRow(config, fallbackStyle, cssClass, onSelect) { 1073 var t = config.surveyTemplate; 1074 if (t && t !== 'open' && _VALID_TEMPLATES.indexOf(t) !== -1) { 1075 return createScaleRow(t, config, onSelect); 1076 } 1077 return createSentimentRow(fallbackStyle, cssClass, onSelect); 1078 } 1079 1080 // ========================================================================= 1081 // SUBMISSION 1082 // ========================================================================= 1083 1084 function submitFeedback(config, data, callback, opts) { 1085 if (_widgetDisabled) { 1086 callback({ ok: false, message: 'This widget is currently unavailable.' }); 1087 return; 1088 } 1089 var userMeta = (window.AnnsaSurveyConfig && window.AnnsaSurveyConfig.user) || null; 1090 1091 // NOTE: api_key is deliberately NOT part of `payload`. On network failure this 1092 // exact object is serialized into the offline queue (localStorage), so keeping 1093 // the credential out of it means it can never be persisted to disk â rather 1094 // than relying on _scrubQueueBody to strip it after the fact. The key is 1095 // attached only to the outbound fetch body below, and re-attached at drain time. 1096 var payload = { 1097 surface: data.surface || surfaceType, 1098 widget_id: _activeWidgetId || undefined 1099 }; 1100 if (config.captureUrl !== false) payload.page_url = window.location.href; 1101 1102 if (data.text) payload.text = data.text.slice(0, 5000); // Client-side cap 1103 if (data.rating != null) payload.rating = data.rating; 1104 if (data.context) payload.context = data.context; 1105 if (data.type) payload.feedback_type = data.type; 1106 if (data.email) payload.email = data.email; 1107 if (userMeta) { 1108 payload.user_metadata = userMeta; 1109 if (!payload.email && userMeta.email) payload.email = userMeta.email; 1110 } 1111 1112 // Rating-only submissions need at least rating 1113 if (!payload.text && payload.rating == null) { 1114 callback({ ok: false, message: 'Please provide feedback' }); 1115 return; 1116 } 1117 1118 var endpoint = config.apiEndpoint || (API_BASE + '/surface/submit'); 1119 1120 fetch(endpoint, { 1121 method: 'POST', 1122 headers: { 'Content-Type': 'application/json' }, 1123 // api_key is attached here only â never on the queued `payload`. 1124 body: JSON.stringify(Object.assign({ api_key: config.apiKey }, payload)), 1125 // keepalive lets a pagehide-time submission survive the navigation. 1126 keepalive: !!(opts && opts.keepalive) 1127 }).then(function(response) { 1128 return response.json().then(function(body) { 1129 // Store feedback ID for shipped notification matching 1130 if (response.ok && body.feedback_id) { 1131 storeFeedbackId(body.feedback_id); 1132 } 1133 // Frequency / cadence â record a successful response so the next 1134 // eligibility check waits cadence_after_response_days. 1135 if (response.ok && _activeWidgetId) { 1136 recordResponse(_activeWidgetId); 1137 } 1138 callback({ 1139 ok: response.ok, 1140 message: response.ok 1141 ? (body.message || config.thankYouMessage) 1142 : (body.detail || body.message || 'Something went wrong. Please try again.') 1143 }); 1144 }); 1145 }).catch(function() { 1146 // Network failure â queue for retry if offline 1147 if (typeof navigator !== 'undefined' && !navigator.onLine) { 1148 queueOffline(endpoint, JSON.stringify(payload)); 1149 callback({ ok: true, message: 'Saved offline. Will send when connected.' }); 1150 } else { 1151 callback({ ok: false, message: 'Failed to submit. Please try again.' }); 1152 } 1153 }); 1154 } 1155 1156 // ========================================================================= 1157 // FLOAT SURFACE (sentiment-first modal) 1158 // ========================================================================= 1159 1160 // Icon SVG paths for the icon-based type selector (Lucide icon set) 1161 var TYPE_ICON_DEFS = { 1162 bug: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2l1.88 1.88M14.12 3.88 16 2"/><path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1"/><path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6z"/><path d="M12 20v-9"/>
1162<path d="M6.53 9C4.6 8.8 3 7.1 3 5"/><path d="M6 13H2"/><path d="M3 21c0-2.1 1.7-3.9 3.8-4"/><path d="M20.97 5c0 2.1-1.6 3.8-3.5 4"/><path d="M22 13h-4"/><path d="M17.2 17c2.1.1 3.8 1.9 3.8 4"/></svg>', 1163 feature: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>', 1164 question: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><path d="M12 17h.01"/></svg>', 1165 other: '<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/></svg>' 1166 }; 1167 var TYPE_ICON_LIST = [ 1168 { key: 'bug', label: 'Bug', value: 'bug' }, 1169 { key: 'feature', label: 'Feature', value: 'feature' }, 1170 { key: 'question', label: 'Question', value: 'question' }, 1171 { key: 'other', label: 'Other', value: 'other' } 1172 ]; 1173 1174 function createFloatSurface(config) { 1175 var selectedRating = null; 1176 var selectedType = null; 1177 var formExpanded = false; 1178 var appendRoot = getAppendRoot(); 1179 1180 // Create FAB button â pill shape with text label when buttonText is configured 1181 var fabHasText = !!(config.buttonText && config.buttonText.trim()); 1182 var fab = createEl('button', { 1183 className: 'circuit-fab' + (fabHasText ? ' circuit-fab--text' : ''), 1184 'aria-label': config.buttonText || 'Open feedback form', 1185 title: config.buttonText || 'Feedback', 1186 innerHTML: '<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><circle cx="12" cy="10" r="8"/><path d="M7 16L4 21L9.5 17.5" stroke-linecap="round" stroke-linejoin="round"/></svg>' + (fabHasText ? '<span>' + escapeHtml(config.buttonText) + '</span>' : '') 1187 }); 1188 1189 // Create overlay + modal 1190 var overlay = createEl('div', { className: 'circuit-overlay', role: 'dialog', 'aria-modal'
1190: 'true', 'aria-labelledby': 'circuit-modal-title' }); 1191 var modal = createEl('div', { className: 'circuit-modal' }); 1192 1193 // Header 1194 var header = createEl('div', { className: 'circuit-header' }); 1195 var title = createEl('h2', { className: 'circuit-title', id: 'circuit-modal-title', textContent: config.title }); 1196 var closeBtn = createEl('button', { className: 'circuit-close', 'aria-label': 'Close', innerHTML: '×' }); 1197 header.appendChild(title); 1198 header.appendChild(closeBtn); 1199 1200 // Sentiment row 1201 var sentimentRow = createRatingRow(config, config.emojiStyle || 'emoji-5', 'circuit-sentiment', function(value) { 1202 selectedRating = value; 1203 formSection.classList.remove('hidden'); 1204 textarea.focus(); 1205 }); 1206 1207 // Form section: visible immediately when showRating=false, otherwise revealed after rating 1208 var formSection = createEl('div', { className: config.showRating ? 'circuit-form hidden' : 'circuit-form' }); 1209 1210 // Type selector (icon-based grid) 1211 var typesRow = null; 1212 if (config.showTypeSelector) { 1213 typesRow = createEl('div', { className: 'circuit-type-icons', role: 'radiogroup', 'aria-label': 'Feedback type' }); 1214 typesRow._selected = null; 1215 TYPE_ICON_LIST.forEach(function(typeItem) { 1216 var btn = createEl('button', { 1217 type: 'button', 1218 className: 'circuit-type-icon-btn', 1219 'aria-label': typeItem.label, 1220 role: 'radio', 1221 'aria-checked': 'false', 1222 innerHTML: TYPE_ICON_DEFS[typeItem.key] + '<span>' + typeItem.label + '</span>', 1223 onClick: function() { 1224 typesRow.querySelectorAll('.circuit-type-icon-btn').forEach(function(b) { 1225 b.classList.remove('selected'); 1226 b.setAttribute('aria-checked', 'false'); 1227 }); 1228 btn.classList.add('selected'); 1229 btn.setAttribute('aria-checked', 'true'); 1230 typesRow._selected = btn; 1231 selectedType = typeItem.value; 1232 } 1233 }); 1234 typesRow.appendChild(btn); 1235 }); 1236 formSection.appendChild(typesRow); 1237 } 1238 1239 // Textarea (maxlength: 5000, auto-grows with content) 1240 var textarea = createEl('textarea', { className: 'circuit-textarea', placeholder: config.placeholder, maxlength: '5000' }); 1241 textarea.addEventListener('input', function() { 1242 textarea.style.height = 'auto'; 1243 textarea.style.height = Math.min(textarea.scrollHeight, 300) + 'px'; 1244 }); 1245 formSection.appendChild(textarea); 1246 1247 // Email (maxlength: 254 per RFC 5321) 1248 var emailLabel = createEl('label', { className: 'circuit-label', textContent: 'Email (optional)' }); 1249 var emailInput = createEl('input', { type: 'email', className: 'circuit-input', placeholder: '[email protected]', maxlength: '254' }); 1250 var userMeta = (window.AnnsaSurveyConfig && window.AnnsaSurveyConfig.user) || null; 1251 if (userMeta && userMeta.email) emailInput.value = userMeta.email; 1252 formSection.appendChild(emailLabel); 1253 formSection.appendChild(emailInput); 1254 1255 // Honeypot 1256 var honeypot = createEl('input', { type: 'text', name: 'website', className: 'circuit-hp', tabIndex: '-1', autocomplete: 'off' }); 1257 formSection.appendChild(honeypot); 1258 1259 // Submit 1260 var submitBtn = createEl('button', { type: 'button', className: 'circuit-submit', textContent: 'Send Feedback' }); 1261 formSection.appendChild(submitBtn); 1262 1263 // Message 1264 var msgDiv = createEl('div', { className: 'circuit-msg', 'aria-live': 'polite', style: { display: 'none' } }); 1265 formSection.appendChild(msgDiv); 1266 1267 // Branding 1268 if (config.showBranding) { 1269 var branding = createEl('div', { className: 'circuit-branding' }); 1270 var brandLink = createEl('a', { href: 'https://annsa.ai', target: '_blank', rel: 'noopener noreferrer', textContent: 'Powered by annsa.ai' }); 1271 branding.appendChild(brandLink); 1272 formSection.appendChild(branding); 1273 } 1274 1275 // Escape hatch â "Not what you're looking for? Contact support / See documentation" 1276 if (config.supportUrl || config.docsUrl) { 1277 var escapeDiv = createEl('div', { className: 'circuit-escape' }); 1278 var escapeParts = 'Not what you\u2019re looking for? '; 1279 var links = [];
1280 if (config.supportUrl) { 1281 links.push('<a href="' + escapeHtml(config.supportUrl) + '" target="_blank" rel="noopener noreferrer">Contact support</a>'); 1282 } 1283 if (config.docsUrl) { 1284 links.push('<a href="' + escapeHtml(config.docsUrl) + '" target="_blank" rel="noopener noreferrer">See documentation</a>'); 1285 } 1286 escapeDiv.innerHTML = escapeParts + links.join(' or '); 1287 formSection.appendChild(escapeDiv); 1288 } 1289 1290 // Hide sentiment row when rating is disabled 1291 if (!config.showRating) { 1292 sentimentRow.style.display = 'none'; 1293 } 1294 1295 modal.appendChild(header); 1296 modal.appendChild(sentimentRow); 1297 modal.appendChild(formSection); 1298 overlay.appendChild(modal); 1299 1300 // Focus trap elements 1301 var focusableSelector = 'button:not([disabled]), textarea, input:not([tabindex="-1"]), a[href]'; 1302 1303 function trapFocus(e) { 1304 if (e.key !== 'Tab') return; 1305 var focusable = modal.querySelectorAll(focusableSelector); 1306 if (focusable.length === 0) return; 1307 var first = focusable[0]; 1308 var last = focusable[focusable.length - 1]; 1309 // In Shadow DOM, document.activeElement returns the host â use shadow root 1310 var activeEl = (_shadowRoot && _shadowRoot.activeElement) || document.activeElement; 1311 if (e.shiftKey && activeEl === first) { 1312 e.preventDefault(); 1313 last.focus(); 1314 } else if (!e.shiftKey && activeEl === last) { 1315 e.preventDefault(); 1316 first.focus(); 1317 } 1318 } 1319 1320 function openModal() { 1321 overlay.classList.add('active'); 1322 modal.addEventListener('keydown', trapFocus); 1323 if (config.showRating) { 1324 // Sentiment-first flow: focus first rating button 1325 var firstBtn = sentimentRow.querySelector('button'); 1326 if (firstBtn) firstBtn.focus(); 1327 } else { 1328 // Direct flow: focus textarea immediately 1329 textarea.focus(); 1330 } 1331 } 1332 1333 function closeModal() { 1334 overlay.classList.remove('active'); 1335 modal.removeEventListener('keydown', trapFocus); 1336 // Reset state 1337 textarea.value = ''; 1338 emailInput.value = (userMeta && userMeta.email) || ''; 1339 honeypot.value = ''; 1340 selectedRating = null; 1341 selectedType = null; 1342 formExpanded = false; 1343 // When showRating is false the form is always visible; don't hide it on reset 1344 if (config.showRating) { 1345 formSection.classList.add('hidden'); 1346 } 1347 // Reset textarea height 1348 textarea.style.height = ''; 1349 submitBtn.disabled = false; 1350 submitBtn.textContent = 'Send Feedback'; 1351 msgDiv.style.display = 'none'; 1352 // Deselect sentiment 1353 sentimentRow.querySelectorAll('button').forEach(function(b) { 1354 b.classList.remove('selected'); 1355 b.setAttribute('aria-checked', 'false'); 1356 }); 1357 // Deselect icon type buttons 1358 if (typesRow) { 1359 typesRow.querySelectorAll('.circuit-type-icon-btn').forEach(function(b) { 1360 b.classList.remove('selected'); 1361 b.setAttribute('aria-checked', 'false'); 1362 }); 1363 typesRow._selected = null; 1364 } 1365 // Return focus 1366 if (fab && fab.parentNode) fab.focus(); 1367 } 1368 1369 fab.addEventListener('click', openModal); 1370 closeBtn.addEventListener('click', function() { 1371 if (_activeWidgetId) recordDismissal(_activeWidgetId); 1372 closeModal(); 1373 }); 1374 overlay.addEventListener('click', function(e) { 1375 if (e.target === overlay) { 1376 if (_activeWidgetId) recordDismissal(_activeWidgetId); 1377 closeModal(); 1378 } 1379 }); 1380 1381 // Submit handler 1382 submitBtn.addEventListener('click', function() { 1383 if (honeypot.value) { showSuccess(); return; } 1384 1385 var text = textarea.value.trim() || null; 1386 var rating = selectedRating; 1387 if (!text && rating == null) { showMsg('Please enter your feedback or select a rating.', 'error'); return; } 1388 1389 // Optimistic save: show success state immediately so the user can keep going 1390 showSuccess(); 1391 1392 // Fire-and-forget â server handles enrichment in the background 1393 submitFeedback(config, { 1394 text: text, 1395 rating: rating, 1396 type: selectedType, 1397 email: emailInput.value.trim() || null, 1398 context: _activeContext || undefined, 1399 surface: surfaceType 1400 }, function() {}); 1401 }); 1402 1403 function showMsg(text, type) { 1404 msgDiv.textContent = text; 1405 msgDiv.className = 'circuit-msg ' + type; 1406 msgDiv.style.display = 'block'; 1407 } 1408 1409 function showSuccess() { 1410 // Hide modal content, show the success card (matches configurator preview) 1411 header.style.display = 'none'; 1412 sentimentRow.style.display = 'none'; 1413 formSection.style.display = 'none'; 1414 1415 var successEl = createEl('div', { className: 'circuit-success-state' }); 1416 var _iconStroke = (getThemeColors(config.theme).bg === '#1C1A18') ? '#1C1A18' : (config.textColor || '#ffffff'); 1417 var iconEl = createEl('div', { 1418 className: 'circuit-success-icon', 1419 innerHTML: '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><polyline points="20 6 9 17 4 12" stroke="' + _iconStroke + '" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg>' 1420 }); 1421 var msgEl = createEl('p', { className: 'circuit-success-msg', textContent: config.thankYouMessage }); 1422 successEl.appendChild(iconEl); 1423 successEl.appendChild(msgEl); 1424 if (config.showBranding) { 1425 var successBrand = createEl('div', { className: 'circuit-branding' }); 1426 var successBrandLink = createEl('a', { href: 'https://annsa.ai', target: '_blank', rel: 'noopener noreferrer', textContent: 'Powered by annsa.ai' }); 1427 successBrand.appendChild(successBrandLink); 1428 successEl.appendChild(successBrand); 1429 } 1430 modal.appendChild(successEl); 1431 1432 setTimeout(function() { 1433 if (successEl.parentNode) successEl.parentNode.removeChild(successEl); 1434 header.style.display = ''; 1435 if (config.showRating) sentimentRow.style.display = ''; 1436 formSection.style.display = ''; 1437 closeModal(); 1438 }, 2000); 1439 } 1440 1441 // Escape key (events bubble out of shadow DOM, overlay ref is in closure) 1442 _escHandler = function(e) { 1443 if (e.key === 'Escape' && overlay.classList.contains('active')) closeModal(); 1444 }; 1445 document.addEventListener('keydown', _escHandler); 1446 1447 // Trigger selector support 1448 if (triggerSelector) { 1449 var waitForTrigger = function() { 1450 var el = document.querySelector(triggerSelector); 1451 if (el) { 1452 _triggerEl = el; 1453 _triggerHandler = function(e) { e.preventDefault(); openModal(); }; 1454 el.addEventListener('click', _triggerHandler); 1455 } else if (document.readyState !== 'complete') { 1456 setTimeout(waitForTrigger, 100); 1457 } 1458 }; 1459 waitForTrigger(); 1460 } 1461
1462 // Append to shadow root (or document.body fallback) 1463 if (!isHeadless && !triggerSelector) appendRoot.appendChild(fab); 1464 appendRoot.appendChild(overlay); 1465 1466 return { open: openModal, close: closeModal, fab: fab }; 1467 } 1468 1469 // ========================================================================= 1470 // INLINE SURFACE ("Was this helpful?") 1471 // ========================================================================= 1472 1473 function createInlineSurface(config) { 1474 var _attachedInlineTargets = typeof WeakSet !== 'undefined' ? new WeakSet() : null; 1475 1476 // Embedded is a PLACEMENT, not a different survey: it renders a trigger that 1477 // opens the same modal every other placement opens. Rendering its own 1478 // reaction strip is what let it drift from the instrument config -- it 1479 // hardcoded "Was this helpful?" and showed a rating even for open text. 1480 var floatInstance = createFloatSurface(config); 1481 if (floatInstance.fab && floatInstance.fab.parentNode) { 1482 floatInstance.fab.parentNode.removeChild(floatInstance.fab); 1483 } 1484 1485 var appendRoot = _shadowRoot || document.body; 1486 1487 var _origClose = floatInstance.close; 1488 floatInstance.close = function() { 1489 _activeWidgetId = widgetId; 1490 injectStyles(config); 1491 _origClose(); 1492 }; 1493 1494 function attachInlineTarget(target) { 1495 if (_attachedInlineTargets && _attachedInlineTargets.has(target)) return; 1496 if (_attachedInlineTargets) _attachedInlineTargets.add(target); 1497 1498 var context = target.getAttribute('data-context') || ''; 1499 // data-prompt labels the TRIGGER. The question itself lives in the modal, 1500 // sourced from the instrument config. 1501 var prompt = target.getAttribute('data-prompt') || ''; 1502 var ctaLabel = target.getAttribute('data-cta') || config.buttonText || 'Feedback'; 1503 1504 var container = createEl('div', { className: 'circuit-inline' }); 1505 var row = createEl('div', { className: 'circuit-inline-row' }); 1506 if (prompt) { 1507 row.appendChild(createEl('span', { className: 'circuit-inline-prompt', textContent: prompt })); 1508 } 1509 1510 var trigger = createEl('button', { 1511 type: 'button', 1512 className: 'circuit-inline-trigger', 1513 textContent: ctaLabel 1514 }); 1515 1516 var elemWid = (target.getAttribute('data-surface-id') || '').trim(); 1517 if (elemWid && elemWid !== widgetId) { 1518 function prewarm() { fetchSurfaceConfig(elemWid, config); } 1519 trigger.addEventListener('mouseenter', prewarm, { once: true }); 1520 trigger.addEventListener('focus', prewarm, { once: true }); 1521 } 1522 1523 trigger.addEventListener('click', async function(e) { 1524 e.preventDefault(); 1525 _activeContext = context; 1526 var wid = (target.getAttribute('data-surface-id') || '').trim(); 1527 var titleEl = appendRoot.querySelector('.circuit-title'); 1528 var textareaEl = appendRoot.querySelector('.circuit-textarea'); 1529 1530 if (!wid || wid === widgetId) { 1531 _activeWidgetId = widgetId; 1532 if (titleEl) titleEl.textContent = config.title; 1533 if (textareaEl) textareaEl.placeholder = config.placeholder; 1534 floatInstance.open(); 1535 return; 1536 } 1537 1538 _activeWidgetId = wid; 1539 var overrides = await fetchSurfaceConfig(wid, config); 1540 if (titleEl) titleEl.textContent = (overrides && overrides.title) || config.title; 1541 if (textareaEl) textareaEl.placeholder = (overrides && overrides.placeholder) || config.placeholder; 1542 if (overrides && (overrides.primaryColor !== config.primaryColor || overrides.textColor !== config.textColor)) { 1543 injectStyles(Object.assign({}, config, { primaryColor: overrides.primaryColor, textColor: overrides.textColor })); 1544 } 1545 floatInstance.open(); 1546 }); 1547 1548 row.appendChild(trigger); 1549 container.appendChild(row); 1550 target.appendChild(container); 1551 _inlineContainers.push({ target: target, container: container }); 1552 } 1553 1554 var targets = document.querySelectorAll('[data-circuit-inline]'); 1555 if (targets.length === 0) { 1556 console.warn('[Annsa] Inline surface: no [data-circuit-inline] elements found. Waiting via MutationObserver (SPA support).'); 1557 }
1558 targets.forEach(attachInlineTarget); 1559 1560 // Watch for late-rendered inline elements (React/SPA support) 1561 if (typeof MutationObserver !== 'undefined') { 1562 _inlineObserver = new MutationObserver(function(mutations) { 1563 mutations.forEach(function(mutation) { 1564 mutation.addedNodes.forEach(function(node) { 1565 if (node.nodeType !== 1) return; 1566 if (node.hasAttribute && node.hasAttribute('data-circuit-inline')) attachInlineTarget(node); 1567 if (node.querySelectorAll) node.querySelectorAll('[data-circuit-inline]').forEach(attachInlineTarget); 1568 }); 1569 }); 1570 }); 1571 _inlineObserver.observe(document.body, { childList: true, subtree: true }); 1572 } 1573 1574 return floatInstance; 1575 } 1576 1577 // ========================================================================= 1578 // BAR SURFACE (prompt bar) 1579 // ========================================================================= 1580 1581 function createBarSurface(config) { 1582 var pos = parsePosition(config.position); 1583 var delay = parseInt(currentScript.getAttribute('data-delay') || '3000', 10); 1584 var prompt = currentScript.getAttribute('data-prompt') || config.title || 'How was your experience?'; 1585 var context = currentScript.getAttribute('data-context') || ''; 1586 var sessionKey = 'circuit_bar_dismissed_' + (context || 'default'); 1587 var appendRoot = getAppendRoot(); 1588 1589 // Check if already dismissed this session (guarded for private browsing) 1590 if (safeStorage('get', sessionKey)) return; 1591 1592 // Banner is a PLACEMENT: the bar is the trigger, the modal is the survey. 1593 // Its old self-contained rating strip drifted from the instrument config. 1594 var floatInstance = createFloatSurface(config); 1595 if (floatInstance.fab && floatInstance.fab.parentNode) { 1596 floatInstance.fab.parentNode.removeChild(floatInstance.fab); 1597 } 1598 1599 var _origClose = floatInstance.close; 1600 floatInstance.close = function() { 1601 _activeWidgetId = widgetId; 1602 injectStyles(config); 1603 _origClose(); 1604 // The banner has done its job once the modal has been through. 1605 dismiss(); 1606 }; 1607 1608 var bar = createEl('div', { className: 'circuit-bar ' + pos.v }); 1609 var promptEl = createEl('span', { className: 'circuit-bar-prompt', textContent: prompt }); 1610 var actionsEl = createEl('div', { className: 'circuit-bar-actions' }); 1611 1612 var ctaBtn = createEl('button', { 1613 type: 'button', 1614 className: 'circuit-bar-cta', 1615 textContent: currentScript.getAttribute('data-cta') || config.buttonText || 'Feedback' 1616 }); 1617 1618 ctaBtn.addEventListener('click', function() { 1619 _activeWidgetId = widgetId; 1620 _activeContext = context; 1621 floatInstance.open(); 1622 }); 1623 1624 var dismissBtn = createEl('button', { className: 'circuit-bar-dismiss', 'aria-label': 'Dismiss', innerHTML: '×' }); 1625 1626 var dismissed = false; 1627 function dismiss() { 1628 if (dismissed) return;
1629 dismissed = true; 1630 if (_activeWidgetId) recordDismissal(_activeWidgetId); 1631 bar.classList.remove('visible'); 1632 safeStorage('set', sessionKey, 'true'); 1633 setTimeout(function() { if (bar.parentNode) bar.parentNode.removeChild(bar); }, 300); 1634 } 1635 1636 dismissBtn.addEventListener('click', dismiss); 1637 1638 actionsEl.appendChild(ctaBtn); 1639 actionsEl.appendChild(dismissBtn); 1640 1641 bar.appendChild(promptEl); 1642 bar.appendChild(actionsEl); 1643 1644 // Append to shadow root (or document.body fallback) 1645 appendRoot.appendChild(bar); 1646 1647 // Show after delay 1648 setTimeout(function() { bar.classList.add('visible'); }, delay); 1649 1650 return floatInstance; 1651 } 1652 1653 // ========================================================================= 1654 // THUMBS SURFACE (minimal inline) 1655 // ========================================================================= 1656 1657 function createThumbsSurface(config) { 1658 var _attachedThumbsTargets = typeof WeakSet !== 'undefined' ? new WeakSet() : null; 1659 1660 function attachThumbsTarget(target) { 1661 if (_attachedThumbsTargets && _attachedThumbsTargets.has(target)) return; 1662 if (_attachedThumbsTargets) _attachedThumbsTargets.add(target); 1663 1664 var context = target.getAttribute('data-context') || ''; 1665 var submitted = false; 1666 1667 var container = createEl('div', { className: 'circuit-thumbs' }); 1668 1669 // Use the shared Lucide SVG thumb icons (ICON_SETS.thumbs), not emoji, so the 1670 // approval surface matches the sentiment rows and the product's icon set. 1671 var thumbUp = createEl('button', { type: 'button', className: 'circuit-thumb-btn', 'aria-label': 'Thumbs up', innerHTML: ICON_SETS['thumbs'][1].svg }); 1672 var thumbDown = createEl('button', { type: 'button', className: 'circuit-thumb-btn', 'aria-label': 'Thumbs down', innerHTML: ICON_SETS['thumbs'][0].svg }); 1673 1674 // Selection state machine (2026-08-04 UX fix): a click SELECTS, the 1675 // other thumb SWITCHES, the same thumb again UNSELECTS. Nothing 1676 // submits until Send or Skip â the old hidden auto-submit timer is 1677 // gone (it locked the first tap in and fired behind the reader's 1678 // back). If the visitor leaves with a selection un-sent, a keepalive 1679 // rating-only submission preserves the signal on pagehide. 1680 var selectedValue = null; // 5 | 1 | null 1681 var followup = null; 1682 1683 thumbUp.setAttribute('aria-pressed', 'false'); 1684 thumbDown.setAttribute('aria-pressed', 'false'); 1685 1686 function syncSelection() { 1687 thumbUp.classList.toggle('selected', selectedValue === 5); 1688 thumbDown.classList.toggle('selected', selectedValue === 1); 1689 thumbUp.setAttribute('aria-pressed', selectedValue === 5 ? 'true' : 'false'); 1690 thumbDown.setAttribute('aria-pressed', selectedValue === 1 ? 'true' : 'false'); 1691 } 1692 1693 function removeFollowup() { 1694 if (followup) { followup.remove(); followup = null; } 1695 } 1696 1697 function finish(includeNote, keepalive) { 1698 if (submitted || selectedValue == null) return; 1699 submitted = true; 1700 var text = null; 1701 if (includeNote && followup) { 1702 var noteInput = followup.querySelector('.circuit-thumbs-input'); 1703 if (noteInput) text = noteInput.value.trim() || null; 1704 } 1705 var rating = selectedValue; 1706 container.innerHTML = ''; 1707 container.appendChild(createEl('span', { className: 'circuit-inline-thanks', 'aria-live': 'polite', textContent: config.thankYouMessage || 'Thanks.' })); 1708 var data = { rating: rating, context: context, surface: 'thumbs' }; 1709 if (text) data.text = text; 1710 submitFeedback(config, data, function() {}, { keepalive: !!keepalive }); 1711 } 1712 1713 function showFollowup() { 1714 if (!followup) { 1715 followup = createEl('div', { className: 'circuit-thumbs-followup' }); 1716 var input = createEl('input', { type: 'text', className: 'circuit-thumbs-input', maxlength: '2000' }); 1717 var send = createEl('button', { type: 'button', className: 'circuit-thumbs-send', textContent: 'Send' }); 1718 // Skip sends the rating without a note â an explicit way to not say why. 1719 var skip = createEl('button', { type: 'button', className: 'circuit-thumbs-skip', textContent: 'Skip' }); 1720 1721 send.addEventListener('click', function() { finish(true); }); 1722 skip.addEventListener('click', function() { finish(false); }); 1723 input.addEventListener('keydown', function(e) { 1724 if (e.key === 'Enter') finish(true); 1725 if (e.key === 'Escape') { selectedValue = null; syncSelection(); removeFollowup(); } 1726 }); 1727 1728 followup.appendChild(input); 1729 followup.appendChild(send); 1730 followup.appendChild(skip); 1731 container.appendChild(followup); 1732 input.focus(); 1733 } 1734 followup.querySelector('.circuit-thumbs-input').placeholder = 1735 selectedValue === 5 ? 'What was useful?' : 'What was missing?'; 1736 } 1737 1738 function handleThumb(value) { 1739 if (submitted) return; 1740 // No follow-up configured: the tap IS the whole interaction â submit 1741 // immediately and visibly, never via a hidden timer. 1742 if (config.showWrittenFeedback === false) { 1743 selectedValue = value; 1744 syncSelection(); 1745 finish(false); 1746 return; 1747 } 1748 selectedValue = selectedValue === value ? null : value; 1749 syncSelection(); 1750 if (selectedValue == null) { removeFollowup(); return; } 1751 showFollowup(); 1752 } 1753 1754 // Leaving with a selection un-sent: submit the rating alone so the 1755 // signal isn't lost. finish() no-ops when already submitted or when 1756 // nothing is selected. 1757 window.addEventListener('pagehide', function() { finish(false, true); }); 1758 1759 thumbUp.addEventListener('click', function() { handleThumb(5); }); 1760 thumbDown.addEventListener('click', function() { handleThumb(1); }); 1761 1762 container.appendChild(thumbDown); 1763 container.appendChild(thumbUp); 1764 target.appendChild(container); 1765 _inlineContainers.push({ target: target, container: container }); 1766 } 1767 1768 var targets = document.querySelectorAll('[data-circuit-thumbs]'); 1769 if (targets.length === 0) { 1770 console.warn('[Annsa] Thumbs surface: no [data-circuit-thumbs] elements found. Waiting via MutationObserver (SPA support).'); 1771 }
1772 targets.forEach(attachThumbsTarget); 1773 1774 // Watch for late-rendered thumbs elements (React/SPA support) 1775 if (typeof MutationObserver !== 'undefined') { 1776 _thumbsObserver = new MutationObserver(function(mutations) { 1777 mutations.forEach(function(mutation) { 1778 mutation.addedNodes.forEach(function(node) { 1779 if (node.nodeType !== 1) return; 1780 if (node.hasAttribute && node.hasAttribute('data-circuit-thumbs')) attachThumbsTarget(node); 1781 if (node.querySelectorAll) node.querySelectorAll('[data-circuit-thumbs]').forEach(attachThumbsTarget); 1782 }); 1783 }); 1784 }); 1785 _thumbsObserver.observe(document.body, { childList: true, subtree: true }); 1786 } 1787 } 1788 1789 // ========================================================================= 1790 // PAGE SURFACE (CTA link opens modal) 1791 // ========================================================================= 1792 1793 // Fetches surface-specific config overrides for a given widget_id. 1794 // Results are cached in _surfaceConfigCache so repeated clicks don't re-fetch. 1795 // Returns an object with the fields that differ from the global config, 1796 // or null if the fetch fails (caller uses global config as fallback). 1797 async function fetchSurfaceConfig(wid, baseConfig) { 1798 if (_surfaceConfigCache[wid]) return _surfaceConfigCache[wid]; 1799 try { 1800 var controller = new AbortController(); 1801 var tid = setTimeout(function() { controller.abort(); }, 3000); 1802 var url = API_BASE + '/surface/config/public?api_key=' + encodeURIComponent(apiKey) 1803 + '&widget_id=' + encodeURIComponent(wid); 1804 var resp = await fetch(url, { signal: controller.signal }); 1805 clearTimeout(tid); 1806 if (resp.ok) { 1807 var sc = await resp.json(); 1808 var overrides = { 1809 title: sc.title || baseConfig.title, 1810 placeholder: sc.placeholder || baseConfig.placeholder, 1811 thankYouMessage: sc.thank_you_message || baseConfig.thankYouMessage, 1812 primaryColor: sanitizeColor(sc.primary_color) || baseConfig.primaryColor, 1813 textColor: sanitizeColor(sc.text_color) || baseConfig.textColor, 1814 }; 1815 _surfaceConfigCache[wid] = overrides; 1816 return overrides; 1817 } 1818 } catch (e) { /* network/timeout â fall through to global config */ } 1819 return null; 1820 } 1821 1822 function createPageSurface(config) { 1823 var floatInstance = createFloatSurface(config); 1824 // Remove the FAB since page surface uses CTA links 1825 if (floatInstance.fab && floatInstance.fab.parentNode) { 1826 floatInstance.fab.parentNode.removeChild(floatInstance.fab); 1827 } 1828 1829 var appendRoot = _shadowRoot || document.body; 1830 1831 // Wrap close so _activeWidgetId resets to the global surface when the modal dismisses. 1832 var _origClose = floatInstance.close; 1833 floatInstance.close = function() { 1834 _activeWidgetId = widgetId; 1835 // Restore global config colors when modal closes (another surface may have changed them). 1836 injectStyles(config); 1837 _origClose(); 1838 }; 1839 1840 // Attach click handler to a single [data-circuit-page] element. 1841 // Guarded by _attachedPageTargets (WeakSet) to prevent double-attach when 1842 // the MutationObserver fires on elements already handled in the initial scan. 1843 function attachPageTrigger(target) { 1844 if (_attachedPageTargets && _attachedPageTargets.has(target)) return; 1845 if (_attachedPageTargets) _attachedPageTargets.add(target); 1846 1847 // Pre-warm config cache on hover/focus so the click opens instantly. 1848 // Only needed when the element targets a different surface than the script tag. 1849 var elemWid = (target.getAttribute('data-surface-id') || '').trim(); 1850 if (elemWid && elemWid !== widgetId) { 1851 function prewarm() { fetchSurfaceConfig(elemWid, config); } 1852 target.addEventListener('mouseenter', prewarm, { once: true }); 1853 target.addEventListener('focus', prewarm, { once: true }); 1854 } 1855 1856 target.addEventListener('click', async function(e) { 1857 e.preventDefault(); 1858 var elemWid = (target.getAttribute('data-surface-id') || '').trim(); 1859 1860 // Same surface as script tag (or no per-element override) â open unchanged. 1861 if (!elemWid || elemWid === widgetId) { 1862 _activeWidgetId = widgetId; 1863 // Restore global modal text in case a different surface was opened earlier. 1864 var titleEl = appendRoot.querySelector('.circuit-title'); 1865 var textareaEl = appendRoot.querySelector('.circuit-textarea'); 1866 if (titleEl) titleEl.textContent = config.title; 1867 if (textareaEl) textareaEl.placeholder = config.placeholder; 1868 floatInstance.open(); 1869 return; 1870 } 1871 1872 // Different surface: fetch its config overrides (cached after first load). 1873 _activeWidgetId = elemWid; 1874 var overrides = await fetchSurfaceConfig(elemWid, config); 1875 var titleEl = appendRoot.querySelector('.circuit-title'); 1876 var textareaEl = appendRoot.querySelector('.circuit-textarea'); 1877 if (titleEl) titleEl.textContent = (overrides && overrides.title) || config.title; 1878 if (textareaEl) textareaEl.placeholder = (overrides && overrides.placeholder) || config.placeholder; 1879 // Re-inject styles with surface colors if they differ from global config. 1880 if (overrides && (overrides.primaryColor !== config.primaryColor || overrides.textColor !== config.textColor)) { 1881 injectStyles(Object.assign({}, config, { primaryColor: overrides.primaryColor, textColor: overrides.textColor })); 1882 } 1883 floatInstance.open(); 1884 }); 1885 } 1886 1887 // Attach to elements with data-circuit-page already in the DOM. 1888 // If an element carries data-surface-id, that surface's config is fetched lazily
1889 // (and cached) so the modal title/placeholder/colors update before opening. 1890 var targets = document.querySelectorAll('[data-circuit-page]'); 1891 if (targets.length === 0) { 1892 console.warn('[Annsa] Page surface: no [data-circuit-page] elements found. Waiting for elements via MutationObserver (React/SPA support).'); 1893 } 1894 targets.forEach(attachPageTrigger); 1895 1896 // MutationObserver: catch [data-circuit-page] elements added after init 1897 // (React SPAs, dynamically rendered headers, etc.) 1898 if (typeof MutationObserver !== 'undefined') { 1899 _pageObserver = new MutationObserver(function(mutations) { 1900 mutations.forEach(function(mutation) { 1901 mutation.addedNodes.forEach(function(node) { 1902 if (node.nodeType !== 1) return; // elements only 1903 // Check the node itself 1904 if (node.hasAttribute && node.hasAttribute('data-circuit-page')) { 1905 attachPageTrigger(node); 1906 } 1907 // Check descendants 1908 if (node.querySelectorAll) { 1909 node.querySelectorAll('[data-circuit-page]').forEach(attachPageTrigger); 1910 } 1911 }); 1912 }); 1913 }); 1914 _pageObserver.observe(document.body, { childList: true, subtree: true }); 1915 } 1916 1917 return floatInstance; 1918 } 1919 1920 // ========================================================================= 1921 // INITIALIZATION 1922 // ========================================================================= 1923 1924 var floatInstance = null; 1925 1926 function initWidget() { 1927 var config = Object.assign({}, defaultConfig, { apiKey: apiKey }); 1928 1929 // Apply embed-tag attribute overrides synchronously â these are baked into the embed code 1930 // so the FAB can render immediately without waiting for a network round-trip. 1931 var _ec = sanitizeColor(currentScript.getAttribute('data-primary-color')); 1932 if (_ec) config.primaryColor = _ec; 1933 var _etc = sanitizeColor(currentScript.getAttribute('data-text-color')); 1934 if (_etc) config.textColor = _etc; 1935 var _ep = currentScript.getAttribute('data-position'); 1936 if (_ep) config.position = _ep; 1937 var _et = currentScript.getAttribute('data-theme'); 1938 if (_et) config.theme = _et; 1939 var _ebt = currentScript.getAttribute('data-button-text'); 1940 if (_ebt) config.buttonText = _ebt; 1941 var _ebr = sanitizeDimension(currentScript.getAttribute('data-border-radius')); 1942 if (_ebr) config.borderRadius = _ebr; 1943 var _eff = currentScript.getAttribute('data-font-family'); 1944 if (_eff && FONT_FAMILIES[_eff]) { config.fontFamily = _eff; injectFontFace(_eff); } 1945 if (currentScript.getAttribute('data-show-branding') === 'false') config.showBranding = false; 1946 1947 // Sanitize (embed-tag values are already sanitized above; this is a safety net for defaults) 1948 config.primaryColor = sanitizeColor(config.primaryColor) || defaultConfig.primaryColor; 1949 config.textColor = sanitizeColor(config.textColor) || defaultConfig.textColor; 1950 config.borderRadius = sanitizeDimension(config.borderRadius) || defaultConfig.borderRadius; 1951 1952 // Resolve {page_name} token in title (e.g. "Help us improve {page_name}") 1953 if (config.title && config.title.indexOf('{page_name}') !== -1) { 1954 var pathParts = window.location.pathname.replace(/\/$/, '').split('/').filter(Boolean); 1955 var pageName = pathParts.length > 0 1956 ? pathParts[pathParts.length - 1].replace(/[-_]/g, ' ') 1957 : 'this page'; 1958 pageName = pageName.charAt(0).toUpperCase() + pageName.slice(1); 1959 config.title = config.title.replace('{page_name}', pageName); 1960 } 1961 1962 // Fetch server config in background â FAB renders immediately above, server overrides 1963 // are applied afterward (colors via style re-injection, advanced settings for next open). 1964 // _t param busts any CDN/CloudFront cache so saved config changes take effect immediately. 1965 var _cfgUrl = API_BASE + '/surface/config/public?api_key=' + encodeURIComponent(apiKey) + '&_t=' + Date.now(); 1966 if (widgetId) _cfgUrl += '&widget_id=' + encodeURIComponent(widgetId); 1967 _cfgController = new AbortController(); 1968 var _cfgTimeout = setTimeout(function() { _cfgController.abort(); }, 3000); 1969 fetch(_cfgUrl, { signal: _cfgController.signal }).then(function(response) { 1970 clearTimeout(_cfgTimeout); 1971 if (response.status === 401) { 1972 console.warn('[Annsa] Invalid API key. Widget will not render.'); 1973 _widgetDisabled = true; 1974 _hideAllSurfaces(); 1975 return; 1976 } 1977 if (response.status === 403) { 1978 console.warn('[Annsa] Widget is disabled. Widget will not render.'); 1979 _widgetDisabled = true; 1980 _hideAllSurfaces(); 1981 return; 1982 } 1983 if (!response.ok) { 1984 console.warn('[Annsa] Config fetch failed (' + response.status + '). Using default config.'); 1985 return; 1986 } 1987 return response.json(); 1988 }).then(function(serverConfig) { 1989 if (!serverConfig) return; 1990 // Surface disabled â hide widget entirely (customer toggled off in Annsa dashboard). 1991 if (serverConfig.enabled === false) { 1992 _widgetDisabled = true; 1993 _hideAllSurfaces(); 1994 return; 1995 } 1996 // Only apply server color if the embed tag didn't explicitly set it. 1997 // Explicit embed-tag attributes take priority over server config. 1998 if (!_explicitPrimaryColor) { 1999 config.primaryColor = sanitizeColor(serverConfig.primary_color) || config.primaryColor; 2000 } 2001 if (!_explicitTextColor) { 2002 config.textColor = sanitizeColor(serverConfig.text_color) || config.textColor; 2003 } 2004 config.position = serverConfig.position || config.position; 2005 config.theme = serverConfig.theme || config.theme; 2006 config.buttonText = serverConfig.button_text || config.buttonText; 2007 config.borderRadius = sanitizeDimension(serverConfig.border_radius) || config.borderRadius; 2008 config.showBranding = serverConfig.show_branding !== false; 2009 config.thankYouMessage = serverConfig.thank_you_message || config.thankYouMessage; 2010 config.title = serverConfig.title || config.title; 2011 config.placeholder = serverConfig.placeholder || config.placeholder; 2012 config.showTypeSelector = serverConfig.show_type_selector || false; 2013 config.showRating = serverConfig.show_rating === true; 2014 config.showWrittenFeedback = serverConfig.show_written_feedback !== false;
2015 config.supportUrl = serverConfig.support_url || null; 2016 config.docsUrl = serverConfig.docs_url || null; 2017 if (serverConfig.font_family && FONT_FAMILIES[serverConfig.font_family]) { 2018 config.fontFamily = serverConfig.font_family; 2019 injectFontFace(config.fontFamily); 2020 } 2021 config.captureUrl = serverConfig.capture_url !== false; 2022 // Survey template + scale labels â gate the scale renderer in createRatingRow. 2023 if (_VALID_TEMPLATES.indexOf(serverConfig.survey_template) !== -1) { 2024 config.surveyTemplate = serverConfig.survey_template; 2025 } 2026 if (serverConfig.scale_low_label) config.scaleLowLabel = serverConfig.scale_low_label; 2027 if (serverConfig.scale_high_label) config.scaleHighLabel = serverConfig.scale_high_label; 2028 if (serverConfig.follow_up_question) config.followUpQuestion = serverConfig.follow_up_question; 2029 // Surveys always need the rating row visible â it's the whole interaction. 2030 if (config.surveyTemplate && config.surveyTemplate !== 'open') config.showRating = true; 2031 // Frequency / cadence â apply server-side trigger_rules and run the 2032 // eligibility check. If the user has been asked recently (per localStorage) 2033 // we hide the surface so it doesn't render again. signup_date can come 2034 // from window.AnnsaSurveyConfig.user.signup_date. 2035 if (serverConfig.trigger_rules && typeof serverConfig.trigger_rules === 'object') { 2036 config.triggerRules = serverConfig.trigger_rules; 2037 } 2038 if (config.triggerRules && _activeWidgetId) { 2039 var _userMeta = (window.AnnsaSurveyConfig && window.AnnsaSurveyConfig.user) || {}; 2040 var _signup = _userMeta.signup_date || _userMeta.signupDate || null; 2041 var _decision = checkEligibility(config.triggerRules, _activeWidgetId, _signup); 2042 if (!_decision.eligible) { 2043 if (console && console.info) { 2044 console.info('[Annsa] Surface hidden by frequency rules:', _decision.reason); 2045 } 2046 _widgetDisabled = true; 2047 _hideAllSurfaces(); 2048 return; 2049 } 2050 // Eligible â record the impression so the next visit honours 2051 // cadence_after_impression_days even if the user doesn't interact. 2052 recordImpression(_activeWidgetId); 2053 } 2054 // trigger_mode: 'inline' means no FAB â widget opened programmatically or via trigger selector 2055 if (serverConfig.trigger_mode === 'inline') { isHeadless = true; } 2056 // Re-inject styles to apply any changed colors/fonts 2057 injectStyles(config); 2058 // Update modal title text if it changed (visible on next modal open) 2059 var titleEl = (_shadowRoot || document).querySelector('#circuit-modal-title'); 2060 if (titleEl && serverConfig.title) titleEl.textContent = config.title; 2061 2062 // Simplified embed (no data-survey attribute): use server-provided 2063 // surface_type to determine which surface to render. This fires after the 2064 // config fetch completes (~100-300ms) rather than immediately on load. 2065 if (!_surfaceAttr) { 2066 if (serverConfig.surface_type && _VALID_SURFACES.indexOf(serverConfig.surface_type) !== -1) { 2067 surfaceType = serverConfig.surface_type; 2068 } 2069 // Initialise shadow/light DOM roots needed for the resolved surface type 2070 var _needsShadowDeferred = (surfaceType === 'float' || surfaceType === 'bar' || surfaceType === 'page' || isHeadless); 2071 if (_needsShadowDeferred && !_shadowRoot) { 2072 try { 2073 _shadowHost = document.createElement('div'); 2074 _shadowHost.id = 'circuit-widget-root'; 2075 document.body.appendChild(_shadowHost); 2076 _shadowRoot = _shadowHost.attachShadow({ mode: 'open' }); 2077 _styleRoots.push(_shadowRoot); 2078 } catch (e) { 2079 if (_shadowHost && _shadowHost.parentNode) _shadowHost.parentNode.removeChild(_shadowHost); 2080 _shadowRoot = null; 2081 _shadowHost = null; 2082 } 2083 } 2084 injectStyles(config); 2085 _initSurfaceRouting(); 2086 } 2087 }).catch(function() { clearTimeout(_cfgTimeout); }); 2088 2089 // ----------------------------------------------------------------------- 2090 // Shadow DOM / light DOM setup â only when surface type is already known. 2091 // When data-survey is absent the surface type comes from the server config 2092 // fetch (deferred path above), so DOM setup is also deferred to that path. 2093 // ----------------------------------------------------------------------- 2094 if (_surfaceAttr) { 2095 var needsShadow = (surfaceType === 'float' || surfaceType === 'bar' || surfaceType === 'page' || isHeadless); 2096 if (needsShadow) { 2097 try { 2098 _shadowHost = document.createElement('div'); 2099 _shadowHost.id = 'circuit-widget-root'; 2100 document.body.appendChild(_shadowHost); 2101 _shadowRoot = _shadowHost.attachShadow({ mode: 'open' }); 2102 _styleRoots.push(_shadowRoot); 2103 } catch (e) { 2104 // Shadow DOM not supported â clean up host, fall back to document.body 2105 if (_shadowHost && _shadowHost.parentNode) _shadowHost.parentNode.removeChild(_shadowHost); 2106 _shadowRoot = null; 2107 _shadowHost = null; 2108 } 2109 } 2110 2111 // Inline/thumbs live in light DOM â inject styles into document.head 2112 var needsLightDOM = ( 2113 surfaceType === 'inline' || surfaceType === 'thumbs' || 2114 document.querySelector('[data-circuit-inline]') || 2115 document.querySelector('[data-circuit-thumbs]') 2116 ); 2117 if (needsLightDOM || !_shadowRoot) { 2118 _styleRoots.push(document.head); 2119 } 2120 2121 // Inject styles into all registered roots 2122 injectStyles(config); 2123 } else { 2124 // Simplified embed â always push document.head as a style root so that 2125 // inline/thumbs elements (which use light DOM) work when resolved from server. 2126 _styleRoots.push(document.head); 2127 } 2128 2129 // Listen for theme changes â both OS-level (prefers-color-scheme) and 2130 // in-app dark mode (host page adds/removes .dark class on <html>)
2131 // Deferred to idle: theme detection isn't needed in the first frame 2132 if (config.theme === 'auto') { 2133 idleCallback(function() { 2134 if (window.matchMedia) { 2135 window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function() { 2136 injectStyles(config); 2137 }); 2138 } 2139 _themeObserver = new MutationObserver(function() { 2140 clearTimeout(_themeDebounceTimer); 2141 _themeDebounceTimer = setTimeout(function() { injectStyles(config); }, 100); 2142 }); 2143 _themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] }); 2144 _themeObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] }); 2145 }); 2146 } 2147 2148 // Route to the correct surface. Called immediately when data-survey is 2149 // present in the embed tag, or deferred until after the server config 2150 // responds when data-survey is absent (simplified embed). 2151 function _initSurfaceRouting() { 2152 if (surfaceType === 'inline' || document.querySelector('[data-circuit-inline]')) { 2153 createInlineSurface(config); 2154 } 2155 2156 if (surfaceType === 'thumbs' || document.querySelector('[data-circuit-thumbs]')) { 2157 createThumbsSurface(config); 2158 } 2159 2160 if (surfaceType === 'bar') { 2161 createBarSurface(config); 2162 } 2163 2164 if (surfaceType === 'page' || document.querySelector('[data-circuit-page]')) { 2165 floatInstance = createPageSurface(config); 2166 } 2167 2168 if (surfaceType === 'float' || (!isHeadless && surfaceType !== 'bar' && surfaceType !== 'inline' && surfaceType !== 'thumbs' && surfaceType !== 'page')) { 2169 floatInstance = createFloatSurface(config); 2170 } 2171 // Drain any open() calls that landed before floatInstance was ready. 2172 // This handles both the simplified embed race (config fetch not yet done) 2173 // and pre-load clicks that were queued and replayed before floatInstance existed. 2174 if (_deferredOpen && floatInstance) { 2175 _deferredOpen = false; 2176 floatInstance.open(); 2177 } 2178 } 2179 2180 // If data-survey was explicitly set, render immediately (current behavior). 2181 // If absent, defer until server config returns the surface_type â this is 2182 // what powers the simplified one-liner embed (data-api-key only). 2183 if (_surfaceAttr) { 2184 _initSurfaceRouting(); 2185 } 2186 2187 // Check for shipped notifications (non-blocking, deferred to idle) 2188 var appendRoot = _shadowRoot || document.body; 2189 idleCallback(function() { pollNotifications(config, appendRoot); }); 2190 2191 // Global API 2192 window.AnnsaSurvey = { 2193 version: WIDGET_VERSION, 2194 open: function(surface) { 2195 if (floatInstance) floatInstance.open(); 2196 else if (!_widgetDisabled) _deferredOpen = true; 2197 }, 2198 close: function() { 2199 if (floatInstance) floatInstance.close(); 2200 }, 2201 config: config, 2202 identify: function(user) { 2203 if (!user || typeof user !== 'object') return; 2204 window.AnnsaSurveyConfig = window.AnnsaSurveyConfig || {}; 2205 window.AnnsaSurveyConfig.user = Object.assign({}, window.AnnsaSurveyConfig.user || {}, user); 2206 }, 2207 reset: function() { 2208 window.AnnsaSurveyConfig = window.AnnsaSurveyConfig || {}; 2209 window.AnnsaSurveyConfig.user = null; 2210 }, 2211 setUser: function(user) { window.AnnsaSurvey.identify(user); }, 2212 destroy: function() { 2213 // Remove shadow host (contains float/bar elements and their styles) 2214 if (_shadowHost && _shadowHost.parentNode) { 2215 _shadowHost.parentNode.removeChild(_shadowHost); 2216 } 2217 // Remove inline/thumbs containers from their targets 2218 _inlineContainers.forEach(function(item) { 2219 if (item.container && item.container.parentNode === item.target) { 2220 item.target.removeChild(item.container); 2221 } 2222 }); 2223 _inlineContainers = []; 2224 // Remove light DOM styles 2225 var lightStyle = document.getElementById('circuit-styles'); 2226 if (lightStyle) lightStyle.parentNode.removeChild(lightStyle); 2227 // Remove online listener 2228 if (_onlineHandler) { 2229 window.removeEventListener('online', _onlineHandler); 2230 _onlineHandler = null; 2231 } 2232 // Stop MutationObserver (Lesson 93 â theme tracking) 2233 if (_themeObserver) {
2234 _themeObserver.disconnect(); 2235 _themeObserver = null; 2236 } 2237 // Stop page-trigger MutationObserver 2238 if (_pageObserver) { 2239 _pageObserver.disconnect(); 2240 _pageObserver = null; 2241 } 2242 // Stop inline/thumbs SPA observers 2243 if (_inlineObserver) { 2244 _inlineObserver.disconnect(); 2245 _inlineObserver = null; 2246 } 2247 if (_thumbsObserver) { 2248 _thumbsObserver.disconnect(); 2249 _thumbsObserver = null; 2250 } 2251 _attachedPageTargets = typeof WeakSet !== 'undefined' ? new WeakSet() : null; 2252 // Clear pending theme debounce timer 2253 if (_themeDebounceTimer) { 2254 clearTimeout(_themeDebounceTimer); 2255 _themeDebounceTimer = null; 2256 } 2257 // Abort pending config fetch 2258 if (_cfgController) { 2259 _cfgController.abort(); 2260 } 2261 // Remove Escape key listener 2262 if (_escHandler) { 2263 document.removeEventListener('keydown', _escHandler); 2264 _escHandler = null; 2265 } 2266 // Clean up trigger selector listener 2267 if (_triggerEl && _triggerHandler) { 2268 _triggerEl.removeEventListener('click', _triggerHandler); 2269 _triggerEl = null; 2270 _triggerHandler = null; 2271 } 2272 // Reset state 2273 _shadowHost = null; 2274 _shadowRoot = null; 2275 _styleRoots = []; 2276 _surfaceConfigCache = {}; 2277 floatInstance = null; 2278 _deferredOpen = false; 2279 window.__circuitWidgetLoaded = false; 2280 window.AnnsaSurvey = null; 2281 } 2282 }; 2283 2284 // Replay any commands queued before init completed 2285 _pendingQueue.forEach(function(call) { 2286 var method = Array.isArray(call) ? call[0] : call; 2287 var args = Array.isArray(call) ? call.slice(1) : []; 2288 if (window.AnnsaSurvey && typeof window.AnnsaSurvey[method] === 'function') { 2289 window.AnnsaSurvey[method].apply(null, args); 2290 } 2291 }); 2292 2293 // Drain any feedback queued while offline (deferred to idle â not needed in first frame) 2294 idleCallback(function() { 2295 drainOfflineQueue(); 2296 _onlineHandler = drainOfflineQueue; 2297 window.addEventListener('online', _onlineHandler); 2298 }); 2299 } 2300 2301 // With async loading, the script may execute before the DOM is ready. 2302 // Wait for DOMContentLoaded so surface target selectors find their elements. 2303 if (document.readyState === 'loading') { 2304 document.addEventListener('DOMContentLoaded', initWidget); 2305 } else { 2306 initWidget(); 2307 } 2308})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.