PageSourceSearch

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

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

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.