PageSourceSearch

https://www.commune-nointel.fr/wp-content/plugins/hob-formchat-pro/assets/js/hob-conditional.js?ver=1.29

js commune-nointel.fr collected 2026-10-03 02:46:33 UTC 41,850 bytes, 1,069 lines download raw bytes

1'use strict';
2
3/**
4 * HobConditional — Moteur de logique conditionnelle pour HOB FormChat Pro
5 * Evalue les regles en temps reel au fur et a mesure que les valeurs du formulaire changent.
6 *
7 * Structure d'une regle (stockee dans config_json.conditional_rules) :
8 * {
9 *   id: "rule_abc123",
10 *   label: "Description de la regle",
11 *   enabled: true,
12 *   match: "all"|"any",       // "all" = ET logique, "any" = OU logique
13 *   conditions: [{ field_id, operator, value }],
14 *   actions: [{ type, target, value?, source? }]
15 * }
16 *
17 * Fonctionne cote front-end (formulaires publics) et dans l'apercu du builder.
18 */
19const HobConditional = {
20
21    /** Reference au formulaire courant */
22    _formEl: null,
23
24    /** Regles actives */
25    _rules: [],
26
27    /** Handler d'evenements (conserve pour pouvoir le retirer) */
28    _handler: null,
29
30    /**
31     * Normalise une règle au format v2 (groups[]).
32     * Compatible avec l'ancien format (conditions[] flat + match).
33     */
34    normalizeRule: function (rule) {
35        if (!rule) return rule;
36        // Convert old format to new: wrap flat conditions in a single group
37        if (rule.conditions && !rule.groups) {
38            rule.groups = [{
39                operator: rule.match === 'any' ? 'OR' : 'AND',
40                conditions: rule.conditions
41            }];
42            rule.group_operator = rule.match === 'any' ? 'OR' : 'AND';
43        }
44        // Ensure then is an array
45        if (rule.actions && !Array.isArray(rule.actions)) {
46            rule.actions = [rule.actions];
47        }
48        // Default trigger
49        if (!rule.trigger) rule.trigger = 'on_change';
50        if (rule.enabled === undefined) rule.enabled = true;
51        return rule;
52    },
53
54    normalizeRules: function (rules) {
55        var self = this;
56        return (rules || []).map(function (r) { return self.normalizeRule(r); });
57    },
58
59    /* ====================================================================
60     *  INITIALISATION / DESTRUCTION
61     * ==================================================================== */
62
63    /**
64     * Initialise le moteur conditionnel sur un formulaire.
65     * 1. Masque les champs cibles d'actions "show" (etat par defaut = masque)
66     * 2. Ecoute les evenements input/change
67     * 3. Execute une premiere evaluation
68     *
69     * @param {HTMLElement} formEl  Element <form> ou conteneur
70     * @param {Array}       rules   Tableau de regles conditionnelles
71     */
72    init: function (formEl, rules) {
73        if (!formEl || !Array.isArray(rules)) return;
74
75        this._formEl = formEl;
76        this._rules  = rules;
77        this._rules = this.normalizeRules(rules);
78        rules = this._rules;
79
80        // Group rules by trigger
81        var byTrigger = { on_load: [], on_change: [], on_next_page: [], on_submit_unlock: [] };
82        rules.forEach(function (rule) {
83            var t = rule.trigger || 'on_change';
84            if (byTrigger[t]) byTrigger[t].push(rule);
85            else byTrigger.on_change.push(rule);
86        });
87        this._byTrigger = byTrigger;
88
89        // Combine on_load + on_change for initial default-hide of "show" targets
90        var initRules = byTrigger.on_load.concat(byTrigger.on_change);
91        initRules.forEach(function (rule) {
92            if (!rule.enabled) return;
93            (rule.actions || []).forEach(function (action) {
94                if (action.type === 'show') {
95                    var target = HobConditional.getTarget(action.target);
96                    if (target) {
97                        target.style.display = 'none';
98                        target.setAttribute('data-hidden', '1');
99                    }
100                }
101            });
102        });
103
104        // TRIGGER on_load: evaluate once immediately
105        if (byTrigger.on_load.length) {
106            this.evaluateForm(formEl, byTrigger.on_load);
107        }
108
109        // TRIGGER on_change: bind input/change events
110        var changeRules = byTrigger.on_change;
111        this._handler = function () {
112            HobConditional.evaluateForm(formEl, changeRules);
113            // Also re-evaluate submit unlock on every change
114            if (byTrigger.on_submit_unlock.length) {
115                HobConditional._evaluateSubmitUnlock(formEl, byTrigger.on_submit_unlock);
116            }
117        };
118        formEl.addEventListener('input',  this._handler);
119        formEl.addEventListener('change', this._handler);
120
121        // First evaluation of on_change rules
122        this.evaluateForm(formEl, changeRules);
123
124        // TRIGGER on_next_page: install guard on Next buttons
125        if (byTrigger.on_next_page.length) {
126            this._installNextPageGuard(formEl, byTrigger.on_next_page);
127        }
128
129        // TRIGGER on_submit_unlock: lock submit by default, evaluate
130        if (byTrigger.on_submit_unlock.length) {
131            this._lockSubmit(formEl);
132            this._evaluateSubmitUnlock(formEl, byTrigger.on_submit_unlock);
133        }
134    },
135
136    /**
137     * Detruit les ecouteurs et nettoie les references.
138     */
139    destroy: function () {
140        if (this._formEl && this._handler) {
141            this._formEl.removeEventListener('input',  this._handler);
142            this._formEl.removeEventListener('change', this._handler);
143        }
144        this._formEl  = null;
145        this._rules   = [];
146        this._handler = null;
147    },
148
149    /* ====================================================================
150     *  EVALUATION PRINCIPALE
151     * ==================================================================== */
152
153    /**
154     * Construit l'etat du formulaire puis evalue toutes les regles.
155     *
156     * @param {HTMLElement} formEl  Conteneur du formulaire
157     * @param {Array}       rules   Regles a evaluer
158     */
159    evaluateForm: function (formEl, rules) {
160        if (!formEl) return;
161
162        var state = {};
163        var wrappers = formEl.querySelectorAll('[data-field-id]');
164
165        wrappers.forEach(function (wrapper) {
166            var fieldId = wrapper.getAttribute('data-field-id');
167            var inputs  = wrapper.querySelectorAll('input, select, textarea');
168
169            if (!inputs.length) {
170                state[fieldId] = '';
171                return;
172            }
173
174            var first = inputs[0];
175
176            // Checkbox : tableau des valeurs cochees
177            if (first.type === 'checkbox') {
178                var checked = [];
179                inputs.forEach(function (cb) {
180                    if (cb.checked) checked.push(cb.value);
181                });
182                state[fieldId] = checked;
183                return;
184            }
185
186            // Radio : valeur selectionnee ou chaine vide
187            if (first.type === 'radio') {
188                var selected = '';
189                inputs.forEach(function (r) {
190                    if (r.checked) selected = r.value;
191                });
192                state[fieldId] = selected;
193                return;
194            }
195
196            // Autres (text, select, textarea, etc.)
197            state[fieldId] = first.value || '';
198        });
199
200        this.evaluate(state, rules);
201        this.resolvePiping(formEl, state);
202    },
203
204    /**
205     * Evalue chaque regle active et applique ou annule les actions.
206     *
207     * @param {Object} formState  Cle = field_id, Valeur = valeur courante
208     * @param {Array}  rules      Regles conditionnelles
209     */
210    evaluate: function (formState, rules) {
211        if (!Array.isArray(rules)) return;
212        var self = this;
213
214        // Two-pass: collect final action per target, then apply.
215        // Matched rules take priority; if no rule matches, last revert wins.
216        var finalActions = {};
217
218        rules.forEach(function (rule) {
219            if (!rule || !rule.enabled) return;
220
221            var matched = false;
222
223            // NEW: grouped conditions (v2 format)
224            if (rule.groups && Array.isArray(rule.groups)) {
225                var outerOp = (rule.group_operator || 'AND').toUpperCase();
226                var groupResults = rule.groups.map(function (g) {
227                    return self._evaluateGroup(g, formState);
228                });
229                matched = outerOp === 'OR'
230                    ? groupResults.some(Boolean)
231                    : groupResults.every(Boolean);
232            } else {
233                // OLD: flat conditions with match:"all"|"any"
234                var conditions = rule.conditions || [];
235                if (rule.match === 'any') {
236                    matched = conditions.some(function (cond) {
237                        return self.testCondition(cond, formState);
238                    });
239                } else {
240                    matched = conditions.every(function (cond) {
241                        return self.testCondition(cond, formState);
242                    });
243                }
244            }
245
246            // Record intended action per target — matched rules always win
247            (rule.actions || []).forEach(function (action) {
248                var key = (action.target || '') + '::' + (action.type || '');
249                if (matched) {
250                    finalActions[key] = { action: action, matched: true };
251                } else if (!finalActions[key] || !finalActions[key].matched) {
252                    finalActions[key] = { action: action, matched: false };
253                }
254            });
255        });
256
257        // Apply resolved decisions
258        var keys = Object.keys(finalActions);
259        for (var i = 0; i < keys.length; i++) {
260            var entry = finalActions[keys[i]];
261            if (entry.matched) {
262                self.applyAction(entry.action, formState);
263            } else {
264                self.revertAction(entry.action);
265            }
266        }
267    },
268
269    /* ====================================================================
270     *  TEST D'UNE CONDITION
271     * ==================================================================== */
272
273    /**
274     * Teste une condition unique contre l'etat du formulaire.
275     *
276     * @param  {Object}  cond   { field_id, operator, value }
277     * @param  {Object}  state  Etat courant du formulaire
278     * @return {boolean}
279     */
280    testCondition: function (cond, state) {
281        if (!cond || !cond.field_id) return false;
282
283        var fieldVal  = state[cond.field_id];
284        var expected  = cond.value;
285
286        // Normaliser : null/undefined → chaine vide
287        if (fieldVal === null || fieldVal === undefined) fieldVal = '';
288        var strVal = Array.isArray(fieldVal) ? fieldVal.join(',') : String(fieldVal).trim();
289
290        switch (cond.operator) {
291
292            /* --- Egalite / Inegalite --- */
293            case 'equals':
294                return strVal === String(expected || '');
295
296            case 'not_equals':
297                return strVal !== String(expected || '');
298
299            /* --- Contenu --- */
300            case 'contains':
301                return strVal.indexOf(String(expected || '')) !== -1;
302
303            case 'not_contains':
304                return strVal.indexOf(String(expected || '')) === -1;
305
306            case 'starts_with':
307                return strVal.indexOf(String(expected || '')) === 0;
308
309            case 'ends_with': {
310                var suffix = String(expected || '');
311                return suffix.length > 0 && strVal.slice(-suffix.length) === suffix;
312            }
313
314            /* --- Vide / Non-vide --- */
315            case 'is_empty':
316                return strVal.trim() === '' || (Array.isArray(fieldVal) && fieldVal.length === 0);
317
318            case 'is_not_empty':
319                return strVal.trim() !== '' && !(Array.isArray(fieldVal) && fieldVal.length === 0);
320
321            /* --- Comparaisons numeriques / dates --- */
322            case 'greater_than':
323                return this._compare(fieldVal, expected) > 0;
324
325            case 'less_than':
326                return this._compare(fieldVal, expected) < 0;
327
328            case 'greater_or_equal':
329                return this._compare(fieldVal, expected) >= 0;
330
331            case 'less_or_equal':
332                return this._compare(fieldVal, expected) <= 0;
333
334            /* --- Plage (between) --- */
335            case 'between': {
336                var range = this._parseRange(expected);
337                if (!range) return false;
338                var num = parseFloat(strVal);
339                return !isNaN(num) && num >= range[0] && num <= range[1];
340            }
341
342            /* --- Listes --- */
343            case 'in_list': {
344                var list = this._toArray(expected);
345                return list.indexOf(strVal) !== -1;
346            }
347
348            case 'not_in_list': {
349                var listN = this._toArray(expected);
350                return listN.indexOf(strVal) === -1;
351            }
352
353            /* --- Regex --- */
354            case 'matches_regex':
355                try {
356                    return new RegExp(expected).test(strVal);
357                } catch (e) {
358                    return false;
359                }
360
361            /* --- Cases a cocher (booleen) --- */
362            case 'checked':
363                return this._isTruthy(fieldVal);
364
365            case 'not_checked':
366                return !this._isTruthy(fieldVal);
367
368            /* --- Multi-checkbox : contient au moins un / tous --- */
369            case 'contains_any': {
370                var arrField = Array.isArray(fieldVal) ? fieldVal : [strVal];
371                var arrExp   = this._toArray(expected);
372                return arrExp.some(function (v) { return arrField.indexOf(v) !== -1; });
373            }
374
375            case 'contains_all': {
376                var arrF = Array.isArray(fieldVal) ? fieldVal : [strVal];
377                var arrE = this._toArray(expected);
378                return arrE.every(function (v) { return arrF.indexOf(v) !== -1; });
379            }
380
381            default:
382                return false;
383        }
384    },
385
386    /* ====================================================================
387     *  APPLICATION / ANNULATION DES ACTIONS
388     * ==================================================================== */
389
390    /**
391     * Applique une action sur le DOM.
392     *
393     * @param {Object} action  { type, target, value?, source? }
394     * @param {Object} state   Etat courant du formulaire
395     */
396    applyAction: function (action, state) {
397        if (!action || !action.type) return;
398
399        var target  = this.getTarget(action.target);
400        var inputs;
401
402        switch (action.type) {
403
404            /* --- Visibilite --- */
405            case 'show':
406                if (target) {
407                    target.style.display = '';
408                    target.removeAttribute('data-hidden');
409                    target.classList.remove('hob-fc-field-hidden');
410                    inputs = target.querySelectorAll('input, select, textarea');
411                    inputs.forEach(function (el) { el.disabled = false; });
412                }
413                break;
414
415            case 'hide':
416                if (target) {
417                    target.style.display = 'none';
418                    target.setAttribute('data-hidden', '1');
419                    target.classList.add('hob-fc-field-hidden');
420                    inputs = target.querySelectorAll('input, select, textarea');
421                    inputs.forEach(function (el) {
422                        el.disabled = true;
423                        el.removeAttribute('required');
424                    });
425                }
426                break;
427
428            /* --- Obligatoire --- */
429            case 'require':
430                if (target) {
431                    inputs = target.querySelectorAll('input, select, textarea');
432                    inputs.forEach(function (el) { el.setAttribute('required', 'required'); });
433                    // Add visual star indicator
434                    var label = target.querySelector('.hob-fc-label, .hob-fc-field-label');
435                    if (label && !label.querySelector('.hob-fc-req-star')) {
436                        var star = document.createElement('span');
437                        star.className = 'hob-fc-req-star';
438                        star.textContent = ' *';
439                        star.style.color = '#DC2626';
440                        label.appendChild(star);
441                    }
442                }
443                break;
444
445            case 'unrequire':
446                if (target) {
447                    inputs = target.querySelectorAll('input, select, textarea');
448                    inputs.forEach(function (el) { el.removeAttribute('required'); });
449                }
450                break;
451
452            /* --- Actif / Desactive --- */
453            case 'enable':
454                if (target) {
455                    inputs = target.querySelectorAll('input, select, textarea, button');
456                    inputs.forEach(function (el) { el.removeAttribute('disabled'); });
457                }
458                break;
459
460            case 'disable':
461                if (target) {
462                    inputs = target.querySelectorAll('input, select, textarea, button');
463                    inputs.forEach(function (el) { el.setAttribute('disabled', 'disabled'); });
464                }
465                break;
466
467            /* --- Valeurs --- */
468            case 'set_value':
469                if (target) {
470                    var resolved = this.resolveValue(action.value || '', state);
471                    inputs = target.querySelectorAll('input, select, textarea');
472                    inputs.forEach(function (el) {
473                        el.value = resolved;
474                        el.dispatchEvent(new Event('input', { bubbles: true }));
475                    });
476                }
477                break;
478
479            case 'clear_value':
480                if (target) {
481                    inputs = target.querySelectorAll('input, select, textarea');
482                    inputs.forEach(function (el) {
483                        if (el.type === 'checkbox' || el.type === 'radio') {
484                            el.checked = false;
485                        } else {
486                            el.value = '';
487                        }
488                    });
489                }
490                break;
491
492            /* --- Calcul --- */
493            case 'calculate':
494                if (target) {
495                    target.dispatchEvent(new CustomEvent('hob:recalculate', { bubbles: true }));
496                }
497                break;
498
499            /* --- Classes CSS --- */
500            case 'add_class':
501                if (target && action.value) {
502                    target.classList.add(action.value);
503                }
504                break;
505
506            case 'remove_class':
507                if (target && action.value) {
508                    target.classList.remove(action.value);
509                }
510                break;
511
512            /* --- Navigation multi-pages --- */
513            case 'jump_to_page':
514                if (window.hobMultipage && typeof window.hobMultipage.goTo === 'function') {
515                    window.hobMultipage.goTo(parseInt(action.value, 10));
516                }
517                break;
518
519            case 'skip_to_page':
520                if (window.hobMultipage && typeof window.hobMultipage.goTo === 'function') {
521                    if (window.hobMultipage.setSkip) {
522                        window.hobMultipage.setSkip(true);
523                    }
524                    window.hobMultipage.goTo(parseInt(action.value, 10));
525                }
526                break;
527
528            /* --- Notification AJAX --- */
529            case 'send_notification':
530                if (typeof jQuery !== 'undefined' && typeof hobFcAjax !== 'undefined') {
531                    jQuery.post(hobFcAjax.ajaxUrl, {
532                        action:   'hob_fc_send_notification',
533                        nonce:    hobFcAjax.nonce || '',
534                        target:   action.target,
535                        value:    action.value || '',
536                        _source:  'conditional'
537                    });
538                }
539                break;
540
541            /* --- Redirection --- */
542            case 'redirect':
543                if (action.value) {
544                    window.location.href = action.value;
545                }
546                break;
547
548            /* --- Peupler les options d'un select --- */
549            case 'populate_options':
550                this.populateOptions(action.target, action.value, state);
551                break;
552
553            /* --- Copier la valeur d'un champ vers un autre --- */
554            case 'copy_value': {
555                var sourceVal = (action.source && state[action.source] !== undefined)
556                    ? state[action.source]
557                    : '';
558                var destTarget = this.getTarget(action.target);
559                if (destTarget) {
560                    var destInputs = destTarget.querySelectorAll('input, select, textarea');
561                    var val = Array.isArray(sourceVal) ? sourceVal.join(', ') : String(sourceVal);
562                    destInputs.forEach(function (el) {
563                        el.value = val;
564                        el.dispatchEvent(new Event('input', { bubbles: true }));
565                    });
566                }
567                break;
568            }
569
570            /* --- Message en ligne --- */
571            case 'show_message': {
572                var msgEl = document.getElementById('hob-cond-msg-' + action.target);
573                if (msgEl) {
574                    msgEl.style.display = '';
575                    if (action.value) msgEl.textContent = action.value;
576                }
577                break;
578            }
579
580            /* --- Validation personnalisee AJAX --- */
581            case 'validate_custom':
582                if (typeof jQuery !== 'undefined' && typeof hobFcAjax !== 'undefined') {
583                    jQuery.post(hobFcAjax.ajaxUrl, {
584                        action:   'hob_fc_validate_custom',
585                        nonce:    hobFcAjax.nonce || '',
586                        field_id: action.target,
587                        value:    state[action.target] || '',
588                        rule:     action.value || ''
589                    });
590                }
591                break;
592
593            /* --- Soumission du formulaire --- */
594            case 'submit_form':
595                if (this._formEl) {
596                    this._formEl.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
597                }
598                break;
599
600            /* --- Bouton Envoyer --- */
601            case 'hide_submit': {
602                var submitWrap = (HobConditional._formEl || document).querySelector('.hob-fc-submit-wrap, [data-submit-wrap]');
603                if (submitWrap) {
604                    submitWrap.style.display = 'none';
605                    var btn = submitWrap.querySelector('button[type="submit"], input[type="submit"]');
606                    if (btn) btn.disabled = true;
607                }
608                break;
609            }
610
611            case 'show_submit': {
612                var submitWrap2 = (HobConditional._formEl || document).querySelector('.hob-fc-submit-wrap, [data-submit-wrap]');
613                if (submitWrap2) {
614                    submitWrap2.style.display = '';
615                    var btn2 = submitWrap2.querySelector('button[type="submit"], input[type="submit"]');
616                    if (btn2) btn2.disabled = false;
617                }
618                break;
619            }
620
621            default:
622                break;
623        }
624    },
625
626    /**
627     * Annule une action (uniquement show/hide sont reversibles automatiquement).
628     *
629     * @param {Object} action  Action a annuler
630     */
631    revertAction: function (action) {
632        if (!action || !action.type) return;
633
634        var target = this.getTarget(action.target);
635        if (!target) return;
636
637        var inputs;
638
639        switch (action.type) {
640            case 'show':
641                // L'inverse de show = masquer
642                target.style.display = 'none';
643                target.setAttribute('data-hidden', '1');
644                target.classList.add('hob-fc-field-hidden');
645                inputs = target.querySelectorAll('input, select, textarea');
646                inputs.forEach(function (el) {
647                    el.disabled = true;
648                    el.removeAttribute('required');
649                });
650                break;
651
652            case 'hide':
653                // L'inverse de hide = afficher
654                target.style.display = '';
655                target.removeAttribute('data-hidden');
656                target.classList.remove('hob-fc-field-hidden');
657                inputs = target.querySelectorAll('input, select, textarea');
658                inputs.forEach(function (el) { el.disabled = false; });
659                break;
660
661            case 'require':
662                // Revert: unrequire
663                if (target) {
664                    var inputs = target.querySelectorAll('input, select, textarea');
665                    inputs.forEach(function (el) { el.removeAttribute('required'); });
666                    var star = target.querySelector('.hob-fc-req-star');
667                    if (star) star.remove();
668                }
669                break;
670
671            case 'unrequire':
672                // Revert: restore require (only if field was originally required)
673                break;
674
675            case 'hide_submit': {
676                var sw = (HobConditional._formEl || document).querySelector('.hob-fc-submit-wrap, [data-submit-wrap]');
677                if (sw) {
678                    sw.style.display = '';
679                    var b = sw.querySelector('button[type="submit"], input[type="submit"]');
680                    if (b) b.disabled = false;
681                }
682                break;
683            }
684
685            case 'show_submit': {
686                // Revert show_submit = hide submit
687                var sw2 = (HobConditional._formEl || document).querySelector('.hob-fc-submit-wrap, [data-submit-wrap]');
688                if (sw2) {
689                    sw2.style.display = 'none';
690                    var b2 = sw2.querySelector('button[type="submit"], input[type="submit"]');
691                    if (b2) b2.disabled = true;
692                }
693                break;
694            }
695
696            // Les autres actions ne sont pas automatiquement reversibles
697            default:
698                break;
699        }
700    },
701
702    /* ====================================================================
703     *  UTILITAIRES
704     * ==================================================================== */
705
706    /**
707     * Evalue un groupe de conditions.
708     * @param {Object} group  { operator: "AND"|"OR", conditions: [...] }
709     * @param {Object} state  Etat du formulaire
710     * @return {boolean}
711     */
712    _evaluateGroup: function (group, state) {
713        var self = this;
714        var op = (group.operator || 'AND').toUpperCase();
715        var conditions = group.conditions || [];
716        var results = conditions.map(function (cond) {
717            return self.testCondition(cond, state);
718        });
719        return op === 'OR'
720            ? results.some(Boolean)
721            : results.every(Boolean);
722    },
723
724    /**
725     * Résout le piping {field:xxx} dans les labels et textes du formulaire.
726     * @param {HTMLElement} formEl
727     * @param {Object}      state   Etat courant du formulaire
728     */
729    resolvePiping: function (formEl, state) {
730        if (!formEl) return;
731        formEl.querySelectorAll('[data-pipe-template]').forEach(function (el) {
732            el.textContent = el.getAttribute('data-pipe-template').replace(
733                /\{field:([^}]+)\}/g,
734                function (match, fieldId) {
735                    var val = state[fieldId];
736                    if (val === undefined || val === null) return '';
737                    return Array.isArray(val) ? val.join(', ') : String(val);
738                }
739            );
740        });
741    },
742
743    /**
744     * Retourne l'element wrapper d'un champ par son data-field-id.
745     *
746     * @param  {string}       id  Identifiant du champ
747     * @return {HTMLElement|null}
748     */
749    getTarget: function (id) {
750        if (!id) return null;
751        return document.querySelector('[data-field-id="' + id + '"]');
752    },
753
754    /**
755     * Remplace les references {field_id} dans un template par les valeurs du state.
756     *
757     * @param  {string} template  Chaine contenant des placeholders {field_id}
758     * @param  {Object} state     Etat courant du formulaire
759     * @return {string}
760     */
761    resolveValue: function (template, state) {
762        if (!template || typeof template !== 'string') return template || '';
763
764        return template.replace(/\{([^}]+)\}/g, function (match, fieldId) {
765            var val = state[fieldId];
766            if (val === undefined || val === null) return '';
767            return Array.isArray(val) ? val.join(', ') : String(val);
768        });
769    },
770
771    /**
772     * Peuple les options d'un select a partir d'un tableau ou d'une URL.
773     * Si sourceData est un tableau, les options sont ajoutees directement.
774     * Si c'est une URL (chaine), on fait un fetch JSON.
775     *
776     * Format attendu du JSON : [{ value: "...", label: "..." }, ...]
777     *
778     * @param {string}       targetFieldId  ID du champ select cible
779     * @param {Array|string} sourceData     Tableau d'options ou URL
780     * @param {Object}       state          Etat courant (pour resoudre d'eventuels placeholders)
781     */
782    populateOptions: function (targetFieldId, sourceData, state) {
783        var self   = this;
784        var target = this.getTarget(targetFieldId);
785        if (!target) return;
786
787        var select = target.querySelector('select');
788        if (!select) return;
789
790        /**
791         * Reconstruit les <option> du select.
792         * @param {Array} options  [{ value, label }, ...]
793         */
794        var rebuild = function (options) {
795            // Conserver le placeholder eventuel
796            var placeholder = select.querySelector('option[value=""]');
797            select.innerHTML = '';
798            if (placeholder) select.appendChild(placeholder);
799
800            (options || []).forEach(function (opt) {
801                var o   = document.createElement('option');
802                o.value = opt.value !== undefined ? opt.value : opt;
803                o.textContent = opt.label || opt.text || o.value;
804                select.appendChild(o);
805            });
806        };
807
808        // Tableau fourni directement
809        if (Array.isArray(sourceData)) {
810            rebuild(sourceData);
811            return;
812        }
813
814        // URL a interroger en asynchrone
815        if (typeof sourceData === 'string' && sourceData.length > 0) {
816            var url = self.resolveValue(sourceData, state || {});
817
818            fetch(url, { credentials: 'same-origin' })
819                .then(function (resp) {
820                    if (!resp.ok) throw new Error('Erreur HTTP ' + resp.status);
821                    return resp.json();
822                })
823                .then(function (data) {
824                    rebuild(Array.isArray(data) ? data : []);
825                })
826                .catch(function (err) {
827                    // En cas d'erreur, on vide les options
828                    rebuild([]);
829                    if (typeof console !== 'undefined') {
830                        console.warn('[HobConditional] populateOptions — echec du fetch :', err);
831                    }
832                });
833        }
834    },
835
836    /* ====================================================================
837     *  METHODES INTERNES (PRIVEES PAR CONVENTION)
838     * ==================================================================== */
839
840    /**
841     * Compare deux valeurs (numeriques ou dates).
842     * Retourne un nombre negatif, zero ou positif (similaire a strcmp).
843     *
844     * @param  {*} a  Valeur du champ
845     * @param  {*} b  Valeur de reference
846     * @return {number}
847     */
848    _compare: function (a, b) {
849        var na = parseFloat(a);
850        var nb = parseFloat(b);
851
852        // Si les deux sont des nombres valides, comparaison numerique
853        if (!isNaN(na) && !isNaN(nb)) return na - nb;
854
855        // Tenter une comparaison de dates
856        var da = new Date(a);
857        var db = new Date(b);
858        if (!isNaN(da.getTime()) && !isNaN(db.getTime())) return da.getTime() - db.getTime();
859
860        // Comparaison par defaut en chaines
861        return String(a || '').localeCompare(String(b || ''));
862    },
863
864    /**
865     * Parse une plage "min,max" ou [min, max].
866     *
867     * @param  {*}          val  Valeur brute
868     * @return {Array|null}      [min, max] ou null
869     */
870    _parseRange: function (val) {
871        if (Array.isArray(val) && val.length >= 2) {
872            return [parseFloat(val[0]), parseFloat(val[1])];
873        }
874        if (typeof val === 'string') {
875            var parts = val.split(',');
876            if (parts.length >= 2) {
877                var min = parseFloat(parts[0].trim());
878                var max = parseFloat(parts[1].trim());
879                if (!isNaN(min) && !isNaN(max)) return [min, max];
880            }
881        }
882        return null;
883    },
884
885    /**
886     * Convertit une valeur en tableau (accepte tableau ou chaine separee par des virgules).
887     *
888     * @param  {*}     val
889     * @return {Array}
890     */
891    _toArray: function (val) {
892        if (Array.isArray(val)) return val;
893        if (typeof val === 'string') {
894            return val.split(',').map(function (s) { return s.trim(); });
895        }
896        return [];
897    },
898
899    /**
900     * Verifie si une valeur est "cochee" / truthy.
901     * Faux pour : "", "0", "false", false, null, undefined, tableau vide.
902     *
903     * @param  {*}       val
904     * @return {boolean}
905     */
906    _isTruthy: function (val) {
907        if (val === null || val === undefined) return false;
908        if (Array.isArray(val)) return val.length > 0;
909        var s = String(val).trim().toLowerCase();
910        return s !== '' && s !== '0' && s !== 'false';
911    },
912
913    /* ====================================================================
914     *  TRIGGER on_next_page
915     * ==================================================================== */
916
917    _installNextPageGuard: function (formEl, rules) {
918        var self = this;
919        var nextBtns = formEl.querySelectorAll('.hob-fc-next-btn, .hob-fc-obo__next');
920        nextBtns.forEach(function (btn) {
921            btn.addEventListener('click', function (e) {
922                var state = {};
923                self._collectState(formEl, state);
924                var failures = [];
925
926                rules.forEach(function (rule) {
927                    if (!rule.enabled) return;
928                    var matched = self._evaluateConditions(rule, state);
929                    (rule.actions || []).forEach(function (action) {
930                        if (action.type === 'block_next' && matched) {
931                            failures.push(action.message || rule.label || 'Condition non remplie.');
932                        }
933                    });
934                });
935
936                if (failures.length) {
937                    e.preventDefault();
938                    e.stopImmediatePropagation();
939                    var msgZone = btn.parentNode.querySelector('.hob-fc-next-block-msg');
940                    if (!msgZone) {
941                        msgZone = document.createElement('div');
942                        msgZone.className = 'hob-fc-next-block-msg';
943                        btn.insertAdjacentElement('afterend', msgZone);
944                    }
945                    msgZone.innerHTML = failures.map(function (m) {
946                        return '<p>' + m + '</p>';
947                    }).join('');
948                    msgZone.style.display = 'block';
949                    btn.classList.add('hob-fc-btn-blocked');
950                    setTimeout(function () { btn.classList.remove('hob-fc-btn-blocked'); }, 600);
951                    msgZone.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
952                } else {
953                    var existing = btn.parentNode.querySelector('.hob-fc-next-block-msg');
954                    if (existing) { existing.style.display = 'none'; existing.innerHTML = ''; }
955                }
956            }, true); // capture phase to fire before default handler
957        });
958    },
959
960    /* ====================================================================
961     *  TRIGGER on_submit_unlock
962     * ==================================================================== */
963
964    _lockSubmit: function (formEl) {
965        var btn = formEl.querySelector('button[type=submit], input[type=submit], .hob-fc-submit-btn');
966        if (!btn) return;
967        btn.disabled = true;
968        btn.classList.add('hob-fc-submit-locked');
969        btn.dataset.originalText = btn.dataset.originalText || btn.textContent;
970    },
971
972    _unlockSubmit: function (formEl) {
973        var btn = formEl.querySelector('button[type=submit], input[type=submit], .hob-fc-submit-btn');
974        if (!btn) return;
975        btn.disabled = false;
976        btn.classList.remove('hob-fc-submit-locked');
977        if (btn.dataset.originalText) btn.textContent = btn.dataset.originalText;
978        var msgZone = formEl.querySelector('.hob-fc-submit-unlock-msg');
979        if (msgZone) { msgZone.style.display = 'none'; msgZone.innerHTML = ''; }
980    },
981
982    _evaluateSubmitUnlock: function (formEl, rules) {
983        if (!rules || !rules.length) return;
984        var self = this;
985        var state = {};
986        self._collectState(formEl, state);
987        var unmetMsgs = [];
988
989        rules.forEach(function (rule) {
990            if (!rule.enabled) return;
991            var matched = self._evaluateConditions(rule, state);
992            (rule.actions || []).forEach(function (action) {
993                if (action.type !== 'unlock_submit') return;
994                if (!matched) {
995                    unmetMsgs.push(action.message || rule.label || 'Condition requise.');
996                }
997            });
998        });
999
1000        var btn = formEl.querySelector('button[type=submit], input[type=submit], .hob-fc-submit-btn');
1001        if (!btn) return;
1002        var msgZone = formEl.querySelector('.hob-fc-submit-unlock-msg');
1003        if (!msgZone) {
1004            msgZone = document.createElement('ul');
1005            msgZone.className = 'hob-fc-submit-unlock-msg';
1006            msgZone.style.display = 'none';
1007            btn.insertAdjacentElement('beforebegin', msgZone);
1008        }
1009
1010        if (unmetMsgs.length) {
1011            self._lockSubmit(formEl);
1012            msgZone.innerHTML = unmetMsgs.map(function (m) {
1013                return '<li class="hob-fc-unlock-item"><span class="hob-fc-unlock-icon">○</span> ' + m + '</li>';
1014            }).join('');
1015            msgZone.style.display = 'block';
1016        } else {
1017            self._unlockSubmit(formEl);
1018        }
1019    },
1020
1021    /* ====================================================================
1022     *  HELPERS — shared state collection & condition evaluation
1023     * ==================================================================== */
1024
1025    _collectState: function (formEl, state) {
1026        var wrappers = formEl.querySelectorAll('[data-field-id]');
1027        wrappers.forEach(function (wrapper) {
1028            var fieldId = wrapper.getAttribute('data-field-id');
1029            if (state.hasOwnProperty(fieldId)) return; // skip duplicates
1030            var inputs = wrapper.querySelectorAll('input, select, textarea');
1031            if (!inputs.length) { state[fieldId] = ''; return; }
1032            var first = inputs[0];
1033            if (first.type === 'checkbox') {
1034                var checked = [];
1035                inputs.forEach(function (cb) { if (cb.checked) checked.push(cb.value); });
1036                state[fieldId] = checked;
1037            } else if (first.type === 'radio') {
1038                var sel = '';
1039                inputs.forEach(function (r) { if (r.checked) sel = r.value; });
1040                state[fieldId] = sel;
1041            } else {
1042                state[fieldId] = first.value || '';
1043            }
1044        });
1045    },
1046
1047    _evaluateConditions: function (rule, state) {
1048        var self = this;
1049        var matched = false;
1050        if (rule.groups && Array.isArray(rule.groups)) {
1051            var outerOp = (rule.group_operator || 'AND').toUpperCase();
1052            var groupResults = rule.groups.map(function (g) {
1053                return self._evaluateGroup(g, state);
1054            });
1055            matched = outerOp === 'OR' ? groupResults.some(Boolean) : groupResults.every(Boolean);
1056        } else if (rule.conditions) {
1057            var conditions = rule.conditions;
1058            if (rule.match === 'any') {
1059                matched = conditions.some(function (c) { return self.testCondition(c, state); });
1060            } else {
1061                matched = conditions.every(function (c) { return self.testCondition(c, state); });
1062            }
1063        }
1064        return matched;
1065    }
1066};
1067
1068/* Exposition globale */
1069window.HobConditional = HobConditional;

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.