;/* global LLMS, $ */ /* jshint strict: true */ /** * Front End Quiz Class * * @type {Object} * @since 1.0.0 * @version 3.24.3 */( function( $ ) { var quiz = { /** * Selector of all the available button elements * * @type obj */ $buttons: null, /** * Main Question Container Element * * @type obj */ $container: null, /** * Main Quiz container UI element * * @type obj */ $ui: null, /** * Attempt key for the current quiz * * @type {[type]} */ attempt_key: null, /** * Question ID of the current question * * @type {Number} */ current_question: 0, /** * Total number of questions in the current quiz * * @type {Number} */ total_questions: 0, /** * Object of quiz question HTML * * @type {Object} */ questions: {}, /** * Validator functions for question types * Third party custom question types can register validators for use when answering questions * * @type {Object} */ validators: {}, /** * Records current status of a quiz session * If a user attempts to navigate away from a quiz * while taking the quiz they'll be warned that their progress * will not be saved if this status is not null * * @type boolean */ status: null, /** * Bind DOM events * * @return void * @since 1.0.0 * @version 3.16.6 */ bind: function() { var self = this; // start quiz $( '#llms_start_quiz' ).on( 'click', function( e ) { e.preventDefault(); self.start_quiz(); } ); // draw quiz grade circular chart $( '.llms-donut' ).each( function() { LLMS.Donut( $( this ) ); } ); // redirect to attempt on attempt selection change $( '#llms-quiz-attempt-select' ).on( 'change', function() { var val = $( this ).val(); if ( val ) { window.location.href = val; } } ); // warn when quiz is running and user tries to leave the page $( window ).on( 'beforeunload', function() { if ( self.status ) { return LLMS.l10n.translate( 'Are you sure you wish to quit this quiz attempt?' ); } } ); // complete the quiz attempt when user leaves if the quiz is running $( window ).on( 'unload', function() { if ( self.status ) { self.complete_quiz(); } } ); $( document ).on( 'llms-post-append-question', self.post_append_question ); // register validators this.register_validator( 'content', this.validate ); this.register_validator( 'choice', this.validate_choice ); this.register_validator( 'picture_choice', this.validate_choice ); this.register_validator( 'true_false', this.validate_choice ); }, /** * Add an error message to the UI * * @param string msg error message string * @return void * @since 3.16.0 * @version 3.16.0 */ add_error: function( msg ) { var self = this; self.$container.find( '.llms-error' ).remove(); var $err = $( '<p class="llms-error">' + msg + '<a href="#"><i class="fa fa-times-circle" aria-hidden="true"></i></a></p>' ); $err.on( 'click', 'a', function( e ) { e.preventDefault(); $err.fadeOut( '200' ); setTimeout( function() { $err.remove(); }, 210 ); } ); self.$container.append( $err ); }, /** * Answer a Question * * @param obj $btn jQuery object for the "Next Lesson" button * @return void * @since 1.0.0 * @version 3.16.6 */ answer_question: function( $btn ) { var self = this, $question = this.$container.find( '.llms-question-wrapper' ), type = $question.attr( 'data-type' ), valid; if ( ! this.validators[ type ] ) { console.log( 'No validator registered for question type ' + type ); return; } valid = this.validators[ type ]( $question ); if ( ! valid || true !== valid.valid || ! valid.answer ) { return self.add_error( valid.valid ); } LLMS.Ajax.call( { data: { action: 'quiz_answer_question', answer: valid.answer, attempt_key: self.attempt_key, question_id: $question.attr( 'data-id' ), question_type: $question.attr( 'data-type' ), }, beforeSend: function() { var msg = $btn.hasClass( 'llms-button-quiz-complete' ) ? LLMS.l10n.translate( 'Grading Quiz...' ) : LLMS.l10n.translate( 'Loading Question...' ); self.toggle_loader( 'show', msg ); self.update_progress_bar( 'increment' ); }, success: function( r ) { self.toggle_loader( 'hide' ); if ( r.data && r.data.html ) { // load html from the cached questions if it exists already if ( r.data.question_id && self.questions[ 'q-' + r.data.question_id ] ) { self.load_question( self.questions[ 'q-' + r.data.question_id ] ); // load html from server if the question's never been seen before } else { self.load_question( r.data.html ); } } else if ( r.data && r.data.redirect ) { self.redirect( r.data.redirect ); } else if ( r.message ) { self.$container.append( '<p>' + r.message + '</p>' ); } else { var msg = LLMS.l10n.translate( 'An unknown error occurred. Please try again.' ); self.$container.append( '<p>' + msg + '</p>' ); } } } ); }, /** * Complete the quiz * Called when timed quizzes reach time limit * & during unload events to record the attempt as abandoned * * @return void * @since 1.0.0 * @version 3.9.0 */ complete_quiz: function() { var self = this; LLMS.Ajax.call( { data: { action: 'quiz_end', attempt_key: self.attempt_key, }, beforeSend: function() { self.toggle_loader( 'show', 'Grading Quiz...' ); }, success: function( r ) { self.toggle_loader( 'hide' ); if ( r.data && r.data.redirect ) { self.redirect( r.data.redirect ); } else if ( r.message ) { self.$container.append( '<p>' + r.message + '</p>' ); } else { var msg = LLMS.l10n.translate( 'An unknown error occurred. Please try again.' ); self.$container.append( '<p>' + msg + '</p>' ); } } } ); }, /** * Retrieve the index of a question by question id * * @param int qid WP Post ID of the question * @return int * @since 3.16.0 * @version 3.16.0 */ get_question_index: function( qid ) { return Object.keys( this.questions ).indexOf( 'q-' + qid ); }, /** * Redirect on quiz completion / timeout * * @param string url redirect url * @return void * @since 3.9.0 * @version 3.16.0 */ redirect: function( url ) { this.toggle_loader( 'show', 'Grading Quiz...' ); this.status = null; window.location.href = url; }, /** * Return to the previous question * * @return void * @since 1.0.0 * @version 3.16.6 */ previous_question: function() { var self = this; self.toggle_loader( 'show', LLMS.l10n.translate( 'Loading Question...' ) ); self.update_progress_bar( 'decrement' ); var ids = Object.keys( self.questions ), curr = ids.indexOf( 'q-' + self.current_question ), prev_id = ids[0]; if ( curr >= 1 ) { prev_id = ids[ curr - 1 ]; } setTimeout( function() { self.toggle_loader( 'hide' ); self.load_question( self.questions[ prev_id ] ); }, 100 ); }, /** * Register question type validator functions * * @param string type question type id * @param function func callback function to validate the question with * @return void * @since 3.16.0 * @version 3.16.0 */ register_validator: function( type, func ) { this.validators[ type ] = func; }, /** * Start a Quiz via AJAX call * * @return void * @since 1.0.0 * @version 3.24.3 */ start_quiz: function () { var self = this; this.load_ui_elements(); this.$ui = $( '#llms-quiz-ui' ); this.$buttons = $( '#llms-quiz-nav button' ); this.$container = $( '#llms-quiz-question-wrapper' ); // bind submission event for answering questions $( '#llms-next-question, #llms-complete-quiz' ).on( 'click', function( e ) { e.preventDefault(); self.answer_question( $( this ) ); } ); // bind submission event for navigating backwards $( '#llms-prev-question' ).on( 'click', function( e ) { e.preventDefault(); self.previous_question(); } ); LLMS.Ajax.call( { data: { action: 'quiz_start', attempt_key: $( '#llms-attempt-key' ).val(), lesson_id : $( '#llms-lesson-id' ).val(), quiz_id : $( '#llms-quiz-id' ).val(), }, beforeSend: function() { self.status = true; $( '#llms-quiz-wrapper, #quiz-start-button' ).remove(); $( 'html, body' ).stop().animate( {scrollTop: 0 }, 500 ); self.toggle_loader( 'show', LLMS.l10n.translate( 'Loading Quiz...' ) ); }, error: function( r, s, t ) { console.log( r, s, t ); }, success: function( r ) { self.toggle_loader( 'hide' ); if ( r.data && r.data.html ) { // start the quiz timer when a time limit is set if ( r.data.time_limit ) { self.start_quiz_timer( r.data.time_limit ); } self.attempt_key = r.data.attempt_key; self.total_questions = r.data.total; self.load_question( r.data.html ); } else if ( r.message ) { self.$container.append( '<p>' + r.message + '</p>' ); } else { var msg = LLMS.l10n.translate( 'An unknown error occurred. Please try again.' ); self.$container.append( '<p>' + msg + '</p>' ); } } } ); /** * Use JS mouse events instead of CSS :hover because iOS is really smart * * @see: https://css-tricks.com/annoying-mobile-double-tap-link-issue/ */ if ( ! LLMS.is_touch_device() ) { this.$ui.on( 'mouseenter', 'li.llms-choice label', function() { $( this ).addClass( 'hovered' ); } ); this.$ui.on( 'mouseleave', 'li.llms-choice label', function() { $( this ).removeClass( 'hovered' ); } ); } }, /** * Start Quiz Timer * Gets minutes from hidden field * Not used as actual quiz timer. Quiz is timed on the server from the quiz class * Calculates minutes to milliseconds and then converts to hours / minutes * When time limit reaches 0 calls complete_quiz() to complete quiz. * * @return Calls get_count_down at a set interval of 1 second * @since 1.0.0 * @version 3.16.0 */ start_quiz_timer: function( total_minutes ) { // create and append the UI for the countdown clock var $el = $( '<div class="llms-quiz-timer" id="llms-quiz-timer" />' ), msg = LLMS.l10n.translate( 'Time Remaining' ); $el.append( '<i class="fa fa-clock-o" aria-hidden="true"></i><span class="screen-reader-text">' + msg + '</span>' ); $el.append( '<div id="llms-tiles" class="llms-tiles"></div>' ); $( '#llms-quiz-header' ).append( $el ); // start the timer var self = this, target_date = new Date().getTime() + ( ( total_minutes * 60 ) * 1000 ), // set the countdown date time_limit = ( ( total_minutes * 60 ) * 1000 ), countdown = document.getElementById( 'llms-tiles' ), // get tag element days, hours, minutes, seconds; // variables for time units // set actual timer setTimeout( function() { self.complete_quiz(); }, time_limit + 1000 ); this.getCountdown( total_minutes, target_date, time_limit, days, hours, minutes, seconds, countdown ); // call get_count_down every 1 second setInterval( function () { self.getCountdown( total_minutes, target_date, time_limit, days, hours, minutes, seconds, countdown ); }, 1000 ); }, /** * Trigger events * * @param string event event to trigger * @return void * @since 3.16.0 * @version 3.16.0 */ trigger: function( event ) { var self = this; // trigger question submission for the current question if ( 'answer_question' === event ) { if ( this.get_question_index( self.current_question ) === self.total_questions ) { $( '#llms-complete-quiz' ).trigger( 'click' ); } else { $( '#llms-next-question' ).trigger( 'click' ); } } }, /** * Load the HTML of a question into the DOM and the question cache * * @param string html string of html * @return void * @since 3.9.0 * @version 3.16.6 */ load_question: function( html ) { var $html = $( html ), qid = $html.attr( 'data-id' ); // cache the question HTML for faster rewinds if ( ! this.questions[ 'q-' + qid ] ) { this.questions[ 'q-' + qid ] = $html; } this.update_progress( qid ); this.current_question = qid; $( document ).trigger( 'llms-pre-append-question', $html ); this.$container.append( $html ); $( document ).trigger( 'llms-post-append-question', $html ); }, /** * Constructs the quiz UI & adds the elements into the DOM * * @return void * @since 3.16.0 * @version 3.16.9 */ load_ui_elements: function() { var $html = $( '<div class="llms-quiz-ui" id="llms-quiz-ui" />' ), $header = $( '<header class="llms-quiz-header" id="llms-quiz-header" />' ) $footer = $( '<footer class="llms-quiz-nav" id="llms-quiz-nav" />' ); $footer.append( '<button class="button large llms-button-action" id="llms-next-question" name="llms_next_question" type="submit">' + LLMS.l10n.translate( 'Next Question' ) + '</button>' ); $footer.append( '<button class="button large llms-button-action llms-button-quiz-complete" id="llms-complete-quiz" name="llms_complete_quiz" type="submit" style="display:none;">' + LLMS.l10n.translate( 'Complete Quiz' ) + '</button>' ); $footer.append( '<button class="button llms-button-secondary" id="llms-prev-question" name="llms_prev_question" type="submit" style="display:none;">' + LLMS.l10n.translate( 'Previous Question' ) + '</button>' ); $header.append( '<div class="llms-progress"><div class="progress-bar-complete"></div></div>' ); $footer.append( '<div class="llms-quiz-counter" id="llms-quiz-counter"><span class="llms-current"></span><span class="llms-sep">/</span><span class="llms-total"></span></div>' ) $html.append( $header ) .append( '<div class="llms-quiz-question-wrapper" id="llms-quiz-question-wrapper" />' ) .append( $footer ); $( '#llms-quiz-wrapper' ).after( $html ); }, /** * Perform actions on question HTML after it's been appended to the DOM * * @param obj event js event object * @param obj html js HTML object * @return void * @since 3.16.6 * @version 3.16.6 */ post_append_question: function( event, html ) { var $html = $( html ); if ( $html.find( 'audio' ).length ) { wp.mediaelement.initialize(); } }, /** * Show or hide the "loading" spinner with an option message * * @param string display show|hide * @param string msg text to display when showing * @return void * @since 3.9.0 * @version 3.16.6 */ toggle_loader: function( display, msg ) { if ( 'show' === display ) { msg = msg || LLMS.l10n.translate( 'Loading...' ); this.$buttons.attr( 'disabled', 'disabled' ); this.$container.empty(); LLMS.Spinner.start( this.$container ); this.$container.append( '<div class="llms-quiz-loading">' + LLMS.l10n.translate( msg ) + '</div>' ); } else { LLMS.Spinner.stop( this.$container ); this.$buttons.removeAttr( 'disabled' ); this.$container.find( '.llms-quiz-loading' ).remove(); } }, /** * Update the progress bar and toggle button availability based on question the question being shown * * @param {[type]} qid [description] * @return {[type]} * @since 3.16.0 * @version 3.16.0 */ update_progress: function( qid ) { var index = this.get_question_index( qid ), progress; if ( -1 === index ) { return; } index++; $( '#llms-quiz-counter .llms-current' ).text( index ); if ( index === 1 ) { $( '#llms-quiz-counter .llms-total' ).text( this.total_questions ); $( '#llms-quiz-counter' ).show(); } // handle prev question if ( index >= 2 ) { $( '#llms-prev-question' ).show(); } else { $( '#llms-prev-question' ).hide(); } if ( index === this.total_questions ) { $( '#llms-next-question' ).hide(); $( '#llms-complete-quiz' ).show(); } else { $( '#llms-next-question' ).show(); $( '#llms-complete-quiz' ).hide(); } }, /** * Increase progress bar ui element * * @param string dir update direction [increment|decrement] * @return void * @since 3.16.0 * @version 3.16.0 */ update_progress_bar: function( dir ) { var index = this.get_question_index( this.current_question ); if ( 'increment' === dir ) { index++; } else { index--; } progress = ( index / this.total_questions ) * 100; this.$ui.find( '.progress-bar-complete' ).css( 'width', progress + '%' ); }, /** * Get Count Down * Called every second to update the on screen countdown timer * Changes color to yellow at 1/2 of total time * Changes color to red at 1/4 of total time * * @param {[int]} minutes [description] * @param {[date]} target_date [description] * @param {[int]} time_limit [description] * @param {[int]} days [description] * @param {[int]} hours [description] * @param {[int]} minutes [description] * @param {[int]} seconds [description] * @param {[int]} countdown [description] * @return Displays updates hours, minutes on quiz timer * @since 1.0.0 * @version 1.0.0 */ getCountdown: function( total_minutes, target_date, time_limit, days, hours, minutes, seconds, countdown ){ // find the amount of "seconds" between now and target var current_date = new Date().getTime(), seconds_left = ( target_date - current_date ) / 1000; if ( seconds_left >= 0 ) { if ( ( seconds_left * 1000 ) < ( time_limit / 2 ) ) { $( '#llms-quiz-timer' ).addClass( 'color-half' ); } if ( ( seconds_left * 1000 ) < ( time_limit / 4 ) ) { $( '#llms-quiz-timer' ).removeClass( 'color-half' ); $( '#llms-quiz-timer' ).addClass( 'color-empty' ); } days = this.pad( parseInt( seconds_left / 86400 ) ); seconds_left = seconds_left % 86400; hours = this.pad( parseInt( seconds_left / 3600 ) ); seconds_left = seconds_left % 3600; minutes = this.pad( parseInt( seconds_left / 60 ) ); seconds = this.pad( parseInt( seconds_left % 60 ) ); // format countdown string + set tag value countdown.innerHTML = '<span class="hours">' + hours + '</span>:<span class="minutes">' + minutes + '</span>:<span class="seconds">' + seconds + '</span>'; } }, /** * Pad Number * pads number with 0 if single digit. * * @param {[int]} n [number] * @return {[string]} [padded number] * @since 1.0.0 * @version 1.0.0 */ pad: function(n) { return (n < 10 ? '0' : '') + n; }, /** * Basic validation method which performs no validation and returns a validation object * in the format required by the application * * @param obj $question jQuery selector of the question * @return obj * @since 3.16.0 * @version 3.16.0 */ validate: function( $question ) { return { answer: [], valid: true, }; }, /** * Validates a choice question to ensure there's at least one checked input * * @param obj $question jQuery selector of the question * @return obj * @since 3.16.0 * @version 3.16.0 */ validate_choice: function( $question ) { var ret = window.llms.quizzes.validate( $question ), checked = $question.find( 'input:checked' ); if ( ! checked.length ) { ret.valid = LLMS.l10n.translate( 'You must select an answer to continue.' ); } else { checked.each( function() { ret.answer.push( $( this ).val() ); } ); } return ret; }, }; quiz.bind(); window.llms = window.llms || {}; window.llms.quizzes = quiz; } )( jQuery );