1jQuery(document).ready(function ($) { 2 // Reparent the modal to a direct child of <body>, regardless of where the 3 // shortcode/block is placed in the page. Wherever it's dropped in page 4 // content, an ancestor wrapper (header/content wrapper, etc.) can trap 5 // it in a lower stacking context than the theme's own fixed header, or 6 // clip it via that ancestor's own overflow -- moving it out from under 7 // any such ancestor sidesteps both issues entirely. 8 if ($('.pf2-wizard-container').length) { 9 $('.pf2-wizard-container').appendTo('body'); 10 } 11 12 // Step definitions: DOM step number -> {taxonomy, answer key, multi-select cap}. 13 // Answer keys match PF2_Recommendation_Engine::get_recommendations() input shape directly. 14 const STEPS = { 15 1: { taxonomy: 'pf2_audience', key: 'audience', multi: false }, 16 2: { taxonomy: 'pf2_interest', key: 'interests', multi: true, cap: 3 }, // BR-004 17 3: { taxonomy: 'pf2_credential', key: 'credential', multi: false }, 18 4: { taxonomy: 'pf2_format', key: 'format', multi: false }, 19 5: { taxonomy: 'pf2_timeframe', key: 'timeframe', multi: false }, 20 }; 21 const TOTAL_STEPS = 5; 22 const RESULTS_STEP = 6; 23 24 let userAnswers = { audience: '', interests: [], credential: '', format: '', timeframe: '' }; 25 let selectedProgram = null; 26 let wizardTrigger = null; 27 28 const validators = { 29 fullname: { 30 required: true, 31 minLength: 2, 32 maxLength: 100, 33 pattern: /^[a-zA-Z\s'-]+$/, 34 message: { 35 required: 'Full name is required', 36 minLength: 'Full name must be at least 2 characters', 37 maxLength: 'Full name must not exceed 100 characters', 38 pattern: 'Please enter a valid name (letters, spaces, hyphens, and apostrophes only)', 39 }, 40 }, 41 email: { 42 required: true, 43 pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, 44 message: { 45 required: 'Email is required', 46 pattern: 'Please enter a valid email address', 47 }, 48 }, 49 gdpr: { 50 required: true, 51 message: { required: 'You must accept the privacy policy to continue' }, 52 }, 53 }; 54 55 function validateField(fieldName, value) { 56 const rules = validators[fieldName]; 57 if (!rules) return { valid: true }; 58 if (rules.required && !value) return { valid: false, message: rules.message.required }; 59 if (rules.minLength && value.length < rules.minLength) return { valid: false, message: rules.message.minLength }; 60 if (rules.maxLength && value.length > rules.maxLength) return { valid: false, message: rules.message.maxLength }; 61 if (rules.pattern && !rules.pattern.test(value)) return { valid: false, message: rules.message.pattern }; 62 return { valid: true }; 63 } 64 65 function showError(fieldId, message) { 66 const $field = $('#' + fieldId); 67 $field.addClass('error'); 68 $field.parent().find('.pf2-error-message').remove(); 69 $field.after(`<span class="pf2-error-message">${message}</span>`); 70 } 71 function clearError(fieldId) { 72 const $field = $('#' + fieldId); 73 $field.removeClass('error'); 74 $field.parent().find('.pf2-error-message').remove(); 75 } 76 function clearAllErrors() { 77 $('.pf2-survey-wizard-container .error').removeClass('error'); 78 $('.pf2-survey-wizard-container .pf2-error-message').remove(); 79 } 80 81 function showSuccessMessage(message) { 82 $('.pf2-form-message').remove(); 83 $('.pf2-details-form').prepend(` 84 <div class="pf2-form-message pf2-success-message"> 85 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> 86 <path d="M10 0C4.48 0 0 4.48 0 10C0 15.52 4.48 20 10 20C15.52 20 20 15.52 20 10C20 4.48 15.52 0 10 0ZM8 15L3 10L4.41 8.59L8 12.17L15.59 4.58L17 6L8 15Z" fill="#4CAF50"/> 87 </svg> 88 <span>${message}</span> 89 </div> 90 `); 91 $('.pf2-success-message')[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); 92 } 93 94 function showGeneralError(message) { 95 $('.pf2-form-message').remove(); 96 $('.pf2-details-form').prepend(` 97 <div class="pf2-form-message pf2-general-error-message"> 98 <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> 99 <path d="M10 0C4.48 0 0 4.48 0 10C0 15.52 4.48 20 10 20C15.52 20 20 15.52 20 10C20 4.48 15.52 0 10 0ZM11 15H9V13H11V15ZM11 11H9V5H11V11Z" fill="#f44336"/> 100 </svg> 101 <span>${message}</span> 102 </div> 103 `); 104 $('.pf2-general-error-message')[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); 105 } 106 107 $('#pf2-fullname').on('blur keyup', function () { 108 const v = validateField('fullname', $(this).val().trim()); 109 v.valid ? clearError('pf2-fullname') : showError('pf2-fullname', v.message); 110 }); 111 $('#pf2-email-result').on('blur keyup', function () { 112 const v = validateField('email', $(this).val().trim()); 113 v.valid ? clearError('pf2-email-result') : showError('pf2-email-result', v.message); 114 }); 115 $('#pf2-gdpr-consent').on('change', function () { 116 if ($(this).is(':checked')) clearError('pf2-gdpr-consent'); 117 }); 118 119 function validateForm() { 120 clearAllErrors(); 121 let isValid = true; 122 123 const fullname = $('#pf2-fullname').val().trim(); 124 const fullnameValidation = validateField('fullname', fullname); 125 if (!fullnameValidation.valid) { 126 showError('pf2-fullname', fullnameValidation.message); 127 isValid = false;
128 } 129 130 const email = $('#pf2-email-result').val().trim(); 131 const emailValidation = validateField('email', email); 132 if (!emailValidation.valid) { 133 showError('pf2-email-result', emailValidation.message); 134 isValid = false; 135 } 136 137 const gdprConsent = $('#pf2-gdpr-consent').is(':checked'); 138 const gdprValidation = validateField('gdpr', gdprConsent); 139 if (!gdprValidation.valid) { 140 showError('pf2-gdpr-consent', gdprValidation.message); 141 $('#pf2-gdpr-consent').parent().addClass('error'); 142 isValid = false; 143 } else { 144 $('#pf2-gdpr-consent').parent().removeClass('error'); 145 } 146 147 if (!selectedProgram) { 148 isValid = false; 149 $('#pf2-recommended-programs').prepend(` 150 <div class="pf2-error-message pf2-program-error-message"> 151 <svg width="16" height="16" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg"> 152 <path d="M10 0C4.48 0 0 4.48 0 10C0 15.52 4.48 20 10 20C15.52 20 20 15.52 20 10C20 4.48 15.52 0 10 0ZM11 15H9V13H11V15ZM11 11H9V5H11V11Z" fill="#f44336"/> 153 </svg> 154 Please select a program from the results below 155 </div> 156 `); 157 const step = $('.pf2-step.active'); 158 const results = $('#pf2-recommended-programs'); 159 if (step.length && results.length) { 160 step.animate({ scrollTop: results.offset().top - step.offset().top + step.scrollTop() }, 500); 161 } 162 } 163 164 for (let i = 1; i <= TOTAL_STEPS; i++) { 165 const def = STEPS[i]; 166 const value = userAnswers[def.key]; 167 const empty = def.multi ? value.length === 0 : !value; 168 if (empty) isValid = false; 169 } 170 171 if (!isValid) showGeneralError('Please correct the errors below to continue'); 172 return isValid; 173 } 174 175 function updateStepCounter(step) { 176 $('.pf2-step-counter').text(`STEP ${step} OF ${TOTAL_STEPS}`); 177 } 178 function updateProgressBar(step) { 179 $('.pf2-progress-step').removeClass('active'); 180 for (let i = 1; i <= step; i++) { 181 $(`.pf2-progress-step[data-step="${i}"]`).addClass('active'); 182 } 183 } 184 function checkSelectedOptions(step) { 185 return $(`#pf2-step${step} .pf2-option.selected`).length > 0; 186 } 187 function updateContinueButton(step) { 188 $(`#pf2-step${step} .pf2-continue-btn`).prop('disabled', !checkSelectedOptions(step)); 189 } 190 191 function resetWizard() { 192 userAnswers = { audience: '', interests: [], credential: '', format: '', timeframe: '' }; 193 selectedProgram = null; 194 195 $('.pf2-survey-wizard-container .pf2-option').removeClass('selected disabled'); 196 $('.pf2-survey-wizard-container .pf2-step').removeClass('active'); 197 $('#pf2-step1').addClass('active'); 198 199 updateStepCounter(1); 200 updateProgressBar(1); 201 for (let i = 1; i <= TOTAL_STEPS; i++) updateContinueButton(i); 202 203 $('#pf2-recommended-programs').html(''); 204 $('.pf2-results-actions').hide(); 205 206 $('#pf2-fullname').val(''); 207 $('#pf2-email-result').val(''); 208 $('#pf2-gdpr-consent').prop('checked', false); 209 210 clearAllErrors(); 211 $('.pf2-form-message').remove(); 212 $('.pf2-program-error-message').remove(); 213 } 214 215 // Step options are rendered server-side (Survey_Wizard_V2::render_taxonomy_options()) 216 // directly into the initial page HTML, so just sync the continue buttons' 217 // disabled state to the (empty) starting selection here. 218 if ($('.pf2-survey-wizard').length) { 219 Object.keys(STEPS).forEach((step) => updateContinueButton(step)); 220 } 221 222 // Option selection. 223 $(document).on('click', '.pf2-option', function () { 224 const step = $(this).closest('.pf2-step').attr('id').replace('pf2-step', ''); 225 const def = STEPS[step]; 226 if (!def) return; 227 const value = $(this).data('value'); 228 229 if (!def.multi) { 230 userAnswers[def.key] = value; 231 // .siblings() only reaches elements sharing this button's exact 232 // parent -- credential options are wrapped in 233 // .pf2-credential-option-wrapper for the hover tooltip, so a 234 // same-parent sibling lookup no longer sees the other option 235 // buttons (each one's parent is its own wrapper). Walk up to 236 // the shared options container and find() instead, which works 237 // regardless of nesting depth. 238 $(this) 239 .addClass('selected') 240 .closest('.pf2-options') 241 .find('.pf2-option') 242 .not(this) 243 .removeClass('selected'); 244 } else {
245 const current = userAnswers[def.key]; 246 if ($(this).hasClass('selected')) { 247 $(this).removeClass('selected'); 248 userAnswers[def.key] = current.filter((v) => v !== value); 249 $(`#pf2-step${step} .pf2-option`).removeClass('disabled'); 250 } else { 251 if (def.cap && current.length >= def.cap) { 252 return; // BR-004: cap reached, ignore further selections 253 } 254 $(this).addClass('selected'); 255 userAnswers[def.key] = current.concat([value]); 256 if (def.cap && userAnswers[def.key].length >= def.cap) { 257 $(`#pf2-step${step} .pf2-option`).not('.selected').addClass('disabled'); 258 } 259 } 260 } 261 updateContinueButton(step); 262 }); 263 264 // Program titles come from a public post type any Author/Editor can 265 // publish -- never trust them as pre-sanitized HTML. Escape before any 266 // template-literal insertion into the DOM (fixes a stored-XSS path 267 // found in the Phase 6 security review). 268 function escapeHtml(value) { 269 return String(value == null ? '' : value) 270 .replace(/&/g, '&') 271 .replace(/</g, '<') 272 .replace(/>/g, '>') 273 .replace(/"/g, '"') 274 .replace(/'/g, '''); 275 } 276 277 function renderResults(data) { 278 const programs = data.programs || []; 279 const categories = data.categories || []; 280 let html = ''; 281 282 if (programs.length > 0) { 283 programs.forEach((program) => { 284 const safeTitle = escapeHtml(program.title); 285 const safePermalink = escapeHtml(program.permalink || ''); 286 const canLink = !!program.permalink; 287 const titleMarkup = canLink 288 ? `<a href="${safePermalink}" class="pf2-program-title-link" target="_blank" rel="noopener noreferrer">${safeTitle}</a>` 289 : `<span class="pf2-program-title">${safeTitle}</span>`; 290 html += ` 291 <div class="pf2-program-card" 292 data-course-id="${escapeHtml(program.id)}" 293 data-course-title="${safeTitle}" 294 data-course-permalink="${safePermalink}"> 295 ${titleMarkup} 296 </div> 297 `; 298 }); 299 300 // One "browse all" card per distinct category represented above, 301 // styled and sized the same as a program card, in the same row -- 302 // not a link stuck inside a program's own card. 303 categories.forEach((category) => { 304 const safeName = escapeHtml(category.name || ''); 305 const safeUrl = escapeHtml(category.url || ''); 306 if (!safeName || !safeUrl) return; 307 html += ` 308 <div class="pf2-program-card pf2-category-card"> 309 <a href="${safeUrl}" class="pf2-program-title-link" target="_blank" rel="noopener noreferrer">${safeName}</a> 310 </div> 311 `; 312 }); 313 } else { 314 html = `<div class="pf2-program-card no-results"><p>No programs found matching your criteria.<br>Please adjust your selections or contact us for assistance.</p></div>`; 315 } 316 317 $('#pf2-recommended-programs').html(html); 318 319 if (programs.length > 0) { 320 $('.pf2-results-actions').show(); 321 $('.pf2-program-card').first().addClass('selected'); 322 selectedProgram = programs[0]; 323 } else { 324 $('.pf2-results-actions').hide(); 325 } 326 } 327 328 // Navigation: continue. 329 $(document).on('click', '.pf2-continue-btn:not(:disabled)', function () { 330 const step = parseInt($(this).data('step'), 10); 331 const nextStep = step + 1; 332 333 $(`#pf2-step${step}`).removeClass('active'); 334 $(`#pf2-step${nextStep}`).addClass('active'); 335 updateStepCounter(Math.min(nextStep, TOTAL_STEPS)); 336 updateProgressBar(nextStep); 337 $(`#pf2-step${nextStep}`).find('button, .pf2-option, a, [tabindex]').first().focus(); 338 339 if (nextStep === RESULTS_STEP) { 340 $('.pf2-program-error-message').remove(); 341 $('#pf2-recommended-programs').html('<div class="pf2-loading">Loading programs...</div>'); 342 $('.pf2-results-actions').hide(); 343 344 const resultsEl = document.getElementById('pf2-recommended-programs'); 345 if (resultsEl) resultsEl.scrollIntoView({ behavior: 'smooth', block: 'center' }); 346 347 $.ajax({ 348 url: survey_wizard_v2_ajax.ajax_url, 349 type: 'POST', 350 data: { 351 action: 'pf2_get_recommendations', 352 answers: userAnswers, 353 nonce: survey_wizard_v2_ajax.nonce, 354 }, 355 success: function (response) { 356 if (response.success) { 357 renderResults(response.data); 358 } else { 359 console.error('Error:', response); 360 $('#pf2-recommended-programs').html('<div class="pf2-program-card error no-results"><p>Error finding matching programs.</p></div>'); 361 $('.pf2-results-actions').hide(); 362 } 363 }, 364 error: function (xhr, status, error) { 365 console.error('AJAX Error:', status, error); 366 $('#pf2-recommended-programs').html('<div class="pf2-program-card error no-results"><p>Error finding matching programs.</p></div>'); 367 $('.pf2-results-actions').hide(); 368 }, 369 }); 370 } 371 }); 372 373 // Navigation: previous. 374 $(document).on('click', '.pf2-prev-btn', function () { 375 const step = parseInt($(this).data('step'), 10); 376 const prevStep = step - 1; 377 $(`#pf2-step${step}`).removeClass('active'); 378 $(`#pf2-step${prevStep}`).addClass('active'); 379 updateStepCounter(Math.min(prevStep, TOTAL_STEPS)); 380 updateProgressBar(prevStep); 381 if (step === RESULTS_STEP) { 382 $('.pf2-program-error-message').remove(); 383 } 384 }); 385 386 updateStepCounter(1); 387 updateProgressBar(1); 388 for (let i = 1; i <= TOTAL_STEPS; i++) updateContinueButton(i); 389 390 // Program card selection. The card's link now covers the whole box (not 391 // just the title text), and opens in a new tab (target="_blank") without 392 // preventDefault -- so clicking it still selects the card here too, 393 // exactly like clicking anywhere else on the card. 394 $(document).on('click', '.pf2-program-card', function (e) { 395 if ($(this).hasClass('no-results') || $(this).hasClass('error') || $(this).hasClass('pf2-category-card')) return; 396 397 $('.pf2-program-error-message').remove(); 398 $('.pf2-program-card').removeClass('selected'); 399 $(this).addClass('selected'); 400 401 selectedProgram = { 402 id: $(this).data('course-id'), 403 title: $(this).data('course-title'), 404 permalink: $(this).data('course-permalink') || null, 405 }; 406 }); 407 408 // Email results. 409 $(document).on('click', '.pf2-email-results-btn', function () { 410 if (!selectedProgram) { 411 $('.pf2-program-error-message').remove(); 412 $('#pf2-recommended-programs').prepend(` 413 <div class="pf2-error-message pf2-program-error-message">Please select a program first</div> 414 `); 415 $('.pf2-program-error-message')[0].scrollIntoView({ behavior: 'smooth', block: 'center' }); 416 return; 417 } 418 $('#pf2-step-wizard-form').addClass('active'); 419 $('.pf2-survey-wizard').addClass('hide'); 420 }); 421 422 // Submit lead-capture form. 423 $(document).on('click', '.pf2-btn-submit-result', function (e) { 424 e.preventDefault(); 425 $('.pf2-form-message').remove(); 426 if (!validateForm()) return; 427 428 $('#pf2-fav-spinner').show(); 429 $(this).prop('disabled', true); 430 431 const formData = { 432 action: 'pf2_submit_results', 433 nonce: survey_wizard_v2_ajax.nonce, 434 fullname: $('#pf2-fullname').val().trim(), 435 email: $('#pf2-email-result').val().trim(), 436 gdpr_consent: $('#pf2-gdpr-consent').is(':checked') ? 'true' : 'false', 437 answers: userAnswers, 438 selected_program: selectedProgram.title, 439 selected_program_id: selectedProgram.id, 440 }; 441 442 $.ajax({ 443 url: survey_wizard_v2_ajax.ajax_url, 444 type: 'POST', 445 data: formData, 446 success: function (response) { 447 $('#pf2-fav-spinner').hide(); 448 $('.pf2-btn-submit-result').prop('disabled', false); 449 if (response.success) {
450 showSuccessMessage('Thank you! Your results have been saved and emailed to you. Check your inbox for your personalized pathway recommendations.'); 451 setTimeout(function () { 452 $('.pf2-wizard-container').removeClass('show'); 453 $('#pf2-step-wizard-form').removeClass('active'); 454 $('.pf2-survey-wizard').removeClass('hide'); 455 $('body').removeClass('pf2-wizard-open'); 456 resetWizard(); 457 if (wizardTrigger) { 458 wizardTrigger.focus(); 459 wizardTrigger = null; 460 } 461 }, 3000); 462 } else { 463 let errorMessage = 'An error occurred. Please try again.'; 464 if (response.data && response.data.errors && response.data.errors.length > 0) { 465 errorMessage = response.data.errors.join('<br>'); 466 } else if (response.data && response.data.message) { 467 errorMessage = response.data.message; 468 } 469 showGeneralError(errorMessage); 470 } 471 }, 472 error: function () { 473 $('#pf2-fav-spinner').hide(); 474 $('.pf2-btn-submit-result').prop('disabled', false); 475 showGeneralError('A network error occurred. Please check your connection and try again.'); 476 }, 477 }); 478 }); 479 480 // Open -- both triggers previously owned by V1, now that it's removed: 481 // the sitewide .find-your-path nav item, and any banner button whose 482 // text is "EXPLORE OPTIONS" (block-editor content, not hardcoded). 483 function openPf2Wizard(trigger) { 484 $('.pf2-wizard-container').addClass('show'); 485 $('body').addClass('pf2-wizard-open'); 486 wizardTrigger = trigger; 487 const wiz = document.querySelector('.pf2-wizard-container'); 488 wiz.setAttribute('tabindex', '-1'); 489 wiz.focus({ preventScroll: true }); 490 } 491 492 $(document).on('click', '.find-your-path a, .find-your-path img', function (e) { 493 e.preventDefault(); 494 openPf2Wizard(this); 495 }); 496 497 $(document).on('click', '.banner-content .buttons-wrapper .wp-block-button a', function (e) { 498 if ($(this).text().trim() === 'EXPLORE OPTIONS') { 499 e.preventDefault(); 500 openPf2Wizard(this); 501 } 502 }); 503 504 $(document).on('click', '.pf2-close-btn', function (e) { 505 e.preventDefault(); 506 $('.pf2-wizard-container').removeClass('show'); 507 $('#pf2-step-wizard-form').removeClass('active'); 508 $('.pf2-survey-wizard').removeClass('hide'); 509 $('body').removeClass('pf2-wizard-open'); 510 resetWizard(); 511 if (wizardTrigger) { 512 wizardTrigger.focus(); 513 wizardTrigger = null; 514 } 515 }); 516 517 $(document).on('click', '.pf2-tooltip-btn', function (e) { 518 e.preventDefault(); 519 $(this).toggleClass('show'); 520 }); 521});
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.