PluginProbe ʕ •ᴥ•ʔ
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More / 2.9.0
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More v2.9.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 month ago SBR_Review_Alert_Frontend.php 1 month ago SBR_Review_Alert_Service.php 1 month ago
SBR_Review_Alert_Frontend.php
993 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 // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON encoding handles escaping
532 printf(
533 '<script id="sbr-review-alert-config">var sbrReviewAlertConfig = %s;</script>',
534 wp_json_encode($config)
535 );
536
537 // Render template
538 TemplateRenderer::render('review-alerts/popup', [
539 'popup' => $popup,
540 'reviews' => $reviews,
541 'config' => $config,
542 ]);
543 }
544
545 /**
546 * Get reviews for the popup using existing Feed class
547 *
548 * Applies the same filtering and sorting logic as the Reviews Feed:
549 * - Star rating filter (includedStarFilters)
550 * - Word filters (includeWords, excludeWords)
551 * - Character count filters (filterCharCountMin, filterCharCountMax)
552 * - Sorting by date, rating, or random
553 *
554 * @since 2.5.0
555 * @param array $popup_settings Full popup settings including sources, filters, and sort
556 * @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
557 */
558 private function get_reviews_for_popup(array $popup_settings): array
559 {
560 $source_db_ids = $popup_settings['sources'] ?? [];
561
562 // Filter out invalid values (0, empty strings, non-numeric)
563 // This handles edge cases from failed conversions or corrupted data
564 $source_db_ids = array_filter($source_db_ids, function ($id) {
565 return is_numeric($id) && (int) $id > 0;
566 });
567 $source_db_ids = array_values($source_db_ids); // Re-index array
568
569 // If no sources specified, return empty - no fallback to all sources
570 // User must explicitly select sources for the popup
571 if (empty($source_db_ids)) {
572 return [
573 'reviews' => [],
574 'totalReviews' => 0,
575 'averageRating' => 0,
576 ];
577 }
578
579 // Convert database IDs to account_ids for Feed class compatibility
580 // Following PR #418 pattern: store database IDs to avoid URL encoding issues
581 $source_ids = $this->convert_db_ids_to_account_ids($source_db_ids);
582
583 if (empty($source_ids)) {
584 return [
585 'reviews' => [],
586 'totalReviews' => 0,
587 'averageRating' => 0,
588 ];
589 }
590
591 // Get filter settings (aligned with Feed settings structure)
592 $filters = $popup_settings['filters'] ?? [];
593 $sort = $popup_settings['sort'] ?? [];
594
595 // Build settings for Feed class - merge popup filters/sort with defaults
596 $feed_settings = array_merge(sbr_settings_defaults(), [
597 'sources' => $source_ids,
598 'numPostDesktop' => 500, // Fetch more to allow filtering
599 'numPostTablet' => 500,
600 'numPostMobile' => 500,
601 // Filter settings - use popup settings if available
602 'includedStarFilters' => $filters['includedStarFilters'] ?? [],
603 'includeWords' => $filters['includeWords'] ?? '',
604 'excludeWords' => $filters['excludeWords'] ?? '',
605 'filterCharCountMin' => $filters['filterCharCountMin'] ?? 0,
606 'filterCharCountMax' => $filters['filterCharCountMax'] ?? '',
607 // Sort settings - use popup settings if available
608 'sortByDateEnabled' => $sort['sortByDateEnabled'] ?? true,
609 'sortByDate' => $sort['sortByDate'] ?? 'latest',
610 'sortByRatingEnabled' => $sort['sortByRatingEnabled'] ?? false,
611 'sortByRating' => $sort['sortByRating'] ?? '',
612 'sortRandomEnabled' => $sort['sortRandomEnabled'] ?? false,
613 ]);
614
615 // Create cache ID including filter/sort settings for unique caching
616 $cache_key = md5(wp_json_encode([
617 'sources' => $source_ids,
618 'filters' => $filters,
619 'sort' => $sort,
620 ]));
621 $cache_id = 'review_alert_' . $cache_key;
622
623 // Use Pro Feed if available for WPML support and media optimization
624 $feed_class = Util::sbr_is_pro()
625 ? '\\SmashBalloon\\Reviews\\Pro\\Feed'
626 : '\\SmashBalloon\\Reviews\\Common\\Feed';
627
628 $feed = new $feed_class($feed_settings, $cache_id, new FeedCache($cache_id, DAY_IN_SECONDS));
629
630 $feed->init();
631 $feed->get_set_cache();
632
633 // get_post_set_page() returns filtered posts (Feed::filter_posts is applied internally)
634 $all_reviews = $feed->get_post_set_page();
635
636 // Additional filter to only include "complete" reviews suitable for popup
637 // Pass provider filter if explicitly set (null = no filter, empty array = show none)
638 $allowed_providers = isset($filters['providers']) ? $filters['providers'] : null;
639 $complete_reviews = $this->filter_complete_reviews($all_reviews, $allowed_providers);
640 $cached_count = count($complete_reviews);
641 $cached_sum = 0;
642 foreach ($complete_reviews as $review) {
643 $cached_sum += isset($review['rating']) ? (int) $review['rating'] : 5;
644 }
645
646 // Headline total + average from the feed-header metadata (shared with the
647 // customizer preview path so the two can't drift). SMASH-1616.
648 // Backfill from the FULL cached set (get_posts()), exactly like
649 // FeedDisplay::backfill_review_counts — not the page slice — so providers
650 // whose API returned a zero count aren't under-counted vs the feed header.
651 [$total_reviews, $average_rating, $booking_header] = self::resolve_header_totals($feed, $feed->get_posts(), $cached_count, $cached_sum);
652
653 // Pass up to MAX_POPUP_REVIEWS - JS shows a first batch, then loads the rest on "See all".
654 return [
655 'reviews' => array_slice($complete_reviews, 0, self::MAX_POPUP_REVIEWS),
656 'totalReviews' => $total_reviews,
657 'averageRating' => $average_rating,
658 'bookingHeader' => $booking_header,
659 ];
660 }
661
662 /**
663 * Resolve a Review Alert's headline total + average from the feed-header
664 * metadata (the same numbers the published feed header shows), so the popup
665 * agrees with the feed. Falls back to the cached complete-review set when no
666 * source metadata is available. Shared by this frontend render path AND the
667 * customizer preview (SBR_Review_Alert_Service::get_preview_reviews) so the
668 * two can never drift. SMASH-1616.
669 *
670 * @param object $feed The built Feed (after get_set_cache()).
671 * @param array $cached_reviews Cached review rows (for count backfill).
672 * @param int $cached_count Count of cached complete reviews (fallback total).
673 * @param int $cached_sum Sum of cached complete-review ratings (fallback avg).
674 * @return array{0: int, 1: float, 2: array} [total_reviews, average_rating, booking_header]
675 * booking_header = {is_booking_only:bool, score:float, word:string} (SMASH-782).
676 */
677 public static function resolve_header_totals($feed, array $cached_reviews, int $cached_count, int $cached_sum): array
678 {
679 $businesses = method_exists($feed, 'get_header_data') ? $feed->get_header_data() : [];
680 $parser = new Parser();
681 if (is_array($businesses) && ! empty($businesses)) {
682 $businesses = $parser->backfill_review_counts($businesses, $cached_reviews);
683 }
684 $meta_total = (int) $parser->get_num_ratings($businesses);
685 $meta_average = (float) $parser->get_average_rating($businesses);
686
687 $total = $meta_total > 0 ? $meta_total : $cached_count;
688 if ($meta_average > 0) {
689 $average = round($meta_average, 1);
690 } else {
691 $average = $cached_count > 0 ? round($cached_sum / $cached_count, 1) : 5.0;
692 }
693
694 // SMASH-782: a booking-only alert shows Booking's native 0-10 count-weighted
695 // score + word (matching the feed header) instead of the 0-5 star average.
696 // Reuses the exact feed helper so the two can't drift. Third return element;
697 // callers that only need [total, average] destructure the first two (BC).
698 $booking = \SmashBalloon\Reviews\Common\FeedDisplay::get_booking_header_rating(
699 is_array($businesses) ? $businesses : []
700 );
701
702 return [$total, $average, $booking];
703 }
704
705 /**
706 * Decompose an average rating into per-star fill states, matching the feed
707 * header (4.7 -> full,full,full,full,half). The single source of truth for
708 * star rendering: the popup template consumes this, and the customizer's
709 * React `starFillStates()` in ReviewAlertPreview.js mirrors this exact
710 * formula so the admin preview and the live frontend never diverge (the bug
711 * was the preview pre-rounding with Math.round()). SMASH-1616.
712 *
713 * @param float $average Average rating (raw, NOT pre-rounded).
714 * @param int $count Number of stars (default 5).
715 * @return string[] One of 'full' | 'half' | 'empty' per star, length $count.
716 */
717 public static function star_fill_states(float $average, int $count = 5): array
718 {
719 $states = [];
720 for ($i = 1; $i <= $count; $i++) {
721 if ($average >= $i) {
722 $states[] = 'full';
723 } elseif ($average >= $i - 0.5) {
724 $states[] = 'half';
725 } else {
726 $states[] = 'empty';
727 }
728 }
729
730 return $states;
731 }
732
733 /**
734 * Convert database source IDs to account_ids for Feed class compatibility
735 *
736 * Review Alerts stores database IDs instead of account_ids to avoid URL encoding
737 * issues with special characters (Danish æ, ø, å). This follows PR #418 pattern.
738 *
739 * @since 2.5.0
740 * @param array $db_ids Array of database source IDs (integers)
741 * @return array Array of account_ids (strings)
742 */
743 private function convert_db_ids_to_account_ids(array $db_ids): array
744 {
745 if (empty($db_ids)) {
746 return [];
747 }
748
749 global $wpdb;
750 $sources_table = $wpdb->prefix . 'sbr_sources';
751
752 // Convert to integers for safety
753 $db_ids = array_map('absint', $db_ids);
754 $placeholders = implode(',', array_fill(0, count($db_ids), '%d'));
755
756 // Query account_ids for given database IDs
757 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table name and placeholders are safely generated
758 $results = $wpdb->get_col($wpdb->prepare("SELECT account_id FROM {$sources_table} WHERE id IN ({$placeholders})", ...$db_ids));
759
760 return $results ?: [];
761 }
762
763 /**
764 * Filter reviews to only include "complete" ones suitable for popup display
765 *
766 * Complete reviews must have:
767 * - Valid rating (1-5 stars)
768 * - Review text (non-empty)
769 * - Reviewer name (non-empty, not "Anonymous")
770 * - Avatar (preferred, but reviews without are still included if other criteria met)
771 *
772 * Reviews with avatars are prioritized over those without.
773 *
774 * @since 2.5.0
775 * @param array $reviews Raw reviews array
776 * @param array $allowed_providers Optional array of provider names to filter by
777 * @return array Filtered reviews sorted by completeness
778 */
779 private function filter_complete_reviews(array $reviews, ?array $allowed_providers = null): array
780 {
781 $with_avatar = [];
782 $without_avatar = [];
783
784 foreach ($reviews as $review) {
785 // Filter by provider if provider filter is explicitly set
786 // null = no filter (show all), empty array = show none (all deselected)
787 if ($allowed_providers !== null) {
788 // If providers array is empty, no reviews should show
789 if (empty($allowed_providers)) {
790 continue;
791 }
792 $provider = $review['provider'] ?? '';
793 $review_provider = is_array($provider) ? ($provider['name'] ?? '') : $provider;
794 if (!in_array($review_provider, $allowed_providers, true)) {
795 continue;
796 }
797 }
798
799 // Must have valid rating (1-5)
800 $rating = isset($review['rating']) ? (int) $review['rating'] : 0;
801 if ($rating < 1 || $rating > 5) {
802 continue;
803 }
804
805 // Must have review text
806 $text = trim($review['text'] ?? '');
807 if (empty($text)) {
808 continue;
809 }
810
811 // Must have reviewer name (not empty or "Anonymous")
812 $reviewer = $review['reviewer'] ?? [];
813 $name = is_array($reviewer) ? trim($reviewer['name'] ?? '') : '';
814 if (empty($name) || strtolower($name) === 'anonymous') {
815 continue;
816 }
817
818 // Check for avatar
819 $avatar = is_array($reviewer) ? trim($reviewer['avatar'] ?? '') : '';
820 if (!empty($avatar)) {
821 $with_avatar[] = $review;
822 } else {
823 $without_avatar[] = $review;
824 }
825 }
826
827 // Prioritize reviews with avatars, then those without
828 return array_merge($with_avatar, $without_avatar);
829 }
830
831 /**
832 * Prepare frontend configuration object
833 *
834 * @since 2.5.0
835 * @param array $popup Popup data
836 * @param array $reviews Reviews array (up to MAX_POPUP_REVIEWS for display)
837 * @param int $total_reviews Total matching reviews count (before slicing)
838 * @param float $average_rating Average rating from all matching reviews
839 * @return array Configuration for frontend JS
840 */
841 private function get_frontend_config(array $popup, array $reviews, int $total_reviews, float $average_rating, ?array $booking_header = null): array
842 {
843 $settings = $popup['settings'];
844 $review_feed = $settings['review_feed'] ?? [];
845
846 return [
847 'popupId' => $popup['id'],
848 'pluginUrl' => trailingslashit(SBR_PLUGIN_URL),
849 // SMASH-782: default avatar for reviewers without a photo (same image
850 // the single feed uses) so the cycler can fall back to it, not a "?".
851 'defaultAvatar' => SB_COMMON_ASSETS . 'sb-customizer/assets/images/avatar.jpg',
852 'theme' => $settings['theme'] ?? 'default',
853 'variation' => $settings['variation'] ?? 'v1',
854 'popupType' => $settings['popup_type'] ?? 'aggregate', // 'aggregate' or 'recent'
855 'accentColor' => $settings['accent_color'] ?? '#175CE3',
856 'accentHue' => $settings['accent_hue'] ?? '220',
857 'position' => $settings['position'] ?? 'bottom-right',
858 'linkUrl' => $settings['content']['link_url'] ?? '#',
859 'timing' => [
860 'mode' => $settings['timing']['mode'] ?? 'fixed',
861 'cycleIntervalMin' => $settings['timing']['cycle_interval_min'] ?? 3000,
862 'cycleIntervalMax' => $settings['timing']['cycle_interval_max'] ?? 5000,
863 'displayDuration' => $settings['timing']['display_duration'] ?? 5000,
864 ],
865 'content' => [
866 'showRating' => $settings['content']['show_rating'] ?? true,
867 'showTotalReviews' => $settings['content']['show_total_reviews'] ?? true,
868 'showAvatar' => $settings['content']['show_avatar'] ?? true,
869 'showReviewerName' => $settings['content']['show_reviewer_name'] ?? true,
870 'showDate' => $settings['content']['show_date'] ?? true,
871 'showPlatform' => $settings['content']['show_platform'] ?? true,
872 'showReviewText' => $settings['content']['show_review_text'] ?? true,
873 'showPoweredBy' => $settings['content']['show_powered_by'] ?? true,
874 ],
875 'reviewFeed' => [
876 'showHeading' => $review_feed['show_heading'] ?? true,
877 'headingText' => $review_feed['heading_text'] ?? '',
878 'showButton' => $review_feed['show_button'] ?? true,
879 'buttonText' => $review_feed['button_text'] ?? '',
880 'buttonUrl' => $review_feed['button_url'] ?? '',
881 'buttonIcon' => $review_feed['button_icon'] ?? null,
882 'showStars' => $review_feed['show_stars'] ?? true,
883 'showTitle' => $review_feed['show_title'] ?? true,
884 'showText' => $review_feed['show_content'] ?? true,
885 'showAuthor' => $review_feed['show_author'] ?? true,
886 'showDate' => $review_feed['show_date'] ?? true,
887 'showPoweredBy' => $review_feed['show_powered_by'] ?? true,
888 ],
889 'i18n' => [
890 /* translators: %s: reviewer name */
891 'reviewerHeadingTemplate' => __('%s left us a review', 'reviews-feed'),
892 // SMASH-782: labels the JS cycler needs to rebuild the provider block
893 // per review (so pros/cons, translated, host reply update on rotation).
894 'translatedText' => __('Translated from original', 'reviews-feed'),
895 'hostLabel' => __('Host', 'reviews-feed'),
896 'helpfulSingular' => __('%d person found this helpful', 'reviews-feed'),
897 'helpfulPlural' => __('%d people found this helpful', 'reviews-feed'),
898 ],
899 'reviews' => $this->format_reviews_for_frontend($reviews),
900 'totalReviews' => $total_reviews,
901 'averageRating' => $average_rating,
902 'bookingHeader' => $booking_header,
903 ];
904 }
905
906 /**
907 * Format reviews for frontend consumption
908 *
909 * Decodes HTML entities in text fields (review text, reviewer name) to ensure
910 * special characters like Danish æ, ø, å display correctly on the frontend.
911 *
912 * @since 2.5.0
913 * @param array $reviews Raw reviews array
914 * @return array Formatted reviews
915 */
916 private function format_reviews_for_frontend(array $reviews): array
917 {
918 $formatted = [];
919
920 foreach ($reviews as $review) {
921 // Safely access nested arrays to avoid PHP 8.0+ warnings
922 $reviewer = $review['reviewer'] ?? [];
923 $provider = $review['provider'] ?? '';
924
925 // Extract reviewer fields with defensive is_array() checks
926 $raw_reviewer_name = is_array($reviewer) ? ($reviewer['name'] ?? '') : '';
927 $reviewer_avatar = is_array($reviewer) ? ($reviewer['avatar'] ?? '') : '';
928
929 // Extract provider name with defensive is_array() check
930 $provider_name = is_array($provider) ? ($provider['name'] ?? 'google') : ($provider ?: 'google');
931
932 // Decode HTML entities for proper character display (e.g., &#248; → ø)
933 $text = html_entity_decode($review['text'] ?? '', ENT_QUOTES | ENT_HTML5, 'UTF-8');
934 $reviewer_name = html_entity_decode(
935 $raw_reviewer_name ?: __('Anonymous', 'reviews-feed'),
936 ENT_QUOTES | ENT_HTML5,
937 'UTF-8'
938 );
939
940 $row = [
941 'id' => $review['review_id'] ?? uniqid(),
942 'text' => $text,
943 'title' => isset($review['title']) ? html_entity_decode((string) $review['title'], ENT_QUOTES | ENT_HTML5, 'UTF-8') : '',
944 'rating' => (int) ($review['rating'] ?? 5),
945 'time' => $review['time'] ?? '',
946 'reviewer' => [
947 'name' => $reviewer_name,
948 'avatar' => $reviewer_avatar,
949 ],
950 'provider' => [
951 'name' => $provider_name,
952 ],
953 ];
954
955 // SMASH-782: append the provider-specific payload via the shared
956 // extractor so the frontend and the builder-preview formatters can't
957 // drift on which keys survive (dropping one silently breaks a body
958 // element — that happened repeatedly during 782 development).
959 $formatted[] = $row + self::extract_provider_payload($review);
960 }
961
962 return $formatted;
963 }
964
965 /**
966 * Provider-specific payload forwarded to the popup body, as a whitelist.
967 *
968 * Single source of truth shared by this frontend formatter and the builder
969 * preview formatter (SBR_Review_Alert_Service::get_preview_reviews) so the
970 * two can never disagree on which provider fields reach the renderer.
971 *
972 * Rendered now (popup.php, React preview, and the JS cycler): Booking
973 * pros/cons + helpful, AliExpress translated + buyer-flag + variants, Airbnb
974 * reply. Forwarded for future use (not yet rendered): reviewer_photos,
975 * source. Each value is coerced to a safe shape. Purely additive — add a new
976 * provider field HERE and both render paths pick it up at once.
977 *
978 * @param array $review Raw review row.
979 * @return array{metadata: array, reply: array, response: string, reviewer_photos: array, source: array}
980 */
981 public static function extract_provider_payload(array $review): array
982 {
983 return [
984 'metadata' => is_array($review['metadata'] ?? null) ? $review['metadata'] : [],
985 'reply' => is_array($review['reply'] ?? null) ? $review['reply'] : [],
986 'response' => is_string($review['response'] ?? null) ? $review['response'] : '',
987 'reviewer_photos' => is_array($review['reviewer_photos'] ?? null) ? $review['reviewer_photos'] : [],
988 'source' => is_array($review['source'] ?? null) ? $review['source'] : [],
989 ];
990 }
991
992 }
993