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.