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 + '">×</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.