PluginProbe ʕ •ᴥ•ʔ
EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents / 4.6.5
EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents v4.6.5
4.6.5 4.6.4 4.6.3 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 trunk 1.0.0 1.1.0 1.1.1 1.1.2 1.1.3 1.2.0 1.3.0 1.3.1 1.4.0 1.4.1 1.4.2 1.4.3 1.4.4 1.5.0 1.6.0 1.6.1 1.6.2 1.6.3 1.7.0 1.7.1 1.7.2 1.7.3 1.7.4 1.7.5 2.0.0 2.0.1 2.0.2 2.0.3 2.1.0 2.1.1 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.2.0 2.2.1 2.2.2 2.3.0 2.3.1 2.3.2 2.3.3 2.4.0 2.4.1 2.5.0 2.5.1 2.5.2 2.5.3 2.5.4 2.5.5 2.6.0 2.6.1 2.6.2 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 3.0.0 3.0.1 3.0.2 3.0.3 3.0.4 3.1.0 3.1.1 3.1.2 3.1.3 3.2.0 3.2.1 3.3.0 3.3.1 3.3.2 3.3.3 3.3.4 3.3.5 3.3.6 3.3.7 3.4.0 3.4.1 3.4.2 3.4.3 3.5.0 3.5.1 3.5.2 3.5.3 3.6.0 3.6.1 3.6.2 3.6.3 3.6.4 3.6.5 3.6.6 3.6.7 3.6.8 3.7.0 3.7.1 3.7.2 3.7.3 3.8.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.9.0 3.9.1 3.9.10 3.9.11 3.9.12 3.9.13 3.9.14 3.9.15 3.9.16 3.9.17 3.9.2 3.9.3 3.9.4 3.9.5 3.9.6 3.9.7 3.9.8 3.9.9 4.0.0 4.0.1 4.0.10 4.0.11 4.0.12 4.0.13 4.0.14 4.0.2 4.0.3 4.0.4 4.0.5 4.0.6 4.0.7 4.0.8 4.0.9 4.1.0 4.1.1 4.1.10 4.1.2 4.1.3 4.1.4 4.1.5 4.1.6 4.1.7 4.1.8 4.1.9 4.2.0 4.2.1 4.2.2 4.2.3 4.2.4 4.2.5 4.2.6 4.2.7 4.2.8 4.2.9 4.3.0 4.3.1 4.4.0 4.4.1 4.4.10 4.4.11 4.4.2 4.4.3 4.4.4 4.4.5 4.4.6 4.4.7 4.4.8 4.4.9 4.5.0 4.5.1
embedpress / EmbedPress / Includes / Classes / GoogleReviewsRenderer.php
embedpress / EmbedPress / Includes / Classes Last commit date
Analytics 2 months ago Database 3 months ago DynamicFieldResolver.php 2 months ago Elementor_Enhancer.php 8 months ago EmbedPress_Core_Installer.php 6 years ago EmbedPress_Notice.php 5 months ago EmbedPress_Plugin_Usage_Tracker.php 2 months ago Extend_CustomPlayer_Controls.php 3 months ago Extend_Elementor_Controls.php 1 year ago FeatureNoticeManager.php 1 month ago FeatureNotices.php 1 month ago FeaturePreviewModal.php 1 month ago Feature_Enhancer.php 2 months ago GoogleReviewsAdminPage.php 1 month ago GoogleReviewsApify.php 1 month ago GoogleReviewsManaged.php 2 weeks ago GoogleReviewsRenderer.php 2 weeks ago GoogleReviewsRestController.php 2 weeks ago GoogleReviewsStore.php 1 month ago Helper.php 1 month ago Pdf_Thumbnail_Handler.php 1 month ago PermalinkHelper.php 1 year ago SitePerformance.php 5 days ago View_Count_Display.php 2 months ago
GoogleReviewsRenderer.php
2398 lines
1 <?php
2
3 namespace EmbedPress\Includes\Classes;
4
5 (defined('ABSPATH') && defined('EMBEDPRESS_IS_LOADED')) or die("No direct script access allowed.");
6
7 /**
8 * Renders Google Reviews for shortcode, Gutenberg block, and Elementor widget.
9 * Single source of truth so all three surfaces produce identical markup.
10 */
11 class GoogleReviewsRenderer
12 {
13 const CACHE_PREFIX = 'embedpress_gr_';
14 const OPT_API_KEY = 'embedpress_google_reviews_api_key';
15 const OPT_CACHE_TTL = 'embedpress_google_reviews_cache_ttl';
16 const OPT_API_MODE = 'embedpress_google_reviews_api_mode';
17 const OPT_RECENT = 'embedpress_google_reviews_recent';
18 const OPT_SAVED = 'embedpress_google_reviews_saved';
19 // Apify API token — powers the Pro "Fetch all reviews" provider (gets past
20 // Google's 5-review API cap). Stored globally; Pro reads it via get_apify_token().
21 const OPT_APIFY_TOKEN = 'embedpress_google_reviews_apify_token';
22 // Which provider powers place SEARCH: 'auto' | 'google' | 'apify'.
23 const OPT_SEARCH_PROVIDER = 'embedpress_google_reviews_search_provider';
24 const RECENT_MAX = 10;
25
26 // How many reviews the one-time auto-preview fetch pulls on first render of a
27 // freshly-selected place. Kept small + cheap (matches Google's ≤5 ceiling) so
28 // dropping the block never triggers an expensive unbounded Apify run. Users
29 // pull more via EmbedPress → Google Reviews → Refetch.
30 const AUTO_PREVIEW_LIMIT = 5;
31
32 const ENDPOINT_LEGACY_AUTOCOMPLETE = 'https://maps.googleapis.com/maps/api/place/autocomplete/json';
33 const ENDPOINT_LEGACY_DETAILS = 'https://maps.googleapis.com/maps/api/place/details/json';
34 const ENDPOINT_NEW_AUTOCOMPLETE = 'https://places.googleapis.com/v1/places:autocomplete';
35 const ENDPOINT_NEW_DETAILS = 'https://places.googleapis.com/v1/places/';
36
37 /**
38 * Per-layout capability matrix — MUST stay identical to LAYOUT_CAPS in the
39 * Gutenberg block (src/Blocks/google-reviews/src/edit.js) and the Elementor
40 * widget (Embedpress_Google_Reviews::LAYOUT_CAPS). Single source of truth for
41 * which controls/outputs each layout uses, so the renderer never emits a CSS
42 * var or data-attr for a layout that doesn't support it.
43 */
44 const LAYOUT_CAPS = [
45 'list' => ['reviews' => true, 'header' => 'optional', 'columns' => false, 'gap' => false, 'max_width' => true, 'slider' => false, 'autoplay' => false, 'speed' => false, 'load_more' => true, 'images' => true, 'write_review' => true],
46 'grid' => ['reviews' => true, 'header' => 'optional', 'columns' => true, 'gap' => true, 'max_width' => true, 'slider' => false, 'autoplay' => false, 'speed' => false, 'load_more' => true, 'images' => true, 'write_review' => true],
47 'card' => ['reviews' => true, 'header' => 'optional', 'columns' => true, 'gap' => true, 'max_width' => true, 'slider' => false, 'autoplay' => false, 'speed' => false, 'load_more' => true, 'images' => true, 'write_review' => true],
48 'carousel' => ['reviews' => true, 'header' => 'optional', 'columns' => true, 'gap' => true, 'max_width' => true, 'slider' => true, 'autoplay' => true, 'speed' => false, 'load_more' => false, 'images' => false, 'write_review' => true],
49 'masonry' => ['reviews' => true, 'header' => 'optional', 'columns' => true, 'gap' => true, 'max_width' => true, 'slider' => false, 'autoplay' => false, 'speed' => false, 'load_more' => true, 'images' => true, 'write_review' => true],
50 // badge + knowledge are compact summary widgets — the Pro CSS hides the
51 // write-review button there, so the control is not supported (QA #4).
52 'badge' => ['reviews' => false, 'header' => 'forced', 'columns' => false, 'gap' => false, 'max_width' => true, 'slider' => false, 'autoplay' => false, 'speed' => false, 'load_more' => false, 'images' => false, 'write_review' => false],
53 'spotlight' => ['reviews' => true, 'header' => 'optional', 'columns' => false, 'gap' => false, 'max_width' => true, 'slider' => false, 'autoplay' => true, 'speed' => false, 'load_more' => false, 'images' => true, 'write_review' => true],
54 'knowledge' => ['reviews' => false, 'header' => 'forced', 'columns' => false, 'gap' => false, 'max_width' => true, 'slider' => false, 'autoplay' => false, 'speed' => false, 'load_more' => false, 'images' => false, 'write_review' => false],
55 'marquee' => ['reviews' => true, 'header' => 'optional', 'columns' => true, 'gap' => true, 'max_width' => true, 'slider' => false, 'autoplay' => false, 'speed' => true, 'load_more' => false, 'images' => false, 'write_review' => true],
56 'bubble' => ['reviews' => true, 'header' => 'optional', 'columns' => true, 'gap' => true, 'max_width' => true, 'slider' => false, 'autoplay' => false, 'speed' => false, 'load_more' => true, 'images' => true, 'write_review' => true],
57 ];
58
59 /** Capabilities for a layout (falls back to list for unknown layouts). */
60 public static function layout_caps(string $layout): array
61 {
62 return self::LAYOUT_CAPS[$layout] ?? self::LAYOUT_CAPS['list'];
63 }
64
65 /**
66 * Reset any attribute a layout doesn't support back to a safe value, so a
67 * stale saved attribute (e.g. load_more enabled on list, then switched to
68 * carousel) can never leak into the rendered output. The editor's conditional
69 * controls are UX only — THIS is the authoritative gate on every surface.
70 */
71 public static function enforce_layout_caps(array $args): array
72 {
73 $layout = isset($args['layout']) ? sanitize_key((string) $args['layout']) : 'list';
74 $caps = self::layout_caps($layout);
75
76 // Header is forced on for summary-only layouts; the toggle can't turn it
77 // off (handled again at render, but normalize the arg here too).
78 if (($caps['header'] ?? 'optional') === 'forced') {
79 $args['show_summary'] = true;
80 }
81
82 // Per-review images: off unless the layout renders them.
83 if (empty($caps['images'])) {
84 $args['show_images'] = false;
85 }
86 // Load-more pagination: off unless the layout supports it.
87 if (empty($caps['load_more'])) {
88 $args['load_more'] = false;
89 }
90 // Autoplay: off unless the layout auto-advances (carousel/spotlight).
91 if (empty($caps['autoplay'])) {
92 $args['autoplay'] = false;
93 }
94 // Slider nav (arrows/dots/loop): off unless the layout is a slider.
95 if (empty($caps['slider'])) {
96 $args['show_arrows'] = false;
97 $args['show_dots'] = false;
98 $args['carousel_loop'] = false;
99 }
100 // Columns: neutralize to 1 when the layout doesn't use a column/per-view
101 // count (no stale multi-column on a single-card layout).
102 if (empty($caps['columns'])) {
103 $args['columns'] = 1;
104 }
105 // Gap: zero out when the layout doesn't use the control (badge/list/etc.).
106 if (empty($caps['gap'])) {
107 $args['gap'] = 0;
108 }
109
110 return $args;
111 }
112
113 const DEFAULTS = [
114 'place_id' => '',
115 'place_name' => '',
116 'limit' => 10,
117 'min_rating' => 0,
118 'layout' => 'list',
119 'show_photo' => true,
120 'show_date' => true,
121 'show_stars' => true,
122 'show_link' => false,
123 'show_images' => true, // review photo strips (not the reviewer avatar)
124 // Structural layout controls (free; apply to every layout that uses
125 // them). columns drives grid/masonry column count + carousel
126 // slides-per-view; max_width caps the block width (px, 0 = unset);
127 // gap is the inter-card gap (px). Emitted as CSS custom properties on
128 // the wrapper so every layout's CSS can read them without markup churn.
129 'columns' => 3, // 1..6 (grid/masonry cols, carousel per-view)
130 'max_width' => 0, // px; 0 = no cap
131 'gap' => 20, // px; inter-card gap
132 // Header / summary controls (free).
133 'show_summary' => true, // the whole header block
134 'show_summary_name' => true, // business name
135 'show_summary_rating' => true, // rating score number
136 'show_summary_stars' => true, // the star row
137 'show_summary_count' => true, // "N reviews"
138 'show_write_review' => true, // "Write a review" button
139 'summary_align' => 'left', // left | center | right
140 // Carousel / slider controls (free — carousel is a free layout).
141 'show_arrows' => true, // prev/next arrows
142 'show_dots' => true, // pagination dots
143 'carousel_loop' => true, // seamless infinite loop
144 'autoplay_speed' => 5, // seconds per slide (1..30); 'autoplay' below
145 // Pro-extended keys. Defined here so they round-trip through
146 // wp_parse_args() and are visible to Pro's render filters even on
147 // surfaces (shortcode/Elementor) that don't emit them. Free ignores
148 // them; Pro reads them from the filtered $args.
149 'sort' => 'newest', // newest | highest | lowest | relevant
150 'keyword' => '', // keyword search filter
151 'hide_empty' => false, // hide reviews with no text
152 'fetch_all' => false, // Pro: fetch all reviews via Apify (past API 5-cap)
153 'theme' => 'light', // light | dark
154 'accent_color' => '', // hex accent color
155 'autoplay' => false, // carousel/slider autoplay
156 'schema' => false, // emit AggregateRating + Review JSON-LD
157 'places' => [], // multi-place id list
158 'cache_ttl' => 0, // per-block cache TTL override (seconds)
159 'load_more' => true, // paginate display with a Load More button (FREE) — on by default
160 'per_page' => 0, // page size; 0 = use the "Reviews per page" (limit) control
161 'is_editor_preview' => false, // true only for the block-editor SSR preview (caps sliding-layout cards)
162 ];
163
164 /**
165 * Render a Google Reviews block from a set of args. Single entry point used by
166 * the shortcode, Gutenberg render_callback, and Elementor widget.
167 */
168 public static function render(array $args): string
169 {
170 // Enforce the per-layout capability matrix on the INCOMING args, before
171 // anything else. The editor only HIDES controls a layout doesn't use, but
172 // the saved attribute persists — e.g. enabling "Load more" on a list, then
173 // switching to carousel, would otherwise still emit load-more on the
174 // frontend. This resets every cap-violating attribute to a safe value so
175 // the output can never contradict the layout, on any surface (block /
176 // shortcode / Elementor / REST).
177 $args = self::enforce_layout_caps($args);
178
179 $args = wp_parse_args($args, self::DEFAULTS);
180
181 // Server-side free/Pro enforcement (so the gating can't be bypassed via
182 // shortcode/REST). The split now MIRRORS Essential Addons' free Business
183 // Reviews widget: sort/keyword/hide-empty, header per-part toggles +
184 // alignment, autoplay + speed, theme/accent, and JSON-LD schema are ALL
185 // FREE. Only genuinely premium things stay Pro: the per-review attached
186 // IMAGES strip, multi-place merge (pre_fetch filter), and the premium
187 // layouts (gated separately via gr_pro_unlocked / allowed_layouts).
188 if (!Helper::is_pro_active()) {
189 // Review images (the per-review photo strip) are Pro — force off so it
190 // can't be enabled via shortcode/REST without Pro.
191 $args['show_images'] = false;
192 // Minimum rating ("show only N�
193 ") is Pro — the highest-demand filter.
194 // Force to 0 (Any) so it can't be applied via shortcode/REST in free.
195 $args['min_rating'] = 0;
196 }
197
198 /**
199 * Filter the normalized render args before anything else. Pro uses this
200 * to inject its own attributes (style preset, sort, keyword filter,
201 * multi-place list, cache override, schema toggle, etc.) into the
202 * pipeline. Runs for every surface (block, shortcode, Elementor).
203 *
204 * @param array $args Normalized args (see self::DEFAULTS + Pro extras).
205 */
206 $args = (array) apply_filters('embedpress/google_reviews/render_args', $args);
207
208 // Free layouts are list/grid/carousel/card; Pro extends the allowed set
209 // via this filter (masonry/badge/spotlight/knowledge/marquee/bubble).
210 $allowed_layouts = (array) apply_filters('embedpress/google_reviews/allowed_layouts', ['list', 'grid', 'carousel', 'card']);
211 $requested = (string) $args['layout'];
212
213 // If a PRO layout is selected without a Pro LICENSE, don't silently fall
214 // back to the free list (or render a half-broken unstyled Pro layout) —
215 // show an upsell so the user understands why their layout isn't rendering.
216 // Gate on the license (is_pro_active), NOT allowed_layouts: the Pro PLUGIN
217 // registers those layouts in allowed_layouts even when unlicensed, so the
218 // filter alone can't tell "unlocked" from "merely installed".
219 $pro_layouts = ['masonry', 'badge', 'spotlight', 'knowledge', 'marquee', 'bubble'];
220 if (in_array($requested, $pro_layouts, true) && !self::gr_pro_unlocked()) {
221 return self::render_pro_layout_upsell($requested);
222 }
223
224 $layout = in_array($requested, $allowed_layouts, true) ? $requested : 'list';
225
226 // Visible-count ceiling. Free shows up to 10 reviews. When the site is
227 // connected to the hosted EmbedPress API (the managed proxy), the store
228 // holds many more, so lift the cap to 50 — matching the editor control's
229 // ceiling (REVIEWS_PER_PAGE_MAX in edit.js) so the UI and the renderer
230 // agree. Pro can lift further (multi-place / paginated) via the filter.
231 $source_has_more = \EmbedPress\Includes\Classes\GoogleReviewsManaged::is_connected();
232 $default_cap = $source_has_more ? 50 : 10;
233 $max_allowed = (int) apply_filters('embedpress/google_reviews/max_reviews', $default_cap, $args);
234 $max_allowed = $max_allowed > 0 ? $max_allowed : $default_cap;
235 $limit = max(1, min($max_allowed, (int) $args['limit']));
236
237 // READ from the DB store — rendering does no network calls. If the place
238 // was never fetched, populate it once (auto-fetch), then it's DB-only
239 // until an explicit refresh from the settings page.
240 $result = self::get_reviews_for_render($args['place_id'], $args);
241
242 if (is_wp_error($result)) {
243 // "No place selected" is the normal starting state of a freshly added
244 // block/widget — NOT an error. Show a friendly "pick a place" prompt
245 // instead of the alarming red error box.
246 if ($result->get_error_code() === 'embedpress_gr_missing_place') {
247 return self::render_pick_place_prompt();
248 }
249 return self::render_error($result->get_error_message());
250 }
251
252 $reviews = $result['reviews'] ?? [];
253 $meta = $result['meta'] ?? [];
254 $fetch_status = $result['fetch_status'] ?? GoogleReviewsStore::STATUS_DONE;
255
256 if (!empty($args['min_rating'])) {
257 $min = (int) $args['min_rating'];
258 $reviews = array_values(array_filter($reviews, function ($r) use ($min) {
259 return (int) ($r['rating'] ?? 0) >= $min;
260 }));
261 }
262
263 // Sort / keyword / hide-empty are FREE (mirrors Essential Addons' free
264 // Business Reviews widget). Applied here in the free renderer so they
265 // work without Pro. Pro NO LONGER hooks the reviews filter for these —
266 // it would double-apply. Multi-place merge stays Pro (via pre_fetch).
267 $reviews = self::apply_review_filters($reviews, $args);
268
269 /**
270 * Filter the review set after the built-in min-rating/sort/keyword/
271 * hide-empty handling but before the limit slice. Pro hooks here ONLY
272 * for multi-place merging now; sort/keyword/hide-empty are free (above).
273 *
274 * @param array $reviews List of normalized review arrays.
275 * @param array $args Render args.
276 * @param array $meta Place meta (name/rating/total).
277 */
278 $reviews = (array) apply_filters('embedpress/google_reviews/reviews', $reviews, $args, $meta);
279
280 // Total available (sanity-capped) BEFORE slicing — the AJAX load-more
281 // path needs it to know whether more pages exist.
282 $total_available = min(count($reviews), 200);
283
284 // How many cards to render in the INITIAL markup. "Load more" (FREE,
285 // AJAX) renders only the first page; the button fetches the rest from
286 // /google-reviews/page. Sliding layouts render the full set into their
287 // track. (Filter kept for Pro overrides.)
288 $render_count = self::gr_render_count($limit, $reviews, $args);
289 $render_count = (int) apply_filters('embedpress/google_reviews/render_count', $render_count, $reviews, $args);
290 $render_count = $render_count > 0 ? $render_count : $limit;
291 $reviews = array_slice($reviews, 0, $render_count);
292
293 if (empty($reviews)) {
294 // State-aware empty handling — a freshly-added place whose reviews
295 // are still being fetched in the background must NOT look like a dead
296 // "no reviews" block:
297 // running / queued → a "loading" placeholder that self-refreshes
298 // on the frontend (a tiny poller swaps in the reviews when the
299 // job finishes — no manual reload).
300 // failed → nothing for visitors; an actionable retry
301 // notice for admins (current_user_can('edit_posts')).
302 // done / idle → the genuine "No reviews yet" message.
303 if (in_array($fetch_status, [GoogleReviewsStore::STATUS_RUNNING, GoogleReviewsStore::STATUS_QUEUED], true)) {
304 return self::render_loading($args['place_id']);
305 }
306 if ($fetch_status === GoogleReviewsStore::STATUS_FAILED) {
307 return self::render_fetch_failed($args['place_id']);
308 }
309 return self::render_empty();
310 }
311
312 $client_id = 'ep-gr-' . substr(md5(wp_json_encode($args) . microtime(true)), 0, 10);
313 $caps = self::layout_caps($layout);
314
315 // Summary-only layouts (badge/knowledge) ARE the header — force it on so
316 // turning "Show header" off (or an old saved value) can't render an empty
317 // widget. Other layouts honor the toggle.
318 $show_summary = (($caps['header'] ?? 'optional') === 'forced')
319 || (!isset($args['show_summary']) || $args['show_summary']);
320
321 // Theme (light/dark) + accent + carousel/spotlight autoplay are FREE
322 // (mirrors Essential Addons). Build the base class/data set here; Pro
323 // only ADDS its premium-layout preset classes (masonry/badge/etc.) via
324 // the wrapper_class filter below.
325 $base_classes = [];
326 $theme = isset($args['theme']) ? sanitize_key($args['theme']) : 'light';
327 if ($theme === 'dark') {
328 $base_classes[] = 'ep-gr--dark';
329 }
330 if (!empty($args['autoplay']) && in_array($layout, ['carousel', 'spotlight'], true)) {
331 $base_classes[] = 'ep-gr--autoplay';
332 }
333
334 // Pro appends premium-layout preset classes (masonry/badge/spotlight/
335 // knowledge/marquee/bubble). Theme/autoplay above are already free.
336 $extra_class = trim(
337 implode(' ', $base_classes) . ' '
338 . trim((string) apply_filters('embedpress/google_reviews/wrapper_class', '', $args))
339 );
340
341 // Accent color + autoplay flag (FREE) travel as data-* attrs the frontend
342 // JS reads. Pro can still add its own via the wrapper_data filter.
343 $data_attrs = [];
344 $accent = isset($args['accent_color']) ? sanitize_hex_color($args['accent_color']) : '';
345 if ($accent) {
346 $data_attrs['ep-gr-accent'] = $accent;
347 }
348 if (!empty($args['autoplay'])) {
349 $data_attrs['ep-gr-autoplay'] = '1';
350 }
351 $data_attrs = (array) apply_filters('embedpress/google_reviews/wrapper_data', $data_attrs, $args);
352
353 // "Load more" (FREE, AJAX) → emit a config blob the frontend JS uses to
354 // fetch the NEXT page of cards from /google-reviews/page. Skipped for
355 // sliding layouts (they reveal everything through their own nav). The
356 // per-page count IS the "Reviews per page" control.
357 if (self::gr_load_more_applies($args) && $total_available > count($reviews)) {
358 $per_page = self::gr_page_size($args, $limit);
359 $data_attrs['ep-gr-loadmore'] = wp_json_encode([
360 'rest' => esc_url_raw(rest_url(GoogleReviewsRestController::NS . '/google-reviews/page')),
361 'place_id' => (string) $args['place_id'],
362 'per_page' => $per_page,
363 'offset' => count($reviews), // first page already rendered
364 'query' => [
365 'min_rating' => (int) ($args['min_rating'] ?? 0),
366 'layout' => (string) $layout,
367 'show_photo' => !empty($args['show_photo']) ? 1 : 0,
368 'show_date' => !empty($args['show_date']) ? 1 : 0,
369 'show_stars' => !empty($args['show_stars']) ? 1 : 0,
370 'show_images' => !empty($args['show_images']) ? 1 : 0,
371 'sort' => (string) ($args['sort'] ?? 'newest'),
372 'keyword' => (string) ($args['keyword'] ?? ''),
373 'hide_empty' => !empty($args['hide_empty']) ? 1 : 0,
374 'theme' => (string) ($args['theme'] ?? 'light'),
375 'accent_color' => (string) ($args['accent_color'] ?? ''),
376 'places' => array_values((array) ($args['places'] ?? [])),
377 ],
378 ]);
379 }
380
381 // Structural controls → CSS custom properties on the wrapper, emitted
382 // per the layout's capability matrix so a layout never gets a var it
383 // doesn't use (e.g. no columns var for list/badge/spotlight/knowledge,
384 // no gap var for list/spotlight).
385 $columns = max(1, min(6, (int) $args['columns']));
386 $max_width = max(0, (int) $args['max_width']);
387 $gap = max(0, (int) $args['gap']);
388 $style_parts = [];
389 if ($caps['columns']) {
390 $style_parts[] = '--ep-gr-columns:' . $columns;
391 // Expose columns/per-view to frontend JS (carousel slides-per-view).
392 $data_attrs['ep-gr-columns'] = $columns;
393 }
394 if ($caps['gap']) {
395 $style_parts[] = '--ep-gr-gap:' . $gap . 'px';
396 }
397 if ($max_width > 0) {
398 $style_parts[] = '--ep-gr-max-width:' . $max_width . 'px';
399 }
400 // Accent color must be set as the CSS custom property so the
401 // var(--ep-gr-accent) rules (stars, load-more, dots…) actually pick it
402 // up. The data-ep-gr-accent attribute alone never defines the variable,
403 // so the accent had no visible effect (QA #8). $accent is the sanitized
404 // hex computed above.
405 if (!empty($accent)) {
406 $style_parts[] = '--ep-gr-accent:' . $accent;
407 }
408 $wrapper_style = !empty($style_parts) ? implode(';', $style_parts) . ';' : '';
409
410 // Slider controls (free) → data attrs the frontend reads. Each sliding
411 // layout gets the subset it uses.
412 $speed = max(1, min(30, (float) ($args['autoplay_speed'] ?? 5)));
413 if ($layout === 'carousel') {
414 $data_attrs['ep-gr-arrows'] = !empty($args['show_arrows']) ? '1' : '0';
415 $data_attrs['ep-gr-dots'] = !empty($args['show_dots']) ? '1' : '0';
416 $data_attrs['ep-gr-loop'] = !empty($args['carousel_loop']) ? '1' : '0';
417 $data_attrs['ep-gr-autoplay'] = !empty($args['autoplay']) ? '1' : '0';
418 $data_attrs['ep-gr-speed'] = $speed;
419 } elseif ($layout === 'spotlight') {
420 $data_attrs['ep-gr-autoplay'] = !empty($args['autoplay']) ? '1' : '0';
421 $data_attrs['ep-gr-speed'] = $speed;
422 } elseif ($layout === 'marquee') {
423 // Marquee speed expressed as the same seconds knob (lower = faster).
424 $data_attrs['ep-gr-speed'] = $speed;
425 }
426
427 ob_start();
428 ?>
429 <div id="<?php echo esc_attr($client_id); ?>" class="ep-google-reviews ep-google-reviews--<?php echo esc_attr($layout); ?><?php echo $extra_class !== '' ? ' ' . esc_attr($extra_class) : ''; ?>" data-layout="<?php echo esc_attr($layout); ?>" style="<?php echo esc_attr($wrapper_style); ?>"<?php
430 foreach ($data_attrs as $k => $v) {
431 if (!is_string($k) || $k === '') {
432 continue;
433 }
434 echo ' data-' . esc_attr($k) . '="' . esc_attr(is_scalar($v) ? (string) $v : wp_json_encode($v)) . '"';
435 }
436 ?>>
437 <?php
438 // Schema.org JSON-LD (FREE — mirrors Essential Addons' free Local
439 // Business schema). Emitted inside the wrapper so it travels with the
440 // markup. The filter is kept so Pro/3rd-parties can still augment it.
441 echo apply_filters('embedpress/google_reviews/schema_jsonld', self::build_schema_jsonld('', $reviews, $meta, $args), $reviews, $meta, $args);
442
443 if ($show_summary) {
444 echo self::render_summary($meta, $args);
445 }
446 ?>
447 <div class="ep-gr-items">
448 <?php foreach ($reviews as $review) : ?>
449 <?php
450 /**
451 * Filter a single rendered review card's HTML. Pro hooks
452 * here to add per-card chrome (e.g. a Google logo, owner
453 * response, "verified" badge).
454 *
455 * @param string $card_html Rendered card HTML.
456 * @param array $review The review data.
457 * @param array $args Render args.
458 */
459 echo apply_filters('embedpress/google_reviews/review_card', self::render_review($review, $args), $review, $args);
460 ?>
461 <?php endforeach; ?>
462 </div>
463 <?php if ($args['show_link'] && !empty($args['place_id'])) : ?>
464 <div class="ep-gr-footer">
465 <a class="ep-gr-view-on-google" href="<?php echo esc_url('https://search.google.com/local/reviews?placeid=' . rawurlencode($args['place_id'])); ?>" target="_blank" rel="noopener nofollow">
466 <?php echo esc_html__('View on Google', 'embedpress'); ?>
467 </a>
468 </div>
469 <?php endif; ?>
470 </div>
471 <?php
472 $html = ob_get_clean();
473
474 // "Load more" button (FREE, AJAX) — appended when more reviews exist than
475 // the first page rendered. The JS fetches subsequent pages on click.
476 $html = self::gr_append_load_more($html, count($reviews), $total_available, $args);
477
478 /**
479 * Final filter over the complete block HTML. Last-resort hook for Pro
480 * (e.g. wrapping the whole thing). Most Pro work should use the more
481 * specific filters above.
482 */
483 return (string) apply_filters('embedpress/google_reviews/html', $html, $reviews, $meta, $args);
484 }
485
486 /* ── Load more (FREE): paginate the rendered set, no extra API calls ──── */
487
488 /**
489 * Layouts where "Load more" applies — vertical/flow layouts that stack
490 * cards. Sliding layouts (carousel/marquee/spotlight) own their navigation,
491 * so load-more is a no-op there.
492 */
493 private static function gr_load_more_applies(array $args): bool
494 {
495 if (empty($args['load_more'])) {
496 return false;
497 }
498 $layout = isset($args['layout']) ? sanitize_key($args['layout']) : 'list';
499 return !in_array($layout, ['carousel', 'marquee', 'spotlight'], true);
500 }
501
502 /**
503 * Is Pro UNLOCKED for Google Reviews? Keyed on the Pro PLUGIN being active —
504 * NOT on a valid license. This MUST match the editor's gate
505 * (`isProPluginActive` = defined EMBEDPRESS_SL_ITEM_SLUG, i.e. Pro plugin
506 * active) and the GR block's other Pro controls (sort/theme/etc. gate on
507 * is_pro_active()). Using the stricter license check (is_pro_features_enabled)
508 * here caused the "Pro is active but Masonry shows the upsell" bug: the editor
509 * let you pick the Pro layout (plugin active) while the renderer demanded a
510 * valid license and rendered the upsell — a confusing editor/renderer
511 * mismatch. Gate on plugin-active everywhere so the two surfaces agree.
512 */
513 private static function gr_pro_unlocked(): bool
514 {
515 return (bool) Helper::is_pro_active();
516 }
517
518 /** Effective page size = the "Reviews per page" count (limit). */
519 private static function gr_page_size(array $args, int $limit): int
520 {
521 $n = (int) ($args['per_page'] ?? 0);
522 if ($n < 1) {
523 $n = (int) ($args['limit'] ?? $limit);
524 }
525 return max(1, $n);
526 }
527
528 /**
529 * How many cards to render in the INITIAL markup.
530 * - Sliding layouts render the full set (their nav reveals it).
531 * - "Load more" (AJAX) renders just the FIRST page; the button fetches the
532 * rest from /google-reviews/page on demand.
533 * - Otherwise the display limit.
534 */
535 private static function gr_render_count(int $limit, array $reviews, array $args): int
536 {
537 $layout = isset($args['layout']) ? sanitize_key($args['layout']) : 'list';
538 if (in_array($layout, ['carousel', 'marquee', 'spotlight'], true)) {
539 // EDITOR PREVIEW: cap sliding layouts to a few cards. Rendering up to
540 // 200 cards makes each block-renderer SSR response ~1.3MB; with
541 // ServerSideRender re-firing on every attribute change, rapid layout
542 // switches piled up huge in-flight requests and froze the editor. A
543 // handful of cards is plenty to preview the layout. The FRONTEND
544 // still renders the full set (this flag is only set for the
545 // block-editor SSR request).
546 if (!empty($args['is_editor_preview'])) {
547 return min(count($reviews), 12);
548 }
549 return min(count($reviews), 200);
550 }
551 if (self::gr_load_more_applies($args)) {
552 return self::gr_page_size($args, $limit); // first page only — AJAX loads more
553 }
554 return $limit;
555 }
556
557 /** Append the "Load more" button when more reviews exist than the rendered page. */
558 private static function gr_append_load_more(string $html, int $rendered, int $total, array $args): string
559 {
560 if (!self::gr_load_more_applies($args)) {
561 return $html;
562 }
563 if ($total <= $rendered) {
564 return $html; // everything already shown — nothing to page
565 }
566 $btn = '<div class="ep-gr-loadmore-wrap"><button type="button" class="ep-gr-loadmore">'
567 . esc_html__('Load more reviews', 'embedpress')
568 . '</button></div>';
569 // Insert before the closing wrapper </div> so it sits inside .ep-google-reviews.
570 $pos = strrpos($html, '</div>');
571 if ($pos === false) {
572 return $html . $btn;
573 }
574 return substr($html, 0, $pos) . $btn . substr($html, $pos);
575 }
576
577 /**
578 * AJAX "Load more": render ONE page of review cards from the DB store.
579 * Returns ['html' => cards, 'has_more' => bool, 'next_offset' => int].
580 * Pure DB read — same fetch+filter pipeline as render(), then a slice.
581 */
582 public static function render_page(array $args, int $offset, int $per_page): array
583 {
584 $args = self::enforce_layout_caps($args);
585 $args = wp_parse_args($args, self::DEFAULTS);
586
587 if (!Helper::is_pro_active()) {
588 // Mirror render()'s server-side free enforcement for the Pro-gated bits
589 // a card can carry (none affect card text, but keep parity).
590 $args['accent_color'] = $args['accent_color'] ?? '';
591 }
592
593 $args = (array) apply_filters('embedpress/google_reviews/render_args', $args);
594 // Public "load more" endpoint: render from the store only, never fetch.
595 // Prevents an unauthenticated caller from spraying arbitrary place_ids to
596 // create rows / queue paid scrapes (storage + cost DoS). The place was
597 // already fetched during the initial server-side render, so it's present.
598 $args['render_only'] = true;
599 $result = self::get_reviews_for_render($args['place_id'], $args);
600 if (is_wp_error($result)) {
601 return ['html' => '', 'has_more' => false, 'next_offset' => $offset];
602 }
603
604 $reviews = $result['reviews'] ?? [];
605 $meta = $result['meta'] ?? [];
606
607 // Apply the SAME filter pipeline as render() so paged ("Load more")
608 // results match the first page. Previously this only applied min_rating,
609 // so hide-empty / keyword / sort were skipped on pages 2+ and e.g.
610 // no-text reviews reappeared after Load more even with "Hide reviews
611 // with no text" enabled.
612 if (!empty($args['min_rating'])) {
613 $min = (int) $args['min_rating'];
614 $reviews = array_values(array_filter($reviews, static function ($r) use ($min) {
615 return (int) ($r['rating'] ?? 0) >= $min;
616 }));
617 }
618 $reviews = self::apply_review_filters($reviews, $args);
619 $reviews = (array) apply_filters('embedpress/google_reviews/reviews', $reviews, $args, $meta);
620
621 // Cap the full set the same way render() does (sanity ceiling), then slice
622 // to the requested page.
623 $total = min(count($reviews), 200);
624 $slice = array_slice($reviews, $offset, $per_page);
625
626 $html = '';
627 foreach ($slice as $review) {
628 $html .= apply_filters('embedpress/google_reviews/review_card', self::render_review($review, $args), $review, $args);
629 }
630
631 $next_offset = $offset + count($slice);
632 return [
633 'html' => $html,
634 'has_more' => $next_offset < $total,
635 'next_offset' => $next_offset,
636 ];
637 }
638
639 /**
640 * Get a place's reviews for rendering — from the DB store, never the network.
641 * If the place has never been fetched, do a one-time auto-fetch to populate
642 * the store; thereafter it's DB-only until the user refreshes from settings.
643 *
644 * @return array|\WP_Error {reviews, meta}
645 */
646 public static function get_reviews_for_render(string $place_id, array $args = [])
647 {
648 $place_id = trim($place_id);
649 if ($place_id === '') {
650 return new \WP_Error('embedpress_gr_missing_place', __('No place selected.', 'embedpress'));
651 }
652
653 $row = GoogleReviewsStore::get($place_id);
654
655 // RENDER-ONLY guard (public, unauthenticated callers — e.g. the "load
656 // more" REST endpoint). Such callers must NEVER trigger a fetch: doing so
657 // would let anyone spray arbitrary place_ids to auto-create store rows and
658 // queue paid background scrapes (storage / cost DoS). A legitimate load-
659 // more is always for a place already rendered on the page, so it's already
660 // in the store — a plain read is all that's needed. If it's not there, we
661 // return empty rather than fetching.
662 if (!empty($args['render_only'])) {
663 if (!$row) {
664 return ['reviews' => [], 'meta' => [], 'fetch_status' => GoogleReviewsStore::STATUS_IDLE];
665 }
666 return [
667 'reviews' => is_array($row['reviews']) ? $row['reviews'] : [],
668 'meta' => is_array($row['meta']) ? $row['meta'] : [],
669 'fetch_status' => (string) ($row['fetch_status'] ?? GoogleReviewsStore::STATUS_IDLE),
670 ];
671 }
672
673 // Never fetched (new place, or added but not yet populated) → auto-fetch
674 // once to fill the store. Pro's "fetch all" mode is honoured here too.
675 // BUT skip if a background job is already running/queued for this place —
676 // otherwise every render would restart the job (the instant batch sets
677 // reviews but not last_fetched_at, which stays null until the job ends).
678 $job_active = $row && in_array(
679 $row['fetch_status'] ?? '',
680 [GoogleReviewsStore::STATUS_RUNNING, GoogleReviewsStore::STATUS_QUEUED],
681 true
682 );
683
684 // SELF-HEAL for sites the push webhook can't reach. Results normally
685 // arrive via the webhook (api.embedpress.com POSTs straight into the
686 // store the instant the scrape ends). But on localhost / firewalled /
687 // basic-auth sites the API can't POST in — so a job could sit "running"
688 // here forever with the reviews stranded on the proxy. If a job has been
689 // active longer than the grace window (the webhook should have landed by
690 // now), do ONE inline status.php pull to backfill. This is recovery, not
691 // a timeout: it NEVER flips the place to "failed" — poll_job() either
692 // finalizes with the proxy's reviews or leaves it running for next time.
693 if ($job_active && class_exists('\\EmbedPress\\Includes\\Classes\\GoogleReviewsManaged')) {
694 // updated_at is stored in SITE-LOCAL time (current_time('mysql')), so
695 // compare against a site-local "now" to avoid a timezone-offset skew.
696 $updated = isset($row['updated_at']) ? strtotime((string) $row['updated_at']) : 0;
697 $now_local = (int) current_time('timestamp');
698 $grace = (int) apply_filters('embedpress/google_reviews/webhook_grace_seconds', 45, $place_id);
699 if ($updated > 0 && ($now_local - $updated) >= $grace) {
700 try {
701 (new GoogleReviewsManaged())->poll_job($place_id);
702 $row = GoogleReviewsStore::get($place_id);
703 $job_active = $row && in_array(
704 $row['fetch_status'] ?? '',
705 [GoogleReviewsStore::STATUS_RUNNING, GoogleReviewsStore::STATUS_QUEUED],
706 true
707 );
708 } catch (\Throwable $e) {
709 // Recovery is best-effort — a pull error must never break the
710 // render; fall through and show whatever the store has.
711 }
712 }
713 }
714
715 if (!$job_active && (!$row || empty($row['last_fetched_at']))) {
716 // First render of a freshly-selected place: do a CHEAP, BOUNDED
717 // preview fetch (≤5) only. The expensive "fetch all" (Apify run /
718 // background job, up to the ceiling) must be an explicit, opted-in
719 // action from EmbedPress → Google Reviews → Refetch — never an
720 // automatic side effect of dropping the block on a page. The `auto`
721 // flag tells fetch_into_store to stay on the cheap path.
722 $fetched = self::fetch_into_store($place_id, array_merge($args, ['auto' => true]));
723 if (is_wp_error($fetched)) {
724 return $fetched;
725 }
726 $row = GoogleReviewsStore::get($place_id);
727 }
728
729 if (!$row) {
730 return ['reviews' => [], 'meta' => [], 'fetch_status' => GoogleReviewsStore::STATUS_IDLE];
731 }
732
733 return [
734 'reviews' => is_array($row['reviews']) ? $row['reviews'] : [],
735 'meta' => is_array($row['meta']) ? $row['meta'] : [],
736 // Surface the fetch state so render() can tell apart "still fetching"
737 // (running/queued) from "fetched but genuinely empty" (done) and
738 // "failed" — each gets a different placeholder instead of one flat
739 // "No reviews" message. last_fetched_at null + no active job → idle.
740 'fetch_status' => (string) ($row['fetch_status'] ?? GoogleReviewsStore::STATUS_IDLE),
741 ];
742 }
743
744 /**
745 * Fetch a place's reviews from the source (Pro Apify when fetch_all + token,
746 * else the official ≤5 API) and persist them to the DB store. This is the
747 * ONLY path that hits the network for reviews. Triggered by:
748 * - the settings-page "Refresh" action, or
749 * - a one-time auto-fetch on first render (get_reviews_for_render).
750 *
751 * @return array|\WP_Error {reviews, meta, source}
752 */
753 public static function fetch_into_store(string $place_id, array $args = [])
754 {
755 $place_id = trim($place_id);
756 if ($place_id === '') {
757 return new \WP_Error('embedpress_gr_missing_place', __('No place selected.', 'embedpress'));
758 }
759
760 // Cheap auto-preview fetch (first render): never fetch-all. A bounded
761 // ≤5 pull (Apify run capped at 5, or the Google ≤5 API) is enough to make
762 // the block show real reviews immediately, without burning a large Apify
763 // run. The expensive fetch-all only happens via an explicit Settings
764 // Refetch (which does NOT set `auto`).
765 $is_auto = !empty($args['auto']);
766 if ($is_auto) {
767 $args['fetch_all'] = false;
768 if (!isset($args['fetch_max']) || (int) $args['fetch_max'] < 1) {
769 $args['fetch_max'] = self::AUTO_PREVIEW_LIMIT;
770 }
771 }
772
773 // ── EmbedPress managed scraper WINS (when connected) ──────────────────
774 // The self-hosted scraper at api.embedpress.com/google-reviews/v1
775 // fetches ALL reviews for free — strictly better than the Apify
776 // ≤preview run or the Google Places ≤5 API. When the site is connected,
777 // route EVERY fetch (incl. auto-add) straight to it: a cache HIT returns
778 // all reviews inline; a MISS enqueues a background job (the store goes
779 // `running`, the cron poller fills it in, the block re-renders). We do
780 // NOT fall back to a partial Apify/Google preview that would "succeed"
781 // with a few reviews and freeze the place as done.
782 if (\EmbedPress\Includes\Classes\GoogleReviewsManaged::is_connected()) {
783 $managed_args = $args;
784 $managed_args['fetch_all'] = true; // always pull everything
785 unset($managed_args['fetch_max']); // no preview cap
786 $started = \EmbedPress\Includes\Classes\GoogleReviewsManaged::start_job($place_id, $managed_args);
787 $row = GoogleReviewsStore::get($place_id);
788 if ($started) {
789 // Cache hit → store already holds the full set; miss → running.
790 return [
791 'reviews' => ($row && is_array($row['reviews'])) ? $row['reviews'] : [],
792 'meta' => ($row && is_array($row['meta'])) ? $row['meta'] : [],
793 'source' => 'embedpress',
794 ];
795 }
796 // start_job returned false (proxy refused / unreachable) → fall
797 // through to the legacy Apify/Google paths so the block still shows
798 // something rather than nothing.
799 }
800
801 // Not connected (or managed refused): legacy provider preference.
802 $args = self::maybe_prefer_apify_fetch($args);
803
804 // ── Background fetch-all strategy (Apify) ─────────────────────────────
805 // Pulling every review via Apify takes time (the run-sync actor alone has
806 // a ~15s floor). Rather than block the first render, we start the
807 // background batched job and return immediately — the UIs render now and
808 // show a live progress bar ("Fetching all reviews… N so far"), then
809 // re-render as the cron imports batches into the store (up to the 1000
810 // ceiling). The job manages fetch_status / fetched_so_far itself.
811 // Skipped entirely for auto-previews (cheap path only).
812 if (!$is_auto && !empty($args['fetch_all']) && self::get_apify_token() !== '') {
813 $started = (bool) apply_filters('embedpress/google_reviews/start_fetch_job', false, $place_id, $args);
814 if ($started) {
815 $row = GoogleReviewsStore::get($place_id);
816 return [
817 'reviews' => ($row && is_array($row['reviews'])) ? $row['reviews'] : [],
818 'meta' => ($row && is_array($row['meta'])) ? $row['meta'] : [],
819 'source' => 'apify',
820 ];
821 }
822 // Job couldn't start (no token / Apify error) → fall through to the
823 // official ≤5 API path below so the block still shows something.
824 }
825
826 // Pro hooks pre_fetch to source reviews (Apify "fetch all" / multi-place).
827 // Returns a {reviews, meta} array, or null to use the official API.
828 $pre = apply_filters('embedpress/google_reviews/pre_fetch', null, $place_id, $args);
829 if (is_array($pre)) {
830 $result = self::normalize_result($pre);
831 $source = !empty($args['fetch_all']) ? 'apify' : 'api';
832 } else {
833 $result = self::fetch_from_api($place_id);
834 if (is_wp_error($result)) {
835 return $result;
836 }
837 $source = 'api';
838 }
839
840 $reviews = $result['reviews'] ?? [];
841 $meta = $result['meta'] ?? [];
842
843 GoogleReviewsStore::save_reviews($place_id, $reviews, $meta, $source);
844
845 return ['reviews' => $reviews, 'meta' => $meta, 'source' => $source];
846 }
847
848 /**
849 * If Apify is the usable provider (token set) and Google is not (no key),
850 * flag fetch_all so the Pro Apify pre_fetch sources reviews instead of the
851 * Google API. Only flips the flag when it isn't already set and a Google
852 * fetch would otherwise fail. No-op when a Google key is present (Google
853 * stays the default free path) or when no Apify token is configured.
854 */
855 private static function maybe_prefer_apify_fetch(array $args): array
856 {
857 if (!empty($args['fetch_all'])) {
858 return $args; // already routing through Apify (explicit fetch-all)
859 }
860 $has_google = self::get_api_key() !== '';
861 $has_apify = self::get_apify_token() !== '';
862 if (!$has_google && $has_apify) {
863 // Apify-only site. For an auto-preview, route through Apify but keep
864 // it BOUNDED (fetch_max already set to the preview limit) instead of
865 // flipping fetch_all — a key-less site should still show a few
866 // reviews without an unbounded run. For non-auto (explicit) fetches,
867 // fetch_all stays the full pull.
868 if (empty($args['auto'])) {
869 $args['fetch_all'] = true;
870 } else {
871 // Bounded Apify preview: pre_fetch keys off fetch_all OR places,
872 // so signal a capped fetch_all and let fetch_max do the bounding.
873 $args['fetch_all'] = true;
874 $args['fetch_max'] = isset($args['fetch_max']) && (int) $args['fetch_max'] > 0
875 ? (int) $args['fetch_max']
876 : self::AUTO_PREVIEW_LIMIT;
877 }
878 }
879 return $args;
880 }
881
882 /**
883 * Render the summary header: place name + overall Google rating + total
884 * review count. Falls back gracefully when meta is unavailable (e.g. an
885 * older cache or an API that didn't return it) — it simply renders nothing
886 * rather than a half-empty header.
887 */
888 private static function render_summary(array $meta, array $args): string
889 {
890 $name = $meta['name'] ?? ($args['place_name'] ?? '');
891 $rating = isset($meta['rating']) ? (float) $meta['rating'] : 0.0;
892 $total = isset($meta['total']) ? (int) $meta['total'] : 0;
893
894 if ($name === '' && $rating <= 0) {
895 return '';
896 }
897
898 $address = isset($meta['address']) ? (string) $meta['address'] : '';
899
900 // Granular header toggles (free). Each part can be hidden independently.
901 $show_name = !isset($args['show_summary_name']) || $args['show_summary_name'];
902 $show_rating = !isset($args['show_summary_rating']) || $args['show_summary_rating'];
903 $show_stars = !isset($args['show_summary_stars']) || $args['show_summary_stars'];
904 $show_count = !isset($args['show_summary_count']) || $args['show_summary_count'];
905 $show_write = !isset($args['show_write_review']) || $args['show_write_review'];
906
907 $align = isset($args['summary_align']) ? sanitize_key($args['summary_align']) : 'left';
908 $align = in_array($align, ['left', 'center', 'right'], true) ? $align : 'left';
909 $align_class = ' ep-gr-summary--align-' . $align;
910
911 // Nothing left to show in the rating row? skip it entirely.
912 $has_rating_row = $rating > 0 && ($show_rating || $show_stars || $show_count);
913
914 ob_start();
915 ?>
916 <div class="ep-gr-summary<?php echo esc_attr($align_class); ?>">
917 <div class="ep-gr-summary-head">
918 <div class="ep-gr-summary-place">
919 <?php if ($name !== '' && $show_name) : ?>
920 <div class="ep-gr-summary-name"><?php echo esc_html($name); ?></div>
921 <?php endif; ?>
922 <?php if ($address !== '' && $show_name) : ?>
923 <div class="ep-gr-summary-address"><?php echo esc_html($address); ?></div>
924 <?php endif; ?>
925 </div>
926 <?php if (!empty($args['place_id']) && $show_write) : ?>
927 <a class="ep-gr-write-review" href="<?php echo esc_url('https://search.google.com/local/writereview?placeid=' . rawurlencode($args['place_id'])); ?>" target="_blank" rel="noopener nofollow"><?php esc_html_e('Write a review', 'embedpress'); ?></a>
928 <?php endif; ?>
929 </div>
930 <?php if ($has_rating_row) : ?>
931 <div class="ep-gr-summary-rating">
932 <?php if ($show_rating) : ?>
933 <span class="ep-gr-summary-score"><?php echo esc_html(number_format_i18n($rating, 1)); ?></span>
934 <?php endif; ?>
935 <?php if ($show_stars) : ?>
936 <span class="ep-gr-stars ep-gr-stars--lg" role="img" aria-label="<?php /* translators: %s: average star rating out of 5 */ echo esc_attr(sprintf(__('%s out of 5 stars', 'embedpress'), number_format_i18n($rating, 1))); ?>">
937 <?php echo self::render_star_row($rating); ?>
938 </span>
939 <?php endif; ?>
940 <?php if ($total > 0 && $show_count) : ?>
941 <span class="ep-gr-summary-count"><?php
942 /* translators: %s: number of Google reviews */
943 echo esc_html(sprintf(_n('%s review', '%s reviews', $total, 'embedpress'), number_format_i18n($total)));
944 ?></span>
945 <?php endif; ?>
946 </div>
947 <?php endif; ?>
948 </div>
949 <?php
950 return ob_get_clean();
951 }
952
953 /**
954 * Render a single review card.
955 */
956 private static function render_review(array $review, array $args): string
957 {
958 $author = $review['author_name'] ?? __('Anonymous', 'embedpress');
959 $rating = (int) ($review['rating'] ?? 0);
960 $text = $review['text'] ?? '';
961 $photo = $review['profile_photo_url'] ?? '';
962 $time = isset($review['time']) ? (int) $review['time'] : 0;
963 // Prefer Google's relative phrasing ("a week ago") and fall back to an
964 // absolute date. Matches how reviews read on Google itself.
965 $relative = isset($review['relative_time']) ? (string) $review['relative_time'] : '';
966
967 // Rich fields (present only for Apify-sourced reviews; absent → not rendered).
968 $is_local_guide = !empty($review['is_local_guide']);
969 $rev_count = isset($review['reviewer_reviews']) ? (int) $review['reviewer_reviews'] : 0;
970 $images = (isset($review['images']) && is_array($review['images'])) ? $review['images'] : [];
971 $owner_resp = isset($review['owner_response']) ? (string) $review['owner_response'] : '';
972 $likes = isset($review['likes']) ? (int) $review['likes'] : 0;
973
974 ob_start();
975 ?>
976 <article class="ep-gr-review" itemscope itemtype="https://schema.org/Review">
977 <header class="ep-gr-review-head">
978 <?php if ($args['show_photo']) : ?>
979 <?php /* Initials placeholder sits underneath; the photo overlays it
980 and, if it fails to load, removes itself so the initials show. */ ?>
981 <span class="ep-gr-avatar ep-gr-avatar--placeholder" aria-hidden="true">
982 <?php echo esc_html(self::initials($author)); ?>
983 <?php if ($photo) : ?>
984 <img class="ep-gr-avatar-img" src="<?php echo esc_url($photo); ?>" alt="" loading="lazy" width="40" height="40" referrerpolicy="no-referrer" onerror="this.remove()" />
985 <?php endif; ?>
986 </span>
987 <?php endif; ?>
988 <div class="ep-gr-meta">
989 <div class="ep-gr-author" itemprop="author"><?php echo esc_html($author); ?></div>
990 <?php if ($is_local_guide || $rev_count > 0) : ?>
991 <div class="ep-gr-reviewer-meta">
992 <?php if ($is_local_guide) : ?>
993 <span class="ep-gr-local-guide"><?php esc_html_e('Local Guide', 'embedpress'); ?></span>
994 <?php endif; ?>
995 <?php if ($is_local_guide && $rev_count > 0) : ?>
996 <span class="ep-gr-dot" aria-hidden="true">·</span>
997 <?php endif; ?>
998 <?php if ($rev_count > 0) : ?>
999 <span class="ep-gr-reviewer-count"><?php
1000 /* translators: %s: number of reviews the reviewer has written */
1001 echo esc_html(sprintf(_n('%s review', '%s reviews', $rev_count, 'embedpress'), number_format_i18n($rev_count)));
1002 ?></span>
1003 <?php endif; ?>
1004 </div>
1005 <?php endif; ?>
1006 </div>
1007 <?php /* Google "G" badge — the recognizable mark real Google review
1008 widgets show top-right of each card. */ ?>
1009 <span class="ep-gr-source" aria-label="<?php esc_attr_e('Posted on Google', 'embedpress'); ?>">
1010 <?php echo self::google_g_svg(); ?>
1011 </span>
1012 </header>
1013
1014 <?php if ($args['show_stars'] || ($args['show_date'] && ($relative !== '' || $time))) : ?>
1015 <div class="ep-gr-stars-line">
1016 <?php if ($args['show_stars']) : ?>
1017 <span class="ep-gr-stars" role="img" aria-label="<?php /* translators: %d: star rating out of 5 */ echo esc_attr(sprintf(__('%d out of 5 stars', 'embedpress'), $rating)); ?>">
1018 <?php echo self::render_star_row((float) $rating); ?>
1019 </span>
1020 <?php endif; ?>
1021 <?php if ($args['show_date'] && ($relative !== '' || $time)) : ?>
1022 <time class="ep-gr-date"<?php echo $time ? ' datetime="' . esc_attr(gmdate('c', $time)) . '"' : ''; ?>><?php
1023 echo esc_html($relative !== '' ? $relative : date_i18n(get_option('date_format'), $time));
1024 ?></time>
1025 <?php endif; ?>
1026 </div>
1027 <?php endif; ?>
1028
1029 <?php if ($text) : ?>
1030 <div class="ep-gr-body">
1031 <div class="ep-gr-text" itemprop="reviewBody"><?php echo esc_html($text); ?></div>
1032 <?php /* JS reveals this only when the text actually overflows the clamp. */ ?>
1033 <button type="button" class="ep-gr-readmore" hidden aria-expanded="false">
1034 <span class="ep-gr-readmore-more"><?php esc_html_e('Read more', 'embedpress'); ?></span>
1035 <span class="ep-gr-readmore-less"><?php esc_html_e('Show less', 'embedpress'); ?></span>
1036 </button>
1037 </div>
1038 <?php endif; ?>
1039
1040 <?php if (!empty($images) && !empty($args['show_images'])) : ?>
1041 <?php
1042 // Cap rendered thumbnails; show a "+N" overlay on the last tile
1043 // when there are more. data-count drives the CSS grid layout.
1044 $max_thumbs = 4;
1045 $total_imgs = count($images);
1046 $shown = array_slice($images, 0, $max_thumbs);
1047 $overflow = $total_imgs - count($shown);
1048 ?>
1049 <div class="ep-gr-photos" data-count="<?php echo esc_attr((string) count($shown)); ?>">
1050 <?php foreach ($shown as $idx => $img) : ?>
1051 <?php $is_last = ($idx === count($shown) - 1); ?>
1052 <span class="ep-gr-photo<?php echo ($is_last && $overflow > 0) ? ' ep-gr-photo--more' : ''; ?>"<?php
1053 echo ($is_last && $overflow > 0) ? ' data-more="+' . esc_attr((string) $overflow) . '"' : '';
1054 ?>>
1055 <img src="<?php echo esc_url($img); ?>" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.parentNode.remove()" />
1056 </span>
1057 <?php endforeach; ?>
1058 </div>
1059 <?php endif; ?>
1060
1061 <?php if ($owner_resp !== '') : ?>
1062 <div class="ep-gr-owner">
1063 <div class="ep-gr-owner-head"><?php esc_html_e('Response from the owner', 'embedpress'); ?></div>
1064 <div class="ep-gr-owner-text"><?php echo wp_kses(nl2br(esc_html($owner_resp)), ['br' => []]); ?></div>
1065 </div>
1066 <?php endif; ?>
1067
1068 <?php /* Like / thanks action row — mirrors Google's review footer. Static
1069 (display only), like the rest of an embedded review. */ ?>
1070 <div class="ep-gr-rev-actions" aria-hidden="true">
1071 <span class="ep-gr-rev-action ep-gr-rev-like" title="<?php esc_attr_e('Helpful', 'embedpress'); ?>">
1072 <svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 1 0-7.78 7.78L12 21.23l8.84-8.84a5.5 5.5 0 0 0 0-7.78z"/></svg>
1073 </span>
1074 <span class="ep-gr-rev-action ep-gr-rev-thanks">
1075 <span class="ep-gr-thanks-icon" aria-hidden="true">🙏</span>
1076 <?php if ($likes > 0) : ?><span class="ep-gr-thanks-count"><?php echo esc_html(number_format_i18n($likes)); ?></span><?php endif; ?>
1077 </span>
1078 </div>
1079 </article>
1080 <?php
1081 return ob_get_clean();
1082 }
1083
1084 /**
1085 * Float-aware star row, Google style: a full gray 5-star track with a gold
1086 * fill overlay clipped to the exact rating (so 4.5 shows half a star). The
1087 * rating drives the fill width via the `--ep-gr-rating` custom property; CSS
1088 * does `width: calc(var(--ep-gr-rating)/5*100%)`. Glyphs are aria-hidden —
1089 * the accessible label lives on the wrapping element (caller supplies it).
1090 *
1091 * Back-compat: the inner `.ep-gr-star.is-filled/.is-empty` spans are kept in
1092 * the track so existing Pro CSS that colors `.is-filled` still applies.
1093 */
1094 private static function render_star_row(float $rating): string
1095 {
1096 $rating = max(0.0, min(5.0, $rating));
1097 // Crisp SVG star (one per slot) instead of a font glyph, so the shape is
1098 // identical across platforms/fonts. The gold fill is clipped to the
1099 // rating percentage for half-star precision (CSS overflow:hidden).
1100 $star = '<svg class="ep-gr-star-svg" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>';
1101 $glyphs = str_repeat($star, 5);
1102 $pct = ($rating / 5) * 100;
1103 return '<span class="ep-gr-starrow" style="--ep-gr-rating:' . esc_attr((string) $rating) . '" aria-hidden="true">'
1104 . '<span class="ep-gr-starrow-track">' . $glyphs . '</span>'
1105 . '<span class="ep-gr-starrow-fill" style="width:' . esc_attr((string) round($pct, 2)) . '%">' . $glyphs . '</span>'
1106 . '</span>';
1107 }
1108
1109 /** The Google "G" logo as inline SVG (brand 4-color mark). */
1110 private static function google_g_svg(): string
1111 {
1112 return '<svg viewBox="0 0 48 48" width="18" height="18" aria-hidden="true">'
1113 . '<path fill="#4285F4" d="M45.12 24.5c0-1.56-.14-3.06-.4-4.5H24v8.51h11.84c-.51 2.75-2.06 5.08-4.39 6.64v5.52h7.11c4.16-3.83 6.56-9.47 6.56-16.17z"/>'
1114 . '<path fill="#34A853" d="M24 46c5.94 0 10.92-1.97 14.56-5.33l-7.11-5.52c-1.97 1.32-4.49 2.1-7.45 2.1-5.73 0-10.58-3.87-12.31-9.07H4.34v5.7C7.96 41.07 15.4 46 24 46z"/>'
1115 . '<path fill="#FBBC05" d="M11.69 28.18A13.6 13.6 0 0 1 10.96 24c0-1.45.25-2.86.69-4.18v-5.7H4.34A21.99 21.99 0 0 0 2 24c0 3.55.85 6.91 2.34 9.88l7.35-5.7z"/>'
1116 . '<path fill="#EA4335" d="M24 10.75c3.23 0 6.13 1.11 8.41 3.29l6.31-6.31C34.91 4.18 29.93 2 24 2 15.4 2 7.96 6.93 4.34 14.12l7.35 5.7c1.73-5.2 6.58-9.07 12.31-9.07z"/>'
1117 . '</svg>';
1118 }
1119
1120 /**
1121 * Whole-star renderer (kept for back-compat / any caller that wants discrete
1122 * stars). New code should use render_star_row() for half-star support.
1123 */
1124 private static function render_stars(int $rating): string
1125 {
1126 $rating = max(0, min(5, $rating));
1127 $out = '';
1128 for ($i = 1; $i <= 5; $i++) {
1129 $out .= '<span class="ep-gr-star ' . ($i <= $rating ? 'is-filled' : 'is-empty') . '" aria-hidden="true">�
1130 </span>';
1131 }
1132 return $out;
1133 }
1134
1135 private static function initials(string $name): string
1136 {
1137 $parts = preg_split('/\s+/', trim($name)) ?: [];
1138 $first = mb_substr($parts[0] ?? '', 0, 1);
1139 $last = isset($parts[1]) ? mb_substr($parts[1], 0, 1) : '';
1140 return strtoupper($first . $last);
1141 }
1142
1143 /**
1144 * Sort + keyword + hide-empty filtering — FREE (mirrors Essential Addons'
1145 * free Business Reviews widget). Moved here from the Pro plugin so free
1146 * installs get the same behaviour. Pure array transforms, no network.
1147 *
1148 * @param array $reviews Normalized review arrays.
1149 * @param array $args Render args (sort / keyword / hide_empty).
1150 * @return array
1151 */
1152 /**
1153 * Build AggregateRating + Review JSON-LD — FREE (mirrors Essential Addons'
1154 * free Local Business schema). Returns $html unchanged when the schema
1155 * toggle is off or there are no reviews. Ported from the Pro plugin.
1156 *
1157 * @param string $html Existing markup to append to.
1158 * @param array $reviews Normalized reviews.
1159 * @param array $meta Place meta (name/rating/total).
1160 * @param array $args Render args (schema toggle).
1161 * @return string
1162 */
1163 private static function build_schema_jsonld(string $html, array $reviews, array $meta, array $args): string
1164 {
1165 if (empty($args['schema']) || empty($reviews)) {
1166 return $html;
1167 }
1168
1169 $name = $meta['name'] ?? ($args['place_name'] ?? '');
1170 $rating = isset($meta['rating']) ? (float) $meta['rating'] : 0.0;
1171 $total = isset($meta['total']) ? (int) $meta['total'] : count($reviews);
1172
1173 $review_nodes = [];
1174 foreach ($reviews as $r) {
1175 $body = trim((string) ($r['text'] ?? ''));
1176 $node = [
1177 '@type' => 'Review',
1178 'author' => [
1179 '@type' => 'Person',
1180 'name' => (string) ($r['author_name'] ?? __('Anonymous', 'embedpress')),
1181 ],
1182 'reviewRating' => [
1183 '@type' => 'Rating',
1184 'ratingValue' => (string) (int) ($r['rating'] ?? 0),
1185 'bestRating' => '5',
1186 'worstRating' => '1',
1187 ],
1188 ];
1189 if ($body !== '') {
1190 $node['reviewBody'] = $body;
1191 }
1192 if (!empty($r['time'])) {
1193 $node['datePublished'] = gmdate('Y-m-d', (int) $r['time']);
1194 }
1195 $review_nodes[] = $node;
1196 }
1197
1198 $data = [
1199 '@context' => 'https://schema.org',
1200 '@type' => 'LocalBusiness',
1201 'name' => $name !== '' ? $name : __('Business', 'embedpress'),
1202 ];
1203 if ($rating > 0 && $total > 0) {
1204 $data['aggregateRating'] = [
1205 '@type' => 'AggregateRating',
1206 'ratingValue' => (string) $rating,
1207 'reviewCount' => (string) $total,
1208 'bestRating' => '5',
1209 'worstRating' => '1',
1210 ];
1211 }
1212 if (!empty($review_nodes)) {
1213 $data['review'] = $review_nodes;
1214 }
1215
1216 // SECURITY: review text is attacker-controlled (anyone can leave a Google
1217 // review). Escape <, >, &, ', " so a body like `</script><script>…` can't
1218 // break out of the <script type="application/ld+json"> block and execute.
1219 // JSON_UNESCAPED_SLASHES is intentionally dropped for the same reason.
1220 $json = wp_json_encode(
1221 $data,
1222 JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_UNICODE
1223 );
1224 if (!$json) {
1225 return $html;
1226 }
1227
1228 return $html . '<script type="application/ld+json" class="ep-gr-schema">' . $json . '</script>';
1229 }
1230
1231 private static function apply_review_filters(array $reviews, array $args): array
1232 {
1233 // Hide reviews with no text.
1234 if (!empty($args['hide_empty'])) {
1235 $reviews = array_values(array_filter($reviews, function ($r) {
1236 return trim((string) ($r['text'] ?? '')) !== '';
1237 }));
1238 }
1239
1240 // Keyword filter (case-insensitive substring on review text).
1241 $keyword = isset($args['keyword']) ? trim((string) $args['keyword']) : '';
1242 if ($keyword !== '') {
1243 $needle = function_exists('mb_strtolower') ? mb_strtolower($keyword) : strtolower($keyword);
1244 $reviews = array_values(array_filter($reviews, function ($r) use ($needle) {
1245 $hay = (string) ($r['text'] ?? '');
1246 $hay = function_exists('mb_strtolower') ? mb_strtolower($hay) : strtolower($hay);
1247 return $hay !== '' && strpos($hay, $needle) !== false;
1248 }));
1249 }
1250
1251 // Sort order.
1252 $sort = isset($args['sort']) ? (string) $args['sort'] : 'newest';
1253 switch ($sort) {
1254 case 'highest':
1255 usort($reviews, function ($a, $b) {
1256 return (int) ($b['rating'] ?? 0) <=> (int) ($a['rating'] ?? 0);
1257 });
1258 break;
1259 case 'lowest':
1260 usort($reviews, function ($a, $b) {
1261 return (int) ($a['rating'] ?? 0) <=> (int) ($b['rating'] ?? 0);
1262 });
1263 break;
1264 case 'newest':
1265 usort($reviews, function ($a, $b) {
1266 return (int) ($b['time'] ?? 0) <=> (int) ($a['time'] ?? 0);
1267 });
1268 break;
1269 case 'relevant':
1270 default:
1271 // Leave Google's native ordering.
1272 break;
1273 }
1274
1275 return $reviews;
1276 }
1277
1278 private static function render_empty(): string
1279 {
1280 return '<div class="ep-google-reviews ep-google-reviews--empty">'
1281 . esc_html__('No reviews to display yet.', 'embedpress')
1282 . '</div>';
1283 }
1284
1285 /**
1286 * Reviews for a freshly-added place are still being fetched in the
1287 * background (status running/queued). Render a friendly "loading"
1288 * placeholder — a skeleton + spinner + a message making clear it can take a
1289 * little while — carrying `data-ep-gr-poll` with the place_id so the
1290 * frontend poller (static/js/google-reviews-status.js) can poll the public
1291 * status endpoint and swap in the reviews the moment the job finishes, with
1292 * NO manual reload. The poller reloads the wrapper region when done.
1293 *
1294 * @param string $place_id The place whose fetch we're waiting on.
1295 */
1296 private static function render_loading(string $place_id): string
1297 {
1298 // Three shimmer skeleton cards approximate the incoming review list so
1299 // the block reserves space + reads as "working", not "empty/broken".
1300 $skeleton_card =
1301 '<div class="ep-gr-skel-card" aria-hidden="true">'
1302 . '<div class="ep-gr-skel-row">'
1303 . '<span class="ep-gr-skel ep-gr-skel-avatar"></span>'
1304 . '<span class="ep-gr-skel-lines"><span class="ep-gr-skel ep-gr-skel-line ep-gr-skel-line--sm"></span>'
1305 . '<span class="ep-gr-skel ep-gr-skel-line ep-gr-skel-line--xs"></span></span>'
1306 . '</div>'
1307 . '<span class="ep-gr-skel ep-gr-skel-line"></span>'
1308 . '<span class="ep-gr-skel ep-gr-skel-line"></span>'
1309 . '<span class="ep-gr-skel ep-gr-skel-line ep-gr-skel-line--md"></span>'
1310 . '</div>';
1311
1312 $poll_url = esc_url_raw(rest_url(GoogleReviewsRestController::NS . '/google-reviews/public-status'));
1313
1314 return '<div class="ep-google-reviews ep-google-reviews--loading" '
1315 . 'data-ep-gr-poll="' . esc_attr($place_id) . '" '
1316 . 'data-ep-gr-poll-url="' . esc_url($poll_url) . '" '
1317 . 'data-ep-gr-poll-interval="5000" role="status" aria-live="polite">'
1318 . '<div class="ep-gr-loading-head">'
1319 . '<span class="ep-gr-spinner" aria-hidden="true"></span>'
1320 . '<div class="ep-gr-loading-text">'
1321 . '<strong>' . esc_html__('Loading Google reviews…', 'embedpress') . '</strong>'
1322 . '<span>' . esc_html__('Fetching the latest reviews for this place. This can take a little while the first time — they’ll appear here automatically when ready, no need to refresh.', 'embedpress') . '</span>'
1323 . '</div>'
1324 . '</div>'
1325 . '<div class="ep-gr-skeletons">' . $skeleton_card . $skeleton_card . $skeleton_card . '</div>'
1326 . '</div>';
1327 }
1328
1329 /**
1330 * The background fetch for this place failed (e.g. the scraper was gated, or
1331 * a network error). Visitors see NOTHING (an empty string — the block just
1332 * doesn't render, so the public page never shows a broken state). Logged-in
1333 * editors see an actionable notice pointing at the settings page where they
1334 * can Refetch.
1335 *
1336 * @param string $place_id The place whose fetch failed.
1337 */
1338 private static function render_fetch_failed(string $place_id): string
1339 {
1340 if (!current_user_can('edit_posts')) {
1341 return ''; // visitors: render nothing rather than an error
1342 }
1343 $settings_url = admin_url('admin.php?page=embedpress-google-reviews');
1344 return '<div class="ep-google-reviews ep-google-reviews--error ep-google-reviews--fetch-failed">'
1345 . '<p><strong>' . esc_html__('Couldn’t load this place’s reviews.', 'embedpress') . '</strong></p>'
1346 . '<p>' . esc_html__('The last attempt to fetch reviews for this place didn’t finish. Open EmbedPress → Google Reviews and use “Refetch” to try again.', 'embedpress') . '</p>'
1347 . '<p><a href="' . esc_url($settings_url) . '">' . esc_html__('Open Google Reviews settings →', 'embedpress') . '</a></p>'
1348 . '<p class="ep-gr-admin-only-note"><em>' . esc_html__('Only you (an editor) can see this message — visitors see nothing.', 'embedpress') . '</em></p>'
1349 . '</div>';
1350 }
1351
1352 /**
1353 * A Pro layout was selected without Pro active. Editors see an actionable
1354 * upsell (so they understand why their chosen layout isn't rendering); public
1355 * visitors see nothing (the caller falls back to a free layout for them, so
1356 * the front-end never looks broken to real users).
1357 *
1358 * @return string Upsell markup for editors, '' for visitors (→ free fallback).
1359 */
1360 private static function render_pro_layout_upsell(string $layout): string
1361 {
1362 if (!current_user_can('edit_posts')) {
1363 return ''; // visitor → caller renders the free fallback layout
1364 }
1365 $labels = [
1366 'masonry' => __('Masonry', 'embedpress'),
1367 'badge' => __('Compact badge', 'embedpress'),
1368 'spotlight' => __('Spotlight', 'embedpress'),
1369 'knowledge' => __('Knowledge panel', 'embedpress'),
1370 'marquee' => __('Marquee', 'embedpress'),
1371 'bubble' => __('Bubble', 'embedpress'),
1372 ];
1373 $name = $labels[$layout] ?? ucfirst($layout);
1374 $url = 'https://wpdeveloper.com/in/upgrade-embedpress';
1375
1376 // Reuse EmbedPress's canonical Pro-card visual language (pro__alert__card)
1377 // so this matches the upgrade UI used across the plugin. Rendered inline
1378 // (not as the hidden modal overlay) since it stands in for content.
1379 $icon = '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">'
1380 . '<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" stroke="#5b4e96" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
1381
1382 return '<div class="ep-google-reviews ep-gr-pro-upsell">'
1383 . '<div class="pro__alert__card ep-gr-pro-upsell__card">'
1384 . '<div class="ep-gr-pro-upsell__icon">' . $icon . '</div>'
1385 . '<h2>' . esc_html(sprintf(/* translators: %s: layout name */ __('“%s” is a Pro layout', 'embedpress'), $name)) . '</h2>'
1386 . '<p>' . esc_html__('Upgrade to EmbedPress Pro to use this layout, or pick a free one (List, Grid, Card, Carousel).', 'embedpress') . '</p>'
1387 . '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer" class="pro__alert__btn ep-gr-pro-upsell__btn">'
1388 . esc_html__('Upgrade to Pro', 'embedpress') . '</a>'
1389 . '<p class="ep-gr-pro-upsell__note">' . esc_html__('Visitors see the List layout until you upgrade or switch.', 'embedpress') . '</p>'
1390 . '</div></div>';
1391 }
1392
1393 private static function render_error(string $message): string
1394 {
1395 if (!current_user_can('edit_posts')) {
1396 return '';
1397 }
1398 return '<div class="ep-google-reviews ep-google-reviews--error">'
1399 . esc_html(sprintf(/* translators: %s: error message from Google API */ __('Google Reviews error: %s', 'embedpress'), $message))
1400 . '</div>';
1401 }
1402
1403 /**
1404 * Friendly "no place chosen yet" placeholder for a freshly added block /
1405 * widget. This is the normal starting state, not an error — so it's a calm
1406 * prompt that tells the editor what to do next, not a red error box.
1407 *
1408 * Editor-only: visitors must never see setup instructions, so on the front
1409 * end (no edit_posts cap) we render nothing at all.
1410 *
1411 * @return string
1412 */
1413 private static function render_pick_place_prompt(): string
1414 {
1415 if (!current_user_can('edit_posts')) {
1416 return '';
1417 }
1418 return '<div class="ep-google-reviews ep-google-reviews--placeholder">'
1419 . '<span class="ep-gr-placeholder-icon" aria-hidden="true">'
1420 . '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">'
1421 . '<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z" stroke="currentColor" stroke-width="1.6"/>'
1422 . '<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="1.6"/></svg>'
1423 . '</span>'
1424 . '<strong>' . esc_html__('Choose a Google Business to show its reviews', 'embedpress') . '</strong>'
1425 . '<span>' . esc_html__('Search for a place in the panel, or paste a Place ID, to display its reviews here.', 'embedpress') . '</span>'
1426 . '</div>';
1427 }
1428
1429 /**
1430 * Fetch reviews for a place_id from the Google Places API, with a
1431 * transient cache to stay under quota. Returns up to 5 reviews
1432 * (Places API hard cap) or a WP_Error if the request fails.
1433 *
1434 * Routes through self::dispatch() so we transparently support both the
1435 * legacy Places API and Places API (New). The dispatcher probes once,
1436 * persists the working mode, and falls back if the stored mode goes
1437 * stale (e.g. legacy gets disabled mid-project).
1438 *
1439 * @return array|\WP_Error
1440 */
1441 public static function fetch_reviews(string $place_id, array $args = [])
1442 {
1443 // Back-compat alias. Pro's multi-place merge calls this to get a single
1444 // place's ≤5 reviews; it's now a direct (uncached) API fetch — the DB
1445 // store is the cache layer, so no transient here.
1446 return self::fetch_from_api($place_id);
1447 }
1448
1449 /**
1450 * Raw fetch of a single place's reviews from the Google Places API (≤5,
1451 * the API hard cap). No caching — callers (the DB store / fetch_into_store)
1452 * own persistence. Routes through self::dispatch() for legacy/new API
1453 * auto-detection.
1454 *
1455 * @return array|\WP_Error {reviews, meta}
1456 */
1457 public static function fetch_from_api(string $place_id)
1458 {
1459 if ($place_id === '') {
1460 return new \WP_Error('embedpress_gr_missing_place', __('No place selected.', 'embedpress'));
1461 }
1462 if (!preg_match('/^[A-Za-z0-9_\-]+$/', $place_id)) {
1463 return new \WP_Error('embedpress_gr_invalid_place', __('Invalid place identifier.', 'embedpress'));
1464 }
1465
1466 $api_key = self::get_api_key();
1467 if ($api_key === '') {
1468 // No Google key. If an Apify token is set the caller should have
1469 // routed through Apify already (see maybe_prefer_apify_fetch); reaching
1470 // here means no provider is usable, so give a provider-neutral message.
1471 $message = self::get_apify_token() !== ''
1472 ? __('Could not load reviews. Open EmbedPress → Google Reviews and use “Refetch” for this place.', 'embedpress')
1473 : __('Not connected to the EmbedPress API. Open EmbedPress → Google Reviews and click “Connect to EmbedPress API” to start fetching reviews.', 'embedpress');
1474 return new \WP_Error('embedpress_gr_missing_key', $message);
1475 }
1476
1477 if (defined('WP_DEBUG') && WP_DEBUG) {
1478 error_log('embedpress-gr: API call for place_id=' . $place_id);
1479 }
1480
1481 $result = self::dispatch('details', $api_key, ['place_id' => $place_id]);
1482 if (is_wp_error($result)) {
1483 return $result;
1484 }
1485
1486 return self::normalize_result($result);
1487 }
1488
1489 /**
1490 * Accept both the legacy flat-array review shape (older caches) and the
1491 * current `{reviews, meta}` shape, and always return the latter. Keeps
1492 * pre-existing transients valid after the meta/header change.
1493 */
1494 private static function normalize_result($data): array
1495 {
1496 if (isset($data['reviews']) && is_array($data['reviews'])) {
1497 return [
1498 'reviews' => array_values($data['reviews']),
1499 'meta' => isset($data['meta']) && is_array($data['meta']) ? $data['meta'] : [],
1500 ];
1501 }
1502 // Legacy flat list of reviews (no meta available).
1503 return ['reviews' => array_values((array) $data), 'meta' => []];
1504 }
1505
1506 /**
1507 * Run a Places autocomplete query, normalized across legacy + New API.
1508 * Returns a list of `{place_id, description, main_text, secondary_text}`
1509 * arrays or a WP_Error. Caller is responsible for caching.
1510 *
1511 * @return array|\WP_Error
1512 */
1513 public static function autocomplete(string $q)
1514 {
1515 $api_key = self::get_api_key();
1516 if ($api_key === '') {
1517 // Reached the Google path with no key. If Apify is connected the
1518 // search controller already tried Apify; a genuine timeout is handled
1519 // earlier (returned directly), so reaching here means Apify simply
1520 // found no match for this query — tell the user that, not to add a
1521 // Google key.
1522 $message = self::get_apify_token() !== ''
1523 ? __('No places matched that search. Try a more specific name (including the city), or paste the Place ID directly using “Have a Place ID? Enter manually”.', 'embedpress')
1524 : __('Not connected to the EmbedPress API. Open EmbedPress → Google Reviews and click “Connect to EmbedPress API” to start fetching reviews.', 'embedpress');
1525 return new \WP_Error('embedpress_gr_no_key', $message, ['status' => 400]);
1526 }
1527 return self::dispatch('autocomplete', $api_key, ['q' => $q]);
1528 }
1529
1530 /**
1531 * Verify a Google Places key works WITHOUT saving it — used by the settings
1532 * "Connect" flow. Runs a tiny autocomplete probe with the supplied key.
1533 * Returns true on success or a WP_Error with an actionable message.
1534 *
1535 * @return true|\WP_Error
1536 */
1537 public static function verify_google_key(string $api_key)
1538 {
1539 $api_key = trim($api_key);
1540 if ($api_key === '') {
1541 return new \WP_Error('embedpress_gr_no_key', __('No key provided.', 'embedpress'));
1542 }
1543 // Probe both API variants with the given key (not the stored mode).
1544 $res = self::call_new_autocomplete($api_key, 'coffee');
1545 if (!is_wp_error($res)) {
1546 return true;
1547 }
1548 if (self::is_api_not_enabled_error($res)) {
1549 $legacy = self::call_legacy_autocomplete($api_key, 'coffee');
1550 if (!is_wp_error($legacy)) {
1551 return true;
1552 }
1553 return $legacy;
1554 }
1555 return $res;
1556 }
1557
1558 /**
1559 * Dispatch a Places API call against whichever variant is enabled for
1560 * the user's key. Tries the stored mode first; on a permission-class
1561 * failure, transparently tries the other and updates the stored mode
1562 * so future calls go direct.
1563 *
1564 * $op is 'autocomplete' or 'details'.
1565 *
1566 * @return array|\WP_Error
1567 */
1568 private static function dispatch(string $op, string $api_key, array $params)
1569 {
1570 $mode = self::get_api_mode();
1571
1572 $try = function (string $variant) use ($op, $api_key, $params) {
1573 if ($variant === 'new') {
1574 return $op === 'autocomplete'
1575 ? self::call_new_autocomplete($api_key, (string) ($params['q'] ?? ''))
1576 : self::call_new_details($api_key, (string) ($params['place_id'] ?? ''));
1577 }
1578 return $op === 'autocomplete'
1579 ? self::call_legacy_autocomplete($api_key, (string) ($params['q'] ?? ''))
1580 : self::call_legacy_details($api_key, (string) ($params['place_id'] ?? ''));
1581 };
1582
1583 // Order: try stored mode first; if 'auto', try New first (it's the
1584 // only variant available to GCP projects created after 2025-03-01).
1585 $first = in_array($mode, ['new', 'legacy'], true) ? $mode : 'new';
1586 $second = $first === 'new' ? 'legacy' : 'new';
1587
1588 $result = $try($first);
1589 if (!is_wp_error($result)) {
1590 self::set_api_mode($first);
1591 return $result;
1592 }
1593
1594 if (!self::is_api_not_enabled_error($result)) {
1595 // A real error (bad query, network, etc.) — don't waste a second call.
1596 return $result;
1597 }
1598
1599 $alt = $try($second);
1600 if (!is_wp_error($alt)) {
1601 self::set_api_mode($second);
1602 return $alt;
1603 }
1604
1605 // Both failed. Surface whichever message is more informative.
1606 return self::is_api_not_enabled_error($alt) ? $result : $alt;
1607 }
1608
1609 /**
1610 * Heuristic: does this WP_Error look like "the API variant isn't enabled
1611 * for this project" (vs. a key being invalid, quota exceeded, etc.)?
1612 */
1613 private static function is_api_not_enabled_error(\WP_Error $err): bool
1614 {
1615 $code = strtolower((string) $err->get_error_code());
1616 if (
1617 str_contains($code, 'request_denied')
1618 || str_contains($code, 'permission_denied')
1619 || str_contains($code, 'failed_precondition')
1620 || str_contains($code, 'http_403')
1621 ) {
1622 return true;
1623 }
1624 $msg = strtolower((string) $err->get_error_message());
1625 return str_contains($msg, 'legacy api') || str_contains($msg, 'has not been used in project') || str_contains($msg, 'is not enabled');
1626 }
1627
1628 /**
1629 * Legacy Places API: autocomplete. Returns normalized predictions.
1630 *
1631 * @return array|\WP_Error
1632 */
1633 private static function call_legacy_autocomplete(string $api_key, string $q)
1634 {
1635 $url = add_query_arg([
1636 'input' => $q,
1637 'types' => 'establishment',
1638 'key' => $api_key,
1639 ], self::ENDPOINT_LEGACY_AUTOCOMPLETE);
1640
1641 $response = wp_remote_get($url, ['timeout' => 6]);
1642 if (is_wp_error($response)) {
1643 return new \WP_Error('embedpress_gr_http', $response->get_error_message(), ['status' => 502]);
1644 }
1645 $body = json_decode(wp_remote_retrieve_body($response), true);
1646 $err = self::legacy_status_error($body);
1647 if ($err) return $err;
1648
1649 $predictions = [];
1650 foreach (($body['predictions'] ?? []) as $p) {
1651 $predictions[] = [
1652 'place_id' => isset($p['place_id']) ? (string) $p['place_id'] : '',
1653 'description' => isset($p['description']) ? (string) $p['description'] : '',
1654 'main_text' => isset($p['structured_formatting']['main_text']) ? (string) $p['structured_formatting']['main_text'] : '',
1655 'secondary_text' => isset($p['structured_formatting']['secondary_text']) ? (string) $p['structured_formatting']['secondary_text'] : '',
1656 ];
1657 }
1658 return $predictions;
1659 }
1660
1661 /**
1662 * Legacy Places API: place details (reviews only). Returns normalized
1663 * review list.
1664 *
1665 * @return array|\WP_Error
1666 */
1667 private static function call_legacy_details(string $api_key, string $place_id)
1668 {
1669 $url = add_query_arg([
1670 'place_id' => $place_id,
1671 // Pull place meta (name + overall rating + total + address) alongside
1672 // the reviews so the summary header + Saved-places list need no extra
1673 // API call.
1674 'fields' => 'name,rating,user_ratings_total,formatted_address,reviews',
1675 'reviews_sort' => 'newest',
1676 'key' => $api_key,
1677 ], self::ENDPOINT_LEGACY_DETAILS);
1678
1679 $response = wp_remote_get($url, ['timeout' => 8]);
1680 if (is_wp_error($response)) {
1681 return new \WP_Error('embedpress_gr_http', $response->get_error_message());
1682 }
1683 $code = (int) wp_remote_retrieve_response_code($response);
1684 if ($code !== 200) {
1685 return new \WP_Error('embedpress_gr_http_' . $code, sprintf(/* translators: %d: HTTP status code */ __('Google Places returned HTTP %d.', 'embedpress'), $code));
1686 }
1687 $body = json_decode(wp_remote_retrieve_body($response), true);
1688 $err = self::legacy_status_error($body);
1689 if ($err) return $err;
1690
1691 $result = $body['result'] ?? [];
1692 $reviews = [];
1693 foreach (($result['reviews'] ?? []) as $r) {
1694 $reviews[] = [
1695 'author_name' => isset($r['author_name']) ? (string) $r['author_name'] : '',
1696 'rating' => isset($r['rating']) ? (int) $r['rating'] : 0,
1697 'text' => isset($r['text']) ? (string) $r['text'] : '',
1698 'time' => isset($r['time']) ? (int) $r['time'] : 0,
1699 // Google's own "a week ago" phrasing — preferred over an
1700 // absolute date for review recency.
1701 'relative_time' => isset($r['relative_time_description']) ? (string) $r['relative_time_description'] : '',
1702 'profile_photo_url' => isset($r['profile_photo_url']) ? esc_url_raw($r['profile_photo_url']) : '',
1703 ];
1704 }
1705 return [
1706 'reviews' => $reviews,
1707 'meta' => [
1708 'name' => isset($result['name']) ? (string) $result['name'] : '',
1709 'rating' => isset($result['rating']) ? (float) $result['rating'] : 0.0,
1710 'total' => isset($result['user_ratings_total']) ? (int) $result['user_ratings_total'] : 0,
1711 // Location address for the summary header + Saved-places list.
1712 'address' => isset($result['formatted_address']) ? (string) $result['formatted_address'] : '',
1713 ],
1714 ];
1715 }
1716
1717 /**
1718 * Convert a legacy Places response body into a WP_Error if its `status`
1719 * indicates failure. Returns null on success.
1720 */
1721 private static function legacy_status_error($body): ?\WP_Error
1722 {
1723 if (!is_array($body)) {
1724 return new \WP_Error('embedpress_gr_bad_response', __('Invalid response from Google Places.', 'embedpress'));
1725 }
1726 $status = $body['status'] ?? 'UNKNOWN_ERROR';
1727 if ($status === 'OK' || $status === 'ZERO_RESULTS') return null;
1728 $error_message = isset($body['error_message']) ? (string) $body['error_message'] : '';
1729 $msg = $error_message !== ''
1730 ? sprintf(/* translators: 1: Google API error status, 2: Google API error message */ __('Google Places error: %1$s — %2$s', 'embedpress'), $status, $error_message)
1731 : sprintf(/* translators: %s: Google API error status */ __('Google Places error: %s', 'embedpress'), $status);
1732 $msg .= self::friendly_api_hint($status, $error_message);
1733 return new \WP_Error('embedpress_gr_api_' . strtolower($status), $msg, ['status' => 502]);
1734 }
1735
1736 /**
1737 * Translate Google's terse/cryptic API statuses into an actionable hint
1738 * for the site admin. Google often returns a bare "The caller does not
1739 * have permission" with no remediation; this appends the concrete fix
1740 * (which is almost always a Cloud Console setting, not a plugin bug).
1741 *
1742 * Returns an empty string for non-error / unknown statuses so the base
1743 * message is unchanged.
1744 */
1745 private static function friendly_api_hint(string $status, string $message = ''): string
1746 {
1747 $status = strtoupper($status);
1748 $message = strtolower($message);
1749
1750 // Billing not enabled — Places API (New) requires an active billing account.
1751 if (strpos($message, 'billing') !== false) {
1752 return ' ' . __('Enable billing for your project in the Google Cloud Console — the Places API requires an active billing account.', 'embedpress');
1753 }
1754
1755 // API not enabled on the project, or the legacy API is deprecated.
1756 if (
1757 strpos($message, 'has not been used') !== false
1758 || strpos($message, 'is not enabled') !== false
1759 || strpos($message, 'not activated') !== false
1760 || strpos($message, 'legacy api') !== false
1761 || $status === 'SERVICE_DISABLED'
1762 ) {
1763 return ' ' . __('Enable "Places API (New)" for your project in the Google Cloud Console (APIs & Services → Library), then wait a few minutes for it to take effect.', 'embedpress');
1764 }
1765
1766 // Permission denied with no detail — almost always API-not-enabled or a
1767 // key restriction blocking the Places API.
1768 if ($status === 'PERMISSION_DENIED' || $status === 'REQUEST_DENIED') {
1769 return ' ' . __('Check that "Places API (New)" is enabled for your project and that your API key is not restricted from calling it (APIs & Services → Credentials → your key → API restrictions). Server-side calls also require the key to allow application restriction "None" or your server IP.', 'embedpress');
1770 }
1771
1772 // Quota / rate limit.
1773 if ($status === 'RESOURCE_EXHAUSTED' || $status === 'OVER_QUERY_LIMIT') {
1774 return ' ' . __('Your Google Places API quota has been exceeded. Check your usage and quotas in the Google Cloud Console.', 'embedpress');
1775 }
1776
1777 // Bad / unauthorized key.
1778 if ($status === 'UNAUTHENTICATED' || $status === 'INVALID_ARGUMENT' || strpos($message, 'api key not valid') !== false) {
1779 return ' ' . __('Your Google Places API key appears to be invalid. Re-copy it from the Google Cloud Console (APIs & Services → Credentials).', 'embedpress');
1780 }
1781
1782 return '';
1783 }
1784
1785 /**
1786 * Places API (New): autocomplete. Returns normalized predictions.
1787 *
1788 * @return array|\WP_Error
1789 */
1790 private static function call_new_autocomplete(string $api_key, string $q)
1791 {
1792 $response = wp_remote_post(self::ENDPOINT_NEW_AUTOCOMPLETE, [
1793 'timeout' => 6,
1794 'headers' => [
1795 'Content-Type' => 'application/json',
1796 'X-Goog-Api-Key' => $api_key,
1797 ],
1798 'body' => wp_json_encode([
1799 'input' => $q,
1800 'includedPrimaryTypes' => ['establishment'],
1801 ]),
1802 ]);
1803 if (is_wp_error($response)) {
1804 return new \WP_Error('embedpress_gr_http', $response->get_error_message(), ['status' => 502]);
1805 }
1806 $body = json_decode(wp_remote_retrieve_body($response), true);
1807 $code = (int) wp_remote_retrieve_response_code($response);
1808 $err = self::new_api_error($body, $code);
1809 if ($err) return $err;
1810
1811 $predictions = [];
1812 foreach (($body['suggestions'] ?? []) as $s) {
1813 $p = $s['placePrediction'] ?? null;
1814 if (!$p) continue;
1815 $predictions[] = [
1816 'place_id' => isset($p['placeId']) ? (string) $p['placeId'] : '',
1817 'description' => isset($p['text']['text']) ? (string) $p['text']['text'] : '',
1818 'main_text' => isset($p['structuredFormat']['mainText']['text']) ? (string) $p['structuredFormat']['mainText']['text'] : '',
1819 'secondary_text' => isset($p['structuredFormat']['secondaryText']['text']) ? (string) $p['structuredFormat']['secondaryText']['text'] : '',
1820 ];
1821 }
1822 return $predictions;
1823 }
1824
1825 /**
1826 * Places API (New): place details (reviews). Returns normalized review list.
1827 *
1828 * @return array|\WP_Error
1829 */
1830 private static function call_new_details(string $api_key, string $place_id)
1831 {
1832 // The New API uses place resource names (`places/PLACE_ID`); a bare
1833 // place_id is also accepted at this endpoint.
1834 $response = wp_remote_get(self::ENDPOINT_NEW_DETAILS . rawurlencode($place_id), [
1835 'timeout' => 8,
1836 'headers' => [
1837 'X-Goog-Api-Key' => $api_key,
1838 // Place meta (name + overall rating + total) alongside reviews.
1839 'X-Goog-FieldMask' => 'displayName,rating,userRatingCount,formattedAddress,reviews',
1840 ],
1841 ]);
1842 if (is_wp_error($response)) {
1843 return new \WP_Error('embedpress_gr_http', $response->get_error_message());
1844 }
1845 $body = json_decode(wp_remote_retrieve_body($response), true);
1846 $code = (int) wp_remote_retrieve_response_code($response);
1847 $err = self::new_api_error($body, $code);
1848 if ($err) return $err;
1849
1850 $reviews = [];
1851 foreach (($body['reviews'] ?? []) as $r) {
1852 $time = 0;
1853 if (!empty($r['publishTime'])) {
1854 $t = strtotime((string) $r['publishTime']);
1855 $time = $t ? $t : 0;
1856 }
1857 $reviews[] = [
1858 'author_name' => isset($r['authorAttribution']['displayName']) ? (string) $r['authorAttribution']['displayName'] : '',
1859 'rating' => isset($r['rating']) ? (int) $r['rating'] : 0,
1860 'text' => isset($r['text']['text']) ? (string) $r['text']['text'] : (isset($r['originalText']['text']) ? (string) $r['originalText']['text'] : ''),
1861 'time' => $time,
1862 // Google's own "a week ago" phrasing — preferred over an
1863 // absolute date for review recency.
1864 'relative_time' => isset($r['relativePublishTimeDescription']) ? (string) $r['relativePublishTimeDescription'] : '',
1865 'profile_photo_url' => isset($r['authorAttribution']['photoUri']) ? esc_url_raw((string) $r['authorAttribution']['photoUri']) : '',
1866 ];
1867 }
1868 return [
1869 'reviews' => $reviews,
1870 'meta' => [
1871 'name' => isset($body['displayName']['text']) ? (string) $body['displayName']['text'] : '',
1872 'rating' => isset($body['rating']) ? (float) $body['rating'] : 0.0,
1873 'total' => isset($body['userRatingCount']) ? (int) $body['userRatingCount'] : 0,
1874 // Location address for the summary header + Saved-places list.
1875 'address' => isset($body['formattedAddress']) ? (string) $body['formattedAddress'] : '',
1876 ],
1877 ];
1878 }
1879
1880 /**
1881 * Convert a Places API (New) response into a WP_Error if it indicates
1882 * failure. The new API uses Google's standard error envelope
1883 * `{ error: { code, message, status } }` plus a non-200 HTTP status.
1884 */
1885 private static function new_api_error($body, int $http_code): ?\WP_Error
1886 {
1887 if (is_array($body) && isset($body['error']) && is_array($body['error'])) {
1888 $status = (string) ($body['error']['status'] ?? 'UNKNOWN_ERROR');
1889 $message = (string) ($body['error']['message'] ?? '');
1890 $msg = $message !== ''
1891 ? sprintf(/* translators: 1: Google API error status, 2: Google API error message */ __('Google Places error: %1$s — %2$s', 'embedpress'), $status, $message)
1892 : sprintf(/* translators: %s: Google API error status */ __('Google Places error: %s', 'embedpress'), $status);
1893 $msg .= self::friendly_api_hint($status, $message);
1894 return new \WP_Error('embedpress_gr_api_' . strtolower($status), $msg, ['status' => 502]);
1895 }
1896 if ($http_code !== 200) {
1897 return new \WP_Error('embedpress_gr_http_' . $http_code, sprintf(/* translators: %d: HTTP status code */ __('Google Places returned HTTP %d.', 'embedpress'), $http_code));
1898 }
1899 if (!is_array($body)) {
1900 return new \WP_Error('embedpress_gr_bad_response', __('Invalid response from Google Places.', 'embedpress'));
1901 }
1902 return null;
1903 }
1904
1905 /**
1906 * Cache an error briefly so we don't hammer the API while the user fixes
1907 * the underlying problem (bad key, quota exceeded, etc.).
1908 */
1909 private static function cache_error(string $cache_key, string $message)
1910 {
1911 set_transient($cache_key . '_err', $message, 5 * MINUTE_IN_SECONDS);
1912 }
1913
1914 public static function get_api_key(): string
1915 {
1916 if (defined('EMBEDPRESS_GOOGLE_REVIEWS_API_KEY') && EMBEDPRESS_GOOGLE_REVIEWS_API_KEY) {
1917 return (string) EMBEDPRESS_GOOGLE_REVIEWS_API_KEY;
1918 }
1919 $key = get_option(self::OPT_API_KEY, '');
1920 return is_string($key) ? trim($key) : '';
1921 }
1922
1923 /**
1924 * Apify API token for the Pro "fetch all reviews" provider. Prefers a
1925 * wp-config constant (most secure), then the saved option.
1926 */
1927 public static function get_apify_token(): string
1928 {
1929 if (defined('EMBEDPRESS_APIFY_TOKEN') && EMBEDPRESS_APIFY_TOKEN) {
1930 return (string) EMBEDPRESS_APIFY_TOKEN;
1931 }
1932 $token = get_option(self::OPT_APIFY_TOKEN, '');
1933 return is_string($token) ? trim($token) : '';
1934 }
1935
1936 /**
1937 * Which provider powers place SEARCH. 'auto' (default) prefers Google when a
1938 * key is set, else Apify; 'google'/'apify' force a provider. Resolves 'auto'
1939 * to the concrete provider that's actually usable given configured creds.
1940 */
1941 public static function get_search_provider(): string
1942 {
1943 $pref = (string) get_option(self::OPT_SEARCH_PROVIDER, 'auto');
1944 $pref = in_array($pref, ['auto', 'google', 'apify'], true) ? $pref : 'auto';
1945
1946 $has_google = self::get_api_key() !== '';
1947 $has_apify = self::get_apify_token() !== '';
1948
1949 if ($pref === 'google') {
1950 return $has_google ? 'google' : ($has_apify ? 'apify' : 'managed');
1951 }
1952 if ($pref === 'apify') {
1953 return $has_apify ? 'apify' : ($has_google ? 'google' : 'managed');
1954 }
1955 // auto: user's own Google key first, then the hosted EmbedPress proxy
1956 // (session-billed Google Places API — fast, ~1.4s, zero setup).
1957 //
1958 // Apify is LAST on purpose: its place-search actor is a ~20s LIVE
1959 // SCRAPE, far slower than the Google Places Autocomplete API. Having
1960 // an Apify token (set up for the Pro "fetch all reviews" feature)
1961 // must NOT drag the picker onto that slow path. The managed proxy
1962 // gives instant session-based autocomplete; Apify search stays only
1963 // as a final fallback if the proxy is unreachable.
1964 if ($has_google) return 'google';
1965 return 'managed';
1966 }
1967
1968 /**
1969 * Hosted-proxy place SEARCH via api.embedpress.com/google-places.php.
1970 * Zero user setup: EmbedPress's server holds the Google Places key + does
1971 * the Autocomplete call. The proxy caches + rate-limits per IP.
1972 *
1973 * Why this exists: most users don't want to set up a Google Cloud project
1974 * just to search for a place. Hosted search gives the Trustindex-style
1975 * "type and pick" UX out of the box; user keys remain optional for
1976 * higher quotas or full control.
1977 *
1978 * @return array|\WP_Error list of {place_id, main_text, secondary_text, description}
1979 */
1980 public static function managed_search(string $q, string $session_token = '')
1981 {
1982 $endpoint = (string) apply_filters(
1983 'embedpress/google_reviews/managed_search_endpoint',
1984 'https://api.embedpress.com/google-places.php'
1985 );
1986 $params = [
1987 'action' => 'autocomplete',
1988 'q' => $q,
1989 ];
1990 // Passing the same token across N autocompletes + the final details
1991 // call lets Google bill the whole sequence as ONE session — major
1992 // savings on the proxy's free Places API tier.
1993 if ($session_token !== '') {
1994 $params['session_token'] = $session_token;
1995 }
1996 $url = add_query_arg($params, $endpoint);
1997
1998 // The proxy now requires a valid connection token. Place search is free
1999 // and setup-less, so provision a token on demand (open Connect handshake)
2000 // the first time it's needed, then send the same Bearer/site/fingerprint
2001 // headers as the instant-reviews path.
2002 \EmbedPress\Includes\Classes\GoogleReviewsManaged::ensure_connected();
2003 $headers = \EmbedPress\Includes\Classes\GoogleReviewsManaged::managed_headers();
2004 $headers['Accept'] = 'application/json';
2005
2006 $response = wp_remote_get($url, [
2007 'timeout' => (int) apply_filters('embedpress/google_reviews/managed_search_timeout', 8, $q),
2008 'headers' => $headers,
2009 ]);
2010
2011 if (is_wp_error($response)) {
2012 return new \WP_Error('embedpress_gr_managed_search', $response->get_error_message(), ['status' => 502]);
2013 }
2014 $code = (int) wp_remote_retrieve_response_code($response);
2015 $body = json_decode((string) wp_remote_retrieve_body($response), true);
2016
2017 if ($code === 429) {
2018 return new \WP_Error(
2019 'embedpress_gr_managed_rate_limited',
2020 __('Search is busy right now — please try again in a moment.', 'embedpress'),
2021 ['status' => 429]
2022 );
2023 }
2024 if ($code < 200 || $code >= 300 || !is_array($body)) {
2025 $msg = is_array($body) && !empty($body['message']) ? $body['message'] : __('Search service unavailable.', 'embedpress');
2026 return new \WP_Error('embedpress_gr_managed_search', $msg, ['status' => 502]);
2027 }
2028
2029 $predictions = [];
2030 foreach (($body['predictions'] ?? []) as $p) {
2031 if (empty($p['place_id'])) continue;
2032 $predictions[] = [
2033 'place_id' => (string) $p['place_id'],
2034 'main_text' => (string) ($p['main_text'] ?? ''),
2035 'secondary_text' => (string) ($p['secondary_text'] ?? ''),
2036 'description' => (string) ($p['description'] ?? ''),
2037 // Surface rating + review count so the picker can show how many
2038 // reviews each result has — disambiguates same-named places.
2039 'rating' => isset($p['rating']) ? (float) $p['rating'] : null,
2040 'review_count' => isset($p['review_count']) ? (int) $p['review_count'] : null,
2041 ];
2042 }
2043 return $predictions;
2044 }
2045
2046 /**
2047 * Rating + review count for a set of place IDs.
2048 *
2049 * The picker's suggestion list comes from Google Autocomplete, which
2050 * returns names only — no Places API tier includes a rating inside a
2051 * prediction. So the list renders first and this fills in the �
2052 /reviews
2053 * line for the ids it showed. Kept off the search path on purpose: a slow
2054 * count lookup must never hold up the suggestions themselves.
2055 *
2056 * @param string[] $place_ids
2057 * @return array<string, array{rating: float|null, review_count: int|null}>|\WP_Error
2058 */
2059 public static function managed_place_counts(array $place_ids)
2060 {
2061 $place_ids = array_values(array_filter(array_map('trim', $place_ids)));
2062 if (!$place_ids) {
2063 return [];
2064 }
2065
2066 $endpoint = (string) apply_filters(
2067 'embedpress/google_reviews/managed_search_endpoint',
2068 'https://api.embedpress.com/google-places.php'
2069 );
2070 $url = add_query_arg([
2071 'action' => 'counts',
2072 'place_ids' => implode(',', $place_ids),
2073 ], $endpoint);
2074
2075 $response = wp_remote_get($url, [
2076 'timeout' => (int) apply_filters('embedpress/google_reviews/place_counts_timeout', 8),
2077 'headers' => [
2078 'Accept' => 'application/json',
2079 'X-EmbedPress-Site' => home_url(),
2080 ],
2081 ]);
2082
2083 if (is_wp_error($response)) {
2084 return new \WP_Error('embedpress_gr_place_counts', $response->get_error_message(), ['status' => 502]);
2085 }
2086
2087 $code = (int) wp_remote_retrieve_response_code($response);
2088 $body = json_decode((string) wp_remote_retrieve_body($response), true);
2089 if ($code < 200 || $code >= 300 || !is_array($body)) {
2090 return new \WP_Error('embedpress_gr_place_counts', __('Could not load review counts.', 'embedpress'), ['status' => 502]);
2091 }
2092
2093 $counts = [];
2094 foreach (($body['counts'] ?? []) as $id => $entry) {
2095 if (!is_array($entry)) {
2096 continue;
2097 }
2098 $counts[(string) $id] = [
2099 'rating' => isset($entry['rating']) ? (float) $entry['rating'] : null,
2100 'review_count' => isset($entry['review_count']) ? (int) $entry['review_count'] : null,
2101 ];
2102 }
2103
2104 return $counts;
2105 }
2106
2107 /**
2108 * Apify-backed place SEARCH (the block's place picker — no Google key
2109 * needed). This is FREE: searching/picking a place is how you configure the
2110 * block at all, so it must not depend on Pro being active. (Pro owns only the
2111 * heavy "fetch all reviews" bulk scrape, not search.)
2112 *
2113 * Calls the crawler-google-places actor synchronously and returns predictions
2114 * in EmbedPress's picker shape, or a WP_Error the REST layer can surface.
2115 *
2116 * @return array|\WP_Error list of {place_id, main_text, secondary_text, description}
2117 */
2118 public static function apify_search(string $q)
2119 {
2120 $token = self::get_apify_token();
2121 $q = trim($q);
2122 if ($token === '') {
2123 return new \WP_Error('embedpress_gr_no_apify_token', __('Place search isn’t available right now. Paste the Place ID directly using “Have a Place ID? Enter manually”.', 'embedpress'), ['status' => 400]);
2124 }
2125 if ($q === '') {
2126 return [];
2127 }
2128
2129 // Pin a small memory footprint. The crawler-google-places actor DEFAULTS
2130 // to 4096MB, which on Apify's free 8192MB plan gets rejected ("you will
2131 // exceed the memory limit") whenever another run is using/releasing
2132 // memory. A 5-result place search needs nowhere near 4GB — 1024MB runs
2133 // fine. Filterable for users on larger plans who want faster runs.
2134 $memory = (int) apply_filters('embedpress/google_reviews/apify_search_memory', 1024, $q);
2135 $endpoint = 'https://api.apify.com/v2/acts/compass~crawler-google-places/run-sync-get-dataset-items?token=' . rawurlencode($token);
2136 if ($memory > 0) {
2137 $endpoint .= '&memory=' . $memory;
2138 }
2139 $payload = [
2140 'searchStringsArray' => [$q],
2141 'maxCrawledPlacesPerSearch' => (int) apply_filters('embedpress/google_reviews/apify_search_max', 5, $q),
2142 'language' => 'en',
2143 'skipClosedPlaces' => false,
2144 ];
2145
2146 // The crawler-google-places actor is a live scrape: a warm run answers in
2147 // ~20s, a cold start can take longer. Give it a generous budget and, on a
2148 // connection timeout, retry once — the actor is usually warm by the second
2149 // attempt and answers fast.
2150 $timeout = (int) apply_filters('embedpress/google_reviews/apify_search_timeout', 90, $q);
2151 $args = [
2152 'timeout' => $timeout,
2153 'headers' => ['Content-Type' => 'application/json'],
2154 'body' => wp_json_encode($payload),
2155 ];
2156 $response = wp_remote_post($endpoint, $args);
2157 if (is_wp_error($response) && self::is_timeout_error($response)) {
2158 $response = wp_remote_post($endpoint, $args);
2159 }
2160 if (is_wp_error($response)) {
2161 if (self::is_timeout_error($response)) {
2162 return new \WP_Error(
2163 'embedpress_gr_apify_timeout',
2164 __('Place search took too long to respond. Try searching again, or paste the Place ID directly using “Have a Place ID? Enter manually”.', 'embedpress'),
2165 ['status' => 504]
2166 );
2167 }
2168 return new \WP_Error('embedpress_gr_apify_search', $response->get_error_message(), ['status' => 502]);
2169 }
2170
2171 $code = (int) wp_remote_retrieve_response_code($response);
2172 if ($code !== 200 && $code !== 201) {
2173 // Surface the actual Apify reason (e.g. 402 out-of-credit, 401 bad
2174 // token) so the picker shows something actionable.
2175 $body = json_decode(wp_remote_retrieve_body($response), true);
2176 // Keep the user-facing message provider-neutral — don't surface raw
2177 // upstream (Apify) text or billing links to the end user. The
2178 // technical detail stays in the error code for debugging.
2179 $type = is_array($body) && isset($body['error']['type']) ? (string) $body['error']['type'] : '';
2180 $reason = __('Place search is temporarily unavailable. Try again, or paste the Place ID directly using “Have a Place ID? Enter manually”.', 'embedpress');
2181 if ($type === 'not-enough-usage-to-run-paid-actor') {
2182 $reason = __('Place search is temporarily unavailable. Paste the Place ID directly using “Have a Place ID? Enter manually”.', 'embedpress');
2183 }
2184 return new \WP_Error('embedpress_gr_apify_search', $reason, ['status' => 502]);
2185 }
2186
2187 $items = json_decode(wp_remote_retrieve_body($response), true);
2188 if (!is_array($items)) {
2189 return new \WP_Error('embedpress_gr_apify_search', __('Place search returned an unexpected response. Try again, or paste the Place ID directly using “Have a Place ID? Enter manually”.', 'embedpress'), ['status' => 502]);
2190 }
2191
2192 $out = [];
2193 foreach ($items as $it) {
2194 if (!is_array($it) || empty($it['placeId'])) {
2195 continue;
2196 }
2197 $title = (string) ($it['title'] ?? '');
2198 $addr = (string) ($it['address'] ?? '');
2199 $out[] = [
2200 'place_id' => (string) $it['placeId'],
2201 'main_text' => $title,
2202 'secondary_text' => $addr,
2203 'description' => trim($title . ($addr ? ', ' . $addr : '')),
2204 ];
2205 }
2206 return $out;
2207 }
2208
2209 /**
2210 * Is this WP_Error a connection/operation timeout (cURL 28 / "timed out")
2211 * rather than some other transport failure? Used to decide whether a retry is
2212 * worthwhile and how to label the error for the user.
2213 */
2214 private static function is_timeout_error($err): bool
2215 {
2216 if (!is_wp_error($err)) {
2217 return false;
2218 }
2219 $msg = strtolower($err->get_error_message());
2220 return strpos($msg, 'timed out') !== false
2221 || strpos($msg, 'timeout') !== false
2222 || strpos($msg, 'operation too slow') !== false;
2223 }
2224
2225 /**
2226 * Which Places API variant the user's key is enabled for.
2227 * 'new' | 'legacy' | 'auto'. 'auto' means we'll probe on the next call.
2228 */
2229 public static function get_api_mode(): string
2230 {
2231 $mode = (string) get_option(self::OPT_API_MODE, 'auto');
2232 return in_array($mode, ['new', 'legacy', 'auto'], true) ? $mode : 'auto';
2233 }
2234
2235 public static function set_api_mode(string $mode): void
2236 {
2237 if (!in_array($mode, ['new', 'legacy', 'auto'], true)) return;
2238 if ($mode === self::get_api_mode()) return;
2239 update_option(self::OPT_API_MODE, $mode);
2240 }
2241
2242 /**
2243 * Return the list of recently-used and explicitly-saved places. Each
2244 * entry is `{place_id, place_name, used_at|saved_at}`. Saved entries
2245 * persist indefinitely; recent rotates at RECENT_MAX.
2246 */
2247 public static function get_places_lists(): array
2248 {
2249 $recent = get_option(self::OPT_RECENT, []);
2250 $saved = get_option(self::OPT_SAVED, []);
2251 return [
2252 'recent' => is_array($recent) ? array_values($recent) : [],
2253 'saved' => is_array($saved) ? array_values($saved) : [],
2254 ];
2255 }
2256
2257 /**
2258 * Push a place to the head of the "recent" list, deduped by place_id.
2259 * No-op if place_id is empty. Trims to RECENT_MAX.
2260 */
2261 public static function remember_recent_place(string $place_id, string $place_name): void
2262 {
2263 $place_id = trim($place_id);
2264 if ($place_id === '') return;
2265 $recent = get_option(self::OPT_RECENT, []);
2266 if (!is_array($recent)) $recent = [];
2267 $recent = array_values(array_filter($recent, function ($p) use ($place_id) {
2268 return is_array($p) && ($p['place_id'] ?? '') !== $place_id;
2269 }));
2270 array_unshift($recent, [
2271 'place_id' => $place_id,
2272 'place_name' => sanitize_text_field($place_name),
2273 'used_at' => time(),
2274 ]);
2275 if (count($recent) > self::RECENT_MAX) {
2276 $recent = array_slice($recent, 0, self::RECENT_MAX);
2277 }
2278 update_option(self::OPT_RECENT, $recent);
2279 }
2280
2281 /**
2282 * Add or remove a place from the explicit "saved" list.
2283 */
2284 public static function toggle_saved_place(string $place_id, string $place_name, bool $save): void
2285 {
2286 $place_id = trim($place_id);
2287 if ($place_id === '') return;
2288 $saved = get_option(self::OPT_SAVED, []);
2289 if (!is_array($saved)) $saved = [];
2290 $saved = array_values(array_filter($saved, function ($p) use ($place_id) {
2291 return is_array($p) && ($p['place_id'] ?? '') !== $place_id;
2292 }));
2293 if ($save) {
2294 array_unshift($saved, [
2295 'place_id' => $place_id,
2296 'place_name' => sanitize_text_field($place_name),
2297 'saved_at' => time(),
2298 ]);
2299 }
2300 update_option(self::OPT_SAVED, $saved);
2301 }
2302
2303 public static function get_cache_ttl(): int
2304 {
2305 $ttl = (int) get_option(self::OPT_CACHE_TTL, 6 * HOUR_IN_SECONDS);
2306 return $ttl > 0 ? $ttl : 6 * HOUR_IN_SECONDS;
2307 }
2308
2309 /**
2310 * Flush all Google Reviews transients (the cache + error markers).
2311 * Returns the number of rows deleted.
2312 */
2313 public static function clear_cache(): int
2314 {
2315 global $wpdb;
2316 $like = $wpdb->esc_like('_transient_' . self::CACHE_PREFIX) . '%';
2317 $like_timeout = $wpdb->esc_like('_transient_timeout_' . self::CACHE_PREFIX) . '%';
2318 $a = (int) $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $like));
2319 $b = (int) $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $like_timeout));
2320 return $a + $b;
2321 }
2322
2323 /**
2324 * Enqueue the frontend stylesheet. Safe to call multiple times. Also
2325 * registers an `init` hook so the Gutenberg editor pulls the same CSS
2326 * into the editor iframe (ServerSideRender returns raw HTML so the
2327 * editor needs our stylesheet to render the cards correctly).
2328 */
2329 public static function enqueue_assets()
2330 {
2331 if (!wp_style_is('embedpress-google-reviews', 'registered')) {
2332 wp_register_style(
2333 'embedpress-google-reviews',
2334 // assets/ (build output) — NOT static/ (source). static/ is
2335 // excluded from the dist build (.distignore /static), so a
2336 // shipped/built plugin 404s on static/css/google-reviews.css.
2337 // The build copies static/ → assets/, so enqueue from assets.
2338 EMBEDPRESS_URL_ASSETS . 'css/google-reviews.css',
2339 [],
2340 EMBEDPRESS_VERSION
2341 );
2342 }
2343 wp_enqueue_style('embedpress-google-reviews');
2344
2345 // Read-more toggle (vanilla, no deps). Reveals the button only when the
2346 // review text actually overflows the CSS clamp; re-inits on editor SSR
2347 // re-render via a MutationObserver.
2348 if (!wp_script_is('embedpress-google-reviews', 'registered')) {
2349 wp_register_script(
2350 'embedpress-google-reviews',
2351 EMBEDPRESS_URL_ASSETS . 'js/google-reviews.js',
2352 [],
2353 EMBEDPRESS_VERSION,
2354 true
2355 );
2356 }
2357 wp_enqueue_script('embedpress-google-reviews');
2358
2359 // Content-gated Pro hook: fires only when a GR block/shortcode actually
2360 // renders. Pro enqueues its `embedpress-google-reviews-pro` assets here
2361 // (register happens earlier, on wp_enqueue_scripts), so Pro CSS/JS load
2362 // ONLY on pages that contain Google Reviews — not on every page.
2363 do_action('embedpress/google_reviews/enqueue_assets');
2364 }
2365
2366 /**
2367 * Hook for `enqueue_block_editor_assets` — load the frontend stylesheet
2368 * inside the block editor so ServerSideRender output renders correctly.
2369 *
2370 * `enqueue_block_editor_assets` only reaches the editor's TOP document, not
2371 * the iframed block canvas — and the device/responsive preview (Tablet /
2372 * Mobile) always renders inside that iframe. Without the stylesheet there,
2373 * `.ep-gr-star-svg { width:1em }` is lost and the SVG (viewBox, no intrinsic
2374 * size) balloons to fill its container (the "giant black star" bug).
2375 * `wp_enqueue_block_style()` (WP 5.9+) is the API that injects a per-block
2376 * stylesheet into the iframed canvas as well, so it renders correctly in
2377 * both the normal view and every device preview.
2378 */
2379 public static function enqueue_editor_assets()
2380 {
2381 self::enqueue_assets();
2382
2383 if (function_exists('wp_enqueue_block_style')) {
2384 if (!wp_style_is('embedpress-google-reviews', 'registered')) {
2385 wp_register_style(
2386 'embedpress-google-reviews',
2387 EMBEDPRESS_URL_ASSETS . 'css/google-reviews.css',
2388 [],
2389 EMBEDPRESS_VERSION
2390 );
2391 }
2392 wp_enqueue_block_style('embedpress/google-reviews', [
2393 'handle' => 'embedpress-google-reviews',
2394 ]);
2395 }
2396 }
2397 }
2398