1/** 2 * REST API Load More on Click - reads config from data attributes 3 * Uses only WordPress built-in REST endpoints 4 * OMR-193 5 */ 6 7class PureRestLoadMore { 8 constructor(containerElement) { 9 this.debug = window.loadMoreGlobals.debug || false; 10 11 this.container = containerElement; 12 this.settings = this.parseDataAttributes(containerElement); 13 14 this.allFetchedPosts = []; 15 this.filteredPosts = []; 16 this.currentlyShown = 0; 17 this.currentPage = 1; 18 this.totalPosts = 0; 19 this.loading = false; 20 this.allPagesFetched = false; 21 22 this.serverRenderedCount = 0; // <-- FIX 1: Add property to track server-rendered count 23 24 this.init(); 25 } 26 27 parseDataAttributes(element) { 28 const dataset = element.dataset; 29 30 return { 31 postTypes: JSON.parse(dataset.postTypes || '["post"]'), 32 taxonomy: dataset.taxonomy || null, 33 termId: dataset.termId ? parseInt(dataset.termId) : null, 34 author: dataset.author ? parseInt(dataset.author) : null, 35 initialCount: parseInt(dataset.initialCount || 17), 36 loadMoreCount: parseInt(dataset.loadMoreCount || 12), 37 perPage: parseInt(dataset.perPage || 50), 38 excludeTaxonomies: JSON.parse(dataset.excludeTaxonomies || '{}'), 39 excludePosts: JSON.parse(dataset.excludePosts || '[]'), 40 showPostType: dataset.showPostType === 'true', 41 containerSelector: '.rest-posts-container', 42 buttonSelector: '.load-more-btn', 43 spinnerSelector: '.load-more-spinner', 44 endMessageSelector: '.load-more-end' 45 }; 46 } 47 48 async init() { 49 if(this.debug) console.log('Pure REST API Load More initialized:', this.settings); 50 if(this.debug) console.log('Load more count setting:', this.settings.loadMoreCount); 51 // Check if posts are already server-rendered 52 const existingPosts = this.container.querySelectorAll('.rest-item'); 53 54 if (existingPosts.length > 0) { 55 if(this.debug) console.log(`Found ${existingPosts.length} server-rendered posts`); 56 57 // FIX 2a: Set the baseline count 58 this.serverRenderedCount = existingPosts.length; 59 60 if (this.settings.excludePosts.length > 0) { 61 // We are on a "complex" page (market, single) that uses exclude_posts. 62 // The API list will already be offset, so the JS should start its 63 // slice index from 0. 64 if(this.debug) console.log('Exclude mode: setting currentlyShown to 0'); 65 this.currentlyShown = 0; // <-- KEEP THIS LINE (REQUIRED FOR FIRST SLICE INDEX) 66 } else { 67 // We are on a "simple" archive (category, author). 68 // The API list is not offset, so the JS must use the 69 // server post count (e.g., 17) as its offset. 70 if(this.debug) console.log('Simple mode: setting currentlyShown to server count'); 71 this.currentlyShown = existingPosts.length; 72 } 73 74 // Set current page to 0. The first "load more" click will 75 // increment this to 1 and fetch the first page of results. 76 this.currentPage = 0; 77 78 // Set up for load more without initial fetch 79 this.updateLoadMoreButton(); 80 this.bindEvents(); 81 82 // Show the load more button since we have server posts 83 this.showButton(); 84 } else { 85 if(this.debug) console.log('No server posts found, using JavaScript-only mode'); 86 // Original JavaScript-only approach 87 await this.fetchInitialData(); 88 this.filterPosts(); 89 await this.ensureEnoughFilteredPosts(this.settings.initialCount); 90 this.showInitialPosts(); 91 this.bindEvents(); 92 } 93 } 94 95 buildApiUrl(page = 1) { 96 const params = new URLSearchParams({ 97 post_type: this.settings.postTypes, 98 per_page: this.settings.perPage, 99 page: page, 100 orderby: 'date', 101 order: 'desc', 102 }); 103 104 if (this.settings.taxonomy && this.settings.termId) { 105 params.append('taxonomy', this.settings.taxonomy); 106 params.append('term_id', this.settings.termId); 107 } 108 if(this.settings.author) { 109 params.append('coauthors[]', this.settings.author); 110 } 111 112 if (Object.keys(this.settings.excludeTaxonomies).length > 0) { 113 // Encode it as a JSON string to be parsed by PHP 114 params.append('exclude_taxonomies', JSON.stringify(this.settings.excludeTaxonomies)); 115 } 116 117 if (this.settings.excludePosts && this.settings.excludePosts.length > 0) { 118 // Pass as JSON string, similar to exclude_taxonomies 119 params.append('exclude_posts', JSON.stringify(this.settings.excludePosts)); 120 } 121 122 return `${window.loadMoreGlobals.restUrl}multi-posts?${params.toString()}`; 123 } 124 125 async fetchPage(page) { 126 if (this.allPagesFetched) { 127 return { success: false, posts: [] }; 128 } 129 130 if(this.debug) console.log(`Making API call for page ${page}`); 131
132 try { 133 const url = this.buildApiUrl(page); 134 if(this.debug) console.log(`API URL: ${url}`); 135 136 const response = await fetch(url); 137 138 if (!response.ok) { 139 if (response.status === 400) { 140 if(this.debug) console.log('Page not found, marking all pages as fetched'); 141 this.allPagesFetched = true; 142 return { success: false, posts: [] }; 143 } 144 throw new Error(`HTTP ${response.status}: ${response.statusText}`); 145 } 146 147 const data = await response.json(); 148 149 this.totalPosts = data.total; 150 this.allPagesFetched = !data.has_more || data.posts.length === 0; 151 152 if(this.debug) console.log(`API Response:`, { 153 postsReturned: data.posts.length, 154 total: data.total, 155 hasMore: data.has_more, 156 allPagesFetched: this.allPagesFetched 157 }); 158 159 return { 160 success: true, 161 posts: data.posts, 162 hasMore: data.has_more 163 }; 164 165 } catch (error) { 166 console.error(`Failed to fetch page ${page}:`, error); 167 return { success: false, posts: [] }; 168 } 169 } 170 171 async fetchInitialData() { 172 const result = await this.fetchPage(1); 173 if (result.success) { 174 this.allFetchedPosts.push(...result.posts); 175 this.currentPage = 1; 176 } 177 } 178 179 async fetchMoreData() { 180 if (this.allPagesFetched) { 181 if(this.debug) console.log('All pages already fetched'); 182 return false; 183 } 184 185 this.currentPage++; 186 if(this.debug) console.log(`Fetching page ${this.currentPage}`); 187 188 const result = await this.fetchPage(this.currentPage); 189 190 if (result.success && result.posts.length > 0) { 191 if(this.debug) console.log(`Successfully fetched ${result.posts.length} posts from page ${this.currentPage}`); 192 this.allFetchedPosts.push(...result.posts); 193 return true; 194 } 195 196 if(this.debug) console.log(`No posts returned from page ${this.currentPage}`); 197 return false; 198 } 199 200 filterPosts() { 201 if (Object.keys(this.settings.excludeTaxonomies).length === 0) { 202 this.filteredPosts = [...this.allFetchedPosts]; 203 return; 204 } 205 206 const beforeCount = this.filteredPosts.length; 207 208 this.filteredPosts = this.allFetchedPosts.filter(post => { 209 for (const [taxonomy, excludedTerms] of Object.entries(this.settings.excludeTaxonomies)) { 210 const postTerms = post.custom_taxonomies[taxonomy] || []; 211 212 if (excludedTerms.some(excludedTerm => postTerms.includes(excludedTerm))) { 213 return false; 214 } 215 } 216 return true; 217 }); 218 219 const newFiltered = this.filteredPosts.length - beforeCount; 220 if (newFiltered > 0) { 221 if(this.debug) console.log(`Filtered ${newFiltered} posts. Available: ${this.filteredPosts.length}/${this.allFetchedPosts.length}`); 222 } 223 } 224 225 async ensureEnoughFilteredPosts(needed) { 226 let attempts = 0; 227 const maxAttempts = 20; 228 229 if(this.debug) console.log(`ensureEnoughFilteredPosts: need ${needed}, have ${this.filteredPosts.length}`); 230 231 while ( 232 this.filteredPosts.length < needed && 233 !this.allPagesFetched && 234 attempts < maxAttempts 235 ) { 236 if(this.debug) console.log(`Attempt ${attempts + 1}: Fetching more data...`); 237 const success = await this.fetchMoreData(); 238 if (!success) { 239 if(this.debug) console.log('Failed to fetch more data, breaking'); 240 break; 241 } 242 243 this.filterPosts(); 244 attempts++; 245 246 if(this.debug) console.log(`After attempt ${attempts}: Have ${this.filteredPosts.length} filtered posts`); 247 } 248 249 if (attempts === 0) { 250 if(this.debug) console.log('No new API calls needed - using cached posts'); 251 } 252 } 253 254 showInitialPosts() { 255 const initialPosts = this.filteredPosts.slice(0, this.settings.initialCount); 256 this.renderPosts(initialPosts); 257 this.currentlyShown = initialPosts.length; 258 this.updateLoadMoreButton(); 259 if(this.debug) console.log(`Showing initial ${initialPosts.length} posts`); 260 } 261 262 async loadMore() { 263 if (this.loading) return; 264 265 if(this.debug) console.log('Load More clicked - Current state:', { 266 currentlyShown: this.currentlyShown, 267 filteredPostsAvailable: this.filteredPosts.length, 268 loadMoreCount: this.settings.loadMoreCount 269 }); 270 271 this.loading = true; 272 this.showSpinner(); 273 this.hideButton(); 274 275 // FIX 3a: Replace unreliable flag with a robust check for first click in exclude mode 276 const didExclude = (this.settings.excludePosts.length > 0); 277 const isFirstLoadMoreClick = (this.currentlyShown === 0 && didExclude); 278 279 // Check if we have enough cached posts to show 280 const postsNeeded = this.currentlyShown + this.settings.loadMoreCount; 281 282 // Fetch more data if we don't have enough cached posts 283 if (this.filteredPosts.length < postsNeeded && !this.allPagesFetched) { 284 await this.ensureEnoughFilteredPosts(postsNeeded); 285 } 286 287 // Get the next batch of posts to show (just the loadMoreCount amount) 288 289 // SLICING FIX: If it's the first click in exclude mode, start at 0. Otherwise, use the running total. 290 const startIndex = isFirstLoadMoreClick ? 0 : this.currentlyShown;
291 const endIndex = startIndex + this.settings.loadMoreCount; 292 const postsToShow = this.filteredPosts.slice(startIndex, endIndex); 293 294 if(this.debug) console.log(`Showing posts ${startIndex} to ${endIndex - 1} (${postsToShow.length} posts)`); 295 296 setTimeout(() => { 297 if (postsToShow.length > 0) { 298 this.renderPosts(postsToShow, true); // append = true 299 300 // FIX 3b: CORRECT COUNTER UPDATE LOGIC (CRITICAL STEP) 301 // The counter must track the index within the internal array, NOT the total visible count. 302 this.currentlyShown = this.currentlyShown + postsToShow.length; 303 304 if(this.debug) console.log(`Loaded ${postsToShow.length} more. Total shown: ${this.currentlyShown}`); 305 306 // Check if we can load more 307 const canLoadMore = this.currentlyShown < this.filteredPosts.length || !this.allPagesFetched; 308 309 if (canLoadMore) { 310 this.showButton(); 311 } else { 312 this.showEndMessage(); 313 } 314 } else { 315 this.showEndMessage(); 316 } 317 318 this.hideSpinner(); 319 this.loading = false; 320 }, 200); 321 } 322 323 renderPosts(posts, append = false) { 324 const container = this.container.querySelector(this.settings.containerSelector); 325 326 if (!container) return; 327 328 if (!append) { 329 container.innerHTML = ''; 330 } 331 332 posts.forEach(post => { 333 container.appendChild(this.createPostElement(post)); 334 }); 335 } 336 337 createPostElement(post) { 338 const template = document.getElementById('post-card-template'); 339 if (template) { 340 // Use the template system (Underscore.js or modern template literal) 341 const templateFn = _.template(template.innerHTML); 342 const html = templateFn(post); 343 344 const tempDiv = document.createElement('div'); 345 tempDiv.innerHTML = html; 346 return tempDiv.firstElementChild; 347 } 348 } 349 350 updateLoadMoreButton() { 351 const canLoadMore = this.currentlyShown < this.filteredPosts.length || !this.allPagesFetched; 352 353 if (canLoadMore) { 354 this.showButton(); 355 this.hideEndMessage(); 356 } else { 357 this.hideButton(); 358 this.showEndMessage(); 359 } 360 } 361 362 bindEvents() { 363 const button = this.container.querySelector(this.settings.buttonSelector); 364 365 if (button) { 366 button.addEventListener('click', (e) => { 367 e.preventDefault(); 368 this.loadMore(); 369 }); 370 } 371 } 372 373 showButton() { this.toggleElement(this.settings.buttonSelector, true); } 374 hideButton() { this.toggleElement(this.settings.buttonSelector, false); } 375 showSpinner() { this.toggleElement(this.settings.spinnerSelector, true); } 376 hideSpinner() { this.toggleElement(this.settings.spinnerSelector, false); } 377 showEndMessage() { 378 this.toggleElement(this.settings.endMessageSelector, true); 379 this.hideButton(); 380 } 381 hideEndMessage() { this.toggleElement(this.settings.endMessageSelector, false); } 382 383 toggleElement(selector, show) { 384 const el = this.container.querySelector(selector); 385 if (el) el.style.display = show ? 'block' : 'none'; 386 } 387 388 // Public methods for debugging 389 getStats() { 390 return { 391 totalPosts: this.totalPosts, 392 fetchedPosts: this.allFetchedPosts.length, 393 filteredPosts: this.filteredPosts.length, 394 currentlyShown: this.currentlyShown, 395 currentPage: this.currentPage, 396 allPagesFetched: this.allPagesFetched, 397 settings: this.settings 398 }; 399 } 400} 401 402// Auto-initialize all load more containers 403document.addEventListener('DOMContentLoaded', function() { 404 const containers = document.querySelectorAll('.load-more-container'); 405 406 containers.forEach(container => { 407 new PureRestLoadMore(container); 408 }); 409 410 // Global debug helper 411 window.debugLoadMore = function() { 412 const containers = document.querySelectorAll('.load-more-container'); 413 containers.forEach((container, index) => { 414 if (container.loadMoreInstance) { 415 if(this.debug) console.log(`Load More Instance ${index}:`, container.loadMoreInstance.getStats()); 416 } 417 }); 418 }; 419});
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.