PluginProbe ʕ •ᴥ•ʔ
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More / 2.11.0
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More v2.11.0
2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.7 2.6.8 2.6.5 2.6.4 2.6.3 2.6.2 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 trunk 1.0 1.0.1 1.0.2 1.0.3 1.1 1.1.1 1.1.2 1.2.0 2.0 2.1.0 2.1.1 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.5.0 2.5.1
reviews-feed / class / Common / ReviewAlerts / SBR_Review_Alert_Frontend.php
reviews-feed / class / Common / ReviewAlerts Last commit date
SBR_ReviewAlert_Builder.php 1 week ago SBR_Review_Alert_Frontend.php 1 week ago SBR_Review_Alert_Service.php 1 week ago
SBR_Review_Alert_Frontend.php
1012 lines
1 <?php
2
3 // phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped, Generic.Metrics.CyclomaticComplexity
4 // Note: JSON output required for localized script configuration. Config formatting requires complexity.
5
6 /**
7 * Review Alert Frontend
8 *
9 * Handles frontend display of review alerts on public pages.
10 * Conditionally loads assets and renders popup based on page targeting.
11 *
12 * @since 2.5.0
13 * @package SmashBalloon\Reviews\Common\ReviewAlerts
14 */
15
16 namespace SmashBalloon\Reviews\Common\ReviewAlerts;
17
18 if (! defined('ABSPATH')) {
19 exit;
20 }
21
22 use Smashballoon\Stubs\Services\ServiceProvider;
23 use SmashBalloon\Reviews\Common\FeedCache;
24 use SmashBalloon\Reviews\Common\Parser;
25 use SmashBalloon\Reviews\Common\TemplateRenderer;
26 use SmashBalloon\Reviews\Common\Util;
27
28 /**
29 * Class SBR_Review_Alert_Frontend
30 *
31 * @since 2.5.0
32 */
33 class SBR_Review_Alert_Frontend extends ServiceProvider
34 {
35 /**
36 * Max reviews handed to the popup JS (it shows a first batch, then "See all"
37 * loads the rest). Shared with SBR_Review_Alert_Service::get_preview_reviews so
38 * the frontend and the customizer preview cap identically.
39 *
40 * @var int
41 */
42 public const MAX_POPUP_REVIEWS = 150;
43
44 /**
45 * Active popup for current page (cached after first check)
46 *
47 * @var array|null|false
48 */
49 private $active_popup = false;
50
51 /**
52 * Register hooks for frontend display
53 *
54 * @since 2.5.0
55 * @return void
56 */
57 public function register(): void
58 {
59 // Use template_redirect which fires early enough for wp_enqueue_scripts
60 add_action('template_redirect', [$this, 'setup_popup_display']);
61 }
62
63 /**
64 * Setup popup display - check conditions and enqueue assets
65 *
66 * Note: We don't determine which popup to display here because shortcodes
67 * haven't been processed yet. The actual popup selection happens in
68 * render_popup() at wp_footer, after content (and shortcodes) are processed.
69 *
70 * @since 2.5.0
71 * @return void
72 */
73 public function setup_popup_display(): void
74 {
75 // Reset shortcode popup ID at start of each request to prevent leakage
76 // in persistent worker environments (PHP-FPM with opcache, Swoole, etc.)
77 SBR_Review_Alert_Service::reset_shortcode_popup_id();
78
79 // Don't show in admin, login, or REST API requests
80 if (is_admin() || wp_doing_ajax() || defined('REST_REQUEST')) {
81 return;
82 }
83
84 // Notification popup is a Pro Plus/Elite feature (matches admin UI gating)
85 if (!Util::sbr_is_pro_plus()) {
86 return;
87 }
88
89 // Check if there are any active popups configured
90 // Note: This doesn't account for shortcodes yet, but we need to enqueue assets
91 // if ANY popup might display (either via settings or shortcode)
92 $has_active_popups = !empty(SBR_Review_Alert_Service::get_active_popups());
93
94 // Only proceed if there are active popups in the system
95 // (shortcode can only reference active popups, so this check is sufficient)
96 if (!$has_active_popups) {
97 return;
98 }
99
100 // Enqueue CSS and JS assets
101 add_action('wp_enqueue_scripts', [$this, 'enqueue_assets'], 100);
102
103 // Render popup HTML in footer (popup selection happens here, after shortcodes processed)
104 // Priority 10 ensures config is output BEFORE scripts execute at priority 20
105 add_action('wp_footer', [$this, 'render_popup'], 10);
106 }
107
108 /**
109 * Render the active popup (callback for wp_footer)
110 *
111 * Determines which popup to display at render time, after shortcodes
112 * have been processed. This allows shortcode-specified popups to take
113 * priority over settings-based popup targeting.
114 *
115 * @since 2.5.0
116 * @return void
117 */
118 public function render_popup(): void
119 {
120 // Determine active popup now (after shortcodes have been processed)
121 // This allows shortcode to take priority over settings-based popups
122 $this->active_popup = $this->get_active_popup_for_page();
123
124 if ($this->active_popup) {
125 $this->render($this->active_popup);
126 }
127 }
128
129 /**
130 * Get the active popup for the current page
131 *
132 * Returns the highest priority popup that should display on this page.
133 * Priority order (highest to lowest):
134 * 1. Shortcode-specified popup (takes absolute priority)
135 * 2. Settings-based: specific pages targeting
136 * 3. Settings-based: all pages (with exclusions)
137 *
138 * @since 2.5.0
139 * @return array|null Popup data or null if no popup should display
140 */
141 public function get_active_popup_for_page(): ?array
142 {
143 // Priority 1: Check for shortcode-specified popup
144 // Shortcode takes absolute priority over all settings-based popups
145 $shortcode_popup_id = SBR_Review_Alert_Service::get_shortcode_popup_id();
146 if ($shortcode_popup_id !== null) {
147 $shortcode_popup = SBR_Review_Alert_Service::get_popup($shortcode_popup_id);
148 if ($shortcode_popup && $shortcode_popup['status'] === 'active') {
149 return $shortcode_popup;
150 }
151 }
152
153 // Priority 2 & 3: Settings-based popup matching
154 $active_popups = SBR_Review_Alert_Service::get_active_popups();
155
156 if (empty($active_popups)) {
157 return null;
158 }
159
160 $page_id = $this->get_current_page_id();
161 $matching_popups = [];
162
163 foreach ($active_popups as $popup) {
164 if ($this->should_display_on_page($popup, $page_id)) {
165 $matching_popups[] = $popup;
166 }
167 }
168
169 if (empty($matching_popups)) {
170 return null;
171 }
172
173 // Sort by specificity: specific pages first, then by ID (newest first)
174 usort($matching_popups, function ($a, $b) {
175 // Use visibility.display_on (new structure) - 'specific' = more specific than 'all'
176 $a_specific = ($a['settings']['visibility']['display_on'] ?? 'all') === 'specific';
177 $b_specific = ($b['settings']['visibility']['display_on'] ?? 'all') === 'specific';
178
179 if ($a_specific !== $b_specific) {
180 return $b_specific - $a_specific; // Specific pages have higher priority
181 }
182
183 return $b['id'] - $a['id']; // Newer popups have higher priority
184 });
185
186 return $matching_popups[0];
187 }
188
189 /**
190 * Get current page ID
191 *
192 * @since 2.5.0
193 * @return int Page ID or 0
194 */
195 private function get_current_page_id(): int
196 {
197 // Try queried object first (works for pages, posts, CPTs)
198 $queried_object = get_queried_object();
199 if ($queried_object && isset($queried_object->ID)) {
200 return (int) $queried_object->ID;
201 }
202
203 // Fallback to global post
204 global $post;
205 if ($post && isset($post->ID)) {
206 return (int) $post->ID;
207 }
208
209 return 0;
210 }
211
212 /**
213 * Check if popup should display on the current page
214 *
215 * @since 2.5.0
216 * @param array $popup Popup data with settings
217 * @param int $page_id Current page ID
218 * @return bool Whether to display on this page
219 */
220 public function should_display_on_page(array $popup, int $page_id): bool
221 {
222 $visibility = $popup['settings']['visibility'] ?? [];
223 $display_on = $visibility['display_on'] ?? 'all';
224
225 // Get current location type and identifier
226 $location = $this->get_current_location($page_id);
227
228 if ($display_on === 'all') {
229 // Show on all pages EXCEPT those in excluded list
230 $excluded = $visibility['excluded'] ?? [];
231 return !$this->is_location_in_list($location, $excluded);
232 }
233
234 // Specific mode: show ONLY on pages in specific list
235 $specific = $visibility['specific'] ?? [];
236 return $this->is_location_in_list($location, $specific);
237 }
238
239 /**
240 * Check if current page is the homepage
241 *
242 * Returns true if either:
243 * - is_front_page() - the site's front page (static or posts)
244 * - is_home() - the blog posts index
245 *
246 * This allows users to include/exclude "/" in visibility settings.
247 *
248 * @since 2.5.0
249 * @return bool True if on homepage
250 */
251 private function is_homepage(): bool
252 {
253 return is_front_page() || is_home();
254 }
255
256 /**
257 * Get current location type and identifier
258 *
259 * Detection order (most specific to least specific):
260 * 1. Homepage → type: 'page', id: 0
261 * 2. WooCommerce pages (shop, product, product_cat) → handled specially
262 * 3. Category archive → type: 'category', id: term_id
263 * 4. Custom post type archive → type: 'custom_post_type', id: slug
264 * 5. Single page/post → type: 'page'/'post', id: post_id
265 * 6. Custom post type single → type: 'custom_post_type', id: slug
266 *
267 * @since 2.5.0
268 * @param int $page_id Current page ID
269 * @return array{type: string, id: int|string} Location type and identifier
270 */
271 private function get_current_location(int $page_id): array
272 {
273 // 0. Check if we're on the homepage
274 if ($this->is_homepage()) {
275 return ['type' => 'page', 'id' => 0];
276 }
277
278 // 1. WooCommerce: Shop page (main shop archive)
279 // Treat as a page so it shows with "All Pages" mode
280 if (function_exists('is_shop') && is_shop()) {
281 $shop_page_id = function_exists('wc_get_page_id') ? wc_get_page_id('shop') : 0;
282 return [
283 'type' => 'page',
284 'id' => $shop_page_id > 0 ? $shop_page_id : $page_id,
285 ];
286 }
287
288 // 2. WooCommerce: Product category archive
289 if (function_exists('is_product_category') && is_product_category()) {
290 $term = get_queried_object();
291 return [
292 'type' => 'category',
293 'id' => $term ? $term->term_id : 0,
294 ];
295 }
296
297 // 3. WooCommerce: Single product page
298 // Treat as a page (with the product's ID) so it shows with "All Pages" mode
299 if (function_exists('is_product') && is_product()) {
300 return [
301 'type' => 'page',
302 'id' => $page_id,
303 ];
304 }
305
306 // 4. Check if we're on a category archive (WordPress)
307 if (is_category()) {
308 $category = get_queried_object();
309 return [
310 'type' => 'category',
311 'id' => $category ? $category->term_id : 0,
312 ];
313 }
314
315 // 5. Check if we're on a custom post type archive
316 if (is_post_type_archive()) {
317 return [
318 'type' => 'custom_post_type',
319 'id' => get_query_var('post_type'),
320 ];
321 }
322
323 // 6. Get the post object for single pages/posts
324 $post = get_post($page_id);
325 if (!$post) {
326 return ['type' => 'page', 'id' => $page_id];
327 }
328
329 // 7. Check if it's a custom post type (not page or post)
330 if (!in_array($post->post_type, ['page', 'post'], true)) {
331 return [
332 'type' => 'custom_post_type',
333 'id' => $post->post_type, // slug (whole-type targeting)
334 'post_id' => $page_id, // concrete id (individual targeting, e.g. a landing page)
335 ];
336 }
337
338 // 8. It's a page or post
339 return [
340 'type' => $post->post_type, // 'page' or 'post'
341 'id' => $page_id,
342 ];
343 }
344
345 /**
346 * Extract IDs from visibility array (handles both old ID-only and new object formats)
347 *
348 * @since 2.5.0
349 * @param array $items Array of items (IDs or objects with 'id' key)
350 * @param string $key Key to extract ('id' for pages/categories, 'name' for post types)
351 * @return array Array of IDs/slugs
352 */
353 private function extract_visibility_ids(array $items, string $key = 'id'): array
354 {
355 $ids = [];
356 foreach ($items as $item) {
357 if (is_array($item)) {
358 // New format: object with id/name key
359 if (isset($item[$key])) {
360 $ids[] = $key === 'name' ? (string) $item[$key] : (int) $item[$key];
361 }
362 } else {
363 // Old format: just ID or slug
364 $ids[] = $key === 'name' ? (string) $item : (int) $item;
365 }
366 }
367 return $ids;
368 }
369
370 /**
371 * Check if location matches any item in visibility list
372 *
373 * @since 2.5.0
374 * @param array $location Current location {type, id}
375 * @param array $list Visibility list with pages/categories/custom_post_types arrays
376 * @return bool Whether location is in the list
377 */
378 private function is_location_in_list(array $location, array $list): bool
379 {
380 $type = $location['type'];
381 $id = $location['id'];
382
383 switch ($type) {
384 case 'custom_post_type':
385 // Whole-type match: slug against custom_post_types (handles both formats)
386 $cpts = $this->extract_visibility_ids($list['custom_post_types'] ?? [], 'name');
387 if (in_array($id, $cpts, true)) {
388 return true;
389 }
390 // Individual match: a specific CPT entry (e.g. a landing page) picked
391 // in the Pages list is stored under `pages` by its post ID. (SMASH-1616)
392 $post_id = $location['post_id'] ?? 0;
393 if ($post_id) {
394 $pages = $this->extract_visibility_ids($list['pages'] ?? [], 'id');
395 if (in_array((int) $post_id, $pages, true)) {
396 return true;
397 }
398 }
399 return false;
400
401 case 'category':
402 // Check term ID against categories array (handles both formats)
403 $categories = $this->extract_visibility_ids($list['categories'] ?? [], 'id');
404 return in_array((int) $id, $categories, true);
405
406 case 'page':
407 // Pages: check post ID against pages array (handles both formats)
408 $pages = $this->extract_visibility_ids($list['pages'] ?? [], 'id');
409 if (in_array((int) $id, $pages, true)) {
410 return true;
411 }
412
413 // WooCommerce: Also check if current page is a product and 'product' CPT is in list
414 // Products are returned as 'page' type by get_current_location() for "All Pages" compatibility,
415 // but the exclusion list stores Products CPT as 'custom_post_types' => ['product']
416 if (function_exists('is_product') && is_product()) {
417 $cpts = $this->extract_visibility_ids($list['custom_post_types'] ?? [], 'name');
418 if (in_array('product', $cpts, true)) {
419 return true;
420 }
421 }
422
423 // WooCommerce: Also check if current page is the shop page and 'product' CPT is in list
424 if (function_exists('is_shop') && is_shop()) {
425 $cpts = $this->extract_visibility_ids($list['custom_post_types'] ?? [], 'name');
426 if (in_array('product', $cpts, true)) {
427 return true;
428 }
429 }
430
431 return false;
432
433 case 'post':
434 // Posts: check post ID against pages array (handles both formats)
435 $pages = $this->extract_visibility_ids($list['pages'] ?? [], 'id');
436 if (in_array((int) $id, $pages, true)) {
437 return true;
438 }
439
440 // Also check if post belongs to any of the selected categories
441 $categories = $this->extract_visibility_ids($list['categories'] ?? [], 'id');
442 if (!empty($categories)) {
443 $post_categories = wp_get_post_categories($id, ['fields' => 'ids']);
444 if (is_array($post_categories)) {
445 foreach ($post_categories as $cat_id) {
446 if (in_array((int) $cat_id, $categories, true)) {
447 return true;
448 }
449 }
450 }
451 }
452
453 return false;
454
455 default:
456 return false;
457 }
458 }
459
460 /**
461 * Enqueue popup CSS and JS assets
462 *
463 * @since 2.5.0
464 * @return void
465 */
466 public function enqueue_assets(): void
467 {
468 // Theme CSS paths - check in order of priority
469 $customizer_src_path = 'vendor/smashballoon/customizer/sb-common/sb-customizer/src/assets/css/review-alert-themes.css';
470 $customizer_build_path = 'vendor/smashballoon/customizer/sb-common/sb-customizer/assets/css/review-alert-themes.css';
471
472 // Local dev: src/ exists (symlink), Production: assets/ exists (Makefile copies it)
473 if (file_exists(SBR_PLUGIN_DIR . $customizer_src_path)) {
474 $css_path = $customizer_src_path;
475 } else {
476 $css_path = $customizer_build_path;
477 }
478
479 wp_enqueue_style(
480 'sbr-review-alert',
481 SBR_PLUGIN_URL . $css_path,
482 [],
483 SBRVER
484 );
485
486 // Enqueue frontend-specific CSS (positioning, visibility, responsive)
487 wp_enqueue_style(
488 'sbr-review-alert-frontend',
489 SBR_PLUGIN_URL . 'assets/css/sbr-review-alerts-frontend.css',
490 ['sbr-review-alert'],
491 SBRVER
492 );
493
494 // Enqueue JS
495 wp_enqueue_script(
496 'sbr-review-alert',
497 SBR_PLUGIN_URL . 'assets/js/sbr-review-alerts.js',
498 [],
499 SBRVER,
500 true // Load in footer
501 );
502 }
503
504 /**
505 * Render the review alert
506 *
507 * @since 2.5.0
508 * @param array $popup Review alert data with settings
509 * @return void
510 */
511 private function render(array $popup): void
512 {
513 $settings = $popup['settings'];
514
515 // Get reviews using existing Feed class with filter/sort settings
516 $result = $this->get_reviews_for_popup($settings);
517 $reviews = $result['reviews'];
518 $total_reviews = $result['totalReviews'];
519 $average_rating = $result['averageRating'];
520 $booking_header = $result['bookingHeader'] ?? null; // SMASH-782: booking-only 0-10 header.
521
522 // Don't render if no reviews available
523 if (empty($reviews)) {
524 return;
525 }
526
527 // Prepare frontend configuration
528 $config = $this->get_frontend_config($popup, $reviews, $total_reviews, $average_rating, $booking_header);
529
530 // Output config as inline script (wp_localize_script doesn't work in footer after script was enqueued in head)
531 //
532 // JSON_HEX_* because this is an INLINE script element carrying attacker-influenced
533 // review text (SMASH-1795). Without them the only thing stopping a stored
534 // `</script><img src=x onerror=…>` from closing the element early is json_encode's
535 // default slash escaping — `<\/script>`, which does hold, but it is one
536 // JSON_UNESCAPED_SLASHES away from not holding. HEX_TAG encodes `<` and `>` as
537 // </>, so no review body can produce a tag boundary here at all.
538 // Values are unchanged after JSON.parse; only their encoding differs.
539 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON encoding handles escaping
540 printf(
541 '<script id="sbr-review-alert-config">var sbrReviewAlertConfig = %s;</script>',
542 wp_json_encode($config, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT)
543 );
544
545 // Render template
546 TemplateRenderer::render('review-alerts/popup', [
547 'popup' => $popup,
548 'reviews' => $reviews,
549 'config' => $config,
550 ]);
551 }
552
553 /**
554 * Get reviews for the popup using existing Feed class
555 *
556 * Applies the same filtering and sorting logic as the Reviews Feed:
557 * - Star rating filter (includedStarFilters)
558 * - Word filters (includeWords, excludeWords)
559 * - Character count filters (filterCharCountMin, filterCharCountMax)
560 * - Sorting by date, rating, or random
561 *
562 * @since 2.5.0
563 * @param array $popup_settings Full popup settings including sources, filters, and sort
564 * @return array{reviews: array, totalReviews: int, averageRating: float} Array containing reviews (up to MAX_POPUP_REVIEWS), the header total count, and the header average rating
565 */
566 private function get_reviews_for_popup(array $popup_settings): array
567 {
568 $source_db_ids = $popup_settings['sources'] ?? [];
569
570 // Filter out invalid values (0, empty strings, non-numeric)
571 // This handles edge cases from failed conversions or corrupted data
572 $source_db_ids = array_filter($source_db_ids, function ($id) {
573 return is_numeric($id) && (int) $id > 0;
574 });
575 $source_db_ids = array_values($source_db_ids); // Re-index array
576
577 // If no sources specified, return empty - no fallback to all sources
578 // User must explicitly select sources for the popup
579 if (empty($source_db_ids)) {
580 return [
581 'reviews' => [],
582 'totalReviews' => 0,
583 'averageRating' => 0,
584 ];
585 }
586
587 // Convert database IDs to account_ids for Feed class compatibility
588 // Following PR #418 pattern: store database IDs to avoid URL encoding issues
589 $source_ids = $this->convert_db_ids_to_account_ids($source_db_ids);
590
591 if (empty($source_ids)) {
592 return [
593 'reviews' => [],
594 'totalReviews' => 0,
595 'averageRating' => 0,
596 ];
597 }
598
599 // Get filter settings (aligned with Feed settings structure)
600 $filters = $popup_settings['filters'] ?? [];
601 $sort = $popup_settings['sort'] ?? [];
602
603 // Build settings for Feed class - merge popup filters/sort with defaults
604 $feed_settings = array_merge(sbr_settings_defaults(), [
605 'sources' => $source_ids,
606 'numPostDesktop' => 500, // Fetch more to allow filtering
607 'numPostTablet' => 500,
608 'numPostMobile' => 500,
609 // Filter settings - use popup settings if available
610 'includedStarFilters' => $filters['includedStarFilters'] ?? [],
611 'includeWords' => $filters['includeWords'] ?? '',
612 'excludeWords' => $filters['excludeWords'] ?? '',
613 'filterCharCountMin' => $filters['filterCharCountMin'] ?? 0,
614 'filterCharCountMax' => $filters['filterCharCountMax'] ?? '',
615 // Sort settings - use popup settings if available
616 'sortByDateEnabled' => $sort['sortByDateEnabled'] ?? true,
617 'sortByDate' => $sort['sortByDate'] ?? 'latest',
618 'sortByRatingEnabled' => $sort['sortByRatingEnabled'] ?? false,
619 'sortByRating' => $sort['sortByRating'] ?? '',
620 'sortRandomEnabled' => $sort['sortRandomEnabled'] ?? false,
621 ]);
622
623 // Create cache ID including filter/sort settings for unique caching
624 $cache_key = md5(wp_json_encode([
625 'sources' => $source_ids,
626 'filters' => $filters,
627 'sort' => $sort,
628 ]));
629 $cache_id = 'review_alert_' . $cache_key;
630
631 // Use Pro Feed if available for WPML support and media optimization
632 $feed_class = Util::sbr_is_pro()
633 ? '\\SmashBalloon\\Reviews\\Pro\\Feed'
634 : '\\SmashBalloon\\Reviews\\Common\\Feed';
635
636 $feed = new $feed_class($feed_settings, $cache_id, new FeedCache($cache_id, DAY_IN_SECONDS));
637
638 $feed->init();
639 $feed->get_set_cache();
640
641 // get_post_set_page() returns filtered posts (Feed::filter_posts is applied internally)
642 $all_reviews = $feed->get_post_set_page();
643
644 // Additional filter to only include "complete" reviews suitable for popup
645 // Pass provider filter if explicitly set (null = no filter, empty array = show none)
646 $allowed_providers = isset($filters['providers']) ? $filters['providers'] : null;
647 $complete_reviews = $this->filter_complete_reviews($all_reviews, $allowed_providers);
648 $cached_count = count($complete_reviews);
649 $cached_sum = 0;
650 foreach ($complete_reviews as $review) {
651 $cached_sum += isset($review['rating']) ? (int) $review['rating'] : 5;
652 }
653
654 // Headline total + average from the feed-header metadata (shared with the
655 // customizer preview path so the two can't drift). SMASH-1616.
656 // Backfill from the FULL cached set (get_posts()), exactly like
657 // FeedDisplay::backfill_review_counts — not the page slice — so providers
658 // whose API returned a zero count aren't under-counted vs the feed header.
659 [$total_reviews, $average_rating, $booking_header] = self::resolve_header_totals($feed, $feed->get_posts(), $cached_count, $cached_sum);
660
661 // Pass up to MAX_POPUP_REVIEWS - JS shows a first batch, then loads the rest on "See all".
662 return [
663 'reviews' => array_slice($complete_reviews, 0, self::MAX_POPUP_REVIEWS),
664 'totalReviews' => $total_reviews,
665 'averageRating' => $average_rating,
666 'bookingHeader' => $booking_header,
667 ];
668 }
669
670 /**
671 * Resolve a Review Alert's headline total + average from the feed-header
672 * metadata (the same numbers the published feed header shows), so the popup
673 * agrees with the feed. Falls back to the cached complete-review set when no
674 * source metadata is available. Shared by this frontend render path AND the
675 * customizer preview (SBR_Review_Alert_Service::get_preview_reviews) so the
676 * two can never drift. SMASH-1616.
677 *
678 * @param object $feed The built Feed (after get_set_cache()).
679 * @param array $cached_reviews Cached review rows (for count backfill).
680 * @param int $cached_count Count of cached complete reviews (fallback total).
681 * @param int $cached_sum Sum of cached complete-review ratings (fallback avg).
682 * @return array{0: int, 1: float, 2: array} [total_reviews, average_rating, booking_header]
683 * booking_header = {is_booking_only:bool, score:float, word:string} (SMASH-782).
684 */
685 public static function resolve_header_totals($feed, array $cached_reviews, int $cached_count, int $cached_sum): array
686 {
687 $businesses = method_exists($feed, 'get_header_data') ? $feed->get_header_data() : [];
688 $parser = new Parser();
689 if (is_array($businesses) && ! empty($businesses)) {
690 $businesses = $parser->backfill_review_counts($businesses, $cached_reviews);
691 }
692 $meta_total = (int) $parser->get_num_ratings($businesses);
693 $meta_average = (float) $parser->get_average_rating($businesses);
694
695 $total = $meta_total > 0 ? $meta_total : $cached_count;
696 if ($meta_average > 0) {
697 $average = round($meta_average, 1);
698 } else {
699 $average = $cached_count > 0 ? round($cached_sum / $cached_count, 1) : 5.0;
700 }
701
702 // SMASH-782: a booking-only alert shows Booking's native 0-10 count-weighted
703 // score + word (matching the feed header) instead of the 0-5 star average.
704 // Reuses the exact feed helper so the two can't drift. Third return element;
705 // callers that only need [total, average] destructure the first two (BC).
706 $booking = \SmashBalloon\Reviews\Common\FeedDisplay::get_booking_header_rating(
707 is_array($businesses) ? $businesses : []
708 );
709
710 return [$total, $average, $booking];
711 }
712
713 /**
714 * Decompose an average rating into per-star fill states, matching the feed
715 * header (4.7 -> full,full,full,full,half). The single source of truth for
716 * star rendering: the popup template consumes this, and the customizer's
717 * React `starFillStates()` in ReviewAlertPreview.js mirrors this exact
718 * formula so the admin preview and the live frontend never diverge (the bug
719 * was the preview pre-rounding with Math.round()). SMASH-1616.
720 *
721 * @param float $average Average rating (raw, NOT pre-rounded).
722 * @param int $count Number of stars (default 5).
723 * @return string[] One of 'full' | 'half' | 'empty' per star, length $count.
724 */
725 public static function star_fill_states(float $average, int $count = 5): array
726 {
727 $states = [];
728 for ($i = 1; $i <= $count; $i++) {
729 if ($average >= $i) {
730 $states[] = 'full';
731 } elseif ($average >= $i - 0.5) {
732 $states[] = 'half';
733 } else {
734 $states[] = 'empty';
735 }
736 }
737
738 return $states;
739 }
740
741 /**
742 * Convert database source IDs to account_ids for Feed class compatibility
743 *
744 * Review Alerts stores database IDs instead of account_ids to avoid URL encoding
745 * issues with special characters (Danish æ, ø, å). This follows PR #418 pattern.
746 *
747 * @since 2.5.0
748 * @param array $db_ids Array of database source IDs (integers)
749 * @return array Array of account_ids (strings)
750 */
751 private function convert_db_ids_to_account_ids(array $db_ids): array
752 {
753 if (empty($db_ids)) {
754 return [];
755 }
756
757 global $wpdb;
758 $sources_table = $wpdb->prefix . 'sbr_sources';
759
760 // Convert to integers for safety
761 $db_ids = array_map('absint', $db_ids);
762 $placeholders = implode(',', array_fill(0, count($db_ids), '%d'));
763
764 // Query account_ids for given database IDs
765 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table name and placeholders are safely generated
766 $results = $wpdb->get_col($wpdb->prepare("SELECT account_id FROM {$sources_table} WHERE id IN ({$placeholders})", ...$db_ids));
767
768 return $results ?: [];
769 }
770
771 /**
772 * Filter reviews to only include "complete" ones suitable for popup display
773 *
774 * Complete reviews must have:
775 * - Valid rating (1-5 stars)
776 * - Review text (non-empty)
777 * - Reviewer name (non-empty, not "Anonymous")
778 * - Avatar (preferred, but reviews without are still included if other criteria met)
779 *
780 * Reviews with avatars are prioritized over those without.
781 *
782 * @since 2.5.0
783 * @param array $reviews Raw reviews array
784 * @param array $allowed_providers Optional array of provider names to filter by
785 * @return array Filtered reviews sorted by completeness
786 */
787 private function filter_complete_reviews(array $reviews, ?array $allowed_providers = null): array
788 {
789 $with_avatar = [];
790 $without_avatar = [];
791
792 foreach ($reviews as $review) {
793 // Filter by provider if provider filter is explicitly set
794 // null = no filter (show all), empty array = show none (all deselected)
795 if ($allowed_providers !== null) {
796 // If providers array is empty, no reviews should show
797 if (empty($allowed_providers)) {
798 continue;
799 }
800 $provider = $review['provider'] ?? '';
801 $review_provider = is_array($provider) ? ($provider['name'] ?? '') : $provider;
802 if (!in_array($review_provider, $allowed_providers, true)) {
803 continue;
804 }
805 }
806
807 // Must have valid rating (1-5)
808 $rating = isset($review['rating']) ? (int) $review['rating'] : 0;
809 if ($rating < 1 || $rating > 5) {
810 continue;
811 }
812
813 // Must have review text
814 $text = trim($review['text'] ?? '');
815 if (empty($text)) {
816 continue;
817 }
818
819 // Must have reviewer name (not empty or "Anonymous")
820 $reviewer = $review['reviewer'] ?? [];
821 $name = is_array($reviewer) ? trim($reviewer['name'] ?? '') : '';
822 if (empty($name) || strtolower($name) === 'anonymous') {
823 continue;
824 }
825
826 // Check for avatar
827 $avatar = is_array($reviewer) ? trim($reviewer['avatar'] ?? '') : '';
828 if (!empty($avatar)) {
829 $with_avatar[] = $review;
830 } else {
831 $without_avatar[] = $review;
832 }
833 }
834
835 // Prioritize reviews with avatars, then those without
836 return array_merge($with_avatar, $without_avatar);
837 }
838
839 /**
840 * Prepare frontend configuration object
841 *
842 * @since 2.5.0
843 * @param array $popup Popup data
844 * @param array $reviews Reviews array (up to MAX_POPUP_REVIEWS for display)
845 * @param int $total_reviews Total matching reviews count (before slicing)
846 * @param float $average_rating Average rating from all matching reviews
847 * @return array Configuration for frontend JS
848 */
849 private function get_frontend_config(array $popup, array $reviews, int $total_reviews, float $average_rating, ?array $booking_header = null): array
850 {
851 $settings = $popup['settings'];
852 $review_feed = $settings['review_feed'] ?? [];
853
854 return [
855 'popupId' => $popup['id'],
856 'pluginUrl' => trailingslashit(SBR_PLUGIN_URL),
857 // SMASH-782: default avatar for reviewers without a photo (same image
858 // the single feed uses) so the cycler can fall back to it, not a "?".
859 'defaultAvatar' => SB_COMMON_ASSETS . 'sb-customizer/assets/images/avatar.jpg',
860 'theme' => $settings['theme'] ?? 'default',
861 'variation' => $settings['variation'] ?? 'v1',
862 'popupType' => $settings['popup_type'] ?? 'aggregate', // 'aggregate' or 'recent'
863 'accentColor' => $settings['accent_color'] ?? '#175CE3',
864 'accentHue' => $settings['accent_hue'] ?? '220',
865 'position' => $settings['position'] ?? 'bottom-right',
866 'linkUrl' => $settings['content']['link_url'] ?? '#',
867 'timing' => [
868 'mode' => $settings['timing']['mode'] ?? 'fixed',
869 'cycleIntervalMin' => $settings['timing']['cycle_interval_min'] ?? 3000,
870 'cycleIntervalMax' => $settings['timing']['cycle_interval_max'] ?? 5000,
871 'displayDuration' => $settings['timing']['display_duration'] ?? 5000,
872 ],
873 'content' => [
874 'showRating' => $settings['content']['show_rating'] ?? true,
875 'showTotalReviews' => $settings['content']['show_total_reviews'] ?? true,
876 'showAvatar' => $settings['content']['show_avatar'] ?? true,
877 'showReviewerName' => $settings['content']['show_reviewer_name'] ?? true,
878 'showDate' => $settings['content']['show_date'] ?? true,
879 'showPlatform' => $settings['content']['show_platform'] ?? true,
880 'showReviewText' => $settings['content']['show_review_text'] ?? true,
881 'showPoweredBy' => $settings['content']['show_powered_by'] ?? true,
882 ],
883 'reviewFeed' => [
884 'showHeading' => $review_feed['show_heading'] ?? true,
885 'headingText' => $review_feed['heading_text'] ?? '',
886 'showButton' => $review_feed['show_button'] ?? true,
887 'buttonText' => $review_feed['button_text'] ?? '',
888 'buttonUrl' => $review_feed['button_url'] ?? '',
889 'buttonIcon' => $review_feed['button_icon'] ?? null,
890 'showStars' => $review_feed['show_stars'] ?? true,
891 'showTitle' => $review_feed['show_title'] ?? true,
892 'showText' => $review_feed['show_content'] ?? true,
893 'showAuthor' => $review_feed['show_author'] ?? true,
894 'showDate' => $review_feed['show_date'] ?? true,
895 'showPoweredBy' => $review_feed['show_powered_by'] ?? true,
896 ],
897 'i18n' => [
898 /* translators: %s: reviewer name */
899 'reviewerHeadingTemplate' => __('%s left us a review', 'reviews-feed'),
900 // SMASH-782: labels the JS cycler needs to rebuild the provider block
901 // per review (so pros/cons, translated, host reply update on rotation).
902 'translatedText' => __('Translated from original', 'reviews-feed'),
903 'hostLabel' => __('Host', 'reviews-feed'),
904 'helpfulSingular' => __('%d person found this helpful', 'reviews-feed'),
905 'helpfulPlural' => __('%d people found this helpful', 'reviews-feed'),
906 ],
907 'reviews' => $this->format_reviews_for_frontend($reviews),
908 'totalReviews' => $total_reviews,
909 'averageRating' => $average_rating,
910 'bookingHeader' => $booking_header,
911 ];
912 }
913
914 /**
915 * Format reviews for frontend consumption
916 *
917 * Decodes HTML entities in text fields (review text, reviewer name) to ensure
918 * special characters like Danish æ, ø, å display correctly on the frontend.
919 *
920 * @since 2.5.0
921 * @param array $reviews Raw reviews array
922 * @return array Formatted reviews
923 */
924 private function format_reviews_for_frontend(array $reviews): array
925 {
926 $formatted = [];
927
928 foreach ($reviews as $review) {
929 // Safely access nested arrays to avoid PHP 8.0+ warnings
930 $reviewer = $review['reviewer'] ?? [];
931 $provider = $review['provider'] ?? '';
932
933 // Extract reviewer fields with defensive is_array() checks
934 $raw_reviewer_name = is_array($reviewer) ? ($reviewer['name'] ?? '') : '';
935 $reviewer_avatar = is_array($reviewer) ? ($reviewer['avatar'] ?? '') : '';
936
937 // Extract provider name with defensive is_array() check
938 $provider_name = is_array($provider) ? ($provider['name'] ?? 'google') : ($provider ?: 'google');
939
940 // Decode HTML entities for proper character display (e.g., &#248; → ø)
941 $text = html_entity_decode($review['text'] ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8');
942 $reviewer_name = html_entity_decode(
943 $raw_reviewer_name ?: __('Anonymous', 'reviews-feed'),
944 ENT_QUOTES | ENT_HTML5,
945 'UTF-8'
946 );
947
948 $row = [
949 'id' => $review['review_id'] ?? uniqid(),
950 'text' => $text,
951 'title' => isset($review['title']) ? html_entity_decode((string) $review['title'], ENT_QUOTES | ENT_HTML5, 'UTF-8') : '',
952 'rating' => (int) ($review['rating'] ?? 5),
953 'time' => $review['time'] ?? '',
954 'reviewer' => [
955 'name' => $reviewer_name,
956 'avatar' => $reviewer_avatar,
957 ],
958 'provider' => [
959 'name' => $provider_name,
960 ],
961 ];
962
963 // Booking's per-reviewer 0-10 score + band word, resolved HERE rather than
964 // mirrored in the JS. Two reasons: `rating` above is cast to int for the star
965 // renderer, and doubling a truncated 4 would print "8.0 Very good" on a card
966 // the feed and the JSON-LD both call 9.0; and the word stays translated,
967 // which a hardcoded JS ladder could not do — popup.php server-renders the
968 // first review's label and the cycler overwrites it on every rotation.
969 if ('booking' === $provider_name) {
970 $row['bookingScore'] = sbr_booking_review_score($review);
971 $row['bookingScoreWord'] = sbr_booking_score_word($row['bookingScore']);
972 }
973
974 // SMASH-782: append the provider-specific payload via the shared
975 // extractor so the frontend and the builder-preview formatters can't
976 // drift on which keys survive (dropping one silently breaks a body
977 // element — that happened repeatedly during 782 development).
978 $formatted[] = $row + self::extract_provider_payload($review);
979 }
980
981 return $formatted;
982 }
983
984 /**
985 * Provider-specific payload forwarded to the popup body, as a whitelist.
986 *
987 * Single source of truth shared by this frontend formatter and the builder
988 * preview formatter (SBR_Review_Alert_Service::get_preview_reviews) so the
989 * two can never disagree on which provider fields reach the renderer.
990 *
991 * Rendered now (popup.php, React preview, and the JS cycler): Booking
992 * pros/cons + helpful, AliExpress translated + buyer-flag + variants, Airbnb
993 * reply. Forwarded for future use (not yet rendered): reviewer_photos,
994 * source. Each value is coerced to a safe shape. Purely additive — add a new
995 * provider field HERE and both render paths pick it up at once.
996 *
997 * @param array $review Raw review row.
998 * @return array{metadata: array, reply: array, response: string, reviewer_photos: array, source: array}
999 */
1000 public static function extract_provider_payload(array $review): array
1001 {
1002 return [
1003 'metadata' => is_array($review['metadata'] ?? null) ? $review['metadata'] : [],
1004 'reply' => is_array($review['reply'] ?? null) ? $review['reply'] : [],
1005 'response' => is_string($review['response'] ?? null) ? $review['response'] : '',
1006 'reviewer_photos' => is_array($review['reviewer_photos'] ?? null) ? $review['reviewer_photos'] : [],
1007 'source' => is_array($review['source'] ?? null) ? $review['source'] : [],
1008 ];
1009 }
1010
1011 }
1012