PageSourceSearch

https://www.fernstudi.net/media/com_study/js/favorites.js?7

js fernstudi.net collected 2026-09-24 20:13:37 UTC 20,226 bytes, 615 lines download raw bytes

1/**
2 * Favorites (Merkzettel) frontend logic for com_study.
3 *
4 * Persists bookmarked courses via the FavoritesController endpoints. Every
5 * visitor type is stored server-side; localStorage is kept as an offline
6 * mirror and as the source for the one-time migration of pre-existing guests.
7 */
8
9// Keep the current language prefix so the language filter does not redirect GET requests on EN pages
10const FAVORITES_LANG_PREFIX = /^\/en(\/|$)/.test(window.location.pathname) ? '/en' : '';
11const FAVORITES_BASE = FAVORITES_LANG_PREFIX + '/index.php?option=com_study&task=favorites';
12
13/**
14 * Resolve the Joomla CSRF token field name.
15 * Tries Joomla.getOptions('csrf.token') then the meta fallback.
16 *
17 * @returns {string}
18 */
19function getFavoritesCsrfToken() {
20  try {
21    if (window.Joomla && typeof window.Joomla.getOptions === 'function') {
22      const name = window.Joomla.getOptions('csrf.token');
23      if (name) return name;
24    }
25  } catch (e) {
26    // Ignore and fall back to the meta tag.
27  }
28  const meta = document.querySelector('meta[name="joomla-csrf-token"]');
29  return meta && meta.content ? meta.content : '';
30}
31
32/**
33 * Send a state-changing favorites request (add/remove) with CSRF protection.
34 *
35 * @param {string} task     Controller task ('add' or 'remove')
36 * @param {number|string} entryId Course id
37 * @returns {Promise<Response>}
38 */
39function postFavorite(task, entryId) {
40  const body = new URLSearchParams();
41  body.append('id_course', entryId);
42  const token = getFavoritesCsrfToken();
43  if (token) {
44    body.append(token, '1');
45  }
46  return fetch(FAVORITES_BASE + '.' + task, {
47    method: 'POST',
48    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
49    body: body.toString(),
50  });
51}
52
53/**
54 * Update the header/bottom-nav favorites counter badge.
55 */
56function updateHeaderLink(animate = false) {
57  fetch(FAVORITES_BASE + '.count')
58    .then(response => response.text())
59    .then(text => {
60      let numItems = Number(text);
61
62      if (numItems < 1) {
63        const bodyElement = document.body;
64        if (!bodyElement.classList.contains('isLoggedin')) {
65          const favorites = JSON.parse(localStorage.getItem('favorites')) || [];
66          numItems = Object.keys(favorites).length;
67        }
68      }
69
70      const counters = document.querySelectorAll('.menu-favorites-counter');
71      counters.forEach(element => element.remove());
72
73      if (numItems > 0) {
74        const menulinks = document.getElementsByClassName('menu-favorites');
75        Array.prototype.forEach.call(menulinks, function (menulink) {
76          const counter = document.createElement('span');
77          counter.innerHTML = numItems.toString();
78          counter.className = 'mt-1 menu-favorites-counter badge bg-danger float-end';
79          menulink.appendChild(counter);
80
81          // Briefly pulse the header button so the new count is noticed.
82          if (animate) {
83            menulink.classList.remove('menu-favorites--pulse');
84            void menulink.offsetWidth;
85            menulink.classList.add('menu-favorites--pulse');
86            setTimeout(() => menulink.classList.remove('menu-favorites--pulse'), 500);
87          }
88        });
89      }
90    })
91    .catch(error => {
92      console.error('Fehler bei der Anfrage:', error);
93    });
94}
95
96/**
97 * Reload the rendered favorites list into the given container(s).
98 *
99 * @param {string} selector Class name of the target containers
100 */
101function updateFavoriteList(selector = 'list-favorites') {
102  fetch(FAVORITES_BASE + '.get')
103    .then(response => response.text())
104    .then(text => {
105      let favorites = [];
106      try {
107        favorites = JSON.parse(text) || [];
108      } catch (e) {
109        favorites = [];
110      }
111
112      // While the guest migration to the database is still running, fall back to localStorage.
113      if (!Array.isArray(favorites) || favorites.length === 0) {
114        const localFavorites = JSON.parse(localStorage.getItem('favorites')) || [];
115        if (Array.isArray(localFavorites) && localFavorites.length > 0) {
116          favorites = localFavorites;
117        }
118      }
119
120      if (Array.isArray(favorites)) {
121        const lists = document.getElementsByClassName(selector);
122        for (let i = 0; i < lists.length; i++) {
123          lists[i].innerHTML = '<div class="spinner text-center"><img class="spinner-favorites" src="/media/com_study/images/spinner_64.gif" width="28" height="28"></div>';
124        }
125
126        const url = FAVORITES_LANG_PREFIX + '/index.php?option=com_study&view=ajax&layout=favorites&data=' + encodeURIComponent(JSON.stringify(favorites));
127        const xhttp = new XMLHttpRequest();
128        xhttp.onreadystatechange = function () {
129          if (this.readyState === 4 && this.status === 200) {
130            for (let i = 0; i < lists.length; i++) {
131              lists[i].innerHTML = this.responseText;
132            }
133          }
134        };
135        xhttp.open('GET', url, true);
136        xhttp.send();
137      }
138    })
139    .catch(error => {
140      console.error('Fehler bei der Anfrage:', error);
141    });
142}
143
144/**
145 * Add a course to the favorites and update the UI.
146 *
147 * @param {number|string} entryId Course id
148 */
149function saveFavorite(entryId) {
150  postFavorite('add', entryId)
151    .then(response => response.text())
152    .then(text => {
153      let count = 0;
154      try {
155        count = Number(JSON.parse(text).count) || 0;
156      } catch (e) {
157        count = 0;
158      }
159
160      const favorites = JSON.parse(localStorage.getItem('favorites')) || [];
161
162      const alreadyAdded = favorites.some(fav => fav.entryId === entryId);
163      if (!alreadyAdded) {
164        favorites.push({ entryId: entryId });
165        localStorage.setItem('favorites', JSON.stringify(favorites));
166      }
167
168      const favbutton = document.getElementById('btn-fav-' + entryId);
169      if (favbutton) {
170        favbutton.classList.remove('btn-fav', 'btn-outline-secondary');
171        favbutton.classList.add('btn-secondary', 'btn-unfav');
172        favbutton.innerHTML = "<i class='fas fa-bookmark'></i>&nbsp;" + COM_STUDY_FAVORITES_BUTTON_TEXT_BOOKMARKED;
173      }
174
175      updateHeaderLink(true);
176      updateFavoriteList();
177
178      if (typeof _paq !== 'undefined') {
179        _paq.push(['trackEvent', 'com_study', 'click-favorite', entryId]);
180      }
181
182      showFavoritesFunnel(count || favorites.length);
183    })
184    .catch(error => {
185      console.error('Fehler bei der Anfrage:', error);
186    });
187}
188
189/**
190 * Show or hide a course's card and compare column on the favorites page.
191 *
192 * @param {number|string} entryId Course id
193 * @param {boolean} hidden Whether to hide the course
194 */
195function favToggleCourse(entryId, hidden) {
196  const card = document.querySelector('#favorites-cards .course[data-fav-id="' + entryId + '"]');
197  if (card) {
198    card.style.display = hidden ? 'none' : '';
199  }
200  document.querySelectorAll('#favorites-compare [data-fav-col="' + entryId + '"]').forEach(cell => {
201    cell.style.display = hidden ? 'none' : '';
202  });
203}
204
205/**
206 * Remove a course from the favorites and update the UI.
207 *
208 * @param {number|string} entryId Course id
209 */
210function removeFavorite(entryId) {
211  postFavorite('remove', entryId)
212    .then(response => response.text())
213    .then(() => {
214      const favorites = JSON.parse(localStorage.getItem('favorites')) || [];
215      for (let index = 0; index < favorites.length; index++) {
216        if (Number(favorites[index].entryId) === Number(entryId)) {
217          favorites.splice(index, 1);
218          localStorage.setItem('favorites', JSON.stringify(favorites));
219          break;
220        }
221      }
222
223      const onFavoritesPage = document.querySelector('[data-toast-removed]');
224      if (onFavoritesPage) {
225        // On the favorites page: hide the card and compare column, offer undo.
226        favToggleCourse(entryId, true);
227        showFavoritesToast(entryId);
228      } else {
229        // On catalog pages: flip the bookmark button back to the add state.
230        const favbutton = document.getElementById('btn-fav-' + entryId);
231        if (favbutton) {
232          favbutton.classList.remove('btn-unfav', 'btn-secondary');
233          favbutton.classList.add('btn-outline-secondary', 'btn-fav');
234          favbutton.innerHTML = "<i class='far fa-bookmark'></i>&nbsp;" + COM_STUDY_FAVORITES_BUTTON_TEXT;
235        }
236      }
237
238      updateHeaderLink();
239      updateFavoriteList();
240    })
241    .catch(error => {
242      console.error('Fehler bei der Anfrage:', error);
243    });
244}
245
246/**
247 * Flag the currently favorited courses in the UI.
248 */
249function markFavorites() {
250  fetch(FAVORITES_BASE + '.get')
251    .then(response => response.text())
252    .then(text => {
253      let favorites;
254      if (text) {
255        favorites = JSON.parse(text);
256      } else {
257        favorites = JSON.parse(localStorage.getItem('favorites'));
258      }
259      if (Array.isArray(favorites)) {
260        favorites.forEach(function (value) {
261          const favbutton = document.getElementById('btn-fav-' + value.entryId);
262          if (favbutton && favbutton.classList.contains('btn-fav')) {
263            favbutton.classList.remove('btn-fav', 'btn-outline-secondary');
264            favbutton.classList.add('btn-secondary', 'btn-unfav');
265            favbutton.innerHTML = "<i class='fas fa-bookmark'></i>&nbsp;" + COM_STUDY_FAVORITES_BUTTON_TEXT_BOOKMARKED;
266          }
267        });
268      }
269    })
270    .catch(error => {
271      console.error('Fehler bei der Anfrage:', error);
272    });
273}
274
275/**
276 * One-time migration of pre-existing localStorage favorites into the database.
277 *
278 * Runs sequentially so the first request can set the guest cookie before the
279 * next one reuses it, avoiding split identities across multiple tokens.
280 */
281async function migrateLocalFavorites() {
282  if (localStorage.getItem('favorites_migrated') === '1') {
283    return;
284  }
285
286  let local = [];
287  try {
288    local = JSON.parse(localStorage.getItem('favorites')) || [];
289  } catch (e) {
290    local = [];
291  }
292
293  if (!Array.isArray(local) || local.length === 0) {
294    localStorage.setItem('favorites_migrated', '1');
295    return;
296  }
297
298  for (const fav of local) {
299    try {
300      await postFavorite('add', fav.entryId);
301    } catch (e) {
302      // Skip failed items; the mirror in localStorage remains intact.
303    }
304  }
305
306  localStorage.setItem('favorites_migrated', '1');
307  updateHeaderLink();
308  updateFavoriteList();
309}
310
311/**
312 * Switch the favorites view between cards and compare.
313 *
314 * @param {string} view 'cards' or 'compare'
315 */
316function favSetView(view) {
317  document.querySelectorAll('.fav-toolbar__btn').forEach(btn => {
318    btn.classList.toggle('fav-toolbar__btn--active', btn.getAttribute('data-fav-view') === view);
319  });
320  const cards = document.getElementById('favorites-cards');
321  const compare = document.getElementById('favorites-compare');
322  if (cards) {
323    cards.classList.toggle('fav-view--hidden', view !== 'cards');
324  }
325  if (compare) {
326    compare.classList.toggle('fav-view--hidden', view !== 'compare');
327  }
328}
329
330/**
331 * Compute the sorted course order from the card data attributes.
332 *
333 * @param {string} criterion 'default' | 'price' | 'duration' | 'rating'
334 * @returns {Array<{id: string, el: Element}>}
335 */
336function favComputeOrder(criterion) {
337  const cards = Array.from(document.querySelectorAll('#favorites-cards .course[data-fav-id]'));
338  const items = cards.map(card => ({
339    id: card.getAttribute('data-fav-id'),
340    price: parseFloat(card.getAttribute('data-fav-price')) || 0,
341    duration: parseFloat(card.getAttribute('data-fav-duration')) || 0,
342    rating: parseFloat(card.getAttribute('data-fav-rating')) || 0,
343    el: card,
344  }));
345
346  if (criterion === 'price') {
347    items.sort((a, b) => (a.price === 0) - (b.price === 0) || a.price - b.price);
348  } else if (criterion === 'duration') {
349    items.sort((a, b) => (a.duration === 0) - (b.duration === 0) || a.duration - b.duration);
350  } else if (criterion === 'rating') {
351    items.sort((a, b) => b.rating - a.rating);
352  }
353
354  return items;
355}
356
357/**
358 * Reorder both the cards and the compare columns by the given criterion.
359 *
360 * @param {string} criterion Sort criterion
361 */
362function sortFavorites(criterion) {
363  const items = favComputeOrder(criterion);
364  if (!items.length) {
365    return;
366  }
367  const order = items.map(i => i.id);
368
369  const cardsWrap = document.querySelector('#favorites-cards #row-items');
370  if (cardsWrap) {
371    items.forEach(i => cardsWrap.appendChild(i.el));
372  }
373
374  document.querySelectorAll('#favorites-compare tr').forEach(row => {
375    const cells = {};
376    row.querySelectorAll('[data-fav-col]').forEach(cell => {
377      cells[cell.getAttribute('data-fav-col')] = cell;
378    });
379    order.forEach(id => {
380      if (cells[id]) {
381        row.appendChild(cells[id]);
382      }
383    });
384  });
385}
386
387/**
388 * Load the bulk lead form into its container and run its inline scripts.
389 */
390function loadBulkLead() {
391  const container = document.querySelector('[data-fav-bulklead]');
392  if (!container || container.dataset.loaded === '1') {
393    return;
394  }
395  container.dataset.loaded = '1';
396
397  const wrap = document.getElementById('favorites-leadform');
398  const e = container.getAttribute('data-fav-e') || '0';
399  const fv = container.getAttribute('data-fav-fv') || '';
400  const url = FAVORITES_LANG_PREFIX + '/index.php?option=com_study&view=ajax&layout=bulkleadform&e=' + encodeURIComponent(e) + '&fv=' + encodeURIComponent(fv);
401
402  fetch(url)
403    .then(response => response.text())
404    .then(html => {
405      container.innerHTML = html;
406
407      // Only keep the section when an actual form was returned; otherwise the
408      // heading would dangle without a form.
409      if (!container.querySelector('form')) {
410        if (wrap) {
411          wrap.style.display = 'none';
412        }
413        return;
414      }
415
416      container.querySelectorAll('script').forEach(oldScript => {
417        const script = document.createElement('script');
418        if (oldScript.src) {
419          script.src = oldScript.src;
420        } else {
421          script.textContent = oldScript.textContent;
422        }
423        document.body.appendChild(script);
424      });
425    })
426    .catch(error => {
427      if (wrap) {
428        wrap.style.display = 'none';
429      }
430      console.error('Fehler bei der Anfrage:', error);
431    });
432}
433
434/**
435 * Show the post-save funnel modal: confirmation, compare CTA and (for guests)
436 * an account nudge. Built on the fly so it works on every page that has the
437 * bookmark button, not only the favorites page.
438 *
439 * @param {number}
439 count Current number of favorites
440 */
441function showFavoritesFunnel(count) {
442  if (typeof COM_STUDY_FAVORITES_FUNNEL === 'undefined'
443    || typeof bootstrap === 'undefined' || !bootstrap.Modal) {
444    return;
445  }
446
447  const cfg = COM_STUDY_FAVORITES_FUNNEL;
448  const isGuest = !document.body.classList.contains('isLoggedin');
449
450  // The header link already carries the language-correct compare URL.
451  const headerLink = document.querySelector('.menu-favorites');
452  const compareUrl = headerLink ? headerLink.getAttribute('href') : '/merkzettel';
453
454  const countText = (count === 1)
455    ? cfg.countOne
456    : cfg.countMany.replace('%s', count);
457
458  const guestBlock = isGuest
459    ? '<p class="small text-muted mt-3 mb-2">' + cfg.guest + '</p>'
460      + '<a href="' + cfg.accountUrl + '" class="btn btn-outline-secondary btn-sm w-100">' + cfg.guestCta + '</a>'
461    : '';
462
463  let modalEl = document.getElementById('fav-funnel-modal');
464  if (!modalEl) {
465    modalEl = document.createElement('div');
466    modalEl.id = 'fav-funnel-modal';
467    modalEl.className = 'modal fade';
468    modalEl.tabIndex = -1;
469    modalEl.setAttribute('aria-hidden', 'true');
470    document.body.appendChild(modalEl);
471  }
472
473  modalEl.innerHTML =
474    '<div class="modal-dialog modal-dialog-centered modal-sm">'
475    + '<div class="modal-content">'
476    + '<div class="modal-body text-center p-4">'
477    + '<div class="mb-2" style="font-size: 2rem; color: var(--bs-success, #198754);"><i class="fas fa-check-circle"></i></div>'
478    + '<h5 class="mb-1">' + cfg.saved + '</h5>'
479    + '<p class="small text-muted mb-3">' + countText + '</p>'
480    + '<a href="' + compareUrl + '" class="btn btn-primary w-100 mb-2">' + cfg.compare + ' <i class="fas fa-arrow-right"></i></a>'
481    + '<button type="button" class="btn btn-link btn-sm text-muted text-decoration-none w-100" data-bs-dismiss="modal">' + cfg.continueText + '</button>'
482    + guestBlock
483    + '</div>'
484    + '</div>'
485    + '</div>';
486
487  const modal = bootstrap.Modal.getOrCreateInstance(modalEl);
488  modal.show();
489}
490
491/**
492 * Show an undo toast after removing a favorite. No-op outside the favorites page.
493 *
494 * @param {number|string} entryId Course id that was removed
495 */
496function showFavoritesToast(entryId) {
497  const source = document.querySelector('[data-toast-removed]');
498  if (!source || typeof bootstrap === 'undefined' || !bootstrap.Toast) {
499    return;
500  }
501
502  let container = document.querySelector('.fav-toast-container');
503  if (!container) {
504    container = document.createElement('div');
505    container.className = 'fav-toast-container toast-container position-fixed bottom-0 start-50 translate-middle-x p-3';
506    container.style.zIndex = '1090';
507    document.body.appendChild(container);
508  }
509
510  const toastEl = document.createElement('div');
511  toastEl.className = 'toast align-items-center text-bg-dark border-0';
512  toastEl.setAttribute('role', 'alert');
513  toastEl.setAttribute('aria-live', 'assertive');
514  toastEl.setAttribute('aria-atomic', 'true');
515
516  const wrap = document.createElement('div');
517  wrap.className = 'd-flex';
518
519  const body = document.createElement('div');
520  body.className = 'toast-body';
521  body.textContent = source.getAttribute('data-toast-removed');
522
523  const undo = document.createElement('button');
524  undo.type = 'button';
525  undo.className = 'btn btn-link text-warning fw-semibold text-decoration-none me-2 m-auto p-0';
526  undo.textContent = source.getAttribute('data-toast-undo');
527
528  const close = document.createElement('button');
529  close.type = 'button';
530  close.className = 'btn-close btn-close-white me-2 m-auto';
531  close.setAttribute('data-bs-dismiss', 'toast');
532  close.setAttribute('aria-label', 'Close');
533
534  wrap.appendChild(body);
535  wrap.appendChild(undo);
536  wrap.appendChild(close);
537  toastEl.appendChild(wrap);
538  container.appendChild(toastEl);
539
540  const toast = new bootstrap.Toast(toastEl, { delay: 6000 });
541
542  undo.addEventListener('click', function () {
543    // Quiet re-add (no modal), un-hide the course and refresh counters.
544    postFavorite('add', entryId)
545      .then(() => {
546        favToggleCourse(entryId, false);
547        const favorites = JSON.parse(localStorage.getItem('favorites')) || [];
548        if (!favorites.some(fav => Number(fav.entryId) === Number(entryId))) {
549          favorites.push({ entryId: entryId });
550          localStorage.setItem('favorites', JSON.stringify(favorites));
551        }
552        updateHeaderLink();
553        updateFavoriteList();
554      })
555      .catch(error => {
556        console.error('Fehler bei der Anfrage:', error);
557      });
558    toast.hide();
559  });
560
561  toastEl.addEventListener('hidden.bs.toast', function () {
562    toastEl.remove();
563  });
564
565  toast.show();
566}
567
568/**
569 * Enhance the freshly rendered favorites list (bulk lead + button state).
570 */
571function enhanceFavoritesView() {
572  markFavorites();
573  loadBulkLead();
574}
575
576/**
577 * Initialise the favorites feature and the delegated event handlers.
578 */
579function initFavorites() {
580  migrateLocalFavorites();
581  markFavorites();
582  updateFavoriteList();
583
584  document.addEventListener('click', function (event) {
585    const toggle = event.target.closest('.fav-toolbar__btn');
586    if (toggle) {
587      favSetView(toggle.getAttribute('data-fav-view'));
588      return;
589    }
590
591    let target = event.target;
592    while (target && !target.classList.contains('btn-fav') && !target.classList.contains('btn-u
592nfav')) {
593      target = target.parentElement;
594    }
595
596    if (target) {
597      if (target.classList.contains('btn-fav')) {
598        saveFavorite(target.dataset.favid);
599      } else if (target.classList.contains('btn-unfav')) {
600        removeFavorite(target.dataset.favid);
601      }
602    }
603  });
604
605  document.addEventListener('change', function (event) {
606    if (event.target && event.target.id === 'fav-sort') {
607      sortFavorites(event.target.value);
608    }
609  });
610}
611
612document.addEventListener('DOMContentLoaded', function () {
613  initFavorites();
614  updateHeaderLink();
615});

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.