PageSourceSearch

https://www.kitzbuehel.com/typo3conf/ext/ns_t3cs/Resources/Public/JavaScript/feedback-rating-ui.js?1781016078

js kitzbuehel.com collected 2026-10-02 06:04:14 UTC 19,849 bytes, 584 lines download raw bytes

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">&times;</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.