PageSourceSearch

https://www.coconzept.com/wp-content/plugins/testimonial-free/src/Blocks/assets/js/script.js?ver=4.0.0

js coconzept.com collected 2026-10-06 18:03:17 UTC 19,481 bytes, 657 lines download raw bytes

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.