1/** 2 * Frontend JavaScript for WP Assessment 3 * Functional approach for step navigation and answer collection 4 */ 5 6(function() { 7 'use strict'; 8 9 // Get settings from WordPress 10 const settings = window.wpAssessmentSettings || { 11 animation_type: 'fade', 12 animation_duration: 300 13 }; 14 15 // Initialize global debug object 16 window.wpAssessmentDebug = { 17 state: null, 18 settings: settings, 19 localStorage: {}, 20 history: [], 21 events: [], 22 23 // Helper functions 24 printState: function() { 25 console.group('%c WP Assessment - Current State', 'color: #0073aa; font-weight: bold; font-size: 14px;'); 26 console.log('%cState Object:', 'font-weight: bold;'); 27 console.table(this.state); 28 console.log('%cAnswers:', 'font-weight: bold;'); 29 console.table(this.state?.answers || []); 30 console.groupEnd(); 31 return this.state; 32 }, 33 34 printHistory: function() { 35 console.group('%c WP Assessment - State History', 'color: #0073aa; font-weight: bold; font-size: 14px;'); 36 console.log(`Total transitions: ${this.history.length}`); 37 console.table(this.history); 38 console.groupEnd(); 39 return this.history; 40 }, 41 42 printEvents: function() { 43 console.group('%c WP Assessment - Events Log', 'color: #0073aa; font-weight: bold; font-size: 14px;'); 44 console.log(`Total events: ${this.events.length}`); 45 console.table(this.events); 46 console.groupEnd(); 47 return this.events; 48 }, 49 50 export: function() { 51 const data = { 52 state: this.state, 53 settings: this.settings, 54 localStorage: this.localStorage, 55 history: this.history, 56 events: this.events, 57 timestamp: new Date().toISOString() 58 }; 59 60 const json = JSON.stringify(data, null, 2); 61 62 // Try to copy to clipboard 63 if (navigator.clipboard) { 64 navigator.clipboard.writeText(json).then(() => { 65 console.log('%câ Debug data copied to clipboard!', 'color: #46b450; font-weight: bold;'); 66 }).catch(() => { 67 console.log('%cDebug data:', 'font-weight: bold;'); 68 console.log(json); 69 }); 70 } else { 71 console.log('%cDebug data:', 'font-weight: bold;'); 72 console.log(json); 73 } 74 75 return data; 76 }, 77 78 clear: function() { 79 this.history = []; 80 this.events = []; 81 console.log('%câ Debug history cleared', 'color: #46b450;'); 82 } 83 }; 84 85 /** 86 * Send log to server (file) 87 */ 88 const sendLogToServer = (type, message, data = null) => { 89 if (!settings.debug_mode || !settings.ajax_url) return; 90 91 const formData = new FormData(); 92 formData.append('action', 'wp_assessment_log_js'); 93 formData.append('nonce', settings.nonce); 94 formData.append('type', type); 95 formData.append('message', message); 96 if (data) { 97 formData.append('data', JSON.stringify(data, null, 2)); 98 } 99 100 // Send async (don't wait for response) 101 fetch(settings.ajax_url, { 102 method: 'POST', 103 body: formData 104 }).catch(() => { 105 // Silently fail - don't block user experience 106 }); 107 }; 108 109 /** 110 * Log debug event 111 */ 112 const logDebugEvent = (type, message, data = null) => { 113 const event = { 114 timestamp: new Date().toISOString(), 115 type: type, 116 message: message, 117 data: data 118 }; 119 120 window.wpAssessmentDebug.events.push(event); 121 122 // Log to console if debug mode enabled 123 if (settings.debug_mode) { 124 const color = type === 'ERROR' ? '#dc3232' : type === 'WARNING' ? '#f0b849' : '#0073aa'; 125 console.log(`%c[${type}] ${message}`, `color: ${color};`, data || ''); 126 } 127
128 // Send to server log file 129 sendLogToServer(type, message, data); 130 }; 131 132 /** 133 * Log state transition 134 */ 135 const logStateTransition = (fromState, toState, action) => { 136 const transition = { 137 timestamp: new Date().toISOString(), 138 action: action, 139 from: { 140 currentStep: fromState?.currentStep, 141 answerCount: fromState?.answers?.length || 0 142 }, 143 to: { 144 currentStep: toState.currentStep, 145 answerCount: toState.answers.length 146 } 147 }; 148 149 window.wpAssessmentDebug.history.push(transition); 150 window.wpAssessmentDebug.state = toState; 151 152 logDebugEvent('INFO', `State transition: ${action}`, transition); 153 }; 154 155 /** 156 * Update container height to match the active step 157 * This ensures .wp-assessment-steps always matches the active step's height 158 */ 159 const updateContainerHeight = (container, targetStepEl) => { 160 const stepsContainer = container.querySelector('.wp-assessment-steps'); 161 if (!stepsContainer) return; 162 163 // If no target provided, find the active step 164 const stepEl = targetStepEl || container.querySelector('.wp-assessment-step.active'); 165 if (!stepEl) return; 166 167 // Use max of offsetHeight and scrollHeight to ensure all content is visible 168 const stepHeight = Math.max(stepEl.offsetHeight, stepEl.scrollHeight); 169 170 // Apply height to container 171 stepsContainer.style.height = `${stepHeight}px`; 172 }; 173 174 /** 175 * Create initial state 176 */ 177 const createInitialState = (steps) => ({ 178 steps: steps, 179 currentStep: 0, 180 answers: [], 181 answerCache: {}, // All answers ever given, indexed by stepName 182 pathTaken: [], // Steps visited on the final path 183 totalSteps: steps.length 184 }); 185 186 /** 187 * Initialize assessment 188 */ 189 const init = () => { 190 const containers = document.querySelectorAll('.wp-assessment-container'); 191 192 // Store container and state references for popstate handler 193 const assessments = []; 194 195 containers.forEach(container => { 196 const dataElement = container.querySelector('.wp-assessment-data'); 197 if (!dataElement) return; 198 199 try { 200 const rawJson = dataElement.textContent; 201 const steps = JSON.parse(rawJson); 202 const state = createInitialState(steps); 203 204 const assessment = setupAssessment(container, state); 205 assessments.push(assessment); 206 } catch (error) { 207 console.error('Failed to initialize assessment:', error); 208 } 209 }); 210 211 // Handle browser back/forward buttons (global handler) 212 window.addEventListener('popstate', (e) => { 213 if (e.state && e.state.step !== undefined) { 214 assessments.forEach(({ container, getState, setState }) => { 215 const targetStep = e.state.step; 216 const currentState = getState(); 217 const newState = { ...currentState, currentStep: targetStep }; 218 219 navigateToStep(container, newState, targetStep); 220 updateProgress(container, newState); 221 saveStateToLocalStorage(newState); 222 broadcastStateToDebug(newState); 223 setState(newState); 224 }); 225 } 226 }); 227 228 // Handle window resize - update container heights 229 let resizeTimeout; 230 window.addEventListener('resize', () => { 231 // Debounce resize events 232 clearTimeout(resizeTimeout); 233 resizeTimeout = setTimeout(() => { 234 assessments.forEach(({ container, getState }) => { 235 const activeStep = container.querySelector('.wp-assessment-step.active'); 236 if (activeStep) { 237 updateContainerHeight(container, activeStep); 238 } 239 }); 240 }, 100); 241 }); 242 243 // Initialize debug panel if present 244 initDebugPanel(); 245 }; 246 247 /** 248 * Setup assessment with event listeners 249 */ 250 const setupAssessment = (container, initialState) => { 251 let state = initialState; 252 253 // Add assessment ID to state 254 const assessmentId = container.dataset.assessmentId; 255 state = { ...state, assessmentId }; 256 257 // Check URL for initial step 258 const urlStep = getStepFromURL(); 259 260 // If no query string, clear localStorage and start fresh 261 if (urlStep === null) { 262 clearLocalStorage(assessmentId); 263 logDebugEvent('INFO', 'No query string - starting fresh assessment', { assessmentId }); 264 state = { ...state, currentStep: 0, answers: [] }; 265 navigateToStep(container, state, 0); 266 } else if (urlStep >= 0 && urlStep < state.totalSteps) { 267 // URL has a step - try to restore from localStorage 268 const savedState = loadStateFromLocalStorage(assessmentId); 269 if (savedState) { 270 state = { ...state, ...savedState }; 271 } 272 state = { ...state, currentStep: urlStep }; 273 navigateToStep(container, state, urlStep); 274 } else { 275 // Invalid step in URL - start fresh 276 clearLocalStorage(assessmentId); 277 state = { ...state, currentStep: 0, answers: [] }; 278 navigateToStep(container, state, 0); 279 } 280 281 // Set animation duration CSS variable 282 container.style.setProperty('--wp-assessment-duration', settings.animation_duration + 'ms'); 283 284 // Radio buttons handle their own selection state natively 285 286 // Next button (for multi-field steps) 287 container.addEventListener('click', (e) => { 288 if (e.target.classList.contains('wp-assessment-next-btn')) { 289 e.preventDefault(); 290 logDebugEvent('CLICK', 'Next button clicked', { 291 currentStep: state.currentStep, 292 pathTaken: state.pathTaken 293 }); 294 state = handleNextButton(container, state); 295 } 296 // Back button 297 else if (e.target.classList.contains('wp-assessment-back-btn')) { 298 e.preventDefault(); 299 logDebugEvent('CLICK', 'Back button clicked', { 300 currentStep: state.currentStep, 301 pathTaken: state.pathTaken 302 }); 303 state = handleBackButton(container, state); 304 } 305 // Restart button 306 else if (e.target.classList.contains('wp-assessment-restart-btn')) { 307 e.preventDefault(); 308 logDebugEvent('CLICK', 'Restart button clicked', { 309 currentStep: state.currentStep 310 }); 311 state = resetAssessment(container, state); 312 } 313 // Radio button clicks 314 else if (e.target.classList.contains('wp-assessment-radio-input')) { 315 logDebugEvent('CLICK', 'Radio button clicked', { 316 value: e.target.value, 317 fieldName: e.target.name, 318 currentStep: state.currentStep 319 }); 320 321 // Check for auto-advance 322 const currentStepData = state.steps[state.currentStep]; 323 const fields = currentStepData.fields || []; 324 325 // Only auto-advance if step has exactly one field 326 if (fields.length === 1) { 327 const field = fields[0]; 328 329 // Check if field is radio type and has autoAdvance enabled (handle both boolean and string values) 330 if (field.type === 'radio' && (field.autoAdvance === true || field.autoAdvance === '1' || field.autoAdvance === 1)) { 331 // Check if an animation is currently in progress 332 const hasActiveAnimation = container.querySelector('.wp-assessment-step.fade-out, .wp-assessment-step.fade-in'); 333 334 logDebugEvent('AUTO-ADVANCE', 'Auto-advance triggered', { 335 fieldName: field.name, 336 value: e.target.value, 337 autoAdvanceValue: field.autoAdvance, 338 currentStep: state.currentStep, 339 animationInProgress: !!hasActiveAnimation 340 }); 341 342 if (!hasActiveAnimation) { 343 // Auto-advance immediately if no animation in progress 344 state = handleNextButton(container, state); 345 } else { 346 logDebugEvent('AUTO-ADVANCE', 'Auto-advance blocked - animation in progress', { 347 currentStep: state.currentStep 348 }); 349 } 350 } 351 } 352 } 353 }); 354 355 // Initial render 356 updateProgress(container, state); 357 updateURL(state.currentStep, true); // Replace initial state 358 broadcastStateToDebug(state); 359 360 // Recalculate height after content settles (for shortcodes, forms, images) 361 // Multiple timeouts to catch different loading scenarios
362 [200, 500, 1000].forEach(delay => { 363 setTimeout(() => { 364 const activeStep = container.querySelector('.wp-assessment-step.active'); 365 if (activeStep) { 366 updateContainerHeight(container, activeStep); 367 } 368 }, delay); 369 }); 370 371 // Also recalculate on window load (for images/iframes) 372 window.addEventListener('load', () => { 373 const activeStep = container.querySelector('.wp-assessment-step.active'); 374 if (activeStep) { 375 updateContainerHeight(container, activeStep); 376 } 377 }); 378 379 // Listen for custom event to force height recalculation 380 document.addEventListener('wp-assessment:recalculate-height', () => { 381 const activeStep = container.querySelector('.wp-assessment-step.active'); 382 if (activeStep) { 383 updateContainerHeight(container, activeStep); 384 } 385 }); 386 387 // Return state getter/setter for popstate handler 388 return { 389 container, 390 getState: () => state, 391 setState: (newState) => { state = newState; } 392 }; 393 }; 394 395 // Radio buttons manage their own selection state natively 396 397 /** 398 * Handle Back button click 399 * Goes back to the last step in the path taken (not just previous step index) 400 */ 401 const handleBackButton = (container, state) => { 402 // Find current step name in pathTaken 403 const currentStepData = state.steps[state.currentStep]; 404 const currentStepName = currentStepData.name || state.currentStep.toString(); 405 const currentPathIndex = state.pathTaken.indexOf(currentStepName); 406 407 // If current step not in path or at beginning, can't go back 408 if (currentPathIndex <= 0) { 409 logDebugEvent('INFO', 'Cannot go back - at beginning of path', { 410 currentStep: state.currentStep, 411 currentStepName: currentStepName, 412 currentPathIndex: currentPathIndex, 413 pathTaken: state.pathTaken 414 }); 415 return state; 416 } 417 418 // Get the previous step name from path taken 419 const previousStepName = state.pathTaken[currentPathIndex - 1]; 420 421 // Find that step's index in steps array 422 const previousStepIndex = state.steps.findIndex(step => step.name === previousStepName); 423 424 if (previousStepIndex === -1) { 425 logDebugEvent('ERROR', 'Could not find previous step in steps array', { 426 previousStepName: previousStepName, 427 pathTaken: state.pathTaken 428 }); 429 return state; 430 } 431 432 logDebugEvent('INFO', 'Going back to previous step in path', { 433 from: state.currentStep, 434 fromName: currentStepName, 435 to: previousStepIndex, 436 toName: previousStepName, 437 currentPathIndex: currentPathIndex, 438 pathTaken: state.pathTaken 439 }); 440 441 // Trim pathTaken to only include steps up to the target step 442 const newPathTaken = state.pathTaken.slice(0, currentPathIndex); 443 444 // Update state 445 const newState = { 446 ...state, 447 currentStep: previousStepIndex, 448 pathTaken: newPathTaken 449 }; 450 451 // Start navigation animation FIRST, update UI AFTER animation completes 452 navigateToStep(container, newState, previousStepIndex, () => { 453 // Callback: runs after animation finishes 454 updateProgress(container, newState); 455 updateURL(previousStepIndex); 456 saveStateToLocalStorage(newState); 457 broadcastStateToDebug(newState); 458 }); 459 460 return newState; 461 }; 462 463 /** 464 * Handle Next button click (multi-field steps) 465 */ 466 const handleNextButton = (container, state) => { 467 const currentStepEl = container.querySelector(`[data-step="${state.currentStep}"]`); 468 if (!currentStepEl) return state; 469 470 // Check if this step has fields 471 const hasFields = currentStepEl.dataset.hasFields === '1'; 472 473 // For steps without fields, skip validation and just continue 474 if (!hasFields) { 475 return handleAnswer(container, state, 'continue'); 476 } 477 478 // Validate required fields 479 const validationErrors = validateRequiredFields(currentStepEl); 480 if (validationErrors.length > 0) { 481 alert(validationErrors.join('\n')); 482 return state; 483 } 484 485 // Find checked radio or checkbox input (if any) 486 const checkedRadio = currentStepEl.querySelector('.wp-assessment-radio-input:checked'); 487 const checkedCheckbox = currentStepEl.querySelector('.wp-assessment-checkbox-input:checked'); 488 489 // Get the answer value (prefer radio, then checkbox) 490 let answer = null; 491 if (checkedRadio) { 492 answer = checkedRadio.value; 493 } else if (checkedCheckbox) { 494 // For checkboxes with routing, use the first checked value 495 answer = checkedCheckbox.value; 496 } 497
498 // If no answer was selected but this step has routing, we need a selection 499 const currentStepData = state.steps[state.currentStep]; 500 if (!answer && currentStepData.routing && Object.keys(currentStepData.routing).length > 0) { 501 // Check if there's a radio or checkbox field that requires selection 502 const radioField = currentStepEl.querySelector('.wp-assessment-radio-field'); 503 const checkboxField = currentStepEl.querySelector('.wp-assessment-checkbox-field'); 504 if (radioField || checkboxField) { 505 alert('Please select an option before proceeding.'); 506 return state; 507 } 508 } 509 510 // For steps without routing, use 'next' as answer 511 if (!answer) { 512 answer = 'next'; 513 } 514 515 return handleAnswer(container, state, answer); 516 }; 517 518 /** 519 * Validate required fields in current step 520 */ 521 const validateRequiredFields = (stepEl) => { 522 const errors = []; 523 const requiredFields = stepEl.querySelectorAll('.wp-assessment-field[data-required="1"]'); 524 525 requiredFields.forEach(field => { 526 const fieldName = field.dataset.fieldName; 527 528 // Check radio field 529 if (field.classList.contains('wp-assessment-radio-field')) { 530 const checkedRadio = field.querySelector('.wp-assessment-radio-input:checked'); 531 if (!checkedRadio) { 532 const label = field.querySelector('.wp-assessment-field-label'); 533 const labelText = label ? label.textContent.replace('*', '').trim() : fieldName; 534 errors.push(`Please select an option for: ${labelText}`); 535 } 536 } 537 // Check checkbox field 538 else if (field.classList.contains('wp-assessment-checkbox-field')) { 539 const checkedCheckboxes = field.querySelectorAll('.wp-assessment-checkbox-input:checked'); 540 if (checkedCheckboxes.length === 0) { 541 const label = field.querySelector('.wp-assessment-field-label'); 542 const labelText = label ? label.textContent.replace('*', '').trim() : fieldName; 543 errors.push(`Please select at least one option for: ${labelText}`); 544 } 545 } 546 // Check text input 547 else if (field.classList.contains('wp-assessment-text-field')) { 548 const input = field.querySelector('.wp-assessment-text-input'); 549 if (input && !input.value.trim()) { 550 const label = field.querySelector('.wp-assessment-field-label'); 551 const labelText = label ? label.textContent.replace('*', '').trim() : fieldName; 552 errors.push(`Please fill in: ${labelText}`); 553 } 554 } 555 // Check number input 556 else if (field.classList.contains('wp-assessment-number-field')) { 557 const input = field.querySelector('.wp-assessment-number-input'); 558 if (input && !input.value.trim()) { 559 const label = field.querySelector('.wp-assessment-field-label'); 560 const labelText = label ? label.textContent.replace('*', '').trim() : fieldName; 561 errors.push(`Please fill in: ${labelText}`); 562 } 563 } 564 // Check textarea 565 else if (field.classList.contains('wp-assessment-textarea-field')) { 566 const input = field.querySelector('.wp-assessment-textarea-input'); 567 if (input && !input.value.trim()) { 568 const label = field.querySelector('.wp-assessment-field-label'); 569 const labelText = label ? label.textContent.replace('*', '').trim() : fieldName; 570 errors.push(`Please fill in: ${labelText}`); 571 } 572 } 573 // Check height_weight field 574 else if (field.classList.contains('wp-assessment-height-weight-field')) { 575 const feetInput = field.querySelector('.wp-assessment-height-feet-input'); 576 const inchesInput = field.querySelector('.wp-assessment-height-inches-input'); 577 const weightInput = field.querySelector('.wp-assessment-weight-input'); 578 579 if (!feetInput?.value.trim()) { 580 errors.push('Please enter your height in feet'); 581 } 582 if (!inchesInput?.value.trim()) { 583 errors.push('Please enter your height in inches'); 584 } 585 if (!weightInput?.value.trim()) { 586 errors.push('Please enter your weight'); 587 } 588 } 589 }); 590 591 return errors; 592 }; 593 594 /** 595 * Collect all field values from current step 596 */ 597 const collectFieldValues = (container, state) => { 598 const currentStepEl = container.querySelector(`[data-step="${state.currentStep}"]`); 599 if (!currentStepEl) return {}; 600 601 const fields = currentStepEl.querySelectorAll('.wp-assessment-field'); 602 const fieldValues = {}; 603 604 fields.forEach(field => {
605 const fieldName = field.dataset.fieldName; 606 607 // Radio input 608 const radioInput = field.querySelector('.wp-assessment-radio-input:checked'); 609 if (radioInput) { 610 fieldValues[fieldName] = radioInput.value; 611 } 612 613 // Checkbox inputs 614 const checkedCheckboxes = field.querySelectorAll('.wp-assessment-checkbox-input:checked'); 615 if (checkedCheckboxes.length > 0) { 616 fieldValues[fieldName] = Array.from(checkedCheckboxes).map(cb => cb.value); 617 } 618 619 // Text input 620 const textInput = field.querySelector('.wp-assessment-text-input'); 621 if (textInput) { 622 fieldValues[fieldName] = textInput.value; 623 } 624 625 // Number input 626 const numberInput = field.querySelector('.wp-assessment-number-input'); 627 if (numberInput) { 628 fieldValues[fieldName] = numberInput.value; 629 } 630 631 // Textarea 632 const textareaInput = field.querySelector('.wp-assessment-textarea-input'); 633 if (textareaInput) { 634 fieldValues[fieldName] = textareaInput.value; 635 } 636 637 // Height & Weight composite field 638 if (field.classList.contains('wp-assessment-height-weight-field')) { 639 const feetInput = field.querySelector('.wp-assessment-height-feet-input'); 640 const inchesInput = field.querySelector('.wp-assessment-height-inches-input'); 641 const weightInput = field.querySelector('.wp-assessment-weight-input'); 642 643 if (feetInput) { 644 fieldValues[`${fieldName}_feet`] = feetInput.value; 645 } 646 if (inchesInput) { 647 fieldValues[`${fieldName}_inches`] = inchesInput.value; 648 } 649 if (weightInput) { 650 fieldValues[`${fieldName}_weight`] = weightInput.value; 651 } 652 653 // Also store computed values for convenience 654 const feet = parseInt(feetInput?.value) || 0; 655 const inches = parseInt(inchesInput?.value) || 0; 656 const weight = parseFloat(weightInput?.value) || 0; 657 658 // Total height in inches 659 const totalHeightInches = (feet * 12) + inches; 660 fieldValues[`${fieldName}_height_inches`] = totalHeightInches; 661 662 // Calculate BMI if we have both height and weight 663 if (totalHeightInches > 0 && weight > 0) { 664 // BMI = (weight in lbs / (height in inches)²) à 703 665 const bmi = (weight / (totalHeightInches * totalHeightInches)) * 703; 666 fieldValues[`${fieldName}_bmi`] = Math.round(bmi * 10) / 10; // Round to 1 decimal 667 } 668 } 669 }); 670 671 return fieldValues; 672 }; 673 674 /** 675 * Handle answer selection (button click) 676 */ 677 const handleAnswer = (container, state, answer) => { 678 const currentStepData = state.steps[state.currentStep]; 679 680 // Collect all field values from the step 681 const fieldValues = collectFieldValues(container, state); 682 683 // Record answer with all field data 684 const stepName = currentStepData.name || state.currentStep.toString(); 685 const answerRecord = { 686 question: currentStepData.label || currentStepData.name || `Step ${state.currentStep + 1}`, 687 answer: answer, 688 stepName: stepName, 689 stepLabel: currentStepData.label || currentStepData.name || `Step ${state.currentStep + 1}` 690 }; 691 692 // Add any additional field values 693 if (Object.keys(fieldValues).length > 0) { 694 answerRecord.fields = fieldValues; 695 } 696 697 // Update answer cache (stores ALL answers for restoration) 698 const newAnswerCache = { 699 ...state.answerCache, 700 [stepName]: answerRecord 701 }; 702 703 // Update path taken 704 // If step is already in path, this means we're going back and changing answer 705 const existingPathIndex = state.pathTaken.indexOf(stepName); 706 let newPathTaken; 707 if (existingPathIndex >= 0) { 708 // Step exists in path - trim everything after it (path changed) 709 newPathTaken = state.pathTaken.slice(0, existingPathIndex + 1); 710 } else { 711 // New step - add to path 712 newPathTaken = [...state.pathTaken, stepName]; 713 } 714 715 // Build answers array from pathTaken for results display 716 const newAnswers = newPathTaken 717 .map(sName => newAnswerCache[sName]) 718 .filter(a => a); // Filter out any undefined 719 720 const newState = { 721 ...state, 722 answers: newAnswers, 723 answerCache: newAnswerCache, 724 pathTaken: newPathTaken 725 }; 726 727 // Get routing from field that controls routing 728 let routing = 'next'; 729 730 // Find the field that controls routing for this step 731 let routingField = null; 732 const fields = currentStepData.fields || []; 733 734 // Find the first field with options (radio/checkbox) - that controls routing 735 routingField = fields.find(f => f.options && f.options.length > 0); 736 737 if (routingField && routingField.options) { 738 // Check if this is a multi-select (checkbox) field 739 const isMultiSelect = routingField.type === 'checkbox'; 740 const selectedValues = fieldValues[routingField.name]; 741 742 console.log('ð ROUTING DECISION:', { 743 routingFieldName: routingField.name, 744 isMultiSelect: isMultiSelect, 745 selectedValues: selectedValues, 746 allOptions: routingField.options, 747 }); 748 749 if (isMultiSelect && Array.isArray(selectedValues)) { 750 // MULTI-SELECT ROUTING LOGIC 751 // Default to 'next' for multi-select steps 752 // Look for first selected option with an explicit (non-empty, non-'next') goTo 753 let foundExplicitRoute = null; 754 755 for (const value of selectedValues) { 756 const option = routingField.options.find(opt => opt.value === value); 757 if (option && option.goTo && option.goTo !== '' && option.goTo !== 'next') { 758 foundExplicitRoute = option.goTo; 759 console.log('ð¯ Multi-select: Found explicit route', { 760 value: value, 761 goTo: option.goTo 762 }); 763 break; // First explicit match wins 764 } 765 } 766 767 if (foundExplicitRoute) { 768 routing = foundExplicitRoute; 769 } 770 // Otherwise, routing stays as 'next' (default for multi-select) 771 772 console.log('â¡ï¸ MULTI-SELECT FINAL ROUTING:', routing); 773 774 logDebugEvent('DEBUG', 'Multi-select routing decision', { 775 routingFieldName: routingField.name, 776 selectedValues: selectedValues, 777 foundExplicitRoute: foundExplicitRoute, 778 finalRouting: routing 779 }); 780 } else { 781 // SINGLE-SELECT (RADIO) ROUTING LOGIC - existing behavior 782 let answerValue = answer; 783 if (typeof answer === 'object' && answer !== null && !Array.isArray(answer)) { 784 answerValue = answer[routingField.name]; 785 } 786 787 // Find the selected option 788 const selectedOption = routingField.options.find(opt => opt.value === answerValue); 789 790 console.log('ð¯ SELECTED OPTION:', selectedOption); 791 792 if (selectedOption && selectedOption.goTo) { 793 routing = selectedOption.goTo; 794 } 795 796 console.log('â¡ï¸ FINAL ROUTING:', routing); 797 798 logDebugEvent('DEBUG', 'Single-select routing decision', { 799 routingFieldName: routingField.name, 800 answer: answer, 801 answerValue: answerValue, 802 selectedOption: selectedOption, 803 routing: routing 804 }); 805 } 806 } else if (currentStepData.defaultRouting) { 807 // Fallback for info steps or steps without routing fields 808 routing = currentStepData.defaultRouting; 809 console.log('ð USING DEFAULT ROUTING:', routing); 810 } 811 812 logDebugEvent('INFO', 'Answer recorded with routing', { 813 answer: answer, 814 routing: routing, 815 stepName: currentStepData.name, 816 fields: fieldValues, 817 routingField: routingField ? routingField.name : 'none' 818 }); 819 820 // Dispatch step complete event 821 const currentStepEl = container.querySelector(`[data-step="${state.currentStep}"]`); 822 if (currentStepEl) { 823 dispatchStepEvent('complete', currentStepEl, newState, container); 824 } 825 826 // Handle routing 827 if (routing === 'results') { 828 // Route to results step by slug 829 return goToSpecificStep(container, newState, 'results'); 830 } else if (routing.startsWith('step:')) { 831 // Route to specific step 832 const targetStepSlug = routing.substring(5); // Remove 'step:' prefix 833 return goToSpecificStep(container, newState, targetStepSlug); 834 } else { 835 // Default: next step (or stay on current if no more steps) 836 return goToNextStep(container, newState); 837 } 838 }; 839 840 /**
841 * Go to next step 842 */ 843 const goToNextStep = (container, state) => { 844 const nextStep = state.currentStep + 1; 845 846 // Check if we've reached the end of the assessment 847 if (nextStep >= state.totalSteps) { 848 logDebugEvent('INFO', 'Reached end of assessment, staying on current step', { currentStep: state.currentStep, totalSteps: state.totalSteps }); 849 // Stay on current step (should be a results step created by user) 850 return state; 851 } 852 853 logDebugEvent('INFO', 'Navigating to next step (sequential)', { from: state.currentStep, to: nextStep }); 854 855 // Add target step to pathTaken if not already there 856 const targetStepData = state.steps[nextStep]; 857 const targetStepName = targetStepData.name || nextStep.toString(); 858 let newPathTaken = [...state.pathTaken]; 859 860 if (!newPathTaken.includes(targetStepName)) { 861 newPathTaken.push(targetStepName); 862 } 863 864 const newState = { 865 ...state, 866 currentStep: nextStep, 867 pathTaken: newPathTaken 868 }; 869 870 logStateTransition(state, newState, 'NEXT_STEP'); 871 872 // Start navigation animation FIRST, update UI AFTER animation completes 873 navigateToStep(container, newState, nextStep, () => { 874 // Callback: runs after animation finishes 875 updateProgress(container, newState); 876 updateURL(nextStep); 877 saveStateToLocalStorage(newState); 878 broadcastStateToDebug(newState); 879 }); 880 881 return newState; 882 }; 883 884 /** 885 * Go to specific step by slug 886 */ 887 const goToSpecificStep = (container, state, targetStepSlug) => { 888 // Find step index by slug 889 const targetStepIndex = state.steps.findIndex(step => step.name === targetStepSlug); 890 891 if (targetStepIndex === -1) { 892 logDebugEvent('ERROR', 'Could not find target step', { targetStepSlug: targetStepSlug }); 893 // Fallback to next step 894 return goToNextStep(container, state); 895 } 896 897 logDebugEvent('INFO', 'Navigating to specific step (routed)', { 898 from: state.currentStep, 899 to: targetStepIndex, 900 targetSlug: targetStepSlug 901 }); 902 903 // Add target step to pathTaken if not already there 904 let newPathTaken = [...state.pathTaken]; 905 if (!newPathTaken.includes(targetStepSlug)) { 906 newPathTaken.push(targetStepSlug); 907 } 908 909 const newState = { 910 ...state, 911 currentStep: targetStepIndex, 912 pathTaken: newPathTaken 913 }; 914 915 logStateTransition(state, newState, 'ROUTED_STEP'); 916 917 // Start navigation animation FIRST, update UI AFTER animation completes 918 navigateToStep(container, newState, targetStepIndex, () => { 919 // Callback: runs after animation finishes 920 updateProgress(container, newState); 921 updateURL(targetStepIndex); 922 saveStateToLocalStorage(newState); 923 broadcastStateToDebug(newState); 924 }); 925 926 return newState; 927 }; 928 929 /** 930 * Show results 931 */ 932 const showResults = (container, state) => { 933 // Hide current step 934 const currentStepEl = container.querySelector(`[data-step="${state.currentStep}"]`); 935 if (currentStepEl) { 936 currentStepEl.classList.remove('active'); 937 } 938 939 // Show results 940 const resultsEl = container.querySelector('[data-step="results"]'); 941 if (resultsEl) { 942 resultsEl.classList.add('active'); 943 } 944 945 // Format and display answers (pass container to find recommendations across all steps) 946 const resultsText = formatResults(state.answers, state.steps, container); 947 const textarea = container.querySelector('.wp-assessment-results-textarea'); 948 if (textarea) { 949 textarea.value = resultsText; 950 } 951 952 // Update progress to 100% 953 const newState = { 954 ...state, 955 currentStep: state.totalSteps 956 }; 957 958 updateProgress(container, newState); 959 updateURL('results'); 960 clearLocalStorage(state.assessmentId); // Clear saved state when completed
961 broadcastStateToDebug(newState); 962 963 return newState; 964 }; 965 966 /** 967 * Extract visible recommendations from the DOM 968 * @param {HTMLElement} container - The step container to scan for recommendations 969 * @returns {Object} Object with coaching and medical arrays 970 */ 971 const extractVisibleRecommendations = (container) => { 972 const recommendations = { 973 coaching: [], 974 medical: [] 975 }; 976 977 if (!container) return recommendations; 978 979 // Find all recommendation cards with data attributes 980 const cards = container.querySelectorAll('.result-card[data-rec-title]'); 981 982 cards.forEach(card => { 983 // Check if the card is visible (not hidden by show_if/hide_if logic) 984 const computedStyle = window.getComputedStyle(card); 985 if (computedStyle.display === 'none' || computedStyle.visibility === 'hidden') { 986 return; 987 } 988 989 // Also check parent elements for visibility (hide_if wraps the card) 990 let parent = card.parentElement; 991 while (parent && parent !== container) { 992 const parentStyle = window.getComputedStyle(parent); 993 if (parentStyle.display === 'none' || parentStyle.visibility === 'hidden') { 994 return; 995 } 996 parent = parent.parentElement; 997 } 998 999 const title = card.dataset.recTitle; 1000 const desc = card.dataset.recDesc; 1001 const section = card.dataset.recSection || 'other'; 1002 1003 if (title) { 1004 const rec = { title, desc: desc || '' }; 1005 if (section === 'coaching') { 1006 recommendations.coaching.push(rec); 1007 } else if (section === 'medical') { 1008 recommendations.medical.push(rec); 1009 } 1010 } 1011 }); 1012 1013 return recommendations; 1014 }; 1015 1016 /** 1017 * Format results as text (legacy format with recommendations) 1018 * @param {Array} answers - Array of answer records 1019 * @param {Array} steps - Optional array of step definitions for looking up option labels 1020 * @param {HTMLElement} container - Optional container to scan for visible recommendations 1021 */ 1022 const formatResults = (answers, steps = [], container = null) => { 1023 // Filter out info steps (where answer is 'continue' or 'next' with no meaningful fields) 1024 const questionAnswers = answers.filter(item => { 1025 if (item.answer === 'continue') return false; 1026 // Keep 'next' only if there are meaningful fields (like height/weight) 1027 if (item.answer === 'next' && (!item.fields || Object.keys(item.fields).length === 0)) return false; 1028 return true; 1029 }); 1030 1031 // Helper to convert value to sentence case 1032 const toSentenceCase = (str) => { 1033 if (!str) return ''; 1034 // Replace hyphens/underscores with spaces, then capitalize first letter 1035 return str.replace(/[-_]/g, ' ') 1036 .replace(/\b\w/g, (c, i) => i === 0 ? c.toUpperCase() : c.toLowerCase()) 1037 .replace(/^./, c => c.toUpperCase()); 1038 }; 1039 1040 // Helper to get option text from step definition 1041 const getOptionText = (stepName, fieldName, value) => { 1042 const stepDef = steps.find(s => s.name === stepName); 1043 if (!stepDef || !stepDef.fields) return null; 1044 1045 const fieldDef = stepDef.fields.find(f => f.name === fieldName); 1046 if (!fieldDef || !fieldDef.options) return null; 1047 1048 const option = fieldDef.options.find(o => o.value === value); 1049 return option ? option.text : null; 1050 }; 1051 1052 // Helper to format height/weight fields inline 1053 const formatHeightWeightInline = (fields, baseFieldName) => { 1054 const feet = fields[`${baseFieldName}_feet`]; 1055 const inches = fields[`${baseFieldName}_inches`]; 1056 const weight = fields[`${baseFieldName}_weight`]; 1057 const bmi = fields[`${baseFieldName}_bmi`]; 1058 1059 const parts = []; 1060 if (feet || inches) { 1061 parts.push(`Height: ${feet || 0}'${inches || 0}"`); 1062 } 1063 if (weight) { 1064 parts.push(`Weight: ${weight} lbs`); 1065 } 1066 if (bmi) { 1067 parts.push(`BMI: ${bmi}`); 1068 } 1069 return parts.join(' | '); 1070 }; 1071 1072 // Helper to check if a field is part of height/weight composite 1073 const isHeightWeightField = (fieldName) => { 1074 return fieldName.endsWith('_feet') || 1075 fieldName.endsWith('_inches') || 1076 fieldName.endsWith('_weight') || 1077 fieldName.endsWith('_height_inches') || 1078 fieldName.endsWith('_bmi'); 1079 }; 1080 1081 // Helper to get base name of height/weight field 1082 const getHeightWeightBaseName = (fieldName) => { 1083 return fieldName.replace(/_(feet|inches|weight|height_inches|bmi)$/, ''); 1084 }; 1085 1086 // Build the legacy format summary 1087 const summaryLines = []; 1088
1089 questionAnswers.forEach((item) => { 1090 // Track which height/weight groups we've already processed 1091 const processedHeightWeightGroups = new Set(); 1092 const displayedValues = []; 1093 1094 if (item.fields && Object.keys(item.fields).length > 0) { 1095 Object.entries(item.fields).forEach(([fieldName, fieldValue]) => { 1096 // Skip if empty 1097 if (fieldValue === '' || fieldValue === null || fieldValue === undefined) return; 1098 1099 // Handle height/weight composite fields 1100 if (isHeightWeightField(fieldName)) { 1101 const baseName = getHeightWeightBaseName(fieldName); 1102 if (!processedHeightWeightGroups.has(baseName)) { 1103 processedHeightWeightGroups.add(baseName); 1104 const formatted = formatHeightWeightInline(item.fields, baseName); 1105 if (formatted) { 1106 // Height/weight gets its own line at the top 1107 summaryLines.push(formatted); 1108 } 1109 } 1110 return; 1111 } 1112 1113 // Handle arrays (multi-select checkboxes) 1114 if (Array.isArray(fieldValue)) { 1115 const textValues = fieldValue.map(v => { 1116 const optionText = getOptionText(item.stepName, fieldName, v); 1117 return optionText || toSentenceCase(v); 1118 }); 1119 if (textValues.length > 0) { 1120 displayedValues.push(...textValues); 1121 } 1122 return; 1123 } 1124 1125 // Handle single values (radio, text, number, etc.) 1126 const optionText = getOptionText(item.stepName, fieldName, fieldValue); 1127 if (optionText) { 1128 displayedValues.push(optionText); 1129 } else { 1130 displayedValues.push(toSentenceCase(String(fieldValue))); 1131 } 1132 }); 1133 } else if (item.answer && item.answer !== 'next') { 1134 displayedValues.push(toSentenceCase(item.answer)); 1135 } 1136 1137 // Format as "Question label: answer(s)" 1138 if (displayedValues.length > 0) { 1139 // Extract a short label from the question 1140 let label = item.question || 'Response'; 1141 // Remove trailing question mark and common prefixes 1142 label = label.replace(/\?$/, '').replace(/^(What is your |Do you |Are you |Have you )/i, ''); 1143 // Capitalize first letter 1144 label = label.charAt(0).toUpperCase() + label.slice(1); 1145 1146 const valueStr = displayedValues.join(', '); 1147 summaryLines.push(`${label}: ${valueStr}`); 1148 } 1149 }); 1150 1151 // Build the output 1152 let output = 'Assessment Summary:\n\n'; 1153 output += summaryLines.join('\n'); 1154 1155 // Add recommendations if container is provided 1156 if (container) { 1157 const recommendations = extractVisibleRecommendations(container); 1158 const hasCoaching = recommendations.coaching.length > 0; 1159 const hasMedical = recommendations.medical.length > 0; 1160 1161 if (hasCoaching || hasMedical) { 1162 output += '\n\n----------------------------------------\n'; 1163 output += 'Recommended Treatment Options:\n'; 1164 1165 if (hasCoaching) { 1166 output += '\nCOACHING SERVICES:\n'; 1167 recommendations.coaching.forEach(rec => { 1168 output += `- ${rec.title}: ${rec.desc}\n`; 1169 }); 1170 } 1171 1172 if (hasMedical) { 1173 output += '\nMEDICAL OPTIONS:\n'; 1174 recommendations.medical.forEach(rec => { 1175 output += `- ${rec.title}: ${rec.desc}\n`; 1176 }); 1177 } 1178 1179 output += '\n----------------------------------------'; 1180 } 1181 } 1182 1183 // Add footer 1184 const today = new Date(); 1185 const dateStr = today.toLocaleDateString('en-US', { 1186 year: 'numeric', 1187 month: 'long', 1188 day: 'numeric' 1189 }); 1190 output += '\n\nPlease bring this summary to your consultation appointment.'; 1191 output += `\nDate generated: ${dateStr}`; 1192 1193 return output; 1194 }; 1195 1196 /** 1197 * Reset assessment - clear state and navigate to step 0 (no page reload) 1198 */ 1199 const resetAssessment = (container, state) => { 1200 // Clear saved state from localStorage 1201 clearLocalStorage(state.assessmentId); 1202 1203 // Reset state to initial values 1204 state.currentStep = 0; 1205 state.answers = []; 1206 state.answerCache = {}; 1207 state.pathTaken = []; 1208 1209 // Clear all form inputs in the assessment 1210 container.querySelectorAll('input[type="radio"], input[type="checkbox"]').forEach(input => { 1211 input.checked = false;
1212 }); 1213 container.querySelectorAll('input[type="text"], input[type="email"], input[type="tel"], textarea').forEach(input => { 1214 input.value = ''; 1215 }); 1216 container.querySelectorAll('select').forEach(select => { 1217 select.selectedIndex = 0; 1218 }); 1219 1220 // Clear data attributes on container (data-answer-*, data-field-*, data-current-step, etc.) 1221 Array.from(container.attributes).forEach(attr => { 1222 if (attr.name.startsWith('data-answer-') || 1223 attr.name.startsWith('data-field-') || 1224 attr.name === 'data-current-step' || 1225 (attr.name.startsWith('data-') && (attr.value === 'selected' || attr.value === 'unselected'))) { 1226 container.removeAttribute(attr.name); 1227 } 1228 }); 1229 1230 // Clear data-selected attributes on any elements 1231 container.querySelectorAll('[data-selected]').forEach(el => { 1232 el.removeAttribute('data-selected'); 1233 }); 1234 1235 // Remove any selection-related classes from labels/options 1236 container.querySelectorAll('.selected, .is-selected, .is-checked').forEach(el => { 1237 el.classList.remove('selected', 'is-selected', 'is-checked'); 1238 }); 1239 1240 // Navigate smoothly to step 0 1241 navigateToStep(container, state, 0); 1242 updateProgress(container, state); 1243 updateURL(0, true); 1244 1245 logDebugEvent('INFO', 'Assessment reset', { assessmentId: state.assessmentId }); 1246 1247 return state; 1248 }; 1249 1250 /** 1251 * Update progress bar and counter 1252 */ 1253 const updateProgress = (container, state) => { 1254 const progressFill = container.querySelector('.wp-assessment-progress-fill'); 1255 const currentProgressText = container.querySelector('.current-progress'); 1256 1257 if (progressFill && currentProgressText) { 1258 // Check if current step is an endpoint 1259 const currentStepData = state.steps[state.currentStep]; 1260 const isEndpoint = currentStepData && currentStepData.isEndpoint === true; 1261 1262 // Calculate progress based on actual steps only 1263 const currentStepNumber = state.currentStep + 1; 1264 const totalScreens = state.totalSteps; 1265 1266 // If on endpoint, force 100%, otherwise calculate normally 1267 const percentage = isEndpoint ? 100 : Math.round((currentStepNumber / totalScreens) * 100); 1268 1269 logDebugEvent('INFO', 'Updating progress', { 1270 currentStep: state.currentStep, 1271 currentStepNumber: currentStepNumber, 1272 totalScreens: totalScreens, 1273 isEndpoint: isEndpoint, 1274 percentage: percentage 1275 }); 1276 1277 progressFill.style.width = `${percentage}%`; 1278 currentProgressText.textContent = percentage; 1279 } 1280 1281 // Update back button visibility based on path history 1282 // Hide back button only on the very first step (no path history) 1283 const currentStepData = state.steps[state.currentStep]; 1284 const currentStepName = currentStepData.name || state.currentStep.toString(); 1285 const currentPathIndex = state.pathTaken.indexOf(currentStepName); 1286 const shouldHideBack = currentPathIndex <= 0; 1287 1288 // Update ALL back buttons in the container 1289 container.querySelectorAll('.wp-assessment-back-btn').forEach(backBtn => { 1290 backBtn.style.display = shouldHideBack ? 'none' : ''; 1291 }); 1292 1293 // Re-update container height after back button visibility change 1294 const activeStep = container.querySelector('.wp-assessment-step.active'); 1295 if (activeStep) { 1296 updateContainerHeight(container, activeStep); 1297 } 1298 1299 // Sync answer data attributes to container 1300 syncDataAttributes(container, state); 1301 }; 1302 1303 /** 1304 * Sync answer data to container data attributes 1305 * This allows CSS-based conditional display based on user answers 1306 * e.g., [data-answer-step-name="yes"] .conditional-content { display: block; } 1307 */ 1308 const syncDataAttributes = (container, state) => { 1309 // First, remove all existing answer and field data attributes 1310 const existingAttrs = Array.from(container.attributes) 1311 .filter(attr => attr.name.startsWith('data-answer-') || attr.name.startsWith('data-field-'));
1312 existingAttrs.forEach(attr => container.removeAttribute(attr.name)); 1313 1314 // Add current step as data attribute 1315 const currentStepData = state.steps[state.currentStep]; 1316 const currentStepName = currentStepData?.name || state.currentStep.toString(); 1317 container.setAttribute('data-current-step', currentStepName); 1318 1319 // Add data attributes for each answer in the cache 1320 Object.entries(state.answerCache || {}).forEach(([stepName, answerRecord]) => { 1321 if (!answerRecord) return; 1322 1323 // Convert step name to valid data attribute name 1324 // e.g., "do-you-have-seizures" -> "data-answer-do-you-have-seizures" 1325 const attrName = `data-answer-${stepName}`; 1326 1327 // Get the answer value 1328 let answerValue = answerRecord.answer; 1329 1330 // If answer is an object (multiple fields), stringify it or use the first value 1331 if (typeof answerValue === 'object' && answerValue !== null) { 1332 if (Array.isArray(answerValue)) { 1333 // For arrays (checkboxes), join values 1334 answerValue = answerValue.join(','); 1335 } else { 1336 // For objects, use the first field value or stringify 1337 const firstKey = Object.keys(answerValue)[0]; 1338 answerValue = firstKey ? answerValue[firstKey] : JSON.stringify(answerValue); 1339 } 1340 } 1341 1342 // Set the data attribute (lowercase value for easier CSS matching) 1343 if (answerValue !== undefined && answerValue !== null && answerValue !== '') { 1344 container.setAttribute(attrName, String(answerValue).toLowerCase()); 1345 } 1346 1347 // Also add individual field values if present 1348 if (answerRecord.fields) { 1349 Object.entries(answerRecord.fields).forEach(([fieldName, fieldValue]) => { 1350 const fieldAttrName = `data-field-${fieldName.replace(/_/g, '-')}`; 1351 let attrValue = fieldValue; 1352 1353 if (Array.isArray(attrValue)) { 1354 attrValue = attrValue.join(','); 1355 } else if (typeof attrValue === 'object' && attrValue !== null) { 1356 attrValue = JSON.stringify(attrValue); 1357 } 1358 1359 if (attrValue !== undefined && attrValue !== null && attrValue !== '') { 1360 container.setAttribute(fieldAttrName, String(attrValue).toLowerCase()); 1361 } 1362 }); 1363 } 1364 }); 1365 1366 // Add per-selection data attributes for checkbox fields 1367 // This enables CSS targeting like [data-symptoms-headache="selected"] .warning { display: block; } 1368 Object.entries(state.answerCache || {}).forEach(([stepName, answerRecord]) => { 1369 if (!answerRecord || !answerRecord.fields) return; 1370 1371 // Find the corresponding step definition to get all options 1372 const stepData = state.steps.find(s => s.name === stepName); 1373 if (!stepData || !stepData.fields) return; 1374 1375 // Process each field in the step 1376 stepData.fields.forEach(fieldDef => { 1377 // Only process checkbox fields with options 1378 if (fieldDef.type !== 'checkbox' || !fieldDef.options || fieldDef.options.length === 0) return; 1379 1380 const fieldName = fieldDef.name; 1381 const selectedValues = answerRecord.fields[fieldName]; 1382 1383 // Skip if no values recorded for this field 1384 if (!selectedValues) return; 1385 1386 // Ensure selectedValues is an array 1387 const selectedArray = Array.isArray(selectedValues) ? selectedValues : [selectedValues]; 1388 1389 // Add data attribute for each option (selected or unselected) 1390 fieldDef.options.forEach(option => { 1391 const optionValue = option.value; 1392 const isSelected = selectedArray.includes(optionValue); 1393 1394 // Format: data-{stepName}-{optionValue}="selected|unselected" 1395 // Sanitize to valid data attribute name (lowercase, replace non-alphanumeric with dashes
1395) 1396 const attrName = `data-${stepName}-${optionValue}`.toLowerCase().replace(/[^a-z0-9-]/g, '-'); 1397 container.setAttribute(attrName, isSelected ? 'selected' : 'unselected'); 1398 }); 1399 }); 1400 }); 1401 1402 logDebugEvent('DEBUG', 'Synced data attributes', { 1403 currentStep: currentStepName, 1404 attributeCount: container.attributes.length 1405 }); 1406 1407 // Evaluate conditional content after syncing data attributes 1408 evaluateConditionalContent(container); 1409 }; 1410 1411 /** 1412 * Clean empty paragraphs from step content 1413 * WordPress wpautop() adds empty <p></p> tags around shortcodes 1414 */ 1415 const cleanEmptyParagraphs = (container) => { 1416 const stepContents = container.querySelectorAll('.wp-assessment-step-description'); 1417 stepContents.forEach(content => { 1418 // Remove empty paragraphs (with optional whitespace/nbsp) 1419 content.querySelectorAll('p').forEach(p => { 1420 const text = p.textContent.trim(); 1421 const html = p.innerHTML.trim(); 1422 if (!text && (!html || html === ' ' || html.match(/^(<br\s*\/?>|\s| )*$/))) { 1423 p.remove(); 1424 } 1425 }); 1426 // Remove stray <br> tags between block elements by finding and removing them directly 1427 // (Don't use innerHTML assignment as it wipes textarea values) 1428 content.querySelectorAll('br').forEach(br => { 1429 const prev = br.previousElementSibling; 1430 if (prev && prev.tagName === 'DIV') { 1431 br.remove(); 1432 } 1433 }); 1434 }); 1435 }; 1436 1437 /** 1438 * Evaluate conditional content shortcodes [show_if] and [hide_if] 1439 * Shows/hides content based on answer data attributes on the container 1440 * 1441 * Supports: 1442 * Simple (single condition): 1443 * [show_if step="has-headaches" is="yes"] - radio/boolean 1444 * [show_if step="symptoms" is="binge-eating"] - checkbox option 1445 * [hide_if step="pregnant" is-not="no"] - negation 1446 * 1447 * Multi-condition: 1448 * [hide_if any="thyroid:yes,gallbladder:yes"] - OR logic 1449 * [show_if all="age:50-plus,savings:200k"] - AND logic 1450 * [show_if all="eating:binge,age:!12-17"] - negation with ! prefix 1451 */ 1452 const evaluateConditionalContent = (container) => { 1453 // Clean empty paragraphs first 1454 cleanEmptyParagraphs(container); 1455 const conditionals = container.querySelectorAll('.wp-assessment-conditional'); 1456 1457 // Log to server for debugging 1458 logDebugEvent('CONDITIONAL', 'evaluateConditionalContent called', { 1459 conditionalsFound: conditionals.length, 1460 answerAttrs: Array.from(container.attributes) 1461 .filter(a => a.name.startsWith('data-answer-') || a.name.startsWith('data-')) 1462 .map(a => ({ name: a.name, value: a.value })) 1463 }); 1464 1465 conditionals.forEach(el => { 1466 const isShowType = el.classList.contains('wp-assessment-show'); 1467 let conditionMet = false; 1468 1469 // Check for multi-condition syntax (any/all) 1470 const anyConditions = el.dataset.conditionAny; 1471 const allConditions = el.dataset.conditionAll; 1472 1473 if (anyConditions || allConditions) { 1474 // Multi-condition mode 1475 const isAnyMode = !!anyConditions; 1476 const conditionsStr = anyConditions || allConditions; 1477 const conditions = conditionsStr.split(',').map(c => c.trim()); 1478 1479 // Parse each condition (format: "step:value" or "step:!value" for negation) 1480 const results = conditions.map(condition => { 1481 const [step, rawValue] = condition.split(':').map(s => s.trim()); 1482 if (!step || !rawValue) return false; 1483 1484 // Check for negation prefix (!) 1485 const isNegated = rawValue.startsWith('!'); 1486 const value = isNegated ? rawValue.substring(1) : rawValue; 1487 1488 let matches = false;
1489 1490 // Try radio/boolean first: data-answer-{step} 1491 const answerValue = container.getAttribute(`data-answer-${step}`); 1492 if (answerValue !== null && answerValue === value) { 1493 matches = true; 1494 } else { 1495 // Try checkbox: data-{step}-{value}="selected" 1496 const checkboxAttr = container.getAttribute(`data-${step}-${value}`); 1497 matches = checkboxAttr === 'selected'; 1498 } 1499 1500 // Apply negation if needed 1501 return isNegated ? !matches : matches; 1502 }); 1503 1504 // Apply logic: any = OR (at least one true), all = AND (all must be true) 1505 if (isAnyMode) { 1506 conditionMet = results.some(r => r === true); 1507 } else { 1508 conditionMet = results.every(r => r === true); 1509 } 1510 1511 logDebugEvent('CONDITIONAL', 'Multi-condition check', { 1512 mode: isAnyMode ? 'any (OR)' : 'all (AND)', 1513 conditions: conditions, 1514 results: results, 1515 conditionMet: conditionMet, 1516 isShowType: isShowType 1517 }); 1518 } else { 1519 // Simple single-condition mode (backward compatible) 1520 const step = el.dataset.conditionStep; 1521 const value = el.dataset.conditionValue; 1522 const compare = el.dataset.conditionCompare || 'equals'; 1523 1524 // Try radio/boolean first: data-answer-{step} 1525 const answerValue = container.getAttribute(`data-answer-${step}`); 1526 1527 if (answerValue !== null && answerValue === value) { 1528 // Exact match on answer attribute 1529 conditionMet = true; 1530 } else { 1531 // Try checkbox: data-{step}-{value}="selected" 1532 const checkboxAttr = container.getAttribute(`data-${step}-${value}`); 1533 conditionMet = checkboxAttr === 'selected'; 1534 } 1535 1536 // Handle is-not (negation) 1537 if (compare === 'not') { 1538 conditionMet = !conditionMet; 1539 } 1540 1541 // Log to server for debugging 1542 logDebugEvent('CONDITIONAL', 'Single condition check', { 1543 step: step, 1544 expectedValue: value, 1545 answerValue: answerValue, 1546 checkboxAttr: container.getAttribute(`data-${step}-${value}`), 1547 compare: compare, 1548 conditionMet: conditionMet, 1549 isShowType: isShowType, 1550 action: isShowType ? (conditionMet ? 'SHOW' : 'HIDE') : (conditionMet ? 'HIDE' : 'SHOW') 1551 }); 1552 } 1553 1554 // Apply visibility (use explicit 'block' to override CSS) 1555 if (isShowType) { 1556 // Show type: hidden by default, shown when condition met 1557 el.style.display = conditionMet ? 'block' : 'none'; 1558 } else { 1559 // Hide type: visible by default, hidden when condition met 1560 el.style.display = conditionMet ? 'none' : 'block'; 1561 } 1562 }); 1563 1564 logDebugEvent('DEBUG', 'Evaluated conditional content', { 1565 conditionalCount: conditionals.length 1566 }); 1567 }; 1568 1569 /** 1570 * Navigate to a specific step 1571 * @param {Function} onComplete - Optional callback to run after animation completes 1572 */ 1573 const navigateToStep = (container, state, stepNumber, onComplete) => { 1574 const animationType = settings.animation_type; 1575 const duration = parseInt(settings.animation_duration, 10); 1576 1577 const currentStepEl = container.querySelector('.wp-assessment-step.active'); 1578 const targetStepEl = container.querySelector(`[data-step="${stepNumber}"]`); 1579 1580 if (!targetStepEl) return; 1581 1582 // If target is already active, just restore content and skip animation 1583 if (currentStepEl === targetStepEl) { 1584 logDebugEvent('NAVIGATION', 'Target already active - ski
1584pping animation', { 1585 step: stepNumber, 1586 stepName: targetStepEl.dataset.stepName 1587 }); 1588 1589 restoreAnswers(targetStepEl, state); 1590 populateResultsIfPresent(targetStepEl, state, container); 1591 dispatchStepEvent('load', targetStepEl, state, container); 1592 autoPopulateFormField(targetStepEl, state, container); 1593 1594 // Update height in case content changed (e.g., results populated) 1595 updateContainerHeight(container, targetStepEl); 1596 1597 // Schedule additional height recalculations for dynamic content 1598 [100, 300, 600].forEach(delay => { 1599 setTimeout(() => updateContainerHeight(container, targetStepEl), delay); 1600 }); 1601 1602 // Execute callback if provided 1603 if (onComplete && typeof onComplete === 'function') { 1604 onComplete(); 1605 } 1606 return; 1607 } 1608 1609 const startTime = performance.now(); 1610 1611 logDebugEvent('NAVIGATION', 'Navigation started', { 1612 from: state.currentStep, 1613 fromName: currentStepEl ? currentStepEl.dataset.stepName : 'none', 1614 to: stepNumber, 1615 toName: targetStepEl.dataset.stepName, 1616 pathTaken: state.pathTaken, 1617 animationType: animationType, 1618 duration: duration, 1619 timestamp: startTime 1620 }); 1621 1622 // Smooth scroll to top of viewport if enabled 1623 if (settings.scroll_to_top) { 1624 window.scrollTo({ 1625 top: 0, 1626 behavior: 'smooth' 1627 }); 1628 } 1629 1630 // Prepare target step content BEFORE showing it (prevents flash) 1631 restoreAnswers(targetStepEl, state); 1632 populateResultsIfPresent(targetStepEl, state, container); 1633 autoPopulateFormField(targetStepEl, state, container); 1634 1635 logDebugEvent('ANIMATION', 'Content restored', { 1636 step: targetStepEl.dataset.stepName, 1637 elapsed: performance.now() - startTime 1638 }); 1639 1640 if (animationType === 'none' || !currentStepEl) { 1641 // No animation - instant transition 1642 if (currentStepEl) { 1643 currentStepEl.classList.remove('active'); 1644 } 1645 targetStepEl.classList.add('active'); 1646 1647 // Update container height to match new step 1648 updateContainerHeight(container, targetStepEl); 1649 1650 dispatchStepEvent('load', targetStepEl, state, container); 1651 1652 // Schedule additional height recalculations for dynamic content 1653 [100, 300, 600].forEach(delay => { 1654 setTimeout(() => updateContainerHeight(container, targetStepEl), delay); 1655 }); 1656 1657 logDebugEvent('NAVIGATION', 'Navigation completed (instant)', { 1658 duration: performance.now() - startTime 1659 }); 1660 1661 // Execute callback if provided 1662 if (onComplete && typeof onComplete === 'function') { 1663 onComplete(); 1664 } 1665 } else if (animationType === 'fade') { 1666 // CSS handles the transition automatically via opacity 1667 // Just toggle the active class and let CSS do the work 1668 1669 logDebugEvent('ANIMATION', 'Starting CSS transition', { 1670 elapsed: performance.now() - startTime 1671 }); 1672 1673 // Update container height FIRST (so it animates with the fade) 1674 updateContainerHeight(container, targetStepEl); 1675 1676 // Remove active from current (triggers fade-out via CSS) 1677 if (currentStepEl) { 1678 currentStepEl.classList.remove('active'); 1679 } 1680 1681 // Add active to target (triggers fade-in via CSS) 1682 targetStepEl.classList.add('active'); 1683 1684 // Dispatch load event immediately 1685 dispatchStepEvent('load', targetStepEl, state, container); 1686 1687 logDebugEvent('ANIMATION', 'CSS transition started', { 1688 step: targetStepEl.dataset.stepName, 1689 elapsed: performance.now() - startTime 1690 }); 1691 1692 // Wait for CSS transition to complete before running callback 1693 setTimeout(() => { 1694 logDebugEvent('NAVIGATION', 'Navigation completed (fade)', { 1695 step: targetStepEl.dataset.stepName, 1696 totalDuration: performance.now() - startTime, 1697 expectedDuration: duration 1698 }); 1699 1700 // Schedule additional height recalculations for dynamic content
1701 [100, 300, 600].forEach(delay => { 1702 setTimeout(() => updateContainerHeight(container, targetStepEl), delay); 1703 }); 1704 1705 // Execute callback AFTER animation fully completes 1706 if (onComplete && typeof onComplete === 'function') { 1707 logDebugEvent('ANIMATION', 'Executing post-animation callback', { 1708 step: targetStepEl.dataset.stepName, 1709 elapsed: performance.now() - startTime 1710 }); 1711 onComplete(); 1712 } 1713 }, duration); 1714 } 1715 }; 1716 1717 /** 1718 * Restore previously given answers when revisiting a step 1719 */ 1720 const restoreAnswers = (stepElement, state) => { 1721 if (!stepElement) return; 1722 1723 const stepName = stepElement.dataset.stepName; 1724 const cachedAnswer = state.answerCache[stepName]; 1725 1726 if (!cachedAnswer) return; 1727 1728 logDebugEvent('INFO', 'Restoring previous answers', { 1729 stepName: stepName, 1730 answer: cachedAnswer.answer, 1731 fields: cachedAnswer.fields 1732 }); 1733 1734 // Restore field values 1735 if (cachedAnswer.fields && Object.keys(cachedAnswer.fields).length > 0) { 1736 Object.entries(cachedAnswer.fields).forEach(([fieldName, fieldValue]) => { 1737 const field = stepElement.querySelector(`[data-field-name="${fieldName}"]`); 1738 if (!field) return; 1739 1740 // Radio buttons 1741 if (field.classList.contains('wp-assessment-radio-field')) { 1742 const radio = field.querySelector(`input[value="${fieldValue}"]`); 1743 if (radio) radio.checked = true; 1744 } 1745 1746 // Checkboxes 1747 else if (field.classList.contains('wp-assessment-checkbox-field')) { 1748 const values = Array.isArray(fieldValue) ? fieldValue : [fieldValue]; 1749 values.forEach(val => { 1750 const checkbox = field.querySelector(`input[value="${val}"]`); 1751 if (checkbox) checkbox.checked = true; 1752 }); 1753 } 1754 1755 // Text input 1756 else if (field.classList.contains('wp-assessment-text-field')) { 1757 const input = field.querySelector('.wp-assessment-text-input'); 1758 if (input) input.value = fieldValue; 1759 } 1760 1761 // Number input 1762 else if (field.classList.contains('wp-assessment-number-field')) { 1763 const input = field.querySelector('.wp-assessment-number-input'); 1764 if (input) input.value = fieldValue; 1765 } 1766 1767 // Textarea 1768 else if (field.classList.contains('wp-assessment-textarea-field')) { 1769 const textarea = field.querySelector('.wp-assessment-textarea-input'); 1770 if (textarea) textarea.value = fieldValue; 1771 } 1772 }); 1773 } 1774 }; 1775 1776 /** 1777 * Populate results textarea if present in step 1778 */ 1779 const populateResultsIfPresent = (stepElement, state, container = null) => { 1780 const resultsTextarea = stepElement.querySelector('.wp-assessment-results-textarea'); 1781 1782 logDebugEvent('RESULTS', 'populateResultsIfPresent called', { 1783 stepName: stepElement?.dataset?.stepName, 1784 hasTextarea: !!resultsTextarea, 1785 answersCount: state.answers?.length || 0, 1786 hasSteps: !!state.steps 1787 }); 1788 1789 if (resultsTextarea && state.answers && state.answers.length > 0) { 1790 // Use the main container to find recommendations across all steps 1791 const searchContainer = container || stepElement.closest('.wp-assessment-container') || stepElement; 1792 const formattedResults = formatResults(state.answers, state.steps, searchContainer); 1793 resultsTextarea.value = formattedResults; 1794 1795 logDebugEvent('RESULTS', 'Textarea populated', { 1796 resultLength: formattedResults.length, 1797 preview: formattedResults.substring(0, 100) 1798 }); 1799 } 1800 }; 1801 1802 /**
1803 * Dispatch step-specific custom event 1804 */ 1805 const dispatchStepEvent = (eventType, stepElement, state, container) => { 1806 if (!stepElement) return; 1807 1808 const stepSlug = stepElement.dataset.stepName || stepElement.dataset.step; 1809 const stepIndex = parseInt(stepElement.dataset.step, 10); 1810 const assessmentId = container.dataset.assessmentId; 1811 1812 // Event detail object (use container to find recommendations across all steps) 1813 const detail = { 1814 stepSlug: stepSlug, 1815 stepIndex: stepIndex, 1816 assessmentId: assessmentId, 1817 answers: state.answers, 1818 formattedResults: formatResults(state.answers, state.steps, container), 1819 state: state 1820 }; 1821 1822 // Dispatch step-specific event (e.g., "wp-assessment:results:load") 1823 const stepSpecificEvent = new CustomEvent(`wp-assessment:${stepSlug}:${eventType}`, { 1824 detail: detail, 1825 bubbles: true 1826 }); 1827 document.dispatchEvent(stepSpecificEvent); 1828 1829 // Also dispatch generic event (e.g., "wp-assessment:load") 1830 const genericEvent = new CustomEvent(`wp-assessment:${eventType}`, { 1831 detail: detail, 1832 bubbles: true 1833 }); 1834 document.dispatchEvent(genericEvent); 1835 1836 logDebugEvent('EVENT', `Dispatched wp-assessment:${stepSlug}:${eventType}`, detail); 1837 }; 1838 1839 /** 1840 * Auto-populate form field if configured for this step 1841 */ 1842 const autoPopulateFormField = (stepElement, state, container = null) => { 1843 if (!stepElement) return; 1844 1845 const formEnabled = stepElement.dataset.formEnabled; 1846 const formTarget = stepElement.dataset.formTarget; 1847 const formMethod = stepElement.dataset.formMethod || 'prepend'; 1848 1849 if (!formEnabled || !formTarget) return; 1850 1851 try { 1852 const targetField = document.querySelector(formTarget); 1853 if (!targetField) { 1854 logDebugEvent('WARNING', `Form target field not found: ${formTarget}`); 1855 return; 1856 } 1857 1858 // Use the main container to find recommendations across all steps 1859 const searchContainer = container || stepElement.closest('.wp-assessment-container') || stepElement; 1860 const results = formatResults(state.answers, state.steps, searchContainer); 1861 if (!results) return; 1862 1863 if (formMethod === 'replace') { 1864 targetField.value = results; 1865 } else if (formMethod === 'prepend') { 1866 const existingValue = targetField.value || ''; 1867 targetField.value = results + (existingValue ? '\n\n' + existingValue : ''); 1868 } else if (formMethod === 'append') { 1869 const existingValue = targetField.value || ''; 1870 targetField.value = (existingValue ? existingValue + '\n\n' : '') + results; 1871 } 1872 1873 logDebugEvent('INFO', 'Auto-populated form field', { 1874 target: formTarget, 1875 method: formMethod, 1876 resultsLength: results.length 1877 }); 1878 } catch (error) { 1879 logDebugEvent('ERROR', 'Error auto-populating form field', { 1880 target: formTarget, 1881 error: error.message 1882 }); 1883 } 1884 }; 1885 1886 /** 1887 * Update URL with current step 1888 */ 1889 const updateURL = (step, replace = false) => { 1890 // Get step name if it's a number 1891 let stepSlug = step; 1892 1893 if (typeof step === 'number') { 1894 const stepEl = document.querySelector(`[data-step="${step}"]`); 1895 if (stepEl && stepEl.dataset.stepName) { 1896 stepSlug = stepEl.dataset.stepName; 1897 } 1898 } 1899 1900 // Use just the slug as query param (no key) 1901 const url = new URL(window.location); 1902 // Clear all params first 1903 url.search = ''; 1904 // Add just the step slug 1905 url.search = `?${stepSlug}`; 1906 1907 logDebugEvent('INFO', 'Updating URL', { step: step, slug: stepSlug, url: url.toString(), replace: replace }); 1908 1909 if (replace) { 1910 history.replaceState({ step: step }, '', url.toString()); 1911 } else { 1912 history.pushState({ step: step }, '', url.toString()); 1913 } 1914 }; 1915 1916 /** 1917 * Get step number from URL query param 1918 */ 1919 const getStepFromURL = () => { 1920 // Get the query string without the '?' 1921 const query = window.location.search.substring(1); 1922 1923 if (!query) { 1924 return null; 1925 } 1926 1927 // The entire query string is the step identifier 1928 const stepIdentifier = query; 1929 1930 // Check if it's a number 1931 if (/^\d+$/.test(stepIdentifier)) { 1932 return parseInt(stepIdentifier, 10); 1933 } 1934 1935 // It's a step name - find the step index 1936 const stepEl = document.querySelector(`[data-step-name="${stepIdentifier}"]`); 1937 if (stepEl && stepEl.dataset.step) { 1938 const stepIndex = parseInt(stepEl.dataset.step, 10); 1939 logDebugEvent('INFO', 'Resolved step name from URL', { name: stepIdentifier, index: stepIndex }); 1940 return stepIndex; 1941 } 1942 1943 logDebugEvent('WARNING', 'Could not resolve step from URL', { identifier: stepIdentifier }); 1944 return null; 1945 }; 1946 1947 /** 1948 * Save state to localStorage 1949 */ 1950 const saveStateToLocalStorage = (state) => { 1951 if (!state.assessmentId) return; 1952 1953 const storageKey = `wp_assessment_${state.assessmentId}`; 1954 const dataToSave = { 1955 currentStep: state.currentStep, 1956 answers: state.answers, 1957 answerCache: state.answerCache, 1958 pathTaken: state.pathTaken, 1959 timestamp: Date.now() 1960 }; 1961 1962 try { 1963 localStorage.setItem(storageKey, JSON.stringify(dataToSave)); 1964 window.wpAssessmentDebug.localStorage = dataToSave; 1965 logDebugEvent('INFO', 'Saved state to localStorage', { key: storageKey, data: dataToSave }); 1966 } catch (e) { 1967 logDebugEvent('ERROR', 'Failed to save state to localStorage', e.message); 1968 console.error('Failed to save assessment state:', e); 1969 } 1970 }; 1971 1972 /** 1973 * Load state from localStorage 1974 */ 1975 const loadStateFromLocalStorage = (assessmentId) => { 1976 if (!assessmentId) return null; 1977 1978 const storageKey = `wp_assessment_${assessmentId}`; 1979 1980 try { 1981 const saved = localStorage.getItem(storageKey); 1982 if (!saved) { 1983 logDebugEvent('INFO', 'No saved state found in localStorage', { key: storageKey }); 1984 return null; 1985 } 1986 1987 const data = JSON.parse(saved); 1988 1989 // Check if data is too old (24 hours) 1990 const maxAge = 24 * 60 * 60 * 1000; // 24 hours in milliseconds 1991 if (Date.now() - data.timestamp > maxAge) { 1992 localStorage.removeItem(storageKey); 1993 logDebugEvent('INFO', 'Saved state expired, removed from localStorage', { key: storageKey, age: Date.now() - data.timestamp }); 1994 return null; 1995 } 1996 1997 window.wpAssessmentDebug.localStorage = data; 1998 logDebugEvent('INFO', 'Loaded state from localStorage', { key: storageKey, data: data }); 1999 2000 return { 2001 currentStep: data.currentStep, 2002 answers: data.answers || [], 2003 answerCache: data.answerCache || {}, 2004 pathTaken: data.pathTaken || [] 2005 }; 2006 } catch (e) { 2007 logDebugEvent('ERROR', 'Failed to load state from localStorage', e.message); 2008 console.error('Failed to load assessment state:', e); 2009 return null; 2010 } 2011 }; 2012 2013 /** 2014 * Clear state from localStorage 2015 */ 2016 const clearLocalStorage = (assessmentId) => { 2017 if (!assessmentId) return; 2018 2019 const storageKey = `wp_assessment_${assessmentId}`; 2020 2021 try { 2022 localStorage.removeItem(storageKey); 2023 } catch (e) { 2024 console.error('Failed to clear assessment state:', e); 2025 } 2026 }; 2027 2028 /** 2029 * Initialize debug panel 2030 */ 2031 const initDebugPanel = () => { 2032 const debugPanel = document.querySelector('[data-debug-panel]'); 2033 if (!debugPanel) return; 2034 2035 // Setup toggle button 2036 const toggleBtn = debugPanel.querySelector('[data-debug-toggle]'); 2037 if (toggleBtn) { 2038 toggleBtn.addEventListener('click', () => { 2039 debugPanel.classList.toggle('collapsed'); 2040 }); 2041 } 2042 }; 2043 2044 /** 2045 * Broadcast state to debug panel 2046 */ 2047 const broadcastStateToDebug = (state) => { 2048 const debugPanel = document.querySelector('[data-debug-panel]'); 2049 if (!debugPanel) return; 2050 2051 // Update state values 2052 updateDebugElement('[data-debug-current-step]', state.currentStep + 1); 2053 updateDebugElement('[data-debug-total-steps]', state.totalSteps); 2054 updateDebugElement('[data-debug-assessment-id]', state.assessmentId || 'â'); 2055 2056 // Check if current step is an endpoint for progress calculation 2057 const currentStepData = state.steps[state.currentStep]; 2058 const isEndpoint = currentStepData && currentStepData.isEndpoint === true; 2059 const percentage = isEndpoint ? 100 : Math.round(((state.currentStep + 1) / state.totalSteps) * 100); 2060 updateDebugElement('[data-debug-progress]', `${percentage}%`); 2061 2062 // Update journey
2063 updateDebugAnswers(state.answers); 2064 }; 2065 2066 /** 2067 * Update a debug element's text content 2068 */ 2069 const updateDebugElement = (selector, value) => { 2070 const element = document.querySelector(selector); 2071 if (element) { 2072 element.textContent = value; 2073 } 2074 }; 2075 2076 /** 2077 * Update debug journey display 2078 */ 2079 const updateDebugAnswers = (answers) => { 2080 const journeyContainer = document.querySelector('[data-debug-journey]'); 2081 const answersCount = document.querySelector('[data-debug-answers-count]'); 2082 2083 if (!journeyContainer) return; 2084 2085 if (answersCount) { 2086 answersCount.textContent = `(${answers.length})`; 2087 } 2088 2089 if (answers.length === 0) { 2090 journeyContainer.innerHTML = '<em>No steps taken yet</em>'; 2091 return; 2092 } 2093 2094 journeyContainer.innerHTML = answers.map((item, index) => { 2095 let html = ` 2096 <div class="wp-assessment-debug-journey-step"> 2097 <div class="wp-assessment-debug-journey-header"> 2098 <span class="wp-assessment-debug-step-number">${index + 1}</span> 2099 <span class="wp-assessment-debug-step-name">${escapeHtml(item.stepLabel || item.stepName)}</span> 2100 </div> 2101 <div class="wp-assessment-debug-journey-question">${escapeHtml(item.question)}</div> 2102 <div class="wp-assessment-debug-journey-answer"> 2103 <span class="label">Answer:</span> 2104 <span class="value">${escapeHtml(item.answer.toUpperCase())}</span> 2105 </div> 2106 `; 2107 2108 // Add field values if present 2109 if (item.fields && Object.keys(item.fields).length > 0) { 2110 html += '<div class="wp-assessment-debug-journey-fields">'; 2111 Object.entries(item.fields).forEach(([fieldName, fieldValue]) => { 2112 if (fieldValue) { 2113 html += ` 2114 <div class="wp-assessment-debug-field-value"> 2115 <span class="label">${escapeHtml(fieldName)}:</span> 2116 <span class="value">${escapeHtml(fieldValue)}</span> 2117 </div> 2118 `; 2119 } 2120 }); 2121 html += '</div>'; 2122 } 2123 2124 html += '</div>'; 2125 return html; 2126 }).join(''); 2127 }; 2128 2129 2130 /** 2131 * Escape HTML to prevent XSS 2132 */ 2133 const escapeHtml = (text) => { 2134 const div = document.createElement('div'); 2135 div.textContent = text; 2136 return div.innerHTML; 2137 }; 2138 2139 // Initialize when DOM is ready 2140 if (document.readyState === 'loading') { 2141 document.addEventListener('DOMContentLoaded', init); 2142 } else { 2143 init(); 2144 } 2145})();
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.