1/** 2 * contactform.js 3 * Asset key: com_study.leadform 4 * 5 * Loads HTML via AJAX and either: 6 * - injects the lead form into a page container (inline mode), or 7 * - opens a Bootstrap modal and injects the lead form (modal mode). 8 * 9 * Executes embedded <script> tags in order and applies <link>/<style> from the response. 10 * Also bootstraps: 11 * - intl-tel-input 12 * - Pristine validation + AJAX submit 13 * - Fancy Select (Joomla/Choices.js) 14 * - Read-more toggles, address toggle 15 * 16 * Public API: 17 * - loadLeadFormInline(target: string|HTMLElement, opts?: {courseId?: string|number, vendorId?: string|number, layout?: string}): Promise<void> 18 * - getLeadForm(courseId?: string|number, vendorId?: string|number): Promise<void> 19 * 20 * Auto-init: 21 * - Elements with [data-leadform-inline] will be initialized on DOMContentLoaded. 22 */ 23 24const runtime = window.comStudyLeadform || {}; 25 26/* -------------------------------- 27 * Utilities 28 * -------------------------------- */ 29 30/** 31 * Primary language subtag of the current page, e.g. 'de' or 'en'. 32 * @returns {string} 33 */ 34function pageLanguage() { 35 return (document.documentElement.getAttribute('lang') || '').split('-')[0].toLowerCase(); 36} 37 38/** 39 * Base path for the Ajax endpoint. On a language-prefixed page Joomla's language filter 40 * answers /index.php with a redirect to the prefixed SEF root, so the prefix is requested 41 * right away and two redirects per form load disappear. 42 * @returns {string} 43 */ 44function ajaxBase() { 45 const lang = pageLanguage(); 46 const segment = window.location.pathname.split('/')[1] || ''; 47 48 if (lang !== '' && segment.toLowerCase() === lang) { 49 return `/${segment}`; 50 } 51 52 return '/index.php'; 53} 54 55/** 56 * Build the Ajax endpoint URL. 57 * @param {string} courseId 58 * @param {string} vendorId 59 * @param {string} layout 60 * @returns {string} 61 */ 62function buildUrl(courseId = '0', vendorId = '0', layout = 'leadform') { 63 const base = runtime.endpoint || new URL(ajaxBase(), window.location.origin).toString(); 64 const url = new URL(base, window.location.origin); 65 66 if (!url.searchParams.has('option')) url.searchParams.set('option', 'com_study'); 67 if (!url.searchParams.has('view')) url.searchParams.set('view', 'ajax'); 68 if (!url.searchParams.has('layout')) url.searchParams.set('layout', layout); 69 70 url.searchParams.set('e', String(courseId)); 71 url.searchParams.set('v', String(vendorId)); 72 73 if (runtime.token) { 74 url.searchParams.set(runtime.token, '1'); 75 } 76 77 return url.toString(); 78} 79 80/** 81 * Close all currently open Bootstrap modals. 82 */ 83function closeAllModals() { 84 const opened = document.querySelectorAll('.modal.show'); 85 opened.forEach((el) => { 86 const inst = bootstrap.Modal.getInstance(el); 87 if (inst) inst.hide(); 88 }); 89} 90 91/** 92 * Load a script once (idempotent), queue callbacks while loading.
93 * No jQuery dependency. 94 * @param {string} src 95 * @param {() => boolean} isReady 96 * @returns {Promise<void>} 97 */ 98function loadScriptOnce(src, isReady) { 99 if (isReady && isReady()) { 100 return Promise.resolve(); 101 } 102 103 window.__scriptLoads = window.__scriptLoads || {}; 104 const registry = window.__scriptLoads; 105 106 if (registry[src]?.state === 'done') { 107 return Promise.resolve(); 108 } 109 if (registry[src]?.state === 'loading') { 110 return registry[src].promise; 111 } 112 113 let resolveFn, rejectFn; 114 const promise = new Promise((resolve, reject) => { 115 resolveFn = resolve; 116 rejectFn = reject; 117 }); 118 119 registry[src] = { state: 'loading', promise }; 120 121 const s = document.createElement('script'); 122 s.src = src; 123 s.async = true; 124 const pageNonce = document.querySelector('script[nonce]')?.getAttribute('nonce') || null; 125 if (pageNonce) s.setAttribute('nonce', pageNonce); 126 127 s.addEventListener('load', () => { 128 registry[src].state = 'done'; 129 resolveFn(); 130 }); 131 s.addEventListener('error', () => { 132 delete registry[src]; 133 rejectFn(new Error(`Failed to load script: ${src}`)); 134 }); 135 document.head.appendChild(s); 136 137 return promise.then(() => { 138 if (isReady && !isReady()) { 139 return new Promise((r) => setTimeout(r, 0)); 140 } 141 }); 142} 143 144/** 145 * Ensure a stylesheet is present by href (idempotent). 146 * @param {string} href 147 */ 148function ensureStylesheet(href) { 149 const abs = new URL(href, window.location.origin).toString(); 150 const exists = Array.from(document.querySelectorAll('link[rel="stylesheet"]')) 151 .some((l) => l.href === abs); 152 if (exists) return; 153 const l = document.createElement('link'); 154 l.rel = 'stylesheet'; 155 l.href = href; 156 document.head.appendChild(l); 157} 158 159/** 160 * Resolve possibly relative URL against a base. 161 * @param {string} maybeRelative 162 * @param {string} baseUrl 163 * @returns {string} 164 */ 165function toAbsoluteUrl(maybeRelative, baseUrl) { 166 try { 167 return new URL(maybeRelative, baseUrl).toString(); 168 } catch { 169 return maybeRelative; 170 } 171} 172 173/** 174 * Apply <link rel="stylesheet"> and <style> from fetched document into <head>. 175 * Skips duplicates by href. 176 * @param {Document} fetchedDoc 177 * @param {string} baseUrl 178 */ 179function applyStyles(fetchedDoc, baseUrl) { 180 const head = document.head; 181 182 const existingHrefs = new Set( 183 Array.from(document.querySelectorAll('link[rel="stylesheet"][href]')).map((l) => l.href) 184 ); 185 186 fetchedDoc.querySelectorAll('link[rel="stylesheet"][href]').forEach((linkEl) => { 187 const absHref = toAbsoluteUrl(linkEl.getAttribute('href'), baseUrl); 188 if (existingHrefs.has(absHref)) return; 189 190 const l = document.createElement('link'); 191 l.rel = 'stylesheet'; 192 l.href = absHref; 193 ['media', 'title', 'crossorigin', 'referrerpolicy', 'integrity', 'as', 'disabled'] 194 .forEach((attr) => { 195 if (linkEl.hasAttribute(attr)) l.setAttribute(attr, linkEl.getAttribute(attr)); 196 }); 197 198 head.appendChild(l); 199 existingHrefs.add(absHref); 200 }); 201 202 fetchedDoc.querySelectorAll('style').forEach((styleEl) => { 203 const s = document.createElement('style'); 204 for (const { name, value } of Array.from(styleEl.attributes)) { 205 s.setAttribute(name, value); 206 } 207 const pageNonce = document.querySelector('script[nonce]')?.getAttribute('nonce') || null; 208 if (pageNonce && !s.nonce) s.setAttribute('nonce', pageNonce); 209 210 s.textContent = styleEl.textContent || ''; 211 head.appendChild(s); 212 }); 213} 214 215/** 216 * Execute a <script> tag (external or inline). Resolves when finished/loaded. 217 * @param {HTMLScriptElement} original 218 * @param {HTMLElement} container 219 * @param {string|null} pageNonce 220 * @param {string} baseUrl 221 * @returns {Promise<void>} 222 */ 223function runClonedScript(original, container, pageNonce, baseUrl) { 224 return new Promise((resolve) => { 225 const s = document.createElement('script'); 226 227 for (const { name, value } of Array.from(original.attributes)) { 228 if (name === 'async' || name === 'defer') continue; 229 s.setAttribute(name, value); 230 } 231 232 if (pageNonce && !s.nonce) { 233 s.setAttribute('nonce', pageNonce); 234 } 235 236 const isModule = (s.getAttribute('type') || '').toLowerCase() === 'module'; 237 238 if (original.src) { 239 const absSrc = toAbsoluteUrl(original.getAttribute('src'), baseUrl); 240 241 if (document.querySelector(`script[src="${CSS.escape(absSrc)}"]`)) { 242 resolve(); 243 return; 244 } 245 246 ['crossorigin', 'referrerpolicy', 'integrity', 'nomodule'].forEach((attr) => { 247 if (original.hasAttribute(attr)) s.setAttribute(attr, original.getAttribute(attr)); 248 }); 249 250 s.addEventListener('load', () => resolve()); 251 s.addEventListener('error', () => resolve()); 252 253 s.src = absSrc; 254 (isModule ? document.body : document.head).appendChild(s); 255 } else { 256 s.text = original.textContent || ''; 257 (isModule ? document.body : container).appendChild(s); 258 resolve(); 259 } 260 });
261} 262 263/** 264 * Insert fetched HTML into container, then execute scripts in document order. 265 * @param {HTMLElement} container 266 * @param {string} html 267 * @param {string} baseUrl 268 * @returns {Promise<void>} 269 */ 270async function insertHtmlApplyAssetsRunAndBoot(container, html, baseUrl) { 271 const parser = new DOMParser(); 272 const fetched = parser.parseFromString(html, 'text/html'); 273 274 applyStyles(fetched, baseUrl); 275 276 const headScripts = Array.from(fetched.head.querySelectorAll('script')); 277 const bodyScripts = Array.from(fetched.body.querySelectorAll('script')); 278 279 container.innerHTML = ''; 280 const frag = document.createDocumentFragment(); 281 for (const node of Array.from(fetched.body.childNodes)) { 282 if (!(node instanceof HTMLScriptElement)) { 283 frag.appendChild(node); 284 } 285 } 286 container.appendChild(frag); 287 288 const pageNonce = document.querySelector('script[nonce]')?.getAttribute('nonce') || null; 289 for (const s of headScripts) { 290 // eslint-disable-next-line no-await-in-loop 291 await runClonedScript(s, container, pageNonce, baseUrl); 292 } 293 for (const s of bodyScripts) { 294 // eslint-disable-next-line no-await-in-loop 295 await runClonedScript(s, container, pageNonce, baseUrl); 296 } 297 298 bootLeadForm(container); 299} 300 301/* -------------------------------- 302 * intl-tel-input 303 * -------------------------------- */ 304 305/** 306 * Initialize intl-tel-input for a given form. 307 * @param {HTMLElement} form 308 */ 309function initIntlTel(form) { 310 const phoneInput = form.querySelector('.js-phone'); 311 if (!phoneInput) return; 312 313 const doInit = () => { 314 if (phoneInput._iti) { // eslint-disable-line no-underscore-dangle 315 phoneInput._iti.destroy(); // eslint-disable-line no-underscore-dangle 316 } 317 // A German page may preselect Germany. Everywhere else the audience applies from 318 // abroad, so nothing is preselected: a preset +49 silently turns a foreign number 319 // into an unreachable German one. Typing a + prefix still selects the country. 320 const isGermanPage = pageLanguage() === 'de'; 321 322 const instance = window.intlTelInput(phoneInput, { 323 autoPlaceholder: 'off', 324 initialCountry: isGermanPage ? 'de' : '', 325 placeholderNumberType: 'MOBILE', 326 countryOrder: isGermanPage ? ['de', 'ch', 'at', 'us', 'gb'] : ['de', 'gb', 'us', 'at', 'ch'], 327 separateDialCode: true, 328 loadUtils: () => import('/media/com_study/vendor/intl-tel-input/build/js/utils.js') 329 }); 330 // eslint-disable-next-line no-underscore-dangle 331 phoneInput._iti = instance; 332 }; 333 334 loadScriptOnce( 335 '/media/com_study/vendor/intl-tel-input/build/js/intlTelInput.min.js?v2', 336 () => typeof window.intlTelInput === 'function' 337 ).then(doInit).catch((e) => console.warn(e.message)); 338} 339 340/* -------------------------------- 341 * Read-more + Address toggle 342 * -------------------------------- */ 343 344function initReadMore(form) { 345 const hiddenClass = 'hide'; 346 const contents = Array.from(form.querySelectorAll('.read-more-content')); 347 const showBtns = Array.from(form.querySelectorAll('.read-more-show')); 348 349 contents.forEach((cnt) => { 350 cnt.classList.add(hiddenClass); 351 cnt.setAttribute('aria-hidden', 'true'); 352 if (!cnt.querySelector('.read-more-hide')) { 353 const a = document.createElement('a'); 354 a.href = '#'; 355 a.className = 'read-more-hide'; 356 a.setAttribute('role', 'button'); 357 a.setAttribute('aria-expanded', 'true'); 358 a.textContent = 'SchlieÃen'; 359 cnt.appendChild(document.createTextNode(' ')); 360 cnt.appendChild(a); 361 } 362 }); 363 364 showBtns.forEach((btn) => { 365 btn.classList.remove(hiddenClass); 366 btn.setAttribute('role', 'button'); 367 btn.setAttribute('aria-expanded', 'false'); 368 }); 369 370 form.addEventListener('click', (ev) => { 371 const show = ev.target.closest('.read-more-show'); 372 if (show && form.contains(show)) { 373 ev.preventDefault(); 374 const cnt = show.nextElementSibling; 375 if (cnt && cnt.classList.contains('read-more-content')) { 376 cnt.classList.remove(hiddenClass); 377 cnt.setAttribute('aria-hidden', 'false'); 378 show.classList.add(hiddenClass); 379 show.setAttribute('aria-expanded', 'true'); 380 } 381 } 382 383 const hide = ev.target.closest('.read-more-hide'); 384 if (hide && form.contains(hide)) { 385 ev.preventDefault(); 386 const cnt = hide.closest('.read-more-content'); 387 const showBtn = cnt?.previousElementSibling; 388 if (cnt) { 389 cnt.classList.add(hiddenClass); 390 cnt.setAttribute('aria-hidden', 'true'); 391 } 392 if (showBtn && showBtn.classList.contains('read-more-show')) { 393 showBtn.classList.remove(hiddenClass); 394 showBtn.setAttribute('aria-expanded', 'false'); 395 showBtn.focus(); 396 } 397 } 398 });
399} 400 401function initAddressToggle(form) { 402 const sendPost = form.querySelector('.send-post'); 403 const addressFields = form.querySelector('.form-adressfields'); 404 if (!sendPost || !addressFields) return; 405 406 const hideAddressAndReset = () => { 407 const fields = addressFields.querySelectorAll('input, select, textarea'); 408 fields.forEach((el) => { 409 const tag = el.tagName.toLowerCase(); 410 const type = (el.getAttribute('type') || '').toLowerCase(); 411 const name = el.getAttribute('name') || ''; 412 if (/^country$/.test(name)) return; 413 if (['button', 'submit', 'reset', 'hidden'].includes(type)) return; 414 if (['checkbox', 'radio'].includes(type)) { 415 el.checked = false; 416 return; 417 } 418 if (tag === 'select') { 419 el.selectedIndex = -1; 420 return; 421 } 422 el.value = ''; 423 }); 424 425 addressFields.style.display = 'none'; 426 }; 427 428 const onChange = () => { 429 if (sendPost.checked) { 430 addressFields.style.removeProperty('display'); 431 } else { 432 hideAddressAndReset(); 433 } 434 }; 435 436 sendPost.addEventListener('change', onChange); 437 onChange(); 438} 439 440/* -------------------------------- 441 * Fancy Select (Choices.js) â Vanilla 442 * -------------------------------- */ 443 444/** 445 * Ensure Choices (Joomla fancy select) assets are loaded. 446 * @returns {Promise<void>} 447 */ 448function ensureChoices() { 449 ensureStylesheet('/media/vendor/choicesjs/css/choices.min.css'); 450 return loadScriptOnce('/media/vendor/choicesjs/js/choices.min.js', () => typeof window.Choices === 'function'); 451} 452 453/** 454 * Create (or recreate) a Choices instance on a select element. 455 * Adds a placeholder option (value="") if needed. 456 * @param {HTMLSelectElement} select 457 * @param {{searchEnabled?: boolean, placeholder?: string}} [opts] 458 * @returns {any} 459 */ 460function mountChoices(select, opts = {}) { 461 if (select._choices) { 462 try { select._choices.destroy(); } catch (_e) {} 463 select._choices = null; 464 } 465 466 const placeholderText = String(opts.placeholder || ''); 467 if (placeholderText) { 468 const hasEmpty = Array.from(select.options).some((o) => o.value === ''); 469 if (!hasEmpty) { 470 const opt = document.createElement('option'); 471 opt.value = ''; 472 opt.textContent = placeholderText; 473 select.insertBefore(opt, select.firstChild); 474 } 475 select.setAttribute('data-placeholder', placeholderText); 476 } 477 478 const instance = new window.Choices(select, { 479 searchEnabled: !!opts.searchEnabled, 480 shouldSort: false, 481 allowHTML: false, 482 placeholder: !!placeholderText, 483 placeholderValue: placeholderText || '', 484 itemSelectText: '',
485 removeItemButton: false, 486 position: 'auto' 487 }); 488 489 // eslint-disable-next-line no-underscore-dangle 490 select._choices = instance; 491 492 if (placeholderText) { 493 instance.setChoiceByValue(''); 494 } 495 496 // Live validate if Pristine exists on the form 497 const form = select.closest('form'); 498 const onInter = () => { 499 if (form && form._pristine) form._pristine.validate(select); 500 }; 501 select.addEventListener('change', onInter); 502 select.addEventListener('blur', onInter); 503 504 return instance; 505} 506 507/** 508 * Replace all options of a Choices-enhanced select. 509 * @param {HTMLSelectElement} select 510 * @param {Array<{value:string,label:string,selected?:boolean,disabled?:boolean}>} options 511 * @param {string} placeholder 512 */ 513function replaceChoices(select, options, placeholder = '') { 514 const inst = select._choices; // eslint-disable-line no-underscore-dangle 515 if (!inst) return; 516 517 inst.clearChoices(); 518 519 const final = []; 520 if (placeholder) { 521 final.push({ value: '', label: placeholder, selected: false, disabled: false }); 522 } 523 options.forEach((o) =>
523 final.push({ 524 value: String(o.value), 525 label: String(o.label), 526 selected: !!o.selected, 527 disabled: !!o.disabled 528 })); 529 530 inst.setChoices(final, 'value', 'label', true); 531 532 inst.removeActiveItems(); 533 if (placeholder) inst.setChoiceByValue(''); 534} 535 536/** 537 * Initialize Fancy Selects and wire cascading logic. 538 * @param {HTMLElement} form 539 */ 540function initSelects(form) { 541 // Snapshot native options BEFORE mounting Choices 542 const courseModeEl = form.querySelector('[id^="courseMode-"]'); // Studienform 543 const courseIdEl = form.querySelector('[id^="courseId-"]'); // Studiengang 544 const courseIdWrapper = form.querySelector('[id^="courseid-wrapper-"]'); // Wrapper Studiengang 545 const locSelect = form.querySelector('[id^="location-"]'); // Studienort 546 const locGroup = form.querySelector('[id^="form-group-location-"]'); 547 548 if (locSelect) { 549 locSelect.required = true; 550 if (!locSelect.getAttribute('data-pristine-required-message')) locSelect.setAttribute('data-pristine-required-message', 'Bitte auswählen.'); 551 } 552 553 const allCourses = courseIdEl 554 ? Array.from(courseIdEl.querySelectorAll('option')) 555 .filter((o) => o.value !== '') 556 .map((o) => ({ 557 value: o.value, 558 label: (o.textContent || '').trim(), 559 mode: String(o.getAttribute('data-mode') || ''), 560 locations: String(o.getAttribute('data-locations') || '') 561 })) 562 : []; 563 564 const courseIdPlaceholder = courseIdEl ? String(courseIdEl.getAttribute('data-placeholder') || '') : ''; 565 const locPlaceholder = locSelect ? String(locSelect.getAttribute('data-placeholder') || '') : ''; 566 567 ensureChoices().then(() => { 568 // Basiskonfiguration für alle .select-nice 569 Array.from(form.querySelectorAll('.select-nice')).forEach((el) => { 570 const placeholderText = String(el.getAttribute('data-placeholder') || ''); 571 const disableSearch = (el.getAttribute('data-search') === 'false' || el.getAttribute('data-search') === false); 572 mountChoices(el, { searchEnabled: !disableSearch, placeholder: placeholderText }); 573 }); 574 575 // Country default "Deutschland" 576 const countryEl = form.querySelector('[id^="country-"]'); 577 if (countryEl) { 578 const hasGermany = Array.from(countryEl.options).some((o) => o.text === 'Deutschland'); 579 if (hasGermany) { 580 countryEl.value = 'Deutschland'; 581 if (countryEl._choices) countryEl._choices.setChoiceByValue('Deutschland'); // eslint-disable-line no-underscore-dangle 582 } 583 } 584 585 // Helpers for location (required field) 586 const hideLocations = () => { 587 if (!locSelect || !locGroup) return; 588 589 if (locSelect._choices) { // eslint-disable-line no-underscore-dangle 590 locSelect._choices.removeActiveItems(); // eslint-disable-line no-underscore-dangle 591 const ph = String(locSelect.getAttribute('data-placeholder') || ''); 592 if (ph) locSelect._choices.setChoiceByValue(''); // eslint-disable-line no-underscore-dangle 593 } 594 locSelect.value = ''; 595 596 locSelect.disabled = true; 597 locSelect.setAttribute('data-pristine-excluded', 'true'); 598 599 locGroup.style.display = 'none'; 600 }; 601 602 const showLocations = (items) => { 603 if (!locSelect || !locGroup) return; 604 605 // Choices sicher montiert 606 if (!locSelect._choices) { // eslint-disable-line no-underscore-dangle 607 const locPlaceholder = String(locSelect.getAttribute('data-placeholder') || ''); 608 mountChoices(locSelect, { searchEnabled: true, placeholder: locPlaceholder }); 609 } 610 611 // Optionen setzen 612 const locPlaceholder = String(locSelect.getAttribute('data-placeholder') || ''); 613 const data = items.map((v) => ({ value: v, label: v })); 614 replaceChoices(locSelect, data, locPlaceholder); 615 616 // wieder sicht- & validierbar 617 locGroup.style.removeProperty('display'); 618 locSelect.disabled = false; 619 locSelect.removeAttribute('data-pristine-excluded'); 620 // WICHTIG: keine sofortige pristine.validate() hier â sonst Fehlermeldung beim Anzeigen 621 }; 622 623 // Einzelkurs: Orte direkt aus data-locations am Location-Select 624 if (locSelect && !courseIdEl) { 625 const locationsRaw = String(locSelect.getAttribute('data-locations') || ''); 626 if (locationsRaw.trim() !== '') {
627 const items = locationsRaw.split(', ').filter(Boolean); 628 if (!locSelect._choices) mountChoices(locSelect, { searchEnabled: true, placeholder: locPlaceholder }); 629 showLocations(items); 630 } 631 } 632 633 // Studienform -> Studiengang (filtern) 634 const filterCoursesByMode = (modeVal) => { 635 if (!courseIdEl) return; 636 637 if (!courseIdEl._choices) { 638 const ph = String(courseIdEl.getAttribute('data-placeholder') || ''); 639 const searchAttr = courseIdEl.getAttribute('data-search'); 640 const disableSearch = (searchAttr === 'false' || searchAttr === false); 641 mountChoices(courseIdEl, { searchEnabled: !disableSearch, placeholder: ph }); 642 } 643 644 const filtered = allCourses.filter((c) => c.mode === String(modeVal)); 645 const choiceData = filtered.map((c) => ({ value: c.value, label: c.label })); 646 replaceChoices(courseIdEl, choiceData, courseIdPlaceholder); 647 }; 648 649 if (courseModeEl && courseIdEl) { 650 if (courseIdWrapper) courseIdWrapper.style.display = 'none'; 651 courseModeEl.addEventListener('change', () => { 652 const modeVal = courseModeEl.value || ''; 653 if (courseIdWrapper) courseIdWrapper.style.removeProperty('display'); 654 filterCoursesByMode(modeVal); 655 hideLocations(); 656 }); 657 } 658 659 // Studiengang -> Studienort 660 if (courseIdEl && locSelect && locGroup) { 661 courseIdEl.addEventListener('change', () => { 662 const val = courseIdEl.value || ''; 663 const course = allCourses.find((c) => c.value === val); 664 if (!course) { hideLocations(); return; } 665 666 const modeInt = Number(course.mode || '0'); 667 const locationsRaw = String(course.locations || ''); 668 669 if (modeInt !== 2 && locationsRaw.trim() !== '') { 670 const items = locationsRaw.split(', ').filter(Boolean); 671 showLocations(items); 672 } else { 673 if (!locSelect._choices) mountChoices(locSelect, { searchEnabled: true, placeholder: locPlaceholder }); 674 replaceChoices(locSelect, [{ value: 'online', label: 'alle', selected: true }], locPlaceholder); 675 if (locSelect._choices) locSelect._choices.setChoiceByValue('online'); // eslint-disable-line no-underscore-dangle 676 hideLocations(); 677 } 678 679 if (form._pristine) form._pristine.validate(locSelect); 680 }); 681 } 682 }).catch((e) => console.warn(e.message)); 683} 684 685/* -------------------------------- 686 * Pristine validation + submit 687 * -------------------------------- */ 688 689// Nationwide fixed-date German public holidays (MM-DD), mirrored from LeadService 690const CALLBACK_HOLIDAYS = ['01-01', '05-01', '10-03', '12-25', '12-26']; 691 692/** 693 * Validate a callback slot from the date input and the time select. 694 * Checks the accepted weekdays (from the vendor config), fixed-date holidays and 695 * the minimum lead time. The time itself needs no check: the select only offers 696 * times the vendor accepts. Runs in the browser's local time for instant 697 * feedback; the server re-validates strictly in Europe/Berlin. 698 * @param {string} value Combined value as YYYY-MM-DDTHH:MM 699 * @param {number} minLeadMinutes 700 * @param {number[]} days Accepted ISO-8601 weekdays (1 = Monday) 701 * @returns {boolean} 702 */ 703function isValidCallbackSlot(value, minLeadMinutes, days) { 704 if (!value) return false; 705 const slot = new Date(value); 706 if (Number.isNaN(slot.getTime())) return false; 707 // getDay() is 0-based on Sunday, the config uses ISO-8601 (1 = Monday, 7 = Sunday) 708 const isoDay = slot.getDay() === 0 ? 7 : slot.getDay(); 709 if (days.length && !days.includes(isoDay)) return false; 710 const monthDay = `${String(slot.getMonth() + 1).padStart(2, '0')}-${String(slot.getDate()).padStart(2, '0')}`; 711 if (CALLBACK_HOLIDAYS.includes(monthDay)) return false; 712 return slot.getTime() - Date.now() >= minLeadMinutes * 60 * 1000; 713} 714 715/** 716 * Initialize Pristine validation and Ajax submit flow. 717 * @param {HTMLFormElement} form 718 */ 719function initValidator(form) { 720 const pristineReady = () => 721 (typeof window.Pristine !== 'undefined') 722 ? Promise.resolve() 723 : loadScriptOnce('/media/com_study/vendor/pristinejs/dist/pristine.min.js',
723() => typeof window.Pristine !== 'undefined'); 724 725 pristineReady().then(() => { 726 const pristine = new window.Pristine(form, { 727 classTo: 'form-group', 728 errorClass: 'has-danger', 729 successClass: 'has-success', 730 errorTextParent: 'form-group', 731 errorTextTag: 'div', 732 errorTextClass: 'pristine-error' 733 }, true); 734 735 // Exponiere Instanz am Form für spätere gezielte Validierungen 736 // eslint-disable-next-line no-underscore-dangle 737 form._pristine = pristine; 738 739 740 741 // Required-Check für Fancy Selects (inkl. location) 742 const attachRequiredSelectValidator = (select) => { 743 if (!select || !select.required) return; 744 pristine.addValidator( 745 select, 746 () => { 747 if (select.disabled || select.hasAttribute('data-pristine-excluded')) return true; 748 return String(select.value || '') !== ''; 749 }, 750 select.getAttribute('data-pristine-required-message') || 'Bitte auswählen.', 751 2, 752 false 753 ); 754 const trigger = () => pristine.validate(select); 755 select.addEventListener('change', trigger); 756 select.addEventListener('blur', trigger); 757 }; 758 759 form.querySelectorAll('select.select-nice[required]').forEach(attachRequiredSelectValidator); 760 761 // Callback slot: date input + time select are validated as a combined slot, 762 // giving instant feedback on weekday/holiday/lead time (the select itself 763 // only offers valid times) 764 const slotDate = form.querySelector('.js-callback-date'); 765 const slotTime = form.querySelector('.js-callback-time'); 766 if (slotDate && slotTime) { 767 const minLeadMinutes = parseInt(slotDate.getAttribute('data-min-lead-minutes') || '120', 10); 768 const allowedDays = (slotDate.getAttribute('data-callback-days') || '') 769 .split(',') 770 .map((day) => parseInt(day, 10)) 771 .filter((day) => !Number.isNaN(day)); 772 pristine.addValidator( 773 slotDate, 774 () => isValidCallbackSlot(`${slotDate.value}T${slotTime.value}`, minLeadMinutes, allowedDays), 775 slotDate.getAttribute('data-slot-message') || 'Bitte einen angebotenen Termin wählen.', 776 2, 777 false 778 ); 779 const validateSlot = () => pristine.validate(slotDate); 780 slotDate.addEventListener('change', validateSlot); 781 slotTime.addEventListener('change', validateSlot); 782 } 783 784 // Submit 785 let phoneFormatted = false; 786 787 form.addEventListener('submit', (e) => { 788 e.preventDefault(); 789 790 const valid = pristine.validate(); 791 if (!valid) return; 792 793 const submitBtn = form.querySelector('.js-leadform-submit'); 794 if (submitBtn) submitBtn.disabled = true; 795 796 const overlay = document.getElementById('overlay'); 797 const spinner = document.getElementById('spinner'); 798 if (overlay && spinner) { 799 overlay.style.display = 'flex'; 800 spinner.style.display = 'block'; 801 } 802 803 closeAllModals(); 804 805 const phoneInput = form.querySelector('.js-phone'); 806 if (phoneInput && !phoneFormatted && phoneInput._iti) { // eslint-disable-line no-underscore-dangle 807 const iti = phoneInput._iti; // eslint-disable-line no-underscore-dangle 808 const current = phoneInput.value || ''; 809 const country = iti.getSelectedCountry(); 810 const dial = (country && country.dialCode) ? country.dialCode : ''; 811 if (current !== '' && dial !== '') { 812 const formatted = `+${dial}${current.replace(/^\s+/, '0')}`; 813 phoneInput.value = formatted; 814 const cc = form.querySelector('[name="phoneCountryCode"]'); 815 if (cc) cc.value = dial; 816 phoneFormatted = true; 817 } 818 } 819 820 const fd = new FormData(form); 821 fd.set('format', 'json'); 822 823 fetch(form.action, { 824 method: 'POST', 825 body: fd, 826 credentials: 'same-origin', 827 headers: { 'X-Requested-With': 'XMLHttpRequest' } 828 }) 829 .then((res) => res.json().catch(() => { throw new Error('invalid_json'); })) 830 .then((json) => { 831 if (overlay && spinner) { 832 overlay.style.display = 'none'; 833 spinner.style.display = 'none'; 834 } 835 836 const data = (json && typeof json.data === 'object') ? json.data : json; 837 838 if (typeof window._paq !== 'undefined' && data && data.success) { 839 try { 840 const contentName = form.getAttribute('data-content-name') || 'leadform'; 841 window._paq.push(['trackEvent', 'com_study', 'submit-lead', contentName]); 842 } catch (_err) {} 843 } 844 845 if (data && data.success && typeof window.gtag === 'function') { 846 window.gtag('event', 'generate_lead'); 847 } 848 849 // Suppress sticky CTA and exit intent for future visits 850 if (data && data.success) { 851 localStorage.setItem('leadSubmitted', '1'); 852 const bar = document.getElementById('sticky-cta'); 853 if (bar) bar.setAttribute('aria-hidden', 'true'); 854 } 855 856 const payload = new FormData(); 857 const tokenName = runtime.token || ''; 858 if (tokenName) payload.set(tokenName, '1'); 859 payload.set('success', (data && data.success) ? '1' : '0'); 860 payload.set('message', (data && data.message) ? String(data.message) : ''); 861 payload.set('id_lead', (data && data.id_lead) ? String(data.id_lead) : '0'); 862 payload.set('hash', (data && data.lead_hash) ? String(data.lead_hash) : ''); 863 864 return fetch('/index.php?option=com_study&view=ajax&layout=lead.response', { 865 method: 'POST', 866 body: payload, 867 credentials: 'same-origin', 868 headers: { 'X-Requested-With': 'XMLHttpRequest' } 869 }); 870 }) 871 .then((res) => res.text()) 872 .then((html) => { 873 const modalEl = document.getElementById('modal-leadform-success'); 874 const bodyEl = document.getElementById('modal-leadform-success-body'); 875 if (bodyEl) bodyEl.innerHTML = html || ''; 876 877 const showModal = () => { 878 const instance = bootstrap.Modal.getOrCreateInstance(modalEl, { backdrop: 'static' });
879 instance.show(); 880 }; 881 882 if (window.bootstrap && typeof bootstrap.Modal === 'function') { 883 showModal(); 884 } else { 885 loadScriptOnce('/media/vendor/bootstrap/js/bootstrap.bundle.min.js', () => !!(window.bootstrap && bootstrap.Modal)) 886 .then(showModal); 887 } 888 }) 889 .catch((err) => { 890 if (overlay && spinner) { 891 overlay.style.display = 'none'; 892 spinner.style.display = 'none'; 893 } 894 // eslint-disable-next-line no-console 895 console.error('Lead AJAX error:', err); 896 alert('Es ist ein Fehler aufgetreten. Bitte versuchen Sie es erneut.'); 897 }) 898 .finally(() => { 899 const submitBtn = form.querySelector('.js-leadform-submit'); 900 if (submitBtn) submitBtn.disabled = false; 901 }); 902 903 }); 904 905 initReadMore(form); 906 initAddressToggle(form); 907 }).catch((e) => console.warn(e.message)); 908} 909 910/* -------------------------------- 911 * Boot 912 * -------------------------------- */ 913 914/* -------------------------------- 915 * MultiStep navigation 916 * -------------------------------- */ 917 918/** 919 * Initialize step navigation for multi-step lead forms. 920 * @param {HTMLFormElement} form 921 */ 922function initMultiStep(form) { 923 if (!form.classList.contains('js-leadform-multistep')) return; 924 925 const track = form.querySelector('.leadform-steps__track'); 926 if (!track) return; 927 928 const steps = Array.from(track.querySelectorAll('.leadform-step')); 929 const totalSteps = steps.length; 930 if (totalSteps < 2) return; 931 932 const progressFill = form.querySelector('.leadform-progress__fill'); 933 const progressLabel = form.querySelector('.leadform-progress__label'); 934 let currentStep = 0; 935 936 const goToStep = (idx) => { 937 currentStep = idx; 938 track.style.transform = `translateX(-${idx * 100}%)`; 939 940 // Update progress bar 941 const pct = ((idx + 1) / totalSteps) * 100; 942 if (progressFill) progressFill.style.width = `${pct}%`; 943 if (progressLabel) { 944 progressLabel.textContent = progressLabel.textContent 945 .replace(/\d+\s*\//, `${idx + 1} /`); 946 } 947 }; 948 949 // Validate visible fields in the current step before advancing 950 const validateCurrentStep = () => { 951 const pristine = form._pristine; // eslint-disable-line no-underscore-dangle 952 if (!pristine) return true; 953 954 const step = steps[currentStep]; 955 const fields = Array.from(step.querySelectorAll('input[required], select[required]')); 956 let valid = true; 957 958 fields.forEach((field) => { 959 if (!pristine.validate(field)) { 960 valid = false; 961 } 962 }); 963 return valid; 964 }; 965 966 // Next button 967 form.addEventListener('click', (ev) => { 968 const btn = ev.target.closest('.js-step-next'); 969 if (!btn) return; 970 971 ev.preventDefault(); 972 if (!validateCurrentStep()) return; 973 974 if (typeof _paq !== 'undefined') { 975 _paq.push(['trackEvent', 'com_study', 'multistep-next', `step${currentStep + 2}`]); 976 } 977 978 goToStep(currentStep + 1); 979 980 // Start abandonment timer when reaching step 2 981 resetAbandonTimer(); 982 }); 983 984 // Back button 985 form.addEventListener('click', (ev) => { 986 const btn = ev.target.closest('.js-step-back'); 987 if (!btn) return; 988 989 ev.preventDefault(); 990 if (currentStep > 0) { 991 goToStep(currentStep - 1); 992 } 993 }); 994 995 // Abandonment tracking: fire event if user reaches step 2 but doesn't submit 996 let abandonTimer = null; 997 let abandoned = false; 998 const ABANDON_DELAY_MS = 60000; 999 1000 const resetAbandonTimer = () => { 1001 clearTimeout(abandonTimer); 1002 if (abandoned) return; 1003 abandonTimer = setTimeout(() => { 1004 if (currentStep >= 1 && typeof _paq !== 'undefined') { 1005 _paq.push(['trackEvent', 'com_study', 'multistep-abandon', 'step2']); 1006 abandoned = true; 1007 } 1008 }, ABANDON_DELAY_MS); 1009 }; 1010 1011 // Reset timer on any interaction in step 2 1012 form.addEventListener('input', () => { 1013 if (currentStep >= 1) resetAbandonTimer(); 1014 }); 1015 1016 // Cancel timer on successful submit 1017 form.addEventListener('submit', () => { 1018 clearTimeout(abandonTimer); 1019 abandoned = true; 1020 }); 1021} 1022 1023/** 1024 * Boot the lead form inside a given container (scope the init to the newly inserted markup). 1025 * @param {HTMLElement} container 1026 */ 1027function bootLeadForm(container) { 1028 const form = container.querySelector('.js-leadform'); 1029 if (!form) return; 1030 1031 initValidator(form); 1032 initIntlTel(form); 1033 initSelects(form); 1034 initMultiStep(form); 1035} 1036 1037/* -------------------------------- 1038 * Public API 1039 * -------------------------------- */ 1040 1041export async function loadLeadFormInline(target, opts = {}) { 1042 const el = (typeof target === 'string') ? document.getElementById(target) : target; 1043 if (!el) return; 1044 1045 el.setAttribute('aria-busy', 'true'); 1046 el.innerHTML = [ 1047 '<div class="text-center my-4">', 1048 '<span class="spinner-border" role="status" aria-hidden="true"></span>', 1049 '<span class="visually-hidden">Loadingâ¦</span>', 1050 '</div>' 1051 ].join(''); 1052 1053 const courseId = String(opts.courseId ?? '0'); 1054 const vendorId = String(opts.vendorId ?? '0'); 1055 const layout = String(opts.layout ?? 'leadform'); 1056 const url = buildUrl(courseId, vendorId, layout); 1057 1058 try { 1059 const res = await fetch(url, { 1060 method: 'GET', 1061 credentials: 'same-origin', 1062 headers: { 'X-Requested-With': 'XMLHttpRequest', 'Accept': 'text/html' } 1063 }); 1064 if (!res.ok) throw new Error(`HTTP ${res.status}`); 1065 1066 const html = await res.text(); 1067 await insertHtmlApplyAssetsRunAndBoot(el, html, url); 1068 } catch (_err) { 1069 el.innerHTML = '<div class="alert alert-danger m-0" role="alert">The form could not be loaded.</div>'; 1070 } finally { 1071 el.setAttribute('aria-busy', 'false'); 1072 } 1073} 1074 1075export async function getLeadForm(courseId = '0', vendorId = '0', layout = 'leadform') { 1076 const modalEl = document.getElementById('modal-leadform'); 1077 if (!modalEl) return; 1078 1079 const modal = bootstrap.Modal.getOrCreateInstance(modalEl); 1080 modal.show(); 1081 1082 const container = document.getElementById('container-leadform'); 1083 if (!container) return; 1084 1085 container.setAttribute('aria-busy', 'true'); 1086 container.innerHTML = [ 1087 '<div class="text-center my-5">', 1088 '<span class="spinner-border" role="status" aria-hidden="true"></span>', 1089 '<span class="visually-hidden">Loadingâ¦</span>', 1090 '</div>' 1091 ].join(''); 1092 1093 const url = buildUrl(String(courseId), String(vendorId), String(layout || 'leadform'));
1094 1095 try { 1096 const res = await fetch(url, { 1097 method: 'GET', 1098 credentials: 'same-origin', 1099 headers: { 1100 'X-Requested-With': 'XMLHttpRequest', 1101 'Accept': 'text/html' 1102 } 1103 }); 1104 if (!res.ok) throw new Error(`HTTP ${res.status}`); 1105 1106 const html = await res.text(); 1107 await insertHtmlApplyAssetsRunAndBoot(container, html, url); 1108 } catch (_err) { 1109 container.innerHTML = ( 1110 '<div class="alert alert-danger m-0" role="alert">The form could not be loaded.</div>' 1111 ); 1112 } finally { 1113 container.setAttribute('aria-busy', 'false'); 1114 } 1115} 1116 1117/* -------------------------------- 1118 * Delegation + Auto init 1119 * -------------------------------- */ 1120 1121document.addEventListener('click', (ev) => { 1122 const btn = ev.target.closest('.btn-lead'); 1123 if (!btn) return; 1124 1125 ev.preventDefault(); 1126 const courseId = btn.dataset.courseid || '0'; 1127 const vendorId = btn.dataset.vendorid || '0'; 1128 const layout = btn.dataset.layout || 'leadform'; 1129 void getLeadForm(courseId, vendorId, layout); 1130}); 1131 1132document.addEventListener('DOMContentLoaded', () => { 1133 const nodes = document.querySelectorAll('[data-leadform-inline]'); 1134 nodes.forEach((el) => { 1135 const vendorId = el.getAttribute('data-vendorid') || '0'; 1136 const courseId = el.getAttribute('data-courseid') || '0'; 1137 const layout = el.getAttribute('data-layout') || 'leadform'; 1138 void loadLeadFormInline(el, { vendorId, courseId, layout }); 1139 }); 1140}); 1141 1142// Copy discount code to clipboard 1143document.addEventListener('click', (ev) => { 1144 const btn = ev.target.closest('.js-copy-code'); 1145 if (!btn) return; 1146 const code = btn.dataset.code; 1147 navigator.clipboard.writeText(code).then(() => { 1148 const orig = btn.innerHTML; 1149 btn.textContent = 'Kopiert!'; 1150 setTimeout(() => { btn.innerHTML = orig; }, 2000); 1151 }); 1152 if (typeof _paq !== 'undefined') { 1153 _paq.push(['trackEvent', 'com_study', 'discount-copy', code]); 1154 } 1155}); 1156 1157// Legacy globals 1158window.getLeadForm = getLeadForm; 1159window.loadLeadFormInline = loadLeadFormInline; 1160 1161/* -------------------------------- 1162 * Sticky CTA bar + Exit intent 1163 * -------------------------------- */ 1164 1165// Shared state: is the inline leadform currently in the viewport? 1166let leadformVisible = true; 1167 1168// Minimum scroll before sticky bar can appear (px) 1169const STICKY_SCROLL_MIN = 400; 1170 1171function updateStickyBar(stickyBar, formVisible) { 1172 if (!stickyBar || stickyBar.dataset.dismissed === '1') return; 1173 const scrolledEnough = window.scrollY > STICKY_SCROLL_MIN; 1174 stickyBar.setAttribute('aria-hidden', (!formVisible && scrolledEnough) ? 'false' : 'true'); 1175} 1176 1177function initLeadTriggers() { 1178 const triggers = runtime.triggers; 1179 if (!triggers) return; 1180 1181 if (localStorage.getItem('leadSubmitted') === '1') return; 1182 1183 const leadformEl = document.getElementById('leadform'); 1184 const stickyBar = document.getElementById('sticky-cta'); 1185 1186 // Restore dismissed state from session 1187 if (stickyBar && localStorage.getItem('stickyCtaDismissed') === '1') { 1188 stickyBar.dataset.dismissed = '1'; 1189 } 1190 1191 // Update sticky bar on scroll (respects minimum scroll threshold) 1192 if (stickyBar) { 1193 window.addEventListener('scroll', () => { 1194 updateStickyBar(stickyBar, leadformVisible); 1195 }, { passive: true }); 1196 } 1197 1198 if (leadformEl) { 1199 // Wait for inline form to finish loading before observing 1200 const startObserving = () => { 1201 const observer = new IntersectionObserver( 1202 ([entry]) => { 1203 leadformVisible = entry.isIntersecting; 1204 updateStickyBar(stickyBar, leadformVisible); 1205 }, 1206 { threshold: 0 } 1207 ); 1208 observer.observe(leadformEl); 1209 }; 1210 1211 // If the form loads via AJAX, wait until aria-busy is cleared 1212 if (leadformEl.getAttribute('aria-busy') === 'true') { 1213 const mo = new MutationObserver(() => { 1214 if (leadformEl.getAttribute('aria-busy') !== 'true') { 1215 mo.disconnect(); 1216 startObserving(); 1217 } 1218 }); 1219 mo.observe(leadformEl, { attributes: true, attributeFilter: ['aria-busy'] }); 1220 } else { 1221 startObserving(); 1222 } 1223 } else { 1224 // No inline form on page 1225 leadformVisible = false; 1226 } 1227 1228 initStickyCta(triggers, leadformEl, stickyBar); 1229 1230 // No exit intent on affiliate profiles (no leadform available) 1231 if (!triggers.affiliateUrl || triggers.showLeadform) {
1232 initExitIntent(triggers); 1233 } 1234} 1235 1236function initStickyCta(triggers, leadformEl, stickyBar) { 1237 if (!stickyBar) return; 1238 1239 // Dismiss button 1240 stickyBar.querySelector('.sticky-cta__close')?.addEventListener('click', () => { 1241 stickyBar.setAttribute('aria-hidden', 'true'); 1242 stickyBar.style.display = 'none'; 1243 stickyBar.dataset.dismissed = '1'; 1244 localStorage.setItem('stickyCtaDismissed', '1'); 1245 }); 1246 1247 const btn = stickyBar.querySelector('.sticky-cta__btn'); 1248 if (!btn) return; 1249 1250 // Affiliate links navigate via href, only add tracking 1251 if (triggers.affiliateUrl) { 1252 btn.addEventListener('click', () => { 1253 if (typeof _paq !== 'undefined') { 1254 _paq.push(['trackEvent', 'com_study', 'stickycta-affiliate', 1255 `${triggers.pageType}-${triggers.courseId || triggers.vendorId}`]); 1256 } 1257 }); 1258 return; 1259 } 1260 1261 // Lead form button 1262 btn.addEventListener('click', () => { 1263 if (typeof _paq !== 'undefined') { 1264 _paq.push(['trackEvent', 'com_study', 'stickycta-click', 1265 `${triggers.pageType}-${triggers.courseId || triggers.vendorId}`]); 1266 } 1267 1268 if (!leadformEl || leadformEl.getBoundingClientRect().top > window.innerHeight) { 1269 void getLeadForm( 1270 String(triggers.courseId || '0'), 1271 String(triggers.vendorId || '0') 1272 ); 1273 } else { 1274 leadformEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); 1275 } 1276 }); 1277} 1278 1279/* -------------------------------- 1280 * Exit intent detection 1281 * -------------------------------- */ 1282 1283const EXIT_INTENT_MAX = 3; 1284const EXIT_INTENT_COOLDOWN_MS = 24 * 60 * 60 * 1000; 1285const EXIT_INTENT_MIN_TIME_S = 15; 1286 1287function initExitIntent(triggers) { 1288 const id = triggers.courseId || triggers.vendorId || 0; 1289 const storageKey = `exitIntent_course_${id}`; 1290 1291 // Suppression checks 1292 if (localStorage.getItem(storageKey) === '1') return; 1293 1294 const countRaw = parseInt(localStorage.getItem('exitIntent_count') || '0', 10); 1295 if (countRaw >= EXIT_INTENT_MAX) return; 1296 1297 const dismissedAt = parseInt(localStorage.getItem('exitIntent_dismissed') || '0', 10); 1298 if (dismissedAt && (Date.now() - dismissedAt) < EXIT_INTENT_COOLDOWN_MS) return; 1299 1300 const pageLoadTime = Date.now(); 1301 let triggered = false; 1302 1303 const canTrigger = () => { 1304 if (triggered) return false; 1305 if (localStorage.getItem('leadSubmitted') === '1') return false; 1306 if ((Date.now() - pageLoadTime) < EXIT_INTENT_MIN_TIME_S * 1000) return false; 1307 if (leadformVisible) return false; 1308 if (document.querySelector('.modal.show')) return false; 1309 return true; 1310 }; 1311 1312 const fire = () => { 1313 triggered = true; 1314 1315 const newCount = countRaw + 1; 1316 localStorage.setItem('exitIntent_count', String(newCount)); 1317 localStorage.setItem(storageKey, '1'); 1318 1319 if (typeof _paq !== 'undefined') { 1320 _paq.push(['trackEvent', 'com_study', 'exitintent-show', 1321 `${triggers.pageType}-${id}`]); 1322 } 1323 1324 void getLeadForm( 1325 String(triggers.courseId || '0'), 1326 String(triggers.vendorId || '0') 1327 ); 1328 1329 // Track dismissal when modal closes 1330 const modalEl = document.getElementById('modal-leadform'); 1331 if (modalEl) { 1332 const onHidden = () => { 1333 localStorage.setItem('exitIntent_dismissed', String(Date.now())); 1334 if (typeof _paq !== 'undefined') { 1335 _paq.push(['trackEvent', 'com_study', 'exitintent-dismiss', 1336 `${triggers.pageType}-${id}`]); 1337 } 1338 modalEl.removeEventListener('hidden.bs.modal', onHidden); 1339 }; 1340 modalEl.addEventListener('hidden.bs.modal', onHidden); 1341 } 1342 }; 1343 1344 // Desktop: mouseleave at top of viewport 1345 if (window.innerWidth >= 768) { 1346 document.documentElement.addEventListener('mouseleave', (ev) => { 1347 if (ev.clientY > 0) return; 1348 if (!canTrigger()) return; 1349 fire(); 1350 }); 1351 } else { 1352 // Mobile: scroll-up velocity detection 1353 let lastScrollY = window.scrollY; 1354 let lastScrollTime = Date.now(); 1355 1356 window.addEventListener('scroll', () => { 1357 const now = Date.now(); 1358 const currentY = window.scrollY; 1359 const dt = now - lastScrollTime; 1360 const dy = lastScrollY - currentY; 1361 1362 // Check: scrolling up fast (> 50px in < 300ms) after 25% page depth
1363 const pageDepth = currentY / (document.documentElement.scrollHeight - window.innerHeight); 1364 if (dt > 0 && dt < 300 && dy > 50 && pageDepth > 0.25) { 1365 if (canTrigger()) { 1366 fire(); 1367 } 1368 } 1369 1370 lastScrollY = currentY; 1371 lastScrollTime = now; 1372 }, { passive: true }); 1373 } 1374} 1375 1376document.addEventListener('DOMContentLoaded', initLeadTriggers);
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.