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