1/** 2 * SB Wizard Conditional Logic Frontend Engine 3 * 4 * Handles dynamic show/hide, navigation state, and form validation 5 * with perfect Next/Previous button management 6 */ 7 8class SBWizardConditionalLogic { 9 constructor(config = {}) { 10 this.config = { 11 formSelector: '.sb-wizard-form', 12 questionSelector: '.sb-question', 13 nextButtonSelector: '.sb-wizard-btn-next', 14 prevButtonSelector: '.sb-wizard-btn-prev', 15 submitButtonSelector: '.sb-wizard-btn-submit', 16 progressSelector: '.sb-wizard-progress', 17 autoSaveEnabled: false, // Disable auto-save to prevent AJAX errors 18 autoSaveDelay: 1000, 19 animationDuration: 300, 20 debugMode: false, 21 ...config 22 }; 23 24 // Core state management 25 this.questions = new Map(); 26 this.conditionalRules = new Map(); 27 this.currentAnswers = new Map(); 28 this.visibilityState = new Map(); 29 this.navigationHistory = []; 30 this.currentStep = 0; 31 this.totalSteps = 0; 32 33 // Navigation state tracking 34 this.canGoNext = false; 35 this.canGoPrev = false; 36 this.visibleQuestions = []; 37 this.requiredQuestions = []; 38 39 // Performance optimization 40 this.debounceTimers = new Map(); 41 this.cachedSelectors = new Map(); 42 43 this.init(); 44 } 45 46 /** 47 * Initialize the conditional logic system 48 */ 49 init() { 50 this.log('Initializing SB Wizard Conditional Logic'); 51 52 // Load questions and rules from DOM 53 this.loadQuestionsFromDOM(); 54 this.loadConditionalRules(); 55 56 // Set up event listeners 57 this.bindEvents(); 58 59 // Calculate initial state 60 this.calculateInitialVisibility(); 61 this.updateNavigationState(); 62 this.updateProgress(); 63 64 // Auto-save setup if enabled 65 if (this.config.autoSaveEnabled) { 66 this.setupAutoSave(); 67 } 68 69 this.log('SB Wizard Conditional Logic initialized successfully'); 70 } 71 72 /** 73 * Alias for init() for backward compatibility 74 */ 75 initialize() { 76 return this.init(); 77 } 78 79 /** 80 * Load questions from DOM and build question map 81 */ 82 loadQuestionsFromDOM() { 83 const questions = document.querySelectorAll(this.config.questionSelector); 84 85 questions.forEach((questionEl, index) => { 86 const questionId = this.getQuestionId(questionEl); 87 const questionData = { 88 id: questionId, 89 element: questionEl, 90 step: index, 91 type: this.getQuestionType(questionEl), 92 required: this.isQuestionRequired(questionEl), 93 dependsOn: [], 94 affects: [], 95 visible: true, // Default visibility 96 answered: false 97 }; 98 99 this.questions.set(questionId, questionData); 100 this.visibilityState.set(questionId, true); 101 }); 102 103 this.totalSteps = questions.length; 104 this.log(`Loaded ${questions.length} questions`); 105 } 106 107 /** 108 * Load conditional rules from backend or DOM data attributes 109 */ 110 /** 111 * Load conditional rules from DOM data attributes first, then AJAX 112 */ 113 async loadConditionalRules() { 114 try { 115 // First, try to load from DOM data attributes 116 let rulesLoaded = 0; 117 118 document.querySelectorAll(this.config.questionSelector).forEach(questionEl => { 119 const questionId = this.getQuestionId(questionEl); 120 const conditionalLogicData = questionEl.dataset.conditionalLogic; 121 122 if (conditionalLogicData) { 123 try { 124 // HTML decode the data attribute value 125 const decodedData = conditionalLogicData 126 .replace(/"/g, '"') 127 .replace(/'/g, "'") 128 .replace(/</g, '<') 129 .replace(/>/g, '>') 130 .replace(/&/g, '&'); 131 132 this.log(`Loading conditional logic for question ${questionId}:`, decodedData); 133 134 const rules = JSON.parse(decodedData); 135 if (rules && rules.enabled) { 136 this.conditionalRules.set(questionId, rules); 137 this.buildDependencyMap(questionId, rules); 138 rulesLoaded++; 139 this.log(`â Conditional logic loaded for question ${questionId}:`, rules); 140 } 141 } catch (error) { 142 this.log(`â Error parsing conditional logic for question ${questionId}:`, error); 143 this.log(`Raw data:`, conditionalLogicData); 144 } 145 } 146 }); 147 148 // If no DOM rules found, fall back to AJAX loading 149 if (rulesLoaded === 0) {
150 const segmentId = this.getSegmentId(); 151 const questionIds = Array.from(this.questions.keys()); 152 153 // Load conditional logic for each question via AJAX 154 for (const questionId of questionIds) { 155 const rules = await this.fetchConditionalLogic(questionId, segmentId); 156 if (rules && rules.enabled) { 157 this.conditionalRules.set(questionId, rules); 158 this.buildDependencyMap(questionId, rules); 159 rulesLoaded++; 160 } 161 } 162 } 163 164 this.log(`Loaded conditional rules for ${rulesLoaded} questions`); 165 } catch (error) { 166 this.log('Error loading conditional rules:', error); 167 } 168 } 169 170 /** 171 * Add a conditional rule manually (for backward compatibility) 172 */ 173 addRule(questionId, ruleData) { 174 try { 175 let rules; 176 if (typeof ruleData === 'string') { 177 rules = JSON.parse(ruleData); 178 } else { 179 rules = ruleData; 180 } 181 182 if (rules && rules.enabled) { 183 this.conditionalRules.set(questionId, rules); 184 this.buildDependencyMap(questionId, rules); 185 this.log(`Added conditional rule for question ${questionId}`); 186 } 187 } catch (error) { 188 this.log(`Error adding rule for question ${questionId}:`, error); 189 } 190 } 191 192 /** 193 * Build dependency maps for efficient evaluation 194 */ 195 buildDependencyMap(questionId, rules) { 196 const question = this.questions.get(questionId); 197
198 rules.rules.forEach(rule => { 199 const dependsOnId = rule.depends_on_question; 200 201 // Add dependency to current question 202 if (!question.dependsOn.includes(dependsOnId)) { 203 question.dependsOn.push(dependsOnId); 204 } 205 206 // Add reverse dependency to the question being depended on 207 const dependsOnQuestion = this.questions.get(dependsOnId); 208 if (dependsOnQuestion && !dependsOnQuestion.affects.includes(questionId)) { 209 dependsOnQuestion.affects.push(questionId); 210 } 211 }); 212 } 213 214 /** 215 * Set up event listeners for form interactions 216 */ 217 bindEvents() { 218 const form = this.getSelector(this.config.formSelector); 219 if (!form) return; 220 221 // Answer change events 222 form.addEventListener('input', (e) => this.handleAnswerChange(e)); 223 form.addEventListener('change', (e) => this.handleAnswerChange(e)); 224 225 // Navigation button events 226 const nextBtn = this.getSelector(this.config.nextButtonSelector); 227 const prevBtn = this.getSelector(this.config.prevButtonSelector); 228 const submitBtn = this.getSelector(this.config.submitButtonSelector); 229 230 if (nextBtn) { 231 nextBtn.addEventListener('click', (e) => this.handleNext(e)); 232 } 233 234 if (prevBtn) { 235 prevBtn.addEventListener('click', (e) => this.handlePrevious(e)); 236 } 237 238 if (submitBtn) { 239 submitBtn.addEventListener('click', (e) => this.handleSubmit(e)); 240 } 241 242 // Keyboard navigation 243 document.addEventListener('keydown', (e) => this.handleKeyboardNavigation(e)); 244 } 245 246 /** 247 * Handle answer changes with conditional logic evaluation 248 */ 249 handleAnswerChange(event) { 250 const questionElement = event.target.closest(this.config.questionSelector); 251 if (!questionElement) return; 252 253 const questionId = this.getQuestionId(questionElement); 254 const answer = this.getAnswerValue(event.target); 255 256 // Update answer state 257 this.currentAnswers.set(questionId, answer); 258 259 // Mark question as answered 260 const question = this.questions.get(questionId); 261 if (question) { 262 question.answered = !!answer; 263 } 264 265 // Debounce evaluation for performance 266 this.debounceEvaluation(questionId, () => { 267 this.evaluateConditionalLogic(questionId); 268 this.updateNavigationState(); 269 this.updateProgress(); 270 }); 271 272 // Auto-save if enabled 273 if (this.config.autoSaveEnabled) { 274 this.debounceAutoSave(); 275 } 276 } 277 278 /** 279 * Evaluate conditional logic for affected questions 280 */ 281 evaluateConditionalLogic(changedQuestionId) { 282 const question = this.questions.get(changedQuestionId); 283 if (!question) return; 284 285 this.log(`Evaluating conditional logic for changed question: ${changedQuestionId}`); 286 287 // Store the current value for the changed question before syncing 288 const currentChangedValue = this.currentAnswers.get(changedQuestionId); 289 console.log(`ð Preserving current value for Q${changedQuestionId}: "${currentChangedValue}"`); 290 291 // Sync all answers from main wizard if available 292 if (typeof window.sbWizard !== 'undefined' && window.sbWizard.answers) { 293 Object.entries(window.sbWizard.answers).forEach(([qId, answer]) => { 294 // Skip the changed question to preserve its fresh value 295 if (qId == changedQuestionId) { 296 console.log(`âï¸ Skipping sync for changed question Q${qId} to preserve fresh value`); 297 return; 298 } 299 300 if (answer !== undefined && answer !== null && answer !== '') { 301 this.currentAnswers.set(qId, answer); 302 this.log(`Synced answer from main wizard: question ${qId} = "${answer}"`); 303 } 304 }); 305 } 306 307 // Restore the current value for the changed question 308 if (currentChangedValue !== undefined) { 309 this.currentAnswers.set(changedQuestionId, currentChangedValue);
310 console.log(`ð Restored fresh value for Q${changedQuestionId}: "${currentChangedValue}"`); 311 } 312 313 // When a question changes, re-evaluate ALL questions with conditional logic 314 // because any question could potentially be affected 315 this.conditionalRules.forEach((rules, questionId) => { 316 this.evaluateQuestionVisibility(questionId); 317 }); 318 319 // Update visible questions list 320 this.updateVisibleQuestionsList(); 321 322 // DO NOT auto-show questions - let the main wizard handle navigation 323 // Only update visibility state, don't change display 324 } 325 326 /** 327 * Evaluate all conditional rules (convenience method) 328 */ 329 evaluateAllRules() { 330 this.log('=== Evaluating all conditional rules ==='); 331 for (const questionId of this.conditionalRules.keys()) { 332 this.evaluateQuestionVisibility(questionId); 333 } 334 335 // Force DOM update completion before updating navigation 336 setTimeout(() => { 337 this.updateVisibleQuestionsList(); 338 this.log('All conditional rules evaluated - DOM updated'); 339 340 // Trigger wizard navigation update after all DOM changes are complete 341 if (typeof window.sbWizard !== 'undefined' && window.sbWizard.updateNavigation) { 342 setTimeout(() => { 343 console.log('ð Final wizard navigation update after all conditional logic evaluation'); 344 window.sbWizard.updateNavigation(); 345 }, 10); 346 } 347 }, 10); 348 } 349 350 /** 351 * Evaluate visibility for a specific question 352 */ 353 evaluateQuestionVisibility(questionId) { 354 const rules = this.conditionalRules.get(questionId); 355 if (!rules || !rules.enabled) { 356 // No rules - question should remain visible by default 357 this.setQuestionVisibility(questionId, true); 358 return; 359 } 360 361 let isVisible = false; 362 363 if (rules.logic === 'OR') { 364 // OR logic: any rule passes = visible 365 isVisible = rules.rules.some(rule => this.evaluateRule(rule)); 366 } else { 367 // AND logic: all rules must pass = visible 368 isVisible = rules.rules.every(rule => this.evaluateRule(rule)); 369 } 370 371 this.log(`Evaluating question ${questionId}: rules=${JSON.stringify(rules)}, result=${isVisible}`); 372 373 // CRITICAL DEBUG: Check if we reach this point 374 console.log(`ð¨ CRITICAL: About to call setQuestionVisibility(${questionId}, ${isVisible})`); 375 376 // Debug logging before calling setQuestionVisibility 377 console.log(`ð¯ About to call setQuestionVisibility(${questionId}, ${isVisible})`); 378 379 // Apply visibility change 380 this.setQuestionVisibility(questionId, isVisible); 381 382 console.log(`â setQuestionVisibility(${questionId}, ${isVisible}) completed`); 383 } 384 385 /** 386 * Evaluate a single conditional rule 387 */ 388 evaluateRule(rule) { 389 const dependsOnAnswer = this.currentAnswers.get(rule.depends_on_question); 390 391 this.log(`Evaluating rule: depends_on_question=${rule.depends_on_question}, operator=${rule.operator}, value="${rule.value}", actual_answer="${dependsOnAnswer}"`); 392 console.log(`ð RULE DEBUG: Q${rule.depends_on_question} actual="${dependsOnAnswer}" vs expected="${rule.value}" (${rule.operator})`); 393 394 if (dependsOnAnswer === undefined || dependsOnAnswer === null) { 395 this.log(`No answer found for question ${rule.depends_on_question}, rule fails`); 396 console.log(`â Rule fails: No answer for Q${rule.depends_on_question}`); 397 return false; // No answer yet 398 } 399 400 // Handle array values (checkbox questions) 401 const isArrayAnswer = Array.isArray(dependsOnAnswer); 402 this.log(`Answer is array: ${isArrayAnswer}, value: ${JSON.stringify(dependsOnAnswer)}`); 403 404 let result = false;
405 switch (rule.operator) { 406 case 'equals': 407 if (isArrayAnswer) { 408 // For checkbox arrays, check if the value exists in the array 409 result = dependsOnAnswer.includes(rule.value.toString()); 410 this.log(`Array equals check: ${JSON.stringify(dependsOnAnswer)}.includes("${rule.value}") = ${result}`); 411 } else { 412 result = dependsOnAnswer.toString() === rule.value.toString(); 413 } 414 break; 415 416 case 'not_equals': 417 if (isArrayAnswer) { 418 // For checkbox arrays, check if the value does NOT exist in the array 419 result = !dependsOnAnswer.includes(rule.value.toString()); 420 this.log(`Array not_equals check: !${JSON.stringify(dependsOnAnswer)}.includes("${rule.value}") = ${result}`); 421 } else { 422 result = dependsOnAnswer.toString() !== rule.value.toString(); 423 } 424 break; 425 426 case 'not_empty': 427 if (isArrayAnswer) { 428 // For arrays, check if array has any elements 429 result = dependsOnAnswer.length > 0; 430 this.log(`Array not_empty check: ${JSON.stringify(dependsOnAnswer)}.length > 0 = ${result}`); 431 } else { 432 result = dependsOnAnswer !== '' && dependsOnAnswer !== null; 433 } 434 break; 435 436 case 'contains': 437 if (isArrayAnswer) { 438 // For arrays, check if any element contains the search value 439 result = dependsOnAnswer.some(item => item.toString().toLowerCase().includes(rule.value.toString().toLowerCase())); 440 this.log(`Array contains check: some item in ${JSON.stringify(dependsOnAnswer)} contains "${rule.value}" = ${result}`); 441 } else { 442 result = dependsOnAnswer.toString().toLowerCase().includes(rule.value.toString().toLowerCase()); 443 } 444 break; 445 446 case 'greater_than': 447 if (isArrayAnswer) { 448 // For arrays, check if any numeric value in array is greater than rule value 449 result = dependsOnAnswer.some(item => parseFloat(item) > parseFloat(rule.value)); 450 this.log(`Array greater_than check: some item in ${JSON.stringify(dependsOnAnswer)} > ${rule.value} = ${result}`); 451 } else { 452 result = parseFloat(dependsOnAnswer) > parseFloat(rule.value); 453 } 454 break; 455 456 case 'less_than': 457 if (isArrayAnswer) { 458 // For arrays, check if any numeric value in array is less than rule value 459 result = dependsOnAnswer.some(item => parseFloat(item) < parseFloat(rule.value)); 460 this.log(`Array less_than check: some item in ${JSON.stringify(dependsOnAnswer)} < ${rule.value} = ${result}`); 461 } else { 462 result = parseFloat(dependsOnAnswer) < parseFloat(rule.value); 463 } 464 break; 465 466 case 'greater_than_or_equal': 467 if (isArrayAnswer) { 468 // For arrays, check if any numeric value in array is greater than or equal to rule value 469 result = dependsOnAnswer.some(item => parseFloat(item) >= parseFloat(rule.value)); 470 this.log(`Array greater_than_or_equal check: some item in ${JSON.stringify(dependsOnAnswer)} >= ${rule.value} = ${result}`); 471 } else { 472 result = parseFloat(dependsOnAnswer) >= parseFloat(rule.value); 473 } 474 break; 475 476 case 'less_than_or_equal': 477 if (isArrayAnswer) { 478 // For arrays, check if any numeric value in array is less than or equal to rule value 479 result = dependsOnAnswer.some(item => parseFloat(item) <= parseFloat(rule.value)); 480 this.log(`Array less_than_or_equal check: some item in ${JSON.stringify(dependsOnAnswer)} <= ${rule.value} = ${result}`); 481 } else { 482 result = parseFloat(dependsOnAnswer) <= parseFloat(rule.value); 483 } 484 break; 485 486 case 'between': 487 if (Array.isArray(rule.value) && rule.value.length === 2) { 488 const minValue = parseFloat(rule.value[0]); 489 const maxValue = parseFloat(rule.value[1]); 490 if (isArrayAnswer) { 491 // For arrays, check if any numeric value in array is between min and max 492 result = dependsOnAnswer.some(item => { 493 const numValue = parseFloat(item); 494 return numValue >= minValue && numValue <= maxValue; 495 }); 496 this.log(`Array between check: some item in ${JSON.stringify(dependsOnAnswer)} between ${minValue} and ${maxValue} = ${result}`); 497 } else { 498 const numValue = parseFloat(dependsOnAnswer); 499 result = numValue >= minValue && numValue <= maxValue; 500 this.log(`Between check: ${numValue} between ${minValue} and ${maxValue} = ${result}`); 501 } 502 } else { 503 this.log(`Invalid between rule value: ${JSON.stringify(rule.value)}, expected array with 2 elements`); 504 result = false;
505 } 506 break; 507 508 default: 509 this.log(`Unknown operator: ${rule.operator}`); 510 result = false; 511 } 512 513 this.log(`Rule evaluation result: ${result} (${dependsOnAnswer} ${rule.operator} ${rule.value})`); 514 return result; 515 } 516 517 /** 518 * Set question visibility with smooth animations 519 */ 520 setQuestionVisibility(questionId, visible) { 521 console.log(`ð§ setQuestionVisibility(${questionId}, ${visible}) called`); 522 523 const previousVisibility = this.visibilityState.get(questionId); 524 console.log(`Previous visibility for Q${questionId}: ${previousVisibility}`); 525 526 const question = this.questions.get(questionId); 527 console.log(`Question object found for Q${questionId}:`, !!question); 528 if (!question) { 529 console.log(`â No question found for Q${questionId} in questions map`); 530 return; 531 } 532 533 // Always update DOM attribute and visibility state - don't skip if values match 534 // This ensures DOM is in sync even if state tracking shows no change 535 this.visibilityState.set(questionId, visible); 536 console.log(`ð Forced update: Setting Q${questionId} visibility state to ${visible}`); // Update data attribute for wizard navigation - proper DOM handling 537 const questionEl = question.element; 538 if (visible) { 539 questionEl.removeAttribute('data-conditional-hidden'); 540 } else { 541 questionEl.setAttribute('data-conditional-hidden', 'true'); 542 } 543 544 // Force DOM flush for navigation update 545 questionEl.offsetHeight; // Force reflow 546 547 // Update jQuery data for compatibility 548 if (window.jQuery) { 549 window.jQuery(questionEl).data('conditional-hidden', visible ? null : 'true'); 550 } 551 552 this.log(`Question ${questionId} visibility: ${visible ? 'visible' : 'hidden'} (data-conditional-hidden=${questionEl.getAttribute('data-conditional-hidden') || 'null'})`); 553 554 // Debug DOM attribute setting 555 console.log(`ð§ DOM DEBUG Q${questionId}: element exists=${!!questionEl}, attribute set=${questionEl.getAttribute('data-conditional-hidden')}, visible=${visible}`); 556 console.log(`ð§ Element classes:`, questionEl.className); 557 console.log(`ð§ Element data-question-id:`, questionEl.getAttribute('data-question-id')); 558 559 // Update debug info if available 560 this.updateDebugInfo(questionId, visible); 561 562 // Trigger wizard navigation update after DOM changes 563 if (typeof window.sbWizard !== 'undefined' && window.sbWizard.updateNavigation) { 564 setTimeout(() => { 565 console.log(`ð Triggering wizard navigation update for question ${questionId} visibility change`); 566 window.sbWizard.updateNavigation(); 567 }, 10); // Reduced delay since DOM handling is now fixed 568 } 569 } 570 571 /** 572 * Update debug information in the question HTML 573 */ 574 updateDebugInfo(questionId, isVisible) { 575 const question = this.questions.get(questionId); 576 if (!question) return; 577 578 const debugDiv = question.element.querySelector('.sb-wizard-debug-info'); 579 if (!debugDiv) return; 580 581 // Update visibility status 582 const visibilitySpan = debugDiv.querySelector('.debug-visibility-status'); 583 if (visibilitySpan) { 584 visibilitySpan.textContent = isVisible ? 'VISIBLE â ' : 'HIDDEN â'; 585 visibilitySpan.style.color = isVisible ? 'green' : 'red'; 586 visibilitySpan.style.fontWeight = 'bold'; 587 } 588 589 // Update last evaluation time 590 const lastEvalSpan = debugDiv.querySelector('.debug-last-eval'); 591 if (lastEvalSpan) { 592 lastEvalSpan.textContent = new Date().toLocaleTimeString(); 593 } 594 595 // Update current answer 596 const answerSpan = debugDiv.querySelector('.debug-current-answer'); 597 if (answerSpan) { 598 const currentAnswer = this.currentAnswers.get(questionId); 599 answerSpan.textContent = currentAnswer !== undefined ? JSON.stringify(currentAnswer) : 'null';
600 } 601 602 // Update dependencies info 603 const depsSpan = debugDiv.querySelector('.debug-dependencies'); 604 if (depsSpan) { 605 const rules = this.conditionalRules.get(questionId); 606 if (rules && rules.rules) { 607 const depsInfo = rules.rules.map(rule => { 608 const depAnswer = this.currentAnswers.get(rule.depends_on_question); 609 const ruleResult = this.evaluateRule(rule); 610 return `Q${rule.depends_on_question}="${depAnswer}" ${rule.operator} "${rule.value}" = ${ruleResult ? 'â ' : 'â'}`; 611 }).join(' | '); 612 depsSpan.innerHTML = `<br><strong>Rule Evaluation:</strong> ${depsInfo}`; 613 614 // Show overall result 615 const overallResult = rules.logic === 'OR' 616 ? rules.rules.some(rule => this.evaluateRule(rule)) 617 : rules.rules.every(rule => this.evaluateRule(rule)); 618 depsSpan.innerHTML += `<br><strong>Overall (${rules.logic}):</strong> ${overallResult ? 'SHOW â ' : 'HIDE â'}`; 619 } else { 620 depsSpan.textContent = 'None'; 621 } 622 } 623 624 // Update all answers debug info 625 const allAnswersSpan = debugDiv.querySelector('.debug-all-answers'); 626 if (allAnswersSpan) { 627 const allAnswers = []; 628 for (const [qId, answer] of this.currentAnswers.entries()) { 629 allAnswers.push(`Q${qId}="${answer}"`); 630 } 631 allAnswersSpan.textContent = allAnswers.length > 0 ? allAnswers.join(', ') : 'No answers stored'; 632 } 633 } 634 635 /** 636 * Show question with animation 637 */ 638 showQuestion(element) { 639 element.style.display = 'block'; 640 element.style.opacity = '0'; 641 element.style.transform = 'translateY(20px)'; 642 643 // Trigger animation 644 requestAnimationFrame(() => { 645 element.style.transition = `opacity ${this.config.animationDuration}ms ease, transform ${this.config.animationDuration}ms ease`; 646 element.style.opacity = '1'; 647 element.style.transform = 'translateY(0)'; 648 }); 649 650 // Update tab index 651 this.updateTabIndex(element, false); 652 } 653 654 /** 655 * Hide question with animation 656 */ 657 hideQuestion(element) { 658 element.style.transition = `opacity ${this.config.animationDuration}ms ease, transform ${this.config.animationDuration}ms ease`; 659 element.style.opacity = '0'; 660 element.style.transform = 'translateY(-20px)'; 661 662 setTimeout(() => { 663 element.style.display = 'none'; 664 }, this.config.animationDuration); 665 666 // Update tab index 667 this.updateTabIndex(element, true); 668 } 669 670 /** 671 * Clear answer for hidden question 672 */ 673 clearQuestionAnswer(questionId) { 674 const question = this.questions.get(questionId); 675 if (!question) return; 676 677 // Clear answer value 678 this.currentAnswers.delete(questionId); 679 question.answered = false; 680 681 // Clear form inputs 682 const inputs = question.element.querySelectorAll('input, select, textarea'); 683 inputs.forEach(input => { 684 if (input.type === 'checkbox' || input.type === 'radio') { 685 input.checked = false; 686 } else { 687 input.value = ''; 688 } 689 }); 690 } 691 692 /** 693 * Update visible questions list for navigation 694 */ 695 updateVisibleQuestionsList() { 696 this.visibleQuestions = Array.from(this.questions.values()) 697 .filter(q => this.visibilityState.get(q.id)) 698 .sort((a, b) => a.step - b.step); 699 700 this.requiredQuestions = this.visibleQuestions 701 .filter(q => q.required); 702 } 703 704 /** 705 * Update navigation button states 706 */ 707 updateNavigationState() { 708 this.updateVisibleQuestionsList(); 709 710 // Calculate current position in visible questions 711 const currentQuestion = this.getCurrentQuestion(); 712 const currentIndex = this.visibleQuestions.findIndex(q => q.id === currentQuestion?.id); 713 714 // Update navigation flags 715 this.canGoPrev = currentIndex > 0; 716 this.canGoNext = this.canProceedNext(currentIndex); 717 718 // Update button states 719 this.updateNavigationButtons(); 720 721 this.log(`Navigation state: prev=${this.canGoPrev}, next=${this.canGoNext}, step=${currentIndex + 1}/${this.visibleQuestions.length}`); 722 } 723 724 /** 725 * Check if user can proceed to next question 726 */ 727 canProceedNext(currentIndex) { 728 if (currentIndex < 0 || currentIndex >= this.visibleQuestions.length - 1) { 729 return false; // At last question or invalid position 730 } 731 732 // Check if current question is answered (if required) 733 const currentQuestion = this.visibleQuestions[currentIndex]; 734 if (currentQuestion?.required) { 735 return currentQuestion.answered && this.isAnswerValid(currentQuestion.id); 736 } 737 738 return true; // Can proceed if not required or answered 739 } 740 741 /** 742 * Update navigation button visual states 743 */ 744 updateNavigationButtons() { 745 // TEMPORARILY DISABLED - Main wizard handles navigation 746 // The conditional logic and main wizard navigation were conflicting
747 console.log('[SB Wizard] Navigation buttons update skipped - main wizard handles this'); 748 return; 749 750 const nextBtn = this.getSelector(this.config.nextButtonSelector); 751 const prevBtn = this.getSelector(this.config.prevButtonSelector); 752 const submitBtn = this.getSelector(this.config.submitButtonSelector); 753 754 // Previous button 755 if (prevBtn) { 756 prevBtn.disabled = !this.canGoPrev; 757 prevBtn.classList.toggle('disabled', !this.canGoPrev); 758 } 759 760 // Next/Submit button logic 761 const isLastQuestion = this.isAtLastQuestion(); 762 763 if (isLastQuestion) { 764 // Show submit button, hide next 765 if (nextBtn) { 766 nextBtn.style.display = 'none'; 767 } 768 if (submitBtn) { 769 submitBtn.style.display = 'inline-block'; 770 submitBtn.disabled = !this.canSubmit(); 771 submitBtn.classList.toggle('disabled', !this.canSubmit()); 772 } 773 } else { 774 // Show next button, hide submit 775 if (nextBtn) { 776 nextBtn.style.display = 'inline-block'; 777 nextBtn.disabled = !this.canGoNext; 778 nextBtn.classList.toggle('disabled', !this.canGoNext); 779 } 780 if (submitBtn) { 781 submitBtn.style.display = 'none'; 782 } 783 } 784 } 785 786 /** 787 * Handle next button click 788 */ 789 handleNext(event) { 790 event.preventDefault(); 791 792 if (!this.canGoNext) { 793 this.showValidationMessage('Please complete the current question before proceeding.'); 794 return; 795 } 796 797 const currentIndex = this.getCurrentQuestionIndex(); 798 if (currentIndex >= 0 && currentIndex < this.visibleQuestions.length - 1) { 799 this.navigateToQuestion(currentIndex + 1); 800 this.addToNavigationHistory(currentIndex + 1); 801 } 802 } 803 804 /** 805 * Handle previous button click 806 */ 807 handlePrevious(event) { 808 event.preventDefault(); 809 810 if (!this.canGoPrev) { 811 return; 812 } 813 814 const currentIndex = this.getCurrentQuestionIndex(); 815 if (currentIndex > 0) { 816 this.navigateToQuestion(currentIndex - 1); 817 this.addToNavigationHistory(currentIndex - 1); 818 } 819 } 820 821 /** 822 * Navigate to specific question index 823 */ 824 navigateToQuestion(targetIndex) { 825 if (targetIndex < 0 || targetIndex >= this.visibleQuestions.length) { 826 return; 827 } 828 829 // Hide current question 830 const currentQuestion = this.getCurrentQuestion(); 831 if (currentQuestion) { 832 currentQuestion.element.classList.remove('active'); 833 } 834 835 // Show target question 836 const targetQuestion = this.visibleQuestions[targetIndex]; 837 targetQuestion.element.classList.add('active'); 838 targetQuestion.element.scrollIntoView({ behavior: 'smooth', block: 'center' }); 839 840 // Update current step 841 this.currentStep = targetIndex; 842 843 // Update states 844 this.updateNavigationState(); 845 this.updateProgress(); 846 847 this.log(`Navigated to question ${targetIndex + 1}/${this.visibleQuestions.length}`); 848 } 849 850 /** 851 * Handle form submission 852 */ 853 async handleSubmit(event) { 854 event.preventDefault(); 855 856 if (!this.canSubmit()) { 857 this.showValidationErrors(); 858 return; 859 } 860 861 try { 862 const formData = this.collectFormData(); 863 await this.submitForm(formData); 864 } catch (error) { 865 this.log('Submission error:', error); 866 this.showErrorMessage('Submission failed. Please try again.'); 867 } 868 } 869 870 /** 871 * Check if form can be submitted 872 */ 873 canSubmit() { 874 // All required visible questions must be answered 875 return this.requiredQuestions.every(question => 876 question.answered && this.isAnswerValid(question.id) 877 ); 878 } 879 880 /** 881 * Update progress indicator 882 */ 883 updateProgress() {
884 const progressEl = this.getSelector(this.config.progressSelector); 885 if (!progressEl) return; 886 887 const answered = this.visibleQuestions.filter(q => q.answered).length; 888 const total = this.visibleQuestions.length; 889 const percentage = total > 0 ? Math.round((answered / total) * 100) : 0; 890 891 progressEl.style.width = `${percentage}%`; 892 progressEl.setAttribute('aria-valuenow', percentage); 893 progressEl.textContent = `${percentage}% Complete`; 894 895 this.log(`Progress: ${answered}/${total} questions (${percentage}%)`); 896 } 897 898 /** 899 * Auto-save functionality 900 */ 901 setupAutoSave() { 902 this.autoSaveTimer = null; 903 } 904 905 debounceAutoSave() { 906 if (this.autoSaveTimer) { 907 clearTimeout(this.autoSaveTimer); 908 } 909 910 this.autoSaveTimer = setTimeout(() => { 911 this.performAutoSave(); 912 }, this.config.autoSaveDelay); 913 } 914 915 async performAutoSave() { 916 try { 917 const formData = this.collectFormData(); 918 await this.saveProgress(formData); 919 this.showSaveStatus('Saved automatically'); 920 } catch (error) { 921 this.log('Auto-save error:', error); 922 } 923 } 924 925 /** 926 * Utility functions 927 */ 928 getQuestionId(element) { 929 return element.dataset.questionId || element.id; 930 } 931 932 getQuestionType(element) { 933 return element.dataset.questionType || 'text'; 934 } 935 936 isQuestionRequired(element) { 937 return element.dataset.required === 'true' || element.required; 938 } 939 940 getSegmentId() { 941 const form = this.getSelector(this.config.formSelector); 942 return form?.dataset.segmentId || 1; 943 } 944 945 getAnswerValue(input) { 946 if (input.type === 'checkbox') { 947 return input.checked ? input.value : ''; 948 } 949 if (input.type === 'radio') { 950 const form = input.closest('form'); 951 const checked = form?.querySelector(`input[name="${input.name}"]:checked`); 952 return checked ? checked.value : ''; 953 } 954 return input.value; 955 } 956 957 getCurrentQuestion() { 958 return this.visibleQuestions[this.currentStep]; 959 } 960 961 getCurrentQuestionIndex() { 962 return this.currentStep; 963 } 964 965 isAtLastQuestion() { 966 return this.currentStep >= this.visibleQuestions.length - 1; 967 } 968 969 getSelector(selector) { 970 if (!this.cachedSelectors.has(selector)) { 971 this.cachedSelectors.set(selector, document.querySelector(selector)); 972 } 973 return this.cachedSelectors.get(selector); 974 } 975 976 debounceEvaluation(questionId, callback) { 977 if (this.debounceTimers.has(questionId)) { 978 clearTimeout(this.debounceTimers.get(questionId)); 979 } 980 981 this.debounceTimers.set(questionId, setTimeout(callback, 100)); 982 } 983 984 addToNavigationHistory(index) { 985 this.navigationHistory.push(index); 986 if (this.navigationHistory.length > 10) { 987 this.navigationHistory.shift(); 988 } 989 } 990 991 /** 992 * API calls 993 */ 994 async fetchConditionalLogic(questionId, segmentId) { 995 try { 996 const response = await fetch(sbWizardAjax.ajaxurl, { 997 method: 'POST', 998 headers: { 999 'Content-Type': 'application/x-www-form-urlencoded', 1000 }, 1001 body: new URLSearchParams({ 1002 action: 'sbw_get_question_conditional_logic', 1003 nonce: sbWizardAjax.nonce, 1004 question_id: questionId, 1005 segment_id: segmentId 1006 }) 1007 }); 1008 1009 const data = await response.json(); 1010 return data.success ? data.data : null; 1011 } catch (error) { 1012 this.log('Failed to fetch conditional logic:', error); 1013 return null; 1014 } 1015 } 1016 1017 calculateInitialVisibility() { 1018 // Evaluate all conditional rules on page load
1019 this.conditionalRules.forEach((rules, questionId) => { 1020 this.evaluateQuestionVisibility(questionId); 1021 }); 1022 1023 this.updateVisibleQuestionsList(); 1024 } 1025 1026 /** 1027 * Validation functions 1028 */ 1029 isAnswerValid(questionId) { 1030 const question = this.questions.get(questionId); 1031 if (!question) return false; 1032 1033 const answer = this.currentAnswers.get(questionId); 1034 1035 // Check if required question has answer 1036 if (question.required && (!answer || answer === '')) { 1037 return false; 1038 } 1039 1040 // Type-specific validation 1041 switch (question.type) { 1042 case 'email': 1043 return !answer || this.validateEmail(answer); 1044 case 'phone': 1045 return !answer || this.validatePhone(answer); 1046 case 'number': 1047 return !answer || this.validateNumber(answer); 1048 case 'url': 1049 return !answer || this.validateUrl(answer); 1050 default: 1051 return true; 1052 } 1053 } 1054 1055 validateEmail(email) { 1056 const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 1057 return emailRegex.test(email); 1058 } 1059 1060 validatePhone(phone) { 1061 const phoneRegex = /^[\+]?[(]?[0-9\s\-\(\)]{10,}$/; 1062 return phoneRegex.test(phone.replace(/\s/g, '')); 1063 } 1064 1065 validateNumber(number) { 1066 return !isNaN(parseFloat(number)) && isFinite(number); 1067 } 1068 1069 validateUrl(url) { 1070 try { 1071 new URL(url); 1072 return true; 1073 } catch { 1074 return false; 1075 } 1076 } 1077 1078 showValidationMessage(message, type = 'error') { 1079 const messageEl = document.createElement('div'); 1080 messageEl.className = `sb-${type}-message`; 1081 messageEl.textContent = message; 1082 1083 // Remove existing messages 1084 document.querySelectorAll('.sb-error-message, .sb-success-message').forEach(el => el.remove()); 1085 1086 // Add new message 1087 const form = this.getSelector(this.config.formSelector); 1088 if (form) { 1089 form.insertBefore(messageEl, form.firstChild); 1090 1091 // Auto-remove after 5 seconds 1092 setTimeout(() => messageEl.remove(), 5000); 1093 } 1094 } 1095 1096 showValidationErrors() { 1097 const errors = []; 1098 1099 this.requiredQuestions.forEach(question => { 1100 if (!question.answered || !this.isAnswerValid(question.id)) { 1101 errors.push(`Please complete: ${this.getQuestionText(question)}`); 1102 this.highlightQuestionError(question); 1103 } 1104 }); 1105 1106 if (errors.length > 0) { 1107 this.showValidationMessage(`Please complete all required fields:\n${errors.join('\n')}`); 1108 } 1109 } 1110 1111 highlightQuestionError(question) { 1112 question.element.classList.add('invalid'); 1113 question.element.classList.remove('valid'); 1114 1115 // Remove error class after user interacts 1116 const removeError = () => { 1117 question.element.classList.remove('invalid'); 1118 question.element.removeEventListener('input', removeError); 1119 question.element.removeEventListener('change', removeError); 1120 }; 1121 1122 question.element.addEventListener('input', removeError); 1123 question.element.addEventListener('change', removeError); 1124 } 1125 1126 highlightQuestionValid(question) { 1127 question.element.classList.add('valid'); 1128 question.element.classList.remove('invalid'); 1129 } 1130 1131 getQuestionText(question) { 1132 const label = question.element.querySelector('label, .question-text'); 1133 return label ? label.textContent.trim() : `Question ${question.step + 1}`; 1134 } 1135 1136 showSaveStatus(message, isError = false) { 1137 let statusEl = document.querySelector('.sb-save-status'); 1138 if (!statusEl) { 1139 statusEl = document.createElement('div'); 1140 statusEl.className = 'sb-save-status'; 1141 document.body.appendChild(statusEl); 1142 } 1143 1144 statusEl.textContent = message; 1145 statusEl.className = `sb-save-status ${isError ? 'error' : ''} visible`; 1146 1147 // Hide after 3 seconds 1148 setTimeout(() => { 1149 statusEl.classList.remove('visible'); 1150 }, 3000); 1151 } 1152 1153 showErrorMessage(message) { 1154 this.showValidationMessage(message, 'error'); 1155 } 1156 1157 updateTabIndex(element, disabled) { 1158 const inputs = element.querySelectorAll('input, select, textarea, button');
1159 inputs.forEach(input => { 1160 input.tabIndex = disabled ? -1 : 0; 1161 }); 1162 } 1163 1164 handleKeyboardNavigation(event) { 1165 // Ctrl/Cmd + Arrow keys for navigation 1166 if ((event.ctrlKey || event.metaKey)) { 1167 if (event.key === 'ArrowRight' && this.canGoNext) { 1168 event.preventDefault(); 1169 this.handleNext(event); 1170 } else if (event.key === 'ArrowLeft' && this.canGoPrev) { 1171 event.preventDefault(); 1172 this.handlePrevious(event); 1173 } 1174 } 1175 1176 // Enter key to proceed 1177 if (event.key === 'Enter' && !event.target.matches('textarea')) { 1178 if (this.isAtLastQuestion() && this.canSubmit()) { 1179 event.preventDefault(); 1180 this.handleSubmit(event); 1181 } else if (this.canGoNext) { 1182 event.preventDefault(); 1183 this.handleNext(event); 1184 } 1185 } 1186 } 1187 1188 /** 1189 * Data collection and submission 1190 */ 1191 collectFormData() { 1192 const formData = { 1193 segment_id: this.getSegmentId(), 1194 answers: Object.fromEntries(this.currentAnswers), 1195 visible_questions: this.visibleQuestions.map(q => q.id), 1196 navigation_history: this.navigationHistory, 1197 completion_percentage: this.calculateCompletionPercentage(), 1198 timestamp: new Date().toISOString() 1199 }; 1200 1201 return formData; 1202 } 1203 1204 calculateCompletionPercentage() { 1205 const answered = this.visibleQuestions.filter(q => q.answered).length; 1206 const total = this.visibleQuestions.length; 1207 return total > 0 ? Math.round((answered / total) * 100) : 0; 1208 } 1209 1210 async saveProgress(formData) { 1211 const response = await fetch(sbWizardAjax.ajaxurl, { 1212 method: 'POST', 1213 headers: { 1214 'Content-Type': 'application/x-www-form-urlencoded', 1215 }, 1216 body: new URLSearchParams({ 1217 action: 'sbw_save_progress', 1218 nonce: sbWizardAjax.nonce, 1219 form_data: JSON.stringify(formData) 1220 }) 1221 }); 1222 1223 if (!response.ok) { 1224 throw new Error('Save failed'); 1225 } 1226 1227 return response.json(); 1228 } 1229 1230 async submitForm(formData) { 1231 // Show loading state 1232 const submitBtn = this.getSelector(this.config.submitButtonSelector); 1233 if (submitBtn) { 1234 submitBtn.classList.add('sb-loading'); 1235 submitBtn.disabled = true; 1236 submitBtn.textContent = 'Submitting...'; 1237 } 1238 1239 try { 1240 const response = await fetch(sbWizardAjax.ajaxurl, { 1241 method: 'POST', 1242 headers: { 1243 'Content-Type': 'application/x-www-form-urlencoded', 1244 }, 1245 body: new URLSearchParams({ 1246 action: 'sbw_submit_form', 1247 nonce: sbWizardAjax.nonce, 1248 segment_id: this.config.segmentId, 1249 form_data: JSON.stringify(formData) 1250 }) 1251 }); 1252 1253 const result = await response.json(); 1254 1255 if (result.success) { 1256 this.handleSuccessfulSubmission(result.data); 1257 } else { 1258 throw new Error(result.data || 'Submission failed'); 1259 } 1260 } catch (error) { 1261 this.log('Submission error:', error); 1262 this.handleSubmissionError(error); 1263 } 1264 } 1265 1266 /** 1267 * Handle successful form submission 1268 */ 1269 handleSuccessfulSubmission(data) { 1270 // Hide the entire form 1271 const formContainer = this.getSelector('.sb-wizard-form'); 1272 if (formContainer) { 1273 formContainer.style.display = 'none'; 1274 } 1275 1276 // Show completion message 1277 this.showCompletionMessage(data); 1278 1279 // Redirect after delay if URL provided 1280 if (data.redirect_url) { 1281 setTimeout(() => { 1282 window.location.href = data.redirect_url; 1283 }, 2000); 1284 } 1285 } 1286 1287 /** 1288 * Handle submission errors 1289 */ 1290 handleSubmissionError(error) {
1291 const submitBtn = this.getSelector(this.config.submitButtonSelector); 1292 if (submitBtn) { 1293 submitBtn.classList.remove('sb-loading'); 1294 submitBtn.disabled = false; 1295 submitBtn.textContent = 'Submit'; 1296 } 1297 1298 this.showValidationMessage('Submission failed. Please try again.', 'error'); 1299 this.log('Submission error:', error); 1300 } 1301 1302 /** 1303 * Show completion message 1304 */ 1305 showCompletionMessage(data) { 1306 const container = this.getSelector('.sb-wizard-container') || document.body; 1307 1308 const completionDiv = document.createElement('div'); 1309 completionDiv.className = 'sb-wizard-completion'; 1310 completionDiv.innerHTML = ` 1311 <div class="completion-content"> 1312 <div class="completion-icon">â </div> 1313 <h2>Thank You!</h2> 1314 <p>Your responses have been submitted successfully.</p> 1315 ${data.message ? `<p class="completion-message">${data.message}</p>` : ''} 1316 ${data.recommendations ? `<div class="recommendations">${data.recommendations}</div>` : ''} 1317 ${data.redirect_url ? '<p class="redirect-notice">Redirecting you now...</p>' : ''} 1318 </div> 1319 `; 1320 1321 container.appendChild(completionDiv); 1322 1323 // Animate in 1324 setTimeout(() => { 1325 completionDiv.classList.add('show'); 1326 }, 100); 1327 } 1328 1329 log(...args) { 1330 if (this.config.debugMode) { 1331 console.log('[SB Wizard]', ...args); 1332 } 1333 } 1334} 1335 1336// Auto-initialize when DOM is ready 1337document.addEventListener('DOMContentLoaded', () => { 1338 if (document.querySelector('.sb-wizard-form')) { 1339 window.sbWizardConditionalLogic = new SBWizardConditionalLogic({ 1340 debugMode: true // Enable for development 1341 }); 1342 } 1343});
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.