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_Service.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_Service.php
1749 lines
1 <?php
2
3 // phpcs:disable Generic.Metrics.CyclomaticComplexity
4 // Note: Comprehensive sanitization requires complexity.
5
6 /**
7 * Review Alert Service
8 *
9 * Consolidated service class for review alerts feature.
10 * Handles: registration, CRUD, AJAX handlers, settings, and tier checks.
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\Util;
24 use SmashBalloon\Reviews\Common\Feed;
25 use SmashBalloon\Reviews\Common\FeedCache;
26
27 /**
28 * Class SBR_Review_Alert_Service
29 *
30 * @since 2.5.0
31 */
32 class SBR_Review_Alert_Service extends ServiceProvider
33 {
34 /**
35 * Custom post type name (max 20 characters)
36 */
37 const POST_TYPE = 'sbr_review_alert';
38
39 /**
40 * Shortcode-specified popup ID (takes priority over settings-based popups)
41 *
42 * @var int|null
43 */
44 private static $shortcode_popup_id = null;
45
46 /**
47 * Register hooks and actions
48 *
49 * @since 2.5.0
50 * @return void
51 */
52 public function register(): void
53 {
54 add_action('init', [$this, 'register_post_type']);
55 add_action('init', [$this, 'register_shortcode']);
56
57 // AJAX handlers
58 add_action('wp_ajax_sbr_review_alert_save', [__CLASS__, 'ajax_save']);
59 add_action('wp_ajax_sbr_review_alert_delete', [__CLASS__, 'ajax_delete']);
60 add_action('wp_ajax_sbr_review_alert_bulk_delete', [__CLASS__, 'ajax_bulk_delete']);
61 add_action('wp_ajax_sbr_review_alert_list', [__CLASS__, 'ajax_list']);
62 add_action('wp_ajax_sbr_review_alert_duplicate', [__CLASS__, 'ajax_duplicate']);
63 add_action('wp_ajax_sbr_review_alert_preview_reviews', [__CLASS__, 'ajax_preview_reviews']);
64 add_action('wp_ajax_sbr_review_alert_toggle_status', [__CLASS__, 'ajax_toggle_status']);
65 }
66
67 /**
68 * Register custom post type for storing review alerts
69 *
70 * @since 2.5.0
71 * @return void
72 */
73 public function register_post_type(): void
74 {
75 $args = [
76 'labels' => [
77 'name' => __('Review Alerts', 'reviews-feed'),
78 'singular_name' => __('Review Alert', 'reviews-feed'),
79 ],
80 'public' => false,
81 'show_ui' => false,
82 'show_in_menu' => false,
83 'show_in_admin_bar' => false,
84 'show_in_nav_menus' => false,
85 'can_export' => true,
86 'has_archive' => false,
87 'exclude_from_search' => true,
88 'publicly_queryable' => false,
89 'capability_type' => 'post',
90 'supports' => ['title'],
91 ];
92
93 register_post_type(self::POST_TYPE, $args);
94 }
95
96 /**
97 * Register review alert shortcode
98 *
99 * Shortcode: [sbr-popup id="123"]
100 *
101 * When a shortcode is present on a page, it takes priority over
102 * settings-based popup display (configured via admin for "all pages"
103 * or specific page targeting).
104 *
105 * @since 2.5.0
106 * @return void
107 */
108 public function register_shortcode(): void
109 {
110 add_shortcode('sbr-popup', [$this, 'render_shortcode']);
111 }
112
113 /**
114 * Shortcode callback for review alert
115 *
116 * Registers the popup ID for display in the footer. The actual popup
117 * rendering happens via SBR_Review_Alert_Frontend which checks
118 * for shortcode-specified popups before falling back to settings-based logic.
119 *
120 * Usage: [sbr-popup id="123"]
121 *
122 * @since 2.5.0
123 * @param array $atts Shortcode attributes
124 * @return string Empty string (popup renders in footer, not inline)
125 */
126 public function render_shortcode($atts): string
127 {
128 // Parse shortcode attributes
129 $atts = shortcode_atts([
130 'id' => 0,
131 ], $atts, 'sbr-popup');
132
133 $popup_id = absint($atts['id']);
134
135 // Validate popup exists and is active
136 if ($popup_id <= 0) {
137 return '';
138 }
139
140 $popup = self::get_popup($popup_id);
141 if (!$popup) {
142 return '';
143 }
144
145 // Check if popup is active (published)
146 if ($popup['status'] !== 'active') {
147 return '';
148 }
149
150 // Store the shortcode-specified popup ID for priority rendering
151 self::$shortcode_popup_id = $popup_id;
152
153 // Return empty - popup renders in footer via Frontend class
154 return '';
155 }
156
157 /**
158 * Get the shortcode-specified popup ID (if any)
159 *
160 * @since 2.5.0
161 * @return int|null Popup ID or null if no shortcode specified
162 */
163 public static function get_shortcode_popup_id(): ?int
164 {
165 return self::$shortcode_popup_id;
166 }
167
168 /**
169 * Check if a shortcode-specified popup exists for current page
170 *
171 * @since 2.5.0
172 * @return bool True if shortcode specified a popup
173 */
174 public static function has_shortcode_popup(): bool
175 {
176 return self::$shortcode_popup_id !== null;
177 }
178
179 /**
180 * Reset the shortcode popup ID
181 *
182 * Called at the start of each request to ensure static property
183 * doesn't leak between requests in persistent worker environments.
184 *
185 * @since 2.5.0
186 * @return void
187 */
188 public static function reset_shortcode_popup_id(): void
189 {
190 self::$shortcode_popup_id = null;
191 }
192
193 /**
194 * Get default settings for review alert
195 *
196 * @since 2.5.0
197 * @return array Default settings
198 */
199 public static function get_defaults(): array
200 {
201 return [
202 'theme' => 'light',
203 'variation' => 'v1',
204 'popup_type' => 'aggregate', // 'aggregate' (summary view) or 'recent' (cycles through reviews)
205 'accent_color' => '#175CE3',
206 'accent_hue' => '220', // Hue value (0-360) for HSL theming, corresponds to #175CE3
207 'position' => 'bottom-right',
208 'timing' => [
209 'mode' => 'fixed', // 'fixed' or 'random' - controls review cycling interval
210 'cycle_interval_min' => 3000, // Min cycle interval for random mode (ms)
211 'cycle_interval_max' => 5000, // Cycle interval for fixed mode / max for random (ms)
212 'display_duration' => 5000,
213 ],
214 'content' => [
215 'show_rating' => true,
216 'show_total_reviews' => true,
217 'show_avatar' => true,
218 'show_platform' => true,
219 'show_reviewer_name' => true,
220 'show_date' => true,
221 'show_review_text' => true,
222 'show_powered_by' => true,
223 'link_url' => '#', // URL for "View All Reviews" link
224 ],
225 'sources' => [],
226 // Filters - aligned with Feed settings structure
227 'filters' => [
228 'includedStarFilters' => [], // Array of star ratings (1-5) to include
229 'includeWords' => '', // Comma-separated words to include
230 'excludeWords' => '', // Comma-separated words to exclude
231 'filterCharCountMin' => 0, // Minimum character count
232 'filterCharCountMax' => '', // Maximum character count (empty = no limit)
233 ],
234 // Sorting - aligned with Feed settings structure
235 'sort' => [
236 'sortByDateEnabled' => true, // Enable date sorting
237 'sortByDate' => 'latest', // 'latest' or 'oldest'
238 'sortByRatingEnabled' => false, // Enable rating sorting
239 'sortByRating' => '', // 'highest' or 'lowest'
240 'sortRandomEnabled' => false, // Randomize order
241 ],
242 'visibility' => [
243 'display_on' => 'specific',
244 'excluded' => [
245 'pages' => [],
246 'categories' => [],
247 'custom_post_types' => [],
248 ],
249 'specific' => [
250 'pages' => [],
251 'categories' => [],
252 'custom_post_types' => [],
253 ],
254 ],
255 // Review Feed (expanded popup) settings
256 'review_feed' => [
257 'show_heading' => true,
258 'heading_text' => '', // Empty = use default "See what our Customers say..."
259 'show_button' => true,
260 'button_text' => '', // Empty = use default "Get Smash Balloon Feed Pro"
261 'button_url' => '', // Empty = use default "#"
262 'button_icon' => null, // Icon ID: arrow-right, external-link, chevron-right, star, heart
263 'show_stars' => true,
264 'show_title' => true,
265 'show_content' => true,
266 'show_author' => true,
267 'show_date' => true,
268 'show_powered_by' => true,
269 ],
270 'status' => 'inactive',
271 ];
272 }
273
274 /**
275 * Sanitize review alert settings
276 *
277 * @since 2.5.0
278 * @param array $settings Raw settings to sanitize
279 * @return array Sanitized settings
280 */
281 public static function sanitize_settings(array $settings): array
282 {
283 $defaults = self::get_defaults();
284 $sanitized = [];
285
286 // Theme - must be 'light', 'dark', 'minimal', or 'minimal-dark'
287 $valid_themes = ['light', 'dark', 'minimal', 'minimal-dark'];
288 $sanitized['theme'] = isset($settings['theme']) && in_array($settings['theme'], $valid_themes, true)
289 ? $settings['theme']
290 : $defaults['theme'];
291
292 // Variation - must be 'v1', 'v2', or 'v3'
293 $sanitized['variation'] = isset($settings['variation']) && in_array($settings['variation'], ['v1', 'v2', 'v3'], true)
294 ? $settings['variation']
295 : $defaults['variation'];
296
297 // Popup type - must be 'aggregate' or 'recent'
298 $sanitized['popup_type'] = isset($settings['popup_type']) && in_array($settings['popup_type'], ['aggregate', 'recent'], true)
299 ? $settings['popup_type']
300 : $defaults['popup_type'];
301
302 // Accent color - must be valid hex color
303 $sanitized['accent_color'] = isset($settings['accent_color']) && preg_match('/^#[a-fA-F0-9]{6}$/', $settings['accent_color'])
304 ? sanitize_hex_color($settings['accent_color'])
305 : $defaults['accent_color'];
306
307 // Accent hue - must be valid hue value (0-360) for HSL theming
308 // This is sent from the React customizer alongside accent_color
309 // Always save accent_hue (fallback to default if not provided)
310 $sanitized['accent_hue'] = isset($settings['accent_hue'])
311 ? (string) min(360, max(0, absint($settings['accent_hue'])))
312 : $defaults['accent_hue'];
313
314 // Position - must be valid position
315 $valid_positions = ['bottom-left', 'bottom-right', 'top-left', 'top-right'];
316 $sanitized['position'] = isset($settings['position']) && in_array($settings['position'], $valid_positions, true)
317 ? $settings['position']
318 : $defaults['position'];
319
320 // Timing - sanitize mode and cycle intervals
321 $valid_timing_modes = ['fixed', 'random'];
322 $sanitized['timing'] = [
323 'mode' => isset($settings['timing']['mode']) && in_array($settings['timing']['mode'], $valid_timing_modes, true)
324 ? $settings['timing']['mode']
325 : $defaults['timing']['mode'],
326 'cycle_interval_min' => isset($settings['timing']['cycle_interval_min'])
327 ? max(0, absint($settings['timing']['cycle_interval_min']))
328 : $defaults['timing']['cycle_interval_min'],
329 'cycle_interval_max' => isset($settings['timing']['cycle_interval_max'])
330 ? max(1000, absint($settings['timing']['cycle_interval_max']))
331 : $defaults['timing']['cycle_interval_max'],
332 'display_duration' => isset($settings['timing']['display_duration'])
333 ? max(1000, absint($settings['timing']['display_duration']))
334 : $defaults['timing']['display_duration'],
335 ];
336
337 // Content - sanitize (booleans for show_* settings, URL for link_url)
338 $sanitized['content'] = [];
339 foreach ($defaults['content'] as $key => $default_value) {
340 if ($key === 'link_url') {
341 // Sanitize as URL
342 $sanitized['content'][$key] = isset($settings['content'][$key])
343 ? esc_url_raw($settings['content'][$key])
344 : $default_value;
345 } else {
346 // Sanitize as boolean
347 $sanitized['content'][$key] = isset($settings['content'][$key])
348 ? (bool) $settings['content'][$key]
349 : $default_value;
350 }
351 }
352
353 // Sources - sanitize as array of integers (database IDs)
354 // Uses database ID instead of account_id to avoid URL encoding issues with special characters
355 // Following the source_id pattern from PR #418
356 // Backward compatible: accepts both integer IDs (new) and string account_ids (old)
357 $sanitized['sources'] = [];
358 $legacy_account_ids = [];
359 if (isset($settings['sources']) && is_array($settings['sources'])) {
360 foreach ($settings['sources'] as $source) {
361 if (is_numeric($source)) {
362 // New format: database ID (integer)
363 $sanitized['sources'][] = absint($source);
364 } elseif (is_string($source) && !empty($source)) {
365 // Old format: account_id string - collect for conversion
366 $legacy_account_ids[] = $source;
367 }
368 }
369 }
370 // Convert legacy account_ids to database IDs
371 if (!empty($legacy_account_ids)) {
372 $converted_ids = self::convert_account_ids_to_db_ids($legacy_account_ids);
373 $sanitized['sources'] = array_unique(array_merge($sanitized['sources'], $converted_ids));
374 }
375 // Filter out any invalid values (0s from failed conversions)
376 $sanitized['sources'] = array_values(array_filter($sanitized['sources'], function ($id) {
377 return $id > 0;
378 }));
379
380 // Visibility - sanitize page targeting
381 // New clean structure: visibility.excluded/specific.pages/categories/custom_post_types
382 // Default to 'specific' (matches get_defaults()) - safer default requiring explicit page selection
383 $sanitized['visibility'] = [
384 'display_on' => 'specific',
385 'excluded' => [
386 'pages' => [],
387 'categories' => [],
388 'custom_post_types' => [],
389 ],
390 'specific' => [
391 'pages' => [],
392 'categories' => [],
393 'custom_post_types' => [],
394 ],
395 ];
396
397 // Check if UI sent 'visibility' structure
398 if (isset($settings['visibility']) && is_array($settings['visibility'])) {
399 $visibility = $settings['visibility'];
400
401 // Display on: 'all' or 'specific'
402 if (isset($visibility['display_on']) && in_array($visibility['display_on'], ['all', 'specific'], true)) {
403 $sanitized['visibility']['display_on'] = $visibility['display_on'];
404 }
405
406 // Excluded pages/categories/custom_post_types (for 'all' mode)
407 if (isset($visibility['excluded']) && is_array($visibility['excluded'])) {
408 // Pages - array of objects {id, title, url} or IDs (backwards compat)
409 if (isset($visibility['excluded']['pages']) && is_array($visibility['excluded']['pages'])) {
410 $sanitized['visibility']['excluded']['pages'] = self::sanitize_visibility_pages($visibility['excluded']['pages']);
411 }
412
413 // Categories - array of objects {id, name, url} or IDs (backwards compat)
414 if (isset($visibility['excluded']['categories']) && is_array($visibility['excluded']['categories'])) {
415 $sanitized['visibility']['excluded']['categories'] = self::sanitize_visibility_categories($visibility['excluded']['categories']);
416 }
417
418 // Custom post types - array of objects {name, label} or slugs (backwards compat)
419 if (isset($visibility['excluded']['custom_post_types']) && is_array($visibility['excluded']['custom_post_types'])) {
420 $sanitized['visibility']['excluded']['custom_post_types'] = self::sanitize_visibility_post_types($visibility['excluded']['custom_post_types']);
421 }
422 }
423
424 // Specific pages/categories/custom_post_types (for 'specific' mode)
425 if (isset($visibility['specific']) && is_array($visibility['specific'])) {
426 // Pages - array of objects {id, title, url} or IDs (backwards compat)
427 if (isset($visibility['specific']['pages']) && is_array($visibility['specific']['pages'])) {
428 $sanitized['visibility']['specific']['pages'] = self::sanitize_visibility_pages($visibility['specific']['pages']);
429 }
430
431 // Categories - array of objects {id, name, url} or IDs (backwards compat)
432 if (isset($visibility['specific']['categories']) && is_array($visibility['specific']['categories'])) {
433 $sanitized['visibility']['specific']['categories'] = self::sanitize_visibility_categories($visibility['specific']['categories']);
434 }
435
436 // Custom post types - array of objects {name, label} or slugs (backwards compat)
437 if (isset($visibility['specific']['custom_post_types']) && is_array($visibility['specific']['custom_post_types'])) {
438 $sanitized['visibility']['specific']['custom_post_types'] = self::sanitize_visibility_post_types($visibility['specific']['custom_post_types']);
439 }
440 }
441 }
442
443 // Filters - aligned with Feed settings (includedStarFilters, includeWords, etc.)
444 $sanitized['filters'] = [
445 'includedStarFilters' => [],
446 'includeWords' => '',
447 'excludeWords' => '',
448 'filterCharCountMin' => 0,
449 'filterCharCountMax' => '',
450 ];
451
452 if (isset($settings['filters']) && is_array($settings['filters'])) {
453 // Star filters - must be array of integers 1-5
454 if (isset($settings['filters']['includedStarFilters']) && is_array($settings['filters']['includedStarFilters'])) {
455 $sanitized['filters']['includedStarFilters'] = array_values(array_filter(
456 array_map('absint', $settings['filters']['includedStarFilters']),
457 function ($star) {
458 return $star >= 1 && $star <= 5;
459 }
460 ));
461 }
462
463 // Include words - sanitize as comma-separated text
464 if (isset($settings['filters']['includeWords'])) {
465 $sanitized['filters']['includeWords'] = sanitize_text_field($settings['filters']['includeWords']);
466 }
467
468 // Exclude words - sanitize as comma-separated text
469 if (isset($settings['filters']['excludeWords'])) {
470 $sanitized['filters']['excludeWords'] = sanitize_text_field($settings['filters']['excludeWords']);
471 }
472
473 // Min character count - must be non-negative integer
474 if (isset($settings['filters']['filterCharCountMin'])) {
475 $sanitized['filters']['filterCharCountMin'] = max(0, absint($settings['filters']['filterCharCountMin']));
476 }
477
478 // Max character count - sanitize as positive integer or empty string
479 if (isset($settings['filters']['filterCharCountMax']) && $settings['filters']['filterCharCountMax'] !== '') {
480 $sanitized['filters']['filterCharCountMax'] = max(1, absint($settings['filters']['filterCharCountMax']));
481 }
482
483 // Provider filter - array of provider names (e.g., ['google', 'facebook'])
484 // Note: We explicitly check isset() to distinguish between:
485 // - Not set (null) = no filter, show all providers
486 // - Empty array [] = show no reviews (all providers deselected)
487 // - Array with values = show only those providers
488 if (isset($settings['filters']['providers'])) {
489 if (is_array($settings['filters']['providers'])) {
490 $valid_providers = ['google', 'facebook', 'yelp', 'tripadvisor', 'trustpilot', 'wordpress', 'woocommerce', 'edd'];
491 $sanitized['filters']['providers'] = array_values(array_filter(
492 array_map('sanitize_key', $settings['filters']['providers']),
493 function ($provider) use ($valid_providers) {
494 return in_array($provider, $valid_providers, true);
495 }
496 ));
497 } else {
498 // If providers is set but not an array, treat as empty (show none)
499 $sanitized['filters']['providers'] = [];
500 }
501 }
502 // If providers key is not set, don't add it to sanitized - this means "no filter"
503 }
504
505 // Sort settings - aligned with Feed settings
506 $sanitized['sort'] = $defaults['sort'];
507
508 if (isset($settings['sort']) && is_array($settings['sort'])) {
509 // Sort by date enabled
510 if (isset($settings['sort']['sortByDateEnabled'])) {
511 $sanitized['sort']['sortByDateEnabled'] = (bool) $settings['sort']['sortByDateEnabled'];
512 }
513
514 // Sort by date direction - must be 'latest' or 'oldest'
515 if (isset($settings['sort']['sortByDate']) && in_array($settings['sort']['sortByDate'], ['latest', 'oldest'], true)) {
516 $sanitized['sort']['sortByDate'] = $settings['sort']['sortByDate'];
517 }
518
519 // Sort by rating enabled
520 if (isset($settings['sort']['sortByRatingEnabled'])) {
521 $sanitized['sort']['sortByRatingEnabled'] = (bool) $settings['sort']['sortByRatingEnabled'];
522 }
523
524 // Sort by rating direction - must be 'highest' or 'lowest'
525 if (isset($settings['sort']['sortByRating']) && in_array($settings['sort']['sortByRating'], ['highest', 'lowest'], true)) {
526 $sanitized['sort']['sortByRating'] = $settings['sort']['sortByRating'];
527 }
528
529 // Random sort enabled
530 if (isset($settings['sort']['sortRandomEnabled'])) {
531 $sanitized['sort']['sortRandomEnabled'] = (bool) $settings['sort']['sortRandomEnabled'];
532 }
533 }
534
535 // Review Feed (expanded popup) settings
536 // Always initialize with defaults to prevent data loss on partial updates
537 $sanitized['review_feed'] = $defaults['review_feed'];
538
539 if (isset($settings['review_feed']) && is_array($settings['review_feed'])) {
540 $review_feed = $settings['review_feed'];
541
542 // Boolean visibility toggles
543 $bool_keys = ['show_heading', 'show_button', 'show_stars', 'show_title', 'show_content', 'show_author', 'show_date', 'show_powered_by'];
544 foreach ($bool_keys as $key) {
545 if (isset($review_feed[$key])) {
546 $sanitized['review_feed'][$key] = (bool) $review_feed[$key];
547 }
548 }
549
550 // Text fields
551 if (isset($review_feed['heading_text'])) {
552 $sanitized['review_feed']['heading_text'] = sanitize_text_field($review_feed['heading_text']);
553 }
554 if (isset($review_feed['button_text'])) {
555 $sanitized['review_feed']['button_text'] = sanitize_text_field($review_feed['button_text']);
556 }
557 if (isset($review_feed['button_url'])) {
558 $sanitized['review_feed']['button_url'] = esc_url_raw($review_feed['button_url']);
559 }
560
561 // Button icon - must be a valid icon ID or null
562 $valid_icons = ['arrow-right', 'external-link', 'chevron-right', 'star', 'heart'];
563 if (isset($review_feed['button_icon']) && in_array($review_feed['button_icon'], $valid_icons, true)) {
564 $sanitized['review_feed']['button_icon'] = $review_feed['button_icon'];
565 } else {
566 $sanitized['review_feed']['button_icon'] = null;
567 }
568 }
569
570 // Status - must be 'active' or 'inactive'
571 $sanitized['status'] = isset($settings['status']) && in_array($settings['status'], ['active', 'inactive'], true)
572 ? $settings['status']
573 : $defaults['status'];
574
575 return $sanitized;
576 }
577
578 /**
579 * Check if user can use a specific premium feature
580 *
581 * @since 2.5.0
582 * @internal Reserved for future feature-gating implementation. Currently unused but
583 * provides the infrastructure for granular tier-based feature restrictions.
584 * @param string $feature Feature key to check
585 * @return bool Whether the feature is available
586 */
587 public static function can_use_feature(string $feature): bool
588 {
589 // Determine tier: pro_plus > pro > free
590 $tier = Util::sbr_is_pro_plus() ? 'pro_plus' : (Util::sbr_is_pro() ? 'pro' : 'free');
591
592 // Feature requirements by tier
593 $feature_tiers = [
594 'variations_v2_v3' => ['pro', 'pro_plus'],
595 'dark_theme' => ['pro', 'pro_plus'],
596 'minimal_theme' => ['pro', 'pro_plus'],
597 'custom_accent_color' => ['pro', 'pro_plus'],
598 'recent_reviews' => ['pro', 'pro_plus'],
599 'multiple_popups' => ['pro_plus'],
600 'page_targeting' => ['pro_plus'],
601 'remove_branding' => ['pro_plus'],
602 ];
603
604 // Unknown features are available to all
605 if (!isset($feature_tiers[$feature])) {
606 return true;
607 }
608
609 return in_array($tier, $feature_tiers[$feature], true);
610 }
611
612 /**
613 * Get a single popup by ID
614 *
615 * @since 2.5.0
616 * @param int $id Popup post ID
617 * @return array|null Popup data or null if not found
618 */
619 public static function get_popup(int $id): ?array
620 {
621 $post = get_post($id);
622
623 if (!$post || $post->post_type !== self::POST_TYPE) {
624 return null;
625 }
626
627 $settings = json_decode($post->post_content, true);
628 if (!is_array($settings)) {
629 $settings = [];
630 }
631
632 // Use array_replace_recursive for proper nested array merging
633 // This ensures new nested defaults are applied to older saved popups
634 $merged_settings = array_replace_recursive(self::get_defaults(), $settings);
635
636 // Convert locations to visibility structure for React UI compatibility
637 $merged_settings = self::convert_locations_to_visibility($merged_settings);
638
639 return [
640 'id' => $post->ID,
641 'name' => $post->post_title,
642 'settings' => $merged_settings,
643 'status' => $post->post_status === 'publish' ? 'active' : 'inactive',
644 'created' => $post->post_date,
645 'modified' => $post->post_modified,
646 ];
647 }
648
649 /**
650 * Get list of popups
651 *
652 * @since 2.5.0
653 * @param array $args Query arguments
654 * @return array List of popups
655 */
656 public static function get_popups(array $args = []): array
657 {
658 $defaults = [
659 'posts_per_page' => 20,
660 'paged' => 1,
661 'orderby' => 'ID', // Use ID for consistent ordering (newest first, never changes)
662 'order' => 'DESC',
663 'post_status' => ['publish', 'draft'],
664 ];
665
666 $query_args = array_merge($defaults, $args, [
667 'post_type' => self::POST_TYPE,
668 ]);
669
670 $query = new \WP_Query($query_args);
671 $popups = [];
672
673 foreach ($query->posts as $post) {
674 $popup = self::get_popup($post->ID);
675 if ($popup) {
676 $popups[] = $popup;
677 }
678 }
679
680 return [
681 'popups' => $popups,
682 'total' => $query->found_posts,
683 'total_pages' => $query->max_num_pages,
684 ];
685 }
686
687 /**
688 * Save (create or update) a popup
689 *
690 * @since 2.5.0
691 * @param array $data Popup data
692 * @return int|\WP_Error Post ID on success, WP_Error on failure
693 */
694 public static function save_popup(array $data)
695 {
696 $id = isset($data['id']) ? absint($data['id']) : 0;
697 $name = isset($data['name']) ? sanitize_text_field($data['name']) : '';
698 $settings = isset($data['settings']) && is_array($data['settings']) ? $data['settings'] : [];
699
700 // For existing popups, ALWAYS preserve the current status
701 // Status changes should only happen via the dedicated ajax_toggle_status endpoint
702 // This prevents active popups from being accidentally set to inactive on save
703 $existing_post = null;
704 if ($id > 0) {
705 $existing_post = get_post($id);
706 if (!$existing_post || $existing_post->post_type !== self::POST_TYPE) {
707 return new \WP_Error('invalid_popup', __('Popup not found.', 'reviews-feed'));
708 }
709
710 // Always use existing post status - ignore whatever the frontend sends
711 $settings['status'] = $existing_post->post_status === 'publish' ? 'active' : 'inactive';
712 } else {
713 // New popups MUST start as inactive (draft)
714 // This prevents bypassing the intended workflow via direct AJAX calls
715 $settings['status'] = 'inactive';
716 }
717
718 // Sanitize settings
719 $settings = self::sanitize_settings($settings);
720
721 // Determine post status from settings
722 $post_status = ($settings['status'] ?? 'active') === 'active' ? 'publish' : 'draft';
723
724 $post_data = [
725 'post_type' => self::POST_TYPE,
726 'post_title' => $name ?: __('Review Alert', 'reviews-feed'),
727 'post_content' => wp_json_encode($settings),
728 'post_status' => $post_status,
729 ];
730
731 if ($id > 0) {
732 // Update existing popup
733 $post_data['ID'] = $id;
734 $result = wp_update_post($post_data, true);
735 } else {
736 // Create new popup
737 $result = wp_insert_post($post_data, true);
738 }
739
740 return $result;
741 }
742
743 /**
744 * Delete a popup
745 *
746 * @since 2.5.0
747 * @param int $id Popup post ID
748 * @return bool True on success, false on failure
749 */
750 public static function delete_popup(int $id): bool
751 {
752 $post = get_post($id);
753
754 if (!$post || $post->post_type !== self::POST_TYPE) {
755 return false;
756 }
757
758 $result = wp_delete_post($id, true);
759 return $result !== false && $result !== null;
760 }
761
762 /**
763 * Duplicate a popup
764 *
765 * @since 2.5.0
766 * @param int $id Popup post ID to duplicate
767 * @return int|\WP_Error New post ID on success, WP_Error on failure
768 */
769 public static function duplicate_popup(int $id)
770 {
771 $original = self::get_popup($id);
772
773 if (!$original) {
774 return new \WP_Error('invalid_popup', __('Popup not found.', 'reviews-feed'));
775 }
776
777 // Duplicate settings but force status to inactive (draft)
778 // Duplicated popups should not go live immediately
779 $duplicated_settings = $original['settings'];
780 $duplicated_settings['status'] = 'inactive';
781
782 return self::save_popup([
783 'name' => sprintf('%s %s', $original['name'], __('(copy)', 'reviews-feed')),
784 'settings' => $duplicated_settings,
785 ]);
786 }
787
788 /**
789 * AJAX handler: Save popup
790 *
791 * @since 2.5.0
792 * @return void
793 */
794 public static function ajax_save(): void
795 {
796 check_ajax_referer('sbr-admin', 'nonce');
797
798 if (! sbr_current_user_can('manage_reviews_feed_options')) {
799 wp_send_json_error(['message' => __('Unauthorized access.', 'reviews-feed')], 403);
800 }
801
802 $id = isset($_POST['id']) ? absint($_POST['id']) : 0;
803 $name = isset($_POST['name']) ? sanitize_text_field(wp_unslash($_POST['name'])) : '';
804 $settings = isset($_POST['settings']) ? json_decode(wp_unslash($_POST['settings']), true) : [];
805
806 if (!is_array($settings)) {
807 wp_send_json_error(['message' => __('Invalid settings data.', 'reviews-feed')], 400);
808 }
809
810 // Enforce tier restrictions
811 $is_pro = Util::sbr_is_pro();
812 $is_pro_plus = Util::sbr_is_pro_plus();
813
814 // Check popup limit for non-Pro Plus users (only 1 popup allowed)
815 if ($id === 0 && !$is_pro_plus) {
816 $existing = self::get_popups(['posts_per_page' => 1]);
817 if ($existing['total'] >= 1) {
818 wp_send_json_error([
819 'message' => __('Upgrade to Pro Plus to create multiple review alerts.', 'reviews-feed'),
820 'upsell_key' => 'reviewAlertMultiple',
821 ], 403);
822 }
823 }
824
825 // Enforce Pro-only settings for free users
826 if (!$is_pro) {
827 // Free users can only use 'light' theme (dark theme is Pro)
828 if (isset($settings['theme']) && $settings['theme'] !== 'light') {
829 $settings['theme'] = 'light';
830 }
831 // Free users can only use 'aggregate' popup type
832 if (isset($settings['popup_type']) && $settings['popup_type'] !== 'aggregate') {
833 $settings['popup_type'] = 'aggregate';
834 }
835 }
836
837 // Enforce Pro Plus-only settings
838 if (!$is_pro_plus) {
839 // Non-Pro Plus users cannot hide branding
840 if (isset($settings['content']['show_powered_by'])) {
841 $settings['content']['show_powered_by'] = true;
842 }
843 if (isset($settings['review_feed']['show_powered_by'])) {
844 $settings['review_feed']['show_powered_by'] = true;
845 }
846 }
847
848 $result = self::save_popup([
849 'id' => $id,
850 'name' => $name,
851 'settings' => $settings,
852 ]);
853
854 if (is_wp_error($result)) {
855 wp_send_json_error(['message' => $result->get_error_message()], 400);
856 }
857
858 $popup = self::get_popup($result);
859
860 wp_send_json_success([
861 'popup' => $popup,
862 'message' => $id > 0 ? __('Popup updated.', 'reviews-feed') : __('Popup created.', 'reviews-feed'),
863 ]);
864 }
865
866 /**
867 * AJAX handler: Delete popup
868 *
869 * @since 2.5.0
870 * @return void
871 */
872 public static function ajax_delete(): void
873 {
874 check_ajax_referer('sbr-admin', 'nonce');
875
876 if (! sbr_current_user_can('manage_reviews_feed_options')) {
877 wp_send_json_error(['message' => __('Unauthorized access.', 'reviews-feed')], 403);
878 }
879
880 $id = isset($_POST['id']) ? absint($_POST['id']) : 0;
881
882 if ($id <= 0) {
883 wp_send_json_error(['message' => __('Invalid popup ID.', 'reviews-feed')], 400);
884 }
885
886 $result = self::delete_popup($id);
887
888 if (!$result) {
889 wp_send_json_error(['message' => __('Failed to delete popup.', 'reviews-feed')], 400);
890 }
891
892 // Return updated list
893 $popups = self::get_popups();
894
895 wp_send_json_success([
896 'popupsList' => $popups['popups'],
897 'popupsCount' => $popups['total'],
898 'message' => __('Popup deleted.', 'reviews-feed'),
899 ]);
900 }
901
902 /**
903 * AJAX handler: Bulk delete popups
904 *
905 * @since 2.5.0
906 * @return void
907 */
908 public static function ajax_bulk_delete(): void
909 {
910 check_ajax_referer('sbr-admin', 'nonce');
911
912 if (! sbr_current_user_can('manage_reviews_feed_options')) {
913 wp_send_json_error(['message' => __('Unauthorized access.', 'reviews-feed')], 403);
914 }
915
916 // Get array of IDs from POST
917 // FormData.append converts arrays to comma-separated strings (e.g., "243" or "243,244")
918 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Sanitized below
919 // @phpstan-ignore-next-line (wp_unslash can return string|array depending on input)
920 $ids_raw = isset($_POST['ids']) ? wp_unslash($_POST['ids']) : '';
921
922 // Handle different formats:
923 // 1. Comma-separated string from FormData: "243" or "243,244"
924 // 2. JSON string: "[243, 244]"
925 // 3. PHP array from standard form submission: ['243', '244']
926 $ids = [];
927 // @phpstan-ignore-next-line (wp_unslash can return array for $_POST['ids[]'] form fields)
928 if (is_array($ids_raw)) {
929 $ids = $ids_raw;
930 } elseif (is_string($ids_raw)) {
931 // Try JSON decode first
932 $json_decoded = json_decode($ids_raw, true);
933 if (is_array($json_decoded)) {
934 $ids = $json_decoded;
935 } else {
936 // Fall back to comma-separated string
937 $ids = array_filter(
938 explode(',', $ids_raw),
939 function ($val) {
940 return strlen($val) > 0;
941 }
942 );
943 }
944 }
945
946 if (empty($ids)) {
947 wp_send_json_error(['message' => __('No popups selected.', 'reviews-feed')], 400);
948 }
949
950 // Sanitize all IDs
951 $ids = array_map('absint', $ids);
952 $ids = array_filter($ids, function ($id) {
953 return $id > 0;
954 });
955
956 if (empty($ids)) {
957 wp_send_json_error(['message' => __('Invalid popup IDs.', 'reviews-feed')], 400);
958 }
959
960 // Delete each popup
961 $deleted_count = 0;
962 foreach ($ids as $id) {
963 if (self::delete_popup($id)) {
964 $deleted_count++;
965 }
966 }
967
968 // Return updated list
969 $popups = self::get_popups();
970
971 wp_send_json_success([
972 'popupsList' => $popups['popups'],
973 'popupsCount' => $popups['total'],
974 'deletedCount' => $deleted_count,
975 'message' => sprintf(
976 /* translators: %d: number of deleted popups */
977 _n('%d popup deleted.', '%d popups deleted.', $deleted_count, 'reviews-feed'),
978 $deleted_count
979 ),
980 ]);
981 }
982
983 /**
984 * AJAX handler: List popups
985 *
986 * @since 2.5.0
987 * @return void
988 */
989 public static function ajax_list(): void
990 {
991 check_ajax_referer('sbr-admin', 'nonce');
992
993 if (! sbr_current_user_can('manage_reviews_feed_options')) {
994 wp_send_json_error(['message' => __('Unauthorized access.', 'reviews-feed')], 403);
995 }
996
997 $page = isset($_POST['page']) ? absint($_POST['page']) : 1;
998
999 $popups = self::get_popups([
1000 'paged' => $page,
1001 ]);
1002
1003 wp_send_json_success([
1004 'popupsList' => $popups['popups'],
1005 'popupsCount' => $popups['total'],
1006 'totalPages' => $popups['total_pages'],
1007 ]);
1008 }
1009
1010 /**
1011 * AJAX handler: Duplicate popup
1012 *
1013 * @since 2.5.0
1014 * @return void
1015 */
1016 public static function ajax_duplicate(): void
1017 {
1018 check_ajax_referer('sbr-admin', 'nonce');
1019
1020 if (! sbr_current_user_can('manage_reviews_feed_options')) {
1021 wp_send_json_error(['message' => __('Unauthorized access.', 'reviews-feed')], 403);
1022 }
1023
1024 $id = isset($_POST['id']) ? absint($_POST['id']) : 0;
1025
1026 if ($id <= 0) {
1027 wp_send_json_error(['message' => __('Invalid popup ID.', 'reviews-feed')], 400);
1028 }
1029
1030 // Check popup limit for non-Pro Plus users (only 1 popup allowed)
1031 $is_pro_plus = Util::sbr_is_pro_plus();
1032 if (!$is_pro_plus) {
1033 $existing = self::get_popups(['posts_per_page' => 1]);
1034 if ($existing['total'] >= 1) {
1035 wp_send_json_error([
1036 'message' => __('Upgrade to Pro Plus to create multiple review alerts.', 'reviews-feed'),
1037 'upsell_key' => 'reviewAlertMultiple',
1038 ], 403);
1039 }
1040 }
1041
1042 $result = self::duplicate_popup($id);
1043
1044 if (is_wp_error($result)) {
1045 wp_send_json_error(['message' => $result->get_error_message()], 400);
1046 }
1047
1048 // Return updated list
1049 $popups = self::get_popups();
1050
1051 wp_send_json_success([
1052 'popupsList' => $popups['popups'],
1053 'popupsCount' => $popups['total'],
1054 'newPopupId' => $result,
1055 'message' => __('Popup duplicated.', 'reviews-feed'),
1056 ]);
1057 }
1058
1059 /**
1060 * AJAX handler: Toggle popup status (active/inactive)
1061 *
1062 * @since 2.5.0
1063 * @return void
1064 */
1065 public static function ajax_toggle_status(): void
1066 {
1067 check_ajax_referer('sbr-admin', 'nonce');
1068
1069 if (! sbr_current_user_can('manage_reviews_feed_options')) {
1070 wp_send_json_error(['message' => __('Unauthorized access.', 'reviews-feed')], 403);
1071 }
1072
1073 $id = isset($_POST['id']) ? absint($_POST['id']) : 0;
1074
1075 if ($id <= 0) {
1076 wp_send_json_error(['message' => __('Invalid popup ID.', 'reviews-feed')], 400);
1077 }
1078
1079 // Get current popup
1080 $popup = self::get_popup($id);
1081 if (!$popup) {
1082 wp_send_json_error(['message' => __('Popup not found.', 'reviews-feed')], 404);
1083 }
1084
1085 // Toggle status
1086 $new_status = $popup['status'] === 'active' ? 'inactive' : 'active';
1087 $new_post_status = $new_status === 'active' ? 'publish' : 'draft';
1088
1089 // Update post status
1090 $result = wp_update_post([
1091 'ID' => $id,
1092 'post_status' => $new_post_status,
1093 ], true);
1094
1095 if (is_wp_error($result)) {
1096 wp_send_json_error(['message' => $result->get_error_message()], 400);
1097 }
1098
1099 // Return updated list
1100 $popups = self::get_popups();
1101
1102 wp_send_json_success([
1103 'popupsList' => $popups['popups'],
1104 'popupsCount' => $popups['total'],
1105 'newStatus' => $new_status,
1106 'message' => $new_status === 'active'
1107 ? __('Popup activated.', 'reviews-feed')
1108 : __('Popup deactivated.', 'reviews-feed'),
1109 ]);
1110 }
1111
1112 /**
1113 * Get active popups for frontend display
1114 *
1115 * @since 2.5.0
1116 * @return array List of active popups
1117 */
1118 public static function get_active_popups(): array
1119 {
1120 $result = self::get_popups([
1121 'posts_per_page' => -1,
1122 'post_status' => 'publish',
1123 ]);
1124
1125 return $result['popups'];
1126 }
1127
1128 /**
1129 * AJAX handler: Get preview reviews for popup editor
1130 *
1131 * Fetches reviews based on popup settings (sources, filters, sort)
1132 * for live preview in the customizer.
1133 *
1134 * @since 2.5.0
1135 * @return void
1136 */
1137 public static function ajax_preview_reviews(): void
1138 {
1139 check_ajax_referer('sbr-admin', 'nonce');
1140
1141 if (! sbr_current_user_can('manage_reviews_feed_options')) {
1142 wp_send_json_error(['message' => __('Unauthorized access.', 'reviews-feed')], 403);
1143 }
1144
1145 // Get settings from POST
1146 $settings = isset($_POST['settings']) ? json_decode(wp_unslash($_POST['settings']), true) : [];
1147
1148 if (!is_array($settings)) {
1149 wp_send_json_error(['message' => __('Invalid settings data.', 'reviews-feed')], 400);
1150 }
1151
1152 // Sanitize settings
1153 $settings = self::sanitize_settings($settings);
1154
1155 // Get reviews using the same logic as frontend
1156 $result = self::get_preview_reviews($settings);
1157
1158 wp_send_json_success([
1159 'reviews' => $result['reviews'],
1160 'totalReviews' => $result['totalReviews'],
1161 'unfilteredTotal' => $result['unfilteredTotal'],
1162 'averageRating' => $result['averageRating'],
1163 ]);
1164 }
1165
1166 /**
1167 * Get reviews for popup preview
1168 *
1169 * Uses the same logic as SBR_Review_Alert_Frontend::get_reviews_for_popup()
1170 * but accessible as a static method for the AJAX handler.
1171 *
1172 * @since 2.5.0
1173 * @param array $popup_settings Popup settings with sources, filters, sort
1174 * @return array{reviews: array, totalReviews: int, unfilteredTotal: int, averageRating: float} Array containing reviews, filtered count, unfiltered total, and average rating
1175 */
1176 public static function get_preview_reviews(array $popup_settings): array
1177 {
1178 $source_db_ids = $popup_settings['sources'] ?? [];
1179
1180 // Filter out invalid values (0, empty strings, non-numeric)
1181 // This handles edge cases from failed conversions or corrupted data
1182 $source_db_ids = array_filter($source_db_ids, function ($id) {
1183 return is_numeric($id) && (int) $id > 0;
1184 });
1185 $source_db_ids = array_values($source_db_ids); // Re-index array
1186
1187 // If no sources specified, return empty - no fallback to all sources
1188 // User must explicitly select sources for the popup
1189 if (empty($source_db_ids)) {
1190 return [
1191 'reviews' => [],
1192 'totalReviews' => 0,
1193 'unfilteredTotal' => 0,
1194 'averageRating' => 0,
1195 ];
1196 }
1197
1198 // Convert database IDs to account_ids for Feed class compatibility
1199 // Following PR #418 pattern: store database IDs to avoid URL encoding issues
1200 $source_account_ids = self::convert_db_ids_to_account_ids($source_db_ids);
1201
1202 if (empty($source_account_ids)) {
1203 return [
1204 'reviews' => [],
1205 'totalReviews' => 0,
1206 'unfilteredTotal' => 0,
1207 'averageRating' => 0,
1208 ];
1209 }
1210
1211 // Get filter settings
1212 $filters = $popup_settings['filters'] ?? [];
1213 $sort = $popup_settings['sort'] ?? [];
1214
1215 // Use Pro Feed if available, otherwise Common Feed
1216 $feed_class = Util::sbr_is_pro()
1217 ? '\\SmashBalloon\\Reviews\\Pro\\Feed'
1218 : '\\SmashBalloon\\Reviews\\Common\\Feed';
1219
1220 // First, fetch ALL reviews WITHOUT user filters to get unfiltered total
1221 // This gives us the total "complete" reviews before filtering
1222 $unfiltered_settings = array_merge(sbr_settings_defaults(), [
1223 'sources' => $source_account_ids,
1224 'numPostDesktop' => 500,
1225 'numPostTablet' => 500,
1226 'numPostMobile' => 500,
1227 // No filters applied - we want all reviews from sources
1228 'includedStarFilters' => [],
1229 'includeWords' => '',
1230 'excludeWords' => '',
1231 'filterCharCountMin' => 0,
1232 'filterCharCountMax' => '',
1233 'sortByDateEnabled' => true,
1234 'sortByDate' => 'latest',
1235 'sortByRatingEnabled' => false,
1236 'sortByRating' => '',
1237 'sortRandomEnabled' => false,
1238 ]);
1239
1240 $unfiltered_cache_id = 'popup_preview_unfiltered_' . md5(wp_json_encode(['sources' => $source_db_ids]));
1241 $unfiltered_feed = new $feed_class($unfiltered_settings, $unfiltered_cache_id, new FeedCache($unfiltered_cache_id, 300));
1242 $unfiltered_feed->init();
1243 $unfiltered_feed->get_set_cache();
1244 $unfiltered_reviews = $unfiltered_feed->get_post_set_page();
1245
1246 if (isset($unfiltered_reviews['data'])) {
1247 $unfiltered_reviews = $unfiltered_reviews['data'];
1248 }
1249
1250 // Count complete reviews (with rating, text, name) for unfiltered total
1251 $unfiltered_total = self::count_complete_reviews($unfiltered_reviews);
1252
1253 // Now fetch filtered reviews with user's filter settings
1254 $feed_settings = array_merge(sbr_settings_defaults(), [
1255 'sources' => $source_account_ids,
1256 'numPostDesktop' => 500,
1257 'numPostTablet' => 500,
1258 'numPostMobile' => 500,
1259 'includedStarFilters' => $filters['includedStarFilters'] ?? [],
1260 'includeWords' => $filters['includeWords'] ?? '',
1261 'excludeWords' => $filters['excludeWords'] ?? '',
1262 'filterCharCountMin' => $filters['filterCharCountMin'] ?? 0,
1263 'filterCharCountMax' => $filters['filterCharCountMax'] ?? '',
1264 'sortByDateEnabled' => $sort['sortByDateEnabled'] ?? true,
1265 'sortByDate' => $sort['sortByDate'] ?? 'latest',
1266 'sortByRatingEnabled' => $sort['sortByRatingEnabled'] ?? false,
1267 'sortByRating' => $sort['sortByRating'] ?? '',
1268 'sortRandomEnabled' => $sort['sortRandomEnabled'] ?? false,
1269 ]);
1270
1271 // Create unique cache ID for preview (short TTL for admin preview)
1272 $cache_key = md5(wp_json_encode([
1273 'sources' => $source_db_ids,
1274 'filters' => $filters,
1275 'sort' => $sort,
1276 'preview' => true,
1277 ]));
1278 $cache_id = 'popup_preview_' . $cache_key;
1279
1280 $feed = new $feed_class($feed_settings, $cache_id, new FeedCache($cache_id, 300)); // 5 min cache for preview
1281
1282 $feed->init();
1283 $feed->get_set_cache();
1284
1285 $all_reviews = $feed->get_post_set_page();
1286
1287 // Handle nested data structure
1288 if (isset($all_reviews['data'])) {
1289 $all_reviews = $all_reviews['data'];
1290 }
1291
1292 // Filter for complete reviews and format for preview
1293 // Uses same logic as SBR_Review_Alert_Frontend::filter_complete_reviews()
1294 $complete_reviews = [];
1295 $total_matching = 0;
1296 $total_rating = 0;
1297
1298 // Get provider filter - if explicitly set, only show reviews from these providers
1299 // Note: null/not set = no filter (show all), empty array = show none (all deselected)
1300 $allowed_providers = $filters['providers'] ?? null;
1301 $has_provider_filter = isset($filters['providers']);
1302
1303 foreach ($all_reviews as $review) {
1304 // Filter by provider if provider filter is explicitly set
1305 // Extract provider and reviewer safely (avoid PHP 8.0+ warnings on non-array access)
1306 $provider = $review['provider'] ?? '';
1307 $review_provider = is_array($provider) ? ($provider['name'] ?? '') : $provider;
1308 $reviewer = $review['reviewer'] ?? [];
1309
1310 if ($has_provider_filter) {
1311 // If providers array is empty, no reviews should show (all providers deselected)
1312 if (empty($allowed_providers)) {
1313 continue;
1314 }
1315 if (!in_array($review_provider, $allowed_providers, true)) {
1316 continue;
1317 }
1318 }
1319
1320 // Must have valid rating (1-5)
1321 $rating = isset($review['rating']) ? (int) $review['rating'] : 0;
1322 if ($rating < 1 || $rating > 5) {
1323 continue;
1324 }
1325
1326 // Must have review text (non-empty)
1327 $text = trim($review['text'] ?? '');
1328 if (empty($text)) {
1329 continue;
1330 }
1331
1332 // Must have reviewer name (not empty or "Anonymous")
1333 $reviewer_name = is_array($reviewer) ? trim($reviewer['name'] ?? '') : '';
1334 if (empty($reviewer_name) || strtolower($reviewer_name) === 'anonymous') {
1335 continue;
1336 }
1337
1338 // Count all matching reviews for total and sum ratings
1339 $total_matching++;
1340 $total_rating += $rating;
1341
1342 // Add to preview array up to the shared frontend cap.
1343 if (count($complete_reviews) < SBR_Review_Alert_Frontend::MAX_POPUP_REVIEWS) {
1344 // Decode HTML entities for special characters (e.g., &amp; -> &, &#039; -> ')
1345 $decoded_text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
1346 $decoded_name = html_entity_decode($reviewer_name, ENT_QUOTES | ENT_HTML5, 'UTF-8');
1347
1348 $reviewer_avatar = is_array($reviewer) ? ($reviewer['avatar'] ?? '') : '';
1349 $complete_reviews[] = [
1350 'id' => $review['review_id'] ?? $review['id'] ?? uniqid(),
1351 'reviewer' => [
1352 'name' => $decoded_name,
1353 'avatar' => $reviewer_avatar,
1354 ],
1355 'rating' => (int) $rating,
1356 'text' => $decoded_text,
1357 'relativeDate' => self::get_relative_date($review['time'] ?? 0),
1358 'provider' => $review_provider ?: 'unknown',
1359 ];
1360 }
1361 }
1362
1363 // Headline total + average from the feed-header metadata, via the shared
1364 // helper the frontend render path uses too, so the two can't drift. SMASH-1616.
1365 // Backfill from the FULL cached set (get_posts()), matching FeedDisplay and
1366 // the frontend path — not the page slice — so the preview headline can't
1367 // under-count providers with a zero API total.
1368 [$total_reviews, $average_rating] = SBR_Review_Alert_Frontend::resolve_header_totals(
1369 $feed,
1370 $feed->get_posts(),
1371 $total_matching,
1372 $total_rating
1373 );
1374
1375 return [
1376 'reviews' => $complete_reviews,
1377 'totalReviews' => $total_reviews,
1378 'unfilteredTotal' => $unfiltered_total,
1379 'averageRating' => $average_rating,
1380 ];
1381 }
1382
1383 /**
1384 * Count complete reviews (have rating 1-5, non-empty text, valid reviewer name)
1385 *
1386 * @since 2.5.0
1387 * @param array $reviews Array of reviews
1388 * @return int Count of complete reviews
1389 */
1390 private static function count_complete_reviews(array $reviews): int
1391 {
1392 $count = 0;
1393
1394 foreach ($reviews as $review) {
1395 // Must have valid rating (1-5)
1396 $rating = isset($review['rating']) ? (int) $review['rating'] : 0;
1397 if ($rating < 1 || $rating > 5) {
1398 continue;
1399 }
1400
1401 // Must have review text
1402 $text = trim($review['text'] ?? '');
1403 if (empty($text)) {
1404 continue;
1405 }
1406
1407 // Must have reviewer name (not empty or "Anonymous")
1408 $reviewer = $review['reviewer'] ?? [];
1409 $reviewer_name = is_array($reviewer) ? trim($reviewer['name'] ?? '') : '';
1410 if (empty($reviewer_name) || strtolower($reviewer_name) === 'anonymous') {
1411 continue;
1412 }
1413
1414 $count++;
1415 }
1416
1417 return $count;
1418 }
1419
1420 /**
1421 * Convert database source IDs to account_ids for Feed class compatibility
1422 *
1423 * Review Alerts stores database IDs instead of account_ids to avoid URL encoding
1424 * issues with special characters (Danish æ, ø, å). This follows PR #418 pattern.
1425 *
1426 * @since 2.5.0
1427 * @param array $db_ids Array of database source IDs (integers)
1428 * @return array Array of account_ids (strings)
1429 */
1430 private static function convert_db_ids_to_account_ids(array $db_ids): array
1431 {
1432 if (empty($db_ids)) {
1433 return [];
1434 }
1435
1436 global $wpdb;
1437 $sources_table = $wpdb->prefix . 'sbr_sources';
1438
1439 // Convert to integers for safety
1440 $db_ids = array_map('absint', $db_ids);
1441 $placeholders = implode(',', array_fill(0, count($db_ids), '%d'));
1442
1443 // Query account_ids for given database IDs
1444 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table name and placeholders are safely generated
1445 $results = $wpdb->get_col($wpdb->prepare("SELECT account_id FROM {$sources_table} WHERE id IN ({$placeholders})", ...$db_ids));
1446
1447 return $results ?: [];
1448 }
1449
1450 /**
1451 * Convert account_ids to database IDs for backward compatibility
1452 *
1453 * Existing popups may have account_id strings stored in settings.sources.
1454 * This converts them to database IDs (integers) for the new format.
1455 *
1456 * @since 2.5.0
1457 * @param array $account_ids Array of account_id strings
1458 * @return array Array of database IDs (integers)
1459 */
1460 private static function convert_account_ids_to_db_ids(array $account_ids): array
1461 {
1462 if (empty($account_ids)) {
1463 return [];
1464 }
1465
1466 global $wpdb;
1467 $sources_table = $wpdb->prefix . 'sbr_sources';
1468
1469 // Sanitize account_ids
1470 $account_ids = array_map('sanitize_text_field', $account_ids);
1471 $placeholders = implode(',', array_fill(0, count($account_ids), '%s'));
1472
1473 // Query database IDs for given account_ids
1474 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table name and placeholders are safely generated
1475 $results = $wpdb->get_col($wpdb->prepare("SELECT id FROM {$sources_table} WHERE account_id IN ({$placeholders})", ...$account_ids));
1476
1477 return array_map('absint', $results ?: []);
1478 }
1479
1480 /**
1481 * Convert old locations structure to new visibility structure for React UI
1482 *
1483 * Old format (locations):
1484 * - all_pages: bool
1485 * - exclude_pages: [{id, type}] or [id, id, ...]
1486 * - specific_pages: [{id, type}] or [id, id, ...]
1487 *
1488 * New format (visibility):
1489 * - display_on: 'all' | 'specific'
1490 * - excluded: {pages: [], categories: [], custom_post_types: []}
1491 * - specific: {pages: [], categories: [], custom_post_types: []}
1492 *
1493 * @since 2.5.0
1494 * @param array $settings Popup settings
1495 * @return array Settings with visibility structure
1496 */
1497 private static function convert_locations_to_visibility(array $settings): array
1498 {
1499 // Check if old locations format has data that needs conversion
1500 $locations = $settings['locations'] ?? [];
1501 $has_old_data = !empty($locations['exclude_pages']) || !empty($locations['specific_pages']);
1502
1503 // Only convert if there's old data - new data uses visibility structure directly
1504 if (!$has_old_data) {
1505 return $settings;
1506 }
1507
1508 // Initialize new visibility structure
1509 $visibility = [
1510 'display_on' => !empty($locations['all_pages']) ? 'all' : 'specific',
1511 'excluded' => [
1512 'pages' => [],
1513 'categories' => [],
1514 'custom_post_types' => [],
1515 ],
1516 'specific' => [
1517 'pages' => [],
1518 'categories' => [],
1519 'custom_post_types' => [],
1520 ],
1521 ];
1522
1523 // Convert from old locations format
1524 if (is_array($locations)) {
1525 $locations = $settings['locations'];
1526
1527 // Convert all_pages boolean to display_on string
1528 $visibility['display_on'] = !empty($locations['all_pages']) ? 'all' : 'specific';
1529
1530 // Convert exclude_pages - group by type
1531 if (!empty($locations['exclude_pages']) && is_array($locations['exclude_pages'])) {
1532 foreach ($locations['exclude_pages'] as $item) {
1533 self::add_item_to_visibility_group($item, $visibility['excluded']);
1534 }
1535 }
1536
1537 // Convert specific_pages - group by type
1538 if (!empty($locations['specific_pages']) && is_array($locations['specific_pages'])) {
1539 foreach ($locations['specific_pages'] as $item) {
1540 self::add_item_to_visibility_group($item, $visibility['specific']);
1541 }
1542 }
1543 }
1544
1545 $settings['visibility'] = $visibility;
1546 return $settings;
1547 }
1548
1549 /**
1550 * Add item to visibility group (excluded or specific)
1551 *
1552 * @since 2.5.0
1553 * @param int|array $item Item ID or {id, type} object
1554 * @param array &$group Reference to visibility group (excluded or specific)
1555 */
1556 private static function add_item_to_visibility_group($item, array &$group): void
1557 {
1558 // Handle legacy format: just an integer ID (assume it's a page)
1559 if (is_numeric($item)) {
1560 $id = absint($item);
1561 if ($id > 0 && !in_array($id, $group['pages'], true)) {
1562 $group['pages'][] = $id;
1563 }
1564 return;
1565 }
1566
1567 // Handle new format: {id, type} object
1568 if (!is_array($item) || !isset($item['id'])) {
1569 return;
1570 }
1571
1572 $type = $item['type'] ?? 'page';
1573
1574 // Normalize 'post' to 'page' (WordPress posts are treated as pages in visibility)
1575 if ($type === 'post') {
1576 $type = 'page';
1577 }
1578
1579 switch ($type) {
1580 case 'page':
1581 $id = absint($item['id']);
1582 if ($id > 0 && !in_array($id, $group['pages'], true)) {
1583 $group['pages'][] = $id;
1584 }
1585 break;
1586
1587 case 'category':
1588 $id = absint($item['id']);
1589 if ($id > 0 && !in_array($id, $group['categories'], true)) {
1590 $group['categories'][] = $id;
1591 }
1592 break;
1593
1594 case 'post_type':
1595 // For post types, the ID is actually the slug
1596 $slug = is_numeric($item['id']) ? '' : sanitize_key($item['id']);
1597 if (!empty($slug) && !in_array($slug, $group['custom_post_types'], true)) {
1598 $group['custom_post_types'][] = $slug;
1599 }
1600 break;
1601 }
1602 }
1603
1604 /**
1605 * Convert timestamp to relative date string
1606 *
1607 * @since 2.5.0
1608 * @param int $timestamp Unix timestamp
1609 * @return string Relative date (e.g., "3d ago", "1w ago")
1610 */
1611 private static function get_relative_date(int $timestamp): string
1612 {
1613 if ($timestamp <= 0) {
1614 return '';
1615 }
1616
1617 $diff = time() - $timestamp;
1618
1619 if ($diff < 60) {
1620 return __('just now', 'reviews-feed');
1621 } elseif ($diff < 3600) {
1622 $mins = (int) floor($diff / 60);
1623 return sprintf(_n('%dm ago', '%dm ago', $mins, 'reviews-feed'), $mins);
1624 } elseif ($diff < 86400) {
1625 $hours = (int) floor($diff / 3600);
1626 return sprintf(_n('%dh ago', '%dh ago', $hours, 'reviews-feed'), $hours);
1627 } elseif ($diff < 604800) {
1628 $days = (int) floor($diff / 86400);
1629 return sprintf(_n('%dd ago', '%dd ago', $days, 'reviews-feed'), $days);
1630 } elseif ($diff < 2592000) {
1631 $weeks = (int) floor($diff / 604800);
1632 return sprintf(_n('%dw ago', '%dw ago', $weeks, 'reviews-feed'), $weeks);
1633 } elseif ($diff < 31536000) {
1634 $months = (int) floor($diff / 2592000);
1635 return sprintf(_n('%dmo ago', '%dmo ago', $months, 'reviews-feed'), $months);
1636 } else {
1637 $years = (int) floor($diff / 31536000);
1638 return sprintf(_n('%dy ago', '%dy ago', $years, 'reviews-feed'), $years);
1639 }
1640 }
1641
1642 /**
1643 * Sanitize visibility pages array
1644 * Handles both old format (ID-only) and new format (objects with metadata)
1645 *
1646 * @since 2.5.0
1647 * @param array $pages Array of pages (IDs or objects)
1648 * @return array Sanitized pages array
1649 */
1650 private static function sanitize_visibility_pages(array $pages): array
1651 {
1652 $sanitized = [];
1653 foreach ($pages as $page) {
1654 if (is_array($page)) {
1655 // New format: {id, title, url}
1656 $item = [
1657 'id' => isset($page['id']) ? absint($page['id']) : 0,
1658 ];
1659 if (isset($page['title'])) {
1660 $item['title'] = sanitize_text_field($page['title']);
1661 }
1662 if (isset($page['url'])) {
1663 $item['url'] = esc_url_raw($page['url']);
1664 }
1665 // ID 0 is valid (homepage)
1666 if ($item['id'] >= 0) {
1667 $sanitized[] = $item;
1668 }
1669 } else {
1670 // Old format: just ID
1671 $id = absint($page);
1672 if ($id >= 0) {
1673 $sanitized[] = $id;
1674 }
1675 }
1676 }
1677 return array_values($sanitized);
1678 }
1679
1680 /**
1681 * Sanitize visibility categories array
1682 * Handles both old format (ID-only) and new format (objects with metadata)
1683 *
1684 * @since 2.5.0
1685 * @param array $categories Array of categories (IDs or objects)
1686 * @return array Sanitized categories array
1687 */
1688 private static function sanitize_visibility_categories(array $categories): array
1689 {
1690 $sanitized = [];
1691 foreach ($categories as $category) {
1692 if (is_array($category)) {
1693 // New format: {id, name, url}
1694 $id = isset($category['id']) ? absint($category['id']) : 0;
1695 if ($id > 0) {
1696 $item = ['id' => $id];
1697 if (isset($category['name'])) {
1698 $item['name'] = sanitize_text_field($category['name']);
1699 }
1700 if (isset($category['url'])) {
1701 $item['url'] = esc_url_raw($category['url']);
1702 }
1703 $sanitized[] = $item;
1704 }
1705 } else {
1706 // Old format: just ID
1707 $id = absint($category);
1708 if ($id > 0) {
1709 $sanitized[] = $id;
1710 }
1711 }
1712 }
1713 return array_values($sanitized);
1714 }
1715
1716 /**
1717 * Sanitize visibility custom post types array
1718 * Handles both old format (slug-only) and new format (objects with metadata)
1719 *
1720 * @since 2.5.0
1721 * @param array $post_types Array of post types (slugs or objects)
1722 * @return array Sanitized post types array
1723 */
1724 private static function sanitize_visibility_post_types(array $post_types): array
1725 {
1726 $sanitized = [];
1727 foreach ($post_types as $post_type) {
1728 if (is_array($post_type)) {
1729 // New format: {name (slug), label}
1730 $slug = isset($post_type['name']) ? sanitize_key($post_type['name']) : '';
1731 if (!empty($slug)) {
1732 $item = ['name' => $slug];
1733 if (isset($post_type['label'])) {
1734 $item['label'] = sanitize_text_field($post_type['label']);
1735 }
1736 $sanitized[] = $item;
1737 }
1738 } else {
1739 // Old format: just slug
1740 $slug = sanitize_key($post_type);
1741 if (!empty($slug)) {
1742 $sanitized[] = $slug;
1743 }
1744 }
1745 }
1746 return array_values($sanitized);
1747 }
1748 }
1749