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> " + 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> " + 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> " + 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.