PageSourceSearch

https://q1075.com/wp-content/plugins/embedpress/assets/js/google-reviews.js?ver=1790702076

js q1075.com collected 2026-10-02 14:01:01 UTC 20,045 bytes, 443 lines download raw bytes

1/**
2 * EmbedPress — Google Reviews (free) frontend behaviour.
3 *
4 * Read-more toggle: the review text is line-clamped via CSS; this reveals the
5 * "Read more" button ONLY when the text actually overflows, and toggles the
6 * expanded state on click. Rating-only / short reviews never get a button.
7 *
8 * Vanilla JS, no dependencies. Idempotent and re-runnable: the block is injected
9 * by ServerSideRender in the editor AFTER load, so a MutationObserver re-inits
10 * newly-added review cards. Uses a distinct init flag from the Pro script so the
11 * two never double-bind the same node.
12 */
13(function () {
14    'use strict';
15
16    var INIT_FLAG = 'epGrRmInit';
17
18    function initCard(card) {
19        if (card.dataset[INIT_FLAG] === '1') return;
20        var text = card.querySelector('.ep-gr-text');
21        var btn = card.querySelector('.ep-gr-readmore');
22        if (!text || !btn) return;
23        card.dataset[INIT_FLAG] = '1';
24
25        // Show the toggle only when the (clamped) text overflows.
26        var overflows = text.scrollHeight - text.clientHeight > 2;
27        if (!overflows) {
28            btn.hidden = true;
29            return;
30        }
31        btn.hidden = false;
32        btn.addEventListener('click', function () {
33            var expanded = card.classList.toggle('is-expanded');
34            btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
35        });
36    }
37
38    function init(root) {
39        var scope = root && root.querySelectorAll ? root : document;
40        var cards = scope.querySelectorAll('.ep-gr-body');
41        for (var i = 0; i < cards.length; i++) {
42            initCard(cards[i]);
43        }
44        var carousels = scope.querySelectorAll('.ep-google-reviews--carousel');
45        for (var c = 0; c < carousels.length; c++) {
46            initCarousel(carousels[c]);
47        }
48        var roots = scope.querySelectorAll('.ep-google-reviews[data-ep-gr-loadmore]');
49        for (var m = 0; m < roots.length; m++) {
50            initLoadMore(roots[m]);
51        }
52        var loaders = scope.querySelectorAll('.ep-google-reviews--loading[data-ep-gr-poll]');
53        for (var p = 0; p < loaders.length; p++) {
54            initPoller(loaders[p]);
55        }
56    }
57
58    /**
59     * Frontend "still fetching" self-refresh. A freshly-added place whose reviews
60     * aren't fetched yet renders the .ep-google-reviews--loading placeholder
61     * (see GoogleReviewsRenderer::render_loading) carrying the place_id + the
62     * public status endpoint URL. The moment the background job finishes WITH
63     * reviews, we reload the page once so the server re-renders the real cards
64     * in place — no manual refresh.
65     *
66     * COMPLETION IS NOT CLOCK-BASED. Results are delivered by a push webhook
67     * (api.embedpress.com POSTs straight into the store the instant the scrape
68     * ends), so this poller's real job is just to notice that the data landed
69     * and swap it in — it does NOT have to out-wait the scrape. We therefore
70     * removed the old 10-minute "give up quietly" deadline that surfaced as a
71     * stuck placeholder / perceived failure when a slow scrape outran it.
72     *
73     * Instead: poll with gentle exponential backoff (so a slow job doesn't get
74     * hammered), pause entirely while the tab is hidden (Page Visibility) and
75     * resume on focus — a backgrounded tab burns nothing. A finished-but-empty
76     * or failed job stops the poll (nothing to show); anything still running
77     * keeps checking for as long as the visitor is actually looking at the page.
78     * The endpoint also advances the job server-side as a fallback for sites the
79     * webhook can't reach.
80     */
81    function initPoller(root) {
82        if (root.dataset.epGrPollInit === '1') return;
83        var placeId = root.getAttribute('data-ep-gr-poll');
84        var url = root.getAttribute('data-ep-gr-poll-url');
85        if (!placeId || !url) return;
86        root.dataset.epGrPollInit = '1';
87
88        var base = parseInt(root.getAttribute('data-ep-gr-poll-interval'), 10) || 5000;
89        // Backoff band: start at the configured interval, grow gently up to 30s
90        // so a long scrape is checked patiently rather than every 5s forever.
91        var MAX_INTERVAL = 30000;
92        var interval = base;
93        var stopped = false;
94        var timer = null;
95
96        function schedule() {
97            if (stopped) return;
98            timer = window.setTimeout(tick, interval);
99            // Grow the interval toward the ceiling for the next round.
100            interval = Math.min(MAX_INTERVAL, Math.round(interval * 1.5));
101        }
102
103        function tick() {
104            timer = null;
105            if (stopped) return;
106            // Don't poll a hidden tab — parking here (timer=null) lets the
107            // visibilitychange handler restart the loop when the tab returns.
108            if (document.hidden) { return; }
109            var u = url + (url.indexOf('?') === -1 ? '?' : '&') + 'place_id=' + encodeURIComponent(placeId);
110            fetch(u, { headers: { Accept: 'application/json' } })
111                .then(function (r) { return r.json(); })
112                .then(function (res) {
113                    if (stopped) return;
114                    if (!res) { schedule(); return; }
115                    if (res.ready) {
116                        // Terminal. Reload only when reviews actually landed so
117                        // the server renders them in place; on failed/0-reviews
118                        // stop — there's nothing to show.
119                        stopped = true;
120                        if ((res.review_count || 0) > 0) {
121                            window.location.reload();
122                        }
123                        return;
124                    }
125                    schedule(); // still running/queued — keep watching
126                })
127                .catch(function () { if (!stopped) schedule(); });
128        }
129
130        // Resume promptly when the visitor returns to the tab (reset the backoff
131        // so a returning user gets a fast first check).
132        document.addEventListener('visibilitychange', function () {
133            if (!document.hidden && !stopped && timer === null) {
134                interval = base;
135                tick();
136            }
137        });
138
139        schedule();
140    }
141
142    /**
143     * "Load more" (FREE, AJAX): the server renders the first page and stamps a
144     * config blob on data-ep-gr-loadmore. On click we fetch the next page of
145     * rendered cards from the REST endpoint, append them, advance the offset, and
146     * hide the button when the server reports no more.
147     */
148    function initLoadMore(root) {
149        if (root.dataset.epGrLoadmoreInit === '1') return;
150        var cfg;
151        try { cfg = JSON.parse(root.getAttribute('data-ep-gr-loadmore') || '{}'); }
152        catch (e) { return; }
153        if (!cfg || !cfg.rest || !cfg.place_id) return;
154
155        var btn = root.querySelector('.ep-gr-loadmore');
156        var wrap = root.querySelector('.ep-gr-loadmore-wrap');
157        var items = root.querySelector('.ep-gr-items');
158        if (!btn || !items) return;
159        root.dataset.epGrLoadmoreInit = '1';
160
161        var offset = parseInt(cfg.offset, 10) || 0;
162        var loading = false;
163
164        btn.addEventListener('click', function () {
165            if (loading) return;
166            loading = true;
167            btn.classList.add('is-loading');
168            btn.disabled = true;
169
170            var params = ['place_id=' + encodeURIComponent(cfg.place_id),
171                'offset=' + offset,
172                'per_page=' + (parseInt(cfg.per_page, 10) || 5)];
173            var q = cfg.query || {};
174            for (var k in q) {
175                if (!Object.prototype.hasOwnProperty.call(q, k)) continue;
176                var v = q[k];
177                if (Array.isArray(v)) {
178                    for (var i = 0; i < v.length; i++) {
179                        params.push(encodeURIComponent(k + '[]') + '=' + encodeURIComponent(v[i]));
180                    }
181                } else {
182                    params.push(encodeURIComponent(k) + '=' + encodeURIComponent(v));
183                }
184            }
185            var url = cfg.rest + (cfg.rest.indexOf('?') === -1 ? '?' : '&') + params.join('&');
186
187            fetch(url, { headers: { Accept: 'application/json' } })
188                .then(function (r) { return r.json(); })
189                .then(function (res) {
190                    if (res && res.html) {
191                        items.insertAdjacentHTML('beforeend', res.html);
192                        init(root); // wire read-more on the new cards
193                    }
194                    if (res && typeof res.next_offset === 'number') offset = res.next_offset;
195                    if (!res || !res.has_more) {
196                        if (wrap) { wrap.style.display = 'none'; } else { btn.style.display = 'none'; }
197                    }
198                })
199                .catch(function () { /* leave button for retry */ })
200                .then(function () {
201                    loading = false;
202                    btn.classList.remove('is-loading');
203                    btn.disabled = false;
204                });
205        });
206    }
207
208    /**
209     * Real carousel: arrows + dots + autoplay, sliding one "page" at a time.
210     * Page size = how many cards fit the viewport (1 or 2, matching the CSS
211     * flex-basis breakpoint). Idempotent via data flag; rebuilds controls if the
212     * editor re-renders.
213     */
214    function initCarousel(root) {
215        if (root.dataset.epGrCarousel === '1') return;
216        var track = root.querySelector('.ep-gr-items');
217        if (!track) return;
218        var items = track.querySelectorAll('.ep-gr-review');
219        if (items.length < 2) return; // nothing to slide
220        root.dataset.epGrCarousel = '1';
221
222        // The track is the element we translate, so it must NOT be the one that
223        // clips overflow — a clip box on the moving element travels with it, so
224        // after translateX(-100%) the next page lands outside its own clip and
225        // shows blank (the "empty next" bug). Wrap the track in a stationary
226        // viewport that owns overflow:hidden. Idempotent across re-inits.
227        var viewport = track.parentNode;
228        if (!viewport || !viewport.classList || !viewport.classList.contains('ep-gr-viewport')) {
229            viewport = document.createElement('div');
230            viewport.className = 'ep-gr-viewport';
231            track.parentNode.insertBefore(viewport, track);
232            viewport.appendChild(track);
233        }
234
235        function perView() {
236            // Derive from the first card's rendered width vs the viewport width.
237            // Use the viewport (stationary, clips) not the track (sized to its
238            // 17-card content) so the ratio reflects what's actually on screen.
239            var tw = viewport.clientWidth || track.clientWidth || 1;
240            var cw = items[0].getBoundingClientRect().width || tw;
241            return Math.max(1, Math.round(tw / cw));
242        }
243
244        var pv = perView();
245        var realPages = Math.max(1, Math.ceil(items.length / pv));
246
247        // ── Seamless infinite loop ────────────────────────────────────────
248        // Clone the first page's cards and append them as a phantom page. We
249        // animate forward into the phantom, then — once the slide finishes —
250        // silently snap (transition off) back to the real page 0. The viewer
251        // never sees the reset, so it reads as a continuous, never-ending loop.
252        // Seamless infinite loop unless explicitly disabled (data-ep-gr-loop="0").
253        var loopEnabled = root.getAttribute('data-ep-gr-loop') !== '0';
254        var loop = loopEnabled && realPages > 1;
255        if (loop && root.dataset.epGrCloned !== '1') {
256            for (var ci = 0; ci < pv && ci < items.length; ci++) {
257                var clone = items[ci].cloneNode(true);
258                clone.setAttribute('data-ep-gr-clone', '1');
259                clone.setAttribute('aria-hidden', 'true');
260                track.appendChild(clone);
261            }
262            root.dataset.epGrCloned = '1';
263        }
264
265        // FIXED height for the whole carousel — the tallest card across the set,
266        // capped. A fixed box means the cards/dots/everything below never jump
267        // up and down as you slide (the per-page approach caused that). With the
268        // carousel text clamped to 3 lines in CSS, the tallest "normal" card is
269        // modest, so this fixed height is also compact (no giant empty box). The
270        // cap guards against a rare photo-heavy outlier. Computed once.
271        function fitHeight() {
272            var max = 0;
273            for (var i = 0; i < items.length; i++) {
274                var h = items[i].offsetHeight;
275                if (h > max) max = h;
276            }
277            // Cap to a typical clamped-card height. With text limited to 3 lines
278            // the normal tallest card fits well under this; a rare photo-heavy
279            // card is clipped by the viewport rather than ballooning the whole
280            // slider into a tall empty box for every other page.
281            if (max > 340) max = 340;
282            if (max > 0) viewport.style.setProperty('height', max + 'px', 'important');
283        }
284
285        var page = 0;          // logical page, 0..realPages-1
286        var slot = 0;          // physical slot, 0..realPages (realPages == clone)
287        var animating = false;
288
289        function paint(animate) {
290            var offset = -(slot * 100);
291            track.style.setProperty('transition', animate ? 'transform 0.45s ease' : 'none', 'important');
292            // Use !important so the widget's transform reset (style isolation)
293            // can't cancel the slide.
294            track.style.setProperty('transform', 'translateX(' + offset + '%)', 'important');
295            updateDots();
296        }
297
298        function go(target) {
299            if (animating) return;
300            if (!loop) {
301                page = (target + realPages) % realPages;
302                slot = page;
303                paint(true);
304                return;
305            }
306            // Forward into clone (target == realPages) animates, then resets.
307            if (target >= realPages) {
308                animating = true;
309                slot = realPages; page = 0;
310                paint(true);
311                afterSlide(function () { slot = 0; paint(false); animating = false; });
312            } else if (target < 0) {
313                // Going back from page 0: jump to the clone position first
314                // (no anim), then animate to the last real page.
315                slot = realPages; paint(false);
316                // force reflow so the next transform animates from the clone
317                void track.offsetWidth;
318                animating = true;
319                page = realPages - 1; slot = page;
320                paint(true);
321                afterSlide(function () { animating = false; });
322            } else {
323                page = target; slot = target;
324                paint(true);
325            }
326        }
327
328        // Run cb once after the current transform transition ends (with a
329        // timeout fallback so a missed transitionend never wedges the carousel).
330        function afterSlide(cb) {
331            var done = false;
332            function fire() { if (done) return; done = true; track.removeEventListener('transitionend', onEnd); cb(); }
333            function onEnd(e) { if (e.propertyName === 'transform') fire(); }
334            track.addEventListener('transitionend', onEnd);
335            window.setTimeout(fire, 600);
336        }
337
338        // Controls
339        var prev = document.createElement('button');
340        prev.type = 'button';
341        prev.className = 'ep-gr-carousel-arrow ep-gr-carousel-prev';
342        prev.setAttribute('aria-label', 'Previous');
343        prev.innerHTML = '‹';
344        var next = document.createElement('button');
345        next.type = 'button';
346        next.className = 'ep-gr-carousel-arrow ep-gr-carousel-next';
347        next.setAttribute('aria-label', 'Next');
348        next.innerHTML = '›';
349        prev.addEventListener('click', function () { go(page - 1); stopAuto(); });
350        next.addEventListener('click', function () { go(page + 1); stopAuto(); });
351        // Show arrows unless explicitly disabled (data-ep-gr-arrows="0").
352        var showArrows = root.getAttribute('data-ep-gr-arrows') !== '0';
353        if (showArrows) {
354            root.appendChild(prev);
355            root.appendChild(next);
356        }
357
358        var dotsWrap = document.createElement('div');
359        dotsWrap.className = 'ep-gr-carousel-dots';
360        // Show dots unless explicitly disabled (data-ep-gr-dots="0").
361        var showDots = root.getAttribute('data-ep-gr-dots') !== '0';
362        if (showDots) {
363            root.appendChild(dotsWrap);
364        }
365        function buildDots() {
366            dotsWrap.innerHTML = '';
367            for (var i = 0; i < realPages; i++) {
368                (function (idx) {
369                    var d = document.createElement('button');
370                    d.type = 'button';
371                    d.className = 'ep-gr-carousel-dot';
372                    d.setAttribute('aria-label', 'Go to slide ' + (idx + 1));
373                    d.addEventListener('click', function () { go(idx); stopAuto(); });
374                    dotsWrap.appendChild(d);
375                })(i);
376            }
377        }
378        function updateDots() {
379            var dots = dotsWrap.children;
380            for (var i = 0; i < dots.length; i++) {
381                dots[i].classList.toggle('is-active', i === page);
382            }
383        }
384
385        // Autoplay (opt-in via data-ep-gr-autoplay; pauses on hover). Interval
386        // from data-ep-gr-speed (seconds), clamped to a sane 1–30s band.
387        var timer = null;
388        var autoplay = root.getAttribute('data-ep-gr-autoplay') === '1';
389        var speed = parseFloat(root.getAttribute('data-ep-gr-speed')) || 5;
390        speed = Math.min(30, Math.max(1, speed)) * 1000;
391        function startAuto() {
392            if (!autoplay) return;
393            if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
394            stopAuto();
395            timer = window.setInterval(function () { go(page + 1); }, speed);
396        }
397        function stopAuto() { if (timer) { window.clearInterval(timer); timer = null; } }
398        root.addEventListener('mouseenter', stopAuto);
399        root.addEventListener('mouseleave', startAuto);
400
401        buildDots();
402        fitHeight();
403        paint(false);
404        startAuto();
405
406        // Rebuild on resize (perView may change between 1 and 2). pv/realPages
407        // are captured at init; if perView changes we re-fit height and re-clamp
408        // to a valid page so the box and dots stay correct.
409        var rt;
410        window.addEventListener('resize', function () {
411            window.clearTimeout(rt);
412            rt = window.setTimeout(function () {
413                fitHeight();
414                if (page > realPages - 1) page = realPages - 1;
415                slot = page;
416                paint(false);
417            }, 200);
418        });
419    }
420
421    if (document.readyState === 'loading') {
422        document.addEventListener('DOMContentLoaded', function () { init(document); });
423    } else {
424        init(document);
425    }
426
427    // Editor parity: re-init when SSR injects fresh review markup.
428    if (typeof MutationObserver !== 'undefined') {
429        var obs = new MutationObserver(function (muts) {
430            for (var i = 0; i < muts.length; i++) {
431                if (muts[i].addedNodes && muts[i].addedNodes.length) {
432                    init(document);
433                    break;
434                }
435            }
436        });
437        var start = function () {
438            if (document.body) obs.observe(document.body, { childList: true, subtree: true });
439        };
440        if (document.body) start();
441        else document.addEventListener('DOMContentLoaded', start);
442    }
443})();

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.