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