PageSourceSearch

https://brunswickcc.edu/wp-content/themes/brunswick/assets/js/survey-wizard-v2.js?ver=2.0.0

js brunswickcc.edu collected 2026-09-24 10:17:21 UTC 18,959 bytes, 521 lines download raw bytes

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, '&amp;')
271			.replace(/</g, '&lt;')
272			.replace(/>/g, '&gt;')
273			.replace(/"/g, '&quot;')
274			.replace(/'/g, '&#39;');
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.