1( function () { 2 'use strict'; 3 4 const { esc, initLowerLabels, renderCategoryLink, debounce } = window.SiteUtils; 5 6 // Data-driven post-type map localized from PHP: 7 // { slug: { label, singular, endpoint } } 8 // Read lazily (inside the helpers, not at module-parse time) so it works 9 // regardless of whether the localized global is printed before or after 10 // this script. 11 function postTypeMap() { 12 return ( window.momentiveResourceFilters && window.momentiveResourceFilters.postTypes ) || {}; 13 } 14 15 function postTypeEndpoint( slug ) { 16 return postTypeMap()[ slug ]?.endpoint ?? '/wp-json/wp/v2/posts'; 17 } 18 19 function postTypeSingular( slug ) { 20 return postTypeMap()[ slug ]?.singular ?? ''; 21 } 22 23 function initLoadMore( grid ) { 24 const queryBlock = grid.closest( '.wp-block-query' ); 25 const pagination = queryBlock?.querySelector( '.wp-block-query-pagination' ); 26 27 if ( ! pagination ) return null; 28 29 const btn = document.createElement( 'div' ); 30 btn.className = 'load-more-wrapper'; 31 btn.innerHTML = ` 32 <div class="wp-block-buttons is-content-justification-center"> 33 <div class="wp-block-button"> 34 <button class="wp-block-button__link wp-element-button load-more-btn" type="button"> 35 Load More 36 </button> 37 </div> 38 </div> 39 `; 40 41 pagination.insertAdjacentElement( 'afterend', btn ); 42 pagination.hidden = true; 43 44 return btn.querySelector( '.load-more-btn' ); 45 } 46 47 function initAccordionLoadMore( accordion ) { 48 const btn = document.createElement( 'div' ); 49 btn.className = 'load-more-wrapper'; 50 btn.innerHTML = ` 51 <div class="wp-block-buttons is-content-justification-center"> 52 <div class="wp-block-button"> 53 <button class="wp-block-button__link wp-element-button load-more-btn" type="button"> 54 Load More 55 </button> 56 </div> 57 </div> 58 `; 59 accordion.insertAdjacentElement( 'afterend', btn ); 60 return btn.querySelector( '.load-more-btn' ); 61 } 62 63 function findNearestQuery( bar ) { 64 let el = bar.nextElementSibling; 65 while ( el ) { 66 const grid = el.querySelector( '.wp-block-post-template' ); 67 if ( grid ) return { type: 'grid', el: grid }; 68 69 const accordion = el.querySelector( '.momentive-accordion.is-query-mode' ); 70 if ( accordion ) return { type: 'accordion', el: accordion }; 71 72 el = el.nextElementSibling; 73 } 74 const fallbackGrid = bar.closest( '.wp-block-group' ) 75 ?.querySelector( '.wp-block-post-template' ) ?? null; 76 if ( fallbackGrid ) return { type: 'grid', el: fallbackGrid }; 77 78 const fallbackAccordion = bar.closest( '.wp-block-group' ) 79 ?.querySelector( '.momentive-accordion.is-query-mode' ) ?? null; 80 if ( fallbackAccordion ) return { type: 'accordion', el: fallbackAccordion }; 81 82 return null; 83 } 84 85 function initFilterBar( bar ) { 86 const queryTarget = findNearestQuery( bar ); 87 if ( ! queryTarget ) return; 88 89 const isAccordion = queryTarget.type === 'accordion'; 90 const grid = isAccordion ? null : queryTarget.el; 91 const accordion = isAccordion ? queryTarget.el : null; 92 93 const moreBtn = isAccordion ? initAccordionLoadMore( accordion ) : initLoadMore( grid ); 94 95 // ââ Read the default post type from a data attribute set by PHP âââââââ 96 // This drives renderCard's top-label logic when no post_type filter 97 // is actively selected by the user. 98 const defaultPostType = bar.dataset.defaultPostType || 'post'; 99 100 // Custom taxonomies â read once from the data attribute set by PHP. 101 // Shape: [ { slug: 'organization_type', label: 'Organization Type' }, ⦠] 102 const customTaxonomies = ( () => { 103 try { 104 return JSON.parse( bar.dataset.customTaxonomies || '[]' ); 105 } catch { 106 return []; 107 } 108 } )(); 109 110 const state = { 111 categories: [], 112 postTypes: [], 113 customTaxFilters: Object.fromEntries( customTaxonomies.map( t => [ t.slug, [] ] ) ), 114 search: '', 115 orderby: isAccordion ? 'menu_order' : 'date', 116 order: isAccordion ? 'asc' : 'desc', 117 page: 1, 118 totalPages: 1, 119 loading: false, 120 }; 121 122 // Seed totalPages from the data attribute PHP already wrote on the accordion wrapper, 123 // then immediately show or hide the button. Without this, the button stays hidden 124 // until the first fetch fires (which only happens on user interaction). 125 if ( isAccordion && moreBtn ) { 126 state.totalPages = parseInt( accordion.dataset.totalPages || '1', 10 ); 127 moreBtn.closest( '.load-more-wrapper' ).hidden = state.totalPages <= 1; 128 } 129 130 const toggle = bar.querySelector( '.filter-toggle' ); 131 const panel = bar.querySelector( '.filter-panel' ); 132 const resetBtn = bar.querySelector( '.filter-reset' ); 133 const countBadge = bar.querySelector( '.filter-toggle .filter-count' ); 134 const activeTags = bar.querySelector( '.filter-active-tags' ); 135 const searchInput = bar.querySelector( '.filter-search' ); 136 const sortSelect = bar.querySelector( '.filter-sort' ); 137 138 toggle?.addEventListener( 'click', () => { 139 const open = toggle.getAttribute( 'aria-expanded' ) === 'true'; 140 toggle.setAttribute( 'aria-expanded', String( ! open ) ); 141 panel?.toggleAttribute( 'hidden', open ); 142 } ); 143 144 // Close the panel and any open inline dropdowns when clicking outside the filter bar. 145 document.addEventListener( 'click', ( e ) => { 146 if ( bar.contains( e.target ) ) return; 147 if ( toggle?.getAttribute( 'aria-expanded' ) === 'true' ) { 148 toggle.setAttribute( 'aria-expanded', 'false' ); 149 panel?.toggleAttribute( 'hidden', true ); 150 } 151 closeAllDropdowns(); 152 } ); 153 154 bar.querySelectorAll( '.filter-group-toggle' ).forEach( legend => { 155 legend.addEventListener( 'click', () => { 156 const expanded = legend.getAttribute( 'aria-expanded' ) !== 'false'; 157 legend.setAttribute( 'aria-expanded', String( ! expanded ) );
158 legend.nextElementSibling?.toggleAttribute( 'hidden', expanded ); 159 } ); 160 } ); 161 162 // ââ Inline taxonomy dropdowns âââââââââââââââââââââââââââââââââââââââââ 163 // Each custom taxonomy renders as its own dropdown in the filter bar top. 164 // Only one can be open at a time. 165 166 function closeAllDropdowns() { 167 bar.querySelectorAll( '.filter-dropdown-toggle[aria-expanded="true"]' ).forEach( btn => { 168 btn.setAttribute( 'aria-expanded', 'false' ); 169 btn.closest( '.filter-dropdown' ) 170 ?.querySelector( '.filter-dropdown-panel' ) 171 ?.setAttribute( 'hidden', '' ); 172 } ); 173 } 174 175 bar.querySelectorAll( '.filter-dropdown-toggle' ).forEach( btn => { 176 btn.addEventListener( 'click', () => { 177 const isOpen = btn.getAttribute( 'aria-expanded' ) === 'true'; 178 closeAllDropdowns(); 179 if ( ! isOpen ) { 180 btn.setAttribute( 'aria-expanded', 'true' ); 181 btn.closest( '.filter-dropdown' ) 182 ?.querySelector( '.filter-dropdown-panel' ) 183 ?.removeAttribute( 'hidden' ); 184 } 185 } ); 186 } ); 187 188 bar.querySelectorAll( 'input[name="category"]' ).forEach( input => { 189 input.addEventListener( 'change', () => { 190 state.categories = Array.from( 191 bar.querySelectorAll( 'input[name="category"]:checked' ) 192 ).map( el => el.value ); 193 state.page = 1; 194 syncUI(); 195 fetchPosts(); 196 } ); 197 } ); 198 199 // ââ "All types" checkbox ââââââââââââââââââââââââââââââââââââââââââââââ 200 // Separate input (name="post_type_all") so it never lands in state.postTypes. 201 // Checked = all individual types selected. 202 // Indeterminate = some selected. 203 // Unchecked = none selected. 204 205 const allTypesInput = bar.querySelector( 'input.post-type-all' ); 206 207 function syncAllTypesCheckbox() { 208 if ( ! allTypesInput ) return; 209 const inputs = Array.from( bar.querySelectorAll( 'input[name="post_type"]' ) ); 210 const checked = inputs.filter( i => i.checked ).length; 211 const total = inputs.length; 212 if ( checked === total && total > 0 ) { 213 allTypesInput.checked = true; 214 allTypesInput.indeterminate = false; 215 } else if ( checked === 0 ) { 216 allTypesInput.checked = false; 217 allTypesInput.indeterminate = false; 218 } else { 219 allTypesInput.checked = false; 220 allTypesInput.indeterminate = true; 221 } 222 } 223 224 allTypesInput?.addEventListener( 'change', () => { 225 const inputs = Array.from( bar.querySelectorAll( 'input[name="post_type"]' ) ); 226 if ( allTypesInput.checked ) { 227 inputs.forEach( el => { el.checked = true; } ); 228 state.postTypes = inputs.map( el => el.value ); 229 } else { 230 inputs.forEach( el => { el.checked = false; } ); 231 state.postTypes = []; 232 } 233 allTypesInput.indeterminate = false; 234 state.page = 1; 235 syncUI(); 236 fetchPosts(); 237 } ); 238 239 bar.querySelectorAll( 'input[name="post_type"]' ).forEach( input => { 240 input.addEventListener( 'change', () => { 241 state.postTypes = Array.from( 242 bar.querySelectorAll( 'input[name="post_type"]:checked' ) 243 ).map( el => el.value ); 244 state.page = 1; 245 syncAllTypesCheckbox(); 246 syncUI(); 247 fetchPosts(); 248 } ); 249 } ); 250 251 // Wire custom taxonomy checkboxes (one listener per taxonomy slug). 252 customTaxonomies.forEach( tax => { 253 bar.querySelectorAll( `input[name="${ tax.slug }"]` ).forEach( input => { 254 input.addEventListener( 'change', () => { 255 state.customTaxFilters[ tax.slug ] = Array.from( 256 bar.querySelectorAll( `input[name="${ tax.slug }"]:checked` ) 257 ).map( el => el.value ); 258 state.page = 1; 259 syncUI(); 260 fetchPosts(); 261 } ); 262 } ); 263 } ); 264 265 const debouncedSearch = debounce( () => { 266 state.search = searchInput?.value.trim() ?? ''; 267 state.page = 1; 268 syncUI(); 269 fetchPosts(); 270 }, 350 ); 271 272 searchInput?.addEventListener( 'input', debouncedSearch ); 273 274 sortSelect?.addEventListener( 'change', () => { 275 const [ orderby, order ] = ( sortSelect.value || 'date-desc' ).split( '-' ); 276 state.orderby = orderby; 277 state.order = order; 278 state.page = 1; 279 updateURL(); 280 fetchPosts(); 281 } ); 282 283 resetBtn?.addEventListener( 'click', () => { 284 bar.querySelectorAll( 'input[type="checkbox"]' ).forEach( el => { 285 el.checked = false;
286 } ); 287 if ( searchInput ) searchInput.value = ''; 288 state.categories = []; 289 state.postTypes = []; 290 state.customTaxFilters = Object.fromEntries( customTaxonomies.map( t => [ t.slug, [] ] ) ); 291 state.search = ''; 292 state.page = 1; 293 syncUI(); 294 fetchPosts(); 295 } ); 296 297 moreBtn?.addEventListener( 'click', () => { 298 if ( state.loading ) return; 299 state.page++; 300 fetchPosts( true ); 301 } ); 302 303 // ââ Auto-query / deep-link boot âââââââââââââââââââââââââââââââââââââââ 304 // applyURLState() reads ?cat=&type=&search=&sort= from the URL, pre- 305 // checks the matching inputs, seeds state, and returns true when at 306 // least one param was found. URL params always take priority over the 307 // auto-query default so a shared link lands with the expected filters. 308 const hasURLState = applyURLState(); 309 310 if ( hasURLState ) { 311 fetchPosts(); 312 } else if ( bar.dataset.autoQuery === 'true' ) { 313 // No URL params: select all types (including the "All" checkbox) and 314 // fetch so the grid opens with the merged multi-type view. 315 const typeInputs = Array.from( bar.querySelectorAll( 'input[name="post_type"]' ) ); 316 typeInputs.forEach( el => { el.checked = true; } ); 317 state.postTypes = typeInputs.map( el => el.value ); 318 if ( allTypesInput ) { allTypesInput.checked = true; allTypesInput.indeterminate = false; } 319 fetchPosts(); 320 } 321 322 // Restore filter state when the user navigates back/forward. 323 window.addEventListener( 'popstate', () => { 324 bar.querySelectorAll( 'input[type="checkbox"]' ).forEach( el => { el.checked = false; } ); 325 if ( allTypesInput ) { allTypesInput.checked = false; allTypesInput.indeterminate = false; } 326 if ( searchInput ) searchInput.value = ''; 327 if ( sortSelect ) sortSelect.value = isAccordion ? 'menu_order-asc' : 'date-desc'; 328 state.categories = []; 329 state.postTypes = []; 330 state.customTaxFilters = Object.fromEntries( customTaxonomies.map( t => [ t.slug, [] ] ) ); 331 state.search = ''; 332 state.orderby = isAccordion ? 'menu_order' : 'date'; 333 state.order = isAccordion ? 'asc' : 'desc'; 334 state.page = 1; 335 const urlRestored = applyURLState(); 336 // If navigating back to the unfiltered state on an auto-query page, 337 // restore the "all types" selection the same way the initial boot does. 338 if ( ! urlRestored && bar.dataset.autoQuery === 'true' ) { 339 const typeInputs = Array.from( bar.querySelectorAll( 'input[name="post_type"]' ) ); 340 typeInputs.forEach( el => { el.checked = true; } ); 341 state.postTypes = typeInputs.map( el => el.value ); 342 if ( allTypesInput ) { allTypesInput.checked = true; allTypesInput.indeterminate = false; } 343 syncUI(); 344 } 345 fetchPosts(); 346 } ); 347 348 // ââ Fetch âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 349 async function fetchPosts( append = false ) { 350 if ( state.loading ) return; 351 state.loading = true; 352 353 // aria-busy on whichever container is active. 354 const busyTarget = isAccordion ? accordion : grid; 355 busyTarget.setAttribute( 'aria-busy', 'true' ); 356 357 if ( moreBtn ) { 358 moreBtn.disabled = true; 359 moreBtn.textContent = 'Loadingâ¦'; 360 } 361 362 // ââ Resolve which post type(s) to query ââââââââââââââââââââââââââââ 363 // Accordion mode (FAQ query mode) only ever targets a single type in 364 // practice, so it keeps the original single-endpoint behavior. Grid 365 // mode now supports true multi-type merging: when more than one 366 // post_type checkbox is active, this calls the momentive/v1/resources 367 // REST route (inc/resources.php) instead of falling back to a single 368 // type. That route runs one WP_Query across several post types, so 369 // merge, sort, and pagination stay correct â the gap this file used 370 // to document as "a separate, planned feature." 371 const isMultiType = ! isAccordion && state.postTypes.length > 1; 372 373 const activePostType = state.postTypes.length === 1 374 ? state.postTypes[0] 375 : defaultPostType; 376 377 try { 378 let posts; 379 380 if ( isMultiType ) { 381 const multiParams = new URLSearchParams( { 382 per_page: 15, 383 page: state.page, 384 orderby: state.orderby, 385 order: state.order, 386 post_type: state.postTypes.join( ',' ), 387 } ); 388 if ( state.categories.length ) { 389 multiParams.set( 'categories', state.categories.join( ',' ) ); 390 } 391 if ( state.search ) { 392 multiParams.set( 'search', state.search ); 393 } 394 395 const res = await fetch( `/wp-json/momentive/v1/resources?${ multiParams }` ); 396 state.totalPages = parseInt( res.headers.get( 'X-WP-TotalPages' ) || '1', 10 ); 397 posts = await res.json(); 398 399 const html = posts.map( renderMultiTypeCard ).join( '' ); 400 401 if ( append ) { 402 grid.insertAdjacentHTML( 'beforeend', html ); 403 } else { 404 grid.innerHTML = html; 405 } 406 initLowerLabels( grid ); 407 408 } else { 409 const params = new URLSearchParams( { 410 per_page: 15, 411 page: state.page, 412 orderby: state.orderby, 413 order: state.order, 414 _embed: true, 415 } ); 416 417 if ( state.categories.length ) { 418 params.set( 'categories', state.categories.join( ',' ) ); 419 } 420 // Append any active custom taxonomy filters as native REST params. 421 // WP auto-generates ?{taxonomy_slug}= for show_in_rest taxonomies. 422 Object.entries( state.customTaxFilters ).forEach( ( [ slug, ids ] ) => { 423 if ( ids.length ) params.set( slug, ids.join( ',' ) ); 424 } ); 425 if ( state.search ) { 426 params.set( 'search', state.search ); 427 } 428 429 const endpoint = postTypeEndpoint( activePostType ); 430 431 // Single fetch â result used by both grid and accordion paths. 432 const res = await fetch( `${ endpoint }?${ params }` ); 433 state.totalPages = parseInt( res.headers.get( 'X-WP-TotalPages' ) || '1', 10 ); 434 posts = await res.json(); 435 436 if ( isAccordion ) { 437 const items = posts.map( buildAccordionItem ).join( '' ); 438 439 if ( append ) { 440 accordion.insertAdjacentHTML( 'beforeend', items ); 441 accordion.querySelectorAll( '.accordion-trigger:not([data-init])' ) 442 .forEach( t => { t.setAttribute( 'data-init', '' ); wireAccordionTrigger( t ); } ); 443 } else { 444 accordion.innerHTML = items; 445 accordion.querySelectorAll( '.accordion-trigger' ) 446 .forEach( t => { t.setAttribute( 'data-init', '' ); wireAccordionTrigger( t ); } ); 447 } 448 } else { 449 const html = posts.map( post => 450 activePostType === 'fundraiser' 451 ? renderDonationExampleCard( post ) 452 : renderCard( post, activePostType ) 453 ).join( '' ); 454 455 if ( append ) { 456 grid.insertAdjacentHTML( 'beforeend', html ); 457 } else { 458 grid.innerHTML = html; 459 } 460 initLowerLabels( grid ); 461 } 462 } 463 464 } catch ( err ) { 465 console.error( 'Resource filter fetch error:', err ); 466 } finally { 467 state.loading = false;
468 busyTarget.removeAttribute( 'aria-busy' ); 469 470 if ( moreBtn ) { 471 moreBtn.disabled = false; 472 moreBtn.textContent = 'Load More'; 473 moreBtn.closest( '.load-more-wrapper' ).hidden = 474 state.page >= state.totalPages; 475 } 476 } 477 } 478 479 // ââ Card renderer âââââââââââââââââââââââââââââââââââââââââââââââââââââ 480 // activePostType is now passed as a parameter instead of being 481 // referenced from an outer scope â this was the source of the 482 // ReferenceError when renderCard was called via posts.map(). 483 484 function renderCard( post, activePostType ) { 485 const terms = post._embedded?.[ 'wp:term' ] ?? []; 486 const cats = terms.find( group => group[0]?.taxonomy === 'category' ) ?? []; 487 const media = post._embedded?.[ 'wp:featuredmedia' ]?.[0]; 488 const date = new Date( post.date ).toLocaleDateString( 'en-US', { 489 month: 'long', day: 'numeric', year: 'numeric', 490 } ); 491 const excerpt = ( post.excerpt?.rendered ?? '' ) 492 .replace( /<[^>]+>/g, '' ) 493 .replace( /…/g, 'â¦' ) 494 .slice( 0, 140 ); 495 496 const catLinks = cats.map( renderCategoryLink ).join( '<span class="wp-block-post-terms__separator"> </span>' ); 497 498 // resource_card_meta is a PHP-computed REST field (inc/resource-cta.php) 499 // that exposes the type-aware top label, schedule, CTA label/URL, and 500 // whether to show the publication date. Use it when available; fall back 501 // to the previous static-label / "Read more" behaviour otherwise. 502 const cardMeta = post.resource_card_meta; 503 504 const topLabel = cardMeta 505 ? cardMeta.top_label_html 506 : ( activePostType === 'press-article' 507 ? ( cats[0] ? `<p class="top-label wp-block-paragraph">${ esc( cats[0].name ) }</p>` : '' ) 508 : ( postTypeSingular( activePostType ) 509 ? `<p class="top-label wp-block-paragraph">${ esc( postTypeSingular( activePostType ) ) }</p>` 510 : '' ) ); 511 512 const schedule = cardMeta?.schedule_html ?? '';
513 const ctaLabel = cardMeta?.cta_label ?? 'Read more'; 514 const ctaUrl = cardMeta?.cta_url ?? post.link; 515 const showDate = cardMeta ? cardMeta.show_date : true; 516 517 return `<li class="wp-block-post"> 518 <div class="wp-block-group story-card"> 519 520 ${ topLabel } 521 522 ${ media ? `<figure class="wp-block-post-featured-image" style="aspect-ratio:16/9"> 523 <a href="${ esc( post.link ) }" tabindex="-1" aria-hidden="true"> 524 <img src="${ esc( media.source_url ) }" 525 alt="" 526 loading="lazy" 527 style="width:100%;height:100%;object-fit:cover;"> 528 </a> 529 </figure>` : '' } 530 531 <div class="story-content"> 532 533 ${ activePostType !== 'press-article' && catLinks 534 ? `<div class="taxonomy-category lower-label wp-block-post-terms"> 535 ${ catLinks } 536 </div>` 537 : '' 538 } 539 540 <h3 class="wp-block-post-title"> 541 <a href="${ esc( post.link ) }">${ post.title.rendered }</a> 542 </h3> 543 <div class="wp-block-post-excerpt"> 544 <p>${ excerpt }â¦</p> 545 </div> 546 547 ${ schedule } 548 549 <div class="wp-block-group meta"> 550 <a class="wp-block-read-more resource-cta is-${ esc( activePostType ) }" href="${ esc( ctaUrl ) }"> 551 ${ esc( ctaLabel ) } 552 <span class="screen-reader-text">: ${ post.title.rendered }</span> 553 </a> 554 ${ showDate ? `<div class="wp-block-post-date"> 555 <time datetime="${ esc( post.date ) }">${ date }</time> 556 </div>` : '' } 557 </div> 558 559 </div> 560 </div> 561 </li>`; 562 } 563 564 // ââ Donation Example card renderer âââââââââââââââââââââââââââââââââââ 565 // Used when activePostType === 'donation-example'. Reads custom REST 566 // fields (organization_name, campaign_link) registered in donation-examples.php, 567 // plus wp:term groups for organization_type (badge) and fundraising_features (pills). 568 569 function renderDonationExampleCard( post ) { 570 const terms = post._embedded?.[ 'wp:term' ] ?? []; 571 const orgTypes = terms.find( g => g[0]?.taxonomy === 'organization_type' ) ?? []; 572 const features = terms.find( g => g[0]?.taxonomy === 'fundraising_features' ) ?? []; 573 const media = post._embedded?.[ 'wp:featuredmedia' ]?.[0]; 574 const campaignLink = post.campaign_link || post.link; 575 const orgName = post.organization_name || ''; 576 const excerpt = ( post.excerpt?.rendered ?? '' ) 577 .replace( /<[^>]+>/g, '' ) 578 .replace( /…/g, 'â¦' ) 579 .slice( 0, 140 ); 580 581 const orgBadge = orgTypes[0] 582 ? `<span class="fundraiser-card__org-badge">${ esc( orgTypes[0].name ) }</span>` 583 : ''; 584 585 const featurePills = features.map( 586 f => `<span class="fundraiser-card__feature-pill">${ esc( f.name ) }</span>` 587 ).join( '' ); 588 589 const imageHtml = media 590 ? `<figure class="fundraiser-card__image" style="aspect-ratio:16/9;position:relative;"> 591 <a href="${ esc( campaignLink ) }" tabindex="-1" aria-hidden="true" target="_blank" rel="noopener noreferrer"> 592 <img src="${ esc( media.source_url ) }" 593 alt="" 594 loading="lazy" 595 style="width:100%;height:100%;object-fit:cover;display:block;"> 596 </a> 597 ${ orgBadge } 598 </figure>` 599 : ''; 600 601 return `<li class="wp-block-post"> 602 <div class="story-card fundraiser-card"> 603 604 ${ imageHtml } 605 606 ${ featurePills 607 ? `<div class="fundraiser-card__features">${ featurePills }</div>` 608 : '' 609 } 610 611 <div class="story-content"> 612 <h3 class="wp-block-post-title"> 613 <a href="${ esc( campaignLink ) }" target="_blank" rel="noopener noreferrer"> 614 ${ esc( post.title?.rendered ?? '' ) } 615 </a> 616 </h3> 617 ${ orgName 618 ? `<p class="fundraiser-card__org-name">${ esc( orgName ) }</p>` 619 : '' 620 } 621 ${ excerpt 622 ? `<div class="wp-block-post-excerpt"><p>${ esc( excerpt ) }â¦</p></div>` 623 : '' 624 } 625 </div> 626 627 </div> 628 </li>`; 629 } 630 631 // ââ Multi-type card renderer âââââââââââââââââââââââââââââââââââââââââ 632 // Renders an item from the momentive/v1/resources REST route (a flat 633 // shape â see momentive_resource_to_rest_item() in inc/resources.php), 634 // not a core /wp/v2/{type} response, so it can't share renderCard() 635 // above directly. Top label always shows the post type's singular 636 // label (item.type_label) rather than a category name: results here 637 // span several types, so the type is the discriminating piece of 638 // info, same reasoning momentive/solution-resources uses server-side. 639 function renderMultiTypeCard( item ) { 640 const date = new Date( item.date ).toLocaleDateString( 'en-US', { 641 month: 'long', day: 'numeric', year: 'numeric', 642 } ); 643 644 const catLinks = ( item.categories || [] ) 645 .map( renderCategoryLink ) 646 .join( '<span class="wp-block-post-terms__separator"> </span>' ); 647 648 // resource_card_meta is added to the /momentive/v1/resources response 649 // by momentive_resource_to_rest_item() (inc/resources.php). Use it for 650 // type-aware labels, schedule, CTA, and date visibility; fall back to 651 // the previous static type_label / "Read more" behaviour when absent. 652 const cardMeta = item.resource_card_meta; 653 654 const topLabel = cardMeta 655 ? cardMeta.top_label_html 656 : ( item.type_label 657 ? `<p class="top-label wp-block-paragraph">${ esc( item.type_label ) }</p>` 658 : '' ); 659 660 const schedule = cardMeta?.schedule_html ?? '';
661 const ctaLabel = cardMeta?.cta_label ?? 'Read more'; 662 const ctaUrl = cardMeta?.cta_url ?? item.link; 663 const showDate = cardMeta ? cardMeta.show_date : true; 664 665 return `<li class="wp-block-post"> 666 <div class="wp-block-group story-card"> 667 668 ${ topLabel } 669 670 <figure class="wp-block-post-featured-image" style="aspect-ratio:16/9"> 671 <a href="${ esc( item.link ) }" tabindex="-1" aria-hidden="true"> 672 <img src="${ esc( item.featured_image || ( window.momentiveResourceFilters?.themeUri + '/assets/images/default-card-image.webp' ) ) }" 673 alt="" 674 loading="lazy" 675 style="width:100%;height:100%;object-fit:cover;"> 676 </a> 677 </figure> 678 679 <div class="story-content"> 680 681 ${ catLinks 682 ? `<div class="taxonomy-category lower-label wp-block-post-terms"> 683 ${ catLinks } 684 </div>` 685 : '' 686 } 687 688 <h3 class="wp-block-post-title"> 689 <a href="${ esc( item.link ) }">${ esc( item.title ) }</a> 690 </h3> 691 <div class="wp-block-post-excerpt"> 692 <p>${ esc( item.excerpt ) }</p> 693 </div> 694 695 ${ schedule } 696 697 <div class="wp-block-group meta"> 698 <a class="wp-block-read-more resource-cta" href="${ esc( ctaUrl ) }"> 699 ${ esc( ctaLabel ) } 700 <span class="screen-reader-text">: ${ esc( item.title ) }</span> 701 </a> 702 ${ showDate ? `<div class="wp-block-post-date"> 703 <time datetime="${ esc( item.date ) }">${ date }</time> 704 </div>` : '' } 705 </div> 706 707 </div> 708 </div> 709 </li>`; 710 } 711 712 713 // ââ URL deep-linking ââââââââââââââââââââââââââââââââââââââââââââââââââ 714 // Serializes the current filter state into URL query params and updates 715 // the browser URL via replaceState (no history entry per filter change). 716 // Param names: cat=<id,id>, type=<slug,slug>, <tax_slug>=<id,id>, 717 // search=<term>, sort=<orderby-order>. 718 // The sort param is omitted when it matches the archive's default so 719 // links stay clean for the common case. 720 721 function updateURL() { 722 const params = new URLSearchParams( window.location.search ); 723 const managed = [ 'cat', 'type', 'search', 'sort' ]; 724 customTaxonomies.forEach( t => managed.push( t.slug ) ); 725 managed.forEach( k => params.delete( k ) ); 726 727 if ( state.categories.length ) params.set( 'cat', state.categories.join( ',' ) ); 728 // Omit ?type= when all configured types are selected â that's the 729 // default "no filter" state and the URL should stay clean. 730 if ( state.postTypes.length && ! allTypesInput?.checked ) { 731 params.set( 'type', state.postTypes.join( ',' ) ); 732 } 733 customTaxonomies.forEach( t => { 734 const ids = state.customTaxFilters[ t.slug ] || []; 735 if ( ids.length ) params.set( t.slug, ids.join( ',' ) ); 736 } ); 737 if ( state.search ) params.set( 'search', state.search ); 738 const defaultSort = isAccordion ? 'menu_order-asc' : 'date-desc'; 739 const currentSort = `${ state.orderby }-${ state.order }`; 740 if ( currentSort !== defaultSort ) params.set( 'sort', currentSort ); 741 742 const qs = params.toString(); 743 const newURL = window.location.pathname + ( qs ? '?' + qs : '' ) + window.location.hash; 744 history.replaceState( null, '', newURL ); 745 } 746 747 // Reads URL params, pre-checks matching inputs, seeds state. 748 // Returns true when at least one filter param was present. 749 750 function applyURLState() { 751 const params = new URLSearchParams( window.location.search ); 752 let changed = false; 753 754 const catParam = params.get( 'cat' ); 755 if ( catParam ) { 756 state.categories = catParam.split( ',' ).filter( Boolean ); 757 state.categories.forEach( id => { 758 const input = bar.querySelector( `input[name="category"][value="${ id }"]` ); 759 if ( input ) input.checked = true; 760 } ); 761 changed = true; 762 } 763 764 const typeParam = params.get( 'type' ); 765 if ( typeParam ) { 766 state.postTypes = typeParam.split( ',' ).filter( Boolean ); 767 state.postTypes.forEach( slug => { 768 const input = bar.querySelector( `input[name="post_type"][value="${ slug }"]` ); 769 if ( input ) input.checked = true; 770 } ); 771 changed = true; 772 } 773
774 customTaxonomies.forEach( tax => { 775 const taxParam = params.get( tax.slug ); 776 if ( taxParam ) { 777 state.customTaxFilters[ tax.slug ] = taxParam.split( ',' ).filter( Boolean ); 778 state.customTaxFilters[ tax.slug ].forEach( id => { 779 const input = bar.querySelector( `input[name="${ tax.slug }"][value="${ id }"]` ); 780 if ( input ) input.checked = true; 781 } ); 782 changed = true; 783 } 784 } ); 785 786 const searchParam = params.get( 'search' ); 787 if ( searchParam ) { 788 state.search = searchParam; 789 if ( searchInput ) searchInput.value = searchParam; 790 changed = true; 791 } 792 793 const sortParam = params.get( 'sort' ); 794 if ( sortParam ) { 795 const parts = sortParam.match( /^(.+)-(asc|desc)$/ ); 796 if ( parts ) { 797 state.orderby = parts[1]; 798 state.order = parts[2]; 799 if ( sortSelect ) sortSelect.value = sortParam; 800 changed = true; 801 } 802 } 803 804 if ( changed ) syncUI(); 805 return changed; 806 } 807 808 // ââ UI sync âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 809 810 function syncUI() { 811 const customTaxActive = Object.values( state.customTaxFilters ).reduce( ( n, ids ) => n + ids.length, 0 ); 812 // When "All types" is checked, post-type selection isn't a real filter. 813 const postTypesActive = allTypesInput?.checked ? 0 : state.postTypes.length; 814 const totalActive = state.categories.length + postTypesActive + 815 customTaxActive + ( state.search ? 1 : 0 ); 816 const hasActive = totalActive > 0; 817 818 if ( countBadge ) { 819 countBadge.textContent = totalActive; 820 countBadge.hidden = ! hasActive; 821 } 822 823 // Per-dropdown badges â post_type, category, and custom taxonomies. 824 bar.querySelectorAll( '.filter-dropdown' ).forEach( dropdown => { 825 const taxSlug = dropdown.dataset.tax; 826 let count; 827 if ( taxSlug === 'post_type' ) { 828 count = allTypesInput?.checked ? 0 : state.postTypes.length; 829 } else if ( taxSlug === 'category' ) { 830 count = state.categories.length; 831 } else { 832 count = ( state.customTaxFilters[ taxSlug ] || [] ).length; 833 } 834 const badge = dropdown.querySelector( '.filter-count' ); 835 if ( badge ) { 836 badge.textContent = String( count ); 837 badge.hidden = count === 0; 838 } 839 } ); 840 841 if ( resetBtn ) resetBtn.hidden = ! hasActive; 842 843 renderActiveTags(); 844 updateURL(); 845 } 846 847 function renderActiveTags() { 848 if ( ! activeTags ) return; 849 850 const tags = []; 851 852 bar.querySelectorAll( 'input[name="category"]:checked' ).forEach( el => { 853 tags.push( { 854 label: el.closest( 'label' )?.querySelector( '.filter-item-label' )?.textContent?.trim(), 855 value: el.value, 856 name: 'category', 857 } ); 858 } ); 859 860 bar.querySelectorAll( 'input[name="post_type"]:checked' ).forEach( el => { 861 tags.push( { 862 label: el.closest( 'label' )?.querySelector( '.filter-item-label' )?.textContent?.trim(), 863 value: el.value, 864 name: 'post_type', 865 } ); 866 } ); 867 868 customTaxonomies.forEach( tax => { 869 bar.querySelectorAll( `input[name="${ tax.slug }"]:checked` ).forEach( el => { 870 tags.push( { 871 label: el.closest( 'label' )?.querySelector( '.filter-item-label' )?.textContent?.trim(), 872 value: el.value, 873 name: tax.slug, 874 } ); 875 } ); 876 } ); 877 878 if ( state.search ) { 879 tags.push( { label: `"${ state.search }"`, value: '__search__', name: 'search' } ); 880 } 881 882 activeTags.innerHTML = tags.map( tag => ` 883 <span class="active-tag"> 884 ${ esc( tag.label ) } 885 <button type="button" 886 data-name="${ esc( tag.name ) }" 887 data-value="${ esc( tag.value ) }" 888 aria-label="Remove filter: ${ esc( tag.label ) }">Ã</button> 889 </span> 890 ` ).join( '' ); 891 892 activeTags.querySelectorAll( 'button' ).forEach( btn => { 893 btn.addEventListener( 'click', () => { 894 const { name, value } = btn.dataset; 895 if ( name === 'search' ) { 896 if ( searchInput ) searchInput.value = ''; 897 state.search = ''; 898 } else { 899 const input = bar.querySelector( 900 `input[name="${ name }"][value="${ value }"]` 901 ); 902 if ( input ) { 903 input.checked = false;
904 input.dispatchEvent( new Event( 'change', { bubbles: true } ) ); 905 return; 906 } 907 } 908 state.page = 1; 909 syncUI(); 910 fetchPosts(); 911 } ); 912 } ); 913 } 914 915 function buildAccordionItem( post ) { 916 const itemId = 'accordion-item-' + post.id; 917 const panelId = itemId + '-panel'; 918 919 // Pull category name from embedded terms (requires _embed=true in the fetch). 920 const terms = post._embedded?.[ 'wp:term' ] ?? []; 921 const cats = terms.find( group => group[0]?.taxonomy === 'category' ) ?? []; 922 const catName = cats[0]?.name ?? ''; 923 const catSlug = catName ? catName.toLowerCase().replace( /\s+/g, '-' ) : ''; 924 const color = cats[0]?.tag_color ?? ''; 925 926 const catHtml = catName 927 ? `<span class="accordion-category" data-category="${ esc( catSlug ) }">${ esc( catName ) }</span>` 928 : ''; 929 930 return `<div class="accordion-item"${ color ? ` style="--category-color: ${ esc( color ) }"` : '' }> 931 <button class="accordion-trigger" type="button" 932 aria-expanded="false" 933 aria-controls="${ esc( panelId ) }" 934 id="${ esc( itemId ) }" data-init> 935 <span class="accordion-question">${ post.title?.rendered ?? '' }</span> 936 ${ catHtml } 937 <span class="accordion-chevron" aria-hidden="true"> 938 <svg viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg"> 939 <path d="M1.5 4L6 8L10.5 4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round"/> 940 </svg> 941 </span> 942 </button> 943 <div class="accordion-panel" id="${ esc( panelId ) }" 944 role="region" aria-labelledby="${ esc( itemId ) }" hidden> 945 <div class="accordion-panel-inner">${ post.content?.rendered ?? '' }</div> 946 </div> 947 </div>`; 948 } 949 950 function openItem( trigger ) { 951 const panel = document.getElementById( trigger.getAttribute( 'aria-controls' ) ); 952 trigger.setAttribute( 'aria-expanded', 'true' ); 953 trigger.closest( '.accordion-item' )?.classList.add( 'is-open' ); 954 if ( panel ) panel.removeAttribute( 'hidden' ); 955 } 956 957 function closeItem( trigger ) { 958 const panel = document.getElementById( trigger.getAttribute( 'aria-controls' ) ); 959 trigger.setAttribute( 'aria-expanded', 'false' ); 960 trigger.closest( '.accordion-item' )?.classList.remove( 'is-open' ); 961 if ( panel ) panel.setAttribute( 'hidden', '' ); 962 } 963 964 function wireAccordionTrigger( trigger ) { 965 trigger.addEventListener( 'click', function () { 966 const isOpen = trigger.getAttribute( 'aria-expanded' ) === 'true'; 967 isOpen ? closeItem( trigger ) : openItem( trigger ); 968 } ); 969 } 970 971 } 972 973 // ââ Boot ââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 974 975 document.addEventListener( 'DOMContentLoaded', () => { 976 document.querySelectorAll( '.resource-filter-bar' ).forEach( initFilterBar ); 977 initLowerLabels( document ); 978 } ); 979 980} () );
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.