PageSourceSearch

https://www.hopehospice.com/wp-content/mu-plugins/assets/js/developer.js?v=1789558942

js hopehospice.com collected 2026-10-01 15:27:13 UTC 44,278 bytes, 981 lines download raw bytes

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, '&amp;' )
474            .replace( /</g,  '&lt;'  )
475            .replace( />/g,  '&gt;'  )
476            .replace( /"/g, '&quot;' );
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, '&amp;' )
977            .replace( /</g, '&lt;' )
978            .replace( />/g, '&gt;' )
979            .replace( /"/g, '&quot;' );
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.