1/** 2 * Real Testimonials â frontend runtime. 3 * 4 * Swiper carousel/slider init, ajax pagination, and the testimonial submission 5 * form. Enqueued raw (no transpile) â keep this file browser-native JS. 6 */ 7(() => { 8 'use strict'; 9 10 // ------------------------------------------------------------------ 11 // Shared helpers 12 // ------------------------------------------------------------------ 13 14 const toInt = (value, fallback = 0) => parseInt(value, 10) || fallback; 15 16 const onReady = (callback) => { 17 if (document.readyState === 'loading') { 18 document.addEventListener('DOMContentLoaded', callback); 19 } else { 20 callback(); 21 } 22 }; 23 24 /** 25 * Delegated document-level listener â covers ajax-loaded cards too. 26 * 27 * @param {string} type Event type (click, submit, â¦) 28 * @param {string} selector Closest-match selector for the real target 29 * @param {Function} handler (event, matchedElement) => 30 */ 31 const delegate = (type, selector, handler) => { 32 document.addEventListener(type, (event) => { 33 const matched = event.target.closest(selector); 34 if (matched) { 35 handler(event, matched); 36 } 37 }); 38 }; 39 40 const ajaxPost = async (body) => { 41 const response = await fetch(sp_real_localize_data.ajaxUrl, { 42 method: 'POST', 43 body, 44 }); 45 return response.json(); 46 }; 47 48 // ------------------------------------------------------------------ 49 // Carousel (Swiper) 50 // ------------------------------------------------------------------ 51 52 const EFFECT_CONFIGS = { 53 cube: { 54 slideShadows: true, 55 shadow: true, 56 shadowOffset: 20, 57 shadowScale: 0.94, 58 }, 59 flip: { 60 slideShadows: true, 61 limitRotation: true, 62 }, 63 }; 64 65 // Effects that render one slide at a time â multi-column layouts are 66 // emulated by grouping cards into a single slide. 67 const GROUPED_EFFECTS = ['cube', 'flip']; 68 69 // Resolve a swiper setting for the current viewport (base value 70 // overridden by the largest matching breakpoint). 71 const getBreakpointSetting = (settings, key) => { 72 const width = window.innerWidth || 0; 73 const breakpoints = settings.breakpoints || {}; 74 75 return Object.keys(breakpoints) 76 .map(Number) 77 .sort((a, b) => a - b) 78 .reduce( 79 (value, breakpoint) => 80 width >= breakpoint && 81 breakpoints[breakpoint]?.[key] !== undefined 82 ? breakpoints[breakpoint][key] 83 : value, 84 settings[key] 85 ); 86 }; 87 88 const groupEffectSlides = (sliderEl, settings) => { 89 if (!GROUPED_EFFECTS.includes(settings.effect)) { 90 return; 91 } 92 93 const wrapper = sliderEl.querySelector('.swiper-wrapper'); 94 const groupSize = Math.max( 95 1, 96 Math.floor(Number(getBreakpointSetting(settings, 'slidesPerView')) || 1) 97 ); 98 99 // Bail if single-column, or already grouped â the dataset flag guards 100 // against a second init (e.g. resize) re-wrapping the cards. 101 if (!wrapper || groupSize <= 1 || wrapper.dataset.spRealEffectGrouped) { 102 return; 103 } 104 105 const groupGap = 106 Number(getBreakpointSetting(settings, 'spaceBetween')) || 0; 107 const slides = Array.from(wrapper.children); 108 wrapper.dataset.spRealEffectGrouped = 'true'; 109 110 slides.forEach((slide) => { 111 slide.classList.remove('swiper-slide', 'sp-real-swiper-slide'); 112 slide.classList.add('sp-real-swiper-effect-group-item'); 113 slide.style.width = ''; 114 }); 115 116 for (let index = 0; index < slides.length; index += groupSize) { 117 const group = document.createElement('div'); 118 group.className = 119 'sp-real-swiper-slide swiper-slide sp-real-swiper-effect-group'; 120 group.style.setProperty( 121 '--sp-real-carousel-effect-columns', 122 groupSize 123 ); 124 group.style.setProperty( 125 '--sp-real-carousel-effect-gap', 126 `${groupGap}px` 127 ); 128 129 slides 130 .slice(index, index + groupSize) 131 .forEach((slide) => group.appendChild(slide)); 132 wrapper.appendChild(group); 133 } 134 135 // Grouped cards travel as one slide â collapse per-view settings. 136 const collapse = (target) => { 137 target.slidesPerView = 1; 138 target.slidesPerGroup = 1; 139 target.spaceBetween = 0; 140 }; 141 collapse(settings); 142 Object.values(settings.breakpoints || {}).forEach(collapse); 143 }; 144 145 const buildPaginationConfig = (paginationEl, paginationStyle) => { 146 if (!paginationEl) { 147 return false;
148 } 149 150 // Dots, dynamic, stepper â all bullets with different styles. 151 return { 152 el: paginationEl, 153 clickable: true, 154 type: 'bullets', 155 dynamicBullets: paginationStyle === 'dynamic', 156 }; 157 }; 158 159 /** 160 * Initialize a testimonial carousel Swiper. 161 * 162 * @param {Element} block - DOM context to search within 163 */ 164 const initSlider = (block) => { 165 const sliderEl = block.querySelector( 166 '.sp-testimonial-swiper[data-swiper-settings]' 167 ); 168 if (!sliderEl) { 169 return; 170 } 171 172 let settings; 173 try { 174 settings = JSON.parse(sliderEl.dataset.swiperSettings); 175 } catch (e) { 176 // eslint-disable-next-line no-console 177 console.error('Invalid swiper settings:', e); 178 return; 179 } 180 181 const parent = sliderEl.parentElement; 182 const paginationEl = 183 sliderEl.querySelector('.sp-real-swiper-pagination') || 184 parent?.querySelector('.sp-real-swiper-pagination'); 185 const paginationStyle = settings?.paginationStyle; 186 const nextEl = parent?.querySelector('.sp-real-nav-next'); 187 const prevEl = parent?.querySelector('.sp-real-nav-prev'); 188 const hasNav = settings.navigation && nextEl && prevEl; 189 190 groupEffectSlides(sliderEl, settings); 191 192 const swiperConfig = { 193 ...settings, 194 navigation: hasNav ? { nextEl, prevEl } : false, 195 pagination: buildPaginationConfig(paginationEl, paginationStyle), 196 on: { 197 resize(swiper) { 198 swiper.update(); 199 }, 200 }, 201 }; 202 203 const effectConfig = EFFECT_CONFIGS[settings.effect]; 204 if (effectConfig) { 205 swiperConfig[`${settings.effect}Effect`] = effectConfig; 206 } 207 // eslint-disable-next-line no-undef 208 new Swiper(sliderEl, swiperConfig); 209 }; 210 211 // ------------------------------------------------------------------ 212 // Ajax query â shared fetch for search / filter / pagination 213 // ------------------------------------------------------------------ 214 215 /** 216 * Query testimonials via admin-ajax and render into the cards wrapper. 217 * 218 * @param {Object} queryData Query params merged into the request 219 * @param {Element} mainBlock The .sp-real-block-frontend element 220 * @param {Object} [options] 221 * @param {string} [options.mode] 'replace' (default) or 'append' 222 * @param {Function} [options.onSuccess] Called with res.data on success 223 * @return {Promise<Object|undefined>} Parsed ajax response 224 */ 225 const queryTestimonials = async (queryData, mainBlock, options = {}) => { 226 const { mode = 'replace', onSuccess } = options; 227 const cardsWrapper = mainBlock.querySelector('.sp-real-cards-wrapper'); 228 229 try { 230 const res = await ajaxPost( 231 new URLSearchParams({ 232 action: 'sp_real_query_testimonials', 233 nonce: sp_real_localize_data.nonce, 234 ...queryData, 235 }) 236 ); 237 if (!res?.success || !cardsWrapper) { 238 return res; 239 } 240 const html = res.data?.html ?? ''; 241 if (mode === 'append') { 242 if (html) { 243 cardsWrapper.insertAdjacentHTML('beforeend', html); 244 } 245 } else { 246 cardsWrapper.innerHTML = html; 247 } 248 onSuccess?.(res.data || {}); 249 return res; 250 } catch { 251 // Swallow â UI keeps prior state. 252 } 253 }; 254 255 // ------------------------------------------------------------------ 256 // Group block child blocks â pagination 257 // ------------------------------------------------------------------ 258 259 const initAjaxPagination = (qs, state) => { 260 if (!qs('.sp-real-ajax-pagination')) { 261 return; 262 } 263 const { mainBlock } = state; 264 const paginationWrapper = qs('.sp-real-pagination-wrapper'); 265 const cardsWrapper = qs('.sp-real-cards-wrapper'); 266 if (!paginationWrapper || !cardsWrapper) { 267 return; 268 } 269 270 // initialLimit = cards already in DOM from first render; totalItems = 271 // full matching count. Both feed the totalPages math below. 272 const initialLimit = toInt(mainBlock?.dataset.limit); 273 const totalItems = toInt(mainBlock?.dataset.totalPosts); 274 275 const paginationData = JSON.parse( 276 paginationWrapper.dataset.pagination || '{}' 277 ); 278 const itemsPerPage = parseInt(paginationData?.itemsPerPage); 279 const { paginationButtonType, paginationNumberType } = paginationData; 280 281 let currentPage = 1; 282 let isLoading = false; 283 // Initially rendered cards beyond one page reduce the remaining pages. 284 const computeTotalPages = (total) => 285 total > 0 286 ? Math.ceil( 287 (initialLimit - itemsPerPage > 0 288 ? total - itemsPerPage 289 : total) / itemsPerPage 290 ) 291 : 0; 292 let totalPages = computeTotalPages(totalItems); 293 state.query = { ...state.query, per_page: itemsPerPage }; 294 295 // Per-style UI re-sync hooks, run when search/filter swap the result 296 // set (each pagination style registers its own below). 297 const resetHandlers = []; 298 state.pagination = { 299 reset(total) { 300 totalPages = computeTotalPages(toInt(total)); 301 currentPage = 1;
302 resetHandlers.forEach((handler) => handler()); 303 }, 304 }; 305 306 const goToPage = (page, mode = 'replace') => { 307 if (isLoading) { 308 return; 309 } 310 isLoading = true; 311 return queryTestimonials( 312 { ...state.query, per_page: itemsPerPage, current_page: page }, 313 mainBlock, 314 { mode } 315 ).finally(() => { 316 isLoading = false; 317 }); 318 }; 319 320 // Load-more button. 321 if (paginationButtonType === 'load-more') { 322 const loadMoreWrap = qs('.sp-real-load-more-button'); 323 const loadMoreBtn = loadMoreWrap?.querySelector( 324 'a.sp-real-pagination-item' 325 ); 326 const endMessage = qs('.sp-real-pagination-ending'); 327 328 const showEndState = () => { 329 if (loadMoreBtn) { 330 loadMoreBtn.style.display = 'none'; 331 } 332 endMessage?.classList.remove('sp-d-hidden'); 333 }; 334 335 // Hide button when already on last page (or no extra pages); 336 // restore it when a reset brings more pages back. 337 const syncLoadMoreState = () => { 338 if (totalPages <= 1) { 339 showEndState(); 340 return; 341 } 342 if (loadMoreBtn) { 343 loadMoreBtn.style.display = ''; 344 } 345 endMessage?.classList.add('sp-d-hidden'); 346 }; 347 syncLoadMoreState(); 348 resetHandlers.push(syncLoadMoreState); 349 350 if (loadMoreWrap && loadMoreBtn) { 351 loadMoreWrap.addEventListener('click', (e) => { 352 const button = e.target.closest('a.sp-real-pagination-item'); 353 if (!button || button.classList.contains('sp-d-hidden')) { 354 return; 355 } 356 e.preventDefault(); 357 const nextPage = currentPage + 1; 358 if (nextPage > totalPages) { 359 showEndState(); 360 return; 361 } 362 // goToPage returns undefined when its isLoading guard trips, 363 // so only spin up the loading state when a request started. 364 const request = goToPage(nextPage, 'append'); 365 if (request) { 366 button.classList.add('sp-real-is-loading'); 367 button.setAttribute('aria-busy', 'true'); 368 request.finally(() => { 369 button.classList.remove('sp-real-is-loading'); 370 button.setAttribute('aria-busy', 'false'); 371 }); 372 } 373 currentPage = nextPage; 374 if (currentPage >= totalPages) { 375 showEndState(); 376 } 377 }); 378 } 379 } 380 381 // Numbered pagination. 382 if (paginationButtonType === 'number') { 383 const buttonsWrapper = qs('.sp-real-pagination-buttons'); 384 if (!buttonsWrapper) { 385 return; 386 } 387 // PHP renders prev/next stubs â keep them, manage numbered between. 388 const prevBtn = buttonsWrapper.querySelector('.prev-button'); 389 const nextBtn = buttonsWrapper.querySelector('.next-button'); 390 391 const renderPageButtons = () => { 392 // Drop stale numbered buttons (keep the prev/next stubs). 393 buttonsWrapper 394 .querySelectorAll('.sp-real-pagination-item') 395 .forEach((b) => { 396 if (b !== prevBtn && b !== nextBtn) { 397 b.remove(); 398 } 399 }); 400 401 if (totalPages <= 1 || paginationNumberType === 'prev-next') { 402 return; 403 } 404 for (let p = 1; p <= totalPages; p++) { 405 const btn = document.createElement('a'); 406 btn.href = '#'; 407 btn.className = 408 'sp-real-pagination-item' + 409 (p === currentPage ? ' current' : ''); 410 btn.dataset.value = p; 411 btn.textContent = p; 412 if (nextBtn) { 413 buttonsWrapper.insertBefore(btn, nextBtn); 414 } else { 415 buttonsWrapper.appendChild(btn); 416 } 417 } 418 }; 419 420 const syncNavState = () => { 421 prevBtn?.classList.toggle('sp-d-disabled', currentPage <= 1); 422 nextBtn?.classList.toggle( 423 'sp-d-disabled', 424 totalPages > 0 && currentPage >= totalPages 425 ); 426 buttonsWrapper 427 .querySelectorAll('.sp-real-pagination-item') 428 .forEach((b) => { 429 if (b === prevBtn || b === nextBtn) { 430 return; 431 } 432 b.classList.toggle( 433 'current', 434 toInt(b.dataset.value) === currentPage 435 ); 436 }); 437 buttonsWrapper.dataset.page = currentPage; 438 }; 439 renderPageButtons(); 440 syncNavState(); 441 resetHandlers.push(() => { 442 renderPageButtons(); 443 syncNavState(); 444 }); 445 446 buttonsWrapper.addEventListener('click', (e) => { 447 const btn = e.target.closest('.sp-real-pagination-item'); 448 if (!btn) { 449 return; 450 } 451 e.preventDefault(); 452 if (btn.classList.contains('sp-d-disabled')) { 453 return; 454 } 455 let page = currentPage; 456 if (btn.classList.contains('prev-button')) { 457 page = Math.max(1, currentPage - 1); 458 } else if (btn.classList.contains('next-button')) { 459 page = totalPages 460 ? Math.min(totalPages, currentPage + 1) 461 : currentPage + 1; 462 } else if (btn.dataset.value) { 463 page = toInt(btn.dataset.value, 1); 464 } 465 if (page === currentPage) { 466 return; 467 } 468 currentPage = page; 469 goToPage(currentPage, 'replace'); 470 syncNavState(); 471 }); 472 } 473 }; 474 475 const initGroupBlock = (block) => { 476 const qs = (selector) => block.querySelector(selector); 477 const mainBlock = qs('.sp-real-block-frontend:not(.sp-real-testimonial-submission-form)'); 478 if (!mainBlock) { 479 return; 480 } 481 // Shared mutable query state across the group's child blocks. 482 const state = { 483 mainBlock, 484 query: { 485 unique_id: mainBlock.id, 486 post_id: mainBlock.dataset?.postId, 487 }, 488 }; 489 initAjaxPagination(qs, state); 490 }; 491 492 // ------------------------------------------------------------------ 493 // TESTIMONIAL SUBMISSION FORM â ajax submit, messages 494 // ------------------------------------------------------------------ 495 496 const showFormMessage = (form, type, text) => { 497 form.querySelectorAll('.sp-real-tsf-message').forEach((m) => { 498 const match = m.classList.contains(`sp-real-tsf-message__${type}`); 499 m.hidden = !match; 500 if (!match) { 501 return; 502 } 503 // Preserve the block's configured default, then show a 504 // server-provided reason (nonce / captcha / insert failure) when one 505 // is returned â otherwise fall back to the default. 506 if (m.dataset.defaultMessage === undefined) { 507 m.dataset.defaultMessage = m.textContent.trim(); 508 } 509 m.textContent = text || m.dataset.defaultMessage; 510 }); 511 }; 512 513 // Required-fields notice â reveal only when the user attempts to submit 514 // with an empty required field, hide again once the form validates. 515 // `invalid` fires per-field during native validation and does not bubble, 516 // so listen in the capture phase at the document level. 517 const toggleRequiredNotice = (form, show) => { 518 const notice = form?.querySelector('.sp-real-tsf-required-notice'); 519 if (notice) { 520 notice.hidden = !show; 521 } 522 }; 523 524 document.addEventListener( 525 'invalid', 526 (event) => { 527 const form = event.target.closest('.sp-real-tsf-form'); 528 toggleRequiredNotice(form, true); 529 }, 530 true 531 ); 532 delegate('submit', '.sp-real-tsf-form', (event, form) => { 533 toggleRequiredNotice(form, false); 534 }); 535 536 // Delegated ajax submit â covers forms cloned into the shared modal 537 // (e.g. the review-summary "Write a Review" popup) as well as inline blocks. 538 delegate('submit', '.sp-real-tsf-form.sp-real-tsf-ajax', async (event, form) => { 539 event.preventDefault(); 540 const data = new FormData(form); 541 data.append('action', 'sp_real_tsf_submit'); 542 543 try { 544 const res = await ajaxPost(data); 545 if (!res?.success) { 546 // wp_send_json_error nests the payload under `data`. 547 showFormMessage(form, 'error', res?.data?.message); 548 return; 549 } 550 showFormMessage(form, 'success', res?.data?.message); 551 form.reset(); 552 } catch { 553 showFormMessage(form, 'error'); 554 } 555 }); 556 557 // ------------------------------------------------------------------ 558 // Boot 559 // ------------------------------------------------------------------ 560 561 /** 562 * Main initialization function for Real Testimonials Pro blocks. 563 * Called on DOMContentLoaded and by page builder integrations. 564 */ 565 const spRealTestimonialInit = () => { 566 document 567 .querySelectorAll('.sp-real-testimonial-block') 568 .forEach((block) => { 569 initSlider(block); 570 }); 571 572 document 573 .querySelectorAll('.sp-real-testimonial-group') 574 .forEach(initGroupBlock); 575 576 // Non-AJAX submit lands here with a status flag in the URL; PHP has 577 // already rendered the message for this load, so strip the flag so a 578 // reload (or bookmark/share) doesn't re-show it.
579 const url = new URL(window.location.href); 580 if ( 581 url.searchParams.has('sp_real_tsf_submitted') || 582 url.searchParams.has('sp_real_tsf_error') 583 ) { 584 url.searchParams.delete('sp_real_tsf_submitted'); 585 url.searchParams.delete('sp_real_tsf_error'); 586 window.history.replaceState({}, '', url); 587 } 588 }; 589 590 // Boot on DOM ready. 591 onReady(spRealTestimonialInit); 592 593 // Expose init function globally for page builder integrations. 594 window.spRealTestimonialInit = spRealTestimonialInit; 595})(); 596 597// ------------------------------------------------------------------ 598// Elementor integration 599// ------------------------------------------------------------------ 600 601(function () { 602 // Elementor frontend hook - triggers when widget is ready in editor/frontend. 603 window.addEventListener('elementor/frontend/init', function () { 604 if (typeof elementorFrontend !== 'undefined' && elementorFrontend.hooks) { 605 elementorFrontend.hooks.addAction( 606 'frontend/element_ready/real_testimonial_pro_saved_template.default', 607 function () { 608 if (typeof window.spRealTestimonialInit === 'function') { 609 window.spRealTestimonialInit(); 610 } 611 } 612 ); 613 } 614 }); 615 616 // Elementor preview editor - watch for DOM changes. 617 if (typeof ElementorPreview !== 'undefined' || document.body.classList.contains('elementor-editor-preview')) { 618 const observer = new MutationObserver(function (mutations) { 619 mutations.forEach(function (mutation) { 620 if (mutation.addedNodes.length) { 621 mutation.addedNodes.forEach(function (node) { 622 if (node.nodeType === 1) { 623 // Direct match. 624 if (node.classList && node.classList.contains('sp-real-testimonial-block')) { 625 if (typeof window.spRealTestimonialInit === 'function') { 626 window.spRealTestimonialInit(); 627 } 628 return; 629 } 630 // Nested match. 631 const blocks = node.querySelectorAll && node.querySelectorAll('.sp-real-testimonial-block, .sp-real-testimonial-group'); 632 if (blocks && blocks.length > 0) { 633 if (typeof window.spRealTestimonialInit === 'function') { 634 window.spRealTestimonialInit(); 635 } 636 } 637 } 638 }); 639 } 640 }); 641 }); 642 643 observer.observe(document.body, { 644 childList: true, 645 subtree: true 646 }); 647 } 648 649 // Fallback: Re-initialize on window load for Elementor. 650 window.addEventListener('load', function () { 651 if (document.body.classList.contains('elementor-editor-preview')) { 652 if (typeof window.spRealTestimonialInit === 'function') { 653 window.spRealTestimonialInit(); 654 } 655 } 656 }); 657})();
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.