PluginProbe ʕ •ᴥ•ʔ
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More / 2.8.0
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More v2.8.0
2.11.0 2.10.0 2.9.0 2.8.0 2.7.0 2.6.7 2.6.8 2.6.5 2.6.4 2.6.3 2.6.2 2.6.0 2.5.5 2.5.4 2.5.3 2.5.2 trunk 1.0 1.0.1 1.0.2 1.0.3 1.1 1.1.1 1.1.2 1.2.0 2.0 2.1.0 2.1.1 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.4.5 2.4.6 2.5.0 2.5.1
reviews-feed / class / Common / ReviewAlerts / SBR_Review_Alert_Service.php
reviews-feed / class / Common / ReviewAlerts Last commit date
SBR_ReviewAlert_Builder.php 1 month ago SBR_Review_Alert_Frontend.php 1 month ago SBR_Review_Alert_Service.php 1 month ago
SBR_Review_Alert_Service.php
1759 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 // SMASH-782: booking-only alerts show Booking's native 0-10 score + word.
1164 'bookingHeader' => $result['bookingHeader'] ?? null,
1165 ]);
1166 }
1167
1168 /**
1169 * Get reviews for popup preview
1170 *
1171 * Uses the same logic as SBR_Review_Alert_Frontend::get_reviews_for_popup()
1172 * but accessible as a static method for the AJAX handler.
1173 *
1174 * @since 2.5.0
1175 * @param array $popup_settings Popup settings with sources, filters, sort
1176 * @return array{reviews: array, totalReviews: int, unfilteredTotal: int, averageRating: float} Array containing reviews, filtered count, unfiltered total, and average rating
1177 */
1178 public static function get_preview_reviews(array $popup_settings): array
1179 {
1180 $source_db_ids = $popup_settings['sources'] ?? [];
1181
1182 // Filter out invalid values (0, empty strings, non-numeric)
1183 // This handles edge cases from failed conversions or corrupted data
1184 $source_db_ids = array_filter($source_db_ids, function ($id) {
1185 return is_numeric($id) && (int) $id > 0;
1186 });
1187 $source_db_ids = array_values($source_db_ids); // Re-index array
1188
1189 // If no sources specified, return empty - no fallback to all sources
1190 // User must explicitly select sources for the popup
1191 if (empty($source_db_ids)) {
1192 return [
1193 'reviews' => [],
1194 'totalReviews' => 0,
1195 'unfilteredTotal' => 0,
1196 'averageRating' => 0,
1197 ];
1198 }
1199
1200 // Convert database IDs to account_ids for Feed class compatibility
1201 // Following PR #418 pattern: store database IDs to avoid URL encoding issues
1202 $source_account_ids = self::convert_db_ids_to_account_ids($source_db_ids);
1203
1204 if (empty($source_account_ids)) {
1205 return [
1206 'reviews' => [],
1207 'totalReviews' => 0,
1208 'unfilteredTotal' => 0,
1209 'averageRating' => 0,
1210 ];
1211 }
1212
1213 // Get filter settings
1214 $filters = $popup_settings['filters'] ?? [];
1215 $sort = $popup_settings['sort'] ?? [];
1216
1217 // Use Pro Feed if available, otherwise Common Feed
1218 $feed_class = Util::sbr_is_pro()
1219 ? '\\SmashBalloon\\Reviews\\Pro\\Feed'
1220 : '\\SmashBalloon\\Reviews\\Common\\Feed';
1221
1222 // First, fetch ALL reviews WITHOUT user filters to get unfiltered total
1223 // This gives us the total "complete" reviews before filtering
1224 $unfiltered_settings = array_merge(sbr_settings_defaults(), [
1225 'sources' => $source_account_ids,
1226 'numPostDesktop' => 500,
1227 'numPostTablet' => 500,
1228 'numPostMobile' => 500,
1229 // No filters applied - we want all reviews from sources
1230 'includedStarFilters' => [],
1231 'includeWords' => '',
1232 'excludeWords' => '',
1233 'filterCharCountMin' => 0,
1234 'filterCharCountMax' => '',
1235 'sortByDateEnabled' => true,
1236 'sortByDate' => 'latest',
1237 'sortByRatingEnabled' => false,
1238 'sortByRating' => '',
1239 'sortRandomEnabled' => false,
1240 ]);
1241
1242 $unfiltered_cache_id = 'popup_preview_unfiltered_' . md5(wp_json_encode(['sources' => $source_db_ids]));
1243 $unfiltered_feed = new $feed_class($unfiltered_settings, $unfiltered_cache_id, new FeedCache($unfiltered_cache_id, 300));
1244 $unfiltered_feed->init();
1245 $unfiltered_feed->get_set_cache();
1246 $unfiltered_reviews = $unfiltered_feed->get_post_set_page();
1247
1248 if (isset($unfiltered_reviews['data'])) {
1249 $unfiltered_reviews = $unfiltered_reviews['data'];
1250 }
1251
1252 // Count complete reviews (with rating, text, name) for unfiltered total
1253 $unfiltered_total = self::count_complete_reviews($unfiltered_reviews);
1254
1255 // Now fetch filtered reviews with user's filter settings
1256 $feed_settings = array_merge(sbr_settings_defaults(), [
1257 'sources' => $source_account_ids,
1258 'numPostDesktop' => 500,
1259 'numPostTablet' => 500,
1260 'numPostMobile' => 500,
1261 'includedStarFilters' => $filters['includedStarFilters'] ?? [],
1262 'includeWords' => $filters['includeWords'] ?? '',
1263 'excludeWords' => $filters['excludeWords'] ?? '',
1264 'filterCharCountMin' => $filters['filterCharCountMin'] ?? 0,
1265 'filterCharCountMax' => $filters['filterCharCountMax'] ?? '',
1266 'sortByDateEnabled' => $sort['sortByDateEnabled'] ?? true,
1267 'sortByDate' => $sort['sortByDate'] ?? 'latest',
1268 'sortByRatingEnabled' => $sort['sortByRatingEnabled'] ?? false,
1269 'sortByRating' => $sort['sortByRating'] ?? '',
1270 'sortRandomEnabled' => $sort['sortRandomEnabled'] ?? false,
1271 ]);
1272
1273 // Create unique cache ID for preview (short TTL for admin preview)
1274 $cache_key = md5(wp_json_encode([
1275 'sources' => $source_db_ids,
1276 'filters' => $filters,
1277 'sort' => $sort,
1278 'preview' => true,
1279 ]));
1280 $cache_id = 'popup_preview_' . $cache_key;
1281
1282 $feed = new $feed_class($feed_settings, $cache_id, new FeedCache($cache_id, 300)); // 5 min cache for preview
1283
1284 $feed->init();
1285 $feed->get_set_cache();
1286
1287 $all_reviews = $feed->get_post_set_page();
1288
1289 // Handle nested data structure
1290 if (isset($all_reviews['data'])) {
1291 $all_reviews = $all_reviews['data'];
1292 }
1293
1294 // Filter for complete reviews and format for preview
1295 // Uses same logic as SBR_Review_Alert_Frontend::filter_complete_reviews()
1296 $complete_reviews = [];
1297 $total_matching = 0;
1298 $total_rating = 0;
1299
1300 // Get provider filter - if explicitly set, only show reviews from these providers
1301 // Note: null/not set = no filter (show all), empty array = show none (all deselected)
1302 $allowed_providers = $filters['providers'] ?? null;
1303 $has_provider_filter = isset($filters['providers']);
1304
1305 foreach ($all_reviews as $review) {
1306 // Filter by provider if provider filter is explicitly set
1307 // Extract provider and reviewer safely (avoid PHP 8.0+ warnings on non-array access)
1308 $provider = $review['provider'] ?? '';
1309 $review_provider = is_array($provider) ? ($provider['name'] ?? '') : $provider;
1310 $reviewer = $review['reviewer'] ?? [];
1311
1312 if ($has_provider_filter) {
1313 // If providers array is empty, no reviews should show (all providers deselected)
1314 if (empty($allowed_providers)) {
1315 continue;
1316 }
1317 if (!in_array($review_provider, $allowed_providers, true)) {
1318 continue;
1319 }
1320 }
1321
1322 // Must have valid rating (1-5)
1323 $rating = isset($review['rating']) ? (int) $review['rating'] : 0;
1324 if ($rating < 1 || $rating > 5) {
1325 continue;
1326 }
1327
1328 // Must have review text (non-empty)
1329 $text = trim($review['text'] ?? '');
1330 if (empty($text)) {
1331 continue;
1332 }
1333
1334 // Must have reviewer name (not empty or "Anonymous")
1335 $reviewer_name = is_array($reviewer) ? trim($reviewer['name'] ?? '') : '';
1336 if (empty($reviewer_name) || strtolower($reviewer_name) === 'anonymous') {
1337 continue;
1338 }
1339
1340 // Count all matching reviews for total and sum ratings
1341 $total_matching++;
1342 $total_rating += $rating;
1343
1344 // Add to preview array up to the shared frontend cap.
1345 if (count($complete_reviews) < SBR_Review_Alert_Frontend::MAX_POPUP_REVIEWS) {
1346 // Decode HTML entities for special characters (e.g., &amp; -> &, &#039; -> ')
1347 $decoded_text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
1348 $decoded_name = html_entity_decode($reviewer_name, ENT_QUOTES | ENT_HTML5, 'UTF-8');
1349
1350 $reviewer_avatar = is_array($reviewer) ? ($reviewer['avatar'] ?? '') : '';
1351 // SMASH-782: the provider-specific payload (metadata/reply/response/
1352 // reviewer_photos/source) comes from the SAME shared extractor the
1353 // frontend formatter uses, so preview and frontend can't drift on which
1354 // keys survive. The core shape below stays preview-specific (relativeDate
1355 // + string provider) because the React preview consumes it differently
1356 // than the JS cycler.
1357 $complete_reviews[] = [
1358 'id' => $review['review_id'] ?? $review['id'] ?? uniqid(),
1359 'reviewer' => [
1360 'name' => $decoded_name,
1361 'avatar' => $reviewer_avatar,
1362 ],
1363 'rating' => (int) $rating,
1364 'text' => $decoded_text,
1365 'title' => isset($review['title']) ? html_entity_decode((string) $review['title'], ENT_QUOTES | ENT_HTML5, 'UTF-8') : '',
1366 'relativeDate' => self::get_relative_date($review['time'] ?? 0),
1367 'provider' => $review_provider ?: 'unknown',
1368 ] + SBR_Review_Alert_Frontend::extract_provider_payload($review);
1369 }
1370 }
1371
1372 // Headline total + average from the feed-header metadata, via the shared
1373 // helper the frontend render path uses too, so the two can't drift. SMASH-1616.
1374 // Backfill from the FULL cached set (get_posts()), matching FeedDisplay and
1375 // the frontend path — not the page slice — so the preview headline can't
1376 // under-count providers with a zero API total.
1377 [$total_reviews, $average_rating, $booking_header] = SBR_Review_Alert_Frontend::resolve_header_totals(
1378 $feed,
1379 $feed->get_posts(),
1380 $total_matching,
1381 $total_rating
1382 );
1383
1384 return [
1385 'reviews' => $complete_reviews,
1386 'totalReviews' => $total_reviews,
1387 'unfilteredTotal' => $unfiltered_total,
1388 'averageRating' => $average_rating,
1389 'bookingHeader' => $booking_header,
1390 ];
1391 }
1392
1393 /**
1394 * Count complete reviews (have rating 1-5, non-empty text, valid reviewer name)
1395 *
1396 * @since 2.5.0
1397 * @param array $reviews Array of reviews
1398 * @return int Count of complete reviews
1399 */
1400 private static function count_complete_reviews(array $reviews): int
1401 {
1402 $count = 0;
1403
1404 foreach ($reviews as $review) {
1405 // Must have valid rating (1-5)
1406 $rating = isset($review['rating']) ? (int) $review['rating'] : 0;
1407 if ($rating < 1 || $rating > 5) {
1408 continue;
1409 }
1410
1411 // Must have review text
1412 $text = trim($review['text'] ?? '');
1413 if (empty($text)) {
1414 continue;
1415 }
1416
1417 // Must have reviewer name (not empty or "Anonymous")
1418 $reviewer = $review['reviewer'] ?? [];
1419 $reviewer_name = is_array($reviewer) ? trim($reviewer['name'] ?? '') : '';
1420 if (empty($reviewer_name) || strtolower($reviewer_name) === 'anonymous') {
1421 continue;
1422 }
1423
1424 $count++;
1425 }
1426
1427 return $count;
1428 }
1429
1430 /**
1431 * Convert database source IDs to account_ids for Feed class compatibility
1432 *
1433 * Review Alerts stores database IDs instead of account_ids to avoid URL encoding
1434 * issues with special characters (Danish æ, ø, å). This follows PR #418 pattern.
1435 *
1436 * @since 2.5.0
1437 * @param array $db_ids Array of database source IDs (integers)
1438 * @return array Array of account_ids (strings)
1439 */
1440 private static function convert_db_ids_to_account_ids(array $db_ids): array
1441 {
1442 if (empty($db_ids)) {
1443 return [];
1444 }
1445
1446 global $wpdb;
1447 $sources_table = $wpdb->prefix . 'sbr_sources';
1448
1449 // Convert to integers for safety
1450 $db_ids = array_map('absint', $db_ids);
1451 $placeholders = implode(',', array_fill(0, count($db_ids), '%d'));
1452
1453 // Query account_ids for given database IDs
1454 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table name and placeholders are safely generated
1455 $results = $wpdb->get_col($wpdb->prepare("SELECT account_id FROM {$sources_table} WHERE id IN ({$placeholders})", ...$db_ids));
1456
1457 return $results ?: [];
1458 }
1459
1460 /**
1461 * Convert account_ids to database IDs for backward compatibility
1462 *
1463 * Existing popups may have account_id strings stored in settings.sources.
1464 * This converts them to database IDs (integers) for the new format.
1465 *
1466 * @since 2.5.0
1467 * @param array $account_ids Array of account_id strings
1468 * @return array Array of database IDs (integers)
1469 */
1470 private static function convert_account_ids_to_db_ids(array $account_ids): array
1471 {
1472 if (empty($account_ids)) {
1473 return [];
1474 }
1475
1476 global $wpdb;
1477 $sources_table = $wpdb->prefix . 'sbr_sources';
1478
1479 // Sanitize account_ids
1480 $account_ids = array_map('sanitize_text_field', $account_ids);
1481 $placeholders = implode(',', array_fill(0, count($account_ids), '%s'));
1482
1483 // Query database IDs for given account_ids
1484 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table name and placeholders are safely generated
1485 $results = $wpdb->get_col($wpdb->prepare("SELECT id FROM {$sources_table} WHERE account_id IN ({$placeholders})", ...$account_ids));
1486
1487 return array_map('absint', $results ?: []);
1488 }
1489
1490 /**
1491 * Convert old locations structure to new visibility structure for React UI
1492 *
1493 * Old format (locations):
1494 * - all_pages: bool
1495 * - exclude_pages: [{id, type}] or [id, id, ...]
1496 * - specific_pages: [{id, type}] or [id, id, ...]
1497 *
1498 * New format (visibility):
1499 * - display_on: 'all' | 'specific'
1500 * - excluded: {pages: [], categories: [], custom_post_types: []}
1501 * - specific: {pages: [], categories: [], custom_post_types: []}
1502 *
1503 * @since 2.5.0
1504 * @param array $settings Popup settings
1505 * @return array Settings with visibility structure
1506 */
1507 private static function convert_locations_to_visibility(array $settings): array
1508 {
1509 // Check if old locations format has data that needs conversion
1510 $locations = $settings['locations'] ?? [];
1511 $has_old_data = !empty($locations['exclude_pages']) || !empty($locations['specific_pages']);
1512
1513 // Only convert if there's old data - new data uses visibility structure directly
1514 if (!$has_old_data) {
1515 return $settings;
1516 }
1517
1518 // Initialize new visibility structure
1519 $visibility = [
1520 'display_on' => !empty($locations['all_pages']) ? 'all' : 'specific',
1521 'excluded' => [
1522 'pages' => [],
1523 'categories' => [],
1524 'custom_post_types' => [],
1525 ],
1526 'specific' => [
1527 'pages' => [],
1528 'categories' => [],
1529 'custom_post_types' => [],
1530 ],
1531 ];
1532
1533 // Convert from old locations format
1534 if (is_array($locations)) {
1535 $locations = $settings['locations'];
1536
1537 // Convert all_pages boolean to display_on string
1538 $visibility['display_on'] = !empty($locations['all_pages']) ? 'all' : 'specific';
1539
1540 // Convert exclude_pages - group by type
1541 if (!empty($locations['exclude_pages']) && is_array($locations['exclude_pages'])) {
1542 foreach ($locations['exclude_pages'] as $item) {
1543 self::add_item_to_visibility_group($item, $visibility['excluded']);
1544 }
1545 }
1546
1547 // Convert specific_pages - group by type
1548 if (!empty($locations['specific_pages']) && is_array($locations['specific_pages'])) {
1549 foreach ($locations['specific_pages'] as $item) {
1550 self::add_item_to_visibility_group($item, $visibility['specific']);
1551 }
1552 }
1553 }
1554
1555 $settings['visibility'] = $visibility;
1556 return $settings;
1557 }
1558
1559 /**
1560 * Add item to visibility group (excluded or specific)
1561 *
1562 * @since 2.5.0
1563 * @param int|array $item Item ID or {id, type} object
1564 * @param array &$group Reference to visibility group (excluded or specific)
1565 */
1566 private static function add_item_to_visibility_group($item, array &$group): void
1567 {
1568 // Handle legacy format: just an integer ID (assume it's a page)
1569 if (is_numeric($item)) {
1570 $id = absint($item);
1571 if ($id > 0 && !in_array($id, $group['pages'], true)) {
1572 $group['pages'][] = $id;
1573 }
1574 return;
1575 }
1576
1577 // Handle new format: {id, type} object
1578 if (!is_array($item) || !isset($item['id'])) {
1579 return;
1580 }
1581
1582 $type = $item['type'] ?? 'page';
1583
1584 // Normalize 'post' to 'page' (WordPress posts are treated as pages in visibility)
1585 if ($type === 'post') {
1586 $type = 'page';
1587 }
1588
1589 switch ($type) {
1590 case 'page':
1591 $id = absint($item['id']);
1592 if ($id > 0 && !in_array($id, $group['pages'], true)) {
1593 $group['pages'][] = $id;
1594 }
1595 break;
1596
1597 case 'category':
1598 $id = absint($item['id']);
1599 if ($id > 0 && !in_array($id, $group['categories'], true)) {
1600 $group['categories'][] = $id;
1601 }
1602 break;
1603
1604 case 'post_type':
1605 // For post types, the ID is actually the slug
1606 $slug = is_numeric($item['id']) ? '' : sanitize_key($item['id']);
1607 if (!empty($slug) && !in_array($slug, $group['custom_post_types'], true)) {
1608 $group['custom_post_types'][] = $slug;
1609 }
1610 break;
1611 }
1612 }
1613
1614 /**
1615 * Convert timestamp to relative date string
1616 *
1617 * @since 2.5.0
1618 * @param int $timestamp Unix timestamp
1619 * @return string Relative date (e.g., "3d ago", "1w ago")
1620 */
1621 private static function get_relative_date(int $timestamp): string
1622 {
1623 if ($timestamp <= 0) {
1624 return '';
1625 }
1626
1627 $diff = time() - $timestamp;
1628
1629 if ($diff < 60) {
1630 return __('just now', 'reviews-feed');
1631 } elseif ($diff < 3600) {
1632 $mins = (int) floor($diff / 60);
1633 return sprintf(_n('%dm ago', '%dm ago', $mins, 'reviews-feed'), $mins);
1634 } elseif ($diff < 86400) {
1635 $hours = (int) floor($diff / 3600);
1636 return sprintf(_n('%dh ago', '%dh ago', $hours, 'reviews-feed'), $hours);
1637 } elseif ($diff < 604800) {
1638 $days = (int) floor($diff / 86400);
1639 return sprintf(_n('%dd ago', '%dd ago', $days, 'reviews-feed'), $days);
1640 } elseif ($diff < 2592000) {
1641 $weeks = (int) floor($diff / 604800);
1642 return sprintf(_n('%dw ago', '%dw ago', $weeks, 'reviews-feed'), $weeks);
1643 } elseif ($diff < 31536000) {
1644 $months = (int) floor($diff / 2592000);
1645 return sprintf(_n('%dmo ago', '%dmo ago', $months, 'reviews-feed'), $months);
1646 } else {
1647 $years = (int) floor($diff / 31536000);
1648 return sprintf(_n('%dy ago', '%dy ago', $years, 'reviews-feed'), $years);
1649 }
1650 }
1651
1652 /**
1653 * Sanitize visibility pages array
1654 * Handles both old format (ID-only) and new format (objects with metadata)
1655 *
1656 * @since 2.5.0
1657 * @param array $pages Array of pages (IDs or objects)
1658 * @return array Sanitized pages array
1659 */
1660 private static function sanitize_visibility_pages(array $pages): array
1661 {
1662 $sanitized = [];
1663 foreach ($pages as $page) {
1664 if (is_array($page)) {
1665 // New format: {id, title, url}
1666 $item = [
1667 'id' => isset($page['id']) ? absint($page['id']) : 0,
1668 ];
1669 if (isset($page['title'])) {
1670 $item['title'] = sanitize_text_field($page['title']);
1671 }
1672 if (isset($page['url'])) {
1673 $item['url'] = esc_url_raw($page['url']);
1674 }
1675 // ID 0 is valid (homepage)
1676 if ($item['id'] >= 0) {
1677 $sanitized[] = $item;
1678 }
1679 } else {
1680 // Old format: just ID
1681 $id = absint($page);
1682 if ($id >= 0) {
1683 $sanitized[] = $id;
1684 }
1685 }
1686 }
1687 return array_values($sanitized);
1688 }
1689
1690 /**
1691 * Sanitize visibility categories array
1692 * Handles both old format (ID-only) and new format (objects with metadata)
1693 *
1694 * @since 2.5.0
1695 * @param array $categories Array of categories (IDs or objects)
1696 * @return array Sanitized categories array
1697 */
1698 private static function sanitize_visibility_categories(array $categories): array
1699 {
1700 $sanitized = [];
1701 foreach ($categories as $category) {
1702 if (is_array($category)) {
1703 // New format: {id, name, url}
1704 $id = isset($category['id']) ? absint($category['id']) : 0;
1705 if ($id > 0) {
1706 $item = ['id' => $id];
1707 if (isset($category['name'])) {
1708 $item['name'] = sanitize_text_field($category['name']);
1709 }
1710 if (isset($category['url'])) {
1711 $item['url'] = esc_url_raw($category['url']);
1712 }
1713 $sanitized[] = $item;
1714 }
1715 } else {
1716 // Old format: just ID
1717 $id = absint($category);
1718 if ($id > 0) {
1719 $sanitized[] = $id;
1720 }
1721 }
1722 }
1723 return array_values($sanitized);
1724 }
1725
1726 /**
1727 * Sanitize visibility custom post types array
1728 * Handles both old format (slug-only) and new format (objects with metadata)
1729 *
1730 * @since 2.5.0
1731 * @param array $post_types Array of post types (slugs or objects)
1732 * @return array Sanitized post types array
1733 */
1734 private static function sanitize_visibility_post_types(array $post_types): array
1735 {
1736 $sanitized = [];
1737 foreach ($post_types as $post_type) {
1738 if (is_array($post_type)) {
1739 // New format: {name (slug), label}
1740 $slug = isset($post_type['name']) ? sanitize_key($post_type['name']) : '';
1741 if (!empty($slug)) {
1742 $item = ['name' => $slug];
1743 if (isset($post_type['label'])) {
1744 $item['label'] = sanitize_text_field($post_type['label']);
1745 }
1746 $sanitized[] = $item;
1747 }
1748 } else {
1749 // Old format: just slug
1750 $slug = sanitize_key($post_type);
1751 if (!empty($slug)) {
1752 $sanitized[] = $slug;
1753 }
1754 }
1755 }
1756 return array_values($sanitized);
1757 }
1758 }
1759