1/** 2 * QSM-11 Quiz Navigation JavaScript 3 * 4 * Handles navigation, pagination, and validation functionality 5 * for the new QSM-11 rendering system 6 * 7 * @package QSM 8 * @version 1.0.0 9 */ 10var show_result_validation = true; 11var QSMPagination; 12 13/** 14 * Limit multiple response based on question limit. 15 * 16 * Defined globally because the new renderer's question templates emit an inline 17 * `onchange="qsmCheckMR(this, limit)"` handler. The legacy definition lives in 18 * js/qsm-quiz.js, which the new renderer does not enqueue, so it is redefined here. 19 * 20 * @returns 21 */ 22if (typeof window.qsmCheckMR !== 'function') { 23 window.qsmCheckMR = function(event, limit) { 24 let $section = jQuery(event).parents('.quiz_section'); 25 let checked = $section.find(':checkbox:checked').length; 26 if (checked > limit) { 27 event.checked = false; 28 if ($section.find('.multi-checkbox-limit-reach').length == 0) { 29 let message = (typeof qmn_ajax_object !== 'undefined' && qmn_ajax_object.multicheckbox_limit_reach) ? qmn_ajax_object.multicheckbox_limit_reach : ''; 30 $section.append('<span class="multi-checkbox-limit-reach">' + message + '</span>'); 31 } 32 } else { 33 $section.find('.multi-checkbox-limit-reach').remove(); 34 } 35 }; 36} 37 38(function($) { 39 /** 40 * Main QSM New Navigation Object 41 */ 42 QSMPagination = { 43 Navigation: { 44 quizObjects: {}, 45 timeTakenIntervals: {}, 46 videoAttributePatterns: [ 47 /\ssrc="([^"]+)"/, 48 /\smp4="([^"]+)"/, 49 /\sm4v="([^"]+)"/, 50 /\swebm="([^"]+)"/, 51 /\sogv="([^"]+)"/, 52 /\swmv="([^"]+)"/, 53 /\sflv="([^"]+)"/, 54 /\swidth="(\d+)"/, 55 /\sheight="(\d+)"/ 56 ], 57 config: { 58 selectors: { 59 quizContainer: '.qsm-quiz-container', 60 form: '.qsm-quiz-form', 61 page: '.qsm-page', 62 pagination: '.qsm-pagination', 63 previousBtn: '.qsm-previous-btn, .qsm-previous', 64 nextBtn: '.qsm-next-btn, .qsm-next', 65 submitBtn: '.qsm-submit-btn', 66 startBtn: '.qsm-start-btn', 67 pageCounter: '.qsm-page-counter', 68 errorMessage: '.qsm-error-message', 69 modalSubmitBtn: '.submit-the-form', 70 timerField: '#timer', 71 timerMsField: 'input[name="timer_ms"]',
72 currentPageField: 'input[name="current_page"]' 73 }, 74 classes: { 75 hidden: 'qsm-hidden', 76 disabled: 'qsm-disabled', 77 loading: 'qsm-loading', 78 error: 'qsm-error', 79 current: 'qsm-current-page' 80 }, 81 animation: { 82 duration: 1000, 83 easing: 'swing' 84 } 85 }, 86 87 submit_status: true, 88 89 /** 90 * Get quiz data from either legacy or new variable 91 * Provides backward compatibility 92 */ 93 getQuizData: function(quizId) { 94 if (qmn_quiz_data && qmn_quiz_data[quizId]) { 95 return qmn_quiz_data[quizId]; 96 } 97 return {}; 98 }, 99 100 /** 101 * Initialize navigation for all quizzes on page 102 */ 103 init: function(initialPage = 1) { 104 let self = this; 105 106 // Initialize each quiz form found 107 $(this.config.selectors.quizContainer).each(function() { 108 109 let $quizContainer = $(this); 110 let quizId = self.getQuizId($quizContainer); 111 112 if (quizId) { 113 self.initQuiz(quizId, $quizContainer,initialPage); 114 } 115 116 }); 117 }, 118 119 initQuizObject: function(quizId, $quizContainer, initialPage) { 120 121 // Get quiz data from backend config 122 let quizData = this.getQuizData(quizId); 123 124 // Find all pages for this quiz 125 let $pages = $quizContainer.find(this.config.selectors.page); 126 127 // Determine if first page is disabled 128 let hasFirstPage = (Number(quizData.disable_first_page) !== 1); 129 130 // Question pages count (excluding intro page) 131 let questionPages = hasFirstPage ? $pages.length - 1 : $pages.length; 132 133 // Build quiz object 134 this.quizObjects[quizId] = { 135 id: quizId, 136 quizContainer: $quizContainer, 137 form: $quizContainer.find(this.config.selectors.form), 138 pagination: jQuery('.qsm-pagination-' + quizId), 139 nonceValue: $quizContainer.find('#qsm_nonce_' + quizId).val() 140 || $quizContainer.find('input[name="qsm_nonce"]').val(), 141 142 pages: $pages, 143 currentPage: initialPage, 144 totalPages: $pages.length, 145 questionPages: questionPages, 146 hasFirstPage: hasFirstPage, 147 data: quizData, 148 149 // Validation state 150 validation: { 151 enabled: true, 152 errors: [] 153 }, 154 155 // Runtime values (extendable later) 156 runtime: { 157 initialized: false, 158 started: false, 159 completed: false, 160 timeTakenSeconds: 0, 161 } 162 }; 163 164 return this.quizObjects[quizId]; 165 }, 166 167 168 /** 169 * Initialize a specific quiz 170 */ 171 initQuiz: function(quizId, $quizContainer,initialPage) { 172 // Create quiz object 173 let quizObj = this.initQuizObject(quizId, $quizContainer,initialPage); 174 175 // Set start date in localStorage 176 if ( localStorage.getItem('mlw_quiz_start_date' + quizId) == null ) { 177 localStorage.setItem('mlw_quiz_start_date' + quizId, qmn_ajax_object.start_date); 178 } 179 180 // Initialize pagination UI 181 this.initPagination(quizId); 182 183 // Show first page 184 this.showPage(quizId, quizObj.currentPage); 185 186 this.manageFocus(quizId); 187 188 // Timer 189 let self = this; 190 setInterval(function() { 191 self.qsmQuizTimeTakenTimer(quizId); 192 }, 1000); 193 194 // Progress Bar 195 if (QSMPagination && QSMPagination.ProgressBar) { 196 QSMPagination.ProgressBar.initProgressBar( 197 quizId, 198 $quizContainer, 199 quizObj.form 200 ); 201 } 202 203 // Bind events 204 this.bindEvents(quizId); 205 206 this.bindHintToggle(quizObj.quizContainer); 207 208 // Mark as initialized 209 quizObj.runtime.initialized = true; 210 211 // Fire event 212 $(document).trigger('qsm_quiz_initialized', [quizId, quizObj]); 213 }, 214 215 216 qsmQuizTimeTakenTimer: function(quizId) { 217 let currentQuiz = this.quizObjects[quizId]; 218 if (!currentQuiz) return; 219 220 let $timerField = currentQuiz.quizContainer.find(this.config.selectors.timerField); 221 if ($timerField.length) { 222 let timerValue = Number.parseInt($timerField.val()) || 0; 223 if (Number.isNaN(timerValue)) { 224 timerValue = 0; 225 } 226 timerValue++; 227 $timerField.val(timerValue); 228 } 229 }, 230 231 /** 232 * Initialize pagination for a quiz 233 */ 234 initPagination: function(quizId) { 235 let currentQuiz = this.quizObjects[quizId]; 236 if (!currentQuiz) return; 237 238 // Hide all pages initially using jQuery hide() 239 currentQuiz.pages.hide(); 240 241 // Initialize navigation buttons and show page counter 242 this.updateNavigationButtons(quizId); 243 this.updatePageCounter(quizId); 244 jQuery(document).trigger('qsm_init_pagination_after', [quizId, qmn_quiz_data]); 245 }, 246 247 closeHintWrapper: function($wrapper) { 248 const $toggle = $wrapper.find('.qsm-hint-toggle'); 249 const $panel = $wrapper.find('.qsm-hint-panel').first(); 250 const $label = $toggle.find('.qsm-hint-label'); 251 $wrapper.removeClass('is-open'); 252 $toggle.attr('aria-expanded', 'false'); 253 $panel.attr('aria-hidden', 'true'); 254 $label.text($label.data('show') || $label.text()); 255 window.setTimeout(function() { 256 if (!$wrapper.hasClass('is-open')) { 257 $panel.attr('hidden', 'hidden'); 258 } 259 }, 260); 260 }, 261 262 openHintWrapper: function($wrapper) { 263 const $toggle = $wrapper.find('.qsm-hint-toggle'); 264 const $panel = $wrapper.find('.qsm-hint-panel').first(); 265 const $label = $toggle.find('.qsm-hint-label'); 266 $panel.removeAttr('hidden'); 267 $panel[0]?.getBoundingClientRect(); 268 $wrapper.addClass('is-open'); 269 $toggle.attr('aria-expanded', 'true'); 270 $panel.attr('aria-hidden', 'false'); 271 $label.text($label.data('hide') || $label.text()); 272 }, 273 274 bindHintToggle: function($quizContainer) { 275 if (!$quizContainer?.length) { 276 return; 277 } 278 $quizContainer.off('click.qsmHint') 279 .on('click.qsmHint', '.qsm-hint-toggle', (e) => { 280 e.preventDefault(); 281 const $toggle = $(e.currentTarget); 282 const $wrapper = $toggle.closest('.qsm-hint-wrapper'); 283 const isOpen = $wrapper.hasClass('is-open'); 284 285 $quizContainer.find('.qsm-hint-wrapper.is-open').not($wrapper).each((_, el) => { 286 this.closeHintWrapper($(el)); 287 }); 288 289 if (isOpen) { 290 this.closeHintWrapper($wrapper); 291 } else { 292 this.openHintWrapper($wrapper); 293 } 294 }); 295 }, 296 297 /** 298 * Initialize validation 299 */ 300 initValidation: function(quizId) { 301 let currentQuiz = this.quizObjects[quizId]; 302 if (!currentQuiz) return; 303 304 // Set up validation rules based on quiz configuration 305 currentQuiz.validation.rules = this.getValidationRules(quizId); 306 }, 307 308 /** 309 * Bind events for a quiz instance 310 */ 311 bindEvents: function(quizId) { 312 let self = this; 313 let currentQuiz = this.quizObjects[quizId]; 314 let $form = currentQuiz.form; 315 let $start_btn = '.qsm-start-btn-'+quizId; 316 let $prev_btn = '.qsm-previous-btn-'+quizId; 317 let $next_btn = '.qsm-next-btn-'+quizId; 318 let $submit_btn = '.qsm-submit-btn-'+quizId; 319 320 // ========================================= 321 // ð¥ Prevent duplicate click handlers 322 // ========================================= 323 $(document).off('click', $start_btn); 324 $(document).off('click', $prev_btn); 325 $(document).off('click', $next_btn); 326 $(document).off('click', $submit_btn); 327 328 // Navigation button clicks - bind to container since navigation is outside form 329 $(document).on('click', $prev_btn, function(e) { 330 e.preventDefault(); 331 self.previousPage(quizId); 332 }); 333 334 $(document).on('click', $next_btn, function(e) { 335 e.preventDefault(); 336 self.nextPage(quizId); 337 }); 338 339 // Start button click - bind to container (multiple selectors for compatibility) 340 $(document).on('click', $start_btn, function(e) { 341 e.preventDefault(); 342 // Validate current page before proceeding 343 if (!self.validateCurrentPage(quizId)) { 344 return; 345 } 346 self.startQuiz(quizId); 347 self.nextPage(quizId); 348 }); 349 350 // Submit button click - bind to container since submit button is in navigation 351 $(document).on('click', $submit_btn, function(e) { 352 e.preventDefault(); 353 jQuery(document).trigger('qsm_quiz_submit_trigger', [quizId]); 354 355 self.submitFormWithRecaptcha(quizId); 356 return false;
357 }); 358 359 $(document).on('click', this.config.selectors.modalSubmitBtn, function(e) { 360 e.preventDefault(); 361 jQuery(document).trigger('qsm_quiz_submit_trigger', [quizId]); 362 363 self.submitFormWithRecaptcha(quizId); 364 return false; 365 }); 366 367 // Form submission 368 $form.on('submit', function(e) { 369 e.preventDefault(); 370 jQuery(document).trigger('qsm_quiz_submit_trigger', [quizId]); 371 372 self.submitFormWithRecaptcha(quizId); 373 return false; 374 }); 375 376 // Input change events for real-time validation 377 $form.on('change input', 'input, select, textarea', function() { 378 self.clearFieldError($(this)); 379 }); 380 381 // Keyboard navigation 382 $form.on('keydown', function(e) { 383 self.handleKeyboardNavigation(e, quizId); 384 }); 385 386 // Answer selection event handlers 387 self.bindAnswerEvents(quizId); 388 }, 389 390 submitFormWithRecaptcha: function(quizId) { 391 let quizData = this.quizObjects[quizId]; 392 if (!quizData) return; 393 394 let $form = quizData.form; 395 let $recaptcha = $form.find('#qsm_grecaptcha_v3'); 396 397 if (!$recaptcha.length) { 398 this.submitForm(quizId); 399 return; 400 } 401 402 let siteKey = $form.find('#qsm_grecaptcha_v3_sitekey').val(); 403 let submitAction = $form.find('#qsm_grecaptcha_v3_nonce').val(); 404 405 if (!siteKey || typeof window.grecaptcha === 'undefined' || !window.grecaptcha || typeof window.grecaptcha.execute !== 'function') { 406 this.submitForm(quizId); 407 return; 408 } 409 410 window.grecaptcha.ready(function () { 411 window.grecaptcha.execute(siteKey, { action: submitAction }).then(function (token) { 412 $form.find('#qsm_grecaptcha_v3_response').val(token); 413 this.submitForm(quizId); 414 }); 415 }); 416 }, 417 418 /** 419 * Bind answer selection event handlers (matching legacy qsm-quiz.js) 420 */ 421 bindAnswerEvents: function(quizId) { 422 let self = this; 423 let currentQuiz = this.quizObjects[quizId]; 424 let $container = currentQuiz.quizContainer; 425 426 // bindAnswerEvents() runs again on every lazy-loaded page navigation. 427 // The handlers below are delegated on the persistent $container, so without 428 // removing the previous bindings they stack up and fire qsm_after_select_answer 429 // once per navigation (1x on Q1, 2x on Q2, ...). Namespace + off prevents that. 430 $container.off('.qsmAnswerEvents'); 431 432 // Remove active-question highlight when using mouse (border is for keyboard navigation only) 433 $container.off('click.qsmActiveQuestion'); 434 $container.on('click.qsmActiveQuestion', '.qsm-question-wrapper', function() { 435 $container.find('.qsm-question-wrapper').removeClass('qsm-active-question qsm-last-active-question'); 436 }); 437 438 // Multiple choice radio buttons and dropdowns (matching legacy: .qmn-multiple-choice-input, .qsm_dropdown, .mlw_answer_date) 439 $container.on('change.qsmAnswerEvents', '.qmn-multiple-choice-input, .qsm_dropdown, .mlw_answer_date', function(e) { 440 let $i_this = $(this); 441 let value = $i_this.val(); 442 let $this = $i_this.closest('.quiz_section, .qsm-question'); 443 let question_id = $i_this.attr('name').split('question')[1]; 444 let inputType; 445 446 if ($i_this.hasClass('mlw_answer_date')) { 447 inputType = 'input'; 448 } else { 449 inputType = 'radio'; 450 } 451 452 let quizData = self.getQuizData(quizId); 453 454 // Quick result / inline feedback 455 if (Number(quizData.enable_quick_result_mc) === 1) { 456 self.qsmShowInlineResult(quizId, question_id, value, $this, inputType, $i_this); 457 } else if (Number(quizData.enable_quick_correct_answer_info) !== 0) { 458 let data = self.qsmQuestionQuickResultJs(question_id, value, inputType, quizData.enable_quick_correct_answer_info, quizId); 459 $this.find('.quick-question-res-p, .qsm-inline-correct-info').remove(); 460 if (value && value.length > 0 && data.success !== '') { 461 $this.append('<div class="qsm-inline-correct-info">' + self.qsmCheckShortcode(data.message) + '</div>'); 462 } 463 } 464 465 // Trigger after select answer event (for addons) 466 $(document).trigger('qsm_after_select_answer', [quizId, question_id, value, $this, inputType]); 467 468 // Auto-submit if answer is wrong 469 if (quizData.end_quiz_if_wrong > 0 && !$container.find('.qsm-next-btn:visible').length) { 470 self.qsmSubmitQuizIfAnswerWrong(quizId, question_id, value, $this, currentQuiz.form); 471 } 472 }); 473 474 // Multiple response checkboxes (matching legacy: .qsm-multiple-response-input) 475 $container.on('change.qsmAnswerEvents', '.qsm-multiple-response-input', function(e) { 476 let $i_this = $(this); 477 let question_id = $i_this.attr('name').split('question')[1];
478 let $this = $i_this.closest('.quiz_section, .qsm-question'); 479 let parent = $i_this.closest('.qmn_check_answers, .qsm-answer-options'); 480 let checkedValues = parent.find('input[type="checkbox"]:checked').map(function() { 481 return $(this).val(); 482 }).get(); 483 484 let quizData = self.getQuizData(quizId); 485 486 // Auto-submit if answer is wrong 487 if (quizData.end_quiz_if_wrong > 0 && !$container.find('.qsm-next-btn:visible').length) { 488 self.qsmSubmitQuizIfAnswerWrong(quizId, question_id, checkedValues, $this, currentQuiz.form, 'checkbox'); 489 } 490 491 // Quick result / inline feedback 492 if (Number(quizData.enable_quick_result_mc) === 1) { 493 self.qsmShowInlineResult(quizId, question_id, checkedValues, $this, 'checkbox', $i_this); 494 } else if (Number(quizData.enable_quick_correct_answer_info) !== 0) { 495 let data = self.qsmQuestionQuickResultJs(question_id, checkedValues, 'checkbox', quizData.enable_quick_correct_answer_info, quizId); 496 $this.find('.quick-question-res-p, .qsm-inline-correct-info').remove(); 497 if (checkedValues.length > 0 && data.success != '') { 498 $this.append('<div class="qsm-inline-correct-info">' + self.qsmCheckShortcode(data.message) + '</div>'); 499 } 500 } 501 502 // Trigger after select answer event (for addons) 503 $(document).trigger('qsm_after_select_answer', [quizId, question_id, checkedValues, $this, 'checkbox']); 504 }); 505 506 // File upload functionality (matching legacy: .mlw_answer_file_upload) 507 this.bindFileUploadEvents(quizId); 508 509 // Text, number, and fill-blank inputs (matching legacy: .mlw_answer_open_text, .mlw_answer_number, .qmn_fill_blank) 510 let qsm_inline_result_timer; 511 $container.on('keyup.qsmAnswerEvents', '.mlw_answer_open_text, .mlw_answer_number, .qmn_fill_blank', function(e) { 512 let $i_this = $(this); 513 let question_id = $i_this.attr('name').split('question')[1]; 514 let $this = $i_this.closest('.quiz_section, .qsm-question'); 515 let value; 516 517 if ($i_this.hasClass('qmn_fill_blank')) { 518 value = $this.find('.qmn_fill_blank').map(function() { 519 let val = $(this).val(); 520 return val || null; 521 }).get().filter(function(v) { return v !== null; }); 522 } else { 523 value = $i_this.val(); 524 } 525 526 let sendValue; 527 if (typeof value === 'string') { 528 sendValue = value.trim(); 529 } else if (value.length) { 530 sendValue = value[value.length - 1]; 531 } else { 532 sendValue = ''; 533 } 534 535 let quizData = self.getQuizData(quizId); 536 537 clearTimeout(qsm_inline_result_timer); 538 qsm_inline_result_timer = setTimeout(function() { 539 let showFeedback = true; 540 541 // For fill-blank, only show feedback when all blanks are filled 542 if ($i_this.hasClass('qmn_fill_blank')) { 543 let $allBlanks = $this.find('.qmn_fill_blank'); 544 let totalBlanks = $allBlanks.length; 545 let filledBlanks = $allBlanks.filter(function() { 546 return $(this).val().trim() !== ''; 547 }).length; 548 showFeedback = (totalBlanks > 0 && filledBlanks === totalBlanks); 549 550 if (!showFeedback) { 551 $this.find('.quick-question-res-p, .qsm-inline-correct-info').remove(); 552 } 553 } 554 555 if (showFeedback) { 556 if (Number(quizData.enable_quick_result_mc) === 1) { 557 self.qsmShowInlineResult(quizId, question_id, sendValue, $this, 'input', $i_this, $this.find('.qmn_fill_blank').index($i_this)); 558 } else if (Number(quizData.enable_quick_correct_answer_info) !== 0) { 559 let data = self.qsmQuestionQuickResultJs(question_id, sendValue, 'input', quizData.enable_quick_correct_answer_info, quizId, $this.find('.qmn_fill_blank').index($i_this)); 560 $this.find('.quick-question-res-p, .qsm-inline-correct-info').remove(); 561 if (value.length > 0 && data.success != '') { 562 $this.append('<div class="qsm-inline-correct-info">' + self.qsmCheckShortcode(data.message) + '</div>'); 563 } 564 } 565 } 566 567 // Trigger after select answer event (for addons) 568 $(document).trigger('qsm_after_select_answer', [quizId, question_id, value, $this, 'input', $this.find('.qmn_fill_blank').index($i_this)]); 569 }, 2000); 570 }); 571 }, 572 573 /** 574 * Bind file upload event handlers (matching legacy file upload functionality) 575 */ 576 bindFileUploadEvents: function(quizId) { 577 let self = this; 578 let currentQuiz = this.quizObjects[quizId]; 579 let $container = currentQuiz.quizContainer; 580 $container.off('.qsmFileUpload'); 581 582 // File input change event - validation and upload 583 $container.on('change.qsmFileUpload', '.quiz_section .mlw_answer_file_upload', async function() {
584 let $this = $(this); 585 let file_data = $this.prop('files')[0]; 586 if (!file_data) { 587 await self.qsmRemoveUploadedFile($this.parent('.quiz_section, .qsm-question').find('.qsm-file-upload-container').find('.remove-uploaded-file')); 588 return false; 589 } 590 591 let question_id = $this.parent('.quiz_section').find('.mlw_answer_file_upload').attr("name").replace('qsm_file_question', ''); 592 let quizData = self.getQuizData(quizId); 593 let file_upload_type = quizData.questions_settings && quizData.questions_settings[question_id] ? quizData.questions_settings[question_id].file_upload_type : ''; 594 let file_upload_limit = quizData.questions_settings && quizData.questions_settings[question_id] ? quizData.questions_settings[question_id].file_upload_limit : 1; 595 file_upload_limit = file_upload_limit || 1; // Default 1MB 596 597 let $file_upload_status = $this.parent('.quiz_section').find('.qsm-file-upload-status'); 598 $file_upload_status.removeClass('qsm-error qsm-success qsm-processing'); 599 $file_upload_status.addClass('qsm-processing'); 600 601 // Get processing message 602 let processingMsg = (typeof qmn_ajax_object !== 'undefined' && qmn_ajax_object.validate_process) ? qmn_ajax_object.validate_process : 'Processing...'; 603 $file_upload_status.text(processingMsg).show(); 604 605 // Build allowed mime types array 606 let allowed_mime_types = []; 607 if (file_upload_type) { 608 let types = file_upload_type.split(','); 609 types.forEach(function(type) { 610 type = type.trim(); 611 if (type === 'image') { 612 allowed_mime_types.push('image/jpeg', 'image/png', 'image/x-icon', 'image/gif', 'image/webp'); 613 } else if (type === 'doc') { 614 allowed_mime_types.push('application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'); 615 } else if (type === 'excel') { 616 allowed_mime_types.push('application/excel', 'application/vnd.ms-excel', 'application/x-excel', 'application/x-msexcel', 617 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'text/csv'); 618 } else if (type) { 619 allowed_mime_types.push(type); 620 } 621 }); 622 } 623 624 let trigger_message, trigger_type = ''; 625 626 // Validate file type and size 627 if (!allowed_mime_types.includes(file_data.type) || (file_upload_limit > 0 && file_data.size > file_upload_limit * 1024 * 1024)) { 628 let warning_message = ''; 629 if (file_upload_limit > 0 && file_data.size > file_upload_limit * 1024 * 1024) { 630 warning_message = (typeof qmn_ajax_object !== 'undefined' && qmn_ajax_object.invalid_file_size) ? 631 qmn_ajax_object.invalid_file_size + file_upload_limit + 'MB' : 632 'File size must not exceed ' + file_upload_limit + 'MB'; 633 } else { 634 warning_message = (typeof qmn_ajax_object !== 'undefined' && qmn_ajax_object.invalid_file_type) ? 635 qmn_ajax_object.invalid_file_type + file_upload_type : 636 'Invalid file type. Allowed: ' + file_upload_type; 637 } 638 $this.val(''); 639 $file_upload_status.removeClass('qsm-processing'); 640 $file_upload_status.addClass('qsm-error').text(warning_message).show(); 641 trigger_message = warning_message; 642 trigger_type = 'error'; 643 } else {
644 // Validation passed - update UI to show success 645 $file_upload_status.removeClass('qsm-error qsm-processing'); 646 $file_upload_status.addClass('qsm-success'); 647 $this.parent('.quiz_section').find('.qsm-file-upload-name').html($this[0].files[0].name).show(); 648 $this.parent('.quiz_section').find('.qsm-file-upload-container').find('.remove-uploaded-file').show(); 649 650 let successMsg = (typeof qmn_ajax_object !== 'undefined' && qmn_ajax_object.validate_success) ? qmn_ajax_object.validate_success : 'File uploaded successfully'; 651 $file_upload_status.text(successMsg).show(); 652 trigger_message = successMsg; 653 trigger_type = 'success'; 654 } 655 656 // Trigger after file upload event 657 const obj = { 658 type: trigger_type, 659 message: trigger_message, 660 file_name: file_data.name, 661 file_type: file_data.type, 662 file_size: file_data.size, 663 file_path: '', 664 file_url: '', 665 }; 666 $(document).trigger('qsm_after_file_upload', [$this.parent(), obj]); 667 668 return false; 669 }); 670 671 // Remove file click event 672 $container.on('click.qsmFileUpload', '.quiz_section .remove-uploaded-file', async function() { 673 await self.qsmRemoveUploadedFile($(this)); 674 return false; 675 }); 676 677 // Click on upload container to trigger file input 678 $container.on('click.qsmFileUpload', '.quiz_section .qsm-file-upload-container', function(e) { 679 e.preventDefault(); 680 // Don't trigger file upload if clicking on remove button 681 if (!$(e.target).hasClass('remove-uploaded-file')) { 682 $(this).prev('.mlw_answer_file_upload').trigger('click'); 683 } 684 }); 685 686 // Drag and drop events 687 $container.on('dragover.qsmFileUpload', '.quiz_section .qsm-file-upload-container', function(e) { 688 e.preventDefault(); 689 e.stopPropagation(); 690 $(this).addClass('file-hover'); 691 }); 692 693 $container.on('dragenter.qsmFileUpload', '.quiz_section .qsm-file-upload-container', function(e) { 694 e.preventDefault(); 695 e.stopPropagation(); 696 }); 697 698 $container.on('dragleave.qsmFileUpload', '.quiz_section .qsm-file-upload-container', function(e) { 699 e.preventDefault(); 700 e.stopPropagation(); 701 $(this).removeClass('file-hover'); 702 }); 703 704 $container.on('drop.qsmFileUpload', '.quiz_section .qsm-file-upload-container', function(e) { 705 $(this).removeClass('file-hover'); 706 if (e.originalEvent.dataTransfer) { 707 if (e.originalEvent.dataTransfer.files.length) { 708 e.preventDefault(); 709 e.stopPropagation(); 710 $(this).find('.qsm-file-upload-name').html(e.originalEvent.dataTransfer.files[0].name).fadeIn(); 711 $(this).prev('.mlw_answer_file_upload').prop('files', e.originalEvent.dataTransfer.files); 712 $(this).prev('.mlw_answer_file_upload').trigger('change'); 713 } 714 } 715 }); 716 717 $container.on('mouseleave.qsmFileUpload', '.quiz_section .qsm-file-upload-container', function() { 718 $(this).removeClass('file-hover'); 719 }); 720 721 //Deselect all answer on select 722 jQuery('.qsm-deselect-answer').click(function (e) { 723 e.preventDefault(); 724 jQuery(this).parents('.quiz_section').find('input[type="radio"]').prop('checked', false); 725 jQuery(this).parents('.quiz_section').find('input[type="radio"]:hidden').prop('checked', true); 726 }); 727 728 //Submit the form on popup click 729 jQuery(document).on('click', '.submit-the-form', function (e) { 730 e.preventDefault(); 731 // Triggger the click event on the quiz form's submit button. 732 jQuery('.qsm-submit-btn').trigger('click'); 733 jQuery('#modal-3').removeClass('is-open'); 734 }); 735 736 jQuery('.pagetime-goto-nextpage').click(function (e) { 737 e.preventDefault(); 738 let quiz_id = jQuery(this).data('quiz_id'); 739 let $container = jQuery('#quizForm' + quiz_id).closest('.qmn_quiz_container'); 740 if(!$container.find('.qsm-submit-btn').is(':visible')) { 741 QSMPagination.Navigation.nextPage(quiz_id); 742 QSMPagination.Navigation.scrollToQuiz(quiz_id); 743 }else{ 744 $container.find(".mlw_custom_next").hide(); 745 } 746 }); 747 748 jQuery(document).on('keyup', '.mlwPhoneNumber', function (e) { 749 this.value = this.value.replace(/[^- +()0-9\.]/g, ''); 750 }); 751 752 jQuery(document).on('click', '.qsm_social_share_link', function (e) { 753 e.preventDefault(); 754 let network = jQuery(this).attr('data-network'); 755 let share_url = jQuery(this).attr('data-link'); 756 let social_text = jQuery(this).attr('data-text'); 757 let social_id = jQuery(this).attr('data-id'); 758 let url = ''; 759 if (network == 'facebook') { 760 url = "https://www.facebook.com/dialog/feed?" + "display=popup&" + "app_id=" + social_id + 761 "&" + "link=" + encodeURIComponent(share_url) + "&" + "name=" + social_text; 762 } 763 if (network == 'twitter') { 764 url = "https://twitter.com/intent/tweet?text=" + social_text; 765 } 766 if (network == 'linkedin') { 767 url = "https://www.linkedin.com/feed/?text=" + social_text; 768 } 769 let sTop = window.screen.height / 2 - (218); 770 let sLeft = window.screen.width / 2 - (313); 771 let sqShareOptions = "height=400,width=580,toolbar=0,status=0,location=0,menubar=0,directories=0,scrollbars=0,top=" + sTop + ",left=" + sLeft; 772 window.open(url, "Share", sqShareOptions); 773 return false;
774 }); 775 }, 776 777 /** 778 * Remove uploaded file (matching legacy qsm_remove_uploaded_file_fd_question) 779 */ 780 qsmRemoveUploadedFile: async function($removeButton) { 781 let parents_section = $removeButton.parents('.quiz_section, .qsm-question'); 782 parents_section.find('.qsm-file-upload-status').removeClass('qsm-processing qsm-success'); 783 parents_section.find('.qsm-file-upload-status').addClass('qsm-error'); 784 785 let removeMsg = (typeof qmn_ajax_object !== 'undefined' && qmn_ajax_object.remove_file) ? qmn_ajax_object.remove_file : 'Removing file...'; 786 parents_section.find('.qsm-file-upload-status').html(removeMsg).show(); 787 parents_section.find('.qsm-file-upload-name').html('').show(); 788 $removeButton.hide(); 789 parents_section.find('.mlw_answer_file_upload').val(''); 790 791 let successMsg = (typeof qmn_ajax_object !== 'undefined' && qmn_ajax_object.remove_file_success) ? qmn_ajax_object.remove_file_success : 'File removed successfully'; 792 parents_section.find('.qsm-file-upload-status').text(successMsg); 793 794 // Trigger after file remove event 795 $(document).trigger('qsm_after_file_remove', [$removeButton.parent(), {type: 'success', message: successMsg}]); 796 }, 797 798 /** 799 * Start the quiz (handle start button click) 800 */ 801 startQuiz: function(quizId) { 802 let quizData = this.quizObjects[quizId]; 803 if (!quizData) return; 804 805 // Start timer if available 806 if (QSMPagination && QSMPagination.Timer) { 807 QSMPagination.Timer.start(quizId); 808 } 809 810 // Trigger quiz started event 811 $(document).trigger('qsm_quiz_started', [quizId, quizData]); 812 }, 813 814 /** 815 * Navigate to specific page (1-based indexing) 816 */ 817 goToPage: function(quizId, pageNumber, updateHistory) { 818 let self = this; 819 let quizData = self.quizObjects[quizId]; 820 if (!quizData || pageNumber < 1 || pageNumber > quizData.totalPages) { 821 return; 822 } 823 jQuery(document).trigger('qsm_go_to_page_before', [quizId, pageNumber]); 824 825 jQuery('.qsm-multiple-response-input:checked, .qmn-multiple-choice-input:checked , .qsm_select:visible').each(function () { 826 if (quizData.data.end_quiz_if_wrong > 0 && jQuery(this).parents().is(':visible') && jQuery(this).is('input, select')) { 827 if (jQuery(this).parents('.qmn_radio_answers, .qsm_check_answer')) { 828 let question_id = jQuery(this).attr('name').split('question')[1], 829 value = jQuery(this).val(), 830 $this = jQuery(this).parents('.quiz_section'); 831 if (value != "" && value != null) { 832 self.qsmSubmitQuizIfAnswerWrong(quizId, question_id, value, $this, quizData.form); 833 } 834 } 835 } 836 }) 837 838 updateHistory = updateHistory !== false; // Default to true 839 840 // Update current page 841 quizData.currentPage = pageNumber; 842 843 // Show the target page 844 this.showPage(quizId, pageNumber); 845 846 // Update UI components 847 this.updateNavigationButtons(quizId); 848 this.updatePageCounter(quizId); 849 850 // Scroll to top of quiz 851 if (quizData.data.disable_scroll_next_previous_click != 1) { 852 this.scrollToQuiz(quizId); 853 } 854 855 // Trigger after page change event 856 $(document).trigger('qsm_go_to_page_after', [quizId, pageNumber, quizData]); 857 }, 858 859 /** 860 * Show specific page (helper method) 861 */ 862 showPage: function(quizId, pageNumber) { 863 let quizData = this.quizObjects[quizId]; 864 if (!quizData) return; 865 866 // Trigger before page show event 867 $(document).trigger('qsm_show_page_before', [quizId, pageNumber, quizData]); 868 869 // Hide all pages 870 quizData.pages.hide(); 871 872 // Show target page (convert to 0-based index for DOM) 873 let $targetPage = quizData.pages.eq(pageNumber - 1); 874 // Trigger page timer init/update (used by Advanced Timer addon) 875 jQuery(document).trigger('end_page_timer_init_page_timer', [quizId, $targetPage]); 876 if ($targetPage.length > 0) { 877 $targetPage.show(); 878 // Check if this page needs lazy loading 879 if ($targetPage.hasClass('qsm-lazy-load-page') && $targetPage.attr('data-lazy-load') === '1') { 880 this.loadPageQuestions(quizId, $targetPage, pageNumber); 881 } 882 883 // Preemptively load next pages (load 2 pages ahead) 884 this.preloadNextPages(quizId, pageNumber); 885 } 886 887 // Trigger after page show event 888 $(document).trigger('qsm_show_page_after', [quizId, pageNumber, quizData]); 889 }, 890 891 /** 892 * Preload next pages (load 2 pages ahead of current page) 893 * This ensures smooth navigation by loading pages before user reaches them
894 */ 895 preloadNextPages: function(quizId, currentPage) { 896 let self = this; 897 let quizData = this.quizObjects[quizId]; 898 if (!quizData) return; 899 900 // Calculate which pages to preload (next 2 pages) 901 let pagesToPreload = []; 902 903 // Preload page currentPage + 1 and currentPage + 2 904 for (let i = 1; i <= 2; i++) { 905 let nextPageNum = currentPage + i; 906 if (nextPageNum <= quizData.totalPages) { 907 pagesToPreload.push(nextPageNum); 908 } 909 } 910 911 // Load each page that needs preloading 912 pagesToPreload.forEach(function(pageNum) { 913 let $nextPage = quizData.pages.eq(pageNum - 1); 914 915 // Only preload if: 916 // 1. Page exists 917 // 2. Page is marked for lazy loading 918 // 3. Page hasn't been loaded yet 919 // 4. Page is not currently being loaded 920 if ($nextPage.length > 0 && 921 $nextPage.hasClass('qsm-lazy-load-page') && 922 $nextPage.attr('data-lazy-load') === '1' && 923 !$nextPage.hasClass('qsm-loading')) { 924 925 self.loadPageQuestions(quizId, $nextPage, pageNum); 926 } 927 }); 928 }, 929 930 /** 931 * Load questions for a lazy-loaded page via AJAX 932 */ 933 loadPageQuestions: function(quizId, $page, pageNumber) { 934 let self = this; 935 let quizData = this.quizObjects[quizId]; 936 if (!quizData) return; 937 938 // Check if already loading or loaded 939 if ($page.hasClass('qsm-loading') || $page.hasClass('qsm-loaded-page')) { 940 return; 941 } 942 943 // Mark as loading 944 $page.addClass('qsm-loading'); 945 946 // Show loading spinner 947 $page.find('.qsm-lazy-load-spinner').show(); 948 949 // Get question IDs and other data from page attributes 950 let questionIds = $page.attr('data-question-ids') || ''; 951 let questionStartNumber = Number.parseInt($page.attr('data-question-start-number')) || 1; 952 953 // Prepare AJAX data 954 let ajaxData = { 955 action: 'qsm_load_page_questions', 956 nonce: quizData.data.lazy_load_nonce || quizData.data.nonce || (typeof qmn_ajax_object !== 'undefined' ? qmn_ajax_object.security : ''), 957 quiz_id: quizId, 958 page_number: pageNumber, 959 question_ids: questionIds, 960 question_start_number: questionStartNumber, 961 randomness_order: JSON.stringify(quizData.data.randomness_order || []) 962 }; 963 964 // Trigger before lazy load event 965 $(document).trigger('qsm_before_lazy_load', [quizId, pageNumber, $page]); 966 967 // Make AJAX request 968 $.ajax({ 969 url: quizData.data.ajax_url || (typeof qmn_ajax_object !== 'undefined' ? qmn_ajax_object.ajaxurl : '/wp-admin/admin-ajax.php'), 970 type: 'POST', 971 data: ajaxData, 972 success: function(response) { 973 if (response.success && response.data.html) { 974 // Insert questions HTML where the placeholder sits, then remove it. 975 // The placeholder is the stable anchor: .pages_count may be absent 976 // when the "show pagination count" option is disabled. 977 let $placeholder = $page.find('.qsm-lazy-load-placeholder'); 978 if ($placeholder.length) { 979 $placeholder.before(response.data.html); 980 $placeholder.remove(); 981 } else if ($page.find('.pages_count').length) { 982 $page.find('.pages_count').before(response.data.html); 983 } else { 984 $page.append(response.data.html); 985 } 986 987 // Mark page as loaded 988 $page.removeClass('qsm-lazy-load-page qsm-loading'); 989 $page.addClass('qsm-loaded-page'); 990 $page.attr('data-lazy-load', '0'); 991 992 // Persist answer randomization order from the AJAX response 993 // so the server can render results in the order the user saw. 994 if (response.data.quiz_answer_random_ids) { 995 self.appendLazyAnswerRandomIds(quizId, response.data.quiz_answer_random_ids); 996 } 997 998 // Re-bind events for newly loaded questions 999 self.bindAnswerEvents(quizId); 1000 1001 // Initialize any new file upload fields 1002 self.bindFileUploadEvents(quizId); 1003 1004 // Trigger after lazy load event 1005 $(document).trigger('qsm_after_lazy_load', [quizId, pageNumber, $page, response.data]); 1006 } else { 1007 self.handleLazyLoadError(quizId, $page, response.data ? response.data.message : 'Unknown error'); 1008 } 1009 }, 1010 error: function(xhr, status, error) { 1011 self.handleLazyLoadError(quizId, $page, 'AJAX error: ' + error); 1012 } 1013 }); 1014 }, 1015 1016 /**
1017 * Inject hidden inputs that carry the shuffled answer key order for 1018 * lazy-loaded questions, so result rendering can match the order the 1019 * user actually saw. 1020 */ 1021 appendLazyAnswerRandomIds: function(quizId, randomIdsMap) { 1022 if (!randomIdsMap || typeof randomIdsMap !== 'object') { 1023 return; 1024 } 1025 let $form = $('#quizForm' + quizId); 1026 if (!$form.length) { 1027 $form = $('form.qmn_quiz_container[data-quiz-id="' + quizId + '"]'); 1028 } 1029 if (!$form.length) { 1030 return; 1031 } 1032 Object.keys(randomIdsMap).forEach(function(questionId) { 1033 let keys = randomIdsMap[questionId]; 1034 if (!Array.isArray(keys)) { 1035 return; 1036 } 1037 $form.find('input[data-qsm-lazy-answer-qid="' + questionId + '"]').remove(); 1038 keys.forEach(function(key) { 1039 $('<input>', { 1040 type: 'hidden', 1041 name: 'qsm_lazy_answer_random_ids[' + questionId + '][]', 1042 value: key, 1043 'data-qsm-lazy-answer-qid': questionId 1044 }).appendTo($form); 1045 }); 1046 }); 1047 }, 1048 1049 /** 1050 * Handle lazy load errors 1051 */ 1052 handleLazyLoadError: function(quizId, $page, errorMessage) { 1053 1054 // Hide spinner 1055 $page.find('.qsm-lazy-load-spinner').hide(); 1056 1057 // Show error message 1058 let errorHtml = '<div class="qsm-error-message" style="padding: 20px; color: #d63638;">' + 1059 '<strong>Error loading questions:</strong><br>' + errorMessage + 1060 '<br><button class="qsm-retry-load" style="margin-top: 10px;">Retry</button>' + 1061 '</div>'; 1062 $page.find('.qsm-lazy-load-placeholder').html(errorHtml); 1063 1064 // Remove loading class 1065 $page.removeClass('qsm-loading'); 1066 1067 // Bind retry button 1068 let self = this; 1069 $page.find('.qsm-retry-load').on('click', function() { 1070 $page.find('.qsm-error-message').remove(); 1071 $page.find('.qsm-lazy-load-placeholder').html('<div class="qsm-lazy-load-spinner" style="display: none;"></div>'); 1072 self.loadPageQuestions(quizId, $page, Number.parseInt($page.attr('data-page'))); 1073 }); 1074 1075 // Trigger error event 1076 $(document).trigger('qsm_lazy_load_error', [quizId, $page, errorMessage]); 1077 }, 1078 1079 /** 1080 * Go to next page 1081 */ 1082 nextPage: function(quizId) { 1083 let quizData = this.quizObjects[quizId]; 1084 1085 if (!quizData) return; 1086 1087 // Validate current page before proceeding 1088 if (!this.validateCurrentPage(quizId)) { 1089 return; 1090 } 1091 1092 // Check if this is the last page 1093 if (quizData.currentPage >= quizData.totalPages) { 1094 // Submit form if on last page 1095 quizData.form.trigger('submit'); 1096 return; 1097 } 1098 1099 // Go to next page 1100 this.goToPage(quizId, quizData.currentPage + 1); 1101 1102 // Manage focus for accessibility 1103 this.manageFocus(quizId); 1104 1105 // Trigger next button click event (for legacy compatibility) 1106 $(document).trigger('qsm_next_button_click_after', [quizId]); 1107 }, 1108 1109 /** 1110 * Go to previous page 1111 */ 1112 previousPage: function(quizId) { 1113 let quizData = this.quizObjects[quizId]; 1114 if (!quizData) return; 1115 1116 // Check minimum page based on first page setting 1117 let minPage; 1118 if (quizData.hasFirstPage) { 1119 // First page enabled: Can't go back past page 2 (first question page, not welcome page) 1120 minPage = 2; 1121 } else { 1122 // No first page, so all pages are question pages 1123 minPage = 1; 1124 } 1125 1126 if (quizData.currentPage < minPage) return; 1127 this.goToPage(quizId, quizData.currentPage - 1); 1128 1129 // Manage focus for accessibility 1130 this.manageFocus(quizId); 1131 1132 // Trigger previous button click event (for legacy compatibility) 1133 $(document).trigger('qsm_previous_button_click_after', [quizId]); 1134 }, 1135 1136 /** 1137 * Update navigation buttons visibility and state 1138 */ 1139 updateNavigationButtons: function(quizId) { 1140 let quizData = this.quizObjects[quizId]; 1141 if (!quizData) return; 1142 1143 let $previousBtn = jQuery('.qsm-previous-btn-'+quizId); 1144 let $nextBtn = jQuery('.qsm-next-btn-'+quizId); 1145 let $submitBtn = jQuery('.qsm-submit-btn-'+quizId); 1146 let $startBtn = jQuery('.qsm-start-btn-'+quizId); 1147 1148 let currentPage = quizData.currentPage; 1149 let isFirstPage = (currentPage == 1); 1150 let isLastPage = (currentPage == quizData.totalPages); 1151 let showStartButton = (isFirstPage && quizData.hasFirstPage); 1152 1153 let $featureImage = quizData.quizContainer.find('.qsm-quiz-default-feature-image'); 1154 if (showStartButton) { 1155 $featureImage.show(); 1156 } else { 1157 $featureImage.hide(); 1158 } 1159 1160 // Simple button visibility logic 1161 if (showStartButton) { 1162 // First page (welcome page) with start button 1163 $startBtn.show(); 1164 $previousBtn.hide(); 1165 $nextBtn.hide(); 1166 $submitBtn.hide(); 1167 } else { 1168 // Regular navigation pages (question pages) 1169 $startBtn.hide(); 1170 1171 // Previous button logic: 1172 // - Hide on first page when no welcome page 1173 // - Hide on page 2 when there IS a welcome page (can't go back from first question page to welcome) 1174 // - Show otherwise 1175 if (currentPage < 2) { 1176 $previousBtn.hide(); 1177 } else { 1178 $previousBtn.show(); 1179 } 1180 1181 // Next/Submit buttons: submit on last page, next otherwise 1182 if (isLastPage) { 1183 $nextBtn.hide(); 1184 $submitBtn.show(); 1185 1186 } else { 1187 $nextBtn.show(); 1188 $submitBtn.hide(); 1189 1190 } 1191 } 1192 }, 1193 1194 /** 1195 * Update page counter display 1196 */ 1197 updatePageCounter: function(quizId) { 1198 let quizData = this.quizObjects[quizId]; 1199 if (!quizData) return; 1200 1201 let currentPage = quizData.form.find(this.config.selectors.currentPageField); 1202 currentPage.val(quizData.currentPage); 1203 1204 let $counter = quizData.quizContainer.find(this.config.selectors.pageCounter); 1205 if ($counter.length) { 1206 // Don't show page counter for start page (welcome page) 1207 if (quizData.currentPage === 1 && quizData.hasFirstPage) { 1208 $counter.hide(); 1209 } else { 1210 // Calculate display numbers for question pages only 1211 let displayPage, displayTotal; 1212 1213 if (quizData.hasFirstPage) { 1214 // First page exists, so question pages start from page 2 1215 displayPage = Math.max(1, quizData.currentPage - 1); 1216 displayTotal = quizData.questionPages; 1217 } else { 1218 // No first page, so all pages are question pages 1219 displayPage = quizData.currentPage; 1220 displayTotal = quizData.questionPages; 1221 } 1222 1223 let text = 'Page ' + displayPage + ' of ' + displayTotal; 1224 $counter.text(text); 1225 } 1226 } 1227 }, 1228 1229 1230 /** 1231 * Validate current page 1232 */ 1233 validateCurrentPage: function(quizId) { 1234 let quizData = this.quizObjects[quizId]; 1235 if (!quizData || !quizData.validation.enabled) return true; 1236 1237 let $currentPage = quizData.pages.eq(quizData.currentPage - 1); 1238 if (!$currentPage.length) { 1239 return true; 1240 } 1241 1242 return this.validateElements($currentPage.find('*'), quizId); 1243 }, 1244 1245 /** 1246 * Validate entire form (similar to qmnValidation) 1247 */ 1248 validateForm: function(quizId) { 1249 let quizData = this.quizObjects[quizId]; 1250 if (!quizData) return false;
1251 1252 // Skip validation if timer limit is enabled and time expired 1253 if (quizData.data.timer_limit_val > 0 && quizData.data.hasOwnProperty('skip_validation_time_expire') && quizData.data.skip_validation_time_expire != 1) { 1254 return true; 1255 } 1256 1257 return this.validateElements(quizData.form.find('*'), quizId); 1258 }, 1259 1260 /** 1261 * Core validation logic (based on qmnValidation) 1262 */ 1263 validateElements: function($elements, quizId) { 1264 let quizData = this.quizObjects[quizId]; 1265 if (!quizData) return false; 1266 1267 show_result_validation = true; 1268 jQuery(document).trigger('qsm_before_validation', [$elements, 'quizForm' + quizId]); 1269 let data = quizData.data; 1270 let errorMessages = data.error_messages || {}; 1271 1272 // Clear previous errors 1273 this.resetErrors(quizId); 1274 1275 let self = this; 1276 $elements.each(function() { 1277 let $field = $(this); 1278 let fieldClass = $field.attr('class') || ''; 1279 let fieldValue = $field.val() || ''; 1280 1281 // Skip if field is not visible or doesn't have validation classes 1282 if (!fieldClass || (!$field.is(':visible') && !$field.parent().is(':visible'))) { 1283 return; 1284 } 1285 1286 // Email validation 1287 if (fieldClass.indexOf('mlwEmail') !== -1 && fieldValue !== '') { 1288 let email = $.trim(fieldValue); 1289 if (!self.isValidEmail(email)) { 1290 self.displayError(errorMessages.email_error_text || 'Please enter a valid email address.', $field, quizId); 1291 show_result_validation = false; 1292 } 1293 } 1294 1295 // Phone number validation 1296 if (fieldClass.includes('mlwPhoneNumber') && fieldValue !== '') { 1297 let phoneValue = $.trim(fieldValue); 1298 let phonePattern = $field.attr('data-phone-pattern'); 1299 if (phonePattern !== undefined && phonePattern !== '') { 1300 let escapedPattern = phonePattern.replaceAll(/[.+?^$|()\\]/g, String.raw`\$&`); 1301 escapedPattern = escapedPattern.replaceAll(/ /g, String.raw`\s+`); 1302 let pattern = new RegExp(String.raw`^${escapedPattern}$`); 1303 if (!pattern.test(phoneValue)) { 1304 self.displayError(errorMessages.phone_error_text || 'Please enter a valid phone number.', $field, quizId); 1305 show_result_validation = false; 1306 } 1307 } 1308 } 1309 1310 // URL validation 1311 if (fieldClass.indexOf('mlwUrl') !== -1 && fieldValue !== '') { 1312 if (!self.isValidUrl($.trim(fieldValue))) { 1313 self.displayError(errorMessages.url_error_text || 'Please enter a valid URL.', $field, quizId); 1314 show_result_validation = false; 1315 } 1316 } 1317 1318 // Min length validation 1319 if (fieldClass.indexOf('mlwMinLength') !== -1 && fieldValue !== '') { 1320 let minLength = Number.parseInt($field.attr('minlength')) || 0; 1321 if ($.trim(fieldValue).length < minLength) { 1322 let message = (errorMessages.minlength_error_text || 'Minimum %minlength% characters required.').replace('%minlength%', minLength); 1323 self.displayError(message, $field, quizId); 1324 show_result_validation = false;
1325 } 1326 } 1327 1328 // Max length validation 1329 if (fieldClass.indexOf('mlwMaxLength') !== -1 && fieldValue !== '') { 1330 let maxLength = Number.parseInt($field.attr('maxlength')) || 0; 1331 if ($.trim(fieldValue).length > maxLength) { 1332 let message = (errorMessages.maxlength_error_text || 'Maximum %maxlength% characters allowed.').replace('%maxlength%', maxLength); 1333 self.displayError(message, $field, quizId); 1334 show_result_validation = false; 1335 } 1336 } 1337 1338 // Required field validations 1339 let isContactField = $field.closest('.qsm_contact_div').length > 0; 1340 let requiredErrorMsg = (isContactField && errorMessages.contact_field_required_error_text) 1341 ? errorMessages.contact_field_required_error_text 1342 : (errorMessages.empty_error_text || 'Please complete all required fields!'); 1343 1344 if (fieldClass.indexOf('mlwRequiredText') !== -1 && $.trim(fieldValue) === '') { 1345 self.displayError(requiredErrorMsg, $field, quizId); 1346 show_result_validation = false; 1347 } 1348 1349 if (fieldClass.indexOf('mlwRequiredPolar') !== -1 && $.trim(fieldValue) === '') { 1350 self.displayError(errorMessages.empty_error_text || 'This field is required.', $field, quizId); 1351 show_result_validation = false; 1352 } 1353 1354 if (fieldClass.indexOf('mlwRequiredNumber') !== -1 && (fieldValue === '' || Number.isNaN(fieldValue))) { 1355 self.displayError(errorMessages.number_error_text || 'Please enter a valid number.', $field, quizId); 1356 show_result_validation = false; 1357 } 1358 1359 if (fieldClass.indexOf('mlwRequiredDate') !== -1 && fieldValue === '') { 1360 self.displayError(errorMessages.empty_error_text || 'This field is required.', $field, quizId); 1361 show_result_validation = false; 1362 } 1363 1364 if (fieldClass.indexOf('mlwRequiredAccept') !== -1 && !$field.prop('checked')) { 1365 self.displayError(errorMessages.empty_error_text || 'You must accept this.', $field, quizId); 1366 show_result_validation = false; 1367 } 1368 1369 if (fieldClass.indexOf('mlwRequiredRadio') !== -1) { 1370 let checkedVal = $field.find('input:checked').val(); 1371 if (!checkedVal) { 1372 self.displayError(errorMessages.empty_error_text || 'Please select an option.', $field, quizId); 1373 show_result_validation = false; 1374 } 1375 } 1376 1377 if (fieldClass.indexOf('qsmRequiredSelect') !== -1) { 1378 let checkedVal = $field.find('select').val() || $field.filter('select').val(); 1379 if (!checkedVal) { 1380 self.displayError(errorMessages.empty_error_text || 'Please select an option.', $field, quizId); 1381 show_result_validation = false; 1382 } 1383 } 1384 1385 if (fieldClass.indexOf('mlwRequiredCheck') !== -1) { 1386 let checkedVal = $field.find('input:checked').val(); 1387 if (!checkedVal) { 1388 self.displayError(errorMessages.empty_error_text || 'Please select an option.', $field, quizId); 1389 show_result_validation = false; 1390 } 1391 } 1392 1393 if (fieldClass.indexOf('mlwRequiredFileUpload') !== -1) { 1394 let files = $field.get(0).files; 1395 if (!files || files.length === 0) {
1396 self.displayError(errorMessages.empty_error_text || 'Please select a file.', $field, quizId); 1397 show_result_validation = false; 1398 } 1399 } 1400 }); 1401 jQuery(document).trigger('qsm_after_validation', [$elements, 'quizForm' + quizId]); 1402 return show_result_validation; 1403 }, 1404 1405 1406 /** 1407 * Display validation error (similar to qmnDisplayError) 1408 */ 1409 displayError: function(message, $field, quizId) { 1410 let quizData = this.quizObjects[quizId]; 1411 if (!quizData) return; 1412 1413 // Add error class to field 1414 $field.parents('.quiz_section').addClass('qmn_error'); 1415 1416 // Create error message element 1417 quizData.form.find('.qmn_error_message_section').addClass('qmn_error_message'); 1418 quizData.form.find('.qmn_error_message').text(message); 1419 }, 1420 1421 /** 1422 * Reset all errors (similar to qmnResetError) 1423 */ 1424 resetErrors: function(quizId) { 1425 let quizData = this.quizObjects[quizId]; 1426 if (!quizData) return; 1427 1428 quizData.firstErrorFocused = false; 1429 1430 // Remove error messages 1431 quizData.form.find('.qmn_error_message').text(''); 1432 quizData.form.find('.qmn_error_message_section').removeClass('qmn_error_message'); 1433 1434 // Remove error classes 1435 quizData.form.find('.qmn_error').removeClass('qmn_error'); 1436 }, 1437 1438 clearPageErrors: function(quizId) { 1439 let quizData = this.quizObjects[quizId]; 1440 if (!quizData) return; 1441 1442 let $currentPage = quizData.pages.eq(quizData.currentPage - 1); 1443 $currentPage.find('.qmn_error').removeClass('qmn_error'); 1444 quizData.form.find('.qmn_error_message').text(''); 1445 quizData.form.find('.qmn_error_message_section').removeClass('qmn_error_message'); 1446 }, 1447 1448 clearFieldError: function($field) { 1449 $field.removeClass('qmn_error'); 1450 }, 1451 1452 /** 1453 * Validation helper methods 1454 */ 1455 isValidEmail: function(email) { 1456 let regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; 1457 return regex.test(email); 1458 }, 1459 1460 isValidUrl: function(url) { 1461 const regex = /^(https?|ftp):\/\/[^\s/?#]+(?:\?[^\s#]*)?(?:#[^\s]*)?$/i; 1462 return regex.test(url); 1463 }, 1464 1465 /** 1466 * Handle keyboard navigation 1467 */ 1468 handleKeyboardNavigation: function(e, quizId) { 1469 let quizData = this.quizObjects[quizId]; 1470 if (!quizData) { 1471 return; 1472 } 1473 1474 let qsm_keypress_navigation_action = { allowed: true }; 1475 $(document).trigger('qsm_keyboard_navigation_action_start', [e, qsm_keypress_navigation_action]); 1476 if (qsm_keypress_navigation_action.allowed === false) { 1477 return; 1478 } 1479 1480 // Don't interfere with keyboard navigation inside contact fields 1481 if ($(e.target).is('input, textarea, select') && $(e.target).closest('div.qsm_contact_div').length > 0) { 1482 return; 1483 } 1484 1485 let isTypingField = $(e.target).is('textarea, select, input[type="text"], input[type="email"], input[type="number"], input[type="search"], input[type="tel"], input[type="url"], input[type="password"], [contenteditable="true"]'); 1486 1487 // Allow Ctrl+Enter to submit form 1488 if (e.ctrlKey && e.keyCode === 13) { 1489 e.preventDefault(); 1490 quizData.form.trigger('submit'); 1491 return; 1492 } 1493 const tag = e.target.tagName.toLowerCase(); 1494 const isEditable = 1495 tag === 'textarea' || 1496 (tag === 'input' && ( 1497 e.target.type === 'text' || 1498 e.target.type === 'email' || 1499 e.target.type === 'number' || 1500 e.target.type === 'search' || 1501 e.target.type === 'tel' || 1502 e.target.type === 'url' || 1503 e.target.type === 'password' || 1504 e.target.type === 'file' 1505 )) && !['button', 'submit', 'checkbox', 'radio'].includes(e.target.type); 1506 1507 if (e.keyCode === 13 && !isEditable) { 1508 e.preventDefault(); 1509 1510 let $container = quizData.quizContainer; 1511 let $startBtn = $container.find('.qsm-start-btn:visible, .qsm-start-btn-' + quizId + ':visible'); 1512 let $submitBtn = $container.find('.qsm-submit-btn:visible, .qsm-submit-btn-' + quizId + ':visible'); 1513 let $nextBtn = $container.find('.qsm-next-btn:visible, .qsm-next-btn-' + quizId + ':visible'); 1514 1515 // Priority order: Start button â Next button â Submit button (only on last page) 1516 if ($startBtn.length) { 1517 $startBtn.first().trigger('click'); 1518 return; 1519 } 1520 if ($nextBtn.length) { 1521 $nextBtn.first().trigger('click'); 1522 return; 1523 } 1524 // Only submit if on last page (no next button) 1525 if ($submitBtn.length && !$nextBtn.length) { 1526 $submitBtn.first().trigger('click'); 1527 return; 1528 } 1529 1530 // Fallback 1531 this.nextPage(quizId); 1532 return; 1533 } 1534 1535 // Left/Right arrow keys to navigate pages (avoid interfer
1535ing with typing/caret movement) 1536 // Arrow keys should NEVER submit, only navigate 1537 if ((e.keyCode === 37 || e.keyCode === 39) && !isTypingField) { 1538 e.preventDefault(); 1539 let $container = quizData.quizContainer; 1540 if (e.keyCode === 39) { 1541 // Arrow Right - check for start button or next page 1542 let $startBtn = $container.find('.qsm-start-btn:visible, .qsm-start-btn-' + quizId + ':visible'); 1543 let $nextBtn = $container.find('.qsm-next-btn:visible, .qsm-next-btn-' + quizId + ':visible'); 1544 1545 if ($startBtn.length) { 1546 $startBtn.first().trigger('click'); 1547 return; 1548 } 1549 // Only navigate to next page if next button exists, never submit 1550 if ($nextBtn.length) { 1551 this.nextPage(quizId); 1552 } 1553 } else { 1554 // Arrow Left - navigate to previous page 1555 this.previousPage(quizId); 1556 } 1557 return; 1558 } 1559 1560 // Tab / Shift+Tab cycles through individual elements: question inputs â contact fields â navigation buttons 1561 if (e.keyCode === 9) { 1562 let $currentPage = quizData.pages.eq(quizData.currentPage - 1); 1563 let $container = quizData.quizContainer; 1564 1565 // Get all individual focusable elements from questions (inputs, selects, textareas, buttons, radios, checkboxes) 1566 let $questionInputs = $currentPage.find('.qsm-question-wrapper:visible').find('input, select, textarea, button, a[href]').filter(':visible'); 1567 1568 // Contact form fields 1569 let $contactFields = $currentPage.find('.qsm_contact_div').find('input, select, textarea, button, a[href]').filter(':visible'); 1570 1571 // Navigation buttons 1572 let $prevBtn = $container.find('.qsm-previous-btn:visible, .qsm-previous:visible, .qsm-previous-btn-' + quizId + ':visible').first(); 1573 let $nextBtn = $container.find('.qsm-next-btn:visible, .qsm-next:visible, .qsm-next-btn-' + quizId + ':visible').first(); 1574 let $submitBtn = $container.find('.qsm-submit-btn:visible, .qsm-submit-btn-' + quizId + ':visible').first(); 1575 let $startBtn = $container.find('.qsm-start-btn:visible, .qsm-start-btn-' + quizId + ':visible').first(); 1576 1577 // Build ordered list of all individual focusable elements 1578 let focusOrder = []; 1579 1580 // Add question inputs (each individual element) 1581 $questionInputs.each(function() { focusOrder.push($(this)); }); 1582 1583 // Add contact fields 1584 $contactFields.each(function() { focusOrder.push($(this)); }); 1585 1586 // Add navigation buttons 1587 if ($prevBtn.length) focusOrder.push($prevBtn); 1588 if ($startBtn.length) { 1589 focusOrder.push($startBtn); 1590 } else if ($submitBtn.length) { 1591 focusOrder.push($submitBtn); 1592 } else if ($nextBtn.length) { 1593 focusOrder.push($nextBtn); 1594 } 1595 1596 if (focusOrder.length === 0) return; 1597 1598 // Find current focus position 1599 let currentIndex = -1; 1600 let $activeElement = $(document.activeElement); 1601 for (let i = 0; i < focusOrder.length; i++) { 1602 if (focusOrder[i].is($activeElement)) { 1603 currentIndex = i; 1604 break; 1605 } 1606 } 1607 1608 // If no focusable element is focused, start from beginning 1609 if (currentIndex === -1) { 1610 e.preventDefault(); 1611 focusOrder[0].focus(); 1612 // Update active question highlighting 1613 let $wrapper = focusOrder[0].closest('.qsm-question-wrapper'); 1614 if ($wrapper.length) { 1615 $currentPage.find('.qsm-question-wrapper').removeClass('qsm-active-question qsm-last-active-question'); 1616 $wrapper.addClass('qsm-active-question'); 1617 } 1618 return; 1619 } 1620 1621 e.preventDefault(); 1622 1623 // Calculate next index 1624 let nextIndex; 1625 if (e.shiftKey) { 1626 nextIndex = currentIndex - 1; 1627 if (nextIndex < 0) nextIndex = focusOrder.length - 1; 1628 } else { 1629 nextIndex = currentIndex + 1; 1630 if (nextIndex >= focusOrder.length) nextIndex = 0; 1631 } 1632 1633 // Focus the next element 1634 focusOrder[nextIndex].focus(); 1635 1636 // Update active question highlighting for the newly focused element 1637 let $nextWrapper = focusOrder[nextIndex].closest('.qsm-question-wrapper'); 1638 if ($nextWrapper.length) { 1639 $currentPage.find('.qsm-question-wrapper').removeClass('qsm-active-question qsm-last-active-question'); 1640 $nextWrapper.addClass('qsm-active-question'); 1641 } 1642 1643 return; 1644 } 1645 }, 1646 1647 /** 1648 * Scroll to quiz form 1649 */ 1650 scrollToQuiz: function(quizId) { 1651 let quizData = this.quizObjects[quizId]; 1652 if (!quizData) return; 1653 1654 $('html, body').animate({ 1655 scrollTop: quizData.form.offset().top - 50 1656 }, this.config.animation.duration); 1657 }, 1658 1659 /** 1660 * Manage focus for accessibility 1661 */ 1662 manageFocus: function(quizId) { 1663 let quizData = this.quizObjects[quizId]; 1664 if (!quizData) return; 1665 1666 let $currentPage = quizData.pages.eq(quizData.currentPage - 1); 1667 1668 $currentPage.css('outline', 'none').attr('tabindex', '-1').focus(); 1669 }, 1670 1671 /** 1672 * Submit the form (similar to qmnFormSubmit) 1673 */ 1674 submitForm: function(quizId) { 1675 let quizData = this.quizObjects[quizId]; 1676 if (!quizData) return; 1677 1678 let self = this; 1679 if (!self.validateForm(quizId)) { 1680 return false;
1681 } 1682 // Trigger before submit event 1683 $(document).trigger('qsm_before_quiz_submit', ['quizForm' + quizId]); 1684 1685 // Disable submit button to prevent double submission 1686 quizData.form.find('input[type="submit"], .qsm-submit').prop('disabled', true); 1687 quizData.quizContainer.find('input[type="submit"], .qsm-submit').prop('disabled', true); 1688 1689 // Enable all form fields for submission (except submit buttons) 1690 quizData.form.find('input:not([type="submit"]), select, textarea').prop('disabled', false); 1691 1692 // Prepare form data - ensure all form fields are included 1693 let formData = new FormData(); 1694 1695 // Add all form inputs, selects, and textareas 1696 quizData.form.find('input, select, textarea').each(function() { 1697 let $field = $(this); 1698 let name = $field.attr('name'); 1699 let type = $field.attr('type'); 1700 1701 if (!name) return; // Skip fields without names 1702 1703 if (type === 'radio' || type === 'checkbox') { 1704 if ($field.is(':checked')) { 1705 formData.append(name, $field.val()); 1706 } 1707 } else if (type === 'file') { 1708 let files = this.files; 1709 if (files && files.length > 0) { 1710 formData.append(name, files[0]); 1711 } 1712 } else if (type !== 'submit' && type !== 'button') { 1713 formData.append(name, $field.val() || ''); 1714 } 1715 }); 1716 1717 // Add quiz ID 1718 formData.append('quiz_id', quizId); 1719 1720 // Add quiz start date 1721 formData.append('quiz_start_date', localStorage.getItem('mlw_quiz_start_date' + quizId)); 1722 1723 // Add timer data if available 1724 let timerInfo = QSMPagination && QSMPagination.Timer ? 1725 QSMPagination.Timer.getTimerInfo(quizId) : null; 1726 if (timerInfo) { 1727 formData.append('timer_ms', (timerInfo.totalTime - timerInfo.remainingTime) * 1000); 1728 } 1729 1730 // Add required fields for QSM processing (matching legacy exactly) 1731 formData.append('action', 'qmn_process_quiz'); 1732 formData.append('nonce', quizData.form.find('#qsm_nonce_' + quizId).val() || quizData.nonceValue || ''); 1733 formData.append('qsm_unique_key', quizData.form.find('#qsm_unique_key_' + quizId).val() || ''); 1734 formData.append('currentuserTime', Math.round(new Date().getTime() / 1000)); 1735 formData.append('currentuserTimeZone', Intl.DateTimeFormat().resolvedOptions().timeZone); 1736 1737 // Add timer field if it exists 1738 let timerField = quizData.form.find('#timer').length ? 1739 quizData.form.find('#timer') : 1740 quizData.quizContainer.find('#timer'); 1741 if (timerField.length) { 1742 formData.append('timer', timerField.val() || '0'); 1743 } 1744 1745 // Trigger after form data process event (like legacy) 1746 $(document).trigger('qsm_after_form_data_process', ['qsmForm' + quizId, formData]); 1747 1748 // End timer (matching legacy behavior) 1749 if (QSMPagination && QSMPagination.Timer) { 1750 QSMPagination.Timer.endTimer(quizId); 1751 } 1752 1753 // Show loading state 1754 this.showLoading(quizId); 1755 1756 // Hide all micromodal popups 1757 $('.qsm-popup').removeClass('is-open'); 1758 1759 // Get AJAX URL from quiz data or global 1760 let ajaxUrl = quizData.data.ajax_url || 1761 (typeof qmn_ajax_object !== 'undefined' ? qmn_ajax_object.ajaxurl : '') || 1762 '/wp-admin/admin-ajax.php'; 1763 1764 // Submit via AJAX 1765 $.ajax({ 1766 url: ajaxUrl, 1767 data: formData, 1768 contentType: false, 1769 processData: false, 1770 type: 'POST', 1771 success: function(response) { 1772 try { 1773 if (typeof response === 'string') { 1774 response = JSON.parse(response); 1775 } 1776 if (window.qsm_results_data === undefined) { 1777 window.qsm_results_data = new Object(); 1778 } 1779 window.qsm_results_data[quizId] = {
1780 'save_response': response.result_status['save_response'], 1781 'id': response.result_status['id'] 1782 }; 1783 if (response.quizExpired) { 1784 if (typeof MicroModal !== 'undefined') { 1785 MicroModal.show('modal-4'); 1786 } 1787 return; 1788 } 1789 1790 // Check if response has expected structure 1791 if (!response || typeof response !== 'object') { 1792 throw new Error('Invalid response format received from server'); 1793 } 1794 1795 // Display results 1796 self.displayResults(response, quizId); 1797 1798 // Trigger after submit event 1799 $(document).trigger('qsm_after_quiz_submit', ['quizForm' + quizId]); 1800 1801 } catch (e) { 1802 console.log(e); 1803 self.displayError('Error processing quiz results: ' + e.message + '. Please check console for details.', quizId); 1804 } 1805 }, 1806 error: function(xhr, status, error) { 1807 // Re-enable submit button on error 1808 quizData.quizContainer.find('input[type="submit"], .qsm-submit').prop('disabled', false); 1809 1810 // Show error message 1811 let errorMessage = 'Error submitting quiz: ' + error + ' (Status: ' + xhr.status + ')'; 1812 if (xhr.responseText) { 1813 errorMessage += ' - Response: ' + xhr.responseText.substring(0, 200); 1814 } 1815 self.displayError(errorMessage, quizId); 1816 } 1817 }); 1818 }, 1819 1820 /** 1821 * Display results 1822 */ 1823 displayResults: function(response, quizId) { 1824 let quizData = this.quizObjects[quizId]; 1825 if (!quizData) return; 1826 1827 let $quizContainer = quizData.quizContainer; 1828 jQuery(document).trigger('qsm_before_display_result', [response, 'quizForm' + quizId, $quizContainer]); 1829 if (response.redirect && response.redirect != '') { 1830 window.location.replace(response.redirect); 1831 } else { 1832 // Clear existing content 1833 $quizContainer.find('.qsm_results_page').remove(); 1834 1835 // Create results container 1836 let $resultDiv = $('<div class="qsm_results_page qmn_results_page">'); 1837 1838 $quizContainer.find('.qsm-quiz-processing-box').remove(); 1839 $resultDiv.html(response.display); 1840 $quizContainer.append($resultDiv); 1841 $resultDiv.slideDown(); 1842 1843 // Clean up localStorage 1844 if( localStorage.getItem('mlw_quiz_start_date' + quizId) ) { 1845 localStorage.removeItem('mlw_quiz_start_date' + quizId); 1846 } 1847 1848 // Scroll to results 1849 $('html, body').animate({ 1850 scrollTop: $resultDiv.offset().top - 50 1851 }, 500); 1852 1853 // Trigger after result is displayed - matches legacy qsm-quiz.js format 1854 jQuery(document).trigger('qsm_after_display_result', [response, 'quizForm' + quizId, $quizContainer]); 1855 } 1856 }, 1857 1858 /** 1859 * Show loading state 1860 */ 1861 showLoading: function(quizId) { 1862 let quizData = this.quizObjects[quizId]; 1863 if (!quizData) return; 1864 1865 let loadingHtml = '<div class="qsm-quiz-processing-box">'; 1866 loadingHtml += '<div class="qsm-spinner-loader"></div>'; 1867 loadingHtml += '<div class="qsm-processing-message">Processing your quiz...</div>'; 1868 loadingHtml += '</div>'; 1869 1870 quizData.quizContainer.html(loadingHtml); 1871 }, 1872 1873 /** 1874 * Handle window resize 1875 */ 1876 handleResize: function() {
1877 Object.keys(this.quizObjects).forEach(function(quizId) { 1878 $(document).trigger('qsm_navigation_resize', [quizId, this.quizObjects[quizId]]); 1879 }.bind(this)); 1880 }, 1881 1882 1883 /** 1884 * Get quiz ID from container or form element 1885 */ 1886 getQuizId: function($element) { 1887 // Try data attribute first (for containers) 1888 let id = $element.data('quiz-id'); 1889 if (id) { 1890 return id; 1891 } 1892 1893 // Try form ID (if element is a form) 1894 id = $element.attr('id'); 1895 if (id && (id.indexOf('qsmForm') === 0 || id.indexOf('qsmNewForm') === 0)) { 1896 return id.replace(/qsm(New)?Form/, ''); 1897 } 1898 1899 // Try finding form within container 1900 let $form = $element.find('.qsm-quiz-form'); 1901 if ($form.length) { 1902 id = $form.attr('id'); 1903 if (id && (id.indexOf('qsmForm') === 0 || id.indexOf('qsmNewForm') === 0)) { 1904 return id.replace(/qsm(New)?Form/, ''); 1905 } 1906 } 1907 1908 // Try parent container data attribute 1909 let $container = $element.closest('[data-quiz-id]'); 1910 if ($container.length) { 1911 return $container.data('quiz-id'); 1912 } 1913 1914 return null; 1915 }, 1916 1917 /** 1918 * Show inline result feedback (matching legacy qsm_show_inline_result) 1919 */ 1920 qsmShowInlineResult: function(quizId, question_id, value, $this, answer_type, $i_this, index = null) { 1921 if ($i_this?.hasClass?.('mlw_answer_date')) { 1922 if (!/^[1-9]\d{3}-\d{2}-\d{2}$/.test(value)) { 1923 return; 1924 } 1925 } 1926 $('.qsm-spinner-loader').remove(); 1927 this.addSpinnerLoader($this, $i_this); 1928 1929 let quizData = this.getQuizData(quizId); 1930 let data = this.qsmQuestionQuickResultJs(question_id, value, answer_type, quizData.enable_quick_correct_answer_info, quizId, index); 1931 1932 $this.find('.quick-question-res-p, .qsm-inline-correct-info').remove(); 1933 $this.find('.qmn_radio_answers, .qsm-answer-options').children().removeClass('data-correct-answer'); 1934 1935 if (value && value.length > 0 && data.success == 'correct') { 1936 $this.append('<div style="color: green" class="quick-question-res-p qsm-correct-answer-info">' + quizData.quick_result_correct_answer_text + '</div>'); 1937 $this.append('<div class="qsm-inline-correct-info">' + this.qsmCheckShortcode(data.message) + '</div>'); 1938 } else if (value && value.length > 0 && data.success == 'incorrect') { 1939 $this.find('.qmn_radio_answers input[value="' + data.correct_index + '"], .qsm-answer-options input[value="' + data.correct_index + '"]').parent().addClass('data-correct-answer'); 1940 $this.append('<div style="color: red" class="quick-question-res-p qsm-incorrect-answer-info">' + quizData.quick_result_wrong_answer_text + '</div>'); 1941 $this.append('<div class="qsm-inline-correct-info">' + this.qsmCheckShortcode(data.message) + '</div>'); 1942 } 1943 1944 // Render MathJax if enabled 1945 if (Number(quizData.disable_mathjax) !== 1 && typeof MathJax !== 'undefined' && MathJax.typesetPromise) { 1946 MathJax.typesetPromise(); 1947 } 1948 1949 $('.qsm-spinner-loader').remove(); 1950 $(document).trigger('qsm_show_inline_result_after', [quizId, question_id, value, $this, answer_type, $i_this, index]); 1951 }, 1952 1953 /** 1954 * Add spinner loader (matching legacy addSpinnerLoader) 1955 */ 1956 addSpinnerLoader: function($this, $i_this) { 1957 if ($this.find('.mlw_answer_open_text').length) { 1958 $this.find('.mlw_answer_open_text').after('<div class="qsm-spinner-loader" style="font-size: 2.5px;margin-left:10px;"></div>'); 1959 } else if ($this.find('.mlw_answer_number').length) { 1960 $this.find('.mlw_answer_number').after('<div class="qsm-spinner-loader" style="font-size: 2.5px;margin-left:10px;"></div>'); 1961 } else {
1962 $i_this.next('.qsm-input-label').after('<div class="qsm-spinner-loader" style="font-size: 2.5px;"></div>'); 1963 } 1964 }, 1965 1966 /** 1967 * Check answer correctness using encrypted data (matching legacy qsm_question_quick_result_js) 1968 */ 1969 /** 1970 * Format date answer to YYYY-MM-DD. 1971 */ 1972 qsmFormatDateAnswer: function(v) { 1973 if (typeof v === 'string') { 1974 let ymd = /^([1-9]\d{3})[-/](\d{2})[-/](\d{2})$/.exec(v); 1975 if (ymd) { 1976 return ymd[1] + '-' + ymd[2] + '-' + ymd[3]; 1977 } 1978 let dmy = /^(\d{2})[-/](\d{2})[-/]([1-9]\d{3})$/.exec(v); 1979 if (dmy) { 1980 return dmy[3] + '-' + dmy[2] + '-' + dmy[1]; 1981 } 1982 } 1983 return v; 1984 }, 1985 1986 qsmQuestionQuickResultJs: function(question_id, answer, answer_type, show_correct_info, quiz_id, ans_index = null) { 1987 answer_type = answer_type || ''; 1988 show_correct_info = show_correct_info || ''; 1989 1990 if (typeof encryptedData === 'undefined' || typeof encryptedData[quiz_id] === 'undefined') { 1991 return { correct_index: 0, success: '', message: '' }; 1992 } 1993 1994 try { 1995 let decryptedBytes = CryptoJS.AES.decrypt(encryptedData[quiz_id], encryptionKey[quiz_id]); 1996 let decryptedData = decryptedBytes.toString(CryptoJS.enc.Utf8); 1997 let decrypt = JSON.parse(decryptedData); 1998 1999 question_id = typeof question_id !== 'undefined' ? Number.parseInt(question_id) : 0; 2000 answer = typeof answer !== 'undefined' ? answer : ''; 2001 2002 let answer_array = decrypt[question_id].answer_array; 2003 let settings = decrypt[question_id].settings; 2004 let correct_info_text = decrypt[question_id].correct_info_text; 2005 let correct_answer_logic = decrypt.correct_answer_logic; 2006 show_correct_info = typeof show_correct_info !== 'undefined' && show_correct_info != 0 ? show_correct_info : ''; 2007 2008 let got_ans = false; 2009 let correct_answer = false; 2010 let count = 0; 2011 let correct_index = 0; 2012 let answer_count = 0; 2013 let total_correct_answer = 0; 2014 2015 if (answer_array && false === got_ans) { 2016 for (let key in answer_array) { 2017 let value = answer_array[key]; 2018 2019 if ('input' === answer_type) { 2020 if (!settings['case_sensitive']) { 2021 answer = answer.toUpperCase(); 2022 value[0] = value[0].toUpperCase(); 2023 } 2024 2025 let isDateQuestion = 12 === Number.parseInt(decrypt[question_id].question_type_new); 2026 let correctValue = isDateQuestion ? this.qsmFormatDateAnswer(value[0]) : value[0]; 2027 2028 if (answer == correctValue && (1 === Number.parseInt(value[2]) || 14 === Number.parseInt(decrypt[question_id].question_type_new)) && (!settings['matchAnswer'] || 'random' === settings['matchAnswer'] || key == ans_index)) { 2029 got_ans = true; 2030 correct_answer = true; 2031 break; 2032 } 2033 } else if ('checkbox' === answer_type) { 2034 if (0 == correct_answer_logic) { 2035 for (let anskey in answer) { 2036 let ansvalue = answer[anskey]; 2037 if (Number.parseInt(ansvalue) === Number.parseInt(key) && 1 == value[2]) { 2038 got_ans = true; 2039 correct_answer = true; 2040 break; 2041 } 2042 } 2043 } else { 2044 if (answer_array[answer[key]] !== undefined) { 2045 if (1 == answer_array[answer[key]][2]) { 2046 answer_count++; 2047 } else if (answer[key] !== undefined) { 2048 answer_count--; 2049 } 2050 } 2051 if (1 == value[2]) { 2052 total_correct_answer++; 2053 } 2054 } 2055 } else if (Number.parseInt(answer) === Number.parseInt(key) && 1 === Number.parseInt(value[2])) { 2056 got_ans = true; 2057 correct_answer = true; 2058 break; 2059 } 2060 } 2061 2062 for (let key in answer_array) { 2063 let value = answer_array[key]; 2064 if (false == correct_answer) { 2065 if (1 == value[2]) { 2066 correct_index = count; 2067 } 2068 count++; 2069 } 2070 } 2071
2072 if ('checkbox' === answer_type) { 2073 if (1 == correct_answer_logic) { 2074 if (0 != answer_count && 0 != total_correct_answer && total_correct_answer == answer_count) { 2075 got_ans = true; 2076 correct_answer = true; 2077 } 2078 } 2079 } 2080 } 2081 2082 if (2 == show_correct_info) { 2083 got_ans = true; 2084 } 2085 2086 let returnObject = { 2087 "correct_index": correct_index, 2088 "success": correct_answer ? 'correct' : 'incorrect', 2089 "message": show_correct_info && got_ans ? correct_info_text : "" 2090 }; 2091 2092 $(document).trigger('qsm_question_quick_result_js_after', [returnObject, correct_answer, answer, answer_array, answer_type, settings, decrypt, question_id]); 2093 return returnObject; 2094 } catch (e) { 2095 return { correct_index: 0, success: '', message: '' }; 2096 } 2097 }, 2098 2099 /** 2100 * Submit quiz if answer is wrong (matching legacy qsm_submit_quiz_if_answer_wrong) 2101 */ 2102 qsmSubmitQuizIfAnswerWrong: function(quizId, question_id, value, $this, $quizForm, answer_type = '') { 2103 answer_type = answer_type || ''; 2104 let quizData = this.getQuizData(quizId); 2105 2106 let data = this.qsmQuestionQuickResultJs(question_id, value, answer_type, quizData.enable_quick_correct_answer_info, quizId); 2107 2108 this.changes(data, question_id.replace(/\D/g, ""), quizId); 2109 2110 if (data.success == 'incorrect') { 2111 $quizForm.closest('.qsm-quiz-container').find('[class*="Required"]').removeClass(); 2112 $quizForm.closest('.qsm-quiz-container').find('.qsm-submit-btn').trigger('click'); 2113 } 2114 2115 // Render MathJax if enabled 2116 if (Number(quizData.disable_mathjax) !== 1 && typeof MathJax !== 'undefined' && MathJax.typesetPromise) { 2117 MathJax.typesetPromise(); 2118 } 2119 }, 2120 2121 /** 2122 * Initialize q_counter model (matching legacy q_counter) 2123 */ 2124 q_counter: Backbone.Model.extend({ 2125 defaults: { 2126 answers: [] 2127 } 2128 }), 2129 2130 /** 2131 * Changes function (matching legacy changes) 2132 */ 2133 changes: function(data, question_id, quiz_id) { 2134 let quizData = this.getQuizData(quiz_id); 2135 let answers = qsmLogicModel.get('answers'); 2136 2137 answers.push({ 2138 'q_id': question_id, 2139 'incorrect': data.success == 'correct' ? 0 : 1, 2140 }); 2141 2142 qsmLogicModel.set({ 'answers': this.filter_question(qsmLogicModel.get('answers')) }); 2143 2144 let update_answers = qsmLogicModel.get('answers'); 2145 let incorrect = 0; 2146 2147 update_answers.forEach(function(obj){ 2148 if(obj.incorrect == 1){ 2149 incorrect++; 2150 } 2151 }); 2152 2153 if( Number(quizData.end_quiz_if_wrong) <= incorrect ) { 2154 this.submit_status = true; 2155 }else{ 2156 this.submit_status = false; 2157 } 2158 }, 2159 2160 filter_question: function(arr) { 2161 let result = {}; 2162 arr.forEach(function(obj) { 2163 if (obj.q_id) { 2164 result[obj.q_id] = obj; 2165 } 2166 }); 2167 return Object.values(result); 2168 }, 2169 2170 /** 2171 * Process shortcodes in message (matching legacy qsm_check_shortcode) 2172 */ 2173 qsmCheckShortcode: function(message) { 2174 if (!message) return ''; 2175 2176 const videoContentRegex = /\[video(?:\s(?:src|mp4|m4v|webm|ogv|wmv|flv|width|height)="[^"]*")*\](.*?)\[\/video\]/g; 2177 let videoMatch = message.match(videoContentRegex); 2178 2179 if (videoMatch) { 2180 let videoHTML = message.replace(videoContentRegex, function(match, content) { 2181 const { src, width, height } = this.parseAttributes(match); 2182 const videoTag = this.generateVideoTag(src, width, height, content); 2183 return `<div class="video-content">${videoTag}</div>`; 2184 }); 2185 return videoHTML; 2186 } 2187 2188 // Check if message contains an image shortcode 2189 let imageRegex = /\[img(?:(?:\ssrc="([^"]+)")|(?:\salt="([^"]+)")|(?:\swidth="(\d+)")|(?:\sheight="(\d+)")){0,4}\s*\]/g; 2190 let imageMatch = message.match(imageRegex); 2191 2192 if (imageMatch) { 2193 let imageHTML = message.replace(imageRegex, function(match, src, alt, width, height) { 2194 return '<img src="' + (src || '') + '" alt="' + (alt || '') + '" width="' + (width || '') + '" height="' + (height || '') + '">'; 2195 }); 2196 return '<div class="image-content">' + imageHTML + '</div>'; 2197 } 2198 2199 return message; 2200 }, 2201 2202 /** 2203 * Parse attributes from video shortcode 2204 */ 2205 parseAttributes: function(match) { 2206 let videoAttrs = { src: '', width: '', height: '' }; 2207 2208 videoAttributePatterns.forEach(pattern => { 2209 const attrMatch = match.match(pattern); 2210 if (attrMatch) { 2211 const value = attrMatch[1] || ''; 2212 if (pattern.toString().includes('width')) { 2213 videoAttrs.width = value; 2214 } else if (pattern.toString().includes('height')) { 2215 videoAttrs.height = value; 2216 } else { 2217 videoAttrs.src = value; 2218 } 2219 } 2220 }); 2221 2222 return videoAttrs; 2223 }, 2224 2225 /** 2226 * Generate video tag from shortcode 2227 */ 2228 generateVideoTag: function(src, width, height, content) { 2229 return `<video src="${src}" width="${width}" height="${height}" controls>${content}</video>`; 2230 }, 2231 2232 /** 2233 * Get validation rules for quiz 2234 */ 2235 getValidationRules: function(quizId) { 2236 // This can be extended based on quiz configuration 2237 return { 2238 required: true, 2239 email: true, 2240 url: true, 2241 number: true 2242 }; 2243 }, 2244 2245 /** 2246 * Destroy quiz instance 2247 */ 2248 destroy: function(quizId) { 2249 2250 if (this.quizObjects[quizId]) { 2251 // Remove event listeners 2252 this.quizObjects[quizId].form.off(); 2253 2254 // Delete instance 2255 delete this.quizObjects[quizId]; 2256 2257 // Trigger destroy event 2258 $(document).trigger('qsm_navigation_destroyed', [quizId]); 2259 } 2260 } 2261 } 2262 }; 2263 2264 var qsmLogicModel = new QSMPagination.Navigation.q_counter({}); 2265 2266 /** 2267 * jQuery debounce function 2268 */ 2269 $.debounce = function(delay, fn) { 2270 let timer = null; 2271 return function() { 2272 let context = this, args = arguments; 2273 clearTimeout(timer); 2274 timer = setTimeout(function() {
2275 fn.apply(context, args); 2276 }, delay); 2277 }; 2278 }; 2279 2280 /** 2281 * Initialize on document ready 2282 */ 2283 $(document).ready(function() { 2284 QSMPagination.Navigation.init(); 2285 }); 2286 2287 /** 2288 * Re-initialize on AJAX content load 2289 */ 2290 $(document).on('qsm_content_loaded', function() { 2291 QSMPagination.Navigation.init(); 2292 }); 2293 2294})(jQuery);
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.