PageSourceSearch

https://www.illiat.fr/wp-content/plugins/hob-formchat-pro/assets/js/form.js?ver=1.29

js illiat.fr collected 2026-10-02 21:01:45 UTC 98,914 bytes, 2,286 lines download raw bytes

1/* HOB FormChat Pro | (c) 2026 HOB France Services — Groupe HOB */
2'use strict';
3
4(function () {
5    /* i18n helper — uses hobFcI18n if available, otherwise French defaults. */
6    var i18n = (typeof hobFcI18n !== 'undefined') ? hobFcI18n : {};
7    function _t(key, fallback) { return i18n[key] || fallback; }
8
9    /**
10     * Retourne le conteneur de champ (wrapper) auquel appartient un input.
11     *
12     * @param {Element} input
13     * @returns {Element|null}
14     */
15    function fieldWrapOf(input) {
16        return input.closest('.hob-fc-field-wrapper')
17            || input.closest('.hob-fc-field-wrap')
18            || input.closest('.hob-fc-field')
19            || null;
20    }
21
22    /**
23     * Retrouve l'element d'affichage d'erreur associe a un input.
24     *
25     * Les champs sont rendus par plusieurs chemins cote PHP : selon le type,
26     * le <span> d'erreur porte l'id "<field_id>-err" ou vit simplement dans le
27     * wrapper. On tente donc les deux, sinon on en cree un a la volee.
28     *
29     * @param {Element} input
30     * @returns {Element|null}
31     */
32    function errorElOf(input) {
33        var wrap = fieldWrapOf(input);
34
35        // 1. id explicite derive de l'id du champ (hob-field-XXX -> XXX-err).
36        var direct = input.id ? document.getElementById(input.id + '-err') : null;
37        if (direct) return direct;
38
39        var fieldId = (wrap && wrap.dataset && wrap.dataset.fieldId) || '';
40        if (fieldId) {
41            var byId = document.getElementById(fieldId + '-err');
42            if (byId) return byId;
43        }
44
45        if (!wrap) return null;
46
47        // 2. On remonte depuis l'input : le conteneur d'erreur le plus proche
48        //    gagne. Indispensable pour les champs a emplacements multiples
49        //    (pdf_upload) ou chaque slot a son propre message.
50        var node = input.parentElement;
51        while (node) {
52            var found = node.querySelector(':scope > .hob-fc-field-error');
53            if (found) return found;
54            if (node === wrap) break;
55            node = node.parentElement;
56        }
57
58        var existing = wrap.querySelector('.hob-fc-field-error');
59        if (existing) return existing;
60
61        // 3. Aucun conteneur rendu (anciens formulaires) : on l'ajoute.
62        var created = document.createElement('span');
63        created.className = 'hob-fc-field-error';
64        created.setAttribute('role', 'alert');
65        created.setAttribute('aria-live', 'polite');
66        wrap.appendChild(created);
67        return created;
68    }
69
70    /**
71     * Message d'erreur configure pour ce champ (personnalise ou defaut global).
72     *
73     * @param {Element} input
74     * @returns {string}
75     */
76    function messageFor(input) {
77        var custom = input.getAttribute('data-error-message');
78        if (custom && custom.trim()) return custom.trim();
79
80        var wrap = fieldWrapOf(input);
81        if (wrap) {
82            var wrapMsg = wrap.getAttribute('data-error-message');
83            if (wrapMsg && wrapMsg.trim()) return wrapMsg.trim();
84        }
85
86        return _t('required', 'Veuillez remplir ce champ correctement.');
87    }
88
89    /**
90     * Indique si un champ obligatoire est vide, tous types confondus.
91     *
92     * @param {Element} input
93     * @returns {boolean}
94     */
95    function isEmptyValue(input) {
96        if (input.type === 'checkbox' || input.type === 'radio') {
97            var form = input.form;
98            if (!form || !input.name) return !input.checked;
99            var group = form.querySelectorAll('[name="' + CSS.escape(input.name) + '"]');
100            for (var i = 0; i < group.length; i++) {
101                if (group[i].checked) return false;
102            }
103            return true;
104        }
105        if (input.type === 'file') {
106            return !input.files || input.files.length === 0;
107        }
108        return !String(input.value || '').trim();
109    }
110
111    /**
112     * Applique (ou efface) un message d'erreur sur un champ.
113     *
114     * @param {Element} input
115     * @param {string}  msg   Message a afficher, vide pour effacer.
116     */
117    function setFieldError(input, msg) {
118        var hasValue = !isEmptyValue(input);
119        input.classList.toggle('hob-fc-invalid', !!msg);
120        input.classList.toggle('hob-fc-valid', !msg && hasValue);
121        input.setAttribute('aria-invalid', msg ? 'true' : 'false');
122
123        // Champs dont la saisie se fait ailleurs que sur l'input (signature sur
124        // canvas, zone de depot de fichier) : on marque le conteneur visible.
125        var visual = input.closest('.hob-fc-signature-wrap') || input.closest('.hob-fc-file-zone');
126        if (visual) {
127            visual.classList.toggle('hob-fc-invalid', !!msg);
128        }
129
130        var errEl = errorElOf(input);
131        if (errEl) {
132            errEl.textContent = msg || '';
133            errEl.classList.toggle('visible', !!msg);
134        }
135    }
136
137    /**
138     * Valide un champ inline et affiche/masque l'erreur.
139     *
140     * @param {HTMLInputElement|HTMLTextAreaElement|HTMLSelectElement} input
141     * @returns {boolean} true si valide.
142     */
143    function validateField(input) {
144        var val = String(input.value || '').trim();
145        var msg = '';
146
147        if (input.required && isEmptyValue(input)) {
148            msg = messageFor(input);
149        } else if (input.type === 'email' && val && !/^[^@]+@[^@]+\.[^@]+$/.test(val)) {
150            msg = _t('invalidEmail', 'Adresse e-mail invalide.');
151        } else if (input.type === 'tel' && val && !/^[\d\s\+\-\.\(\)]{7,20}$/.test(val)) {
152            msg = _t('invalidPhone', 'Num\u00e9ro de t\u00e9l\u00e9phone invalide.');
153        } else if (input.minLength > 0 && val.length < input.minLength && val) {
154            msg = (_t('minLength', 'Minimum %d caract\u00e8res.')).replace('%d', input.minLength);
155        }
156
157        setFieldError(input, msg);
158        return !msg;
159    }
160
161    /**
162     * Amene le premier champ en erreur dans le viewport et lui donne le focus.
163     *
164     * @param {HTMLFormElement} form
165     */
166    function focusFirstError(form) {
167        var first = form.querySelector('.hob-fc-invalid');
168        if (!first) return;
169        try {
170            first.scrollIntoView({ behavior: 'smooth', block: 'center' });
171        } catch (e) {
172            first.scrollIntoView();
173        }
174        if (typeof first.focus === 'function') {
175            first.focus({ preventScroll: true });
176        }
177    }
178
179    /**
180     * Reporte sur les champs les erreurs de validation renvoyees par le serveur.
181     *
182     * @param {HTMLFormElement} form
183     * @param {Object} errors  Dictionnaire field_id -> message.
184     * @returns {number} Nombre d'erreurs effectivement affichees.
185     */
186    function applyServerErrors(form, errors) {
187        if (!errors || typeof errors !== 'object') return 0;
188        var shown = 0;
189
190        Object.keys(errors).forEach(function (fieldId) {
191            var wrap = form.querySelector('[data-field-id="' + CSS.escape(fieldId) + '"]');
192            if (!wrap) return;
193            var input = wrap.querySelector('input:not([type="hidden"]), select, textarea')
194                     || wrap.querySelector('input');
195            if (!input) return;
196            setFieldError(input, errors[fieldId]);
197            shown++;
198        });
199
200        return shown;
201    }
202
203    /**
204     * Initialise la soumission AJAX pour un formulaire HOB FormChat.
205     *
206     * @param {HTMLFormElement} form
207     */
208    function initForm(form) {
209        // Attacher validation inline sur blur.
210        var fields = form.querySelectorAll('input, textarea, select');
211        for (var i = 0; i < fields.length; i++) {
212            fields[i].addEventListener('blur', function () {
213                validateField(this);
214            });
215        }
216
217        form.addEventListener('submit', function (e) {
218            e.preventDefault();
219
220            // Garde anti double-soumission : ignore tout submit d\u00e9clench\u00e9 pendant
221            // qu'une requ\u00eate est en cours (double Entr\u00e9e, double clic rapide\u2026).
222            if (form.dataset.submitting === '1' || form.dataset.submitted === '1') {
223                return;
224            }
225
226            // Validation de tous les champs avant soumission.
227            var allFields = form.querySelectorAll('input, textarea, select');
228            var isValid   = true;
229            for (var j = 0; j < allFields.length; j++) {
230                if (allFields[j].type === 'hidden' && !allFields[j].closest('.hob-fc-signature-wrap')) continue;
231                if (!validateField(allFields[j])) {
232                    isValid = false;
233                }
234            }
235            if (!isValid) {
236                focusFirstError(form);
237                return;
238            }
239
240            form.dataset.submitting = '1';
241
242            var formId     = form.dataset.formId || '0';
243            var submitBtn  = form.querySelector('.hob-fc-submit-btn');
244            var originalTxt = submitBtn ? submitBtn.textContent : '';
245            var loadingTxt  = submitBtn ? (submitBtn.dataset.loadingText || '...') : '...';
246
247            if (submitBtn) {
248                submitBtn.disabled = true;
249                submitBtn.textContent = loadingTxt;
250                submitBtn.classList.add('loading');
251            }
252
253            var fd = new FormData(form);
254            fd.append('action', 'hob_fc_submit');
255
256            fetch(
257                (typeof hobFcFormConfig !== 'undefined' && hobFcFormConfig.ajax_url) || '/wp-admin/admin-ajax.php',
258                { method: 'POST', body: fd, credentials: 'same-origin' }
259            )
260                .then(function (r) {
261                    // Une reponse non-JSON (erreur PHP, page de securite, 502...)
262                    // doit rester lisible plutot que de finir en "Erreur reseau".
263                    return r.text().then(function (raw) {
264                        try {
265                            return JSON.parse(raw);
266                        } catch (e) {
267                            throw new Error(
268                                _t('serverError', 'Le serveur a renvoy\u00e9 une r\u00e9ponse inattendue')
269                                + ' (HTTP ' + r.status + ').'
270                            );
271                        }
272                    });
273                })
274                .then(function (data) {
275                    if (submitBtn) {
276                        submitBtn.classList.remove('loading');
277                    }
278                    if (data.success) {
279                        form.dataset.submitted = '1';
280                        hobTrack(formId, 'submit', 0);
281                        showSuccessMessage(form, data.data || {});
282                        return;
283                    }
284
285                    form.dataset.submitting = '0';
286
287                    var payload = data.data || {};
288                    var shown   = applyServerErrors(form, payload.errors);
289
290                    showError(
291                        form,
292                        payload.message || _t('genericError', 'Une erreur est survenue.'),
293                        payload.details
294                    );
295
296                    if (shown) focusFirstError(form);
297
298                    if (submitBtn) {
299                        submitBtn.disabled = false;
300                        submitBtn.textContent = originalTxt;
301                    }
302                })
303                .catch(function (err) {
304                    form.dataset.submitting = '0';
305                    if (submitBtn) {
306                        submitBtn.classList.remove('loading');
307                    }
308                    showError(
309                        form,
310                        (err && err.message) || _t('networkError', 'Erreur r\u00e9seau. Veuillez r\u00e9essayer.')
311                    );
312                    if (submitBtn) {
313                        submitBtn.disabled = false;
314                        submitBtn.textContent = originalTxt;
315                    }
316                });
317        });
318    }
319
320    /**
321     * Remplace le formulaire par le message de succes avec animation.
322     *
323     * @param {HTMLFormElement} form
324     * @param {Object}          responseData  Donnees renvoyees par le serveur.
325     */
326    function showSuccessMessage(form, responseData) {
327        // Redirect if configured.
328        if (responseData.redirect) {
329            window.location.href = responseData.redirect;
330            return;
331        }
332
333        // Analytics events.
334        fireAnalytics(form);
335
336        var message    = responseData.message || responseData.success_message || _t('defaultSuccess', 'Merci, votre message a bien \u00e9t\u00e9 envoy\u00e9.');
337        var allowResubmit = !!responseData.allow_resubmit;
338
339        var wrapper = document.createElement('div');
340        wrapper.className = 'hob-fc-success-message';
341        // SEC-007: Use textContent to prevent XSS from server response.
342        wrapper.textContent = message;
343
344        if (allowResubmit) {
345            var btn = document.createElement('button');
346            btn.type = 'button';
347            btn.className = 'hob-fc-resubmit-btn';
348            btn.textContent = _t('resubmit', 'Soumettre \u00e0 nouveau');
349            btn.addEventListener('click', function () {
350                wrapper.parentNode.replaceChild(form, wrapper);
351                form.reset();
352                var submitBtn = form.querySelector('.hob-fc-submit-btn');
353                if (submitBtn) {
354                    submitBtn.disabled = false;
355                    submitBtn.classList.remove('loading');
356                }
357                // Nettoyer les etats de validation.
358                var allInputs = form.querySelectorAll('.hob-fc-invalid, .hob-fc-valid');
359                for (var k = 0; k < allInputs.length; k++) {
360                    allInputs[k].classList.remove('hob-fc-invalid', 'hob-fc-valid');
361                    allInputs[k].removeAttribute('aria-invalid');
362                }
363                var allErrors = form.querySelectorAll('.hob-fc-field-error.visible');
364                for (var m = 0; m < allErrors.length; m++) {
365                    allErrors[m].classList.remove('visible');
366                    allErrors[m].textContent = '';
367                }
368            });
369            wrapper.appendChild(btn);
370        }
371
372        form.parentNode.replaceChild(wrapper, form);
373
374        // Remonte l'utilisateur en haut de la page pour qu'il voie le message de confirmation.
375        try {
376            window.scrollTo({ top: 0, behavior: 'smooth' });
377        } catch (e) {
378            window.scrollTo(0, 0);
379        }
380    }
381
382    /**
383     * Affiche un message d'erreur inline sous le formulaire.
384     *
385     * @param {HTMLFormElement} form
386     * @param {string}          message
387     * @param {string[]}        [details]  Detail par champ, liste a puces.
388     */
389    function showError(form, message, details) {
390        var existing = form.querySelector('.hob-fc-form-error');
391        if (existing) existing.remove();
392
393        var el = document.createElement('div');
394        el.className = 'hob-fc-form-error';
395        el.setAttribute('role', 'alert');
396
397        var p = document.createElement('p');
398        p.className = 'hob-fc-form-error__title';
399        p.textContent = message;
400        el.appendChild(p);
401
402        if (Array.isArray(details) && details.length > 1) {
403            var ul = document.createElement('ul');
404            ul.className = 'hob-fc-form-error__list';
405            details.forEach(function (d) {
406                var li = document.createElement('li');
407                li.textContent = d;
408                ul.appendChild(li);
409            });
410            el.appendChild(ul);
411        }
412
413        form.appendChild(el);
414
415        try {
416            el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
417        } catch (e) { /* noop */ }
418    }
419
420    /* ─── Multi-page navigation ─── */
421
422    /**
423     * Met à jour la barre de progression.
424     */
425    function updateProgressBar(form, currentIdx, total) {
426        var bar = form.closest('.hob-fc-form-wrapper').querySelector('.hob-fc-progress');
427        if (!bar) return;
428        bar.setAttribute('aria-valuenow', currentIdx + 1);
429        var steps = bar.querySelectorAll('.hob-fc-step');
430        for (var i = 0; i < steps.length; i++) {
431            steps[i].classList.toggle('active', i <= currentIdx);
432            steps[i].classList.toggle('done', i < currentIdx);
433        }
434        // Progress line width
435        var pct = total > 1 ? (currentIdx / (total - 1)) * 100 : 100;
436        bar.style.setProperty('--hob-progress-pct', pct + '%');
437    }
438
439    /**
440     * Navigue vers une page du formulaire.
441     */
442    function goToPage(form, fromIdx, toIdx, total) {
443        var pages = form.querySelectorAll('.hob-fc-page');
444        if (!pages[fromIdx] || !pages[toIdx]) return;
445
446        var direction = toIdx > fromIdx ? 1 : -1;
447
448        // Masquer la page actuelle
449        pages[fromIdx].classList.add(direction > 0 ? 'hob-page-out-left' : 'hob-page-out-right');
450        setTimeout(function () {
451            pages[fromIdx].style.display = 'none';
452            pages[fromIdx].classList.remove('hob-page-out-left', 'hob-page-out-right');
453
454            // Afficher la nouvelle page
455            pages[toIdx].style.display = '';
456            pages[toIdx].classList.add(direction > 0 ? 'hob-page-in-right' : 'hob-page-in-left');
457            setTimeout(function () {
458                pages[toIdx].classList.remove('hob-page-in-right', 'hob-page-in-left');
459            }, 300);
460        }, 250);
461
462        form.dataset.currentPage = toIdx;
463        updateProgressBar(form, toIdx, total);
464
465        // Scroll to top of form
466        form.scrollIntoView({ behavior: 'smooth', block: 'start' });
467    }
468
469    /**
470     * Valide tous les champs visibles d'une page.
471     */
472    function validatePage(page) {
473        var inputs = page.querySelectorAll('input, textarea, select');
474        var valid = true;
475        for (var i = 0; i < inputs.length; i++) {
476            if (inputs[i].type === 'hidden' && !inputs[i].closest('.hob-fc-signature-wrap')) continue;
477            if (!validateField(inputs[i])) {
478                valid = false;
479            }
480        }
481        return valid;
482    }
483
484    /**
485     * Initialise la navigation multi-pages.
486     */
487    function initMultiPage(form) {
488        var totalPages = parseInt(form.dataset.totalPages || '1', 10);
489        if (totalPages <= 1) return;
490
491        updateProgressBar(form, 0, totalPages);
492
493        // Boutons Suivant
494        form.addEventListener('click', function (e) {
495            var nextBtn = e.target.closest('.hob-fc-next-btn');
496            if (nextBtn) {
497                var currentIdx = parseInt(form.dataset.currentPage || '0', 10);
498                var currentPage = form.querySelector('.hob-fc-page[data-page="' + currentIdx + '"]');
499                if (currentPage && validatePage(currentPage)) {
500                    goToPage(form, currentIdx, currentIdx + 1, totalPages);
501                }
502                return;
503            }
504
505            var prevBtn = e.target.closest('.hob-fc-prev-btn');
506            if (prevBtn) {
507                var idx = parseInt(form.dataset.currentPage || '0', 10);
508                if (idx > 0) {
509                    goToPage(form, idx, idx - 1, totalPages);
510                }
511            }
512        });
513    }
514
515    /* ─── Analytics ─── */
516
517    /**
518     * Envoie les événements analytics (GA4 + Facebook Pixel) apr
518ès soumission.
519     */
520    function fireAnalytics(form) {
521        var formId = form.dataset.formId || '0';
522
523        // GA4 gtag
524        if (typeof gtag === 'function') {
525            gtag('event', 'form_submit', {
526                event_category: 'hob_formchat',
527                event_label: 'form_' + formId,
528                form_id: formId
529            });
530        }
531
532        // Facebook Pixel
533        if (typeof fbq === 'function') {
534            fbq('track', 'Lead', { content_name: 'hob_form_' + formId });
535        }
536
537        // P25: GA4 Measurement ID + Matomo from form config.
538        hobFcFireExternalAnalytics(form);
539    }
540
541    /* ─── Rich Text Editors ─── */
542
543    /**
544     * Initialise les éditeurs riches (contenteditable) du formulaire.
545     */
546    function initRichEditors(form) {
547        var editors = form.querySelectorAll('.hob-fc-richeditor');
548        if (!editors.length) return;
549
550        editors.forEach(function (editor) {
551            var toolbar = editor.querySelector('.hob-fc-richeditor__toolbar');
552            var body    = editor.querySelector('.hob-fc-richeditor__body');
553            var hidden  = editor.querySelector('input[type="hidden"]');
554            if (!toolbar || !body) return;
555
556            // Placeholder handling.
557            function togglePlaceholder() {
558                var isEmpty = !body.textContent.trim() && !body.querySelector('img, hr, table');
559                body.classList.toggle('is-empty', isEmpty);
560            }
561            togglePlaceholder();
562
563            // Sync hidden input.
564            function syncHidden() {
565                if (hidden) {
566                    hidden.value = body.innerHTML;
567                }
568                togglePlaceholder();
569            }
570
571            body.addEventListener('input', syncHidden);
572            body.addEventListener('blur', syncHidden);
573
574            // Paste as clean HTML.
575            body.addEventListener('paste', function (e) {
576                e.preventDefault();
577                var text = (e.clipboardData || window.clipboardData).getData('text/plain');
578                document.execCommand('insertText', false, text);
579            });
580
581            // Toolbar buttons.
582            toolbar.addEventListener('click', function (e) {
583                var btn = e.target.closest('.hob-re-btn');
584                if (!btn) return;
585                e.preventDefault();
586
587                var cmd = btn.dataset.cmd;
588                body.focus();
589
590                if (cmd === 'link') {
591                    var url = prompt('URL du lien :', 'https://');
592                    if (url) {
593                        document.execCommand('createLink', false, url);
594                    }
595                } else if (cmd === 'unorderedList') {
596                    document.execCommand('insertUnorderedList', false, null);
597                } else {
598                    document.execCommand(cmd, false, null);
599                }
600
601                syncHidden();
602                updateToolbarState();
603            });
604
605            // Active button state tracking.
606            function updateToolbarState() {
607                var buttons = toolbar.querySelectorAll('.hob-re-btn');
608                buttons.forEach(function (b) {
609                    var c = b.dataset.cmd;
610                    var active = false;
611                    if (c === 'unorderedList') {
612                        active = document.queryCommandState('insertUnorderedList');
613                    } else if (c === 'link') {
614                        var sel = window.getSelection();
615                        if (sel.rangeCount) {
616                            var node = sel.anchorNode;
617                            while (node && node !== body) {
618                                if (node.nodeName === 'A') { active = true; break; }
619                                node = node.parentNode;
620                            }
621                        }
622                    } else {
623                        active = document.queryCommandState(c);
624                    }
625                    b.classList.toggle('active', active);
626                });
627            }
628
629            document.addEventListener('selectionchange', function () {
630                if (body.contains(document.activeElement) || body === document.activeElement) {
631                    updateToolbarState();
632                }
633            });
634
635            // Validation for required rich editors.
636            if (body.dataset.required === 'true') {
637                body.addEventListener('blur', function () {
638                    var val = body.textContent.trim();
639                    body.classList.toggle('hob-fc-invalid', !val);
640                    body.classList.toggle('hob-fc-valid', !!val);
641                });
642            }
643        });
644    }
645
646    // Opinion scale: slider value display
647    function initOpinionScales(form) {
648        form.querySelectorAll('.hob-fc-scale-range').forEach(function (range) {
649            var wrap = range.closest('.hob-fc-opinion-scale');
650            if (!wrap) return;
651            var numEl = wrap.querySelector('.hob-fc-scale-value-num');
652            range.addEventListener('input', function () {
653                if (numEl) numEl.textContent = range.value;
654            });
655        });
656    }
657
658    // Repeater button: duplicate referenced fields on the frontend.
659    function initRepeaterButtons(form) {
660        form.querySelectorAll('.hob-fc-repeater-button-wrap').forEach(function (wrap) {
661            var btn = wrap.querySelector('.hob-fc-repeater-btn');
662            if (!btn) return;
663
664            var targetIds = [];
665            try { targetIds = JSON.parse(wrap.dataset.repeaterFields || '[]'); } catch (e) { /* ignore */ }
666            var maxRep = parseInt(wrap.dataset.repeaterMax, 10) || 10;
667            var repCount = 0;
668            var countEl = wrap.querySelector('.hob-fc-repeater-count');
669
670            btn.addEventListener('click', function () {
671                if (repCount >= maxRep) {
672                    alert(_t('repeaterMax', 'Nombre maximum de r\u00e9p\u00e9titions atteint.'));
673                    return;
674                }
675                repCount++;
676                var groupIdx = repCount;
677
678                // Find the row containing this repeater button
679                var btnRow = wrap.closest('.hob-fc-row');
680                var insertRef = btnRow || wrap;
681
682                // Create a group container for this repetition
683                var groupDiv = document.createElement('div');
684                groupDiv.className = 'hob-fc-repeater-group';
685                groupDiv.dataset.repGroup = groupIdx;
686
687                // Header with remove button
688                var header = document.createElement('div');
689                header.className = 'hob-fc-repeater-group-header';
690                header.innerHTML = '<span class="hob-fc-repeater-group-title">#' + (groupIdx + 1) + '</span>'
691                    + '<button type="button" class="hob-fc-repeater-remove" title="Supprimer">\u2715</button>';
692                groupDiv.appendChild(header);
693
694                // Group target fields by their parent row to preserve layout
695                var rowMap = [];  // [{row: DOMElement, fieldIds: []}]
696                for (var i = 0; i < targetIds.length; i++) {
697                    var origWrapper = form.querySelector('.hob-fc-field-wrapper[data-field-id="' + targetIds[i] + '"]')
698                                  || form.querySelector('.hob-fc-field-wrap[data-field-id="' + targetIds[i] + '"]');
699                    if (!origWrapper) continue;
700
701                    var origRow = origWrapper.closest('.hob-fc-row');
702                    if (!origRow) continue;
703
704                    // Find or create entry in rowMap
705                    var found = false;
706                    for (var r = 0; r < rowMap.length; r++) {
707                        if (rowMap[r].row === origRow) {
708                            rowMap[r].fieldIds.push(targetIds[i]);
709                            found = true;
710                            break;
711                        }
712                    }
713                    if (!found) {
714                        rowMap.push({ row: origRow, fieldIds: [targetIds[i]] });
715                    }
716                }
717
718                // Clone each row, keeping only the targeted columns
719                for (var ri = 0; ri < rowMap.length; ri++) {
720                    var entry = rowMap[ri];
721                    var rowClone = entry.row.cloneNode(true);
722
723                    // If not all columns in this row are targeted, remove the others
724                    var allCols = Array.from(entry.row.querySelectorAll(':scope > .hob-fc-col'));
725                    var allColsInRow = allCols.length;
726                    var targetedCols = 0;
727                    for (var ci = 0; ci < allCols.length; ci++) {
728                        var colFieldEl = allCols[ci].querySelector('[data-field-id]');
729                        var colFieldId = colFieldEl ? colFieldEl.dataset.fieldId : null;
730                        if (colFieldId && entry.fieldIds.indexOf(colFieldId) !== -1) {
731                            targetedCols++;
732                        }
733                    }
734
735                    // If only some cols are targeted, remove non-targeted from the clone
736                    if (targetedCols < allColsInRow) {
737                        var clonedCols = Array.from(rowClone.querySelectorAll(':scope > .hob-fc-col'));
738                        for (var ci2 = 0; ci2 < clonedCols.length; ci2++) {
739                            var cf = clonedCols[ci2].querySelector('[data-field-id]');
740                            var cfId = cf ? cf.dataset.fieldId : null;
741                            if (!cfId || entry.fieldIds.indexOf(cfId) === -1) {
742                                clonedCols[ci2].remove();
743                            }
744                        }
745                        // Update row cols class
746                        rowClone.className = rowClone.className.replace(/hob-fc-row--cols-\d+/, 'hob-fc-row--cols-' + targetedCols);
747                    }
748
749                    // Update names, IDs and clear values in the cloned row
750                    rowClone.querySelectorAll('[name]').forEach(function (input) {
751                        var origName = input.getAttribute('name');
752                        input.setAttribute('name', origName.replace(
753                            /hob_fields\[([^\]]+)\]/,
754                            'hob_fields[$1__rep_' + groupIdx + ']'
755                        ));
756                    });
757                    rowClone.querySelectorAll('[id]').forEach(function (el) {
758                        el.id = el.id + '__rep_' + groupIdx;
759                    });
760                    rowClone.querySelectorAll('label[for]').forEach(function (lbl) {
761                        lbl.setAttribute('for', lbl.getAttribute('for') + '__rep_' + groupIdx);
762                    });
763                    rowClone.querySelectorAll('input, textarea, select').forEach(function (input) {
764                        if (input.type === 'checkbox' || input.type === 'radio') {
765                            input.checked = false;
766                        } else if (input.tagName === 'SELECT') {
767                            input.selectedIndex = 0;
768                        } else if (input.type !== 'hidden') {
769                            input.value = '';
770                        }
771                        input.addEventListener('blur', function () { validateField(this); });
772                    });
773
774                    groupDiv.appendChild(rowClone);
775                }
776
777                // Remove button handler
778                groupDiv.querySelector('.hob-fc-repeater-remove').addEventListener('click', function () {
779                    groupDiv.remove();
780                    repCount--;
781                    updateCount();
782                });
783
784                // Insert group before the button's row
785                insertRef.parentNode.insertBefore(groupDiv, insertRef);
786
787                updateCount();
788            });
789
790            function updateCount() {
791                if (countEl) {
792                    if (repCount > 0) {
793                        countEl.style.display = '';
794                        countEl.textContent = repCount + ' ajout\u00e9(s)';
795                    } else {
796                        countEl.style.display = 'none';
797                    }
798                }
799                if (repCount >= maxRep) {
800                    btn.disabled = true;
801                    btn.classList.add('hob-fc-btn-disabled');
802                } else {
803                    btn.disabled = false;
804                    btn.classList.remove('hob-fc-btn-disabled');
805                }
806            }
807        });
808    }
809
810    // Video interactions: pause at timestamp, require fields, conditional resume.
811    function initVideoInteractions(form) {
812        form.querySelectorAll('.hob-fc-video-wrap').forEach(function (wrap) {
813            var pauseEnabled = wrap.dataset.pauseEnabled === '1';
814            var pauseTime    = parseFloat(wrap.dataset.pauseTime || '0');
815            var pauseFieldIds = [];
816            try { pauseFieldIds = JSON.parse(wrap.dataset.pauseFields || '[]'); } catch (e) { /* */ }
817            var resumeRulesRaw = wrap.dataset.resumeRules || '';
818            var resumeRules = [];
819            try { if (resumeRulesRaw) resumeRules = JSON.parse(resumeRulesRaw); } catch (e) { /* */ }
820
821            var overlay   = wrap.querySelector('.hob-fc-video-pause-overlay');
822            var resumeBtn = wrap.querySelector('.hob-fc-video-resume-btn');
823            var iframe    = wrap.querySelector('.hob-fc-video-iframe');
824            var videoEl   = wrap.querySelector('.hob-fc-video-player');
825            var paused    = false;
826
827            if (!pauseEnabled || pauseTime <= 0) return;
828
829            // ---------- Native <video> ----------
830            if (videoEl) {
831                videoEl.addEventListener('timeupdate', function () {
832                    if (!paused && videoEl.currentTime >= pauseTime) {
833                        paused = true;
834                        videoEl.pause();
835                        if (overlay) overlay.style.display = '';
836                        checkPauseFields();
837                    }
838                });
839
840                if (resumeBtn) {
841                    resumeBtn.addEventListener('click', function () {
842                        if (overlay) overlay.style.display = 'none';
843                        var seekTo = getResumeTimestamp();
844                        if (seekTo !== null) {
845                            videoEl.currentTime = seekTo;
846                        }
847                        paused = false;
848                        videoEl.play();
849                    });
850                }
851            }
852
853            // ---------- YouTube iframe API ----------
854            if (iframe && iframe.src && /youtube/.test(iframe.src)) {
855                var ytPlayer = null;
856                var ytInterval = null;
857
858                // Ensure YouTube API is loaded
859                if (!window.YT || !window.YT.Player) {
860                    if (!document.querySelector('script[src*="youtube.com/iframe_api"]')) {
861                        var tag = document.createElement('script');
862                        tag.src = 'https://www.youtube.com/iframe_api';
863                        document.head.appendChild(tag);
864                    }
865                }
866
867                function initYT() {
868                    if (!iframe.id) iframe.id = 'hob-yt-' + Math.random().toString(36).substr(2, 8);
869                    ytPlayer = new YT.Player(iframe.id, {
870                        events: {
871                            onReady: function () {
872                                ytInterval = setInterval(function () {
873                                    if (!paused && ytPlayer.getCurrentTime && ytPlayer.getCurrentTime() >= pauseTime) {
874                                        paused = true;
875                                        ytPlayer.pauseVideo();
876                                        if (overlay) overlay.style.display = '';
877                                        checkPauseFields();
878                                    }
879                                }, 500);
880                            }
881                        }
882                    });
883
884                    if (resumeBtn) {
885                        resumeBtn.addEventListener('click', function () {
886                            if (overlay) overlay.style.display = 'none';
887                            var seekTo = getResumeTimestamp();
888                            if (seekTo !== null) ytPlayer.seekTo(seekTo, true);
889                            paused = false;
890                            ytPlayer.playVideo();
891                        });
892                    }
893                }
894
895                if (window.YT && window.YT.Player) {
896                    initYT();
897                } else {
898                    var prev = window.onYouTubeIframeAPIReady;
899                    window.onYouTubeIframeAPIReady = function () {
900                        if (prev) prev();
901                        initYT();
902                    };
903                }
904            }
905
906            // ---------- Vimeo iframe (Player.js) ----------
907            if (iframe && iframe.src && /vimeo/.test(iframe.src)) {
908                // Lightweight postMessage approach
909                var vimeoReady = false;
910                var vimeoInterval = null;
911                function vimeoCommand(method, value) {
912                    var data = { method: method };
913                    if (value !== undefined) data.value = value;
914                    iframe.contentWindow.postMessage(JSON.stringify(data), '*');
915                }
916                // Listen for timeupdate via postMessage
917                window.addEventListener('message', function (e) {
918                    try {
919                        var data = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
920                        if (data.event === 'ready') {
921                            vimeoReady = true;
922                            vimeoCommand('addEventListener', 'playProgress');
923                        }
924                        if (data.event === 'playProgress' || (data.method === 'playProgress')) {
925                            var seconds = data.data ? data.data.seconds : 0;
926                            if (!paused && seconds >= pauseTime) {
927                                paused = true;
928                                vimeoCommand('pause');
929                                if (overlay) overlay.style.display = '';
930                                checkPauseFields();
931                            }
932                        }
933                    } catch (ex) { /* ignore */ }
934                });
935
936                if (resumeBtn) {
937                    resumeBtn.addEventListener('click', function () {
938                        if (overlay) overlay.style.display = 'none';
939                        var seekTo = getResumeTimestamp();
940                        if (seekTo !== null) vimeoCommand('seekTo', seekTo);
941                        paused = false;
942                        vimeoCommand('play');
943                    });
944                }
945            }
946
947            // ---------- Get value of a form field (text, select, radio, checkbox) ----------
948            function getFieldValue(fieldId) {
949                // Radio buttons
950                var radios = form.querySelectorAll('input[type="radio"][name="hob_fields[' + fieldId + ']"]');
951                if (radios.length) {
952                    for (var r = 0; r < radios.length; r++) {
953                        if (radios[r].checked) return radios[r].value;
954                    }
955                    return '';
956                }
957                // Checkboxes (multiple values)
958                var cbs = form.querySelectorAll('input[type="checkbox"][name="hob_fields[' + fieldId + '][]"]');
959                if (cbs.length) {
960                    var vals = [];
961                    for (var c = 0; c < cbs.length; c++) {
962                        if (cbs[c].checked) vals.push(cbs[c].value);
963                    }
964                    return vals.join(',');
965                }
966                // Standard input/select/textarea
967                var fld = form.querySelector('[name="hob_fields[' + fieldId + ']"]');
968                return fld ? fld.value.trim() : '';
969            }
970
971            // ---------- Check if pause fields are filled ----------
972            function checkPauseFields() {
973                if (!pauseFieldIds.length) {
974                    if (resumeBtn) resumeBtn.disabled = false;
975                    return;
976                }
977                function doCheck() {
978                    var allFilled = true;
979                    for (var i = 0; i < pauseFieldIds.length; i++) {
980                        if (!getFieldValue(pauseFieldIds[i])) {
981                            allFilled = false;
982                            break;
983                        }
984                    }
985                    if (resumeBtn) resumeBtn.disabled = !allFilled;
986                }
987                doCheck();
988                // Listen for input/change on those fields
989                for (var i = 0; i < pauseFieldIds.length; i++) {
990                    var wrapper = form.querySelector('.hob-fc-field-wrapper[data-field-id="' + pauseFieldIds[i] + '"]')
991                              || form.querySelector('.hob-fc-field-wrap[data-field-id="' + pauseFieldIds[i] + '"]');
992                    if (wrapper) {
993                        wrapper.addEventListener('input', doCheck);
994                        wrapper.addEventListener('change', doCheck);
995                        // Highlight the field to fill
996                        wrapper.classList.add('hob-fc-pause-highlight');
997                    }
998                }
999            }
1000
1001            // ---------- Conditional resume: determine seek timestamp ----------
1002            function getResumeTimestamp() {
1003                if (!resumeRules || !resumeRules.length) return null;
1004                for (var i = 0; i < resumeRules.length; i++) {
1005                    var rule = resumeRules[i];
1006                    if (!rule.field || rule.goto === undefined) continue;
1007                    var val = getFieldValue(rule.field);
1008                    if (val === String(rule.value)) {
1009                        return parseFloat(rule.goto);
1010                    }
1011                }
1012                return null; // no rule matched, resume from pause point
1013            }
1014        });
1015    }
1016
1017    /* HOB FormChat Pro | (c) 2026 HOB France Services — Groupe HOB */
1018    // Audio interactions: pause at timestamp, require fields, conditional resume.
1019    function initAudioInteractions(form) {
1020        form.querySelectorAll('.hob-fc-audio-wrap').forEach(function (wrap) {
1021            var pauseEnabled = wrap.dataset.pauseEnabled === '1';
1022            var pauseTime    = parseFloat(wrap.dataset.pauseTime || '0');
1023            var pauseFieldIds = [];
1024            try { pauseFieldIds = JSON.parse(wrap.dataset.pauseFields || '[]'); } catch (e) { /* */ }
1025            var resumeRulesRaw = wrap.dataset.resumeRules || '';
1026            var resumeRules = [];
1027            try { if (resumeRulesRaw) resumeRules = JSON.parse(resumeRulesRaw); } catch (e) { /* */ }
1028
1029            var overlay   = wrap.querySelector('.hob-fc-audio-pause-overlay');
1030            var resumeBtn = wrap.querySelector('.hob-fc-audio-resume-btn');
1031            var audioEl   = wrap.querySelector('.hob-fc-audio-player');
1032            var paused    = false;
1033
1034            if (!pauseEnabled || pauseTime <= 0 || !audioEl) return;
1035
1036            audioEl.addEventListener('timeupdate', function () {
1037                if (!paused && audioEl.currentTime >= pauseTime) {
1038                    paused = true;
1039                    audioEl.pause();
1040                    if (overlay) overlay.style.display = '';
1041                    checkPauseFields();
1042                }
1043            });
1044
1045            if (resumeBtn) {
1046                resumeBtn.addEventListener('click', function () {
1047                    if (overlay) overlay.style.display = 'none';
1048                    var seekTo = getResumeTimestamp();
1049                    if (seekTo !== null) {
1050                        audioEl.currentTime = seekTo;
1051                    }
1052                    paused = false;
1053                    audioEl.play();
1054                });
1055            }
1056
1057            // Get value of a form field
1058            function getFieldValue(fieldId) {
1059                var radios = form.querySelectorAll('input[type="radio"][name="hob_fields[' + fieldId + ']"]');
1060                if (radios.length) {
1061                    for (var r = 0; r < radios.length; r++) {
1062                        if (radios[r].checked) return radios[r].value;
1063                    }
1064                    return '';
1065                }
1066                var cbs = form.querySelectorAll('input[type="checkbox"][name="hob_fields[' + fieldId + '][]"]');
1067                if (cbs.length) {
1068                    var vals = [];
1069                    for (var c = 0; c < cbs.length; c++) {
1070                        if (cbs[c].checked) vals.push(cbs[c].value);
1071                    }
1072                    return vals.join(',');
1073                }
1074                var fld = form.querySelector('[name="hob_fields[' + fieldId + ']"]');
1075                return fld ? fld.value.trim() : '';
1076            }
1077
1078            // Check if pause fields are filled
1079            function checkPauseFields() {
1080                if (!pauseFieldIds.length) {
1081                    if (resumeBtn) resumeBtn.disabled = false;
1082                    return;
1083                }
1084                function doCheck() {
1085                    var allFilled = true;
1086                    for (var i = 0; i < pauseFieldIds.length; i++) {
1087                        if (!getFieldValue(pauseFieldIds[i])) {
1088                            allFilled = false;
1089                            break;
1090                        }
1091                    }
1092                    if (resumeBtn) resumeBtn.disabled = !allFilled;
1093                }
1094                doCheck();
1095                for (var i = 0; i < pauseFieldIds.length; i++) {
1096                    var wrapper = form.querySelector('.hob-fc-field-wrapper[data-field-id="' + pauseFieldIds[i] + '"]')
1097                              || form.querySelector('.hob-fc-field-wrap[data-field-id="' + pauseFieldIds[i] + '"]');
1098                    if (wrapper) {
1099                        wrapper.addEventListener('input', doCheck);
1100                        wrapper.addEventListener('change', doCheck);
1101                        wrapper.classList.add('hob-fc-pause-highlight');
1102                    }
1103                }
1104            }
1105
1106            // Conditional resume
1107            function getResumeTimestamp() {
1108                if (!resumeRules || !resumeRules.length) return null;
1109                for (var i = 0; i < resumeRules.length; i++) {
1110                    var rule = resumeRules[i];
1111                    if (!rule.field || rule.goto === undefined) continue;
1112                    var val = getFieldValue(rule.field);
1113                    if (val === String(rule.value)) {
1114                        return parseFloat(rule.goto);
1115                    }
1116                }
1117                return null;
1118            }
1119        });
1120    }
1121
1122    /* ─── P25: Analytics tracker (sendBeacon) ─── */
1123    var hobSid = Math.random().toString(36).slice(2);
1124    function hobTrack(formId, event, page) {
1125        var ajaxUrl = (typeof hobFcFormConfig !== 'undefined' && hobFcFormConfig.ajax_url)
1126                   || (typeof hobFcPublic !== 'undefined' && hobFcPublic.ajaxurl)
1127                   || '/wp-admin/admin-ajax.php';
1128        var pt = (typeof hobFcPublic !== 'undefined') ? hobFcPublic.page_token : '';
1129        var ps = (typeof hobFcPublic !== 'undefined') ? hobFcPublic.page_ts : '';
1130        navigator.sendBeacon(ajaxUrl,
1131            new URLSearchParams({action:'hob_fc_track', form_id:formId, event:event, page:page||0, sid:hobSid, page_token:pt, page_ts:ps})
1132        );
1133    }
1134
1135    /* ─── P25: GA4 + Matomo fire ─── */
1136    function hobFcFireExternalAnalytics(formEl) {
1137        var ga4  = formEl.dataset.ga4;
1138        var mUrl = formEl.dataset.matomoUrl;
1139        var mSid = formEl.dataset.matomoSite;
1140        var fid  = formEl.dataset.formId;
1141        var title = 'Form ' + fid;
1142
1143        // GA4
1144        if (ga4) {
1145            var fire = function() {
1146                gtag('event', 'form_submit', {
1147                    event_category: 'HOB FormChat Pro',
1148                    event_label: title,
1149                    form_id: fid
1150                });
1151            };
1152            if (typeof gtag === 'function') {
1153                fire();
1154            } else {
1155                window.dataLayer = window.dataLayer || [];
1156                window.gtag = function(){ window.dataLayer.push(arguments); };
1157                gtag('js', new Date());
1158                gtag('config', ga4);
1159                var s = document.createElement('script');
1160                s.async = true;
1161                s.src = 'https://www.googletagmanager.com/gtag/js?id=' + ga4;
1162                s.onload = fire;
1163                document.head.appendChild(s);
1164            }
1165        }
1166
1167        // Matomo
1168        if (mUrl && mSid) {
1169            if (typeof _paq !== 'undefined') {
1170                _paq.push(['trackEvent', 'Form', 'Submit', title]);
1171                _paq.push(['trackGoal', parseInt(mSid)]);
1172            } else {
1173                new Image().src = mUrl + 'matomo.php?idsite=' + mSid +
1174                    '&rec=1&e_c=Form&e_a=Submit&e_n=' + encodeURIComponent(title);
1175            }
1176        }
1177    }
1178
1179    /* ─── P24: Password gate AJAX ─── */
1180    function initPasswordGate() {
1181        document.querySelectorAll('.hob-fc-pwd-btn').forEach(function(btn) {
1182            btn.addEventListener('click', function() {
1183                var pwd = btn.parentNode.querySelector('.hob-fc-pwd-input');
1184                var err = btn.parentNode.querySelector('.hob-fc-pwd-error');
1185                if (!pwd) return;
1186                var ajaxUrl = (typeof hobFcFormConfig !== 'undefined' && hobFcFormConfig.ajax_url)
1187                           || (typeof hobFcPublic !== 'undefined' && hobFcPublic.ajax_url)
1188                           || '/wp-admin/admin-ajax.php';
1189                fetch(ajaxUrl, {
1190                    method: 'POST',
1191                    body: new URLSearchParams({
1192                        action: 'hob_fc_check_password',
1193                        form_id: btn.dataset.formId,
1194                        nonce: btn.dataset.nonce,
1195                        password: pwd.value
1196                    }),
1197                    credentials: 'same-origin'
1198                }).then(function(r) { return r.json(); }).then(function(d) {
1199                    if (d.success) {
1200                        location.reload();
1201                    } else {
1202                        if (err) {
1203                            err.style.display = 'block';
1204                            err.textContent = (d.data && d.data.message) || 'Mot de passe incorrect.';
1205                            err.style.color = '#DC2626';
1206                        }
1207                    }
1208                });
1209            });
1210            // Enter key support.
1211            var pwdInput = btn.parentNode.querySelector('.hob-fc-pwd-input');
1212            if (pwdInput) {
1213                pwdInput.addEventListener('keydown', function(e) {
1214                    if (e.key === 'Enter') { e.preventDefault(); btn.click(); }
1215                });
1216            }
1217        });
1218    }
1219
1220    /* ─── P24: Partial save (abandoned forms) ─── */
1221    function hobFcSavePartial(formEl, formId) {
1222        var fields = {};
1223        formEl.querySelectorAll('input:not([type=hidden]),select,textarea').forEach(function(el) {
1224            if (!el.name) return;
1225            if (el.type === 'checkbox') {
1226                if (!fields[el.name]) fields[el.name] = [];
1227                if (el.checked) fields[el.name].push(el.value);
1228            } else {
1229                fields[el.name] = el.value;
1230            }
1231        });
1232        if (!Object.keys(fields).length) return;
1233        var ajaxUrl = (typeof hobFcFormConfig !== 'undefined' && hobFcFormConfig.ajax_url)
1234                   || (typeof hobFcPublic !== 'undefined' && hobFcPublic.ajaxurl)
1235                   || '/wp-admin/admin-ajax.php';
1236        var pt = (typeof hobFcPublic !== 'undefined') ? hobFcPublic.page_token : '';
1237        var ps = (typeof hobFcPublic !== 'undefined') ? hobFcPublic.page_ts : '';
1238        navigator.sendBeacon(ajaxUrl,
1239            new URLSearchParams({action:'hob_fc_save_partial', form_id:formId, fields:JSON.stringify(fields), page_token:pt, page_ts:ps})
1240        );
1241    }
1242
1243    // Bootstrap all forms on the page.
1244    /**
1245     * Word & character counters for text/textarea fields.
1246     */
1247    /**
1248     * Initialise TinyMCE sur les textarea avec data-rich-editor="1".
1249     */
1250    function initTinyMCE(form) {
1251        var textareas = form.querySelectorAll('textarea[data-rich-editor="1"]');
1252        if (!textareas.length) return;
1253
1254        function doInit() {
1255            textareas.forEach(function (ta) {
1256                var id = ta.id;
1257                if (!id || ta._hobTinyDone) return;
1258                ta._hobTinyDone = true;
1259
1260                var mceSettings = {
1261                    wpautop: true,
1262                    toolbar1: 'bold italic underline | bullist numlist | link unlink | removeformat',
1263                    toolbar2: '',
1264                    menubar: false,
1265                    statusbar: false,
1266                    branding: false,
1267                    height: parseInt(ta.getAttribute('rows'), 10) * 24 || 200,
1268                    setup: function (editor) {
1269                        editor.on('change keyup paste input', function () {
1270                            editor.save();
1271                            ta.dispatchEvent(new Event('input', { bubbles: true }));
1272                        });
1273                    }
1274                };
1275
1276                if (typeof wp !== 'undefined' && wp.editor && wp.editor.initialize) {
1277                    wp.editor.initialize(id, {
1278                        tinymce: mceSettings,
1279                        quicktags: false,
1280                        mediaButtons: false
1281                    });
1282                } else if (typeof tinymce !== 'undefined') {
1283                    mceSettings.selector = '#' + id;
1284                    tinymce.init(mceSettings);
1285                } else {
1286                    // Fallback: show textarea as-is.
1287                    ta.style.display = '';
1288                }
1289            });
1290        }
1291
1292        // wp.editor / tinymce may not be ready at DOMContentLoaded.
1293        // wp_enqueue_editor() prints scripts in wp_footer, so wait.
1294        if ((typeof wp !== 'undefined' && wp.editor) || typeof tinymce !== 'undefined') {
1295            doInit();
1296        } else {
1297            // Poll briefly for scripts loaded via wp_footer.
1298            var attempts = 0;
1299            var poll = setInterval(function () {
1300                attempts++;
1301                if ((typeof wp !== 'undefined' && wp.editor) || typeof tinymce !== 'undefined') {
1302                    clearInterval(poll);
1303                    doInit();
1304                } else if (attempts > 50) {
1305                    // 5 seconds elapsed — give up, show raw textarea.
1306                    clearInterval(poll);
1307                    textareas.forEach(function (ta) { ta.style.display = ''; });
1308                }
1309            }, 100);
1310        }
1311
1312        // Sync all TinyMCE editors before form submit.
1313        form.addEventListener('submit', function () {
1314            if (typeof tinymce !== 'undefined') {
1315                tinymce.triggerSave();
1316            }
1317        }, true);
1318    }
1319
1320    /* HOB FormChat Pro | (c) 2026 HOB France Services — Groupe HOB */
1321    // Rating interactions: click, hover, half-star, animation, gradient, labels, clear.
1322    function initRatingInteractions(form) {
1323        form.querySelectorAll('.hob-fc-rating-wrap').forEach(function (wrap) {
1324            var max       = parseInt(wrap.dataset.max) || 5;
1325            var half      = wrap.dataset.half === '1';
1326            var colorOn   = wrap.dataset.color || '#FFD700';
1327            var colorOff  = wrap.dataset.colorOff || '#D1D5DB';
1328            var iconType  = wrap.dataset.icon || 'star';
1329            var anim      = wrap.dataset.animation || 'bounce';
1330            var isReadonly = wrap.dataset.readonly === '1';
1331            var gradient  = wrap.dataset.gradient === '1';
1332            var customUrl = wrap.dataset.customUrl || '';
1333            var labelsStr = wrap.dataset.labels || '';
1334            var hiddenInput = wrap.querySelector('.hob-fc-rating-input');
1335            var valueDisplay = wrap.querySelector('.hob-fc-rating-value');
1336            var levelLabel   = wrap.querySelector('.hob-fc-rating-level-label');
1337            var clearBtn     = wrap.querySelector('.hob-fc-rating-clear');
1338            var icons        = wrap.querySelectorAll('.hob-fc-rating-icon');
1339
1340            // Parse level labels: "1:Mauvais,2:Passable,3:Moyen"
1341            var labelMap = {};
1342            if (labelsStr) {
1343                labelsStr.split(',').forEach(function (pair) {
1344                    var parts = pair.split(':');
1345                    if (parts.length === 2) {
1346                        labelMap[parts[0].trim()] = parts[1].trim();
1347                    }
1348                });
1349            }
1350
1351            // Gradient colors: red → orange → yellow → green
1352            function getGradientColor(value) {
1353                var ratio = value / max;
1354                if (ratio <= 0.25) return '#EF4444';
1355                if (ratio <= 0.5) return '#F97316';
1356                if (ratio <= 0.75) return '#EAB308';
1357                return '#22C55E';
1358            }
1359
1360            function applyColor(icon, active, value) {
1361                if (customUrl) {
1362                    var img = icon.querySelector('img');
1363                    if (img) img.style.opacity = active ? '1' : '0.3';
1364                    return;
1365                }
1366                var sym = icon.querySelector('.hob-fc-rating-symbol');
1367                if (!sym) return;
1368                if (active) {
1369                    sym.style.color = gradient ? getGradientColor(value) : colorOn;
1370                } else {
1371                    sym.style.color = colorOff;
1372                }
1373            }
1374
1375            function updateDisplay(value) {
1376                if (hiddenInput) hiddenInput.value = value || '';
1377                if (valueDisplay) {
1378                    valueDisplay.textContent = value ? (value + '/' + max) : '';
1379                }
1380                if (levelLabel) {
1381                    // Find the closest label (round down for half-stars)
1382                    var labelVal = Math.ceil(value);
1383                    levelLabel.textContent = labelMap[labelVal] || labelMap[Math.floor(value)] || '';
1384                }
1385                if (clearBtn) {
1386                    clearBtn.style.display = value ? '' : 'none';
1387                }
1388                // Update ARIA
1389                icons.forEach(function (ic) {
1390                    var v = parseFloat(ic.dataset.value);
1391                    ic.setAttribute('aria-checked', v === value ? 'true' : 'false');
1392                });
1393            }
1394
1395            function paint(value) {
1396                icons.forEach(function (ic) {
1397                    var v = parseFloat(ic.dataset.value);
1398                    applyColor(ic, v <= value, value);
1399                });
1400            }
1401
1402            function animateIcon(icon) {
1403                if (anim === 'none') return;
1404                icon.classList.remove('hob-fc-rating-anim-' + anim);
1405                void icon.offsetWidth; // trigger reflow
1406                icon.classList.add('hob-fc-rating-anim-' + anim);
1407            }
1408
1409            // Set initial value
1410            var currentValue = parseFloat(hiddenInput ? hiddenInput.value : '') || 0;
1411            if (currentValue > 0) {
1412                paint(currentValue);
1413                updateDisplay(currentValue);
1414            }
1415
1416            if (isReadonly) return;
1417
1418            // Hover
1419            icons.forEach(function (icon) {
1420                icon.addEventListener('mouseenter', function () {
1421                    var v = parseFloat(this.dataset.value);
1422                    paint(v);
1423                });
1424            });
1425            var iconsContainer = wrap.querySelector('.hob-fc-rating-icons');
1426            if (iconsContainer) {
1427                iconsContainer.addEventListener('mouseleave', function () {
1428                    paint(currentValue);
1429                });
1430            }
1431
1432            // Click
1433            icons.forEach(function (icon) {
1434                icon.addEventListener('click', function () {
1435                    var v = parseFloat(this.dataset.value);
1436                    // Toggle off if clicking same value
1437                    if (v === currentValue) {
1438                        currentValue = 0;
1439                    } else {
1440                        currentValue = v;
1441                        animateIcon(this);
1442                    }
1443                    paint(currentValue);
1444                    updateDisplay(currentValue);
1445                    if (hiddenInput) {
1446                        hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
1447                    }
1448                });
1449
1450                // Keyboard
1451                icon.addEventListener('keydown', function (e) {
1452                    if (e.key === 'Enter' || e.key === ' ') {
1453                        e.preventDefault();
1454                        this.click();
1455                    } else if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
1456                        e.preventDefault();
1457                        var step = half ? 0.5 : 1;
1458                        currentValue = Math.min(max, currentValue + step);
1459                        paint(currentValue);
1460                        updateDisplay(currentValue);
1461                    } else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
1462                        e.preventDefault();
1463                        var step2 = half ? 0.5 : 1;
1464                        currentValue = Math.max(0, currentValue - step2);
1465                        paint(currentValue);
1466                        updateDisplay(currentValue);
1467                    }
1468                });
1469            });
1470
1471            // Clear button
1472            if (clearBtn) {
1473                clearBtn.addEventListener('click', function () {
1474                    currentValue = 0;
1475                    paint(0);
1476                    updateDisplay(0);
1477                    if (hiddenInput) {
1478                        hiddenInput.dispatchEvent(new Event('change', { bubbles: true }));
1479                    }
1480                });
1481            }
1482        });
1483    }
1484
1485    function initDataTables(form) {
1486        var wraps = form.querySelectorAll('.hob-fc-data-table-wrap');
1487        if (!wraps.length) return;
1488
1489        wraps.forEach(function(wrap) {
1490            var table    = wrap.querySelector('.hob-fc-data-table');
1491            var tbody    = table ? table.querySelector('tbody') : null;
1492            var addBtn   = wrap.querySelector('.hob-fc-dt-add-row');
1493            var fieldId  = wrap.dataset.fieldId || '';
1494            var maxRows  = parseInt(wrap.dataset.maxRows, 10) || 20;
1495            var hasSumRow = wrap.dataset.sumRow === '1';
1496            var hasCalc  = !!table.querySelector('.hob-fc-dt-calc');
1497
1498            // --- Resolve a source value for calc ---
1499            function resolveValue(source, rowTr) {
1500                if (!source) return 0;
1501                // col:INDEX — value from another column in the same row
1502                if (source.indexOf('col:') === 0) {
1503                    var colIdx = parseInt(source.substring(4), 10);
1504                    var name_suffix = '[' + colIdx + ']';
1505                    var els = rowTr.querySelectorAll('input, select');
1506                    for (var i = 0; i < els.length; i++) {
1507                        var n = els[i].getAttribute('name') || '';
1508                        if (n.slice(-name_suffix.length) === name_suffix) {
1509                            return parseFloat(els[i].value) || 0;
1510                        }
1511                    }
1512                    return 0;
1513                }
1514                // field:FIELD_ID — value from another form field
1515                if (source.indexOf('field:') === 0) {
1516                    var fId = source.substring(6);
1517                    // Try hob_fields[field_id] naming
1518                    var el = form.querySelector('[name="hob_fields[' + fId + ']"]');
1519                    if (!el) el = form.querySelector('#' + fId);
1520                    if (!el) el = form.querySelector('[data-field-id="' + fId + '"] input[type="number"]');
1521                    return el ? (parseFloat(el.value) || 0) : 0;
1522                }
1523                return parseFloat(source) || 0;
1524            }
1525
1526            function compute(a, op, b) {
1527                switch (op) {
1528                    case '+': return a + b;
1529                    case '-': return a - b;
1530                    case '*': return a * b;
1531                    case '/': return b !== 0 ? a / b : 0;
1532                    case '%': return a * b / 100;
1533                    default:  return a * b;
1534                }
1535            }
1536
1537            function formatNum(n) {
1538                return n % 1 === 0 ? String(n) : n.toFixed(2);
1539            }
1540
1541            // --- Recalculate all calc cells in a row ---
1542            function recalcRow(rowTr) {
1543                var calcCells = rowTr.querySelectorAll('.hob-fc-dt-calc');
1544                calcCells.forEach(function(span) {
1545                    var source = span.dataset.calcSource || '';
1546                    var op = span.dataset.calcOp || '*';
1547                    var operandType = span.dataset.calcOperandType || 'manual';
1548                    var a = resolveValue(source, rowTr);
1549                    var b;
1550                    if (operandType === 'manual') {
1551                        b = parseFloat(span.dataset.calcManual) || 0;
1552                    } else {
1553                        b = resolveValue(span.dataset.calcField || '', rowTr);
1554                    }
1555                    var result = compute(a, op, b);
1556                    span.textContent = formatNum(result);
1557                    // Update hidden input
1558                    var hidden = span.parentNode.querySelector('.hob-fc-dt-calc-val');
1559                    if (hidden) hidden.value = result;
1560                });
1561            }
1562
1563            // --- Recalculate all rows ---
1564            function recalcAll() {
1565                if (!tbody) return;
1566                tbody.querySelectorAll('tr').forEach(function(tr) {
1567                    recalcRow(tr);
1568                });
1569                if (hasSumRow) updateSums(wrap);
1570            }
1571
1572            // --- Listen to changes on all inputs in the table + external form fields ---
1573            if (hasCalc || hasSumRow) {
1574                // Delegate on table for inputs
1575                table.addEventListener('input', function(e) {
1576                    if (e.target.matches('input, select, textarea')) {
1577                        var tr = e.target.closest('tr');
1578                        if (tr && hasCalc) recalcRow(tr);
1579                        if (hasSumRow) updateSums(wrap);
1580                    }
1581                });
1582
1583                // Listen to external form fields referenced by calc columns
1584                if (hasCalc) {
1585                    var calcCells = table.querySelectorAll('.hob-fc-dt-calc');
1586                    var externalFieldIds = {};
1587                    calcCells.forEach(function(span) {
1588                        var src = span.dataset.calcSource || '';
1589                        var fld = span.dataset.calcField || '';
1590                        if (src.indexOf('field:') === 0) externalFieldIds[src.substring(6)] = true;
1591                        if (fld.indexOf('field:') === 0) externalFieldIds[fld.substring(6)] = true;
1592                    });
1593                    Object.keys(externalFieldIds).forEach(function(fId) {
1594                        var el = form.querySelector('[name="hob_fields[' + fId + ']"]');
1595                        if (!el) el = form.querySelector('#' + fId);
1596                        if (el) {
1597                            el.addEventListener('input', recalcAll);
1598                            el.addEventListener('change', recalcAll);
1599                        }
1600                    });
1601
1602                    // Initial calculation
1603                    recalcAll();
1604                }
1605            }
1606
1607            // --- Ajout dynamique de lignes ---
1608            if (addBtn && tbody) {
1609                addBtn.addEventListener('click', function() {
1610                    var currentRows = tbody.querySelectorAll('tr').length;
1611                    if (currentRows >= maxRows) return;
1612
1613                    var lastRow = tbody.querySelector('tr:last-child');
1614                    if (!lastRow) return;
1615
1616                    var newRow = lastRow.cloneNode(true);
1617                    var rowIdx = currentRows;
1618
1619                    // Mettre à jour les noms et valeurs
1620                    newRow.querySelectorAll('input, select, textarea').forEach(function(el) {
1621                        var name = el.getAttribute('name') || '';
1622                        el.setAttribute('name', name.replace(
1623                            /\[(\d+)\]\[(\d+)\]$/,
1624                            '[' + rowIdx + '][$2]'
1625                        ));
1626                        if (el.type === 'checkbox' || el.type === 'radio') {
1627                            el.checked = false;
1628                        } else if (!el.classList.contains('hob-fc-dt-calc-val')) {
1629                            el.value = '';
1630                        }
1631                    });
1632
1633                    // Réinitialiser les spans calc
1634                    newRow.querySelectorAll('.hob-fc-dt-calc').forEach(function(span) {
1635                        span.textContent = '\u2014';
1636                    });
1637
1638                    // Mettre à jour le numéro de ligne si affiché
1639                    var numCell = newRow.querySelector('td:first-child');
1640                    if (numCell && /^\d+$/.test(numCell.textContent.trim())) {
1641                        numCell.textContent = rowIdx + 1;
1642                    }
1643
1644                    newRow.style.background = (rowIdx % 2 === 1) ? '#F5F6F7' : '#fff';
1645                    tbody.appendChild(newRow);
1646
1647                    if (tbody.querySelectorAll('tr').length >= maxRows) {
1648                        addBtn.style.display = 'none';
1649                    }
1650                });
1651            }
1652        });
1653
1654        function updateSums(wrap) {
1655            var table = wrap.querySelector('.hob-fc-data-table');
1656            var sumCells = table.querySelectorAll('.hob-fc-dt-sum');
1657            sumCells.forEach(function(cell) {
1658                var colIdx = parseInt(cell.dataset.col, 10);
1659                var total = 0;
1660                table.querySelectorAll('tbody tr').forEach(function(row) {
1661                    var tds = row.querySelectorAll('td');
1662                    for (var i = 0; i < tds.length; i++) {
1663                        // Check number inputs
1664                        var inp = tds[i].querySelector('input[type="number"]');
1665                        if (inp) {
1666                            var name = inp.getAttribute('name') || '';
1667                            var m = name.match(/\[(\d+)\]$/);
1668                            if (m && parseInt(m[1], 10) === colIdx) {
1669                                total += parseFloat(inp.value) || 0;
1670                            }
1671                        }
1672                        // Check calc hidden inputs
1673                        var calcHidden = tds[i].querySelector('.hob-fc-dt-calc-val');
1674                        if (calcHidden) {
1675                            var calcSpan = tds[i].querySelector('.hob-fc-dt-calc');
1676                            if (calcSpan && parseInt(calcSpan.dataset.col, 10) === colIdx) {
1677                                total += parseFloat(calcHidden.value) || 0;
1678                            }
1679                        }
1680                    }
1681                });
1682                cell.textContent = total % 1 === 0 ? String(total) : total.toFixed(2);
1683            });
1684        }
1685    }
1686
1687    function initSignaturePads(form) {
1688        var wraps = form.querySelectorAll('.hob-fc-signature-wrap');
1689        if (!wraps.length) return;
1690
1691        wraps.forEach(function(wrap) {
1692            var canvas = wrap.querySelector('.hob-fc-signature-pad');
1693            var hiddenInput = wrap.querySelector('input[type="hidden"]');
1694            var clearBtn = wrap.querySelector('.hob-fc-signature-clear');
1695            if (!canvas || !hiddenInput) return;
1696
1697            var ctx = canvas.getContext('2d');
1698            var drawing = false;
1699            var lastX = 0, lastY = 0;
1700
1701            // Set canvas size to match CSS size
1702            function resizeCanvas() {
1703                var rect = canvas.getBoundingClientRect();
1704                if (rect.width > 0) {
1705                    canvas.width = rect.width;
1706                    canvas.height = rect.height || 150;
1707                }
1708            }
1709            resizeCanvas();
1710
1711            ctx.strokeStyle = '#000';
1712            ctx.lineWidth = 2;
1713            ctx.lineCap = 'round';
1714            ctx.lineJoin = 'round';
1715
1716            function getPos(e) {
1717                var rect = canvas.getBoundingClientRect();
1718                var touch = e.touches ? e.touches[0] : e;
1719                return { x: touch.clientX - rect.left, y: touch.clientY - rect.top };
1720            }
1721
1722            function startDraw(e) {
1723                e.preventDefault();
1724                drawing = true;
1725                var pos = getPos(e);
1726                lastX = pos.x;
1727                lastY = pos.y;
1728                ctx.beginPath();
1729                ctx.moveTo(lastX, lastY);
1730            }
1731
1732            function draw(e) {
1733                if (!drawing) return;
1734                e.preventDefault();
1735                var pos = getPos(e);
1736                ctx.lineTo(pos.x, pos.y);
1737                ctx.stroke();
1738                lastX = pos.x;
1739                lastY = pos.y;
1740            }
1741
1742            function endDraw() {
1743                if (!drawing) return;
1744                drawing = false;
1745                // Save to hidden input as data URL
1746                hiddenInput.value = canvas.toDataURL('image/png');
1747                // Le trace vaut saisie : on leve l'erreur "champ obligatoire".
1748                setFieldError(hiddenInput, '');
1749            }
1750
1751            canvas.addEventListener('mousedown', startDraw);
1752            canvas.addEventListener('mousemove', draw);
1753            canvas.addEventListener('mouseup', endDraw);
1754            canvas.addEventListener('mouseleave', endDraw);
1755            canvas.addEventListener('touchstart', startDraw, { passive: false });
1756            canvas.addEventListener('touchmove', draw, { passive: false });
1757            canvas.addEventListener('touchend', endDraw);
1758
1759            if (clearBtn) {
1760                clearBtn.addEventListener('click', function() {
1761                    ctx.clearRect(0, 0, canvas.width, canvas.height);
1762                    hiddenInput.value = '';
1763                    setFieldError(hiddenInput, '');
1764                });
1765            }
1766        });
1767    }
1768
1769    function initCounters(form) {
1770        function countWords(str) {
1771            var s = str.trim();
1772            if (!s) return 0;
1773            return s.split(/\s+/).length;
1774        }
1775
1776        function updateCounter(input) {
1777            var id = input.id;
1778            var val = input.value;
1779            var maxWords = parseInt(input.dataset.maxWords, 10) || 0;
1780            var maxChars = parseInt(input.dataset.maxChars, 10) || 0;
1781            var words = countWords(val);
1782            var chars = val.length;
1783
1784            // Word counter
1785            var wEls = form.querySelectorAll('.hob-fc-word-counter[data-field="' + id + '"]');
1786            wEls.forEach(function(el) {
1787                var txt = words;
1788                if (maxWords > 0) txt += ' / ' + maxWords;
1789                txt += ' ' + _t('words', 'mots');
1790                el.textContent = txt;
1791                el.classList.toggle('hob-fc-counter-over', maxWords > 0 && words > maxWords);
1792            });
1793
1794            // Char counter
1795            var cEls = form.querySelectorAll('.hob-fc-char-counter[data-field="' + id + '"]');
1796            cEls.forEach(function(el) {
1797                var txt = chars;
1798                if (maxChars > 0) txt += ' / ' + maxChars;
1799                txt += ' ' + _t('chars', 'caract.');
1800                el.textContent = txt;
1801                el.classList.toggle('hob-fc-counter-over', maxChars > 0 && chars > maxChars);
1802            });
1803
1804            // Enforce max on input (block typing beyond limit)
1805            if (maxChars > 0 && chars > maxChars) {
1806                input.value = val.substring(0, maxChars);
1807                updateCounter(input);
1808            }
1809        }
1810
1811        var fields = form.querySelectorAll('input[data-max-chars], textarea[data-max-words], textarea[data-max-chars]');
1812        fields.forEach(function(input) {
1813            updateCounter(input);
1814            input.addEventListener('input', function() { updateCounter(input); });
1815            input.addEventListener('paste', function() { setTimeout(function() { updateCounter(input); }, 0); });
1816        });
1817
1818        // Also bind fields that have counters but no data-max-* (display only)
1819        var counterEls = form.querySelectorAll('.hob-fc-word-counter, .hob-fc-char-counter');
1820        counterEls.forEach(function(el) {
1821            var fieldId = el.dataset.field;
1822            if (!fieldId) return;
1823            var input = document.getElementById(fieldId);
1824            if (!input || input._hobCounterBound) return;
1825            input._hobCounterBound = true;
1826            updateCounter(input);
1827            input.addEventListener('input', function() { updateCounter(input); });
1828            input.addEventListener('paste', function() { setTimeout(function() { updateCounter(input); }, 0); });
1829        });
1830    }
1831
1832    document.addEventListener('DOMContentLoaded', function () {
1833        // P24: Password gate.
1834        initPasswordGate();
1835
1836        document.querySelectorAll('.hob-fc-form').forEach(function (form) {
1837            initForm(form);
1838            initMultiPage(form);
1839            initRichEditors(form);
1840            initOpinionScales(form);
1841            initRepeaterButtons(form);
1842            initVideoInteractions(form);
1843            initAudioInteractions(form);
1844            initRatingInteractions(form);
1845            initCounters(form);
1846            initTinyMCE(form);
1847            initSignaturePads(form);
1848            initDataTables(form);
1849
1850            // P25: Track page_view on load.
1851            var fid = form.dataset.formId;
1852            if (fid) hobTrack(fid, 'page_view', 0);
1853        });
1854
1855        // P24: Partial save — beforeunload + interval 30s.
1856        // P25: Track abandon on beforeunload.
1857        window.addEventListener('beforeunload', function() {
1858            document.querySelectorAll('.hob-fc-form[data-form-id]').forEach(function(f) {
1859                if (!f.dataset.submitted) {
1860                    hobFcSavePartial(f, f.dataset.formId);
1861                    hobTrack(f.dataset.formId, 'abandon', 0);
1862                }
1863            });
1864        });
1865        setInterval(function() {
1866            document.querySelectorAll('.hob-fc-form[data-form-id]').forEach(function(f) {
1867                if (!f.dataset.submitted) hobFcSavePartial(f, f.dataset.formId);
1868            });
1869        }, 30000);
1870
1871        // ── P23: One-by-One mode ──
1872        document.querySelectorAll('.hob-fc-obo').forEach(function(obo) {
1873            var slides = obo.querySelectorAll('.hob-fc-obo__slide');
1874            var fill = obo.querySelector('.hob-fc-obo__fill');
1875            var cur = 0;
1876
1877            function goTo(idx) {
1878                if (idx < 0 || idx >= slides.length) return;
1879                slides[cur].classList.remove('hob-fc-obo__slide--active');
1880                cur = idx;
1881                slides[cur].classList.add('hob-fc-obo__slide--active');
1882                var firstInput = slides[cur].querySelector('input,select,textarea');
1883                if (firstInput) firstInput.focus();
1884                if (fill) fill.style.width = (slides.length > 1 ? Math.round(cur / (slides.length - 1) * 100) : 100) + '%';
1885            }
1886
1887            obo.addEventListener('click', function(e) {
1888                if (e.target.matches('.hob-fc-obo__next')) {
1889                    var inputs = slides[cur].querySelectorAll('input[required],select[required],textarea[required]');
1890                    var allValid = true;
1891                    inputs.forEach(function(i) { if (!i.checkValidity()) { allValid = false; i.reportValidity(); } });
1892                    if (allValid) goTo(cur + 1);
1893                }
1894                if (e.target.matches('.hob-fc-obo__prev')) goTo(cur - 1);
1895            });
1896
1897            obo.addEventListener('keydown', function(e) {
1898                if (e.key === 'Enter' && !e.shiftKey) {
1899                    var next = slides[cur].querySelector('.hob-fc-obo__next');
1900                    if (next) { e.preventDefault(); next.click(); }
1901                }
1902            });
1903        });
1904
1905        // ── P23: Popup ──
1906        document.querySelectorAll('.hob-fc-popup').forEach(function(popup) {
1907            var show = function() { popup.style.display = 'flex'; };
1908            var hide = function() { popup.style.display = 'none'; };
1909            var t = popup.dataset.trigger || 'on_load';
1910            var d = parseInt(popup.dataset.delay || '0', 10) * 1000;
1911
1912            if (t === 'on_load') setTimeout(show, d);
1913            if (t === 'on_delay') setTimeout(show, d || 3000);
1914            if (t === 'on_exit') {
1915                document.addEventListener('mouseleave', function(e) {
1916                    if (e.clientY < 10) show();
1917                }, { once: true });
1918            }
1919
1920            var closeBtn = popup.querySelector('.hob-fc-popup__close');
1921            if (closeBtn) closeBtn.addEventListener('click', hide);
1922            popup.addEventListener('click', function(e) { if (e.target === popup) hide(); });
1923            document.addEventListener('keydown', function(e) {
1924                if (e.key === 'Escape' && popup.style.display === 'flex') hide();
1925            });
1926        });
1927
1928        // ── P23: Multi-select ──
1929        document.querySelectorAll('.hob-fc-ms').forEach(function(ms) {
1930            var box = ms.querySelector('.hob-fc-ms__box');
1931            var inp = ms.querySelector('.hob-fc-ms__search');
1932            var drop = ms.querySelector('.hob-fc-ms__drop');
1933            var hid = ms.querySelector('.hob-fc-ms__hidden');
1934            var fid = ms.dataset.fieldId;
1935            var maxSel = parseInt(ms.dataset.max || '0', 10);
1936            var sel = [];
1937
1938            function openDrop() { drop.classList.add('open'); }
1939            function closeDrop() { drop.classList.remove('open'); }
1940
1941            inp.addEventListener('focus', openDrop);
1942            inp.addEventListener('input', function() {
1943                var q = inp.value.toLowerCase();
1944                drop.querySelectorAll('li').forEach(function(li) {
1945                    li.style.display = li.textContent.toLowerCase().indexOf(q) !== -1 ? '' : 'none';
1946                });
1947                openDrop();
1948            });
1949
1950            drop.addEventListener('click', function(e) {
1951                var li = e.target.closest('li');
1952                if (!li) return;
1953                var v = li.dataset.val;
1954                var idx = sel.indexOf(v);
1955                if (idx !== -1) {
1956                    sel.splice(idx, 1);
1957                    li.classList.remove('sel');
1958                } else {
1959                    if (maxSel > 0 && sel.length >= maxSel) return;
1960                    sel.push(v);
1961                    li.classList.add('sel');
1962                }
1963                render();
1964                inp.value = '';
1965                inp.focus();
1966            });
1967
1968            function render() {
1969                box.querySelectorAll('.hob-fc-ms__tag').forEach(function(t) { t.remove(); });
1970                hid.innerHTML = '';
1971                sel.forEach(function(v) {
1972                    var liEl = drop.querySelector('li[data-val="' + v + '"]');
1973                    var lbl = liEl ? liEl.textContent : v;
1974                    var tag = document.createElement('span');
1975                    tag.className = 'hob-fc-ms__tag';
1976                    tag.innerHTML = lbl + '<span class="hob-fc-ms__rm" data-v="' + v + '">&times;</span>';
1977                    box.insertBefore(tag, inp);
1978                    var hidden = document.createElement('input');
1979                    hidden.type = 'hidden';
1980                    hidden.name = fid + '[]';
1981                    hidden.value = v;
1982                    hid.appendChild(hidden);
1983                });
1984            }
1985
1986            box.addEventListener('click', function(e) {
1987                if (e.target.matches('.hob-fc-ms__rm')) {
1988                    var v = e.target.dataset.v;
1989                    sel = sel.filter(function(x) { return x !== v; });
1990                    var liEl = drop.querySelector('li[data-val="' + v + '"]');
1991                    if (liEl) liEl.classList.remove('sel');
1992                    render();
1993                }
1994            });
1995
1996            document.addEventListener('click', function(e) {
1997                if (!ms.contains(e.target)) closeDrop();
1998            });
1999        });
2000
2001        // ── P23: Ranking drag & drop ──
2002        document.querySelectorAll('.hob-fc-rank').forEach(function(c) {
2003            var list = c.querySelector('.hob-fc-rank__list');
2004            var dragged = null;
2005
2006            list.addEventListener('dragstart', function(e) {
2007                dragged = e.target.closest('.hob-fc-rank__item');
2008                if (dragged) dragged.style.opacity = '.4';
2009            });
2010
2011            list.addEventListener('dragend', function() {
2012                if (dragged) dragged.style.opacity = '';
2013                dragged = null;
2014                list.querySelectorAll('.hob-fc-rank__item').forEach(function(i) { i.classList.remove('over'); });
2015            });
2016
2017            list.addEventListener('dragover', function(e) {
2018                e.preventDefault();
2019                var target = e.target.closest('.hob-fc-rank__item');
2020                if (!target || target === dragged) return;
2021                list.querySelectorAll('.hob-fc-rank__item').forEach(function(i) { i.classList.remove('over'); });
2022                target.classList.add('over');
2023                var after = e.clientY > target.getBoundingClientRect().top + target.offsetHeight / 2;
2024                list.insertBefore(dragged, after ? target.nextSibling : target);
2025            });
2026        });
2027
2028        // ── File upload: drag & drop + preview ──
2029        document.querySelectorAll('.hob-fc-file-zone').forEach(function(zone) {
2030            var input   = zone.querySelector('.hob-fc-file-input');
2031            var preview = zone.querySelector('.hob-fc-file-preview');
2032            var label   = zone.querySelector('.hob-fc-file-label');
2033            if (!input) return;
2034
2035            var maxBytes = parseInt(zone.dataset.max || '10485760', 10);
2036            var maxMb    = Math.round(maxBytes / (1024 * 1024));
2037            var maxFiles = parseInt(zone.dataset.maxFiles || '1', 10) || 1;
2038            var isMulti  = input.multiple;
2039            var allowed  = (zone.dataset.acceptExt || '')
2040                               .split(',')
2041                               .map(function(e) { return e.trim().toLowerCase(); })
2042                               .filter(Boolean);
2043
2044            function extOf(name) {
2045                var i = String(name).lastIndexOf('.');
2046                return i === -1 ? '' : String(name).slice(i + 1).toLowerCase();
2047            }
2048
2049            function sizeLabel(bytes) {
2050                return bytes < 1048576
2051                    ? Math.round(bytes / 1024) + ' Ko'
2052                    : (bytes / 1048576).toFixed(1) + ' Mo';
2053            }
2054
2055            function fmt(tpl, a, b) {
2056                return String(tpl)
2057                    .replace('%1$s', a).replace('%2$s', b)
2058                    .replace('%d', a);
2059            }
2060
2061            /**
2062             * Ecarte les fichiers non conformes (extension, poids, nombre).
2063             *
2064             * @param {File[]} files
2065             * @returns {File[]} Fichiers conserves.
2066             */
2067            function filterFiles(files) {
2068                var kept = [];
2069                var errs = [];
2070
2071                files.forEach(function(f) {
2072                    if (allowed.length && allowed.indexOf(extOf(f.name)) === -1) {
2073                        errs.push(fmt(
2074                            _t('fileBadType', '\u00ab %1$s \u00bb n\u2019est pas un format autoris\u00e9 (%2$s).'),
2075                            f.name,
2076                            allowed.join(', ').toUpperCase()
2077                        ));
2078                        return;
2079                    }
2080                    if (f.size > maxBytes) {
2081                        errs.push(fmt(
2082                            _t('fileTooLarge', '\u00ab %1$s \u00bb d\u00e9passe la taille maximale (%2$s Mo).'),
2083                            f.name,
2084                            maxMb
2085                        ));
2086                        return;
2087                    }
2088                    kept.push(f);
2089                });
2090
2091                if (kept.length > maxFiles) {
2092                    errs.push(fmt(_t('tooManyFiles', '%d fichier(s) maximum.'), maxFiles));
2093                    kept = kept.slice(0, maxFiles);
2094                }
2095
2096                setFieldError(input, errs.length ? errs[0] : '');
2097                return kept;
2098            }
2099
2100            function render(files) {
2101                if (!preview) return;
2102                preview.innerHTML = '';
2103
2104                if (!files.length) {
2105                    preview.classList.remove('visible');
2106                    if (label) label.style.opacity = '';
2107                    return;
2108                }
2109
2110                files.forEach(function(f, idx) {
2111                    var row = document.createElement('div');
2112                    row.className = 'hob-fc-file-item';
2113
2114                    var ok = document.createElement('span');
2115                    ok.textContent = '\u2705';
2116
2117                    var nm = document.createElement('span');
2118                    nm.className = 'hob-fc-file-item__name';
2119                    nm.textContent = f.name;
2120
2121                    var sz = document.createElement('span');
2122                    sz.className = 'hob-fc-file-item__size';
2123                    sz.textContent = '(' + sizeLabel(f.size) + ')';
2124
2125                    var rm = document.createElement('button');
2126                    rm.type = 'button';
2127                    rm.className = 'hob-fc-file-remove';
2128                    rm.title = 'Supprimer';
2129                    rm.textContent = '\u2716';
2130                    rm.addEventListener('click', function() {
2131                        var next = Array.prototype.slice.call(input.files);
2132                        next.splice(idx, 1);
2133                        commit(next);
2134                        setFieldError(input, '');
2135                    });
2136
2137                    row.appendChild(ok);
2138                    row.appendChild(nm);
2139                    row.appendChild(sz);
2140                    row.appendChild(rm);
2141                    preview.appendChild(row);
2142                });
2143
2144                preview.classList.add('visible');
2145                if (label) label.style.opacity = '.5';
2146            }
2147
2148            /**
2149             * Reecrit la selection de l'input puis rafraichit l'apercu.
2150             *
2151             * @param {File[]} files
2152             */
2153            function commit(files) {
2154                var dt = new DataTransfer();
2155                files.forEach(function(f) { dt.items.add(f); });
2156                input.files = dt.files;
2157                render(files);
2158            }
2159
2160            input.addEventListener('change', function() {
2161                var picked = Array.prototype.slice.call(input.files);
2162                var valid  = filterFiles(picked);
2163                if (valid.length !== picked.length) {
2164                    commit(valid);
2165                } else {
2166                    render(valid);
2167                }
2168            });
2169
2170            if (zone.classList.contains('hob-fc-file-zone--drag')) {
2171                zone.addEventListener('dragover', function(e) {
2172                    e.preventDefault();
2173                    zone.classList.add('drag-over');
2174                });
2175                zone.addEventListener('dragleave', function() {
2176                    zone.classList.remove('drag-over');
2177                });
2178                zone.addEventListener('drop', function(e) {
2179                    e.preventDefault();
2180                    zone.classList.remove('drag-over');
2181
2182                    var dropped = Array.prototype.slice.call(e.dataTransfer.files);
2183                    if (!dropped.length) return;
2184
2185                    // En mode multiple on cumule avec la selection existante.
2186                    var base = isMulti ? Array.prototype.slice.call(input.files) : [];
2187                    commit(filterFiles(base.concat(dropped)));
2188                });
2189            }
2190        });
2191
2192        // ── PDF Upload: drag & drop + validation ──
2193        document.querySelectorAll('.hob-fc-pdf-slot').forEach(function(slot) {
2194            var input   = slot.querySelector('.hob-fc-pdf-slot__input');
2195            var label   = slot.querySelector('.hob-fc-pdf-slot__label');
2196            var preview = slot.querySelector('.hob-fc-pdf-slot__preview');
2197            if (!input || !label || !preview) return;
2198
2199            var maxSize = parseInt(input.dataset.maxSize || '10485760', 10);
2200
2201            function showFile(file) {
2202                if (!file) return;
2203                if (file.type !== 'application/pdf') {
2204                    var errEl = slot.querySelector('.hob-fc-field-error');
2205                    if (errEl) errEl.textContent = 'Seuls les fichiers PDF sont accept\u00e9s.';
2206                    return;
2207                }
2208                if (file.size > maxSize) {
2209                    var errEl2 = slot.querySelector('.hob-fc-field-error');
2210                    if (errEl2) errEl2.textContent = 'Fichier trop volumineux (max ' + Math.round(maxSize / 1048576) + ' Mo).';
2211                    return;
2212                }
2213                var errClear = slot.querySelector('.hob-fc-field-error');
2214                if (errClear) errClear.textContent = '';
2215                label.style.display = 'none';
2216                preview.style.display = '';
2217                preview.innerHTML = '<span>\uD83D\uDCC4</span> <span>' + file.name + '</span>'
2218                    + ' <span style="color:#999;font-size:11px">(' + (file.size / 1024).toFixed(0) + ' Ko)</span>'
2219                    + '<button type="button" class="hob-fc-pdf-slot__remove-file" title="Supprimer">\u2716</button>';
2220                preview.querySelector('.hob-fc-pdf-slot__remove-file').addEventListener('click', function() {
2221                    input.value = '';
2222                    label.style.display = '';
2223                    preview.style.display = 'none';
2224                    preview.innerHTML = '';
2225                });
2226            }
2227
2228            input.addEventListener('change', function() { showFile(input.files[0]); });
2229
2230            label.addEventListener('dragover', function(e) {
2231                e.preventDefault();
2232                label.classList.add('dragover');
2233            });
2234            label.addEventListener('dragleave', function() {
2235                label.classList.remove('dragover');
2236            });
2237            label.addEventListener('drop', function(e) {
2238                e.preventDefault();
2239                label.classList.remove('dragover');
2240                var f = e.dataTransfer.files[0];
2241                if (f) {
2242                    var dt = new DataTransfer();
2243                    dt.items.add(f);
2244                    input.files = dt.files;
2245                    showFile(f);
2246                }
2247            });
2248        });
2249
2250        // ── P23: Image upload with preview ──
2251        document.querySelectorAll('.hob-fc-imgup').forEach(function(w) {
2252            var input = w.querySelector('.hob-fc-imgup__input');
2253            var zone = w.querySelector('.hob-fc-imgup__zone');
2254            var preview = w.querySelector('.hob-fc-imgup__preview');
2255
2256            function showPreview() {
2257                var f = input.files[0];
2258                if (!f) return;
2259                var r = new FileReader();
2260                r.onload = function(e) { preview.innerHTML = '<img src="' + e.target.result + '" alt="Aperçu">'; };
2261                r.readAsDataURL(f);
2262            }
2263
2264            input.addEventListener('change', showPreview);
2265
2266            zone.addEventListener('dragover', function(e) {
2267                e.preventDefault();
2268                zone.classList.add('dragover');
2269            });
2270            zone.addEventListener('dragleave', function() {
2271                zone.classList.remove('dragover');
2272            });
2273            zone.addEventListener('drop', function(e) {
2274                e.preventDefault();
2275                zone.classList.remove('dragover');
2276                var f = e.dataTransfer.files[0];
2277                if (f && f.type.startsWith('image/')) {
2278                    var dt = new DataTransfer();
2279                    dt.items.add(f);
2280                    input.files = dt.files;
2281                    showPreview();
2282                }
2283            });
2284        });
2285    });
2286})();

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.