PluginProbe ʕ •ᴥ•ʔ
EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents / 4.6.3
EmbedPress – PDF Embedder, 3D PDF FlipBook, Google Reviews, YouTube Videos, Upload & Embed PDF documents v4.6.3
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 1 month ago Database 3 months ago DynamicFieldResolver.php 2 months ago Elementor_Enhancer.php 7 months ago EmbedPress_Core_Installer.php 6 years ago EmbedPress_Notice.php 4 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 2 weeks ago FeaturePreviewModal.php 1 month ago Feature_Enhancer.php 2 months ago GoogleReviewsAdminPage.php 1 month ago GoogleReviewsApify.php 1 month ago GoogleReviewsManaged.php 5 days ago GoogleReviewsRenderer.php 5 days ago GoogleReviewsRestController.php 5 days ago GoogleReviewsStore.php 5 days ago Helper.php 5 days ago Pdf_Thumbnail_Handler.php 5 days ago PermalinkHelper.php 11 months ago View_Count_Display.php 1 month ago
GoogleReviewsRenderer.php
2363 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 $result = self::get_reviews_for_render($args['place_id'], $args);
595 if (is_wp_error($result)) {
596 return ['html' => '', 'has_more' => false, 'next_offset' => $offset];
597 }
598
599 $reviews = $result['reviews'] ?? [];
600 $meta = $result['meta'] ?? [];
601
602 // Apply the SAME filter pipeline as render() so paged ("Load more")
603 // results match the first page. Previously this only applied min_rating,
604 // so hide-empty / keyword / sort were skipped on pages 2+ and e.g.
605 // no-text reviews reappeared after Load more even with "Hide reviews
606 // with no text" enabled.
607 if (!empty($args['min_rating'])) {
608 $min = (int) $args['min_rating'];
609 $reviews = array_values(array_filter($reviews, static function ($r) use ($min) {
610 return (int) ($r['rating'] ?? 0) >= $min;
611 }));
612 }
613 $reviews = self::apply_review_filters($reviews, $args);
614 $reviews = (array) apply_filters('embedpress/google_reviews/reviews', $reviews, $args, $meta);
615
616 // Cap the full set the same way render() does (sanity ceiling), then slice
617 // to the requested page.
618 $total = min(count($reviews), 200);
619 $slice = array_slice($reviews, $offset, $per_page);
620
621 $html = '';
622 foreach ($slice as $review) {
623 $html .= apply_filters('embedpress/google_reviews/review_card', self::render_review($review, $args), $review, $args);
624 }
625
626 $next_offset = $offset + count($slice);
627 return [
628 'html' => $html,
629 'has_more' => $next_offset < $total,
630 'next_offset' => $next_offset,
631 ];
632 }
633
634 /**
635 * Get a place's reviews for rendering — from the DB store, never the network.
636 * If the place has never been fetched, do a one-time auto-fetch to populate
637 * the store; thereafter it's DB-only until the user refreshes from settings.
638 *
639 * @return array|\WP_Error {reviews, meta}
640 */
641 public static function get_reviews_for_render(string $place_id, array $args = [])
642 {
643 $place_id = trim($place_id);
644 if ($place_id === '') {
645 return new \WP_Error('embedpress_gr_missing_place', __('No place selected.', 'embedpress'));
646 }
647
648 $row = GoogleReviewsStore::get($place_id);
649
650 // Never fetched (new place, or added but not yet populated) → auto-fetch
651 // once to fill the store. Pro's "fetch all" mode is honoured here too.
652 // BUT skip if a background job is already running/queued for this place —
653 // otherwise every render would restart the job (the instant batch sets
654 // reviews but not last_fetched_at, which stays null until the job ends).
655 $job_active = $row && in_array(
656 $row['fetch_status'] ?? '',
657 [GoogleReviewsStore::STATUS_RUNNING, GoogleReviewsStore::STATUS_QUEUED],
658 true
659 );
660
661 // SELF-HEAL for sites the push webhook can't reach. Results normally
662 // arrive via the webhook (api.embedpress.com POSTs straight into the
663 // store the instant the scrape ends). But on localhost / firewalled /
664 // basic-auth sites the API can't POST in — so a job could sit "running"
665 // here forever with the reviews stranded on the proxy. If a job has been
666 // active longer than the grace window (the webhook should have landed by
667 // now), do ONE inline status.php pull to backfill. This is recovery, not
668 // a timeout: it NEVER flips the place to "failed" — poll_job() either
669 // finalizes with the proxy's reviews or leaves it running for next time.
670 if ($job_active && class_exists('\\EmbedPress\\Includes\\Classes\\GoogleReviewsManaged')) {
671 // updated_at is stored in SITE-LOCAL time (current_time('mysql')), so
672 // compare against a site-local "now" to avoid a timezone-offset skew.
673 $updated = isset($row['updated_at']) ? strtotime((string) $row['updated_at']) : 0;
674 $now_local = (int) current_time('timestamp');
675 $grace = (int) apply_filters('embedpress/google_reviews/webhook_grace_seconds', 45, $place_id);
676 if ($updated > 0 && ($now_local - $updated) >= $grace) {
677 try {
678 (new GoogleReviewsManaged())->poll_job($place_id);
679 $row = GoogleReviewsStore::get($place_id);
680 $job_active = $row && in_array(
681 $row['fetch_status'] ?? '',
682 [GoogleReviewsStore::STATUS_RUNNING, GoogleReviewsStore::STATUS_QUEUED],
683 true
684 );
685 } catch (\Throwable $e) {
686 // Recovery is best-effort — a pull error must never break the
687 // render; fall through and show whatever the store has.
688 }
689 }
690 }
691
692 if (!$job_active && (!$row || empty($row['last_fetched_at']))) {
693 // First render of a freshly-selected place: do a CHEAP, BOUNDED
694 // preview fetch (≤5) only. The expensive "fetch all" (Apify run /
695 // background job, up to the ceiling) must be an explicit, opted-in
696 // action from EmbedPress → Google Reviews → Refetch — never an
697 // automatic side effect of dropping the block on a page. The `auto`
698 // flag tells fetch_into_store to stay on the cheap path.
699 $fetched = self::fetch_into_store($place_id, array_merge($args, ['auto' => true]));
700 if (is_wp_error($fetched)) {
701 return $fetched;
702 }
703 $row = GoogleReviewsStore::get($place_id);
704 }
705
706 if (!$row) {
707 return ['reviews' => [], 'meta' => [], 'fetch_status' => GoogleReviewsStore::STATUS_IDLE];
708 }
709
710 return [
711 'reviews' => is_array($row['reviews']) ? $row['reviews'] : [],
712 'meta' => is_array($row['meta']) ? $row['meta'] : [],
713 // Surface the fetch state so render() can tell apart "still fetching"
714 // (running/queued) from "fetched but genuinely empty" (done) and
715 // "failed" — each gets a different placeholder instead of one flat
716 // "No reviews" message. last_fetched_at null + no active job → idle.
717 'fetch_status' => (string) ($row['fetch_status'] ?? GoogleReviewsStore::STATUS_IDLE),
718 ];
719 }
720
721 /**
722 * Fetch a place's reviews from the source (Pro Apify when fetch_all + token,
723 * else the official ≤5 API) and persist them to the DB store. This is the
724 * ONLY path that hits the network for reviews. Triggered by:
725 * - the settings-page "Refresh" action, or
726 * - a one-time auto-fetch on first render (get_reviews_for_render).
727 *
728 * @return array|\WP_Error {reviews, meta, source}
729 */
730 public static function fetch_into_store(string $place_id, array $args = [])
731 {
732 $place_id = trim($place_id);
733 if ($place_id === '') {
734 return new \WP_Error('embedpress_gr_missing_place', __('No place selected.', 'embedpress'));
735 }
736
737 // Cheap auto-preview fetch (first render): never fetch-all. A bounded
738 // ≤5 pull (Apify run capped at 5, or the Google ≤5 API) is enough to make
739 // the block show real reviews immediately, without burning a large Apify
740 // run. The expensive fetch-all only happens via an explicit Settings
741 // Refetch (which does NOT set `auto`).
742 $is_auto = !empty($args['auto']);
743 if ($is_auto) {
744 $args['fetch_all'] = false;
745 if (!isset($args['fetch_max']) || (int) $args['fetch_max'] < 1) {
746 $args['fetch_max'] = self::AUTO_PREVIEW_LIMIT;
747 }
748 }
749
750 // ── EmbedPress managed scraper WINS (when connected) ──────────────────
751 // The self-hosted scraper at api.embedpress.com/google-reviews/v1
752 // fetches ALL reviews for free — strictly better than the Apify
753 // ≤preview run or the Google Places ≤5 API. When the site is connected,
754 // route EVERY fetch (incl. auto-add) straight to it: a cache HIT returns
755 // all reviews inline; a MISS enqueues a background job (the store goes
756 // `running`, the cron poller fills it in, the block re-renders). We do
757 // NOT fall back to a partial Apify/Google preview that would "succeed"
758 // with a few reviews and freeze the place as done.
759 if (\EmbedPress\Includes\Classes\GoogleReviewsManaged::is_connected()) {
760 $managed_args = $args;
761 $managed_args['fetch_all'] = true; // always pull everything
762 unset($managed_args['fetch_max']); // no preview cap
763 $started = \EmbedPress\Includes\Classes\GoogleReviewsManaged::start_job($place_id, $managed_args);
764 $row = GoogleReviewsStore::get($place_id);
765 if ($started) {
766 // Cache hit → store already holds the full set; miss → running.
767 return [
768 'reviews' => ($row && is_array($row['reviews'])) ? $row['reviews'] : [],
769 'meta' => ($row && is_array($row['meta'])) ? $row['meta'] : [],
770 'source' => 'embedpress',
771 ];
772 }
773 // start_job returned false (proxy refused / unreachable) → fall
774 // through to the legacy Apify/Google paths so the block still shows
775 // something rather than nothing.
776 }
777
778 // Not connected (or managed refused): legacy provider preference.
779 $args = self::maybe_prefer_apify_fetch($args);
780
781 // ── Background fetch-all strategy (Apify) ─────────────────────────────
782 // Pulling every review via Apify takes time (the run-sync actor alone has
783 // a ~15s floor). Rather than block the first render, we start the
784 // background batched job and return immediately — the UIs render now and
785 // show a live progress bar ("Fetching all reviews… N so far"), then
786 // re-render as the cron imports batches into the store (up to the 1000
787 // ceiling). The job manages fetch_status / fetched_so_far itself.
788 // Skipped entirely for auto-previews (cheap path only).
789 if (!$is_auto && !empty($args['fetch_all']) && self::get_apify_token() !== '') {
790 $started = (bool) apply_filters('embedpress/google_reviews/start_fetch_job', false, $place_id, $args);
791 if ($started) {
792 $row = GoogleReviewsStore::get($place_id);
793 return [
794 'reviews' => ($row && is_array($row['reviews'])) ? $row['reviews'] : [],
795 'meta' => ($row && is_array($row['meta'])) ? $row['meta'] : [],
796 'source' => 'apify',
797 ];
798 }
799 // Job couldn't start (no token / Apify error) → fall through to the
800 // official ≤5 API path below so the block still shows something.
801 }
802
803 // Pro hooks pre_fetch to source reviews (Apify "fetch all" / multi-place).
804 // Returns a {reviews, meta} array, or null to use the official API.
805 $pre = apply_filters('embedpress/google_reviews/pre_fetch', null, $place_id, $args);
806 if (is_array($pre)) {
807 $result = self::normalize_result($pre);
808 $source = !empty($args['fetch_all']) ? 'apify' : 'api';
809 } else {
810 $result = self::fetch_from_api($place_id);
811 if (is_wp_error($result)) {
812 return $result;
813 }
814 $source = 'api';
815 }
816
817 $reviews = $result['reviews'] ?? [];
818 $meta = $result['meta'] ?? [];
819
820 GoogleReviewsStore::save_reviews($place_id, $reviews, $meta, $source);
821
822 return ['reviews' => $reviews, 'meta' => $meta, 'source' => $source];
823 }
824
825 /**
826 * If Apify is the usable provider (token set) and Google is not (no key),
827 * flag fetch_all so the Pro Apify pre_fetch sources reviews instead of the
828 * Google API. Only flips the flag when it isn't already set and a Google
829 * fetch would otherwise fail. No-op when a Google key is present (Google
830 * stays the default free path) or when no Apify token is configured.
831 */
832 private static function maybe_prefer_apify_fetch(array $args): array
833 {
834 if (!empty($args['fetch_all'])) {
835 return $args; // already routing through Apify (explicit fetch-all)
836 }
837 $has_google = self::get_api_key() !== '';
838 $has_apify = self::get_apify_token() !== '';
839 if (!$has_google && $has_apify) {
840 // Apify-only site. For an auto-preview, route through Apify but keep
841 // it BOUNDED (fetch_max already set to the preview limit) instead of
842 // flipping fetch_all — a key-less site should still show a few
843 // reviews without an unbounded run. For non-auto (explicit) fetches,
844 // fetch_all stays the full pull.
845 if (empty($args['auto'])) {
846 $args['fetch_all'] = true;
847 } else {
848 // Bounded Apify preview: pre_fetch keys off fetch_all OR places,
849 // so signal a capped fetch_all and let fetch_max do the bounding.
850 $args['fetch_all'] = true;
851 $args['fetch_max'] = isset($args['fetch_max']) && (int) $args['fetch_max'] > 0
852 ? (int) $args['fetch_max']
853 : self::AUTO_PREVIEW_LIMIT;
854 }
855 }
856 return $args;
857 }
858
859 /**
860 * Render the summary header: place name + overall Google rating + total
861 * review count. Falls back gracefully when meta is unavailable (e.g. an
862 * older cache or an API that didn't return it) — it simply renders nothing
863 * rather than a half-empty header.
864 */
865 private static function render_summary(array $meta, array $args): string
866 {
867 $name = $meta['name'] ?? ($args['place_name'] ?? '');
868 $rating = isset($meta['rating']) ? (float) $meta['rating'] : 0.0;
869 $total = isset($meta['total']) ? (int) $meta['total'] : 0;
870
871 if ($name === '' && $rating <= 0) {
872 return '';
873 }
874
875 $address = isset($meta['address']) ? (string) $meta['address'] : '';
876
877 // Granular header toggles (free). Each part can be hidden independently.
878 $show_name = !isset($args['show_summary_name']) || $args['show_summary_name'];
879 $show_rating = !isset($args['show_summary_rating']) || $args['show_summary_rating'];
880 $show_stars = !isset($args['show_summary_stars']) || $args['show_summary_stars'];
881 $show_count = !isset($args['show_summary_count']) || $args['show_summary_count'];
882 $show_write = !isset($args['show_write_review']) || $args['show_write_review'];
883
884 $align = isset($args['summary_align']) ? sanitize_key($args['summary_align']) : 'left';
885 $align = in_array($align, ['left', 'center', 'right'], true) ? $align : 'left';
886 $align_class = ' ep-gr-summary--align-' . $align;
887
888 // Nothing left to show in the rating row? skip it entirely.
889 $has_rating_row = $rating > 0 && ($show_rating || $show_stars || $show_count);
890
891 ob_start();
892 ?>
893 <div class="ep-gr-summary<?php echo esc_attr($align_class); ?>">
894 <div class="ep-gr-summary-head">
895 <div class="ep-gr-summary-place">
896 <?php if ($name !== '' && $show_name) : ?>
897 <div class="ep-gr-summary-name"><?php echo esc_html($name); ?></div>
898 <?php endif; ?>
899 <?php if ($address !== '' && $show_name) : ?>
900 <div class="ep-gr-summary-address"><?php echo esc_html($address); ?></div>
901 <?php endif; ?>
902 </div>
903 <?php if (!empty($args['place_id']) && $show_write) : ?>
904 <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>
905 <?php endif; ?>
906 </div>
907 <?php if ($has_rating_row) : ?>
908 <div class="ep-gr-summary-rating">
909 <?php if ($show_rating) : ?>
910 <span class="ep-gr-summary-score"><?php echo esc_html(number_format_i18n($rating, 1)); ?></span>
911 <?php endif; ?>
912 <?php if ($show_stars) : ?>
913 <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))); ?>">
914 <?php echo self::render_star_row($rating); ?>
915 </span>
916 <?php endif; ?>
917 <?php if ($total > 0 && $show_count) : ?>
918 <span class="ep-gr-summary-count"><?php
919 /* translators: %s: number of Google reviews */
920 echo esc_html(sprintf(_n('%s review', '%s reviews', $total, 'embedpress'), number_format_i18n($total)));
921 ?></span>
922 <?php endif; ?>
923 </div>
924 <?php endif; ?>
925 </div>
926 <?php
927 return ob_get_clean();
928 }
929
930 /**
931 * Render a single review card.
932 */
933 private static function render_review(array $review, array $args): string
934 {
935 $author = $review['author_name'] ?? __('Anonymous', 'embedpress');
936 $rating = (int) ($review['rating'] ?? 0);
937 $text = $review['text'] ?? '';
938 $photo = $review['profile_photo_url'] ?? '';
939 $time = isset($review['time']) ? (int) $review['time'] : 0;
940 // Prefer Google's relative phrasing ("a week ago") and fall back to an
941 // absolute date. Matches how reviews read on Google itself.
942 $relative = isset($review['relative_time']) ? (string) $review['relative_time'] : '';
943
944 // Rich fields (present only for Apify-sourced reviews; absent → not rendered).
945 $is_local_guide = !empty($review['is_local_guide']);
946 $rev_count = isset($review['reviewer_reviews']) ? (int) $review['reviewer_reviews'] : 0;
947 $images = (isset($review['images']) && is_array($review['images'])) ? $review['images'] : [];
948 $owner_resp = isset($review['owner_response']) ? (string) $review['owner_response'] : '';
949 $likes = isset($review['likes']) ? (int) $review['likes'] : 0;
950
951 ob_start();
952 ?>
953 <article class="ep-gr-review" itemscope itemtype="https://schema.org/Review">
954 <header class="ep-gr-review-head">
955 <?php if ($args['show_photo']) : ?>
956 <?php /* Initials placeholder sits underneath; the photo overlays it
957 and, if it fails to load, removes itself so the initials show. */ ?>
958 <span class="ep-gr-avatar ep-gr-avatar--placeholder" aria-hidden="true">
959 <?php echo esc_html(self::initials($author)); ?>
960 <?php if ($photo) : ?>
961 <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()" />
962 <?php endif; ?>
963 </span>
964 <?php endif; ?>
965 <div class="ep-gr-meta">
966 <div class="ep-gr-author" itemprop="author"><?php echo esc_html($author); ?></div>
967 <?php if ($is_local_guide || $rev_count > 0) : ?>
968 <div class="ep-gr-reviewer-meta">
969 <?php if ($is_local_guide) : ?>
970 <span class="ep-gr-local-guide"><?php esc_html_e('Local Guide', 'embedpress'); ?></span>
971 <?php endif; ?>
972 <?php if ($is_local_guide && $rev_count > 0) : ?>
973 <span class="ep-gr-dot" aria-hidden="true">·</span>
974 <?php endif; ?>
975 <?php if ($rev_count > 0) : ?>
976 <span class="ep-gr-reviewer-count"><?php
977 /* translators: %s: number of reviews the reviewer has written */
978 echo esc_html(sprintf(_n('%s review', '%s reviews', $rev_count, 'embedpress'), number_format_i18n($rev_count)));
979 ?></span>
980 <?php endif; ?>
981 </div>
982 <?php endif; ?>
983 </div>
984 <?php /* Google "G" badge — the recognizable mark real Google review
985 widgets show top-right of each card. */ ?>
986 <span class="ep-gr-source" aria-label="<?php esc_attr_e('Posted on Google', 'embedpress'); ?>">
987 <?php echo self::google_g_svg(); ?>
988 </span>
989 </header>
990
991 <?php if ($args['show_stars'] || ($args['show_date'] && ($relative !== '' || $time))) : ?>
992 <div class="ep-gr-stars-line">
993 <?php if ($args['show_stars']) : ?>
994 <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)); ?>">
995 <?php echo self::render_star_row((float) $rating); ?>
996 </span>
997 <?php endif; ?>
998 <?php if ($args['show_date'] && ($relative !== '' || $time)) : ?>
999 <time class="ep-gr-date"<?php echo $time ? ' datetime="' . esc_attr(gmdate('c', $time)) . '"' : ''; ?>><?php
1000 echo esc_html($relative !== '' ? $relative : date_i18n(get_option('date_format'), $time));
1001 ?></time>
1002 <?php endif; ?>
1003 </div>
1004 <?php endif; ?>
1005
1006 <?php if ($text) : ?>
1007 <div class="ep-gr-body">
1008 <div class="ep-gr-text" itemprop="reviewBody"><?php echo esc_html($text); ?></div>
1009 <?php /* JS reveals this only when the text actually overflows the clamp. */ ?>
1010 <button type="button" class="ep-gr-readmore" hidden aria-expanded="false">
1011 <span class="ep-gr-readmore-more"><?php esc_html_e('Read more', 'embedpress'); ?></span>
1012 <span class="ep-gr-readmore-less"><?php esc_html_e('Show less', 'embedpress'); ?></span>
1013 </button>
1014 </div>
1015 <?php endif; ?>
1016
1017 <?php if (!empty($images) && !empty($args['show_images'])) : ?>
1018 <?php
1019 // Cap rendered thumbnails; show a "+N" overlay on the last tile
1020 // when there are more. data-count drives the CSS grid layout.
1021 $max_thumbs = 4;
1022 $total_imgs = count($images);
1023 $shown = array_slice($images, 0, $max_thumbs);
1024 $overflow = $total_imgs - count($shown);
1025 ?>
1026 <div class="ep-gr-photos" data-count="<?php echo esc_attr((string) count($shown)); ?>">
1027 <?php foreach ($shown as $idx => $img) : ?>
1028 <?php $is_last = ($idx === count($shown) - 1); ?>
1029 <span class="ep-gr-photo<?php echo ($is_last && $overflow > 0) ? ' ep-gr-photo--more' : ''; ?>"<?php
1030 echo ($is_last && $overflow > 0) ? ' data-more="+' . esc_attr((string) $overflow) . '"' : '';
1031 ?>>
1032 <img src="<?php echo esc_url($img); ?>" alt="" loading="lazy" referrerpolicy="no-referrer" onerror="this.parentNode.remove()" />
1033 </span>
1034 <?php endforeach; ?>
1035 </div>
1036 <?php endif; ?>
1037
1038 <?php if ($owner_resp !== '') : ?>
1039 <div class="ep-gr-owner">
1040 <div class="ep-gr-owner-head"><?php esc_html_e('Response from the owner', 'embedpress'); ?></div>
1041 <div class="ep-gr-owner-text"><?php echo wp_kses(nl2br(esc_html($owner_resp)), ['br' => []]); ?></div>
1042 </div>
1043 <?php endif; ?>
1044
1045 <?php /* Like / thanks action row — mirrors Google's review footer. Static
1046 (display only), like the rest of an embedded review. */ ?>
1047 <div class="ep-gr-rev-actions" aria-hidden="true">
1048 <span class="ep-gr-rev-action ep-gr-rev-like" title="<?php esc_attr_e('Helpful', 'embedpress'); ?>">
1049 <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>
1050 </span>
1051 <span class="ep-gr-rev-action ep-gr-rev-thanks">
1052 <span class="ep-gr-thanks-icon" aria-hidden="true">🙏</span>
1053 <?php if ($likes > 0) : ?><span class="ep-gr-thanks-count"><?php echo esc_html(number_format_i18n($likes)); ?></span><?php endif; ?>
1054 </span>
1055 </div>
1056 </article>
1057 <?php
1058 return ob_get_clean();
1059 }
1060
1061 /**
1062 * Float-aware star row, Google style: a full gray 5-star track with a gold
1063 * fill overlay clipped to the exact rating (so 4.5 shows half a star). The
1064 * rating drives the fill width via the `--ep-gr-rating` custom property; CSS
1065 * does `width: calc(var(--ep-gr-rating)/5*100%)`. Glyphs are aria-hidden —
1066 * the accessible label lives on the wrapping element (caller supplies it).
1067 *
1068 * Back-compat: the inner `.ep-gr-star.is-filled/.is-empty` spans are kept in
1069 * the track so existing Pro CSS that colors `.is-filled` still applies.
1070 */
1071 private static function render_star_row(float $rating): string
1072 {
1073 $rating = max(0.0, min(5.0, $rating));
1074 // Crisp SVG star (one per slot) instead of a font glyph, so the shape is
1075 // identical across platforms/fonts. The gold fill is clipped to the
1076 // rating percentage for half-star precision (CSS overflow:hidden).
1077 $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>';
1078 $glyphs = str_repeat($star, 5);
1079 $pct = ($rating / 5) * 100;
1080 return '<span class="ep-gr-starrow" style="--ep-gr-rating:' . esc_attr((string) $rating) . '" aria-hidden="true">'
1081 . '<span class="ep-gr-starrow-track">' . $glyphs . '</span>'
1082 . '<span class="ep-gr-starrow-fill" style="width:' . esc_attr((string) round($pct, 2)) . '%">' . $glyphs . '</span>'
1083 . '</span>';
1084 }
1085
1086 /** The Google "G" logo as inline SVG (brand 4-color mark). */
1087 private static function google_g_svg(): string
1088 {
1089 return '<svg viewBox="0 0 48 48" width="18" height="18" aria-hidden="true">'
1090 . '<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"/>'
1091 . '<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"/>'
1092 . '<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"/>'
1093 . '<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"/>'
1094 . '</svg>';
1095 }
1096
1097 /**
1098 * Whole-star renderer (kept for back-compat / any caller that wants discrete
1099 * stars). New code should use render_star_row() for half-star support.
1100 */
1101 private static function render_stars(int $rating): string
1102 {
1103 $rating = max(0, min(5, $rating));
1104 $out = '';
1105 for ($i = 1; $i <= 5; $i++) {
1106 $out .= '<span class="ep-gr-star ' . ($i <= $rating ? 'is-filled' : 'is-empty') . '" aria-hidden="true">�
1107 </span>';
1108 }
1109 return $out;
1110 }
1111
1112 private static function initials(string $name): string
1113 {
1114 $parts = preg_split('/\s+/', trim($name)) ?: [];
1115 $first = mb_substr($parts[0] ?? '', 0, 1);
1116 $last = isset($parts[1]) ? mb_substr($parts[1], 0, 1) : '';
1117 return strtoupper($first . $last);
1118 }
1119
1120 /**
1121 * Sort + keyword + hide-empty filtering — FREE (mirrors Essential Addons'
1122 * free Business Reviews widget). Moved here from the Pro plugin so free
1123 * installs get the same behaviour. Pure array transforms, no network.
1124 *
1125 * @param array $reviews Normalized review arrays.
1126 * @param array $args Render args (sort / keyword / hide_empty).
1127 * @return array
1128 */
1129 /**
1130 * Build AggregateRating + Review JSON-LD — FREE (mirrors Essential Addons'
1131 * free Local Business schema). Returns $html unchanged when the schema
1132 * toggle is off or there are no reviews. Ported from the Pro plugin.
1133 *
1134 * @param string $html Existing markup to append to.
1135 * @param array $reviews Normalized reviews.
1136 * @param array $meta Place meta (name/rating/total).
1137 * @param array $args Render args (schema toggle).
1138 * @return string
1139 */
1140 private static function build_schema_jsonld(string $html, array $reviews, array $meta, array $args): string
1141 {
1142 if (empty($args['schema']) || empty($reviews)) {
1143 return $html;
1144 }
1145
1146 $name = $meta['name'] ?? ($args['place_name'] ?? '');
1147 $rating = isset($meta['rating']) ? (float) $meta['rating'] : 0.0;
1148 $total = isset($meta['total']) ? (int) $meta['total'] : count($reviews);
1149
1150 $review_nodes = [];
1151 foreach ($reviews as $r) {
1152 $body = trim((string) ($r['text'] ?? ''));
1153 $node = [
1154 '@type' => 'Review',
1155 'author' => [
1156 '@type' => 'Person',
1157 'name' => (string) ($r['author_name'] ?? __('Anonymous', 'embedpress')),
1158 ],
1159 'reviewRating' => [
1160 '@type' => 'Rating',
1161 'ratingValue' => (string) (int) ($r['rating'] ?? 0),
1162 'bestRating' => '5',
1163 'worstRating' => '1',
1164 ],
1165 ];
1166 if ($body !== '') {
1167 $node['reviewBody'] = $body;
1168 }
1169 if (!empty($r['time'])) {
1170 $node['datePublished'] = gmdate('Y-m-d', (int) $r['time']);
1171 }
1172 $review_nodes[] = $node;
1173 }
1174
1175 $data = [
1176 '@context' => 'https://schema.org',
1177 '@type' => 'LocalBusiness',
1178 'name' => $name !== '' ? $name : __('Business', 'embedpress'),
1179 ];
1180 if ($rating > 0 && $total > 0) {
1181 $data['aggregateRating'] = [
1182 '@type' => 'AggregateRating',
1183 'ratingValue' => (string) $rating,
1184 'reviewCount' => (string) $total,
1185 'bestRating' => '5',
1186 'worstRating' => '1',
1187 ];
1188 }
1189 if (!empty($review_nodes)) {
1190 $data['review'] = $review_nodes;
1191 }
1192
1193 $json = wp_json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
1194 if (!$json) {
1195 return $html;
1196 }
1197
1198 return $html . '<script type="application/ld+json" class="ep-gr-schema">' . $json . '</script>';
1199 }
1200
1201 private static function apply_review_filters(array $reviews, array $args): array
1202 {
1203 // Hide reviews with no text.
1204 if (!empty($args['hide_empty'])) {
1205 $reviews = array_values(array_filter($reviews, function ($r) {
1206 return trim((string) ($r['text'] ?? '')) !== '';
1207 }));
1208 }
1209
1210 // Keyword filter (case-insensitive substring on review text).
1211 $keyword = isset($args['keyword']) ? trim((string) $args['keyword']) : '';
1212 if ($keyword !== '') {
1213 $needle = function_exists('mb_strtolower') ? mb_strtolower($keyword) : strtolower($keyword);
1214 $reviews = array_values(array_filter($reviews, function ($r) use ($needle) {
1215 $hay = (string) ($r['text'] ?? '');
1216 $hay = function_exists('mb_strtolower') ? mb_strtolower($hay) : strtolower($hay);
1217 return $hay !== '' && strpos($hay, $needle) !== false;
1218 }));
1219 }
1220
1221 // Sort order.
1222 $sort = isset($args['sort']) ? (string) $args['sort'] : 'newest';
1223 switch ($sort) {
1224 case 'highest':
1225 usort($reviews, function ($a, $b) {
1226 return (int) ($b['rating'] ?? 0) <=> (int) ($a['rating'] ?? 0);
1227 });
1228 break;
1229 case 'lowest':
1230 usort($reviews, function ($a, $b) {
1231 return (int) ($a['rating'] ?? 0) <=> (int) ($b['rating'] ?? 0);
1232 });
1233 break;
1234 case 'newest':
1235 usort($reviews, function ($a, $b) {
1236 return (int) ($b['time'] ?? 0) <=> (int) ($a['time'] ?? 0);
1237 });
1238 break;
1239 case 'relevant':
1240 default:
1241 // Leave Google's native ordering.
1242 break;
1243 }
1244
1245 return $reviews;
1246 }
1247
1248 private static function render_empty(): string
1249 {
1250 return '<div class="ep-google-reviews ep-google-reviews--empty">'
1251 . esc_html__('No reviews to display yet.', 'embedpress')
1252 . '</div>';
1253 }
1254
1255 /**
1256 * Reviews for a freshly-added place are still being fetched in the
1257 * background (status running/queued). Render a friendly "loading"
1258 * placeholder — a skeleton + spinner + a message making clear it can take a
1259 * little while — carrying `data-ep-gr-poll` with the place_id so the
1260 * frontend poller (static/js/google-reviews-status.js) can poll the public
1261 * status endpoint and swap in the reviews the moment the job finishes, with
1262 * NO manual reload. The poller reloads the wrapper region when done.
1263 *
1264 * @param string $place_id The place whose fetch we're waiting on.
1265 */
1266 private static function render_loading(string $place_id): string
1267 {
1268 // Three shimmer skeleton cards approximate the incoming review list so
1269 // the block reserves space + reads as "working", not "empty/broken".
1270 $skeleton_card =
1271 '<div class="ep-gr-skel-card" aria-hidden="true">'
1272 . '<div class="ep-gr-skel-row">'
1273 . '<span class="ep-gr-skel ep-gr-skel-avatar"></span>'
1274 . '<span class="ep-gr-skel-lines"><span class="ep-gr-skel ep-gr-skel-line ep-gr-skel-line--sm"></span>'
1275 . '<span class="ep-gr-skel ep-gr-skel-line ep-gr-skel-line--xs"></span></span>'
1276 . '</div>'
1277 . '<span class="ep-gr-skel ep-gr-skel-line"></span>'
1278 . '<span class="ep-gr-skel ep-gr-skel-line"></span>'
1279 . '<span class="ep-gr-skel ep-gr-skel-line ep-gr-skel-line--md"></span>'
1280 . '</div>';
1281
1282 $poll_url = esc_url_raw(rest_url(GoogleReviewsRestController::NS . '/google-reviews/public-status'));
1283
1284 return '<div class="ep-google-reviews ep-google-reviews--loading" '
1285 . 'data-ep-gr-poll="' . esc_attr($place_id) . '" '
1286 . 'data-ep-gr-poll-url="' . esc_url($poll_url) . '" '
1287 . 'data-ep-gr-poll-interval="5000" role="status" aria-live="polite">'
1288 . '<div class="ep-gr-loading-head">'
1289 . '<span class="ep-gr-spinner" aria-hidden="true"></span>'
1290 . '<div class="ep-gr-loading-text">'
1291 . '<strong>' . esc_html__('Loading Google reviews…', 'embedpress') . '</strong>'
1292 . '<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>'
1293 . '</div>'
1294 . '</div>'
1295 . '<div class="ep-gr-skeletons">' . $skeleton_card . $skeleton_card . $skeleton_card . '</div>'
1296 . '</div>';
1297 }
1298
1299 /**
1300 * The background fetch for this place failed (e.g. the scraper was gated, or
1301 * a network error). Visitors see NOTHING (an empty string — the block just
1302 * doesn't render, so the public page never shows a broken state). Logged-in
1303 * editors see an actionable notice pointing at the settings page where they
1304 * can Refetch.
1305 *
1306 * @param string $place_id The place whose fetch failed.
1307 */
1308 private static function render_fetch_failed(string $place_id): string
1309 {
1310 if (!current_user_can('edit_posts')) {
1311 return ''; // visitors: render nothing rather than an error
1312 }
1313 $settings_url = admin_url('admin.php?page=embedpress-google-reviews');
1314 return '<div class="ep-google-reviews ep-google-reviews--error ep-google-reviews--fetch-failed">'
1315 . '<p><strong>' . esc_html__('Couldn’t load this place’s reviews.', 'embedpress') . '</strong></p>'
1316 . '<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>'
1317 . '<p><a href="' . esc_url($settings_url) . '">' . esc_html__('Open Google Reviews settings →', 'embedpress') . '</a></p>'
1318 . '<p class="ep-gr-admin-only-note"><em>' . esc_html__('Only you (an editor) can see this message — visitors see nothing.', 'embedpress') . '</em></p>'
1319 . '</div>';
1320 }
1321
1322 /**
1323 * A Pro layout was selected without Pro active. Editors see an actionable
1324 * upsell (so they understand why their chosen layout isn't rendering); public
1325 * visitors see nothing (the caller falls back to a free layout for them, so
1326 * the front-end never looks broken to real users).
1327 *
1328 * @return string Upsell markup for editors, '' for visitors (→ free fallback).
1329 */
1330 private static function render_pro_layout_upsell(string $layout): string
1331 {
1332 if (!current_user_can('edit_posts')) {
1333 return ''; // visitor → caller renders the free fallback layout
1334 }
1335 $labels = [
1336 'masonry' => __('Masonry', 'embedpress'),
1337 'badge' => __('Compact badge', 'embedpress'),
1338 'spotlight' => __('Spotlight', 'embedpress'),
1339 'knowledge' => __('Knowledge panel', 'embedpress'),
1340 'marquee' => __('Marquee', 'embedpress'),
1341 'bubble' => __('Bubble', 'embedpress'),
1342 ];
1343 $name = $labels[$layout] ?? ucfirst($layout);
1344 $url = 'https://wpdeveloper.com/in/upgrade-embedpress';
1345
1346 // Reuse EmbedPress's canonical Pro-card visual language (pro__alert__card)
1347 // so this matches the upgrade UI used across the plugin. Rendered inline
1348 // (not as the hidden modal overlay) since it stands in for content.
1349 $icon = '<svg width="48" height="48" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">'
1350 . '<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>';
1351
1352 return '<div class="ep-google-reviews ep-gr-pro-upsell">'
1353 . '<div class="pro__alert__card ep-gr-pro-upsell__card">'
1354 . '<div class="ep-gr-pro-upsell__icon">' . $icon . '</div>'
1355 . '<h2>' . esc_html(sprintf(/* translators: %s: layout name */ __('“%s” is a Pro layout', 'embedpress'), $name)) . '</h2>'
1356 . '<p>' . esc_html__('Upgrade to EmbedPress Pro to use this layout, or pick a free one (List, Grid, Card, Carousel).', 'embedpress') . '</p>'
1357 . '<a href="' . esc_url($url) . '" target="_blank" rel="noopener noreferrer" class="pro__alert__btn ep-gr-pro-upsell__btn">'
1358 . esc_html__('Upgrade to Pro', 'embedpress') . '</a>'
1359 . '<p class="ep-gr-pro-upsell__note">' . esc_html__('Visitors see the List layout until you upgrade or switch.', 'embedpress') . '</p>'
1360 . '</div></div>';
1361 }
1362
1363 private static function render_error(string $message): string
1364 {
1365 if (!current_user_can('edit_posts')) {
1366 return '';
1367 }
1368 return '<div class="ep-google-reviews ep-google-reviews--error">'
1369 . esc_html(sprintf(/* translators: %s: error message from Google API */ __('Google Reviews error: %s', 'embedpress'), $message))
1370 . '</div>';
1371 }
1372
1373 /**
1374 * Friendly "no place chosen yet" placeholder for a freshly added block /
1375 * widget. This is the normal starting state, not an error — so it's a calm
1376 * prompt that tells the editor what to do next, not a red error box.
1377 *
1378 * Editor-only: visitors must never see setup instructions, so on the front
1379 * end (no edit_posts cap) we render nothing at all.
1380 *
1381 * @return string
1382 */
1383 private static function render_pick_place_prompt(): string
1384 {
1385 if (!current_user_can('edit_posts')) {
1386 return '';
1387 }
1388 return '<div class="ep-google-reviews ep-google-reviews--placeholder">'
1389 . '<span class="ep-gr-placeholder-icon" aria-hidden="true">'
1390 . '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">'
1391 . '<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"/>'
1392 . '<circle cx="12" cy="10" r="3" stroke="currentColor" stroke-width="1.6"/></svg>'
1393 . '</span>'
1394 . '<strong>' . esc_html__('Choose a Google Business to show its reviews', 'embedpress') . '</strong>'
1395 . '<span>' . esc_html__('Search for a place in the panel, or paste a Place ID, to display its reviews here.', 'embedpress') . '</span>'
1396 . '</div>';
1397 }
1398
1399 /**
1400 * Fetch reviews for a place_id from the Google Places API, with a
1401 * transient cache to stay under quota. Returns up to 5 reviews
1402 * (Places API hard cap) or a WP_Error if the request fails.
1403 *
1404 * Routes through self::dispatch() so we transparently support both the
1405 * legacy Places API and Places API (New). The dispatcher probes once,
1406 * persists the working mode, and falls back if the stored mode goes
1407 * stale (e.g. legacy gets disabled mid-project).
1408 *
1409 * @return array|\WP_Error
1410 */
1411 public static function fetch_reviews(string $place_id, array $args = [])
1412 {
1413 // Back-compat alias. Pro's multi-place merge calls this to get a single
1414 // place's ≤5 reviews; it's now a direct (uncached) API fetch — the DB
1415 // store is the cache layer, so no transient here.
1416 return self::fetch_from_api($place_id);
1417 }
1418
1419 /**
1420 * Raw fetch of a single place's reviews from the Google Places API (≤5,
1421 * the API hard cap). No caching — callers (the DB store / fetch_into_store)
1422 * own persistence. Routes through self::dispatch() for legacy/new API
1423 * auto-detection.
1424 *
1425 * @return array|\WP_Error {reviews, meta}
1426 */
1427 public static function fetch_from_api(string $place_id)
1428 {
1429 if ($place_id === '') {
1430 return new \WP_Error('embedpress_gr_missing_place', __('No place selected.', 'embedpress'));
1431 }
1432 if (!preg_match('/^[A-Za-z0-9_\-]+$/', $place_id)) {
1433 return new \WP_Error('embedpress_gr_invalid_place', __('Invalid place identifier.', 'embedpress'));
1434 }
1435
1436 $api_key = self::get_api_key();
1437 if ($api_key === '') {
1438 // No Google key. If an Apify token is set the caller should have
1439 // routed through Apify already (see maybe_prefer_apify_fetch); reaching
1440 // here means no provider is usable, so give a provider-neutral message.
1441 $message = self::get_apify_token() !== ''
1442 ? __('Could not load reviews. Open EmbedPress → Google Reviews and use “Refetch” for this place.', 'embedpress')
1443 : __('Not connected to the EmbedPress API. Open EmbedPress → Google Reviews and click “Connect to EmbedPress API” to start fetching reviews.', 'embedpress');
1444 return new \WP_Error('embedpress_gr_missing_key', $message);
1445 }
1446
1447 if (defined('WP_DEBUG') && WP_DEBUG) {
1448 error_log('embedpress-gr: API call for place_id=' . $place_id);
1449 }
1450
1451 $result = self::dispatch('details', $api_key, ['place_id' => $place_id]);
1452 if (is_wp_error($result)) {
1453 return $result;
1454 }
1455
1456 return self::normalize_result($result);
1457 }
1458
1459 /**
1460 * Accept both the legacy flat-array review shape (older caches) and the
1461 * current `{reviews, meta}` shape, and always return the latter. Keeps
1462 * pre-existing transients valid after the meta/header change.
1463 */
1464 private static function normalize_result($data): array
1465 {
1466 if (isset($data['reviews']) && is_array($data['reviews'])) {
1467 return [
1468 'reviews' => array_values($data['reviews']),
1469 'meta' => isset($data['meta']) && is_array($data['meta']) ? $data['meta'] : [],
1470 ];
1471 }
1472 // Legacy flat list of reviews (no meta available).
1473 return ['reviews' => array_values((array) $data), 'meta' => []];
1474 }
1475
1476 /**
1477 * Run a Places autocomplete query, normalized across legacy + New API.
1478 * Returns a list of `{place_id, description, main_text, secondary_text}`
1479 * arrays or a WP_Error. Caller is responsible for caching.
1480 *
1481 * @return array|\WP_Error
1482 */
1483 public static function autocomplete(string $q)
1484 {
1485 $api_key = self::get_api_key();
1486 if ($api_key === '') {
1487 // Reached the Google path with no key. If Apify is connected the
1488 // search controller already tried Apify; a genuine timeout is handled
1489 // earlier (returned directly), so reaching here means Apify simply
1490 // found no match for this query — tell the user that, not to add a
1491 // Google key.
1492 $message = self::get_apify_token() !== ''
1493 ? __('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')
1494 : __('Not connected to the EmbedPress API. Open EmbedPress → Google Reviews and click “Connect to EmbedPress API” to start fetching reviews.', 'embedpress');
1495 return new \WP_Error('embedpress_gr_no_key', $message, ['status' => 400]);
1496 }
1497 return self::dispatch('autocomplete', $api_key, ['q' => $q]);
1498 }
1499
1500 /**
1501 * Verify a Google Places key works WITHOUT saving it — used by the settings
1502 * "Connect" flow. Runs a tiny autocomplete probe with the supplied key.
1503 * Returns true on success or a WP_Error with an actionable message.
1504 *
1505 * @return true|\WP_Error
1506 */
1507 public static function verify_google_key(string $api_key)
1508 {
1509 $api_key = trim($api_key);
1510 if ($api_key === '') {
1511 return new \WP_Error('embedpress_gr_no_key', __('No key provided.', 'embedpress'));
1512 }
1513 // Probe both API variants with the given key (not the stored mode).
1514 $res = self::call_new_autocomplete($api_key, 'coffee');
1515 if (!is_wp_error($res)) {
1516 return true;
1517 }
1518 if (self::is_api_not_enabled_error($res)) {
1519 $legacy = self::call_legacy_autocomplete($api_key, 'coffee');
1520 if (!is_wp_error($legacy)) {
1521 return true;
1522 }
1523 return $legacy;
1524 }
1525 return $res;
1526 }
1527
1528 /**
1529 * Dispatch a Places API call against whichever variant is enabled for
1530 * the user's key. Tries the stored mode first; on a permission-class
1531 * failure, transparently tries the other and updates the stored mode
1532 * so future calls go direct.
1533 *
1534 * $op is 'autocomplete' or 'details'.
1535 *
1536 * @return array|\WP_Error
1537 */
1538 private static function dispatch(string $op, string $api_key, array $params)
1539 {
1540 $mode = self::get_api_mode();
1541
1542 $try = function (string $variant) use ($op, $api_key, $params) {
1543 if ($variant === 'new') {
1544 return $op === 'autocomplete'
1545 ? self::call_new_autocomplete($api_key, (string) ($params['q'] ?? ''))
1546 : self::call_new_details($api_key, (string) ($params['place_id'] ?? ''));
1547 }
1548 return $op === 'autocomplete'
1549 ? self::call_legacy_autocomplete($api_key, (string) ($params['q'] ?? ''))
1550 : self::call_legacy_details($api_key, (string) ($params['place_id'] ?? ''));
1551 };
1552
1553 // Order: try stored mode first; if 'auto', try New first (it's the
1554 // only variant available to GCP projects created after 2025-03-01).
1555 $first = in_array($mode, ['new', 'legacy'], true) ? $mode : 'new';
1556 $second = $first === 'new' ? 'legacy' : 'new';
1557
1558 $result = $try($first);
1559 if (!is_wp_error($result)) {
1560 self::set_api_mode($first);
1561 return $result;
1562 }
1563
1564 if (!self::is_api_not_enabled_error($result)) {
1565 // A real error (bad query, network, etc.) — don't waste a second call.
1566 return $result;
1567 }
1568
1569 $alt = $try($second);
1570 if (!is_wp_error($alt)) {
1571 self::set_api_mode($second);
1572 return $alt;
1573 }
1574
1575 // Both failed. Surface whichever message is more informative.
1576 return self::is_api_not_enabled_error($alt) ? $result : $alt;
1577 }
1578
1579 /**
1580 * Heuristic: does this WP_Error look like "the API variant isn't enabled
1581 * for this project" (vs. a key being invalid, quota exceeded, etc.)?
1582 */
1583 private static function is_api_not_enabled_error(\WP_Error $err): bool
1584 {
1585 $code = strtolower((string) $err->get_error_code());
1586 if (
1587 str_contains($code, 'request_denied')
1588 || str_contains($code, 'permission_denied')
1589 || str_contains($code, 'failed_precondition')
1590 || str_contains($code, 'http_403')
1591 ) {
1592 return true;
1593 }
1594 $msg = strtolower((string) $err->get_error_message());
1595 return str_contains($msg, 'legacy api') || str_contains($msg, 'has not been used in project') || str_contains($msg, 'is not enabled');
1596 }
1597
1598 /**
1599 * Legacy Places API: autocomplete. Returns normalized predictions.
1600 *
1601 * @return array|\WP_Error
1602 */
1603 private static function call_legacy_autocomplete(string $api_key, string $q)
1604 {
1605 $url = add_query_arg([
1606 'input' => $q,
1607 'types' => 'establishment',
1608 'key' => $api_key,
1609 ], self::ENDPOINT_LEGACY_AUTOCOMPLETE);
1610
1611 $response = wp_remote_get($url, ['timeout' => 6]);
1612 if (is_wp_error($response)) {
1613 return new \WP_Error('embedpress_gr_http', $response->get_error_message(), ['status' => 502]);
1614 }
1615 $body = json_decode(wp_remote_retrieve_body($response), true);
1616 $err = self::legacy_status_error($body);
1617 if ($err) return $err;
1618
1619 $predictions = [];
1620 foreach (($body['predictions'] ?? []) as $p) {
1621 $predictions[] = [
1622 'place_id' => isset($p['place_id']) ? (string) $p['place_id'] : '',
1623 'description' => isset($p['description']) ? (string) $p['description'] : '',
1624 'main_text' => isset($p['structured_formatting']['main_text']) ? (string) $p['structured_formatting']['main_text'] : '',
1625 'secondary_text' => isset($p['structured_formatting']['secondary_text']) ? (string) $p['structured_formatting']['secondary_text'] : '',
1626 ];
1627 }
1628 return $predictions;
1629 }
1630
1631 /**
1632 * Legacy Places API: place details (reviews only). Returns normalized
1633 * review list.
1634 *
1635 * @return array|\WP_Error
1636 */
1637 private static function call_legacy_details(string $api_key, string $place_id)
1638 {
1639 $url = add_query_arg([
1640 'place_id' => $place_id,
1641 // Pull place meta (name + overall rating + total + address) alongside
1642 // the reviews so the summary header + Saved-places list need no extra
1643 // API call.
1644 'fields' => 'name,rating,user_ratings_total,formatted_address,reviews',
1645 'reviews_sort' => 'newest',
1646 'key' => $api_key,
1647 ], self::ENDPOINT_LEGACY_DETAILS);
1648
1649 $response = wp_remote_get($url, ['timeout' => 8]);
1650 if (is_wp_error($response)) {
1651 return new \WP_Error('embedpress_gr_http', $response->get_error_message());
1652 }
1653 $code = (int) wp_remote_retrieve_response_code($response);
1654 if ($code !== 200) {
1655 return new \WP_Error('embedpress_gr_http_' . $code, sprintf(/* translators: %d: HTTP status code */ __('Google Places returned HTTP %d.', 'embedpress'), $code));
1656 }
1657 $body = json_decode(wp_remote_retrieve_body($response), true);
1658 $err = self::legacy_status_error($body);
1659 if ($err) return $err;
1660
1661 $result = $body['result'] ?? [];
1662 $reviews = [];
1663 foreach (($result['reviews'] ?? []) as $r) {
1664 $reviews[] = [
1665 'author_name' => isset($r['author_name']) ? (string) $r['author_name'] : '',
1666 'rating' => isset($r['rating']) ? (int) $r['rating'] : 0,
1667 'text' => isset($r['text']) ? (string) $r['text'] : '',
1668 'time' => isset($r['time']) ? (int) $r['time'] : 0,
1669 // Google's own "a week ago" phrasing — preferred over an
1670 // absolute date for review recency.
1671 'relative_time' => isset($r['relative_time_description']) ? (string) $r['relative_time_description'] : '',
1672 'profile_photo_url' => isset($r['profile_photo_url']) ? esc_url_raw($r['profile_photo_url']) : '',
1673 ];
1674 }
1675 return [
1676 'reviews' => $reviews,
1677 'meta' => [
1678 'name' => isset($result['name']) ? (string) $result['name'] : '',
1679 'rating' => isset($result['rating']) ? (float) $result['rating'] : 0.0,
1680 'total' => isset($result['user_ratings_total']) ? (int) $result['user_ratings_total'] : 0,
1681 // Location address for the summary header + Saved-places list.
1682 'address' => isset($result['formatted_address']) ? (string) $result['formatted_address'] : '',
1683 ],
1684 ];
1685 }
1686
1687 /**
1688 * Convert a legacy Places response body into a WP_Error if its `status`
1689 * indicates failure. Returns null on success.
1690 */
1691 private static function legacy_status_error($body): ?\WP_Error
1692 {
1693 if (!is_array($body)) {
1694 return new \WP_Error('embedpress_gr_bad_response', __('Invalid response from Google Places.', 'embedpress'));
1695 }
1696 $status = $body['status'] ?? 'UNKNOWN_ERROR';
1697 if ($status === 'OK' || $status === 'ZERO_RESULTS') return null;
1698 $error_message = isset($body['error_message']) ? (string) $body['error_message'] : '';
1699 $msg = $error_message !== ''
1700 ? sprintf(/* translators: 1: Google API error status, 2: Google API error message */ __('Google Places error: %1$s — %2$s', 'embedpress'), $status, $error_message)
1701 : sprintf(/* translators: %s: Google API error status */ __('Google Places error: %s', 'embedpress'), $status);
1702 $msg .= self::friendly_api_hint($status, $error_message);
1703 return new \WP_Error('embedpress_gr_api_' . strtolower($status), $msg, ['status' => 502]);
1704 }
1705
1706 /**
1707 * Translate Google's terse/cryptic API statuses into an actionable hint
1708 * for the site admin. Google often returns a bare "The caller does not
1709 * have permission" with no remediation; this appends the concrete fix
1710 * (which is almost always a Cloud Console setting, not a plugin bug).
1711 *
1712 * Returns an empty string for non-error / unknown statuses so the base
1713 * message is unchanged.
1714 */
1715 private static function friendly_api_hint(string $status, string $message = ''): string
1716 {
1717 $status = strtoupper($status);
1718 $message = strtolower($message);
1719
1720 // Billing not enabled — Places API (New) requires an active billing account.
1721 if (strpos($message, 'billing') !== false) {
1722 return ' ' . __('Enable billing for your project in the Google Cloud Console — the Places API requires an active billing account.', 'embedpress');
1723 }
1724
1725 // API not enabled on the project, or the legacy API is deprecated.
1726 if (
1727 strpos($message, 'has not been used') !== false
1728 || strpos($message, 'is not enabled') !== false
1729 || strpos($message, 'not activated') !== false
1730 || strpos($message, 'legacy api') !== false
1731 || $status === 'SERVICE_DISABLED'
1732 ) {
1733 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');
1734 }
1735
1736 // Permission denied with no detail — almost always API-not-enabled or a
1737 // key restriction blocking the Places API.
1738 if ($status === 'PERMISSION_DENIED' || $status === 'REQUEST_DENIED') {
1739 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');
1740 }
1741
1742 // Quota / rate limit.
1743 if ($status === 'RESOURCE_EXHAUSTED' || $status === 'OVER_QUERY_LIMIT') {
1744 return ' ' . __('Your Google Places API quota has been exceeded. Check your usage and quotas in the Google Cloud Console.', 'embedpress');
1745 }
1746
1747 // Bad / unauthorized key.
1748 if ($status === 'UNAUTHENTICATED' || $status === 'INVALID_ARGUMENT' || strpos($message, 'api key not valid') !== false) {
1749 return ' ' . __('Your Google Places API key appears to be invalid. Re-copy it from the Google Cloud Console (APIs & Services → Credentials).', 'embedpress');
1750 }
1751
1752 return '';
1753 }
1754
1755 /**
1756 * Places API (New): autocomplete. Returns normalized predictions.
1757 *
1758 * @return array|\WP_Error
1759 */
1760 private static function call_new_autocomplete(string $api_key, string $q)
1761 {
1762 $response = wp_remote_post(self::ENDPOINT_NEW_AUTOCOMPLETE, [
1763 'timeout' => 6,
1764 'headers' => [
1765 'Content-Type' => 'application/json',
1766 'X-Goog-Api-Key' => $api_key,
1767 ],
1768 'body' => wp_json_encode([
1769 'input' => $q,
1770 'includedPrimaryTypes' => ['establishment'],
1771 ]),
1772 ]);
1773 if (is_wp_error($response)) {
1774 return new \WP_Error('embedpress_gr_http', $response->get_error_message(), ['status' => 502]);
1775 }
1776 $body = json_decode(wp_remote_retrieve_body($response), true);
1777 $code = (int) wp_remote_retrieve_response_code($response);
1778 $err = self::new_api_error($body, $code);
1779 if ($err) return $err;
1780
1781 $predictions = [];
1782 foreach (($body['suggestions'] ?? []) as $s) {
1783 $p = $s['placePrediction'] ?? null;
1784 if (!$p) continue;
1785 $predictions[] = [
1786 'place_id' => isset($p['placeId']) ? (string) $p['placeId'] : '',
1787 'description' => isset($p['text']['text']) ? (string) $p['text']['text'] : '',
1788 'main_text' => isset($p['structuredFormat']['mainText']['text']) ? (string) $p['structuredFormat']['mainText']['text'] : '',
1789 'secondary_text' => isset($p['structuredFormat']['secondaryText']['text']) ? (string) $p['structuredFormat']['secondaryText']['text'] : '',
1790 ];
1791 }
1792 return $predictions;
1793 }
1794
1795 /**
1796 * Places API (New): place details (reviews). Returns normalized review list.
1797 *
1798 * @return array|\WP_Error
1799 */
1800 private static function call_new_details(string $api_key, string $place_id)
1801 {
1802 // The New API uses place resource names (`places/PLACE_ID`); a bare
1803 // place_id is also accepted at this endpoint.
1804 $response = wp_remote_get(self::ENDPOINT_NEW_DETAILS . rawurlencode($place_id), [
1805 'timeout' => 8,
1806 'headers' => [
1807 'X-Goog-Api-Key' => $api_key,
1808 // Place meta (name + overall rating + total) alongside reviews.
1809 'X-Goog-FieldMask' => 'displayName,rating,userRatingCount,formattedAddress,reviews',
1810 ],
1811 ]);
1812 if (is_wp_error($response)) {
1813 return new \WP_Error('embedpress_gr_http', $response->get_error_message());
1814 }
1815 $body = json_decode(wp_remote_retrieve_body($response), true);
1816 $code = (int) wp_remote_retrieve_response_code($response);
1817 $err = self::new_api_error($body, $code);
1818 if ($err) return $err;
1819
1820 $reviews = [];
1821 foreach (($body['reviews'] ?? []) as $r) {
1822 $time = 0;
1823 if (!empty($r['publishTime'])) {
1824 $t = strtotime((string) $r['publishTime']);
1825 $time = $t ? $t : 0;
1826 }
1827 $reviews[] = [
1828 'author_name' => isset($r['authorAttribution']['displayName']) ? (string) $r['authorAttribution']['displayName'] : '',
1829 'rating' => isset($r['rating']) ? (int) $r['rating'] : 0,
1830 'text' => isset($r['text']['text']) ? (string) $r['text']['text'] : (isset($r['originalText']['text']) ? (string) $r['originalText']['text'] : ''),
1831 'time' => $time,
1832 // Google's own "a week ago" phrasing — preferred over an
1833 // absolute date for review recency.
1834 'relative_time' => isset($r['relativePublishTimeDescription']) ? (string) $r['relativePublishTimeDescription'] : '',
1835 'profile_photo_url' => isset($r['authorAttribution']['photoUri']) ? esc_url_raw((string) $r['authorAttribution']['photoUri']) : '',
1836 ];
1837 }
1838 return [
1839 'reviews' => $reviews,
1840 'meta' => [
1841 'name' => isset($body['displayName']['text']) ? (string) $body['displayName']['text'] : '',
1842 'rating' => isset($body['rating']) ? (float) $body['rating'] : 0.0,
1843 'total' => isset($body['userRatingCount']) ? (int) $body['userRatingCount'] : 0,
1844 // Location address for the summary header + Saved-places list.
1845 'address' => isset($body['formattedAddress']) ? (string) $body['formattedAddress'] : '',
1846 ],
1847 ];
1848 }
1849
1850 /**
1851 * Convert a Places API (New) response into a WP_Error if it indicates
1852 * failure. The new API uses Google's standard error envelope
1853 * `{ error: { code, message, status } }` plus a non-200 HTTP status.
1854 */
1855 private static function new_api_error($body, int $http_code): ?\WP_Error
1856 {
1857 if (is_array($body) && isset($body['error']) && is_array($body['error'])) {
1858 $status = (string) ($body['error']['status'] ?? 'UNKNOWN_ERROR');
1859 $message = (string) ($body['error']['message'] ?? '');
1860 $msg = $message !== ''
1861 ? sprintf(/* translators: 1: Google API error status, 2: Google API error message */ __('Google Places error: %1$s — %2$s', 'embedpress'), $status, $message)
1862 : sprintf(/* translators: %s: Google API error status */ __('Google Places error: %s', 'embedpress'), $status);
1863 $msg .= self::friendly_api_hint($status, $message);
1864 return new \WP_Error('embedpress_gr_api_' . strtolower($status), $msg, ['status' => 502]);
1865 }
1866 if ($http_code !== 200) {
1867 return new \WP_Error('embedpress_gr_http_' . $http_code, sprintf(/* translators: %d: HTTP status code */ __('Google Places returned HTTP %d.', 'embedpress'), $http_code));
1868 }
1869 if (!is_array($body)) {
1870 return new \WP_Error('embedpress_gr_bad_response', __('Invalid response from Google Places.', 'embedpress'));
1871 }
1872 return null;
1873 }
1874
1875 /**
1876 * Cache an error briefly so we don't hammer the API while the user fixes
1877 * the underlying problem (bad key, quota exceeded, etc.).
1878 */
1879 private static function cache_error(string $cache_key, string $message)
1880 {
1881 set_transient($cache_key . '_err', $message, 5 * MINUTE_IN_SECONDS);
1882 }
1883
1884 public static function get_api_key(): string
1885 {
1886 if (defined('EMBEDPRESS_GOOGLE_REVIEWS_API_KEY') && EMBEDPRESS_GOOGLE_REVIEWS_API_KEY) {
1887 return (string) EMBEDPRESS_GOOGLE_REVIEWS_API_KEY;
1888 }
1889 $key = get_option(self::OPT_API_KEY, '');
1890 return is_string($key) ? trim($key) : '';
1891 }
1892
1893 /**
1894 * Apify API token for the Pro "fetch all reviews" provider. Prefers a
1895 * wp-config constant (most secure), then the saved option.
1896 */
1897 public static function get_apify_token(): string
1898 {
1899 if (defined('EMBEDPRESS_APIFY_TOKEN') && EMBEDPRESS_APIFY_TOKEN) {
1900 return (string) EMBEDPRESS_APIFY_TOKEN;
1901 }
1902 $token = get_option(self::OPT_APIFY_TOKEN, '');
1903 return is_string($token) ? trim($token) : '';
1904 }
1905
1906 /**
1907 * Which provider powers place SEARCH. 'auto' (default) prefers Google when a
1908 * key is set, else Apify; 'google'/'apify' force a provider. Resolves 'auto'
1909 * to the concrete provider that's actually usable given configured creds.
1910 */
1911 public static function get_search_provider(): string
1912 {
1913 $pref = (string) get_option(self::OPT_SEARCH_PROVIDER, 'auto');
1914 $pref = in_array($pref, ['auto', 'google', 'apify'], true) ? $pref : 'auto';
1915
1916 $has_google = self::get_api_key() !== '';
1917 $has_apify = self::get_apify_token() !== '';
1918
1919 if ($pref === 'google') {
1920 return $has_google ? 'google' : ($has_apify ? 'apify' : 'managed');
1921 }
1922 if ($pref === 'apify') {
1923 return $has_apify ? 'apify' : ($has_google ? 'google' : 'managed');
1924 }
1925 // auto: user's own Google key first, then the hosted EmbedPress proxy
1926 // (session-billed Google Places API — fast, ~1.4s, zero setup).
1927 //
1928 // Apify is LAST on purpose: its place-search actor is a ~20s LIVE
1929 // SCRAPE, far slower than the Google Places Autocomplete API. Having
1930 // an Apify token (set up for the Pro "fetch all reviews" feature)
1931 // must NOT drag the picker onto that slow path. The managed proxy
1932 // gives instant session-based autocomplete; Apify search stays only
1933 // as a final fallback if the proxy is unreachable.
1934 if ($has_google) return 'google';
1935 return 'managed';
1936 }
1937
1938 /**
1939 * Hosted-proxy place SEARCH via api.embedpress.com/google-places.php.
1940 * Zero user setup: EmbedPress's server holds the Google Places key + does
1941 * the Autocomplete call. The proxy caches + rate-limits per IP.
1942 *
1943 * Why this exists: most users don't want to set up a Google Cloud project
1944 * just to search for a place. Hosted search gives the Trustindex-style
1945 * "type and pick" UX out of the box; user keys remain optional for
1946 * higher quotas or full control.
1947 *
1948 * @return array|\WP_Error list of {place_id, main_text, secondary_text, description}
1949 */
1950 public static function managed_search(string $q, string $session_token = '')
1951 {
1952 $endpoint = (string) apply_filters(
1953 'embedpress/google_reviews/managed_search_endpoint',
1954 'https://api.embedpress.com/google-places.php'
1955 );
1956 $params = [
1957 'action' => 'autocomplete',
1958 'q' => $q,
1959 ];
1960 // Passing the same token across N autocompletes + the final details
1961 // call lets Google bill the whole sequence as ONE session — major
1962 // savings on the proxy's free Places API tier.
1963 if ($session_token !== '') {
1964 $params['session_token'] = $session_token;
1965 }
1966 $url = add_query_arg($params, $endpoint);
1967
1968 $response = wp_remote_get($url, [
1969 'timeout' => (int) apply_filters('embedpress/google_reviews/managed_search_timeout', 8, $q),
1970 'headers' => [
1971 'Accept' => 'application/json',
1972 'X-EmbedPress-Site' => home_url(),
1973 ],
1974 ]);
1975
1976 if (is_wp_error($response)) {
1977 return new \WP_Error('embedpress_gr_managed_search', $response->get_error_message(), ['status' => 502]);
1978 }
1979 $code = (int) wp_remote_retrieve_response_code($response);
1980 $body = json_decode((string) wp_remote_retrieve_body($response), true);
1981
1982 if ($code === 429) {
1983 return new \WP_Error(
1984 'embedpress_gr_managed_rate_limited',
1985 __('Search is busy right now — please try again in a moment.', 'embedpress'),
1986 ['status' => 429]
1987 );
1988 }
1989 if ($code < 200 || $code >= 300 || !is_array($body)) {
1990 $msg = is_array($body) && !empty($body['message']) ? $body['message'] : __('Search service unavailable.', 'embedpress');
1991 return new \WP_Error('embedpress_gr_managed_search', $msg, ['status' => 502]);
1992 }
1993
1994 $predictions = [];
1995 foreach (($body['predictions'] ?? []) as $p) {
1996 if (empty($p['place_id'])) continue;
1997 $predictions[] = [
1998 'place_id' => (string) $p['place_id'],
1999 'main_text' => (string) ($p['main_text'] ?? ''),
2000 'secondary_text' => (string) ($p['secondary_text'] ?? ''),
2001 'description' => (string) ($p['description'] ?? ''),
2002 // Surface rating + review count so the picker can show how many
2003 // reviews each result has — disambiguates same-named places.
2004 'rating' => isset($p['rating']) ? (float) $p['rating'] : null,
2005 'review_count' => isset($p['review_count']) ? (int) $p['review_count'] : null,
2006 ];
2007 }
2008 return $predictions;
2009 }
2010
2011 /**
2012 * Rating + review count for a set of place IDs.
2013 *
2014 * The picker's suggestion list comes from Google Autocomplete, which
2015 * returns names only — no Places API tier includes a rating inside a
2016 * prediction. So the list renders first and this fills in the �
2017 /reviews
2018 * line for the ids it showed. Kept off the search path on purpose: a slow
2019 * count lookup must never hold up the suggestions themselves.
2020 *
2021 * @param string[] $place_ids
2022 * @return array<string, array{rating: float|null, review_count: int|null}>|\WP_Error
2023 */
2024 public static function managed_place_counts(array $place_ids)
2025 {
2026 $place_ids = array_values(array_filter(array_map('trim', $place_ids)));
2027 if (!$place_ids) {
2028 return [];
2029 }
2030
2031 $endpoint = (string) apply_filters(
2032 'embedpress/google_reviews/managed_search_endpoint',
2033 'https://api.embedpress.com/google-places.php'
2034 );
2035 $url = add_query_arg([
2036 'action' => 'counts',
2037 'place_ids' => implode(',', $place_ids),
2038 ], $endpoint);
2039
2040 $response = wp_remote_get($url, [
2041 'timeout' => (int) apply_filters('embedpress/google_reviews/place_counts_timeout', 8),
2042 'headers' => [
2043 'Accept' => 'application/json',
2044 'X-EmbedPress-Site' => home_url(),
2045 ],
2046 ]);
2047
2048 if (is_wp_error($response)) {
2049 return new \WP_Error('embedpress_gr_place_counts', $response->get_error_message(), ['status' => 502]);
2050 }
2051
2052 $code = (int) wp_remote_retrieve_response_code($response);
2053 $body = json_decode((string) wp_remote_retrieve_body($response), true);
2054 if ($code < 200 || $code >= 300 || !is_array($body)) {
2055 return new \WP_Error('embedpress_gr_place_counts', __('Could not load review counts.', 'embedpress'), ['status' => 502]);
2056 }
2057
2058 $counts = [];
2059 foreach (($body['counts'] ?? []) as $id => $entry) {
2060 if (!is_array($entry)) {
2061 continue;
2062 }
2063 $counts[(string) $id] = [
2064 'rating' => isset($entry['rating']) ? (float) $entry['rating'] : null,
2065 'review_count' => isset($entry['review_count']) ? (int) $entry['review_count'] : null,
2066 ];
2067 }
2068
2069 return $counts;
2070 }
2071
2072 /**
2073 * Apify-backed place SEARCH (the block's place picker — no Google key
2074 * needed). This is FREE: searching/picking a place is how you configure the
2075 * block at all, so it must not depend on Pro being active. (Pro owns only the
2076 * heavy "fetch all reviews" bulk scrape, not search.)
2077 *
2078 * Calls the crawler-google-places actor synchronously and returns predictions
2079 * in EmbedPress's picker shape, or a WP_Error the REST layer can surface.
2080 *
2081 * @return array|\WP_Error list of {place_id, main_text, secondary_text, description}
2082 */
2083 public static function apify_search(string $q)
2084 {
2085 $token = self::get_apify_token();
2086 $q = trim($q);
2087 if ($token === '') {
2088 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]);
2089 }
2090 if ($q === '') {
2091 return [];
2092 }
2093
2094 // Pin a small memory footprint. The crawler-google-places actor DEFAULTS
2095 // to 4096MB, which on Apify's free 8192MB plan gets rejected ("you will
2096 // exceed the memory limit") whenever another run is using/releasing
2097 // memory. A 5-result place search needs nowhere near 4GB — 1024MB runs
2098 // fine. Filterable for users on larger plans who want faster runs.
2099 $memory = (int) apply_filters('embedpress/google_reviews/apify_search_memory', 1024, $q);
2100 $endpoint = 'https://api.apify.com/v2/acts/compass~crawler-google-places/run-sync-get-dataset-items?token=' . rawurlencode($token);
2101 if ($memory > 0) {
2102 $endpoint .= '&memory=' . $memory;
2103 }
2104 $payload = [
2105 'searchStringsArray' => [$q],
2106 'maxCrawledPlacesPerSearch' => (int) apply_filters('embedpress/google_reviews/apify_search_max', 5, $q),
2107 'language' => 'en',
2108 'skipClosedPlaces' => false,
2109 ];
2110
2111 // The crawler-google-places actor is a live scrape: a warm run answers in
2112 // ~20s, a cold start can take longer. Give it a generous budget and, on a
2113 // connection timeout, retry once — the actor is usually warm by the second
2114 // attempt and answers fast.
2115 $timeout = (int) apply_filters('embedpress/google_reviews/apify_search_timeout', 90, $q);
2116 $args = [
2117 'timeout' => $timeout,
2118 'headers' => ['Content-Type' => 'application/json'],
2119 'body' => wp_json_encode($payload),
2120 ];
2121 $response = wp_remote_post($endpoint, $args);
2122 if (is_wp_error($response) && self::is_timeout_error($response)) {
2123 $response = wp_remote_post($endpoint, $args);
2124 }
2125 if (is_wp_error($response)) {
2126 if (self::is_timeout_error($response)) {
2127 return new \WP_Error(
2128 'embedpress_gr_apify_timeout',
2129 __('Place search took too long to respond. Try searching again, or paste the Place ID directly using “Have a Place ID? Enter manually”.', 'embedpress'),
2130 ['status' => 504]
2131 );
2132 }
2133 return new \WP_Error('embedpress_gr_apify_search', $response->get_error_message(), ['status' => 502]);
2134 }
2135
2136 $code = (int) wp_remote_retrieve_response_code($response);
2137 if ($code !== 200 && $code !== 201) {
2138 // Surface the actual Apify reason (e.g. 402 out-of-credit, 401 bad
2139 // token) so the picker shows something actionable.
2140 $body = json_decode(wp_remote_retrieve_body($response), true);
2141 // Keep the user-facing message provider-neutral — don't surface raw
2142 // upstream (Apify) text or billing links to the end user. The
2143 // technical detail stays in the error code for debugging.
2144 $type = is_array($body) && isset($body['error']['type']) ? (string) $body['error']['type'] : '';
2145 $reason = __('Place search is temporarily unavailable. Try again, or paste the Place ID directly using “Have a Place ID? Enter manually”.', 'embedpress');
2146 if ($type === 'not-enough-usage-to-run-paid-actor') {
2147 $reason = __('Place search is temporarily unavailable. Paste the Place ID directly using “Have a Place ID? Enter manually”.', 'embedpress');
2148 }
2149 return new \WP_Error('embedpress_gr_apify_search', $reason, ['status' => 502]);
2150 }
2151
2152 $items = json_decode(wp_remote_retrieve_body($response), true);
2153 if (!is_array($items)) {
2154 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]);
2155 }
2156
2157 $out = [];
2158 foreach ($items as $it) {
2159 if (!is_array($it) || empty($it['placeId'])) {
2160 continue;
2161 }
2162 $title = (string) ($it['title'] ?? '');
2163 $addr = (string) ($it['address'] ?? '');
2164 $out[] = [
2165 'place_id' => (string) $it['placeId'],
2166 'main_text' => $title,
2167 'secondary_text' => $addr,
2168 'description' => trim($title . ($addr ? ', ' . $addr : '')),
2169 ];
2170 }
2171 return $out;
2172 }
2173
2174 /**
2175 * Is this WP_Error a connection/operation timeout (cURL 28 / "timed out")
2176 * rather than some other transport failure? Used to decide whether a retry is
2177 * worthwhile and how to label the error for the user.
2178 */
2179 private static function is_timeout_error($err): bool
2180 {
2181 if (!is_wp_error($err)) {
2182 return false;
2183 }
2184 $msg = strtolower($err->get_error_message());
2185 return strpos($msg, 'timed out') !== false
2186 || strpos($msg, 'timeout') !== false
2187 || strpos($msg, 'operation too slow') !== false;
2188 }
2189
2190 /**
2191 * Which Places API variant the user's key is enabled for.
2192 * 'new' | 'legacy' | 'auto'. 'auto' means we'll probe on the next call.
2193 */
2194 public static function get_api_mode(): string
2195 {
2196 $mode = (string) get_option(self::OPT_API_MODE, 'auto');
2197 return in_array($mode, ['new', 'legacy', 'auto'], true) ? $mode : 'auto';
2198 }
2199
2200 public static function set_api_mode(string $mode): void
2201 {
2202 if (!in_array($mode, ['new', 'legacy', 'auto'], true)) return;
2203 if ($mode === self::get_api_mode()) return;
2204 update_option(self::OPT_API_MODE, $mode);
2205 }
2206
2207 /**
2208 * Return the list of recently-used and explicitly-saved places. Each
2209 * entry is `{place_id, place_name, used_at|saved_at}`. Saved entries
2210 * persist indefinitely; recent rotates at RECENT_MAX.
2211 */
2212 public static function get_places_lists(): array
2213 {
2214 $recent = get_option(self::OPT_RECENT, []);
2215 $saved = get_option(self::OPT_SAVED, []);
2216 return [
2217 'recent' => is_array($recent) ? array_values($recent) : [],
2218 'saved' => is_array($saved) ? array_values($saved) : [],
2219 ];
2220 }
2221
2222 /**
2223 * Push a place to the head of the "recent" list, deduped by place_id.
2224 * No-op if place_id is empty. Trims to RECENT_MAX.
2225 */
2226 public static function remember_recent_place(string $place_id, string $place_name): void
2227 {
2228 $place_id = trim($place_id);
2229 if ($place_id === '') return;
2230 $recent = get_option(self::OPT_RECENT, []);
2231 if (!is_array($recent)) $recent = [];
2232 $recent = array_values(array_filter($recent, function ($p) use ($place_id) {
2233 return is_array($p) && ($p['place_id'] ?? '') !== $place_id;
2234 }));
2235 array_unshift($recent, [
2236 'place_id' => $place_id,
2237 'place_name' => sanitize_text_field($place_name),
2238 'used_at' => time(),
2239 ]);
2240 if (count($recent) > self::RECENT_MAX) {
2241 $recent = array_slice($recent, 0, self::RECENT_MAX);
2242 }
2243 update_option(self::OPT_RECENT, $recent);
2244 }
2245
2246 /**
2247 * Add or remove a place from the explicit "saved" list.
2248 */
2249 public static function toggle_saved_place(string $place_id, string $place_name, bool $save): void
2250 {
2251 $place_id = trim($place_id);
2252 if ($place_id === '') return;
2253 $saved = get_option(self::OPT_SAVED, []);
2254 if (!is_array($saved)) $saved = [];
2255 $saved = array_values(array_filter($saved, function ($p) use ($place_id) {
2256 return is_array($p) && ($p['place_id'] ?? '') !== $place_id;
2257 }));
2258 if ($save) {
2259 array_unshift($saved, [
2260 'place_id' => $place_id,
2261 'place_name' => sanitize_text_field($place_name),
2262 'saved_at' => time(),
2263 ]);
2264 }
2265 update_option(self::OPT_SAVED, $saved);
2266 }
2267
2268 public static function get_cache_ttl(): int
2269 {
2270 $ttl = (int) get_option(self::OPT_CACHE_TTL, 6 * HOUR_IN_SECONDS);
2271 return $ttl > 0 ? $ttl : 6 * HOUR_IN_SECONDS;
2272 }
2273
2274 /**
2275 * Flush all Google Reviews transients (the cache + error markers).
2276 * Returns the number of rows deleted.
2277 */
2278 public static function clear_cache(): int
2279 {
2280 global $wpdb;
2281 $like = $wpdb->esc_like('_transient_' . self::CACHE_PREFIX) . '%';
2282 $like_timeout = $wpdb->esc_like('_transient_timeout_' . self::CACHE_PREFIX) . '%';
2283 $a = (int) $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $like));
2284 $b = (int) $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->options} WHERE option_name LIKE %s", $like_timeout));
2285 return $a + $b;
2286 }
2287
2288 /**
2289 * Enqueue the frontend stylesheet. Safe to call multiple times. Also
2290 * registers an `init` hook so the Gutenberg editor pulls the same CSS
2291 * into the editor iframe (ServerSideRender returns raw HTML so the
2292 * editor needs our stylesheet to render the cards correctly).
2293 */
2294 public static function enqueue_assets()
2295 {
2296 if (!wp_style_is('embedpress-google-reviews', 'registered')) {
2297 wp_register_style(
2298 'embedpress-google-reviews',
2299 // assets/ (build output) — NOT static/ (source). static/ is
2300 // excluded from the dist build (.distignore /static), so a
2301 // shipped/built plugin 404s on static/css/google-reviews.css.
2302 // The build copies static/ → assets/, so enqueue from assets.
2303 EMBEDPRESS_URL_ASSETS . 'css/google-reviews.css',
2304 [],
2305 EMBEDPRESS_VERSION
2306 );
2307 }
2308 wp_enqueue_style('embedpress-google-reviews');
2309
2310 // Read-more toggle (vanilla, no deps). Reveals the button only when the
2311 // review text actually overflows the CSS clamp; re-inits on editor SSR
2312 // re-render via a MutationObserver.
2313 if (!wp_script_is('embedpress-google-reviews', 'registered')) {
2314 wp_register_script(
2315 'embedpress-google-reviews',
2316 EMBEDPRESS_URL_ASSETS . 'js/google-reviews.js',
2317 [],
2318 EMBEDPRESS_VERSION,
2319 true
2320 );
2321 }
2322 wp_enqueue_script('embedpress-google-reviews');
2323
2324 // Content-gated Pro hook: fires only when a GR block/shortcode actually
2325 // renders. Pro enqueues its `embedpress-google-reviews-pro` assets here
2326 // (register happens earlier, on wp_enqueue_scripts), so Pro CSS/JS load
2327 // ONLY on pages that contain Google Reviews — not on every page.
2328 do_action('embedpress/google_reviews/enqueue_assets');
2329 }
2330
2331 /**
2332 * Hook for `enqueue_block_editor_assets` — load the frontend stylesheet
2333 * inside the block editor so ServerSideRender output renders correctly.
2334 *
2335 * `enqueue_block_editor_assets` only reaches the editor's TOP document, not
2336 * the iframed block canvas — and the device/responsive preview (Tablet /
2337 * Mobile) always renders inside that iframe. Without the stylesheet there,
2338 * `.ep-gr-star-svg { width:1em }` is lost and the SVG (viewBox, no intrinsic
2339 * size) balloons to fill its container (the "giant black star" bug).
2340 * `wp_enqueue_block_style()` (WP 5.9+) is the API that injects a per-block
2341 * stylesheet into the iframed canvas as well, so it renders correctly in
2342 * both the normal view and every device preview.
2343 */
2344 public static function enqueue_editor_assets()
2345 {
2346 self::enqueue_assets();
2347
2348 if (function_exists('wp_enqueue_block_style')) {
2349 if (!wp_style_is('embedpress-google-reviews', 'registered')) {
2350 wp_register_style(
2351 'embedpress-google-reviews',
2352 EMBEDPRESS_URL_ASSETS . 'css/google-reviews.css',
2353 [],
2354 EMBEDPRESS_VERSION
2355 );
2356 }
2357 wp_enqueue_block_style('embedpress/google-reviews', [
2358 'handle' => 'embedpress-google-reviews',
2359 ]);
2360 }
2361 }
2362 }
2363