1/** 2 * Resource Filter â developer.js 3 * 4 * Handles: 5 * - Form submit (button click / Enter) â AJAX filter, page resets to 1 6 * - Search clear (Ã) button â clears input, re-runs filter 7 * - Pagination â AJAX page change 8 * 9 * Depends on: 10 * CS_AJAX.ajaxurl 11 * CS_AJAX.nonce 12 * CS_AJAX.post_types (e.g. "post,case-studies") 13 * CS_AJAX.allowed_post_types (same as shortcode whitelist for term AJAX) 14 * CS_AJAX.posts_per_page (e.g. 6) 15 * 16 * @package HM_Resource_Filter 17 */ 18 19/** 20 * Shared skeleton markup for resource / events / job filter AJAX loaders. 21 * 22 * @param {Window} window 23 */ 24( function ( window ) { 25 'use strict'; 26 function skeletonBlock( className ) { 27 return '<span class="hm-skeleton ' + className + '" aria-hidden="true"></span>'; 28 } 29 function buildSkeletonCard( variant ) { 30 const isJob = variant === 'job'; 31 const isEvent = variant === 'event'; 32 const cls = isJob 33 ? 'card card--job card--skeleton' 34 : ( isEvent ? 'card card--event card--skeleton' : 'card card--skeleton' ); 35 let body = ''; 36 if ( ! isEvent && ! isJob ) { 37 body += skeletonBlock( 'hm-skeleton--tag' ); 38 } 39 body += skeletonBlock( 'hm-skeleton--title' ); 40 if ( isEvent ) { 41 body += skeletonBlock( 'hm-skeleton--meta' ); 42 } 43 if ( isJob ) { 44 body += skeletonBlock( 'hm-skeleton--location' ); 45 } 46 body += skeletonBlock( 'hm-skeleton--line' ); 47 body += skeletonBlock( 'hm-skeleton--line-short' ); 48 body += '<div class="filter-btn-main">' + skeletonBlock( 'hm-skeleton--btn' ) + '</div>'; 49 return ( 50 '<article class="' + cls + '" aria-hidden="true">' + 51 '<div class="card__image-wrap">' + skeletonBlock( 'hm-skeleton--image' ) + '</div>' + 52 '<div class="card__body">' + body + '</div>' + 53 '</article>' 54 ); 55 } 56 window.hmFilterSkeleton = { 57 buildCard: buildSkeletonCard, 58 buildGrid: function ( count, variant ) { 59 const total = Math.max( 1, parseInt( count, 10 ) || 6 ); 60 let html = ''; 61 62 for ( let i = 0; i < total; i++ ) { 63 html += buildSkeletonCard( variant ); 64 } 65 return html; 66 }, 67 }; 68}( window ) ); 69( function ( $ ) { 70 'use strict'; 71 // ââ State ââââââââââââââââââââââââââââââââââââââââââââââââ
72 let currentPage = 1; 73 // ââ DOM refs âââââââââââââââââââââââââââââââââââââââââââââ 74 let $form, $searchInput, $clearBtn, 75 $resourceTypeSelect, $postTypeSelect, $categorySelect, $tagSelect, $monthSelect, $venueSelect, 76 $cards, $pagination, $results, $wrapper; 77 // ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 78 // INIT 79 // ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 80 $( document ).ready( function () { 81 $wrapper = $( '.hm-rf-wrapper' ).not( '.hm-jf-wrapper' ); 82 if ( ! $wrapper.length ) return; 83 $form = $( '#hm-rf-form', $wrapper ); 84 $searchInput = $( '#hm-search-name', $wrapper ); 85 $resourceTypeSelect = $( '#hm-filter-resource-type', $wrapper ); 86 $postTypeSelect = $( '#hm-filter-post-type', $wrapper ); 87 $categorySelect = $( '#hm-filter-category', $wrapper ); 88 $tagSelect = $( '#hm-filter-tag', $wrapper ); 89 $monthSelect = $( '#hm-filter-month', $wrapper ); 90 $venueSelect = $( '#hm-filter-venue', $wrapper ); 91 $cards = $( '#hm-rf-cards', $wrapper ); 92 $pagination = $( '#hm-rf-pagination', $wrapper ); 93 $results = $( '.hm-rf-results', $wrapper ); 94 injectClearButton(); 95 bindEvents(); 96 // Respect ?page=2 (or higher) on initial load. 97 const initialPage = getPageFromUrl(); 98 if ( initialPage > 1 ) { 99 currentPage = initialPage; 100 doFilter(); 101 } 102 } ); 103 // ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 104 // INJECT CLEAR (x) BUTTON 105 // ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 106 function injectClearButton() { 107 $clearBtn = $( 108 '<button' + ' type="button"' + ' class="hm-rf-clear-btn"' + ' aria-label="Clear search"' + ' title="Clear search"' + ' style="display:none;"' + '>' + ' <svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">' + ' <path d="M11 1L1 11M1 1L11 11" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>' + ' </svg>' + '</button>' 109 ); 110 $searchInput.closest( '.filter-search' ).append( $clearBtn ); 111 toggleClearBtn(); 112 } 113 // ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 114 // EVENT BINDING 115 // ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 116 function bindEvents() { 117 // ââ Form submit (Search button / Enter key) âââââââââââ 118 $form.on( 'submit', function ( e ) { 119 e.preventDefault(); 120 currentPage = 1; 121 setPageInUrl( 1 ); 122 doFilter(); 123 } ); 124 // ââ Show/hide x as user types (NO auto-search) ââââââââ 125 $searchInput.on( 'input', function () { 126 toggleClearBtn(); 127 } ); 128 // ââ Clear button click âââââââââââââââââââââââââââââââââ 129 $clearBtn.on( 'click', function () { 130 $searchInput.val( '' ).trigger( 'focus' ); 131 toggleClearBtn(); 132 currentPage = 1; 133 setPageInUrl( 1 ); 134 doFilter();
134 // re-run with empty search field 135 } ); 136 // ââ Dropdowns âââââââââââââââââââââââââââââââââââââââââââââ 137 $postTypeSelect.add( $categorySelect ).add( $tagSelect ).add( $resourceTypeSelect ).on( 'change', function () { 138 if ( $postTypeSelect.length && this === $postTypeSelect.get( 0 ) ) { 139 if ( $categorySelect.length ) { 140 $categorySelect.val( '' ); 141 } 142 if ( $tagSelect.length ) { 143 $tagSelect.val( '' ); 144 } 145 if ( CS_AJAX.is_events === '1' ) { 146 currentPage = 1; 147 setPageInUrl( 1 ); 148 doFilter(); 149 return; 150 } 151 const allowed = String( CS_AJAX.allowed_post_types || CS_AJAX.post_types || 'post' ) 152 .split( ',' ) 153 .map( function ( s ) { return $.trim( s ); } ) 154 .filter( Boolean ); 155 let pt = String( $( this ).val() || '' ); 156 if ( pt && allowed.indexOf( pt ) === -1 ) { 157 pt = ''; 158 } 159 const ptCsv = pt ? pt : allowed.join( ',' ); 160 const runFilterAfter = ! pt; 161 fetchFilterTermOptions( ptCsv, runFilterAfter ); 162 return; 163 } 164 // Resource Type narrows Content type, the mirror of what the 165 // Content type branch above does to Category and Tag. 166 // 167 // The first paint already does this server-side â see 168 // hm_rf_get_post_types_for_resource_type() in shortcode.php â which 169 // is why landing on a Resource Type archive shows the right Content 170 // type list. Only the change event never refreshed it, so picking a 171 // different Resource Type left the old Content type options in 172 // place. The endpoint it needs has always existed and has always 173 // returned post_type_options; nothing called it. 174 if ( $resourceTypeSelect.length && this === $resourceTypeSelect.get( 0 ) && CS_AJAX.is_events !== '1' ) { 175 fetchPostTypeOptions( $( this ).val() || 0, true ); 176 return; 177 } 178 179 if ( CS_AJAX.is_events === '1' || $( this ).val() === '' ) { 180 currentPage = 1; 181 setPageInUrl( 1 ); 182 doFilter(); 183 } 184 } ); 185 $monthSelect.add( $venueSelect ).on( 'change', function () { 186 currentPage = 1; 187 setPageInUrl( 1 ); 188 doFilter(); 189 } ); 190 $wrapper.on( 'click', '.hm-rf-page', function () { 191 const $btn = $( this ); 192 if ( 193 $btn.hasClass( 'hm-rf-page--disabled' ) || 194 $btn.hasClass( 'hm-rf-page--ellipsis' ) || 195 $btn.hasClass( 'hm-rf-page--active' ) 196 ) return; 197 const page = parseInt( $btn.data( 'page' ), 10 ); 198 if ( isNaN( page ) || page < 1 ) return; 199 currentPage = page; 200 setPageInUrl( page ); 201 doFilter(); 202 scrollToTop(); 203 } ); 204 } 205 function setPageInUrl( page ) { 206 try { 207 const url = new URL( window.location.href ); 208 if ( page && page > 1 ) { 209 url.searchParams.set( 'page', String( page ) ); 210 } else { 211 url.searchParams.delete( 'page' ); 212 } 213 window.history.replaceState( {}, '', url.toString() ); 214 } catch ( e ) { 215 // If URL API isn't available, do nothing. 216 } 217 } 218 function getPageFromUrl() { 219 try { 220 const url = new URL( window.location.href ); 221 const p = parseInt( url.searchParams.get( 'page' ) || '1', 10 ); 222 return ( isNaN( p ) || p < 1 ) ? 1 : p; 223 } catch ( e ) { 224 return 1; 225 } 226 } 227 // ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 228 // CLEAR BUTTON VISIBILITY 229 // ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 230 function toggleClearBtn() { 231 if ( ! $clearBtn ) return; 232 const hasValue = $.trim( $searchInput.val() ).length > 0;
233 $clearBtn.toggle( hasValue ); 234 // Adds padding-right to the input so text never sits behind the button. 235 $searchInput.toggleClass( 'hm-rf-input--has-value', hasValue ); 236 } 237 // ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 238 // AJAX FILTER 239 // ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 240 function doFilter() { 241 const search = $.trim( $searchInput.val() ); 242 const resourceTypeId = $resourceTypeSelect.length 243 ? ( $resourceTypeSelect.val() || 0 ) 244 : ( CS_AJAX.resource_type_id || 0 ); 245 const categoryId = $categorySelect.length 246 ? ( $categorySelect.val() || 0 ) 247 : ( CS_AJAX.category_id || 0 ); 248 const tagId = $tagSelect.length ? ( $tagSelect.val() || 0 ) : 0; 249 const month = $monthSelect.length ? ( $monthSelect.val() || '' ) : ''; 250 const venueId = $venueSelect.length ? ( $venueSelect.val() || 0 ) : 0; 251 const selectedPostType = $postTypeSelect.length ? ( $postTypeSelect.val() || '' ) : ''; 252 const fallbackPostTypes = $postTypeSelect.length 253 ? ( CS_AJAX.post_types || 'post' ) 254 : ( CS_AJAX.active_post_types || CS_AJAX.post_types || 'post' ); 255 const postTypes = selectedPostType ? String( selectedPostType ) : String( fallbackPostTypes ); 256 showLoading( true ); 257 $.ajax( { 258 url: CS_AJAX.ajaxurl, 259 type: 'POST', 260 data: { 261 action: 'cs_filter', 262 nonce: CS_AJAX.nonce, 263 post_types: postTypes, 264 active_post_types: CS_AJAX.active_post_types || '', 265 posts_per_page: CS_AJAX.posts_per_page, 266 paged: currentPage, 267 search: search, 268 button_label: CS_AJAX.button_label || '', 269 button_labels: CS_AJAX.button_labels || '', 270 resource_library: CS_AJAX.resource_library || '', 271 resource_type_id: resourceTypeId, 272 category_id: categoryId, 273 tag_id: tagId, 274 month: month, 275 venue_id: venueId, 276 }, 277 success: function ( response ) { 278 if ( response.success ) { 279 renderResults( response.data ); 280 } else { 281 renderError( response.data ? response.data.message : 'Something went wrong.' ); 282 } 283 }, 284 error: function ( xhr, status, error ) { 285 console.error( 'CS Filter AJAX error:', status, error ); 286 renderError( 'A network error occurred. Please try again.' ); 287 }, 288 complete: function () { 289 showLoading( false ); 290 }, 291 } ); 292 } 293 /** 294 * Repopulate Category / Tag <select> options for the chosen content type(s) (standard mode). 295 * 296 * @param {string} postTypesCsv Comma-separated post type slugs. 297 * @param {boolean} runFilterAfter If true, reset page and run doFilter() after options load. 298 */ 299 function fetchFilterTermOptions( postTypesCsv, runFilterAfter ) { 300 const allowed = String( CS_AJAX.allowed_post_types || CS_AJAX.post_types || 'post' ) 301 .split( ',' ) 302 .map( function ( s ) { return $.trim( s ); } ) 303 .filter( Boolean ); 304 305 $.ajax( { 306 url: CS_AJAX.ajaxurl, 307 type: 'POST', 308 data: { 309 action: 'hm_rf_filter_terms', 310 nonce: CS_AJAX.nonce, 311 post_types: postTypesCsv, 312 allowed_post_types: allowed.join( ',' ), 313 }, 314 success: function ( response ) { 315 if ( response.success && response.data ) { 316 if ( typeof response.data.resource_type_options !== 'undefined' && $resourceTypeSelect.length ) { 317 // Keep the visitor's Resource Type across a Content type 318 // change. val() returns null when the term is not in the 319 // new option list, which is the signal to drop it. 320 const prevResourceType = $resourceTypeSelect.val() || ''; 321 $resourceTypeSelect.html( response.data.resource_type_options ); 322 323 if ( prevResourceType ) { 324 $resourceTypeSelect.val( prevResourceType ); 325 if ( ! $resourceTypeSelect.val() ) { 326 $resourceTypeSelect.val( '' ); 327 } 328 } 329 } 330 if ( response.data.category_options && $categorySelect.length ) { 331 $categorySelect.html( response.data.category_options ); 332 } 333 if ( response.data.tag_options && $tagSelect.length ) { 334 $tagSelect.html( response.data.tag_options ); 335 } 336 } 337 if ( runFilterAfter ) { 338 currentPage = 1; 339 setPageInUrl( 1 ); 340 doFilter(); 341 } 342 }, 343 } ); 344 } 345 /** 346 * Repopulate the Content type <select> for the chosen Resource Type. 347 * 348 * The counterpart of fetchFilterTermOptions() above, which goes the other 349 * way: Content type narrows Category and Tag, Resource Type narrows Content 350 * type. The server already applies this narrowing on the first paint, so 351 * this only has to keep it true after a change. 352 * 353 * @param {number|string} resourceTypeId Term id, or 0 for "all". 354 * @param {boolean} runFilterAfter Reset page and run doFilter() after. 355 */ 356 function fetchPostTypeOptions( resourceTypeId, runFilterAfter ) { 357 // Events forms and single-post-type instances have no Content type 358 // dropdown at all â there is nothing to narrow, so just show results. 359 if ( ! $postTypeSelect.length ) { 360 if ( runFilterAfter ) { 361 currentPage = 1; 362 setPageInUrl( 1 ); 363 doFilter(); 364 } 365 return; 366 } 367 368 const allowed = String( CS_AJAX.allowed_post_types || CS_AJAX.post_types || 'post' ) 369 .split( ',' ) 370 .map( function ( s ) { return $.trim( s ); } ) 371 .filter( Boolean ); 372 373 $.ajax( { 374 url: CS_AJAX.ajaxurl, 375 type: 'POST', 376 data: { 377 action: 'hm_rf_post_types_for_resource_type', 378 nonce: CS_AJAX.nonce, 379 resource_type_id: resourceTypeId || 0, 380 allowed_post_types: allowed.join( ',' ), 381 }, 382 success: function ( response ) { 383 if ( response.success && response.data && response.data.post_type_options ) { 384 // Keep the visitor's Content type if the narrowed list still 385 // offers it. val() returns null when it does not, which is 386 // the signal to fall back to "all" â the same test 387 // fetchFilterTermOptions() uses for Resource Type. 388 const prevPostType = $postTypeSelect.val() || ''; 389 $postTypeSelect.html( response.data.post_type_options ); 390 391 if ( prevPostType ) { 392 $postTypeSelect.val( prevPostType ); 393 if ( ! $postTypeSelect.val() ) { 394 $postTypeSelect.val( '' ); 395 } 396 } 397 } 398 }, 399 complete: function () { 400 // In complete rather than success: if the request fails, the 401 // grid must still move to the Resource Type the visitor just 402 // picked instead of sitting on the previous one. 403 if ( runFilterAfter ) { 404 currentPage = 1; 405 setPageInUrl( 1 ); 406 doFilter(); 407 } 408 }, 409 } ); 410 } 411 // ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 412 // RENDER HELPERS 413 // ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 414 function renderResults( data ) { 415 // A ?page=N deep link (or a filter change that shrank the result set) 416 // can ask for a page past the end. Snap back to the last real page 417 // instead of showing "No results found." with pagination beside it. 418 const maxPages = parseInt( data.max_pages, 10 ); 419 if ( ! isNaN( maxPages ) && maxPages > 0 && currentPage > maxPages ) { 420 currentPage = maxPages; 421 setPageInUrl( maxPages ); 422 // Deferred so this request's complete handler (which clears the 423 // skeleton) runs before the retry puts a new one up. 424 setTimeout( doFilter, 0 ); 425 return; 426 } 427 $cards.addClass( 'hm-rf-fade-out' ); 428 setTimeout( function () { 429 $cards.html( data.cards ).removeClass( 'hm-rf-fade-out' ).addClass( 'hm-rf-fade-in' ); 430 $pagination.html( data.pagination ); 431 setTimeout( function () { 432 $cards.removeClass( 'hm-rf-fade-in' ); 433 }, 300 ); 434 }, 150 ); 435 } 436 function renderError( message ) { 437 $cards.html( 438 '<p class="hm-rf-error" role="alert">
438' + escapeHtml( message ) + '</p>' 439 ); 440 $pagination.html( '' ); 441 } 442 // ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 443 // UI HELPERS 444 // ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 445 function showLoading( show ) { 446 if ( show ) { 447 const variant = ( typeof CS_AJAX !== 'undefined' && CS_AJAX.is_events === '1' ) ? 'event' : 'resource'; 448 const count = ( typeof CS_AJAX !== 'undefined' && CS_AJAX.posts_per_page ) 449 ? parseInt( CS_AJAX.posts_per_page, 10 ) 450 : parseInt( $wrapper.data( 'ppp' ), 10 ) || 6; 451 const html = window.hmFilterSkeleton 452 ? window.hmFilterSkeleton.buildGrid( count, variant ) 453 : ''; 454 455 $cards.addClass( 'is-skeleton-loading' ).attr( 'aria-busy', 'true' ).html( html ); 456 $results.addClass( 'is-filter-loading' ); 457 $pagination.attr( 'aria-hidden', 'true' ); 458 } else { 459 $cards.removeClass( 'is-skeleton-loading' ).removeAttr( 'aria-busy' ); 460 $results.removeClass( 'is-filter-loading' ); 461 $pagination.removeAttr( 'aria-hidden' ); 462 } 463 } 464 function scrollToTop() { 465 if ( ! $wrapper.length ) return; 466 $( 'html, body' ).animate( 467 { scrollTop: $wrapper.offset().top - 80 }, 468 350 469 ); 470 } 471 function escapeHtml( str ) { 472 return String( str ) 473 .replace( /&/g, '&' ) 474 .replace( /</g, '<' ) 475 .replace( />/g, '>' ) 476 .replace( /"/g, '"' ); 477 } 478}( jQuery ) ); 479/** 480 * Job Filter â skeleton loader / pagination pattern as resource filter. 481 * 482 * Depends on JOB_AJAX (localized from [hm_job_filter] shortcode). 483 */ 484( function ( $ ) { 485 'use strict'; 486 let jfCurrentPage = 1; 487 /** Search applied to dropdowns/AJAX only after user clicks Search (not while typing). */ 488 let jfSearchCommitted = ''; 489 let $jfWrapper, $jfForm, $jfSearch, $jfClearBtn, 490 $jfDept, $jfContract, $jfState, $jfCity, $jfProximity, 491 $jfResetBtn, 492 $jfCards, $jfPagination, $jfResults; 493 $( document ).ready( function () { 494 $jfWrapper = $( '.hm-jf-wrapper' ); 495 if ( ! $jfWrapper.length || typeof JOB_AJAX === 'undefined' ) { 496 return; 497 } 498 $jfForm = $( '#hm-jf-form', $jfWrapper ); 499 $jfSearch = $( '#hm-jf-search', $jfWrapper ); 500 $jfDept = $( '#hm-jf-department', $jfWrapper ); 501 $jfContract = $( '#hm-jf-contract', $jfWrapper ); 502 $jfState = $( '#hm-jf-state', $jfWrapper ); 503 $jfCity = $( '#hm-jf-city', $jfWrapper ); 504 $jfProximity = $( '#hm-jf-proximity', $jfWrapper ); 505 $jfResetBtn = $( '.hm-jf-reset-btn', $jfWrapper ); 506 $jfCards = $( '#hm-jf-cards', $jfWrapper ); 507 $jfPagination = $( '#hm-jf-pagination', $jfWrapper ); 508 $jfResults = $( '.hm-rf-results', $jfWrapper ); 509 $jfDept.data( 'placeholder', $jfDept.find( 'option:first' ).text() || 'Department' ); 510 $jfContract.data( 'placeholder', $jfContract.find( 'option:first' ).text() || 'Contract Type' ); 511 $jfState.data( 'placeholder', $jfState.find( 'option:first' ).text() || 'States' ); 512 $jfCity.data( 'placeholder', $jfCity.find( 'option:first' ).text() || 'Cities' ); 513 $jfProximity.data( 'placeholder', $jfProximity.find( 'option:first' ).text() || 'Proximity' ); 514 jfInjectClearButton(); 515 jfBindEvents(); 516 // On initial load, show only dropdown options that actually have results. 517 // Uses JOB_AJAX.job_facets (built from published jobs), so options with no jobs are removed. 518 jfUpdateDependentDropdowns( 'init' ); 519 const initialPage = jfGetPageFromUrl(); 520 if ( initialPage > 1 ) { 521 jfCurrentPage = initialPage; 522 jfDoFilter(); 523 } 524 } ); 525 function jfInjectClearButton() { 526 $jfClearBtn = $( 527 '<button type="button" class="hm-rf-clear-btn" aria-label="Clear search" title="Clear search" style="display:none;">' + 528 '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">' + 529 '<path d="M11 1L1 11M1 1L11 11" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>' + 530 '</svg></button>' 531 ); 532 $jfSearch.closest( '.filter-search' ).append( $jfClearBtn ); 533 jfToggleClearBtn(); 534 } 535 // ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 536 // DEPENDENT DROPDOWNS (from live job facet data) 537 // ââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 538 function jfGetCommittedSearch() { 539 return jfSearchCommitted || ''; 540 } 541 /** 542 * Normalize search text (lowercase, unicode dashes â hyphen, trim spaces). 543 * Must match PHP hm_normalize_title_search_text() / title_lc in job_facets. 544 */ 545 function jfNormalizeSearchText( str ) { 546 return String( str || '' ) 547 .toLowerCase() 548 .replace( /[\u2010\u2011\u2012\u2013\u2014\u2212\uff0d]/g, '-' ) 549 .replace( /\s+/g, ' ' ) 550 .trim(); 551 } 552 function jfGetFilters() { 553 const search = jfGetCommittedSearch(); 554 const searchLc = jfNormalizeSearchText( search ); 555 return { 556 department_id: parseInt( $jfDept.val(), 10 ) || 0, 557 position_id: parseInt( $jfContract.val(), 10 ) || 0, 558 state: $jfState.val() || '', 559 city_id: parseInt( $jfCity.val(), 10 ) || 0, 560 city_label: jfGetCityLabel(), 561 proximity: $jfProximity.val() || '', 562 search: search, 563 search_lc: searchLc, 564 }; 565 } 566 function jfGetCityLabel() { 567 const $opt = $jfCity.find( 'option:selected' ); 568 if ( ! $opt.length || ! $opt.val() ) { 569 return ''; 570 } 571 return $.trim( $opt.text() ); 572 } 573 function jfJobMatches( job, filters, ignoreKey ) { 574 // Search matches job titles only (same as PHP-side job search implementation). 575 if ( filters.search_lc ) { 576 if ( ! job.title_lc || job.title_lc.indexOf( filters.search_lc ) === -1 ) { 577 return false; 578 } 579 } 580 if ( ignoreKey !== 'department' && filters.department_id ) { 581 if ( job.departments.indexOf( filters.department_id ) === -1 ) { 582 return false; 583 } 584 } 585 if ( ignoreKey !== 'position' && filters.position_id ) { 586 if ( job.positions.indexOf( filters.position_id ) === -1 ) { 587 return false; 588 } 589 } 590 if ( ignoreKey !== 'state' && filters.state ) { 591 const stateLc = filters.state.toLowerCase(); 592 const hasState = job.states.some( function ( s ) { 593 return s.toLowerCase() === stateLc; 594 } ); 595 if ( ! hasState ) { 596 return false; 597 } 598 } 599 if ( ignoreKey !== 'city' && ( filters.city_id || filters.city_label ) ) { 600 const cityLc = ( filters.city_label || '' ).toLowerCase(); 601 const hasCity = job.cities.some( function ( c ) { 602 if ( filters.city_id && parseInt( c.term_id, 10 ) === filters.city_id ) { 603 return true; 604 } 605 return cityLc && c.label.toLowerCase() === cityLc; 606 } ); 607 if ( ! hasCity ) { 608 return false; 609 } 610 } 611 if ( ignoreKey !== 'proximity' && filters.proximity ) { 612 if ( String( job.proximity || '' ).toLowerCase() !== String( filters.proximity || '' ).toLowerCase() ) { 613 return false; 614 } 615 } 616 return true; 617 } 618 function jfMatchingJobs( ignoreKey ) { 619 if ( ! JOB_AJAX.job_facets || ! JOB_AJAX.job_facets.length ) { 620 return []; 621 } 622 const filters = jfGetFilters(); 623 return JOB_AJAX.job_facets.filter( function ( job ) { 624 return jfJobMatches( job, filters, ignoreKey ); 625 } ); 626 } 627 function jfRebuildTermSelect( $select, placeholder, options, currentVal ) { 628 if ( ! $select.length ) { 629 return; 630 } 631 let html = '<option value="">' + jfEscapeHtml( placeholder ) + '</option>'; 632 options.forEach( function ( opt ) { 633 const id = String( opt.id ); 634 const sel = ( String( currentVal ) === id ) ? ' selected' : ''; 635 html += '<option value="' + jfEscapeHtml( id ) + '"' + sel + '>' 636 + jfEscapeHtml( opt.label ) + '</option>'; 637 } ); 638 $select.html( html ); 639 } 640 function jfRebuildCitySelect( cities, currentVal, currentLabel ) {
641 if ( ! $jfCity.length ) { 642 return; 643 } 644 const placeholder = $jfCity.data( 'placeholder' ) || 'Cities'; 645 let html = '<option value="">' + jfEscapeHtml( placeholder ) + '</option>'; 646 cities.forEach( function ( c ) { 647 const id = parseInt( c.term_id, 10 ) || 0; 648 const val = String( id ); 649 let sel = false; 650 if ( currentVal && parseInt( currentVal, 10 ) === id && id > 0 ) { 651 sel = true; 652 } else if ( currentLabel && c.label.toLowerCase() === currentLabel.toLowerCase() ) { 653 sel = true; 654 } 655 html += '<option value="' + val + '"' + ( sel ? ' selected' : '' ) + '>' 656 + jfEscapeHtml( c.label ) + '</option>'; 657 } ); 658 659 $jfCity.html( html ); 660 } 661 function jfCollectUnique( items, keyFn ) { 662 const map = {}; 663 items.forEach( function ( item ) { 664 const key = keyFn( item ); 665 if ( key && ! map[ key ] ) { 666 map[ key ] = item; 667 } 668 } ); 669 return Object.keys( map ).sort( function ( a, b ) { 670 return a.localeCompare( b, undefined, { sensitivity: 'base' } ); 671 } ).map( function ( k ) { 672 return map[ k ]; 673 }); 674 } 675 /** 676 * Rebuild all dropdowns from jobs that match the other active filters. 677 * 678 * @param {string} changedKey Which filter just changed (ignored when matching). 679 */ 680 function jfUpdateDependentDropdowns( changedKey ) { 681 if ( ! JOB_AJAX.job_facets || ! JOB_AJAX.lists ) { 682 return; 683 } 684 const filters = jfGetFilters(); 685 686 // Departments 687 const deptJobs = jfMatchingJobs( 'department' ); 688 const deptIds = {}; 689 deptJobs.forEach( function ( job ) { 690 job.departments.forEach( function ( id ) { 691 deptIds[ id ] = true; 692 } ); 693 } ); 694 const deptOpts = JOB_AJAX.lists.departments.filter( function ( o ) { 695 return deptIds[ o.id ]; 696 } ); 697 const prevDept = filters.department_id; 698 jfRebuildTermSelect( $jfDept, $jfDept.data( 'placeholder' ) || 'Department', deptOpts, prevDept ); 699 if ( prevDept && ! deptIds[ prevDept ] ) { 700 $jfDept.val( '' ); 701 } 702 // Contract type 703 const posJobs = jfMatchingJobs( 'position' ); 704 const posIds = {}; 705 posJobs.forEach( function ( job ) { 706 job.positions.forEach( function ( id ) { 707 posIds[ id ] = true; 708 } ); 709 } ); 710 const posOpts = JOB_AJAX.lists.positions.filter( function ( o ) { 711 return posIds[ o.id ]; 712 } ); 713 const prevPos = parseInt( $jfContract.val(), 10 ) || 0; 714 jfRebuildTermSelect( $jfContract, $jfContract.data( 'placeholder' ) || 'Contract Type', posOpts, prevPos ); 715 if ( prevPos && ! posIds[ prevPos ] ) { 716 $jfContract.val( '' ); 717 } 718 // States 719 const stateJobs = jfMatchingJobs( 'state' ); 720 const stateMap = {}; 721 stateJobs.forEach( function ( job ) { 722 job.states.forEach( function ( s ) { 723 stateMap[ s.toLowerCase() ] = { id: s, label: s }; 724 } ); 725 } ); 726 const stateOpts = jfCollectUnique( Object.keys( stateMap ).map( function ( k ) { 727 return stateMap[ k ]; 728 } ), function ( o ) { 729 return o.label.toLowerCase(); 730 } ); 731 const prevState = $jfState.val() || ''; 732 jfRebuildTermSelect( $jfState, $jfState.data( 'placeholder' ) || 'States', stateOpts, prevState ); 733 if ( prevState && ! stateMap[ prevState.toLowerCase() ] ) { 734 $jfState.val( '' ); 735 } 736 // Cities (from matching jobs â includes primary_location) 737 const cityJobs = jfMatchingJobs( 'city' ); 738 const cityMap = {};
739 cityJobs.forEach( function ( job ) { 740 job.cities.forEach( function ( c ) { 741 const k = c.label.toLowerCase(); 742 if ( ! cityMap[ k ] || ( parseInt( c.term_id, 10 ) > parseInt( cityMap[ k ].term_id, 10 ) ) ) { 743 cityMap[ k ] = c; 744 } 745 } ); 746 } ); 747 let cityList = Object.keys( cityMap ).map( function ( k ) { 748 return cityMap[ k ]; 749 } ); 750 cityList.sort( function ( a, b ) { 751 return a.label.localeCompare( b.label, undefined, { sensitivity: 'base' } ); 752 } ); 753 const activeState = $jfState.val() || ''; 754 if ( activeState && JOB_AJAX.cities_by_state && JOB_AJAX.cities_by_state[ activeState ] ) { 755 const allowed = {}; 756 JOB_AJAX.cities_by_state[ activeState ].forEach( function ( c ) { 757 allowed[ c.label.toLowerCase() ] = true; 758 } ); 759 cityList = cityList.filter( function ( c ) { 760 return allowed[ c.label.toLowerCase() ]; 761 } ); 762 } 763 const prevCityId = parseInt( $jfCity.val(), 10 ) || 0; 764 const prevCityLabel = jfGetCityLabel(); 765 jfRebuildCitySelect( cityList, prevCityId, prevCityLabel ); 766 767 // Proximity 768 const proxJobs = jfMatchingJobs( 'proximity' ); 769 const proxMap = {};
770 proxJobs.forEach( function ( job ) { 771 const k = String( job.proximity || '' ).toLowerCase(); 772 if ( k ) { 773 proxMap[ k ] = true; 774 } 775 } ); 776 const proxOpts = JOB_AJAX.lists.proximity.filter( function ( o ) { 777 return proxMap[ String( o.id || '' ).toLowerCase() ]; 778 } ); 779 const prevProx = $jfProximity.val() || ''; 780 jfRebuildTermSelect( $jfProximity, $jfProximity.data( 'placeholder' ) || 'Proximity', proxOpts, prevProx ); 781 if ( prevProx && ! proxMap[ String( prevProx ).toLowerCase() ] ) { 782 $jfProximity.val( '' ); 783 } 784 } 785 function jfOnFilterChange( changedKey ) { 786 jfUpdateDependentDropdowns( changedKey ); 787 jfCurrentPage = 1; 788 jfSetPageInUrl( 1 ); 789 jfDoFilter(); 790 } 791 function jfBindEvents() { 792 $jfForm.on( 'submit', function ( e ) { 793 e.preventDefault(); 794 jfSearchCommitted = $.trim( $jfSearch.val() ); 795 jfCurrentPage = 1; 796 jfSetPageInUrl( 1 ); 797 jfUpdateDependentDropdowns( 'search' ); 798 jfDoFilter(); 799 } ); 800 $jfSearch.on( 'input', function () { 801 jfToggleClearBtn(); 802 } ); 803 $jfClearBtn.on( 'click', function () { 804 $jfSearch.val( '' ).trigger( 'focus' ); 805 jfSearchCommitted = ''; 806 jfToggleClearBtn(); 807 jfCurrentPage = 1; 808 jfSetPageInUrl( 1 ); 809 jfUpdateDependentDropdowns( 'search' ); 810 jfDoFilter(); 811 } ); 812 if ( $jfResetBtn && $jfResetBtn.length ) { 813 $jfResetBtn.on( 'click', function () { 814 $jfSearch.val( '' ).trigger( 'focus' ); 815 jfSearchCommitted = ''; 816 jfToggleClearBtn(); 817 818 // Reset all dropdowns back to their placeholder option. 819 $jfDept.val( '' ); 820 $jfContract.val( '' ); 821 $jfState.val( '' ); 822 $jfCity.val( '' ); 823 $jfProximity.val( '' ); 824 825 jfCurrentPage = 1; 826 jfSetPageInUrl( 1 ); 827 jfUpdateDependentDropdowns( 'reset' ); 828 jfDoFilter(); 829 } ); 830 } 831 $jfDept.on( 'change', function () { 832 jfOnFilterChange( 'department' ); 833 } ); 834 $jfContract.on( 'change', function () { 835 jfOnFilterChange( 'position' ); 836 } ); 837 $jfState.on( 'change', function () { 838 jfOnFilterChange( 'state' ); 839 } ); 840 $jfCity.on( 'change', function () { 841 jfOnFilterChange( 'city' ); 842 } ); 843 $jfProximity.on( 'change', function () { 844 jfOnFilterChange( 'proximity' ); 845 } ); 846 $jfWrapper.on( 'click', '.hm-rf-page', function () { 847 const $btn = $( this ); 848 if ( 849 $btn.hasClass( 'hm-rf-page--disabled' ) || 850 $btn.hasClass( 'hm-rf-page--ellipsis' ) || 851 $btn.hasClass( 'hm-rf-page--active' ) 852 ) { 853 return; 854 } 855 const page = parseInt( $btn.data( 'page' ), 10 ); 856 if ( isNaN( page ) || page < 1 ) { 857 return; 858 } 859 jfCurrentPage = page; 860 jfSetPageInUrl( page ); 861 jfDoFilter(); 862 jfScrollToTop(); 863 } ); 864 } 865 function jfSetPageInUrl( page ) { 866 try { 867 const url = new URL( window.location.href ); 868 if ( page && page > 1 ) { 869 url.searchParams.set( 'page', String( page ) ); 870 } else { 871 url.searchParams.delete( 'page' ); 872 } 873 window.history.replaceState( {}, '', url.toString() ); 874 } catch ( e ) { 875 // noop 876 } 877 } 878 function jfGetPageFromUrl() { 879 try { 880 const url = new URL( window.location.href ); 881 const p = parseInt( url.searchParams.get( 'page' ) || '1', 10 ); 882 return ( isNaN( p ) || p < 1 ) ? 1 : p; 883 } catch ( e ) { 884 return 1; 885 } 886 } 887 function jfToggleClearBtn() {
888 if ( ! $jfClearBtn ) { 889 return; 890 } 891 const hasValue = $.trim( $jfSearch.val() ).length > 0; 892 $jfClearBtn.toggle( hasValue ); 893 $jfSearch.toggleClass( 'hm-rf-input--has-value', hasValue ); 894 } 895 function jfDoFilter() { 896 const search = jfGetCommittedSearch(); 897 jfShowLoading( true ); 898 899 $.ajax( { 900 url: JOB_AJAX.ajaxurl, 901 type: 'POST', 902 data: { 903 action: 'hm_job_filter', 904 nonce: JOB_AJAX.nonce, 905 posts_per_page: JOB_AJAX.posts_per_page, 906 button_label: JOB_AJAX.button_label, 907 paged: jfCurrentPage, 908 search: search, 909 department_id: $jfDept.val() || 0, 910 position_id: $jfContract.val() || 0, 911 state: $jfState.val() || '', 912 city_id: $jfCity.val() || 0, 913 city_label: jfGetCityLabel(), 914 proximity: $jfProximity.val() || '', 915 }, 916 success: function ( response ) { 917 if ( response.success ) { 918 jfRenderResults( response.data ); 919 } else { 920 jfRenderError( response.data ? response.data.message : 'Something went wrong.' ); 921 } 922 }, 923 error: function () { 924 jfRenderError( 'A network error occurred. Please try again.' ); 925 }, 926 complete: function () { 927 jfShowLoading( false ); 928 }, 929 } ); 930 } 931 function jfRenderResults( data ) { 932 $jfCards.addClass( 'hm-rf-fade-out' ); 933 setTimeout( function () { 934 $jfCards.html( data.cards ).removeClass( 'hm-rf-fade-out' ).addClass( 'hm-rf-fade-in' ); 935 $jfPagination.html( data.pagination ); 936 setTimeout( function () { 937 $jfCards.removeClass( 'hm-rf-fade-in' ); 938 }, 300 ); 939 }, 150 ); 940 } 941 function jfRenderError( message ) { 942 $jfCards.html( 943 '<p class="hm-rf-error" role="alert">' + jfEscapeHtml( message ) + '</p>' 944 ); 945 $jfPagination.html( '' ); 946 } 947 function jfShowLoading( show ) { 948 if ( show ) { 949 const count = ( typeof JOB_AJAX !== 'undefined' && JOB_AJAX.posts_per_page ) 950 ? parseInt( JOB_AJAX.posts_per_page, 10 ) 951 : parseInt( $jfWrapper.data( 'ppp' ), 10 ) || 9; 952 const html = window.hmFilterSkeleton 953 ? window.hmFilterSkeleton.buildGrid( count, 'job' ) 954 : ''; 955 956 $jfCards.addClass( 'is-skeleton-loading' ).attr( 'aria-busy', 'true' ).html( html ); 957 $jfResults.addClass( 'is-filter-loading' ); 958 $jfPagination.attr( 'aria-hidden', 'true' ); 959 } else { 960 $jfCards.removeClass( 'is-skeleton-loading' ).removeAttr( 'aria-busy' ); 961 $jfResults.removeClass( 'is-filter-loading' ); 962 $jfPagination.removeAttr( 'aria-hidden' ); 963 } 964 } 965 function jfScrollToTop() { 966 if ( ! $jfWrapper.length ) { 967 return; 968 } 969 $( 'html, body' ).animate( 970 { scrollTop: $jfWrapper.offset().top - 80 }, 971 350 972 ); 973 } 974 function jfEscapeHtml( str ) { 975 return String( str ) 976 .replace( /&/g, '&' ) 977 .replace( /</g, '<' ) 978 .replace( />/g, '>' ) 979 .replace( /"/g, '"' ); 980 } 981}( 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.