1let customModal = document.getElementById('custom-modal'); 2let body = document.body; 3let scrollPosition; 4 5function explodeString(inputString, by = '#') { 6 // Split the string by the '#' character 7 const parts = inputString.split(by); 8 // Remove any empty strings in the result 9 return parts.filter(part => part.length > 0); 10} 11 12function openModal() { 13 scrollPosition = window.scrollY 14 body.style.position = 'fixed'; 15 body.style.width = '100%' 16 body.style.top = `-${scrollPosition}px`; 17 customModal.style.display = 'block'; 18} 19 20function closeModal() { 21 customModal.style.display = 'none'; 22 body.style.position = ''; 23 body.style.top = ''; 24 window.scrollTo(0, scrollPosition); 25} 26 27function bsTargetHref() { 28 window.location.href = this.dataset.bsTarget; 29} 30function handleClick(event) { 31 /* event.preventDefault(); 32 event.stopImmediatePropagation();*/ 33} 34function changeAgendaSelect( tabid ) { 35 console.log(tabid); 36 let actOption = document.querySelector(".select_location option[data-tabid='" + tabid + "']"); 37 actOption.selected = true; 38 window.location.hash = tabid; 39} 40 41function showAgendaTab(hash = null) { 42 43 if (hash === null && document.querySelector('[data-agenda-view].agenda-tag-results-active')) { 44 return; 45 } 46 47 if ( hash === null ) { 48 hash = window.location.hash; 49 } 50 51 let hash_array = explodeString(hash); 52 53 if ( hash_array.length > 1 ) { 54 setTimeout(() => { 55 const targetElement = document.getElementById(hash_array[1]); 56 if (targetElement) { 57 // Scroll to the element 58 targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); 59 } 60 }, 1000); 61 } 62 let position = hash.search(/___/); 63 if ( position !== -1 ) { 64 const fragments = hash.split("___"); 65 console.log(fragments); 66 let tabElement = document.getElementById(fragments[0].replace("#","") + '-tab') 67 if ( tabElement !== undefined && tabElement !== null ) { 68 let tab = new bootstrap.Tab(tabElement); 69 tab.show(); 70 } 71 setTimeout(() => { 72 let tabElement2 = document.getElementById(hash + '-tab') 73 if ( tabElement2 !== undefined && tabElement !== null ) { 74 const left_button = document.getElementById(hash_array[0]+'-tab'); 75 if ( left_button !== undefined && left_button !== null ) { 76 left_button.click(); 77 } 78 } 79 }, 100); 80 81 if ( hash_array[1] !== undefined && hash_array[1] !== null ) { 82 83 setTimeout(() => { 84 const targetElement = document.getElementById(hash_array[1]); 85 if (targetElement) { 86 // Scroll to the element 87 targetElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); 88 } 89 }, 1500); 90 } 91 92 } else { 93 let tabElement = document.querySelector(hash + '-tab') 94 if ( tabElement !== undefined && tabElement !== null ) { 95 let tab = new bootstrap.Tab(tabElement); 96 tab.show(); 97 } 98 } 99} 100 101$(function () { 102 if (location.hash) { 103 let tabElement = document.querySelector(location.hash + '-tab') 104 let tab = new bootstrap.Tab(tabElement); 105 tab.show(); 106 } 107}); 108 109$(function () { 110 $( window ).on( "load", function(e) { 111 showAgendaTab(); 112 } ); 113 114 if (location.hash) { 115 let tabElement = document.querySelector(location.hash + '-tab') 116 let tab = new bootstrap.Tab(tabElement); 117 tab.show(); 118 } 119 120 document.querySelectorAll('.select_location').forEach(function (el) { 121 const location_select = el; 122 if ( location_select !== undefined && location_select !== null ) { 123 location_select.addEventListener("change", function(e) { 124 const actId = location_select.options[location_select.selectedIndex].dataset.tabid; 125 showAgendaTab( actId ); 126 }); 127 } 128 }); 129 130 131 132}); 133// Figyeli az URL hash változását 134window.addEventListener("hashchange", function() { 135 showAgendaTab(); 136}); 137 138/** 139 * Keep the visitor's room choice when they switch between agenda days. 140 * Room IDs are stable across days, while a room can be absent from an 141 * individual day's program. In that case the first room is shown temporarily 142 * and the saved choice remains available when a later day contains it again. 143 */ 144(function preserveAgendaRoomAcrossDays() { 145 'use strict'; 146 147 const views = document.querySelectorAll('[data-agenda-view].multi_days'); 148 149 views.forEach((view) => { 150 let preferredRoomId = null; 151 const automaticRoomTabs = new WeakSet(); 152 153 const roomTabsIn = (dayPane) => Array.from( 154 dayPane.querySelectorAll('.nav-rooms button[data-agenda-room-id]') 155 ); 156 157 const syncRoomSelect = (dayPane, roomId) => { 158 const select = dayPane.querySelector('.select_location'); 159 if (!select) { 160 return; 161 } 162 163 const matchingOption = Array.from(select.options).find((option) => 164 option.dataset.agendaRoomId === roomId 165 ); 166 if (matchingOption) { 167 select.selectedIndex = matchingOption.index; 168 } 169 }; 170 171 const showRoom = (dayPane, roomId) => { 172 const roomTabs = roomTabsIn(dayPane); 173 if (roomTabs.length === 0) { 174 return; 175 } 176 177 const roomTab = roomTabs.find((tab) => tab.dataset.agendaRoomId === roomId) 178 || roomTabs[0]; 179 syncRoomSelect(dayPane, roomTab.dataset.agendaRoomId); 180 181 if (!roomTab.classList.contains('active')) { 182 automaticRoomTabs.add(roomTab); 183 bootstrap.Tab.getOrCreateInstance(roomTab).show(); 184 } 185 }; 186 187 const initialDay = view.querySelector('.tab-pane.active'); 188 const initialRoomTab = initialDay?.querySelector( 189 '.nav-rooms button.active[data-agenda-room-id]' 190 ) || view.querySelector('.nav-rooms button[data-agenda-room-id]'); 191 if (initialRoomTab) { 192 preferredRoomId = initialRoomTab.dataset.agendaRoomId; 193 } 194 195 view.addEventListener('click', (event) => { 196 const roomTab = event.target.closest('.nav-rooms button[data-agenda-room-id]'); 197 if (roomTab && view.contains(roomTab)) { 198 preferredRoomId = roomTab.dataset.agendaRoomId; 199 } 200 }); 201 202 view.addEventListener('change', (event) => { 203 if (!event.target.matches('.select_location')) { 204 return; 205 } 206 207 const selectedOption = event.target.options[event.target.selectedIndex]; 208 if (selectedOption?.dataset.agendaRoomId) { 209 preferredRoomId = selectedOption.dataset.agendaRoomId; 210 } 211 }); 212 213 view.addEventListener('shown.bs.tab', (event) => { 214 const tab = event.target; 215 216 if (tab.matches('[data-agenda-day-tab]')) { 217 const target = tab.getAttribute('data-bs-target') || ''; 218 const dayPane = target.startsWith('#') 219 ? document.getElementById(target.slice(1)) 220 : null; 221 if (dayPane && view.contains(dayPane)) { 222 showRoom(dayPane, preferredRoomId); 223 } 224 return; 225 } 226 227 if (!tab.matches('.nav-rooms button[data-agenda-room-id]')) { 228 return; 229 } 230 231 if (automaticRoomTabs.has(tab)) { 232 automaticRoomTabs.delete(tab); 233 return; 234 } 235 236 preferredRoomId = tab.dataset.agendaRoomId; 237 }); 238 }); 239})(); 240 241// Ha az oldal betöltésekor is szeretnéd lefuttatni a függvényt, ha van hash az URL-ben 242if (window.location.hash) { 243 showAgendaTab(); 244} 245 246/** 247 * Public agenda booking adapter. 248 * 249 * This deliberately does not use the legacy `app.ajaxLoadPage` helper: that 250 * helper assumes a 200 HTML response and cannot preserve structured 400/429 251 * envelopes or refreshed CSRF fields. The delegated listener also survives 252 * the tag filter moving agenda items between containers. 253 */ 254(function agendaBookingAdapter(window, document) { 255 'use strict'; 256 257 const HASH_PATTERN = /^[A-Z0-9]{12}$/; 258 259 const state = { 260 modal: null, 261 trigger: null, 262 url: null, 263 csrfName: null, 264 csrfValue: null, 265 captchaAction: 'agenda_booking', 266 captchaSiteKey: null, 267 translations: {}, 268 returnFocus: null, 269 rateLimitTimer: null, 270 rateLimitUntil: 0, 271 availability: null, 272 canSubmit: false, 273 loadSequence: 0, 274 selectedTicketHash: null, 275 defaultTitle: '', 276 }; 277 278 const by = (selector) => state.modal?.querySelector(selector) || null; 279 const form = () => by('[data-agenda-booking-form]'); 280 const error = () => by('[data-agenda-booking-error]'); 281 const success = () => by('[data-agenda-booking-success]'); 282 283 const setVisible = (element, visible) => { 284 if (!element) return; 285 element.classList.toggle('d-none', !visible); 286 }; 287 288 const setMessage = (element, message) => { 289 if (!element) return; 290 element.textContent = message || ''; 291 setVisible(element, Boolean(message)); 292 }; 293 294 const setInvalid = (isInvalid) => { 295 const hashField = by('[data-agenda-booking-hash]'); 296 hashField?.setAttribute('aria-invalid', isInvalid ? 'true' : 'false'); 297 }; 298
299 const setLoading = (isLoading) => { 300 setVisible(by('[data-agenda-booking-loading]'), isLoading); 301 setVisible(by('[data-agenda-booking-content]'), !isLoading); 302 const submit = by('[data-agenda-booking-submit]'); 303 if (submit) { 304 submit.disabled = isLoading || !state.canSubmit || Boolean(state.rateLimitUntil); 305 } 306 }; 307 308 /** 309 * Surfaces the in-flight POST beyond the disabled button: a spinner and a 310 * relabeled button so a slow network still reads as "working", not stuck. 311 */ 312 const setSubmitting = (isSubmitting) => { 313 const submitButton = by('[data-agenda-booking-submit]'); 314 const spinner = by('[data-agenda-booking-submit-spinner]'); 315 const label = by('[data-agenda-booking-submit-label]'); 316 if (submitButton) { 317 submitButton.disabled = isSubmitting || !state.canSubmit || Boolean(state.rateLimitUntil); 318 submitButton.setAttribute('aria-busy', isSubmitting ? 'true' : 'false'); 319 } 320 setVisible(spinner, isSubmitting); 321 if (label) label.textContent = isSubmitting ? translate('loading') : translate('submit'); 322 }; 323 324 const loadTranslations = () => { 325 const raw = state.modal?.dataset.agendaBookingTranslations || '{}'; 326 try { 327 const translations = JSON.parse(raw); 328 state.translations = translations && typeof translations === 'object' ? translations : {}; 329 } catch (e) { 330 state.translations = {}; 331 } 332 }; 333 334 const translate = (key) => String(state.translations[key] || ''); 335 336 const translateCount = (key, count) => translate(key).replace('{count}', String(count)); 337 338 const normalizeTicketHash = (value) => String(value ?? '').trim().toUpperCase(); 339 340 const setTicketPickerVisibility = (visible) => { 341 const picker = by('[data-agenda-booking-ticket-selector], [data-agenda-booking-ticket-picker]'); 342 if (!picker) return; 343 picker.hidden = !visible; 344 picker.classList.toggle('d-none', !visible); 345 }; 346 347 const setTicketEmptyVisibility = (visible) => { 348 const empty = by('[data-agenda-booking-ticket-empty]'); 349 if (!empty) return; 350 empty.hidden = !visible; 351 empty.classList.toggle('d-none', !visible); 352 }; 353 354 const resetTicketPicker = () => { 355 state.selectedTicketHash = null; 356 const list = by('[data-agenda-booking-ticket-list]'); 357 if (list) list.replaceChildren(); 358 setTicketEmptyVisibility(false); 359 setTicketPickerVisibility(false); 360 const status = by('[data-agenda-booking-ticket-status]'); 361 if (status) status.textContent = ''; 362 }; 363 364 const appendTicketField = (button, className, labelKey, value) => { 365 const row = document.createElement('span'); 366 row.className = `agenda-booking-ticket-field ${className}`; 367 368 const label = document.createElement('span'); 369 label.className = 'agenda-booking-ticket-label'; 370 label.textContent = translate(labelKey); 371 row.appendChild(label); 372 373 const text = document.createElement('span'); 374 text.className = 'agenda-booking-ticket-value'; 375 text.textContent = value; 376 row.appendChild(text); 377 button.appendChild(row); 378 }; 379 380 const renderUserTickets = (loggedIn, tickets) => { 381 resetTicketPicker(); 382 if (loggedIn !== true) return; 383 384 setTicketPickerVisibility(true); 385 const list = by('[data-agenda-booking-ticket-list]'); 386 if (!list || !Array.isArray(tickets)) { 387 setTicketEmptyVisibility(true); 388 return; 389 } 390 391 const fragment = document.createDocumentFragment(); 392 tickets.forEach((ticket) => { 393 if (!ticket || typeof ticket !== 'object') return; 394 const hash = normalizeTicketHash(ticket.ticket_hash); 395 if (!HASH_PATTERN.test(hash)) return; 396 397 const name = String(ticket.name ?? ''); 398 const email = String(ticket.email ?? ''); 399 const button = document.createElement('button'); 400 button.type = 'button'; 401 button.className = 'agenda-booking-ticket'; 402 button.dataset.agendaBookingTicket = '1'; 403 button.dataset.agendaBookingTicketItem = '1'; 404 button.dataset.ticketHash = hash; 405 button.setAttribute('aria-label', [ 406 [translate('ticket_name'), name], 407 [translate('ticket_email'), email], 408 [translate('ticket_code'), hash], 409 ] 410 .filter(([, value]) => value !== '') 411 .map(([label, value]) => `${label}: ${value}`) 412 .join(', ')); 413 appendTicketField(button, 'agenda-booking-ticket-name', 'ticket_name', name); 414 appendTicketField(button, 'agenda-booking-ticket-email', 'ticket_email', email); 415 appendTicketField(button, 'agenda-booking-ticket-code', 'ticket_code', hash); 416 fragment.appendChild(button); 417 }); 418 419 list.replaceChildren(fragment); 420 const hasTickets = list.children.length > 0; 421 setTicketEmptyVisibility(!hasTickets); 422 }; 423 424 const renderUserBookings = (bookings) => { 425 const section = by('[data-agenda-booking-owned]'); 426 const list = by('[data-agenda-booking-owned-list]'); 427 if (!section || !list) return; 428 list.replaceChildren();
429 bookings.forEach((booking) => { 430 const row = document.createElement('div'); 431 row.className = 'agenda-booking-owned-row'; 432 const description = document.createElement('span'); 433 description.textContent = [booking.name, booking.email, booking.ticket_hash] 434 .filter(Boolean).join(' · '); 435 const cancel = document.createElement('button'); 436 cancel.type = 'button'; 437 cancel.className = 'btn btn-outline-danger btn-sm'; 438 cancel.dataset.agendaBookingCancel = String(booking.booking_id); 439 cancel.textContent = translate('cancel_booking'); 440 row.append(description, cancel); 441 list.appendChild(row); 442 }); 443 section.hidden = bookings.length === 0; 444 section.classList.toggle('d-none', bookings.length === 0); 445 }; 446 447 const selectTicket = (ticketButton) => { 448 const hashField = by('[data-agenda-booking-hash]'); 449 const hash = normalizeTicketHash(ticketButton?.dataset?.ticketHash); 450 if (!hashField || !HASH_PATTERN.test(hash)) return; 451 452 state.selectedTicketHash = hash; 453 hashField.value = hash; 454 setMessage(error(), ''); 455 setMessage(success(), ''); 456 setInvalid(false); 457 458 const status = by('[data-agenda-booking-ticket-status]'); 459 if (status) status.textContent = translate('ticket_selected'); 460 hashField.focus(); 461 }; 462 463 const updateCsrf = (data) => { 464 if (!data || typeof data !== 'object') return; 465 const name = data.csrf_token_name || data.csrf_name || null; 466 const value = data.csrf_token_value || data.csrf_hash || data.csrf || null; 467 if (name) state.csrfName = name; 468 if (value) state.csrfValue = value; 469 const csrf = by('[data-agenda-booking-csrf]'); 470 if (csrf) { 471 csrf.name = state.csrfName || csrf.name || 'csrf_token'; 472 csrf.value = state.csrfValue || ''; 473 } 474 }; 475 476 const parseResponse = async (response) => { 477 let payload = null; 478 try { 479 payload = await response.json(); 480 } catch (e) { 481 payload = { result: false, code: 'TECHNICAL_ERROR', message: translate('invalid_response'), data: null }; 482 } 483 updateCsrf(payload?.data); 484 return { response, payload }; 485 }; 486 487 const availabilityText = (availability) => { 488 if (!availability) return ''; 489 const status = availability.availability_status || 'disabled'; 490 if (['full', 'not_open', 'closed', 'disabled'].includes(status)) return translate(status); 491 if (availability.capacity_unlimited) return translate('unlimited'); 492 if (availability.show_remaining_booking && Number.isFinite(Number(availability.remaining_places_booking))) { 493 return translateCount('remaining', availability.remaining_places_booking); 494 } 495 return ''; 496 }; 497 498 /** 499 * Normalize the small availability envelope returned by GET/POST. A 500 * successful booking can consume the final place while a stale response 501 * still says `available`; treating a finite zero count as full keeps the 502 * modal and the agenda list in sync until the next page refresh. 503 */ 504 const normalizeAvailability = (availability, fallbackStatus = null) => { 505 const normalized = availability && typeof availability === 'object' 506 ? { ...availability } 507 : {}; 508 let status = normalized.availability_status || fallbackStatus || 'disabled'; 509 const rawRemaining = Object.prototype.hasOwnProperty.call(normalized, 'remaining_places_booking') 510 && normalized.remaining_places_booking !== null 511 && normalized.remaining_places_booking !== undefined 512 ? normalized.remaining_places_booking 513 : normalized.remaining_places_list; 514 const remaining = rawRemaining === null || rawRemaining === undefined || rawRemaining === '' 515 ? Number.NaN 516 : Number(rawRemaining); 517 if ( 518 status === 'available' 519 && !Boolean(normalized.capacity_unlimited) 520 && Number.isFinite(remaining) 521 && remaining <= 0 522 ) { 523 status = 'full'; 524 } 525 normalized.availability_status = status; 526 return normalized; 527 }; 528 529 const applyModalAvailability = (availability) => { 530 const previous = state.availability || {}; 531 const incoming = availability && typeof availability === 'object' ? availability : {}; 532 const normalized = normalizeAvailability({ 533 ...incoming, 534 // POST booking responses intentionally expose a small allowlisted 535 // envelope. Preserve GET-only presentation flags while never 536 // carrying a stale remaining count into the next state. 537 ...(Object.prototype.hasOwnProperty.call(incoming, 'enabled') 538 ? {} 539 : { enabled: previous.enabled }), 540 ...(Object.prototype.hasOwnProperty.call(incoming, 'capacity_unlimited') 541 ? {} 542 : { capacity_unlimited: previous.capacity_unlimited }), 543 ...(Object.prototype.hasOwnProperty.call(incoming, 'show_remaining_booking') 544 ? {} 545 : { show_remaining_booking: previous.show_remaining_booking }), 546 ...(Object.prototype.hasOwnProperty.call(incoming, 'show_full') 547 ? {} 548 : { show_full: previous.show_full }), 549 ...(Object.prototype.hasOwnProperty.call(incoming, 'availability_status') 550 ? {} 551 : { availability_status: previous.availability_status }), 552 }, previous.availability_status || null); 553 state.availability = normalized; 554 state.canSubmit = normalized.availability_status === 'available' 555 && normalized.enabled !== false; 556 557 const availabilityElement = by('[data-agenda-booking-availability]'); 558 if (availabilityElement) { 559 availabilityElement.textContent = availabilityText(normalized); 560 } 561 562 const submitButton = by('[data-agenda-booking-submit]'); 563 if (submitButton) { 564 submitButton.disabled = !state.canSubmit || Boolean(state.rateLimitUntil); 565 } 566 return normalized; 567 }; 568 569 const statusMessageKey = (container, availability) => { 570 const status = availability.availability_status || 'disabled'; 571 if (status !== 'full') return status; 572 573 // `show_full` is an explicit presentation switch. Keep the generic 574 // unavailable label when the list was configured not to expose a 575 // separate "full" marker. 576 const showFull = Object.prototype.hasOwnProperty.call(availability, 'show_full') 577 ? Boolean(availability.show_full) 578 : container?.dataset.agendaBookingShowFull === '1'; 579 return showFull ? 'full' : 'disabled'; 580 }; 581 582 const updateTriggers = (agendaId, data) => { 583 if (!agendaId || !data) return; 584 const escapedAgendaId = window.CSS?.escape 585 ? window.CSS.escape(String(agendaId)) 586 : String(agendaId).replace(/(["\\])/g, '\\$1'); 587 document.querySelectorAll(`[data-agenda-booking-id="${escapedAgendaId}"]`).forEach((container) => { 588 const normalized = normalizeAvailability( 589 data, 590 container.dataset.agendaBookingStatus || null 591 ); 592 const remaining = container.querySelector('[data-agenda-booking-remaining]'); 593 const message = container.querySelector('[data-agenda-booking-status-message]'); 594 const hasListCount = Object.prototype.hasOwnProperty.call(data, 'remaining_places_list'); 595 const hasBookingCount = Object.prototype.hasOwnProperty.call(data, 'remaining_places_booking'); 596 if (remaining && (hasListCount || hasBookingCount)) { 597 // The public POST adapter may expose both counts. The list 598 // switch is independent from the modal switch, so prefer the 599 // explicit list count and fall back to the booking count for 600 // older responses. 601 const value = hasListCount ? data.remaining_places_list : data.remaining_places_booking; 602 if (value === null || value === undefined) { 603 remaining.textContent = ''; 604 } else { 605 remaining.textContent = translateCount('remaining', value); 606 } 607 } 608 const status = normalized.availability_status; 609 container.dataset.agendaBookingStatus = status; 610 const detailState = container.querySelector('.agenda-detail-booking-state'); 611 if (detailState) { 612 detailState.textContent = translate(status === 'available' 613 ? 'available' : statusMessageKey(container, normalized)); 614 } 615 if (Object.prototype.hasOwnProperty.call(data, 'user_has_booking')) { 616 container.dataset.agendaUserBooked = data.user_has_booking ? '1' : '0'; 617 } 618 const userBooked = container.dataset.agendaUserBooked === '1'; 619 const trigger = container.querySelector('[data-agenda-booking-trigger]'); 620 if (trigger) trigger.textContent = translate(userBooked ? 'button_with_cancel' : 'button'); 621 if ((status === 'available' && normalized.enabled !== false) || userBooked) { 622 trigger?.classList.remove('d-none'); 623 if (trigger) { 624 trigger.hidden = false;
625 trigger.disabled = false; 626 } 627 if (message) { 628 message.hidden = true; 629 message.classList.add('d-none'); 630 } 631 return; 632 } 633 634 if (trigger) { 635 // The server renders a status-only state for non-bookable 636 // agendas. Mirror that state after a race/full response. 637 trigger.hidden = true; 638 trigger.disabled = true; 639 trigger.classList.add('d-none'); 640 } 641 if (message) { 642 const key = statusMessageKey(container, normalized); 643 message.textContent = translate(key) || availabilityText({ 644 ...normalized, 645 availability_status: key, 646 }); 647 message.dataset.agendaBookingStatusKey = key; 648 message.hidden = false; 649 message.classList.remove('d-none'); 650 } 651 }); 652 }; 653 654 const refreshBookingState = async (preserveAvailability = false) => { 655 const result = await parseResponse(await window.fetch(state.url, { 656 method: 'GET', 657 headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, 658 credentials: 'same-origin', 659 })); 660 if (!result.response.ok || result.payload?.result !== true) { 661 throw new Error(result.payload?.message || translate('load_error')); 662 } 663 const data = result.payload.data || {}; 664 const bookings = Array.isArray(data.user_bookings) ? data.user_bookings : []; 665 renderUserBookings(bookings); 666 const availability = preserveAvailability ? state.availability : data.availability; 667 if (!preserveAvailability) applyModalAvailability(availability); 668 updateTriggers(data.agenda_id, { 669 ...availability, 670 user_has_booking: bookings.length > 0, 671 }); 672 updateCsrf(data); 673 return data; 674 }; 675 676 const cancelBooking = async (button) => { 677 if (!state.url || button.disabled) return; 678 if (!window.confirm(translate('cancel_confirm'))) return; 679 button.disabled = true; 680 setMessage(error(), ''); 681 setMessage(success(), ''); 682 try { 683 const body = new FormData(); 684 if (state.csrfName && state.csrfValue) body.set(state.csrfName, state.csrfValue); 685 const result = await parseResponse(await window.fetch( 686 `${state.url}/bookings/${encodeURIComponent(button.dataset.agendaBookingCancel)}/cancel`, { 687 method: 'POST', body, 688 headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, 689 credentials: 'same-origin', 690 } 691 )); 692 if (!result.response.ok || result.payload?.result !== true) { 693 throw new Error(result.payload?.message || translate('failed')); 694 } 695 await refreshBookingState(); 696 setMessage(success(), result.payload.message || translate('cancel_success')); 697 } catch (exception) { 698 button.disabled = false; 699 setMessage(error(), exception.message || translate('failed')); 700 } 701 }; 702 703 const showModal = () => { 704 if (!state.modal) return; 705 if (window.bootstrap?.Modal) { 706 window.bootstrap.Modal.getOrCreateInstance(state.modal).show(); 707 } else { 708 state.modal.classList.add('show'); 709 state.modal.style.display = 'block'; 710 state.modal.removeAttribute('aria-hidden'); 711 } 712 }; 713 714 const hideModal = () => { 715 if (!state.modal) return; 716 if (window.bootstrap?.Modal) { 717 window.bootstrap.Modal.getOrCreateInstance(state.modal).hide(); 718 } else { 719 state.modal.classList.remove('show'); 720 state.modal.style.display = 'none'; 721 state.modal.setAttribute('aria-hidden', 'true'); 722 } 723 }; 724
725 const loadModal = async (trigger) => { 726 state.modal = document.querySelector('[data-agenda-booking-modal]'); 727 if (!state.modal) return; 728 clearRateLimitCountdown(); 729 const requestSequence = ++state.loadSequence; 730 state.availability = null; 731 state.canSubmit = false; 732 state.csrfName = null; 733 state.csrfValue = null; 734 state.captchaAction = 'agenda_booking'; 735 state.captchaSiteKey = null; 736 loadTranslations(); 737 resetTicketPicker(); 738 renderUserBookings([]); 739 state.trigger = trigger; 740 state.returnFocus = trigger; 741 state.url = trigger.dataset.agendaBookingUrl; 742 if (!state.url) return; 743 setMessage(error(), ''); 744 setMessage(success(), ''); 745 setInvalid(false); 746 const hash = by('[data-agenda-booking-hash]'); 747 if (hash) hash.value = ''; 748 const title = by('[data-agenda-booking-title]'); 749 if (title) title.textContent = translate('title') || state.defaultTitle; 750 const info = by('[data-agenda-booking-info]'); 751 if (info) info.replaceChildren(); 752 setLoading(true); 753 showModal(); 754 try { 755 const result = await parseResponse(await window.fetch(state.url, { 756 method: 'GET', 757 headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, 758 credentials: 'same-origin', 759 })); 760 if (requestSequence !== state.loadSequence) return; 761 if (!result.response.ok || result.payload?.result !== true) { 762 throw new Error(result.payload?.message || translate('load_error')); 763 } 764 const data = result.payload.data || {}; 765 state.captchaAction = data.captcha_action || 'agenda_booking'; 766 state.captchaSiteKey = data.captcha_site_key || null; 767 if (title && data.title) title.textContent = data.title; 768 if (info) info.innerHTML = data.booking_info_html || ''; 769 renderUserTickets( 770 data.user_logged_in === true, 771 Array.isArray(data.user_tickets) ? data.user_tickets : [] 772 ); 773 renderUserBookings(Array.isArray(data.user_bookings) ? data.user_bookings : []); 774 applyModalAvailability(data.availability); 775 updateTriggers(data.agenda_id, { 776 ...data.availability, 777 user_has_booking: Array.isArray(data.user_bookings) && data.user_bookings.length > 0, 778 }); 779 if (hash) hash.value = ''; 780 updateCsrf(data); 781 setLoading(false); 782 window.setTimeout(() => hash?.focus(), 0); 783 } catch (exception) { 784 if (requestSequence !== state.loadSequence) return; 785 resetTicketPicker(); 786 if (hash) hash.value = ''; 787 setInvalid(false); 788 state.availability = null; 789 state.canSubmit = false; 790 setLoading(false); 791 setMessage(error(), exception.message || translate('load_error')); 792 } 793 }; 794 795 const executeCaptcha = async () => { 796 if (!window.grecaptcha?.enterprise?.execute || !state.captchaSiteKey) return ''; 797 return window.grecaptcha.enterprise.execute(state.captchaSiteKey, { action: state.captchaAction }); 798 }; 799 800 const clearRateLimitCountdown = () => { 801 if (state.rateLimitTimer) { 802 window.clearInterval(state.rateLimitTimer); 803 state.rateLimitTimer = null; 804 } 805 state.rateLimitUntil = 0; 806 }; 807 808 const startRateLimitCountdown = (seconds) => { 809 clearRateLimitCountdown(); 810 const submitButton = by('[data-agenda-booking-submit]'); 811 state.rateLimitUntil = Date.now() + Math.max(0, seconds) * 1000; 812 if (submitButton) submitButton.disabled = true; 813 814 const tick = () => { 815 const remaining = Math.max(0, Math.ceil((state.rateLimitUntil - Date.now()) / 1000)); 816 if (remaining <= 0) { 817 clearRateLimitCountdown(); 818 if (submitButton) submitButton.disabled = !state.canSubmit; 819 setMessage(error(), ''); 820 return; 821 } 822 setMessage(error(), translateCount('rate_limited_countdown', remaining)); 823 }; 824 825 tick(); 826 state.rateLimitTimer = window.setInterval(tick, 1000); 827 }; 828 829 const submit = async (event) => { 830 event.preventDefault(); 831 if (!state.url || !state.modal) return; 832 const activeForm = form(); 833 if (!activeForm || activeForm.dataset.submitting === '1') return; 834 const hash = by('[data-agenda-booking-hash]'); 835 // Clear any result from a previous attempt before evaluating this one, 836 // otherwise a stale success/error alert stays stacked above the new 837 // message on every early-return validation branch below. 838 setMessage(error(), ''); 839 setMessage(success(), ''); 840 setInvalid(false); 841 if (state.availability && !state.canSubmit) { 842 setMessage(error(), availabilityText(state.availability) || translate('failed')); 843 return; 844 } 845 const trimmedHash = (hash?.value || '').trim().toUpperCase(); 846 if (!trimmedHash) { 847 setMessage(error(), translate('ticket_required')); 848 setInvalid(true); 849 hash?.focus(); 850 return; 851 } 852 if (!HASH_PATTERN.test(trimmedHash)) { 853 setMessage(error(), translate('invalid_hash_format')); 854 setInvalid(true); 855 hash?.focus(); 856 return; 857 } 858 activeForm.dataset.submitting = '1'; 859 setSubmitting(true); 860 try { 861 const captchaToken = await executeCaptcha().catch(() => ''); 862 const body = new FormData(activeForm); 863 body.set('ticket_hash', trimmedHash); 864 body.set('captcha_token', captchaToken || ''); 865 if (state.csrfName && state.csrfValue) body.set(state.csrfName, state.csrfValue); 866 const result = await parseResponse(await window.fetch(state.url, { 867 method: 'POST', 868 body, 869 headers: { 'Accept': 'application/json', 'X-Requested-With': 'XMLHttpRequest' }, 870 credentials: 'same-origin', 871 })); 872 const payload = result.payload || {}; 873 if (!result.response.ok || payload.result !== true) { 874 throw Object.assign(new Error(payload.message || translate('failed')), { code: payload.code, payload }); 875 } 876 const data = payload.data || {}; 877 applyModalAvailability(data); 878 updateTriggers(data.agenda_id || state.trigger?.dataset.agendaId, data); 879 const emailStatus = data.email_status; 880 const emailStatusMessage = emailStatus ? translate(`email_${emailStatus}`) : ''; 881 const suffix = emailStatusMessage ? ` (${emailStatusMessage})` : ''; 882 setMessage(success(), `${payload.message || translate('success')}${suffix}`); 883 activeForm.reset(); 884 if (hash) hash.value = ''; 885 try { 886 await refreshBookingState(true); 887 } catch (refreshError) { 888 // The booking succeeded; the list can be refreshed when reopened. 889 } 890 } catch (exception) { 891 if (exception.code === 'RATE_LIMITED') { 892 const retryAfter = Number(exception.payload?.data?.retry_after_seconds); 893 startRateLimitCountdown(Number.isFinite(retryAfter) && retryAfter > 0 ? retryAfter : 300); 894 } else { 895 const failureData = exception.payload?.data || {}; 896 const failureCode = String(exception.code || '').toLowerCase(); 897 if (failureCode === 'booking_full') { 898 // A race can fill the slot between opening the modal and 899 // submitting. Prefer the stable code over a stale or 900 // misleading server message (e.g. "invalid HASH") and 901 // lock this modal session immediately. 902 const fullAvailability = applyModalAvailability({ 903 ...failureData, 904 availability_status: 'full', 905 agenda_id: failureData.agenda_id || state.trigger?.dataset.agendaId, 906 }); 907 updateTriggers( 908 fullAvailability.agenda_id || state.trigger?.dataset.agendaId, 909 fullAvailability 910 ); 911 } 912 const messageKey = { 913 booking_full: 'full', 914 booking_closed: 'closed', 915 booking_not_open: 'not_open', 916 captcha_failed: 'captcha_failed',
917 invalid_ticket: 'invalid_ticket', 918 ticket_not_eligible: 'not_eligible', 919 }[failureCode]; 920 if (failureCode === 'invalid_ticket' || failureCode === 'ticket_not_eligible') { 921 setInvalid(true); 922 } 923 setMessage( 924 error(), 925 (messageKey && translate(messageKey)) || exception.message || translate('failed') 926 ); 927 } 928 } finally { 929 activeForm.dataset.submitting = '0'; 930 setSubmitting(false); 931 } 932 }; 933 934 const init = () => { 935 const modal = document.querySelector('[data-agenda-booking-modal]'); 936 if (!modal || modal.dataset.agendaBookingInitialized === '1') return; 937 modal.dataset.agendaBookingInitialized = '1'; 938 state.modal = modal; 939 state.defaultTitle = modal.querySelector('[data-agenda-booking-title]')?.textContent || ''; 940 modal.addEventListener('submit', submit); 941 modal.addEventListener('click', (event) => { 942 const cancelButton = event.target.closest?.('[data-agenda-booking-cancel]'); 943 if (cancelButton && modal.contains(cancelButton)) { 944 event.preventDefault(); 945 cancelBooking(cancelButton); 946 return; 947 } 948 const ticketButton = event.target.closest?.('[data-agenda-booking-ticket]'); 949 if (!ticketButton || !modal.contains(ticketButton)) return; 950 event.preventDefault(); 951 selectTicket(ticketButton); 952 }); 953 modal.addEventListener('shown.bs.modal', () => by('[data-agenda-booking-hash]')?.focus()); 954 modal.addEventListener('hidden.bs.modal', () => { 955 state.loadSequence += 1; 956 clearRateLimitCountdown(); 957 state.availability = null; 958 state.canSubmit = false; 959 state.csrfName = null; 960 state.csrfValue = null; 961 state.captchaSiteKey = null; 962 resetTicketPicker(); 963 const hash = by('[data-agenda-booking-hash]'); 964 if (hash) hash.value = ''; 965 setMessage(error(), ''); 966 setMessage(success(), ''); 967 setInvalid(false); 968 state.returnFocus?.focus?.(); 969 }); 970 document.addEventListener('click', (event) => { 971 const trigger = event.target.closest?.('[data-agenda-booking-trigger]'); 972 if (!trigger) return; 973 event.preventDefault(); 974 loadModal(trigger); 975 }); 976 }; 977 978 window.agendaBookingAdapter = { init, loadModal, submit, updateCsrf }; 979 if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); 980 else init(); 981})(window, document); 982 983function initAgendaTagFilter() { 984 const filter = document.getElementById('agenda-tag-filter'); 985 if (!filter || filter.dataset.initialized === '1') { 986 return; 987 } 988 filter.dataset.initialized = '1'; 989 990 const languageCheckboxes = Array.from(filter.querySelectorAll('.agenda-language-filter-checkbox')); 991 const typeCheckboxes = Array.from(filter.querySelectorAll('.agenda-type-filter-checkbox')); 992 const tagCheckboxes = Array.from(filter.querySelectorAll('.agenda-tag-filter-checkbox')); 993 const checkboxes = [...languageCheckboxes, ...typeCheckboxes, ...tagCheckboxes]; 994 const agendaView = document.querySelector('[data-agenda-view]'); 995 const crossRoomEnabled = filter.dataset.crossRoomEnabled === '1' 996 && Boolean(agendaView?.querySelector('[data-agenda-tag-results]')); 997 const results = crossRoomEnabled ? agendaView.querySelector('[data-agenda-tag-results]') : null; 998 const items = Array.from((crossRoomEnabled ? agendaView : document).querySelectorAll('.agenda-item[data-agenda-tag-ids]')); 999 const empty = document.getElementById('agenda-tag-filter-empty'); 1000 const clear = document.getElementById('agenda-tag-filter-clear'); 1001 const count = document.getElementById('agenda-tag-filter-count'); 1002 const resultList = results?.querySelector('[data-agenda-tag-results-list]'); 1003 const resultSummary = results?.querySelector('[data-agenda-tag-results-summary]'); 1004 const resultEmpty = results?.querySelector('[data-agenda-tag-results-empty]'); 1005 const itemRecords = items.map((item) => ({ 1006 item, 1007 placeholder: document.createComment(`agenda-tag-filter:${item.id}`), 1008 })); 1009 let resultsMode = false;
1010 let savedAgendaHash = null; 1011 1012 const getContrastTextColor = (hex) => { 1013 const valid = /^#[0-9a-fA-F]{6}$/.test(hex || '') ? hex : '#3B82F6'; 1014 const channels = [1, 3, 5].map((i) => parseInt(valid.substr(i, 2), 16)); 1015 const weights = [0.2126, 0.7152, 0.0722]; 1016 let luminance = 0; 1017 channels.forEach((channel, index) => { 1018 const normalized = channel / 255; 1019 const linear = normalized <= 0.03928 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4; 1020 luminance += linear * weights[index]; 1021 }); 1022 const blackContrast = (luminance + 0.05) / 0.05; 1023 const whiteContrast = 1.05 / (luminance + 0.05); 1024 return blackContrast >= whiteContrast ? '#000000' : '#ffffff'; 1025 }; 1026 1027 const setStatusVisibility = (element, visible) => { 1028 if (!element) { 1029 return; 1030 } 1031 element.hidden = !visible; 1032 element.classList.toggle('d-none', !visible); 1033 }; 1034 1035 const setFilterControlVisibility = (element, visible) => { 1036 if (!element) { 1037 return; 1038 } 1039 element.hidden = !visible; 1040 element.classList.toggle('d-none', !visible); 1041 }; 1042 1043 const setResultContextVisibility = (item, visible) => { 1044 const context = item.querySelector('[data-agenda-room-context]'); 1045 if (context) { 1046 context.classList.toggle('d-none', !visible); 1047 } 1048 }; 1049 1050 const ensurePlaceholders = () => { 1051 itemRecords.forEach(({ item, placeholder }) => { 1052 if (!placeholder.parentNode && item.parentNode) { 1053 item.parentNode.insertBefore(placeholder, item); 1054 } 1055 }); 1056 }; 1057 1058 const restoreMovedItems = (removePlaceholders = false) => { 1059 itemRecords.forEach(({ item, placeholder }) => { 1060 if (placeholder.parentNode) { 1061 if (item.parentNode !== placeholder.parentNode || placeholder.nextSibling !== item) { 1062 placeholder.parentNode.insertBefore(item, placeholder.nextSibling); 1063 } 1064 setResultContextVisibility(item, false); 1065 if (removePlaceholders) { 1066 placeholder.remove(); 1067 } 1068 } 1069 }); 1070 }; 1071 1072 const clearResults = () => { 1073 if (resultList) { 1074 resultList.replaceChildren(); 1075 } 1076 }; 1077 1078 const setResultsMode = (enabled) => { 1079 if (!crossRoomEnabled || !results) { 1080 return; 1081 } 1082 1083 agendaView.classList.toggle('agenda-tag-results-active', enabled); 1084 agendaView.querySelectorAll('[data-agenda-normal-control], [data-agenda-normal-content]').forEach((element) => { 1085 element.hidden = enabled; 1086 element.setAttribute('aria-hidden', enabled ? 'true' : 'false'); 1087 }); 1088 results.hidden = !enabled; 1089 results.classList.toggle('d-none', !enabled); 1090 results.setAttribute('aria-hidden', enabled ? 'false' : 'true'); 1091 resultsMode = enabled; 1092 }; 1093 1094 const updateUrl = (selectedIds, selectedLanguages, selectedTypeIds, replace = false) => { 1095 const url = new URL(window.location.href); 1096 if (selectedIds.length > 0) { 1097 url.searchParams.set('tags', selectedIds.join(',')); 1098 } else { 1099 url.searchParams.delete('tags'); 1100 } 1101 url.searchParams.delete('languages');
1102 selectedLanguages.forEach((language) => { 1103 url.searchParams.append('languages', language); 1104 }); 1105 if (selectedTypeIds.length > 0) { 1106 url.searchParams.set('types', selectedTypeIds.join(',')); 1107 } else { 1108 url.searchParams.delete('types'); 1109 } 1110 const method = replace ? 'replaceState' : 'pushState'; 1111 window.history[method]({}, '', url.pathname + (url.search ? url.search : '') + url.hash); 1112 }; 1113 1114 const replaceTemplate = (template, values) => String(template || '').replace(/\{(\w+)\}/g, (match, key) => 1115 Object.prototype.hasOwnProperty.call(values, key) ? values[key] : match 1116 ); 1117 1118 const numericDatasetValue = (item, key, fallback = 0) => { 1119 const value = Number(item.dataset[key]); 1120 return Number.isFinite(value) ? value : fallback; 1121 }; 1122 1123 const getAgendaStartTimestamp = (item) => { 1124 if (item.dataset.agendaFullTime === '1') { 1125 return Number.NEGATIVE_INFINITY; 1126 } 1127 1128 const raw = item.dataset.agendaStartTime || ''; 1129 const timestamp = Date.parse(raw.includes('T') ? raw : raw.replace(' ', 'T')); 1130 return Number.isNaN(timestamp) ? Number.POSITIVE_INFINITY : timestamp; 1131 }; 1132 1133 const compareAgendaItems = (left, right) => { 1134 const dayOrder = numericDatasetValue(left, 'agendaDayOrder') - numericDatasetValue(right, 'agendaDayOrder'); 1135 if (dayOrder !== 0) { 1136 return dayOrder; 1137 } 1138 1139 const leftStartTime = getAgendaStartTimestamp(left); 1140 const rightStartTime = getAgendaStartTimestamp(right); 1141 if (leftStartTime !== rightStartTime) { 1142 if (leftStartTime === Number.NEGATIVE_INFINITY) { 1143 return -1; 1144 } 1145 if (rightStartTime === Number.NEGATIVE_INFINITY) { 1146 return 1; 1147 } 1148 return leftStartTime - rightStartTime; 1149 } 1150 1151 const roomOrder = numericDatasetValue(left, 'agendaRoomOrder') - numericDatasetValue(right, 'agendaRoomOrder'); 1152 if (roomOrder !== 0) { 1153 return roomOrder; 1154 } 1155 1156 const agendaId = numericDatasetValue(left, 'agendaId', parseInt(left.id.replace('agenda-', ''), 10) || 0) 1157 - numericDatasetValue(right, 'agendaId', parseInt(right.id.replace('agenda-', ''), 10) || 0); 1158 if (agendaId !== 0) { 1159 return agendaId; 1160 } 1161 1162 return left.id.localeCompare(right.id); 1163 }; 1164 1165 const renderTagResults = (matchingItems) => { 1166 if (!resultList) { 1167 return; 1168 } 1169 1170 clearResults(); 1171 const groups = new Map(); 1172 [...matchingItems].sort(compareAgendaItems).forEach((item) => { 1173 const dayId = item.dataset.agendaDayId || '__single_day__'; 1174 if (!groups.has(dayId)) { 1175 const group = document.createElement('section'); 1176 group.className = 'agenda-tag-results-day'; 1177 1178 const dayName = (item.dataset.agendaDayName || '').trim(); 1179 if (dayName) { 1180 const dayTitle = document.createElement('h3'); 1181 dayTitle.className = 'agenda-tag-results-day-title'; 1182 dayTitle.textContent = replaceTemplate(results.dataset.resultsDayTemplate, { day: dayName }); 1183 group.appendChild(dayTitle); 1184 } 1185 1186 const dayList = document.createElement('div'); 1187 dayList.className = 'agenda-tag-results-day-list'; 1188 group.appendChild(dayList); 1189 resultList.appendChild(group); 1190 groups.set(dayId, dayList); 1191 } 1192 1193 setResultContextVisibility(item, true); 1194 groups.get(dayId).appendChild(item); 1195 }); 1196 }; 1197 1198 const updateResultSummary = (matchingItems) => { 1199 if (!resultSummary || !results) { 1200 return; 1201 } 1202 1203 const rooms = new Set(matchingItems.map((item) => 1204 item.dataset.agendaRoomId || item.dataset.agendaRoomName || item.id 1205 )); 1206 resultSummary.textContent = replaceTemplate(results.dataset.resultsSummaryTemplate, { 1207 count: matchingItems.length, 1208 rooms: rooms.size, 1209 }); 1210 }; 1211 1212 const restoreAgendaState = () => { 1213 const hashToRestore = savedAgendaHash; 1214 savedAgendaHash = null; 1215 if (hashToRestore === null) { 1216 return; 1217 } 1218 1219 const url = new URL(window.location.href); 1220 url.hash = hashToRestore; 1221 window.history.replaceState({}, '', url.pathname + (url.search ? url.search : '') + url.hash); 1222 if (hashToRestore) { 1223 showAgendaTab(hashToRestore); 1224 } 1225 }; 1226 1227 const normalizeLanguage = (language) => String(language || '').trim().toLocaleLowerCase(); 1228 1229 const apply = (updateAddress = true) => { 1230 const selectedByGroup = new Map(); 1231 const selectedIds = []; 1232 const selectedLanguages = []; 1233 const selectedTypeIds = []; 1234
1235 tagCheckboxes.forEach((checkbox) => { 1236 const label = checkbox.closest('.agenda-tag-filter-option'); 1237 if (label) { 1238 label.classList.toggle('active', checkbox.checked); 1239 if (checkbox.checked) { 1240 const color = label.dataset.tagColor; 1241 label.style.backgroundColor = color; 1242 label.style.borderColor = color; 1243 label.style.color = getContrastTextColor(color); 1244 } else { 1245 label.style.backgroundColor = ''; 1246 label.style.borderColor = ''; 1247 label.style.color = ''; 1248 } 1249 } 1250 if (!checkbox.checked) { 1251 return; 1252 } 1253 1254 const id = String(checkbox.dataset.tagId); 1255 const group = checkbox.dataset.tagGroup || ''; 1256 selectedIds.push(id); 1257 if (!selectedByGroup.has(group)) { 1258 selectedByGroup.set(group, []); 1259 } 1260 selectedByGroup.get(group).push(id); 1261 }); 1262 1263 languageCheckboxes.forEach((checkbox) => { 1264 const label = checkbox.closest('.agenda-tag-filter-option'); 1265 if (label) { 1266 label.classList.toggle('active', checkbox.checked); 1267 if (!checkbox.checked) { 1268 label.style.backgroundColor = ''; 1269 label.style.borderColor = ''; 1270 label.style.color = ''; 1271 } 1272 } 1273 if (checkbox.checked) { 1274 selectedLanguages.push(String(checkbox.dataset.agendaLanguage || '').trim()); 1275 } 1276 }); 1277 1278 typeCheckboxes.forEach((checkbox) => { 1279 const label = checkbox.closest('.agenda-tag-filter-option'); 1280 if (label) { 1281 label.classList.toggle('active', checkbox.checked); 1282 if (!checkbox.checked) { 1283 label.style.backgroundColor = ''; 1284 label.style.borderColor = ''; 1285 label.style.color = ''; 1286 } 1287 } 1288 if (checkbox.checked) { 1289 const typeId = String(checkbox.dataset.agendaTypeId || '').trim(); 1290 if (typeId) { 1291 selectedTypeIds.push(typeId); 1292 } 1293 } 1294 }); 1295 1296 const matchesSelectedTags = (item) => { 1297 const itemTagIds = new Set((item.dataset.agendaTagIds || '').split(',').filter(Boolean)); 1298 return Array.from(selectedByGroup.values()).every((groupIds) => 1299 groupIds.some((tagId) => itemTagIds.has(tagId)) 1300 ); 1301 }; 1302 1303 const matchesSelectedLanguages = (item) => { 1304 if (selectedLanguages.length === 0) { 1305 return true; 1306 } 1307 1308 const itemLanguage = normalizeLanguage(item.dataset.agendaLanguage); 1309 return selectedLanguages.some((language) => normalizeLanguage(language) === itemLanguage); 1310 }; 1311 1312 const matchesSelectedTypes = (item) => selectedTypeIds.length === 0 1313 || selectedTypeIds.includes(String(item.dataset.agendaTypeId || '').trim()); 1314 1315 const matchesSelectedFilters = (item) => matchesSelectedTags(item) 1316 && matchesSelectedLanguages(item) 1317 && matchesSelectedTypes(item); 1318 const selectedCount = selectedIds.length + selectedLanguages.length + selectedTypeIds.length; 1319 1320 let visibleCount = 0; 1321 if (crossRoomEnabled) { 1322 if (selectedCount > 0) { 1323 const firstActivation = !resultsMode; 1324 if (firstActivation) { 1325 savedAgendaHash = window.location.hash; 1326 } 1327 1328 ensurePlaceholders(); 1329 restoreMovedItems(); 1330 const matchingItems = []; 1331 items.forEach((item) => { 1332 const visible = matchesSelectedFilters(item); 1333 item.classList.toggle('d-none', !visible); 1334 if (visible) { 1335 visibleCount += 1; 1336 matchingItems.push(item); 1337 } 1338 }); 1339 1340 setResultsMode(true); 1341 renderTagResults(matchingItems); 1342 updateResultSummary(matchingItems); 1343 setStatusVisibility(resultEmpty, matchingItems.length === 0); 1344 if (resultEmpty) { 1345 resultEmpty.textContent = filter.dataset.emptyMessage || ''; 1346 } 1347 setStatusVisibility(empty, false); 1348 if (firstActivation) { 1349 try { 1350 results.focus({ preventScroll: true }); 1351 } catch (error) { 1352 results.focus(); 1353 } 1354 } 1355 } else { 1356 restoreMovedItems(true);
1357 items.forEach((item) => { 1358 item.classList.remove('d-none'); 1359 setResultContextVisibility(item, false); 1360 }); 1361 clearResults(); 1362 setStatusVisibility(resultEmpty, false); 1363 setStatusVisibility(empty, false); 1364 setResultsMode(false); 1365 restoreAgendaState(); 1366 } 1367 } else { 1368 items.forEach((item) => { 1369 const visible = matchesSelectedFilters(item); 1370 item.classList.toggle('d-none', !visible); 1371 if (visible) { 1372 visibleCount += 1; 1373 } 1374 }); 1375 setStatusVisibility(empty, selectedCount > 0 && visibleCount === 0); 1376 if (empty) { 1377 empty.textContent = filter.dataset.emptyMessage || ''; 1378 } 1379 } 1380 1381 if (count) { 1382 count.textContent = String(selectedCount); 1383 setFilterControlVisibility(count, selectedCount > 0); 1384 } 1385 if (clear) { 1386 setFilterControlVisibility(clear, selectedCount > 0); 1387 } 1388 1389 if (updateAddress) { 1390 updateUrl(selectedIds, selectedLanguages, selectedTypeIds); 1391 } 1392 }; 1393 1394 const applyUrlState = () => { 1395 const url = new URL(window.location.href); 1396 const rawIds = (url.searchParams.get('tags') || '') 1397 .split(',') 1398 .filter(Boolean); 1399 const rawLanguages = url.searchParams.getAll('languages').map((language) => language.trim()).filter(Boolean); 1400 const rawTypeIds = (url.searchParams.get('types') || '') 1401 .split(',') 1402 .filter(Boolean); 1403 const knownIds = new Set(tagCheckboxes.map((checkbox) => String(checkbox.dataset.tagId))); 1404 const validIds = rawIds.filter((id) => knownIds.has(id)); 1405 const requested = new Set(validIds); 1406 tagCheckboxes.forEach((checkbox) => { 1407 checkbox.checked = requested.has(String(checkbox.dataset.tagId)); 1408 }); 1409 const knownLanguages = new Map(languageCheckboxes.map((checkbox) => [ 1410 normalizeLanguage(checkbox.dataset.agendaLanguage), 1411 String(checkbox.dataset.agendaLanguage || '').trim(), 1412 ])); 1413 const validLanguages = [];
1414 rawLanguages.forEach((language) => { 1415 const canonical = knownLanguages.get(normalizeLanguage(language)); 1416 if (canonical && !validLanguages.some((value) => normalizeLanguage(value) === normalizeLanguage(canonical))) { 1417 validLanguages.push(canonical); 1418 } 1419 }); 1420 const requestedLanguages = new Set(validLanguages.map(normalizeLanguage)); 1421 languageCheckboxes.forEach((checkbox) => { 1422 checkbox.checked = requestedLanguages.has(normalizeLanguage(checkbox.dataset.agendaLanguage)); 1423 }); 1424 const knownTypeIds = new Set(typeCheckboxes.map((checkbox) => String(checkbox.dataset.agendaTypeId))); 1425 const validTypeIds = rawTypeIds.filter((typeId) => knownTypeIds.has(typeId)); 1426 const requestedTypeIds = new Set(validTypeIds); 1427 typeCheckboxes.forEach((checkbox) => { 1428 checkbox.checked = requestedTypeIds.has(String(checkbox.dataset.agendaTypeId)); 1429 }); 1430 if ( 1431 rawIds.length !== validIds.length 1432 || rawIds.some((id, index) => id !== validIds[index]) 1433 || rawLanguages.length !== validLanguages.length 1434 || rawLanguages.some((language, index) => language !== validLanguages[index]) 1435 || rawTypeIds.length !== validTypeIds.length 1436 || rawTypeIds.some((typeId, index) => typeId !== validTypeIds[index]) 1437 ) { 1438 updateUrl(validIds, validLanguages, validTypeIds, true); 1439 } 1440 apply(false); 1441 }; 1442 1443 checkboxes.forEach((checkbox) => checkbox.addEventListener('change', () => apply(true))); 1444 if (clear) { 1445 clear.addEventListener('click', () => { 1446 checkboxes.forEach((checkbox) => { 1447 checkbox.checked = false; 1448 }); 1449 apply(true); 1450 setFilterControlVisibility(count, false); 1451 setFilterControlVisibility(clear, false); 1452 }); 1453 } 1454 1455 const knownTagIds = new Set(tagCheckboxes.map((checkbox) => String(checkbox.dataset.tagId))); 1456 const filterByTagId = (tagId) => { 1457 if (!knownTagIds.has(tagId)) { 1458 return; 1459 } 1460 tagCheckboxes.forEach((checkbox) => { 1461 checkbox.checked = String(checkbox.dataset.tagId) === tagId; 1462 }); 1463 apply(true); 1464 filter.scrollIntoView({ behavior: 'smooth', block: 'start' }); 1465 }; 1466 1467 const knownTypeIds = new Set(typeCheckboxes.map((checkbox) => String(checkbox.dataset.agendaTypeId))); 1468 const filterByTypeId = (typeId) => { 1469 const normalizedTypeId = String(typeId || '').trim(); 1470 if (!knownTypeIds.has(normalizedTypeId)) { 1471 return; 1472 } 1473 typeCheckboxes.forEach((checkbox) => { 1474 checkbox.checked = String(checkbox.dataset.agendaTypeId) === normalizedTypeId; 1475 }); 1476 apply(true); 1477 filter.scrollIntoView({ behavior: 'smooth', block: 'start' }); 1478 }; 1479 1480 const filterByLanguage = (language) => { 1481 const normalizedLanguage = normalizeLanguage(language); 1482 if (!normalizedLanguage || !languageCheckboxes.some((checkbox) => 1483 normalizeLanguage(checkbox.dataset.agendaLanguage) === normalizedLanguage 1484 )) { 1485 return; 1486 } 1487 languageCheckboxes.forEach((checkbox) => { 1488 checkbox.checked = normalizeLanguage(checkbox.dataset.agendaLanguage) === normalizedLanguage; 1489 }); 1490 apply(true); 1491 filter.scrollIntoView({ behavior: 'smooth', block: 'start' }); 1492 }; 1493 1494 document.querySelectorAll('.agenda-tag[data-agenda-tag-id]').forEach((pill) => { 1495 const tagId = String(pill.dataset.agendaTagId); 1496 pill.addEventListener('click', () => filterByTagId(tagId)); 1497 pill.addEventListener('keydown', (event) => { 1498 if (event.key === 'Enter' || event.key === ' ') { 1499 event.preventDefault(); 1500 filterByTagId(tagId); 1501 } 1502 }); 1503 }); 1504 1505 document.querySelectorAll('[data-agenda-language-filter]').forEach((pill) => { 1506 const language = pill.dataset.agendaLanguageFilter || ''; 1507 pill.addEventListener('click', () => filterByLanguage(language)); 1508 pill.addEventListener('keydown', (event) => { 1509 if (event.key === 'Enter' || event.key === ' ') { 1510 event.preventDefault(); 1511 filterByLanguage(language); 1512 } 1513 }); 1514 }); 1515 1516 document.querySelectorAll('[data-agenda-type-filter-id]').forEach((pill) => { 1517 const typeId = String(pill.dataset.agendaTypeFilterId || ''); 1518 pill.addEventListener('click', () => filterByTypeId(typeId)); 1519 pill.addEventListener('keydown', (event) => { 1520 if (event.key === 'Enter' || event.key === ' ') { 1521 event.preventDefault(); 1522 filterByTypeId(typeId); 1523 } 1524 }); 1525 }); 1526 1527 window.addEventListener('popstate', applyUrlState); 1528 applyUrlState(); 1529} 1530 1531if (document.readyState === 'loading') { 1532 document.addEventListener('DOMContentLoaded', initAgendaTagFilter); 1533} else { 1534 initAgendaTagFilter(); 1535}
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.