PageSourceSearch

https://www.fernstudi.net/media/com_study/js/contactform.js?1.1.0

js fernstudi.net collected 2026-10-02 06:36:58 UTC 50,610 bytes, 1,376 lines download raw bytes

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.