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 |