@fidusia/question-engine-dynamic 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/engine.js ADDED
@@ -0,0 +1,1243 @@
1
+ // =====================================================
2
+ // QUESTION ENGINE - MOTEUR PRINCIPAL
3
+ // Gère la queue, la navigation et les triggers
4
+ // Version avec configuration dynamique complète
5
+ // =====================================================
6
+ import { loadAllAIQuestionsForSector, convertAIQuestionToModuleQuestion, checkConditions } from "./ai-questions-loader";
7
+ import { getPriorityMappings, getBookedProviderMappings, getPriorityBoosts, getSystemIds, getPriorityToBookedProviders, getPriorityCalculation, getSpecialBehaviors } from './config-loader';
8
+ // Import conditionnel pour la version serveur (si disponible)
9
+ // Utiliser une fonction helper pour charger la version serveur
10
+ // Note: Les fichiers *-server.ts doivent être fournis par le projet consommateur
11
+ async function getServerLoader() {
12
+ // Vérifier si on est côté client (browser)
13
+ // Utiliser une vérification qui fonctionne à la fois en TypeScript et à l'exécution
14
+ const isClient = typeof globalThis !== 'undefined' && 'window' in globalThis;
15
+ if (isClient) {
16
+ // Côté client, ne pas essayer d'importer le module serveur
17
+ return null;
18
+ }
19
+ try {
20
+ // Utiliser une chaîne construite dynamiquement pour éviter l'analyse statique de Next.js
21
+ // Cela empêche Next.js d'essayer de résoudre le module au moment du bundling
22
+ // En construisant le chemin avec des variables, Next.js ne peut pas l'analyser statiquement
23
+ const parts = ['./ai-questions-loader', '-server'];
24
+ const serverModulePath = parts.join('');
25
+ // Utiliser eval pour créer un import vraiment dynamique
26
+ // qui ne peut pas être analysé statiquement par Next.js
27
+ // eslint-disable-next-line no-eval
28
+ const serverModule = await eval(`import('${serverModulePath}')`).catch(() => null);
29
+ if (serverModule && typeof serverModule.loadAllAIQuestionsForSectorServer === 'function') {
30
+ return serverModule.loadAllAIQuestionsForSectorServer;
31
+ }
32
+ return null;
33
+ }
34
+ catch (_a) {
35
+ // Version serveur non disponible, retourner null
36
+ return null;
37
+ }
38
+ }
39
+ export class QuestionEngine {
40
+ constructor(i18n) {
41
+ this.modules = new Map();
42
+ this.listeners = new Set();
43
+ this.advancedByTrigger = false; // Flag pour éviter double avancement
44
+ this.aiQuestionsCache = new Map(); // Cache de toutes les questions IA chargées
45
+ this.config = {}; // Configuration du chatbot
46
+ this.chatbotId = undefined; // ID du chatbot pour filtrer les questions IA
47
+ this.i18n = i18n;
48
+ this.state = this.createInitialState();
49
+ }
50
+ /**
51
+ * Définit l'ID du chatbot pour filtrer les questions IA
52
+ */
53
+ setChatbotId(chatbotId) {
54
+ this.chatbotId = chatbotId;
55
+ }
56
+ // =====================================================
57
+ // INITIALISATION
58
+ // =====================================================
59
+ createInitialState() {
60
+ return {
61
+ queue: [],
62
+ currentModuleId: null,
63
+ currentQuestionId: null,
64
+ completedModules: [],
65
+ hiddenModules: [],
66
+ skippedModules: [],
67
+ formData: {},
68
+ answeredQuestions: [],
69
+ isComplete: false,
70
+ transitionInfo: null,
71
+ };
72
+ }
73
+ registerModule(module) {
74
+ this.modules.set(module.id, module);
75
+ }
76
+ registerModules(modules) {
77
+ modules.forEach((m) => this.registerModule(m));
78
+ }
79
+ /**
80
+ * Définit la configuration du chatbot
81
+ * Doit être appelée avant initialize() pour que la configuration soit utilisée
82
+ */
83
+ setConfig(config) {
84
+ this.config = config;
85
+ }
86
+ async initialize() {
87
+ this.state = this.createInitialState();
88
+ this.buildQueue();
89
+ // Charger toutes les questions IA une seule fois pour tous les secteurs
90
+ await this.loadAllAIQuestions();
91
+ // Injecter les questions IA qui correspondent aux conditions initiales
92
+ this.injectAIQuestionsFromCache();
93
+ // Activer la première question
94
+ this.activateFirstQuestion();
95
+ this.notifyListeners();
96
+ }
97
+ reset() {
98
+ this.initialize();
99
+ }
100
+ // Restaurer l'état depuis localStorage (pour la persistance de session)
101
+ async restoreState(savedState) {
102
+ // Sauvegarder la question actuelle AVANT la restauration
103
+ const savedCurrentQuestionId = savedState.currentQuestionId;
104
+ const savedCurrentModuleId = savedState.currentModuleId;
105
+ this.state = Object.assign({}, savedState);
106
+ // Reconstruire les références des modules dans la queue
107
+ this.state.queue.forEach((queuedModule) => {
108
+ const module = this.modules.get(queuedModule.moduleId);
109
+ if (module) {
110
+ // S'assurer que les questions du module sont synchronisées
111
+ queuedModule.questions.forEach((q) => {
112
+ const moduleQuestion = module.questions.find((mq) => mq.id === q.questionId);
113
+ if (moduleQuestion) {
114
+ q.text = moduleQuestion.text;
115
+ q.type = moduleQuestion.type;
116
+ }
117
+ });
118
+ }
119
+ });
120
+ // IMPORTANT: Recharger les questions IA et les réinjecter dans la queue
121
+ // car elles peuvent avoir changé ou de nouvelles conditions peuvent être remplies
122
+ await this.loadAllAIQuestions();
123
+ this.injectAIQuestionsFromCache();
124
+ // IMPORTANT: Restaurer la question actuelle après l'injection des questions IA
125
+ // pour éviter que l'injection ne change la question actuelle
126
+ if (savedCurrentQuestionId && savedCurrentModuleId) {
127
+ // Vérifier que la question existe toujours dans la queue
128
+ const queuedModule = this.state.queue.find(m => m.moduleId === savedCurrentModuleId);
129
+ if (queuedModule) {
130
+ const queuedQuestion = queuedModule.questions.find(q => q.questionId === savedCurrentQuestionId);
131
+ if (queuedQuestion) {
132
+ // La question existe toujours, restaurer la question actuelle
133
+ this.state.currentQuestionId = savedCurrentQuestionId;
134
+ this.state.currentModuleId = savedCurrentModuleId;
135
+ }
136
+ }
137
+ }
138
+ this.notifyListeners();
139
+ }
140
+ // =====================================================
141
+ // CONSTRUCTION DE LA QUEUE
142
+ // =====================================================
143
+ buildQueue() {
144
+ const queuedModules = [];
145
+ this.modules.forEach((module) => {
146
+ // Vérifier les triggers au niveau module
147
+ if (this.shouldHideModule(module)) {
148
+ this.state.hiddenModules.push(module.id);
149
+ return;
150
+ }
151
+ const queuedModule = {
152
+ moduleId: module.id,
153
+ moduleName: module.name,
154
+ effectivePriority: module.basePriority,
155
+ status: "pending",
156
+ questions: this.buildModuleQuestions(module),
157
+ };
158
+ queuedModules.push(queuedModule);
159
+ });
160
+ // Trier par priorité effective
161
+ queuedModules.sort((a, b) => a.effectivePriority - b.effectivePriority);
162
+ this.state.queue = queuedModules;
163
+ }
164
+ buildModuleQuestions(module) {
165
+ // Exclure les questions IA lors de la construction initiale de la queue
166
+ // Elles seront ré-injectées dynamiquement depuis les métadonnées
167
+ return module.questions
168
+ .filter((q) => !q.aiQuestionMetadata) // Exclure les questions IA
169
+ .map((q) => ({
170
+ questionId: q.id,
171
+ moduleId: module.id,
172
+ text: q.text,
173
+ type: q.type,
174
+ status: "pending",
175
+ }));
176
+ }
177
+ /**
178
+ * Charge toutes les questions IA pour tous les secteurs une seule fois
179
+ * Utilise la version serveur si disponible (API routes), sinon la version fetch (client)
180
+ * OU extrait depuis les métadonnées des modules si disponibles (pour JSON)
181
+ */
182
+ async loadAllAIQuestions() {
183
+ // D'abord, essayer d'extraire les questions IA depuis les métadonnées des modules (pour JSON)
184
+ const aiQuestionsFromMetadata = this.extractAIQuestionsFromModules();
185
+ if (aiQuestionsFromMetadata.size > 0) {
186
+ // Utiliser les questions IA extraites depuis les métadonnées
187
+ aiQuestionsFromMetadata.forEach((questions, sectorId) => {
188
+ this.aiQuestionsCache.set(sectorId, questions);
189
+ });
190
+ return;
191
+ }
192
+ // Sinon, charger depuis l'API (comportement normal)
193
+ // Essayer de charger la version serveur (disponible uniquement côté serveur)
194
+ const serverLoader = await getServerLoader();
195
+ // Parcourir tous les modules dans la queue pour charger leurs questions IA
196
+ const loadPromises = this.state.queue.map(async (queuedModule) => {
197
+ // Utiliser la version serveur si disponible (côté serveur), sinon la version fetch (côté client)
198
+ const loader = serverLoader || loadAllAIQuestionsForSector;
199
+ // Passer le chatbot_id au loader
200
+ const aiQuestions = await loader(queuedModule.moduleId, this.chatbotId);
201
+ this.aiQuestionsCache.set(queuedModule.moduleId, aiQuestions);
202
+ });
203
+ await Promise.all(loadPromises);
204
+ }
205
+ /**
206
+ * Extrait les questions IA depuis les métadonnées des modules (pour import JSON)
207
+ * Retourne une Map<sectorId, AIQuestion[]>
208
+ */
209
+ extractAIQuestionsFromModules() {
210
+ const aiQuestionsMap = new Map();
211
+ // Parcourir tous les modules enregistrés
212
+ for (const [moduleId, module] of this.modules.entries()) {
213
+ const aiQuestions = [];
214
+ // Parcourir toutes les questions du module
215
+ for (const question of module.questions) {
216
+ // Si la question a des métadonnées IA, l'extraire
217
+ if (question.aiQuestionMetadata) {
218
+ const metadata = question.aiQuestionMetadata;
219
+ aiQuestions.push({
220
+ id: question.id, // Utiliser l'ID de la question comme ID
221
+ sectorId: metadata.sectorId || moduleId,
222
+ questionId: question.id,
223
+ questionText: question.text,
224
+ questionType: question.type,
225
+ options: question.options || null,
226
+ placeholder: question.placeholder || null,
227
+ minValue: question.min || null,
228
+ maxValue: question.max || null,
229
+ conditions: metadata.conditions || null,
230
+ isActive: metadata.isActive !== false, // Par défaut true
231
+ parentQuestionId: metadata.parentQuestionId || null,
232
+ nextQuestionId: metadata.nextQuestionId || null,
233
+ });
234
+ }
235
+ }
236
+ if (aiQuestions.length > 0) {
237
+ aiQuestionsMap.set(moduleId, aiQuestions);
238
+ }
239
+ }
240
+ return aiQuestionsMap;
241
+ }
242
+ /**
243
+ * Injecte les questions IA depuis le cache selon les conditions actuelles
244
+ * Évite les doublons en vérifiant si la question est déjà dans la queue
245
+ * Gère l'injection récursive des questions IA qui dépendent d'autres questions IA
246
+ */
247
+ injectAIQuestionsFromCache() {
248
+ // Parcourir tous les modules dans la queue
249
+ for (const queuedModule of this.state.queue) {
250
+ const module = this.modules.get(queuedModule.moduleId);
251
+ if (!module)
252
+ continue;
253
+ // Récupérer les questions IA depuis le cache pour ce secteur
254
+ const allAIQuestions = this.aiQuestionsCache.get(queuedModule.moduleId) || [];
255
+ // Filtrer selon les conditions actuelles
256
+ const aiQuestions = allAIQuestions.filter(q => checkConditions(q.conditions, this.state.formData));
257
+ if (aiQuestions.length === 0)
258
+ continue;
259
+ // Récupérer les IDs des questions déjà dans la queue pour éviter les doublons
260
+ const existingQuestionIds = new Set(queuedModule.questions.map(q => q.questionId));
261
+ // Récupérer les IDs des questions déjà dans le module
262
+ const existingModuleQuestionIds = new Set(module.questions.map(q => q.id));
263
+ // Fonction récursive pour injecter une question IA et ses dépendances
264
+ const injectQuestionRecursive = (aiQuestion, processedIds) => {
265
+ // Éviter les cycles et les doublons
266
+ if (processedIds.has(aiQuestion.questionId) || existingQuestionIds.has(aiQuestion.questionId)) {
267
+ return;
268
+ }
269
+ // Si la question a un parent qui est aussi une question IA, injecter d'abord le parent
270
+ if (aiQuestion.parentQuestionId && !existingQuestionIds.has(aiQuestion.parentQuestionId)) {
271
+ const parentAIQuestion = allAIQuestions.find(q => q.questionId === aiQuestion.parentQuestionId);
272
+ if (parentAIQuestion && checkConditions(parentAIQuestion.conditions, this.state.formData)) {
273
+ // Injecter récursivement le parent d'abord
274
+ injectQuestionRecursive(parentAIQuestion, processedIds);
275
+ }
276
+ }
277
+ // Maintenant injecter cette question
278
+ processedIds.add(aiQuestion.questionId);
279
+ // Convertir en ModuleQuestion
280
+ const moduleQuestion = convertAIQuestionToModuleQuestion(aiQuestion);
281
+ // Vérifier si la question n'est pas déjà dans le module
282
+ if (existingModuleQuestionIds.has(aiQuestion.questionId)) {
283
+ return;
284
+ }
285
+ // Ajouter au module
286
+ module.questions.push(moduleQuestion);
287
+ existingModuleQuestionIds.add(aiQuestion.questionId);
288
+ // Créer la question dans la queue
289
+ const queuedQuestion = {
290
+ questionId: aiQuestion.questionId,
291
+ moduleId: queuedModule.moduleId,
292
+ text: aiQuestion.questionText,
293
+ type: aiQuestion.questionType,
294
+ status: "pending",
295
+ };
296
+ // Si parentQuestionId est défini, injecter après cette question
297
+ if (aiQuestion.parentQuestionId) {
298
+ // Chercher dans la queue ET dans le module (pour les questions statiques)
299
+ const queuedParentIndex = queuedModule.questions.findIndex(q => q.questionId === aiQuestion.parentQuestionId);
300
+ // Si pas trouvé dans la queue, chercher dans le module (question statique)
301
+ if (queuedParentIndex < 0) {
302
+ const parentInModule = module.questions.find(q => q.id === aiQuestion.parentQuestionId);
303
+ if (parentInModule) {
304
+ // Trouver où insérer après la question statique dans la queue
305
+ // Chercher la dernière question qui correspond à une question du module avant ou égale au parent
306
+ let insertIndex = -1;
307
+ for (let i = queuedModule.questions.length - 1; i >= 0; i--) {
308
+ const q = queuedModule.questions[i];
309
+ if (q.questionId === aiQuestion.parentQuestionId) {
310
+ insertIndex = i + 1;
311
+ break;
312
+ }
313
+ // Si on trouve une question qui vient avant le parent dans le module, insérer après
314
+ const qInModule = module.questions.find(mq => mq.id === q.questionId);
315
+ if (qInModule) {
316
+ // Vérifier si cette question vient avant le parent dans l'ordre du module
317
+ const parentIndex = module.questions.findIndex(mq => mq.id === aiQuestion.parentQuestionId);
318
+ const currentIndex = module.questions.findIndex(mq => mq.id === q.questionId);
319
+ if (currentIndex < parentIndex) {
320
+ insertIndex = i + 1;
321
+ break;
322
+ }
323
+ }
324
+ }
325
+ if (insertIndex >= 0) {
326
+ queuedModule.questions.splice(insertIndex, 0, queuedQuestion);
327
+ }
328
+ else {
329
+ // Si on ne trouve pas où insérer, chercher la position après la question statique
330
+ // en suivant le nextInModule
331
+ const findInsertPosition = (parentId) => {
332
+ const parentQ = module.questions.find(q => q.id === parentId);
333
+ if (!parentQ || !parentQ.nextInModule) {
334
+ return queuedModule.questions.length;
335
+ }
336
+ // Chercher si nextInModule est déjà dans la queue
337
+ const nextIndex = queuedModule.questions.findIndex(q => q.questionId === parentQ.nextInModule);
338
+ if (nextIndex >= 0) {
339
+ return nextIndex;
340
+ }
341
+ // Sinon, chercher récursivement
342
+ return findInsertPosition(parentQ.nextInModule);
343
+ };
344
+ const insertPos = findInsertPosition(aiQuestion.parentQuestionId);
345
+ queuedModule.questions.splice(insertPos, 0, queuedQuestion);
346
+ }
347
+ // Modifier le nextInModule de la question parente pour pointer vers la question IA
348
+ if (parentInModule.nextInModule !== aiQuestion.questionId) {
349
+ const originalNextInModule = parentInModule.nextInModule;
350
+ parentInModule.nextInModule = aiQuestion.questionId;
351
+ moduleQuestion.nextInModule = originalNextInModule;
352
+ }
353
+ }
354
+ else {
355
+ // Parent non trouvé (ni dans la queue, ni dans le module)
356
+ // Essayer de trouver une position logique en cherchant la dernière question injectée
357
+ // ou en cherchant une question statique qui pourrait être un parent logique
358
+ // Si le parent est une question IA qui n'existe pas, chercher la dernière question IA injectée
359
+ // ou la dernière question statique du module
360
+ let insertIndex = queuedModule.questions.length;
361
+ // Chercher la dernière question statique du module dans la queue
362
+ for (let i = queuedModule.questions.length - 1; i >= 0; i--) {
363
+ const q = queuedModule.questions[i];
364
+ const qInModule = module.questions.find(mq => mq.id === q.questionId);
365
+ if (qInModule && !allAIQuestions.find(aq => aq.questionId === q.questionId)) {
366
+ // C'est une question statique
367
+ insertIndex = i + 1;
368
+ break;
369
+ }
370
+ }
371
+ // Si on n'a pas trouvé de question statique, chercher la dernière question IA injectée
372
+ if (insertIndex === queuedModule.questions.length) {
373
+ for (let i = queuedModule.questions.length - 1; i >= 0; i--) {
374
+ const q = queuedModule.questions[i];
375
+ if (allAIQuestions.find(aq => aq.questionId === q.questionId)) {
376
+ insertIndex = i + 1;
377
+ break;
378
+ }
379
+ }
380
+ }
381
+ queuedModule.questions.splice(insertIndex, 0, queuedQuestion);
382
+ }
383
+ }
384
+ else {
385
+ // Parent trouvé dans la queue, insérer après
386
+ queuedModule.questions.splice(queuedParentIndex + 1, 0, queuedQuestion);
387
+ // Modifier le nextInModule de la question parente
388
+ const parentQuestionInModule = module.questions.find(q => q.id === aiQuestion.parentQuestionId);
389
+ if (parentQuestionInModule && parentQuestionInModule.nextInModule !== aiQuestion.questionId) {
390
+ const originalNextInModule = parentQuestionInModule.nextInModule;
391
+ parentQuestionInModule.nextInModule = aiQuestion.questionId;
392
+ moduleQuestion.nextInModule = originalNextInModule;
393
+ }
394
+ }
395
+ }
396
+ else {
397
+ // Si pas de parent, ajouter à la fin du module
398
+ queuedModule.questions.push(queuedQuestion);
399
+ }
400
+ // Ajouter à la liste des questions existantes pour éviter les doublons
401
+ existingQuestionIds.add(aiQuestion.questionId);
402
+ };
403
+ // Traiter toutes les questions IA en respectant les dépendances
404
+ const processedIds = new Set();
405
+ for (const aiQuestion of aiQuestions) {
406
+ injectQuestionRecursive(aiQuestion, processedIds);
407
+ }
408
+ }
409
+ this.notifyListeners();
410
+ }
411
+ shouldHideModule(module) {
412
+ if (!module.triggers || module.triggers.length === 0)
413
+ return false;
414
+ for (const trigger of module.triggers) {
415
+ if (trigger.action.type === "hide_module" && this.evaluateModuleTrigger(trigger)) {
416
+ return true;
417
+ }
418
+ }
419
+ return false;
420
+ }
421
+ evaluateModuleTrigger(trigger) {
422
+ const { condition } = trigger;
423
+ const value = this.state.formData[condition.questionId];
424
+ switch (condition.type) {
425
+ case "form_data_equals":
426
+ return value === condition.value;
427
+ case "form_data_contains":
428
+ return typeof value === "string" && value.includes(condition.value);
429
+ case "form_data_exists":
430
+ return value !== undefined && value !== null;
431
+ case "form_data_includes_any":
432
+ if (Array.isArray(value) && Array.isArray(condition.value)) {
433
+ return condition.value.some((v) => value.includes(v));
434
+ }
435
+ return false;
436
+ default:
437
+ return false;
438
+ }
439
+ }
440
+ // =====================================================
441
+ // NAVIGATION
442
+ // =====================================================
443
+ activateFirstQuestion() {
444
+ const firstModule = this.state.queue.find((m) => m.status === "pending");
445
+ if (!firstModule) {
446
+ this.state.isComplete = true;
447
+ return;
448
+ }
449
+ firstModule.status = "active";
450
+ this.state.currentModuleId = firstModule.moduleId;
451
+ const module = this.modules.get(firstModule.moduleId);
452
+ if (module) {
453
+ this.state.currentQuestionId = module.entryQuestionId;
454
+ const queuedQuestion = firstModule.questions.find((q) => q.questionId === module.entryQuestionId);
455
+ if (queuedQuestion) {
456
+ queuedQuestion.status = "active";
457
+ }
458
+ }
459
+ }
460
+ getCurrentQuestion() {
461
+ if (!this.state.currentModuleId || !this.state.currentQuestionId) {
462
+ return null;
463
+ }
464
+ const module = this.modules.get(this.state.currentModuleId);
465
+ if (!module)
466
+ return null;
467
+ const question = module.questions.find((q) => q.id === this.state.currentQuestionId) || null;
468
+ if (!question)
469
+ return null;
470
+ // Récupérer les IDs système et comportements depuis la config
471
+ const systemIds = getSystemIds(this.config);
472
+ const behaviors = getSpecialBehaviors(this.config);
473
+ // Pour la question budget_priority, filtrer les options en fonction des prestataires déjà réservés
474
+ if (question.id === systemIds.budgetPriorityQuestionId && behaviors.budgetPriority.enabled) {
475
+ // Les options du module sont maintenant des clés i18n (ex: "intro_options.budget_priority_venue")
476
+ const loadedOptions = question.options && question.options.length > 0
477
+ ? question.options
478
+ : this.i18n.getQuestionOptions(question.id, 'fr');
479
+ // Traduire les options pour le filtrage
480
+ const translatedOptions = loadedOptions.map(opt => {
481
+ if (opt.includes('.') && !opt.includes(' ')) {
482
+ return this.i18n.t(opt, undefined, 'fr');
483
+ }
484
+ return opt;
485
+ });
486
+ const bookedProviders = this.state.formData.booked_providers;
487
+ if (bookedProviders && bookedProviders.length > 0 && translatedOptions.length > 0 && behaviors.budgetPriority.filterOptionsByBookedProviders) {
488
+ // Récupérer le mapping priorité -> prestataires depuis la config
489
+ const priorityToBookedProviders = getPriorityToBookedProviders(this.config);
490
+ // Filtrer les options traduites
491
+ const filteredTranslatedOptions = translatedOptions.filter((translatedOpt, index) => {
492
+ // Récupérer les prestataires correspondants à cette priorité (utilise les valeurs traduites)
493
+ const relatedProviders = priorityToBookedProviders[translatedOpt] || [];
494
+ // Vérifier si AU MOINS UN prestataire de cette catégorie est déjà réservé
495
+ if (relatedProviders.length === 0) {
496
+ // Pas de prestataire associé (ex: "Les tenues"), on garde l'option
497
+ return true;
498
+ }
499
+ // Si au moins un prestataire de cette catégorie est réservé, on exclut l'option
500
+ const hasBookedProvider = relatedProviders.some(provider => bookedProviders.includes(provider));
501
+ return !hasBookedProvider;
502
+ });
503
+ // Retourner les clés i18n correspondantes aux options traduites filtrées
504
+ const filteredOptions = filteredTranslatedOptions.map(translatedOpt => {
505
+ // Trouver la clé i18n correspondante dans loadedOptions
506
+ const originalKeyIndex = translatedOptions.findIndex(opt => opt === translatedOpt);
507
+ return originalKeyIndex >= 0 ? loadedOptions[originalKeyIndex] : translatedOpt;
508
+ });
509
+ // Retourner les options filtrées (clés i18n) - elles seront traduites dans enrichQuestionWithTranslations
510
+ return Object.assign(Object.assign({}, question), { options: filteredOptions });
511
+ }
512
+ else if (loadedOptions.length > 0) {
513
+ // Si pas de prestataires réservés, retourner avec les options chargées (clés i18n)
514
+ return Object.assign(Object.assign({}, question), { options: loadedOptions });
515
+ }
516
+ }
517
+ // Pour la question contact_correction, déterminer dynamiquement le type (email ou phone)
518
+ if (question.id === systemIds.contactCorrectionQuestionId && behaviors.contactCorrection.enabled) {
519
+ const contactIntro = this.state.formData.contact_intro;
520
+ const contactEmail = this.state.formData.contact_email;
521
+ const detectEmailFrom = behaviors.contactCorrection.detectEmailFrom;
522
+ const isEmail = detectEmailFrom.some(key => contactIntro === key) || contactEmail !== undefined;
523
+ // Modifier le type de question selon le mode de contact
524
+ return Object.assign(Object.assign({}, question), { type: isEmail ? "email" : "phone" });
525
+ }
526
+ return question;
527
+ }
528
+ getCurrentModule() {
529
+ if (!this.state.currentModuleId)
530
+ return null;
531
+ return this.modules.get(this.state.currentModuleId) || null;
532
+ }
533
+ getModuleById(moduleId) {
534
+ return this.modules.get(moduleId) || null;
535
+ }
536
+ // Vérifie si une question has_* doit être sautée (on a déjà l'info depuis l'intro)
537
+ // Retourne: { shouldSkip: boolean, isBooked: boolean }
538
+ checkHasQuestionSkip(questionId, moduleId) {
539
+ // Vérifier si on a posé la question booked_providers
540
+ const bookedProviders = this.state.formData.booked_providers;
541
+ // Si booked_providers n'a pas encore été répondu, ne pas sauter
542
+ if (bookedProviders === undefined) {
543
+ return { shouldSkip: false, isBooked: false };
544
+ }
545
+ // Chercher si ce module/question correspond à un prestataire dans le mapping
546
+ const bookedProviderMappings = getBookedProviderMappings(this.config);
547
+ for (const [providerName, mapping] of Object.entries(bookedProviderMappings)) {
548
+ if (mapping.moduleId === moduleId && mapping.skipQuestion === questionId) {
549
+ // On a trouvé le mapping, donc on doit sauter cette question
550
+ // isBooked = true si le prestataire est dans la liste des réservés
551
+ // (et n'est pas "Aucun pour l'instant")
552
+ const isBooked = bookedProviders.includes(providerName) && !bookedProviders.includes("Aucun pour l'instant");
553
+ return { shouldSkip: true, isBooked };
554
+ }
555
+ }
556
+ return { shouldSkip: false, isBooked: false };
557
+ }
558
+ // Récupère l'ID de la question suivante selon la branche choisie
559
+ getSkipDestination(question, branch) {
560
+ // Si la question a une navigation conditionnelle, aller vers la branche appropriée
561
+ if (question.conditionalNextInModule && question.conditionalNextInModule[branch]) {
562
+ return question.conditionalNextInModule[branch];
563
+ }
564
+ return question.nextInModule || null;
565
+ }
566
+ // =====================================================
567
+ // SOUMISSION DE RÉPONSE
568
+ // =====================================================
569
+ async submitAnswer(answer) {
570
+ const currentQuestion = this.getCurrentQuestion();
571
+ const currentModule = this.getCurrentModule();
572
+ if (!currentQuestion || !currentModule) {
573
+ return null;
574
+ }
575
+ // Reset le flag de double avancement
576
+ this.advancedByTrigger = false;
577
+ // Enregistrer la réponse
578
+ this.state.formData[currentQuestion.id] = answer;
579
+ // Récupérer les IDs système et comportements depuis la config
580
+ const systemIds = getSystemIds(this.config);
581
+ const behaviors = getSpecialBehaviors(this.config);
582
+ // Si c'est la question contact_correction, mettre à jour contact_email ou contact_phone selon le mode
583
+ if (currentQuestion.id === systemIds.contactCorrectionQuestionId && behaviors.contactCorrection.enabled) {
584
+ const contactIntro = this.state.formData.contact_intro;
585
+ const contactEmail = this.state.formData.contact_email;
586
+ const detectEmailFrom = behaviors.contactCorrection.detectEmailFrom;
587
+ const isEmail = detectEmailFrom.some(key => contactIntro === key) || contactEmail !== undefined;
588
+ if (isEmail) {
589
+ // Mettre à jour l'email
590
+ this.state.formData.contact_email = answer;
591
+ }
592
+ else {
593
+ // Mettre à jour le téléphone
594
+ this.state.formData.contact_phone = answer;
595
+ }
596
+ }
597
+ // Après avoir enregistré la réponse, injecter les questions IA depuis le cache
598
+ // qui pourraient maintenant correspondre aux nouvelles conditions
599
+ this.injectAIQuestionsFromCache();
600
+ // Marquer la question comme complétée dans la queue
601
+ const queuedModule = this.state.queue.find((m) => m.moduleId === currentModule.id);
602
+ if (queuedModule) {
603
+ const queuedQuestion = queuedModule.questions.find((q) => q.questionId === currentQuestion.id);
604
+ if (queuedQuestion) {
605
+ queuedQuestion.status = "completed";
606
+ queuedQuestion.answer = answer;
607
+ queuedQuestion.answeredAt = new Date();
608
+ }
609
+ }
610
+ // Ajouter aux questions répondues
611
+ this.state.answeredQuestions.push({
612
+ questionId: currentQuestion.id,
613
+ moduleId: currentModule.id,
614
+ moduleName: currentModule.name,
615
+ text: currentQuestion.text,
616
+ answer,
617
+ answeredAt: new Date(),
618
+ });
619
+ // Exécuter les triggers de la question
620
+ if (currentQuestion.onAnswer) {
621
+ this.executeQuestionTriggers(currentQuestion.onAnswer, answer);
622
+ }
623
+ // Si la question a des sous-questions à générer (multiselect avec prestataires multiples)
624
+ let firstSubQuestionId = null;
625
+ if (currentQuestion.generateSubQuestionsFor && Array.isArray(answer) && answer.length > 0) {
626
+ firstSubQuestionId = this.generateDynamicSubQuestions(currentModule, currentQuestion, answer, queuedModule);
627
+ }
628
+ // Si la question est de type "info" avec generateSubQuestionsFromFormData,
629
+ // générer les sous-questions à partir des données du formulaire
630
+ if (currentQuestion.type === "info" &&
631
+ currentQuestion.generateSubQuestionsFromFormData &&
632
+ currentQuestion.subQuestionTemplates) {
633
+ const sourceData = this.state.formData[currentQuestion.generateSubQuestionsFromFormData];
634
+ if (Array.isArray(sourceData) && sourceData.length > 0) {
635
+ // Filtrer "Aucun pour l'instant" si présent
636
+ const filteredItems = sourceData.filter((item) => item !== "Aucun pour l'instant");
637
+ if (filteredItems.length > 0) {
638
+ firstSubQuestionId = this.generateSubQuestionsFromFormData(currentModule, currentQuestion, filteredItems, queuedModule);
639
+ }
640
+ }
641
+ }
642
+ // Si des sous-questions ont été générées, naviguer vers la première
643
+ if (firstSubQuestionId) {
644
+ this.state.currentQuestionId = firstSubQuestionId;
645
+ const nextQueuedQuestion = queuedModule === null || queuedModule === void 0 ? void 0 : queuedModule.questions.find((q) => q.questionId === firstSubQuestionId);
646
+ if (nextQueuedQuestion) {
647
+ nextQueuedQuestion.status = "active";
648
+ }
649
+ this.notifyListeners();
650
+ return this.getCurrentQuestion();
651
+ }
652
+ // Sinon, déterminer la prochaine question normalement
653
+ const nextQuestionId = this.getNextQuestionId(currentQuestion, answer);
654
+ if (nextQuestionId === null) {
655
+ // Fin du module - Si c'est la question de priorité, appliquer la réorganisation
656
+ if (currentQuestion.id === systemIds.budgetPriorityQuestionId &&
657
+ Array.isArray(answer) &&
658
+ behaviors.budgetPriority.enabled) {
659
+ this.applyPriorityOrder(answer);
660
+ // Marquer le module intro comme complété
661
+ if (queuedModule) {
662
+ queuedModule.status = "completed";
663
+ }
664
+ // Notifier pour mettre à jour la visualisation
665
+ this.notifyListeners();
666
+ }
667
+ // Si c'est la question continue_other_sectors_question et que la réponse est "non"
668
+ // Sauter directement au module final
669
+ if (currentQuestion.id === systemIds.continueOtherSectorsQuestionId &&
670
+ behaviors.continueOtherSectors.enabled &&
671
+ behaviors.continueOtherSectors.skipToFinalOnNo) {
672
+ // Détecter la réponse "non" : boolean false, string "non" ou "false"
673
+ const isNo = answer === false ||
674
+ (typeof answer === "string" && (answer.toLowerCase() === "non" || answer.toLowerCase() === "false"));
675
+ if (isNo) {
676
+ // Trouver le module final
677
+ const finalModule = this.state.queue.find((m) => m.moduleId === systemIds.finalModuleId);
678
+ if (finalModule) {
679
+ // Marquer continue_other_sectors comme complété
680
+ if (queuedModule) {
681
+ queuedModule.status = "completed";
682
+ this.state.completedModules.push(queuedModule.moduleId);
683
+ }
684
+ // Marquer tous les modules non prioritaires comme skippés
685
+ const skipPriority = behaviors.continueOtherSectors.skipModulesWithPriority;
686
+ this.state.queue.forEach((m) => {
687
+ if (m.effectivePriority >= skipPriority &&
688
+ m.moduleId !== systemIds.finalModuleId &&
689
+ m.moduleId !== systemIds.continueOtherSectorsModuleId) {
690
+ m.status = "skipped";
691
+ this.state.skippedModules.push(m.moduleId);
692
+ }
693
+ });
694
+ // Activer le module final
695
+ finalModule.status = "active";
696
+ this.state.currentModuleId = finalModule.moduleId;
697
+ const finalModuleDef = this.modules.get(systemIds.finalModuleId);
698
+ if (finalModuleDef) {
699
+ this.state.currentQuestionId = finalModuleDef.entryQuestionId;
700
+ const queuedQuestion = finalModule.questions.find((q) => q.questionId === finalModuleDef.entryQuestionId);
701
+ if (queuedQuestion) {
702
+ queuedQuestion.status = "active";
703
+ }
704
+ // S'assurer que le module est bien dans la queue et que la question existe
705
+ this.notifyListeners();
706
+ const currentQuestion = this.getCurrentQuestion();
707
+ if (currentQuestion) {
708
+ return currentQuestion;
709
+ }
710
+ }
711
+ // Si on arrive ici, il y a un problème - essayer d'avancer normalement
712
+ return this.advanceToNextModule();
713
+ }
714
+ }
715
+ }
716
+ // Passer au suivant (qui sera maintenant le module priorisé)
717
+ // Mais seulement si un trigger n'a pas déjà fait avancer
718
+ if (this.advancedByTrigger) {
719
+ this.notifyListeners();
720
+ return this.getCurrentQuestion();
721
+ }
722
+ return this.advanceToNextModule();
723
+ }
724
+ // Naviguer vers la prochaine question dans le module
725
+ this.state.currentQuestionId = nextQuestionId;
726
+ const nextQueuedQuestion = queuedModule === null || queuedModule === void 0 ? void 0 : queuedModule.questions.find((q) => q.questionId === nextQuestionId);
727
+ if (nextQueuedQuestion) {
728
+ nextQueuedQuestion.status = "active";
729
+ }
730
+ this.notifyListeners();
731
+ return this.getCurrentQuestion();
732
+ }
733
+ generateDynamicSubQuestions(module, parentQuestion, selectedItems, queuedModule) {
734
+ if (!parentQuestion.generateSubQuestionsFor || !queuedModule)
735
+ return null;
736
+ // Trouver l'index de la question parente dans la queue
737
+ const parentIndex = queuedModule.questions.findIndex((q) => q.questionId === parentQuestion.id);
738
+ if (parentIndex === -1)
739
+ return null;
740
+ const subQuestions = [];
741
+ const dynamicModuleQuestions = [];
742
+ // Vérifier si l'utilisateur veut la coordination
743
+ const wantsCoordination = this.state.formData.aimi_coordination === "oui";
744
+ const hasExistingProviders = this.state.formData.has_existing_providers === "oui";
745
+ const shouldAskContact = wantsCoordination && hasExistingProviders;
746
+ // Filtrer les templates : exclure provider_contact si pas de coordination
747
+ const filteredTemplates = parentQuestion.generateSubQuestionsFor.filter(template => {
748
+ if (template.id === "provider_contact" && !shouldAskContact) {
749
+ return false;
750
+ }
751
+ return true;
752
+ });
753
+ // Pour chaque élément sélectionné, générer les sous-questions
754
+ selectedItems.forEach((item, itemIndex) => {
755
+ filteredTemplates.forEach((template, templateIndex) => {
756
+ // ID unique pour la sous-question générée
757
+ const subQuestionId = `${parentQuestion.id}_${template.id}_${itemIndex}`;
758
+ // Traduire le template puis remplacer {item}
759
+ // Le template peut être une clé i18n (ex: "intro_templates.provider_name")
760
+ let translatedTemplate = template.textTemplate;
761
+ if (template.textTemplate.includes('.') && !template.textTemplate.includes(' ')) {
762
+ // C'est une clé i18n, on la traduit d'abord
763
+ translatedTemplate = this.i18n.t(template.textTemplate, undefined, 'fr');
764
+ }
765
+ // Remplacer {item} dans le template traduit
766
+ const text = translatedTemplate.replace("{item}", item);
767
+ // Traduire le placeholder si c'est une clé i18n
768
+ let translatedPlaceholder = template.placeholder;
769
+ if (template.placeholder && template.placeholder.includes('.') && !template.placeholder.includes(' ')) {
770
+ translatedPlaceholder = this.i18n.t(template.placeholder, undefined, 'fr');
771
+ }
772
+ // Déterminer la prochaine question
773
+ let nextInModule = null;
774
+ const nextItemIndex = itemIndex + 1;
775
+ const hasNextItem = nextItemIndex < selectedItems.length;
776
+ // Récupérer la destination après les sous-questions (depuis la question parente)
777
+ const finalDestination = parentQuestion.nextInModule || null;
778
+ if (template.id === "provider_name") {
779
+ // Après le nom, aller vers contact (si coordination) ou prochain prestataire
780
+ if (shouldAskContact) {
781
+ nextInModule = `${parentQuestion.id}_provider_contact_${itemIndex}`;
782
+ }
783
+ else {
784
+ nextInModule = hasNextItem
785
+ ? `${parentQuestion.id}_provider_name_${nextItemIndex}`
786
+ : finalDestination;
787
+ }
788
+ }
789
+ else if (template.id === "provider_contact") {
790
+ // Après le contact, passer au prochain prestataire ou destination finale
791
+ nextInModule = hasNextItem
792
+ ? `${parentQuestion.id}_provider_name_${nextItemIndex}`
793
+ : finalDestination;
794
+ }
795
+ // Créer la question dynamique
796
+ const dynamicQuestion = {
797
+ id: subQuestionId,
798
+ text,
799
+ type: template.type,
800
+ placeholder: translatedPlaceholder,
801
+ isRequired: template.isRequired,
802
+ nextInModule,
803
+ };
804
+ // Ajouter à la liste des questions du module
805
+ dynamicModuleQuestions.push(dynamicQuestion);
806
+ // Ajouter à la queue
807
+ subQuestions.push({
808
+ questionId: subQuestionId,
809
+ moduleId: module.id,
810
+ text,
811
+ type: template.type,
812
+ status: "pending",
813
+ isDynamic: true,
814
+ parentQuestionId: parentQuestion.id,
815
+ });
816
+ });
817
+ });
818
+ // Ajouter les questions dynamiques au module
819
+ module.questions.push(...dynamicModuleQuestions);
820
+ // Insérer les sous-questions après la question parente
821
+ queuedModule.questions.splice(parentIndex + 1, 0, ...subQuestions);
822
+ // Retourner l'ID de la première sous-question générée
823
+ return subQuestions.length > 0 ? subQuestions[0].questionId : null;
824
+ }
825
+ // Génère des sous-questions à partir des données du formulaire (pour les questions "info")
826
+ generateSubQuestionsFromFormData(module, parentQuestion, selectedItems, queuedModule) {
827
+ if (!parentQuestion.subQuestionTemplates || !queuedModule)
828
+ return null;
829
+ // Trouver l'index de la question parente dans la queue
830
+ const parentIndex = queuedModule.questions.findIndex((q) => q.questionId === parentQuestion.id);
831
+ if (parentIndex === -1)
832
+ return null;
833
+ const subQuestions = [];
834
+ const dynamicModuleQuestions = [];
835
+ // Récupérer la destination après les sous-questions
836
+ const finalDestination = parentQuestion.nextInModule || null;
837
+ // Pour chaque élément sélectionné, générer les sous-questions
838
+ selectedItems.forEach((item, itemIndex) => {
839
+ parentQuestion.subQuestionTemplates.forEach((template, templateIndex) => {
840
+ // ID unique pour la sous-question générée
841
+ const subQuestionId = `${parentQuestion.id}_${template.id}_${itemIndex}`;
842
+ // Traduire le template puis remplacer {item}
843
+ // Le template peut être une clé i18n (ex: "intro_templates.provider_name")
844
+ let translatedTemplate = template.textTemplate;
845
+ if (template.textTemplate.includes('.') && !template.textTemplate.includes(' ')) {
846
+ // C'est une clé i18n, on la traduit d'abord
847
+ translatedTemplate = this.i18n.t(template.textTemplate, undefined, 'fr');
848
+ }
849
+ // Remplacer {item} dans le template traduit (item est déjà traduit depuis booked_providers)
850
+ const text = translatedTemplate.replace("{item}", item.toLowerCase());
851
+ // Traduire le placeholder si c'est une clé i18n
852
+ let translatedPlaceholder = template.placeholder;
853
+ if (template.placeholder && template.placeholder.includes('.') && !template.placeholder.includes(' ')) {
854
+ translatedPlaceholder = this.i18n.t(template.placeholder, undefined, 'fr');
855
+ }
856
+ // Déterminer la prochaine question
857
+ let nextInModule = null;
858
+ const nextItemIndex = itemIndex + 1;
859
+ const hasNextItem = nextItemIndex < selectedItems.length;
860
+ const nextTemplateIndex = templateIndex + 1;
861
+ const hasNextTemplate = nextTemplateIndex < parentQuestion.subQuestionTemplates.length;
862
+ if (hasNextTemplate) {
863
+ // Aller vers le prochain template pour le même item
864
+ nextInModule = `${parentQuestion.id}_${parentQuestion.subQuestionTemplates[nextTemplateIndex].id}_${itemIndex}`;
865
+ }
866
+ else if (hasNextItem) {
867
+ // Aller vers le premier template du prochain item
868
+ nextInModule = `${parentQuestion.id}_${parentQuestion.subQuestionTemplates[0].id}_${nextItemIndex}`;
869
+ }
870
+ else {
871
+ // Fin des sous-questions, aller vers la destination finale
872
+ nextInModule = finalDestination;
873
+ }
874
+ // Créer la question dynamique
875
+ const dynamicQuestion = {
876
+ id: subQuestionId,
877
+ text,
878
+ type: template.type,
879
+ placeholder: translatedPlaceholder,
880
+ isRequired: template.isRequired,
881
+ nextInModule,
882
+ };
883
+ // Ajouter à la liste des questions du module
884
+ dynamicModuleQuestions.push(dynamicQuestion);
885
+ // Ajouter à la queue
886
+ subQuestions.push({
887
+ questionId: subQuestionId,
888
+ moduleId: module.id,
889
+ text,
890
+ type: template.type,
891
+ status: "pending",
892
+ isDynamic: true,
893
+ parentQuestionId: parentQuestion.id,
894
+ });
895
+ });
896
+ });
897
+ // Ajouter les questions dynamiques au module
898
+ module.questions.push(...dynamicModuleQuestions);
899
+ // Insérer les sous-questions après la question parente
900
+ queuedModule.questions.splice(parentIndex + 1, 0, ...subQuestions);
901
+ // Retourner l'ID de la première sous-question générée
902
+ return subQuestions.length > 0 ? subQuestions[0].questionId : null;
903
+ }
904
+ getNextQuestionId(question, answer) {
905
+ // Vérifier la navigation conditionnelle
906
+ if (question.conditionalNextInModule) {
907
+ const answerKey = typeof answer === "boolean"
908
+ ? (answer ? "oui" : "non")
909
+ : String(answer).toLowerCase();
910
+ if (answerKey in question.conditionalNextInModule) {
911
+ return question.conditionalNextInModule[answerKey];
912
+ }
913
+ // Essayer avec la valeur exacte
914
+ if (String(answer) in question.conditionalNextInModule) {
915
+ return question.conditionalNextInModule[String(answer)];
916
+ }
917
+ }
918
+ // Navigation par défaut
919
+ let nextId = question.nextInModule !== undefined ? question.nextInModule : null;
920
+ // Si la prochaine question est une question de contact (*_contact),
921
+ // vérifier si l'utilisateur a accepté la coordination
922
+ if (nextId && nextId.endsWith("_contact")) {
923
+ const wantsCoordination = this.state.formData.aimi_coordination === "oui";
924
+ const hasExistingProviders = this.state.formData.has_existing_providers === "oui";
925
+ // Si l'utilisateur n'a pas accepté la coordination ou n'a pas de prestataires existants,
926
+ // on saute la question de contact (fin du module ou branche)
927
+ if (!wantsCoordination || !hasExistingProviders) {
928
+ return null;
929
+ }
930
+ }
931
+ return nextId;
932
+ }
933
+ advanceToNextModule() {
934
+ var _a;
935
+ // Récupérer le module actuel avant de le marquer comme complété
936
+ const currentQueuedModule = this.state.queue.find((m) => m.moduleId === this.state.currentModuleId);
937
+ // Stocker l'ID du module qui se termine pour les messages de transition
938
+ const completedModuleId = (currentQueuedModule === null || currentQueuedModule === void 0 ? void 0 : currentQueuedModule.moduleId) || null;
939
+ // Marquer le module actuel comme complété
940
+ if (currentQueuedModule) {
941
+ currentQueuedModule.status = "completed";
942
+ this.state.completedModules.push(currentQueuedModule.moduleId);
943
+ }
944
+ // Recalculer la queue (pour appliquer les changements de priorité)
945
+ this.recalculateQueue();
946
+ // Détecter si on passe des modules prioritaires aux modules non prioritaires
947
+ // Les modules prioritaires ont une priorité négative (< 0), les autres ont une priorité positive (>= 0)
948
+ const currentModule = currentQueuedModule ? this.modules.get(currentQueuedModule.moduleId) : null;
949
+ const currentPriority = (_a = currentQueuedModule === null || currentQueuedModule === void 0 ? void 0 : currentQueuedModule.effectivePriority) !== null && _a !== void 0 ? _a : 0;
950
+ // Récupérer les IDs système et comportements depuis la config
951
+ const systemIds = getSystemIds(this.config);
952
+ const behaviors = getSpecialBehaviors(this.config);
953
+ // Trouver le prochain module en attente
954
+ const nextModule = this.state.queue.find((m) => m.status === "pending" && !this.state.hiddenModules.includes(m.moduleId));
955
+ // Si on passe d'un module prioritaire (priorité < 0) à un module non prioritaire (priorité >= 0)
956
+ // ET que le module continue_other_sectors n'a pas encore été complété
957
+ // Alors insérer le module continue_other_sectors
958
+ if (nextModule &&
959
+ currentPriority < 0 &&
960
+ nextModule.effectivePriority >= 0 &&
961
+ behaviors.continueOtherSectors.enabled &&
962
+ behaviors.continueOtherSectors.insertBetweenPriorityAndNonPriority) {
963
+ const continueModule = this.state.queue.find((m) => m.moduleId === systemIds.continueOtherSectorsModuleId &&
964
+ !this.state.completedModules.includes(systemIds.continueOtherSectorsModuleId));
965
+ if (continueModule && continueModule.status === "pending") {
966
+ // Insérer le module continue_other_sectors avant les modules non prioritaires
967
+ nextModule.status = "pending"; // Remettre en pending
968
+ continueModule.status = "active";
969
+ this.state.currentModuleId = continueModule.moduleId;
970
+ const continueModuleDef = this.modules.get(continueModule.moduleId);
971
+ if (continueModuleDef) {
972
+ this.state.currentQuestionId = continueModuleDef.entryQuestionId;
973
+ const queuedQuestion = continueModule.questions.find((q) => q.questionId === continueModuleDef.entryQuestionId);
974
+ if (queuedQuestion) {
975
+ queuedQuestion.status = "active";
976
+ }
977
+ }
978
+ this.notifyListeners();
979
+ return this.getCurrentQuestion();
980
+ }
981
+ }
982
+ if (!nextModule) {
983
+ this.state.isComplete = true;
984
+ this.state.currentModuleId = null;
985
+ this.state.currentQuestionId = null;
986
+ this.notifyListeners();
987
+ return null;
988
+ }
989
+ // Activer le prochain module
990
+ nextModule.status = "active";
991
+ this.state.currentModuleId = nextModule.moduleId;
992
+ const module = this.modules.get(nextModule.moduleId);
993
+ if (module) {
994
+ let entryQuestionId = module.entryQuestionId;
995
+ const entryQuestion = module.questions.find(q => q.id === entryQuestionId);
996
+ // Vérifier si le module doit être skippé (prestataire déjà réservé)
997
+ const { isBooked } = this.checkHasQuestionSkip(entryQuestionId, module.id);
998
+ // Si le prestataire est déjà réservé, on skip TOUT le module
999
+ // car les coordonnées ont déjà été collectées dans l'intro (booked_providers_contact_intro)
1000
+ if (isBooked) {
1001
+ // Marquer le module comme skippé
1002
+ nextModule.status = "skipped";
1003
+ this.state.skippedModules.push(nextModule.moduleId);
1004
+ // Marquer toutes les questions comme skippées
1005
+ nextModule.questions.forEach(q => {
1006
+ q.status = "skipped";
1007
+ });
1008
+ // Passer au module suivant
1009
+ return this.advanceToNextModule();
1010
+ }
1011
+ this.state.currentQuestionId = entryQuestionId;
1012
+ const queuedQuestion = nextModule.questions.find((q) => q.questionId === entryQuestionId);
1013
+ if (queuedQuestion) {
1014
+ queuedQuestion.status = "active";
1015
+ }
1016
+ // Stocker les informations de transition pour les messages
1017
+ // Ces informations seront utilisées dans le hook pour afficher les messages de transition
1018
+ // Ne pas afficher de transition pour les modules exclus
1019
+ // IMPORTANT: transitionInfo est réinitialisé à null après utilisation dans le hook
1020
+ const excludeModules = behaviors.transitions.excludeModules;
1021
+ if (completedModuleId &&
1022
+ !excludeModules.includes(completedModuleId) &&
1023
+ !excludeModules.includes(nextModule.moduleId)) {
1024
+ // Ne créer transitionInfo que s'il n'existe pas déjà (éviter les duplications)
1025
+ if (!this.state.transitionInfo) {
1026
+ this.state.transitionInfo = {
1027
+ completedModuleId,
1028
+ startingModuleId: nextModule.moduleId
1029
+ };
1030
+ }
1031
+ }
1032
+ else {
1033
+ this.state.transitionInfo = null;
1034
+ }
1035
+ }
1036
+ this.notifyListeners();
1037
+ return this.getCurrentQuestion();
1038
+ }
1039
+ // =====================================================
1040
+ // TRIGGERS
1041
+ // =====================================================
1042
+ executeQuestionTriggers(triggers, answer) {
1043
+ for (const trigger of triggers) {
1044
+ if (this.evaluateQuestionTrigger(trigger, answer)) {
1045
+ this.executeAction(trigger.action);
1046
+ }
1047
+ }
1048
+ }
1049
+ evaluateQuestionTrigger(trigger, answer) {
1050
+ const { condition } = trigger;
1051
+ switch (condition.type) {
1052
+ case "answer_equals":
1053
+ return answer === condition.value ||
1054
+ (typeof answer === "boolean" &&
1055
+ ((answer && condition.value === "oui") ||
1056
+ (!answer && condition.value === "non")));
1057
+ case "answer_not_equals":
1058
+ return answer !== condition.value;
1059
+ case "answer_contains":
1060
+ return typeof answer === "string" &&
1061
+ answer.toLowerCase().includes(condition.value.toLowerCase());
1062
+ case "answer_exists":
1063
+ return answer !== undefined && answer !== null && answer !== "";
1064
+ case "answer_includes_any":
1065
+ if (Array.isArray(answer) && Array.isArray(condition.value)) {
1066
+ return condition.value.some((v) => answer.map(a => a.toLowerCase()).includes(v.toLowerCase()));
1067
+ }
1068
+ return false;
1069
+ default:
1070
+ return false;
1071
+ }
1072
+ }
1073
+ executeAction(action) {
1074
+ switch (action.type) {
1075
+ case "hide_module":
1076
+ if (action.targetModuleId && !this.state.hiddenModules.includes(action.targetModuleId)) {
1077
+ this.state.hiddenModules.push(action.targetModuleId);
1078
+ // Mettre à jour le statut dans la queue
1079
+ const queuedModule = this.state.queue.find(m => m.moduleId === action.targetModuleId);
1080
+ if (queuedModule && queuedModule.status === "pending") {
1081
+ queuedModule.status = "hidden";
1082
+ }
1083
+ }
1084
+ break;
1085
+ case "show_module":
1086
+ if (action.targetModuleId) {
1087
+ this.state.hiddenModules = this.state.hiddenModules.filter((id) => id !== action.targetModuleId);
1088
+ const queuedModule = this.state.queue.find(m => m.moduleId === action.targetModuleId);
1089
+ if (queuedModule && queuedModule.status === "hidden") {
1090
+ queuedModule.status = "pending";
1091
+ }
1092
+ }
1093
+ break;
1094
+ case "set_priority":
1095
+ if (action.targetModuleId && action.newPriority !== undefined) {
1096
+ const queuedModule = this.state.queue.find((m) => m.moduleId === action.targetModuleId);
1097
+ if (queuedModule) {
1098
+ queuedModule.effectivePriority = action.newPriority;
1099
+ }
1100
+ }
1101
+ break;
1102
+ case "skip_to_end":
1103
+ // Marquer toutes les questions restantes du module comme skipped
1104
+ const currentQueuedModule = this.state.queue.find((m) => m.moduleId === this.state.currentModuleId);
1105
+ if (currentQueuedModule) {
1106
+ currentQueuedModule.questions.forEach((q) => {
1107
+ if (q.status === "pending") {
1108
+ q.status = "skipped";
1109
+ }
1110
+ });
1111
+ }
1112
+ // Avancer au module suivant et marquer qu'on l'a déjà fait
1113
+ this.advancedByTrigger = true;
1114
+ this.advanceToNextModule();
1115
+ break;
1116
+ }
1117
+ }
1118
+ // =====================================================
1119
+ // PRIORITÉ UTILISATEUR
1120
+ // =====================================================
1121
+ applyUserPriority(priority) {
1122
+ const priorityBoosts = getPriorityBoosts(this.config);
1123
+ const boosts = priorityBoosts[priority];
1124
+ if (!boosts)
1125
+ return;
1126
+ for (const boost of boosts) {
1127
+ const queuedModule = this.state.queue.find((m) => m.moduleId === boost.moduleId);
1128
+ if (queuedModule && queuedModule.status === "pending") {
1129
+ const module = this.modules.get(boost.moduleId);
1130
+ if (module && !module.isFixed) {
1131
+ queuedModule.effectivePriority = module.basePriority + boost.boost;
1132
+ }
1133
+ }
1134
+ }
1135
+ this.recalculateQueue();
1136
+ this.notifyListeners();
1137
+ }
1138
+ recalculateQueue() {
1139
+ // Réévaluer les modules cachés
1140
+ this.modules.forEach((module) => {
1141
+ if (module.triggers) {
1142
+ for (const trigger of module.triggers) {
1143
+ if (this.evaluateModuleTrigger(trigger)) {
1144
+ this.executeAction(trigger.action);
1145
+ }
1146
+ }
1147
+ }
1148
+ });
1149
+ // Trier par priorité effective (ne pas toucher aux modules complétés/actifs)
1150
+ const completed = this.state.queue.filter((m) => m.status === "completed" || m.status === "active");
1151
+ const pending = this.state.queue.filter((m) => m.status === "pending" || m.status === "hidden");
1152
+ pending.sort((a, b) => a.effectivePriority - b.effectivePriority);
1153
+ this.state.queue = [...completed, ...pending];
1154
+ }
1155
+ // =====================================================
1156
+ // GETTERS
1157
+ // =====================================================
1158
+ getState() {
1159
+ return Object.assign({}, this.state);
1160
+ }
1161
+ getFormData() {
1162
+ return Object.assign({}, this.state.formData);
1163
+ }
1164
+ getAnsweredQuestions() {
1165
+ return [...this.state.answeredQuestions];
1166
+ }
1167
+ getProgress() {
1168
+ const totalQuestions = this.state.queue.reduce((acc, m) => acc + (m.status !== "hidden" ? m.questions.length : 0), 0);
1169
+ const answeredCount = this.state.answeredQuestions.length;
1170
+ return {
1171
+ current: answeredCount,
1172
+ total: totalQuestions,
1173
+ percent: totalQuestions > 0 ? Math.round((answeredCount / totalQuestions) * 100) : 0,
1174
+ };
1175
+ }
1176
+ getCurrentModuleName() {
1177
+ const module = this.getCurrentModule();
1178
+ return (module === null || module === void 0 ? void 0 : module.name) || "En cours...";
1179
+ }
1180
+ isComplete() {
1181
+ return this.state.isComplete;
1182
+ }
1183
+ // =====================================================
1184
+ // APPLICATION DES PRIORITÉS
1185
+ // =====================================================
1186
+ applyPriorityOrder(priorityOrder) {
1187
+ // Créer un mapping de priorités: plus haut dans la liste = priorité plus élevée
1188
+ // On utilise des valeurs négatives où plus négatif = plus prioritaire
1189
+ const priorityMappings = getPriorityMappings(this.config);
1190
+ const calc = getPriorityCalculation(this.config);
1191
+ priorityOrder.forEach((priority, index) => {
1192
+ const moduleIds = priorityMappings[priority];
1193
+ if (!moduleIds)
1194
+ return;
1195
+ // Calculer la priorité selon la config
1196
+ const newPriority = calc.base + (index * calc.step);
1197
+ // Appliquer la nouvelle priorité à tous les modules concernés
1198
+ moduleIds.forEach((moduleId) => {
1199
+ const queuedModule = this.state.queue.find((m) => m.moduleId === moduleId);
1200
+ if (queuedModule) {
1201
+ queuedModule.effectivePriority = newPriority;
1202
+ }
1203
+ });
1204
+ });
1205
+ // Retrier la queue selon les nouvelles priorités
1206
+ this.state.queue.sort((a, b) => {
1207
+ // Les modules fixes restent en place
1208
+ const moduleA = this.modules.get(a.moduleId);
1209
+ const moduleB = this.modules.get(b.moduleId);
1210
+ if ((moduleA === null || moduleA === void 0 ? void 0 : moduleA.isFixed) && !(moduleB === null || moduleB === void 0 ? void 0 : moduleB.isFixed))
1211
+ return -1;
1212
+ if (!(moduleA === null || moduleA === void 0 ? void 0 : moduleA.isFixed) && (moduleB === null || moduleB === void 0 ? void 0 : moduleB.isFixed))
1213
+ return 1;
1214
+ if ((moduleA === null || moduleA === void 0 ? void 0 : moduleA.isFixed) && (moduleB === null || moduleB === void 0 ? void 0 : moduleB.isFixed)) {
1215
+ return a.effectivePriority - b.effectivePriority;
1216
+ }
1217
+ return a.effectivePriority - b.effectivePriority;
1218
+ });
1219
+ }
1220
+ // =====================================================
1221
+ // LISTENERS (pour la réactivité)
1222
+ // =====================================================
1223
+ subscribe(listener) {
1224
+ this.listeners.add(listener);
1225
+ return () => this.listeners.delete(listener);
1226
+ }
1227
+ notifyListeners() {
1228
+ const state = this.getState();
1229
+ this.listeners.forEach((listener) => listener(state));
1230
+ }
1231
+ }
1232
+ // Instance singleton
1233
+ let engineInstance = null;
1234
+ export function getQuestionEngine(i18n) {
1235
+ if (!engineInstance) {
1236
+ engineInstance = new QuestionEngine(i18n);
1237
+ }
1238
+ return engineInstance;
1239
+ }
1240
+ export function resetQuestionEngine(i18n) {
1241
+ engineInstance = new QuestionEngine(i18n);
1242
+ return engineInstance;
1243
+ }