1/* global jQuery, HVACPF */ 2(function(window, $){ 3 'use strict'; 4 if (typeof HVACPF === 'undefined') return; 5 6 7 8 const $root = $('#hvacpf'); 9 const $brandsEl = $('#hvacpf-brands-list'); 10 const $catsEl = $('#hvacpf-cats-list'); 11 const $grid = $('#hvacpf-products-grid'); 12 const $search = $('#hvacpf-search'); 13 const $countB = $('#hvacpf-brands-count'); 14 const $countC = $('#hvacpf-cats-count'); 15 const $countP = $('#hvacpf-products-count'); 16 const $more = $('#hvacpf-load-more'); 17 const headerOffset = parseInt(HVACPF.scrollOffset || 0, 10); // e.g. set to 150 in localized data if you want 18 19 // --- NEW: sentinel right after the grid (no class name changes) 20 let $sentinel = $('#hvacpf-sentinel'); 21 if (!$sentinel.length) { 22 $sentinel = $('<div id="hvacpf-sentinel" aria-hidden="true" style="height:1px;"></div>'); 23 $grid.after($sentinel); 24 } 25 26 // State 27 const perPage = parseInt(HVACPF.per_page || 12, 10); 28 let activeBrand = parseInt(HVACPF.initial.brand_id || 0, 10); // 0 = All Brands 29 let activeCat = parseInt(HVACPF.initial.categoryId || 0, 10); // 0 = All Categories 30 let page = 1; 31 let loading = false; 32 let hasMore = true; 33 let q = ''; 34 let activeRequest = null; 35 36 // Selection (what admin allowed) 37 const selection = HVACPF.selection || { brands: [], categories: {}, products: {} }; 38 const brandsAll = (HVACPF.brands || []).filter(b => selection.brands.includes(parseInt(b.id,10))); 39 const categoriesAll = HVACPF.categories || []; 40 41 // Helpers 42 function esc(s){ return String(s||'').replace(/[&<>\"']/g, m => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m])); } 43 function toInt(v){ const n = parseInt(v,10); return isNaN(n) ? 0 : n; } 44 45 function brandNameById(id){ 46 if (!id) return 'All Brands'; 47 const b = brandsAll.find(x => toInt(x.id) === toInt(id)); 48 return b ? b.name : 'Brand #'+id; 49 } 50 function categoryById(id){ 51 if (!id) return { id: 0, name: 'All Categories', slug: 'all' }; 52 const c = categoriesAll.find(x => toInt(x.id) === toInt(id)); 53 return c ? c : { id, name: 'Category #'+id, slug: '' }; 54 } 55 function categorySlugById(id){ 56 const c = categoryById(id); 57 return c.slug || 'all'; 58 } 59 60 function scrollToRoot(){ 61 const y = Math.max(0, $grid.offset().top - headerOffset); 62 // prefers-reduced-motion friendly 63 const reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; 64 if ('scrollBehavior' in document.documentElement.style && !reduce) { 65 window.scrollTo({ top: y, behavior: 'smooth' }); 66 } else { 67 $('html, body').stop(true).animate({ scrollTop: y }, 500); 68 } 69 } 70 71 // URL sync ?category=slug&brand_id=N 72 function updateURL(){ 73 const slug = categorySlugById(activeCat); 74 const urlBase = HVACPF.urlBase || (window.location.origin + window.location.pathname); 75 const newQS = '?category=' + encodeURIComponent(slug) + '&brand_id=' + encodeURIComponent(activeBrand); 76 const newHref = urlBase + newQS; 77 window.history.replaceState({}, '', newHref); 78 } 79 80 // Build Brands list (with "All Brands") 81 function renderBrands(){ 82 $brandsEl.empty(); 83 84 const allItem = $( 85 '<button type="button" class="hvacpf-pill '+(activeBrand===0?'is-active':'')+'" data-id="0">'+ 86 '<span class="pill-name">All Brands</span>'+ 87 '</button>' 88 ); 89 $brandsEl.append(allItem); 90 91 brandsAll.forEach(b => { 92 const el = $( 93 '<button type="button" class="hvacpf-pill '+(activeBrand===toInt(b.id)?'is-active':'')+'" data-id="'+b.id+'">'+ 94 (b.logo ? '<span class="pill-logo"><img src="'+esc(b.logo)+'" alt="'+esc(b.name)+'"></span>' : '')+ 95 '<span class="pill-name">'+esc(b.name)+'</span>'+ 96 '</button>'); 97 $brandsEl.append(el); 98 }); 99 100 const total = 1 + brandsAll.length; 101 $countB.text(total + ' brand' + (total===1?'':'s')); 102 } 103 104 // Build Categories list (depending on active brand) 105function renderCategories(){ 106 $catsEl.empty(); 107 108 // Helper: build ordered list of category IDs for the current brand scope 109 function getOrderedCatIds(){ 110 const seen = new Set(); 111 const out = []; 112 113 if (activeBrand === 0) { 114 // All Brands: walk brands in saved order, then each brand's categories in saved order
115 (selection.brands || []).forEach(bid => { 116 const list = selection.categories[bid] || []; 117 list.forEach(cid => { 118 const key = String(cid); 119 if (!seen.has(key)) { seen.add(key); out.push(parseInt(cid,10)); } 120 }); 121 }); 122 } else { 123 // Single brand: use its saved order directly 124 (selection.categories[activeBrand] || []).forEach(cid => { 125 out.push(parseInt(cid,10)); 126 }); 127 } 128 return out; 129 } 130 131 const orderedCatIds = getOrderedCatIds(); 132 133 // "All Categories" 134 const allBtn = $( 135 '<button type="button" class="hvacpf-pill '+(activeCat===0?'is-active':'')+'" data-id="0">'+ 136 '<span class="pill-name">All Categories</span>'+ 137 '</button>' 138 ); 139 $catsEl.append(allBtn); 140 141 // Render each category in the computed order 142 const byId = {}; 143 (HVACPF.categories || []).forEach(c => { byId[parseInt(c.id,10)] = c; }); 144 145 orderedCatIds.forEach(id => { 146 const c = byId[id]; 147 if (!c) return; 148 const el = $( 149 '<button type="button" class="hvacpf-pill '+(activeCat===id?'is-active':'')+'" data-id="'+id+'">'+ 150 '<span class="pill-name">'+esc(c.name||('Category #'+id))+'</span>'+ 151 '</button>' 152 ); 153 $catsEl.append(el); 154 }); 155 156 const total = 1 + orderedCatIds.length; // includes "All Categories" 157 $countC.text(total + ' categor' + (total===1?'y':'ies')); 158} 159 160 161 162 // Card builder 163 function productCard(p){ 164 const img = p.image 165 ? '<div class="card-thumb"><img src="'+esc(p.image)+'" alt="'+esc(p.title)+'" loading="lazy" decoding="async"></div>' 166 : '<div class="card-thumb card-thumb--placeholder"></div>'; 167 168 const href = (HVACPF.productUrl || '/products/product/') + '?id=' + encodeURIComponent(p.id); 169 170 return $( 171 '<article class="hvacpf-card" data-id="'+p.id+'">'+ 172 '<a class="card-link" href="'+href+'">'+ 173 img+ 174 '<h3 class="card-title">'+esc(p.title)+'</h3>'+ 175 '</a>'+ 176 '</article>' 177 ); 178 } 179 180 function resetGrid(){ 181 page = 1; 182 hasMore = true; 183 $grid.empty(); 184 $countP.text(''); 185 } 186 187 function setLoading(state){ 188 loading = !!state; 189 $more.toggle(loading || hasMore); 190 $more.find('.hvacpf-spinner').toggle(!!state); 191 if (!hasMore && !loading) { 192 $more.text('No more products'); 193 } else { 194 $more.html('<span class="hvacpf-spinner"></span> Loading more productsâ¦'); 195 } 196 } 197 198 // --- NEW: Keep fetching until content exceeds viewport (or no more) 199 function autoFill(){ 200 // Avoid tight loops; re-check after each fetch completes 201 if (loading || !hasMore) return; 202 const scrollable = $(document).height() > ($(window).height() + 50); 203 if (!scrollable) { 204 fetchPage(page + 1).then(function(){ 205 // If still not scrollable and more exists, keep going 206 setTimeout(autoFill, 0); 207 }); 208 } 209 } 210 211 function fetchPage(nextPage){ 212 if (loading) { 213 if (nextPage === 1 && activeRequest && activeRequest.readyState !== 4) { 214 activeRequest.abort(); 215 loading = false; 216 } else { 217 return $.Deferred().resolve().promise(); 218 } 219 } 220 if (!hasMore && nextPage > 1) return $.Deferred().resolve().promise(); 221 222 setLoading(true); 223 const request = $.post(HVACPF.ajax, { 224 action: 'hvac_products_query', 225 nonce: HVACPF.nonce, 226 brand_id: activeBrand, 227 cat_id: activeCat, 228 q: q, 229 page: nextPage, 230 per_page: perPage 231 }); 232 activeRequest = request; 233 234 return request.then(function(res){ 235 if (!res || !res.success) return; 236 const data = res.data || {}; 237 const items = data.items || []; 238 // Ensure boolean 239 hasMore = !!data.has_more; 240 241 items.forEach(it => $grid.append(productCard(it))); 242 243 const total = parseInt(data.total || 0, 10); 244 $countP.text(total + ' product' + (total === 1 ? '' : 's')); 245 246 page = nextPage; 247 }).always(function(){ 248 if (activeRequest === request) { 249 activeRequest = null; 250 }
251 setLoading(false); 252 }); 253 } 254 255 // Initial renders 256 renderBrands(); 257 renderCategories(); 258 resetGrid(); 259 fetchPage(1).then(function(){ 260 updateURL(); 261 autoFill(); // --- NEW: ensure we have enough items to enable scrolling 262 }); 263 264 // Interactions 265 $brandsEl.on('click', '.hvacpf-pill', function(){ 266 const id = toInt($(this).data('id')); 267 if (id === activeBrand) return; 268 activeBrand = id; 269 activeCat = 0; 270 q = ''; $search.val(''); 271 renderBrands(); 272 renderCategories(); 273 resetGrid(); 274 scrollToRoot(); 275 fetchPage(1).then(function(){ 276 updateURL(); 277 autoFill(); 278 }); 279 }); 280 281 $catsEl.on('click', '.hvacpf-pill', function(){ 282 const id = toInt($(this).data('id')); 283 if (id === activeCat) return; 284 activeCat = id; 285 q = ''; $search.val(''); 286 renderCategories(); 287 resetGrid(); 288 scrollToRoot(); 289 fetchPage(1).then(function(){ 290 updateURL(); 291 autoFill(); 292 }); 293 }); 294 295 let searchTimer = null; 296 297 // Live search for products 298 $search.on('input', function(){ 299 q = $(this).val() || ''; 300 clearTimeout(searchTimer); 301 searchTimer = setTimeout(function(){ 302 resetGrid(); 303 fetchPage(1).then(autoFill); 304 }, 250); 305 }).on('keydown', function(e){ 306 if (e.key === 'Enter') e.preventDefault(); 307 }); 308 309 // --- NEW: IntersectionObserver for robust infinite load 310 if ('IntersectionObserver' in window) { 311 const io = new IntersectionObserver((entries) => { 312 if (loading || !hasMore) return; 313 if (entries.some(e => e.isIntersecting)) { 314 fetchPage(page + 1); 315 } 316 }, { root: null, rootMargin: '300px 0px', threshold: 0 }); 317 io.observe($sentinel[0]); 318 } 319 320 // Existing scroll fallback (kept) 321 const preloadOffset = 300; 322 $(window).on('scroll.hvacpf', function(){ 323 if (loading || !hasMore) return; 324 const scrollBottom = $(window).scrollTop() + $(window).height(); 325 const docHeight = $(document).height(); 326 if (docHeight - scrollBottom < preloadOffset) { 327 fetchPage(page + 1); 328 } 329 }); 330 331 332 333 334var $wrap = $('#hvacpf-product'); 335if (!$wrap.length) return; 336 337var $desc = $('#description'); 338var $feat = $('#features'); 339 340// If tabs/pills were removed, just ensure both are visible and optionally scroll to hash. 341if (!$desc.length && !$feat.length) return; 342 343$desc.show().addClass('is-active'); 344$feat.show().addClass('is-active'); 345 346// Optional: support direct links with hash without toggling visibility 347var hash = (location.hash || '').toLowerCase(); 348if (hash === '#features' && $feat.length) { 349 setTimeout(function(){ 350 var top = $feat.offset().top - Math.max(0, headerOffset || 0); 351 $('html, body').stop(true).animate({ scrollTop: top }, 300); 352 }, 0); 353} else if (hash === '#description' && $desc.length) { 354 setTimeout(function(){ 355 var top2 = $desc.offset().top - Math.max(0, headerOffset || 0); 356 $('html, body').stop(true).animate({ scrollTop: top2 }, 300); 357 }, 0); 358} 359 360 function showTab(tabKey, doScroll) { 361 // Toggle pills 362 $pills.removeClass('is-active').attr('aria-selected', 'false') 363 .filter('[data-tab="'+tabKey+'"]').addClass('is-active').attr('aria-selected', 'true'); 364 365 // Toggle content sections 366 var $desc = $('#description'); // <section id="description" data-tab="desc"> 367 var $feat = $('#features'); // <section id="features" data-tab="features"> 368 var $target = tabKey === 'features' ? $feat : $desc; 369 370 $desc.removeClass('is-active').hide(); 371 $feat.removeClass('is-active').hide(); 372 $target.addClass('is-active').show(); 373 374 // Update hash without jump 375 if (history.replaceState) { 376 history.replaceState(null, '', tabKey === 'features' ? '#features' : '#description'); 377 } 378 379 // Smooth scroll to the section ID (not the buttons) 380 if (doScroll !== false && $target.length) { 381 var top = $target.offset().top - Math.max(0, offset); 382 $('html, body').stop(true).animate({ scrollTop: top }, 300); 383 } 384 } 385 386 // Clicks on the pills 387 $wrap.on('click', '.hvacpf-pills .hvacpf-pill', function (e) { 388 e.preventDefault(); 389 var key = $(this).data('tab'); // 'desc' | 'features'
390 showTab(key === 'features' ? 'features' : 'desc', true); 391 }); 392 393 // On load: respect hash (#features | #description) 394 var hash = (location.hash || '').toLowerCase(); 395 if (hash === '#features') { 396 setTimeout(function(){ showTab('features', true); }, 0); 397 } else if (hash === '#description') { 398 setTimeout(function(){ showTab('desc', true); }, 0); 399 } else { 400 // Initialize without scrolling 401 var initial = $pills.filter('.is-active').data('tab') || 'desc'; 402 showTab(initial, false); 403 } 404 405 406})(window, 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.