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