1/** 2 * Shared thumbs-up/down feedback modal for ns_t3ac chatbot and ns_t3as AI search. 3 */ 4(function (global) { 5 if (global.FeedbackRatingUi && global.FeedbackRatingUi.__initialized) { 6 return; 7 } 8 9 const COMMENT_MAX = 2000; 10 11 const DEFAULT_LABELS = { 12 tooltip: 'Was this helpful?', 13 thanks: 'Thanks for your feedback!', 14 reasonsAria: 'Select feedback reasons', 15 modalPositiveTitle: 'Positive feedback', 16 modalNegativeTitle: 'Negative feedback', 17 reasonsOptional: 'Reasons (optional)', 18 commentsOptional: 'Additional Comments (Optional)', 19 commentsPlaceholder: 'Tell us more about your experience with this response...', 20 commentsCounter: '%1$d/%2$d characters', 21 cancel: 'Cancel', 22 submit: 'Submit Feedback', 23 close: 'Close', 24 positiveReasonOrder: ['helpful', 'accurate', 'complete', 'clear', 'fast_response'], 25 negativeReasonOrder: [ 26 'incorrect', 27 'hallucinated', 28 'missing_sources', 29 'too_technical', 30 'outdated_information', 31 'low_quality', 32 'poor_formatting', 33 'slow_response', 34 ], 35 reasons: {}, 36 }; 37 38 let activeModalRoot = null; 39 let activeSession = null; 40 let escapeListenerBound = false; 41 42 function resolveModalContainer(container) { 43 if (container && typeof container.appendChild === 'function') { 44 return container; 45 } 46 return global.document?.body || document.body; 47 } 48 49 function isScopedModalContainer(container) { 50 const body = global.document?.body || document.body; 51 return container !== body; 52 } 53 54 function parseThumbRating(btn) { 55 if (!btn) { 56 return 0; 57 } 58 const raw = String(btn.getAttribute('data-rating') ?? btn.dataset?.rating ?? '0').trim(); 59 if (raw === 'up' || raw === 'positive') { 60 return 1; 61 } 62 if (raw === 'down' || raw === 'negative') { 63 return -1; 64 } 65 const parsed = parseInt(raw, 10); 66 if (Number.isNaN(parsed) || parsed === 0) { 67 return 0; 68 } 69 return parsed > 0 ? 1 : -1; 70 } 71 72 function isThumbRating(value) { 73 return value === 1 || value === -1; 74 } 75 76 function resolveSearchHistoryIdForRow(row, btn) { 77 const fromBtn = parseInt(btn?.dataset?.searchHistoryId || btn?.dataset?.messageId || '0', 10); 78 if (!Number.isNaN(fromBtn) && fromBtn > 0) { 79 return fromBtn; 80 } 81 const fromRow = parseInt(row?.dataset?.searchHistoryId || row?.dataset?.messageId || '0', 10); 82 if (!Number.isNaN(fromRow) && fromRow > 0) { 83 return fromRow; 84 } 85 const resultsEl = 86 row?.closest?.('.ns-t3as-search-results') || 87 row?.closest?.('.search-results') || 88 global.document?.getElementById?.('searchResults'); 89 if (resultsEl) { 90 const meta = resultsEl.querySelector?.('.ns-t3as-search-history-meta'); 91 if (meta) { 92 const fromMeta = parseInt(meta.getAttribute('data-search-history-id') || '0', 10); 93 if (!Number.isNaN(fromMeta) && fromMeta > 0) { 94 return fromMeta; 95 } 96 } 97 const fromResults = parseInt(resultsEl.dataset?.searchHistoryId || '0', 10); 98 if (!Number.isNaN(fromResults) && fromResults > 0) { 99 return fromResults; 100 } 101 } 102 return 0; 103 } 104 105 function parseLabels(containerOrJson) { 106 if (typeof containerOrJson === 'string') { 107 try { 108 return { ...DEFAULT_LABELS, ...JSON.parse(containerOrJson) }; 109 } catch (e) { 110 return { ...DEFAULT_LABELS }; 111 } 112 } 113 if (containerOrJson && typeof containerOrJson === 'object') { 114 if (containerOrJson.tooltip) { 115 return { ...DEFAULT_LABELS, ...containerOrJson }; 116 } 117 if (containerOrJson.dataset && containerOrJson.dataset.feedbackLabels) { 118 try { 119 return { ...DEFAULT_LABELS, ...JSON.parse(containerOrJson.dataset.feedbackLabels) }; 120 } catch (e) { 121 return { ...DEFAULT_LABELS }; 122 } 123 } 124 } 125 if (global.nsT3csFeedbackLabels) { 126 return { ...DEFAULT_LABELS, ...global.nsT3csFeedbackLabels }; 127 } 128 return { ...DEFAULT_LABELS }; 129 } 130 131 function getPrefix(row) { 132 return row.classList.contains('ns-t3as-rating-row') ? 'ns-t3as' : 'chatbot'; 133 } 134 135 function formatCounter(template, current, max) { 136 const tpl = template || DEFAULT_LABELS.commentsCounter; 137 return tpl.replace('%1$d', String(current)).replace('%2$d', String(max)); 138 } 139 140 function showToast(row, text, durationMs) { 141 const prefix = getPrefix(row); 142 const existing = row.querySelector(`.${prefix}-feedback-toast`); 143 if (existing) { 144 existing.remove(); 145 } 146 const el = document.createElement('div'); 147 el.className = `${prefix}-feedback-toast`; 148 el.setAttribute('role', 'status'); 149 el.textContent = text; 150 row.appendChild(el); 151 requestAnimationFrame(() => el.classList.add('is-visible')); 152 window.setTimeout(() => { 153 el.classList.remove('is-visible'); 154 window.setTimeout(() => el.remove(), 300); 155 }, durationMs || 1500); 156 } 157 158 function isRowFeedbackLocked(row) { 159 return ( 160 row && 161 (row.dataset.feedbackSubmitted === '1' || row.classList.contains('is-submitted')) 162 ); 163 } 164 165 function markRowFeedback(row, activeBtn, rating) { 166 if (!row || !activeBtn) { 167 return; 168 } 169 row.dataset.feedbackRating = String(rating); 170 row.querySelectorAll('[data-rating]').forEach((ratingBtn) => { 171 ratingBtn.classList.remove('is-active', 'is-bounce'); 172 }); 173 activeBtn.classList.add('is-active'); 174 if (rating > 0) { 175 activeBtn.classList.add('is-bounce'); 176 } 177 } 178 179 function lockRowFeedbackAfterSubmit(row, activeBtn, rating) { 180 if (!row) { 181 return; 182 } 183 row.dataset.feedbackSubmitted = '1'; 184 row.classList.add('is-submitted'); 185 row.setAttribute('aria-disabled', 'true'); 186 markRowFeedback(row, activeBtn, rating); 187 row.querySelectorAll('[data-rating]').forEach((ratingBtn) => { 188 ratingBtn.disabled = true; 189 ratingBtn.setAttribute('aria-disabled', 'true'); 190 }); 191 } 192 193 function unlockRowFeedbackAfterFailedSubmit(row) { 194 if (!row) { 195 return; 196 } 197 delete row.dataset.feedbackSubmitted; 198 delete row.dataset.feedbackRating; 199 delete row.dataset.feedbackReasons; 200 delete row.dataset.feedbackComment; 201 row.classList.remove('is-submitted'); 202 row.removeAttribute('aria-disabled'); 203 row.querySelectorAll('[data-rating]').forEach((ratingBtn) => { 204 ratingBtn.disabled = false;
205 ratingBtn.removeAttribute('aria-disabled'); 206 ratingBtn.classList.remove('is-active', 'is-bounce'); 207 }); 208 } 209 210 function applyTooltip(btn, labels) { 211 const tooltip = labels.tooltip || DEFAULT_LABELS.tooltip; 212 btn.setAttribute('aria-label', tooltip); 213 btn.setAttribute('data-tooltip', tooltip); 214 } 215 216 function decorateRatingButtons(row, labels) { 217 row.querySelectorAll('[data-rating]').forEach((btn) => { 218 applyTooltip(btn, labels); 219 }); 220 } 221 222 function collectModalRefs(root) { 223 return { 224 icon: root.querySelector('[data-ref="icon"]'), 225 title: root.querySelector('[data-ref="title"]'), 226 closeBtn: root.querySelector('[data-ref="closeBtn"]'), 227 reasonsLabel: root.querySelector('[data-ref="reasonsLabel"]'), 228 commentsLabel: root.querySelector('[data-ref="commentsLabel"]'), 229 comment: root.querySelector('[data-ref="comment"]'), 230 counter: root.querySelector('[data-ref="counter"]'), 231 cancelBtn: root.querySelector('[data-ref="cancelBtn"]'), 232 submitBtn: root.querySelector('[data-ref="submitBtn"]'), 233 footer: root.querySelector('.ns-t3cs-feedback-modal__footer'), 234 chips: root.querySelector('[data-ref="chips"]'), 235 dialog: root.querySelector('.ns-t3cs-feedback-modal__dialog'), 236 backdrop: root.querySelector('[data-action="backdrop"]'), 237 }; 238 } 239 240 function cleanupDuplicateModals(container) { 241 const modals = container?.querySelectorAll?.('.ns-t3cs-feedback-modal'); 242 if (!modals || modals.length <= 1) { 243 return; 244 } 245 for (let i = 1; i < modals.length; i += 1) { 246 modals[i].remove(); 247 } 248 } 249 250 function ensureModalRoot(container) { 251 const mount = resolveModalContainer(container); 252 const scoped = isScopedModalContainer(mount); 253 cleanupDuplicateModals(mount); 254 const existing = mount.querySelector?.('.ns-t3cs-feedback-modal'); 255 if (existing) { 256 if (!existing._refs) { 257 existing._refs = collectModalRefs(existing); 258 } 259 bindModalEvents(existing); 260 return existing; 261 } 262 const root = document.createElement('div'); 263 root.className = scoped 264 ? 'ns-t3cs-feedback-modal ns-t3cs-feedback-modal--scoped' 265 : 'ns-t3cs-feedback-modal'; 266 root.hidden = true; 267 root.innerHTML = 268 '<div class="ns-t3cs-feedback-modal__backdrop" data-action="backdrop"></div>' + 269 '<div class="ns-t3cs-feedback-modal__dialog" role="dialog" aria-modal="true" aria-labelledby="ns-t3cs-feedback-modal-title">' + 270 '<header class="ns-t3cs-feedback-modal__header">' + 271 '<span class="ns-t3cs-feedback-modal__icon" data-ref="icon" aria-hidden="true"></span>' + 272 '<h2 class="ns-t3cs-feedback-modal__title" id="ns-t3cs-feedback-modal-title" data-ref="title"></h2>' + 273 '<button type="button" class="ns-t3cs-feedback-modal__close" data-action="close" data-ref="closeBtn" aria-label="Close">×</button>' + 274 '</header>' + 275 '<div class="ns-t3cs-feedback-modal__body">' + 276 '<div class="ns-t3cs-feedback-modal__field">' + 277 '<span class="ns-t3cs-feedback-modal__label" data-ref="reasonsLabel"></span>' + 278 '<div class="ns-t3cs-feedback-modal__chips" data-ref="chips" role="group"></div>' + 279 '</div>' + 280 '<div class="ns-t3cs-feedback-modal__field">' + 281 '<label class="ns-t3cs-feedback-modal__label" data-ref="commentsLabel" for="ns-t3cs-feedback-comment"></label>' + 282 '<textarea id="ns-t3cs-feedback-comment" class="ns-t3cs-feedback-modal__textarea" data-ref="comment" rows="5" maxlength="' + 283 COMMENT_MAX + 284 '"></textarea>' + 285 '<div class="ns-t3cs-feedback-modal__counter" data-ref="counter"></div>' + 286 '</div>' + 287 '</div>' + 288 '<footer class="ns-t3cs-feedback-modal__footer">' + 289 '<button type="button" class="ns-t3cs-feedback-modal__btn ns-t3cs-feedback-modal__btn--cancel" data-action="cancel" data-ref="cancelBtn"></button>' + 290 '<button type="button" class="ns-t3cs-feedback-modal__btn ns-t3cs-feedback-modal__btn--submit" data-action="submit" data-ref="submitBtn"></button>' + 291 '</footer>' + 292 '</div>'; 293 mount.appendChild(root); 294 295 const refs = collectModalRefs(root); 296 root._refs = refs; 297 bindModalEvents(root); 298 return root; 299 } 300 301 function bindModalEvents(root) { 302 const refs = root._refs; 303 if (!refs || refs.__eventsBound) { 304 return; 305 } 306 refs.__eventsBound = true; 307 308 refs.backdrop?.addEventListener('click', (event) => { 309 event.preventDefault(); 310 closeModal(); 311 }); 312 313 refs.closeBtn?.addEventListener('click', (event) => { 314 event.preventDefault(); 315 event.stopPropagation(); 316 closeModal(); 317 }); 318 319 refs.cancelBtn?.addEventListener('click', (event) => { 320 event.preventDefault(); 321 event.stopPropagation(); 322 closeModal(); 323 }); 324 325 refs.submitBtn?.addEventListener('click', (event) => { 326 event.preventDefault(); 327 event.stopPropagation(); 328 submitModal(); 329 }); 330 331 refs.dialog?.addEventListener('click', (event) => { 332 event.stopPropagation(); 333 }); 334 335 refs.comment.addEventListener('input', () => { 336 updateCounter(refs, activeSession?.labels || DEFAULT_LABELS); 337 }); 338 } 339 340 function updateCounter(refs, labels) { 341 const len = refs.comment.value.length; 342 refs.counter.textContent = formatCounter(labels.commentsCounter, len, COMMENT_MAX); 343 } 344 345 function buildChips(refs, labels, rating) { 346 refs.chips.innerHTML = ''; 347 const order = 348 rating > 0 349 ? labels.positiveReasonOrder || DEFAULT_LABELS.positiveReasonOrder 350 : labels.negativeReasonOrder || DEFAULT_LABELS.negativeReasonOrder; 351 const codes = Array.isArray(order) ? order : []; 352 refs.chips.setAttribute('aria-label', labels.reasonsAria || DEFAULT_LABELS.reasonsAria); 353
354 codes.forEach((code) => { 355 const chip = document.createElement('button'); 356 chip.type = 'button'; 357 chip.className = 'ns-t3cs-feedback-modal__chip'; 358 chip.dataset.reason = code; 359 chip.textContent = (labels.reasons && labels.reasons[code]) || code; 360 chip.addEventListener('click', (event) => { 361 event.preventDefault(); 362 event.stopPropagation(); 363 chip.classList.toggle('is-selected'); 364 }); 365 refs.chips.appendChild(chip); 366 }); 367 } 368 369 function getSelectedReasons(refs) { 370 return Array.from(refs.chips.querySelectorAll('.ns-t3cs-feedback-modal__chip.is-selected')) 371 .map((chip) => chip.dataset.reason || '') 372 .filter(Boolean); 373 } 374 375 function openModal(session) { 376 if (isRowFeedbackLocked(session.row)) { 377 return; 378 } 379 const mount = resolveModalContainer(session.modalContainer); 380 const root = ensureModalRoot(mount); 381 const refs = root._refs; 382 activeModalRoot = root; 383 activeSession = session; 384 385 const isPositive = session.rating > 0; 386 refs.icon.textContent = isPositive ? 'ð' : 'ð'; 387 refs.title.textContent = isPositive 388 ? session.labels.modalPositiveTitle || DEFAULT_LABELS.modalPositiveTitle 389 : session.labels.modalNegativeTitle || DEFAULT_LABELS.modalNegativeTitle; 390 refs.reasonsLabel.textContent = session.labels.reasonsOptional || DEFAULT_LABELS.reasonsOptional; 391 refs.commentsLabel.textContent = session.labels.commentsOptional || DEFAULT_LABELS.commentsOptional; 392 refs.commentsLabel.setAttribute('for', 'ns-t3cs-feedback-comment'); 393 refs.comment.placeholder = 394 session.labels.commentsPlaceholder || DEFAULT_LABELS.commentsPlaceholder; 395 refs.comment.value = ''; 396 refs.closeBtn.setAttribute('aria-label', session.labels.close || DEFAULT_LABELS.close); 397 refs.cancelBtn.textContent = session.labels.cancel || DEFAULT_LABELS.cancel; 398 refs.submitBtn.textContent = session.labels.submit || DEFAULT_LABELS.submit; 399 resetModalFooter(refs); 400 401 buildChips(refs, session.labels, session.rating); 402 refs.comment.value = ''; 403 updateCounter(refs, session.labels); 404 405 root.hidden = false; 406 root.style.display = ''; 407 root.classList.add('is-open'); 408 root.setAttribute('aria-hidden', 'false'); 409 if (!isScopedModalContainer(mount)) { 410 document.body.style.overflow = 'hidden'; 411 } 412 refs.comment.focus(); 413 } 414 415 function resetModalFooter(refs) { 416 if (refs?.footer) { 417 refs.footer.hidden = false; 418 refs.footer.classList.remove('is-hidden'); 419 } 420 if (refs?.cancelBtn) { 421 refs.cancelBtn.hidden = false; 422 refs.cancelBtn.disabled = false; 423 } 424 if (refs?.submitBtn) { 425 refs.submitBtn.hidden = false; 426 refs.submitBtn.disabled = false; 427 refs.submitBtn.classList.remove('is-hidden'); 428 } 429 } 430 431 function closeModal() { 432 if (!activeModalRoot) { 433 return; 434 } 435 const wasScoped = activeModalRoot.classList.contains('ns-t3cs-feedback-modal--scoped'); 436 activeModalRoot.classList.remove('is-open'); 437 activeModalRoot.hidden = true; 438 activeModalRoot.style.display = 'none'; 439 activeModalRoot.setAttribute('aria-hidden', 'true'); 440 if (!wasScoped) { 441 document.body.style.overflow = ''; 442 } 443 if (activeSession) { 444 activeSession.row?.querySelectorAll('[data-rating][hidden]').forEach((el) => { 445 el.removeAttribute('hidden'); 446 }); 447 } 448 activeModalRoot = null; 449 activeSession = null; 450 } 451 452 async function submitModal() { 453 if (!activeSession || !activeModalRoot) { 454 return; 455 } 456 const session = activeSession; 457 const refs = activeModalRoot._refs; 458 459 const reasons = getSelectedReasons(refs); 460 const comment = refs.comment.value.trim(); 461 const { row, btn, labels, rating, onSubmit, getMessageId, parentOnSubmit } = session; 462 463 row.dataset.feedbackReasons = reasons.join(','); 464 row.dataset.feedbackComment = comment; 465 466 closeModal(); 467 468 let saved = false; 469 const submitHandler = typeof parentOnSubmit === 'function' ? parentOnSubmit : onSubmit; 470 if (typeof submitHandler === 'function') {
471 try { 472 let result; 473 if (typeof parentOnSubmit === 'function') { 474 let targetId = 475 typeof getMessageId === 'function' ? getMessageId(row, btn) : resolveSearchHistoryIdForRow(row, btn); 476 if (!targetId || targetId <= 0) { 477 targetId = resolveSearchHistoryIdForRow(row, btn); 478 } 479 if (targetId <= 0) { 480 console.error('FeedbackRatingUi: missing search_history_id (check SSE meta tag and rating row dataset).'); 481 result = false; 482 } else { 483 row.dataset.searchHistoryId = String(targetId); 484 result = parentOnSubmit(targetId, rating, reasons.join(','), comment); 485 } 486 } else { 487 result = onSubmit(rating, reasons.join(','), comment); 488 } 489 const resolved = await Promise.resolve(result); 490 saved = resolved === true || (resolved && typeof resolved === 'object' && resolved.success === true); 491 if (!saved && resolved && typeof resolved === 'object' && resolved.message) { 492 row.dataset.feedbackError = String(resolved.message); 493 } 494 } catch (err) { 495 console.error('FeedbackRatingUi submit failed:', err); 496 saved = false; 497 row.dataset.feedbackError = err?.message || 'Feedback submit failed.'; 498 } 499 } 500 501 if (saved) { 502 lockRowFeedbackAfterSubmit(row, btn, rating); 503 showToast(row, labels.thanks || DEFAULT_LABELS.thanks, 1500); 504 return; 505 } 506 507 unlockRowFeedbackAfterFailedSubmit(row); 508 const errText = 509 row.dataset.feedbackError || 'Could not save feedback. Please try again.'; 510 delete row.dataset.feedbackError; 511 showToast(row, errText, 3500); 512 } 513 514 function bindRow(row, options) { 515 if (!row || row.dataset.feedbackBound === '1') { 516 return; 517 } 518 row.dataset.feedbackBound = '1'; 519 const labelsSource = 520 options?.labelsContainer || 521 options?.labels || 522 row.closest('[data-feedback-labels]') || 523 global.document?.getElementById?.('chatbotAside'); 524 const labels = parseLabels(labelsSource); 525 decorateRatingButtons(row, labels); 526 527 row.addEventListener('click', (event) => { 528 if (isRowFeedbackLocked(row)) { 529 return; 530 } 531 const btn = event.target.closest('[data-rating]'); 532 if (!btn || !row.contains(btn)) { 533 return; 534 } 535 event.preventDefault(); 536 event.stopPropagation(); 537 538 const rating = parseThumbRating(btn); 539 if (!isThumbRating(rating)) { 540 return; 541 } 542 543 const onSubmit = (value, reason, comment) => { 544 if (typeof options?.onSubmit === 'function') { 545 const messageId = 546 typeof options.getMessageId === 'function' 547 ? options.getMessageId(row, btn) 548 : parseInt(row.dataset.messageId || btn.dataset.messageId || '0', 10); 549 options.onSubmit(messageId, value, reason, comment); 550 } 551 }; 552 553 openModal({ 554 row, 555 btn, 556 labels, 557 rating, 558 onSubmit, 559 getMessageId: options.getMessageId, 560 parentOnSubmit: options.onSubmit, 561 modalContainer: options.modalContainer, 562 }); 563 }); 564 565 if (!escapeListenerBound) { 566 escapeListenerBound = true; 567 global.addEventListener('keydown', (event) => { 568 if (event.key === 'Escape' && activeModalRoot && !activeModalRoot.hidden) { 569 closeModal(); 570 } 571 }); 572 } 573 } 574 575 global.FeedbackRatingUi = { 576 __initialized: true, 577 parseLabels, 578 bindRow, 579 showToast, 580 decorateRatingButtons, 581 closeModal, 582 resolveSearchHistoryIdForRow, 583 }; 584})(typeof window !== 'undefined' ? window : this);
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.