PluginProbe ʕ •ᴥ•ʔ
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More / 2.10.0
Reviews Feed – Add Testimonials and Customer Reviews From Google Reviews, Yelp, TripAdvisor, and More v2.10.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 3 weeks ago SBR_Review_Alert_Frontend.php 3 weeks ago SBR_Review_Alert_Service.php 3 weeks ago
SBR_Review_Alert_Service.php
1779 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 // Booking keeps its fractional rating, because the React preview derives
1364 // the badge from THIS field rather than from a server-resolved score:
1365 // ReviewAlertPreview.js:293 calls SbUtils.bookingReviewScore(review),
1366 // and that helper reads `review.rating` and doubles it (SbUtils.js:1232).
1367 // So a truncated 4.5 showed "8.0 Very good" in the popup editor while
1368 // the live popup — which resolves from the raw row via
1369 // sbr_booking_review_score() — showed "9.0 Superb". One band off, on the
1370 // surface whose whole job is to match.
1371 //
1372 // NOT the same contract as the frontend cycler: that one reads the
1373 // server-resolved bookingScore/bookingScoreWord, because its payload
1374 // casts `rating` to int for the star renderer. The preview has no such
1375 // cast to work around and no PHP round-trip, so it computes locally.
1376 // Sending the pair here too would be dead weight until the customizer
1377 // reads it — worth doing, but it needs its own PR and a re-pin.
1378 //
1379 // Safe to send a float: neither preview layout renders stars for a
1380 // Booking card, the score badge takes that slot.
1381 'rating' => 'booking' === $review_provider
1382 ? (float) ($review['rating'] ?? 0)
1383 : (int) $rating,
1384 'text' => $decoded_text,
1385 'title' => isset($review['title']) ? html_entity_decode((string) $review['title'], ENT_QUOTES | ENT_HTML5, 'UTF-8') : '',
1386 'relativeDate' => self::get_relative_date($review['time'] ?? 0),
1387 'provider' => $review_provider ?: 'unknown',
1388 ] + SBR_Review_Alert_Frontend::extract_provider_payload($review);
1389 }
1390 }
1391
1392 // Headline total + average from the feed-header metadata, via the shared
1393 // helper the frontend render path uses too, so the two can't drift. SMASH-1616.
1394 // Backfill from the FULL cached set (get_posts()), matching FeedDisplay and
1395 // the frontend path — not the page slice — so the preview headline can't
1396 // under-count providers with a zero API total.
1397 [$total_reviews, $average_rating, $booking_header] = SBR_Review_Alert_Frontend::resolve_header_totals(
1398 $feed,
1399 $feed->get_posts(),
1400 $total_matching,
1401 $total_rating
1402 );
1403
1404 return [
1405 'reviews' => $complete_reviews,
1406 'totalReviews' => $total_reviews,
1407 'unfilteredTotal' => $unfiltered_total,
1408 'averageRating' => $average_rating,
1409 'bookingHeader' => $booking_header,
1410 ];
1411 }
1412
1413 /**
1414 * Count complete reviews (have rating 1-5, non-empty text, valid reviewer name)
1415 *
1416 * @since 2.5.0
1417 * @param array $reviews Array of reviews
1418 * @return int Count of complete reviews
1419 */
1420 private static function count_complete_reviews(array $reviews): int
1421 {
1422 $count = 0;
1423
1424 foreach ($reviews as $review) {
1425 // Must have valid rating (1-5)
1426 $rating = isset($review['rating']) ? (int) $review['rating'] : 0;
1427 if ($rating < 1 || $rating > 5) {
1428 continue;
1429 }
1430
1431 // Must have review text
1432 $text = trim($review['text'] ?? '');
1433 if (empty($text)) {
1434 continue;
1435 }
1436
1437 // Must have reviewer name (not empty or "Anonymous")
1438 $reviewer = $review['reviewer'] ?? [];
1439 $reviewer_name = is_array($reviewer) ? trim($reviewer['name'] ?? '') : '';
1440 if (empty($reviewer_name) || strtolower($reviewer_name) === 'anonymous') {
1441 continue;
1442 }
1443
1444 $count++;
1445 }
1446
1447 return $count;
1448 }
1449
1450 /**
1451 * Convert database source IDs to account_ids for Feed class compatibility
1452 *
1453 * Review Alerts stores database IDs instead of account_ids to avoid URL encoding
1454 * issues with special characters (Danish æ, ø, å). This follows PR #418 pattern.
1455 *
1456 * @since 2.5.0
1457 * @param array $db_ids Array of database source IDs (integers)
1458 * @return array Array of account_ids (strings)
1459 */
1460 private static function convert_db_ids_to_account_ids(array $db_ids): array
1461 {
1462 if (empty($db_ids)) {
1463 return [];
1464 }
1465
1466 global $wpdb;
1467 $sources_table = $wpdb->prefix . 'sbr_sources';
1468
1469 // Convert to integers for safety
1470 $db_ids = array_map('absint', $db_ids);
1471 $placeholders = implode(',', array_fill(0, count($db_ids), '%d'));
1472
1473 // Query account_ids for given database 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 account_id FROM {$sources_table} WHERE id IN ({$placeholders})", ...$db_ids));
1476
1477 return $results ?: [];
1478 }
1479
1480 /**
1481 * Convert account_ids to database IDs for backward compatibility
1482 *
1483 * Existing popups may have account_id strings stored in settings.sources.
1484 * This converts them to database IDs (integers) for the new format.
1485 *
1486 * @since 2.5.0
1487 * @param array $account_ids Array of account_id strings
1488 * @return array Array of database IDs (integers)
1489 */
1490 private static function convert_account_ids_to_db_ids(array $account_ids): array
1491 {
1492 if (empty($account_ids)) {
1493 return [];
1494 }
1495
1496 global $wpdb;
1497 $sources_table = $wpdb->prefix . 'sbr_sources';
1498
1499 // Sanitize account_ids
1500 $account_ids = array_map('sanitize_text_field', $account_ids);
1501 $placeholders = implode(',', array_fill(0, count($account_ids), '%s'));
1502
1503 // Query database IDs for given account_ids
1504 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table name and placeholders are safely generated
1505 $results = $wpdb->get_col($wpdb->prepare("SELECT id FROM {$sources_table} WHERE account_id IN ({$placeholders})", ...$account_ids));
1506
1507 return array_map('absint', $results ?: []);
1508 }
1509
1510 /**
1511 * Convert old locations structure to new visibility structure for React UI
1512 *
1513 * Old format (locations):
1514 * - all_pages: bool
1515 * - exclude_pages: [{id, type}] or [id, id, ...]
1516 * - specific_pages: [{id, type}] or [id, id, ...]
1517 *
1518 * New format (visibility):
1519 * - display_on: 'all' | 'specific'
1520 * - excluded: {pages: [], categories: [], custom_post_types: []}
1521 * - specific: {pages: [], categories: [], custom_post_types: []}
1522 *
1523 * @since 2.5.0
1524 * @param array $settings Popup settings
1525 * @return array Settings with visibility structure
1526 */
1527 private static function convert_locations_to_visibility(array $settings): array
1528 {
1529 // Check if old locations format has data that needs conversion
1530 $locations = $settings['locations'] ?? [];
1531 $has_old_data = !empty($locations['exclude_pages']) || !empty($locations['specific_pages']);
1532
1533 // Only convert if there's old data - new data uses visibility structure directly
1534 if (!$has_old_data) {
1535 return $settings;
1536 }
1537
1538 // Initialize new visibility structure
1539 $visibility = [
1540 'display_on' => !empty($locations['all_pages']) ? 'all' : 'specific',
1541 'excluded' => [
1542 'pages' => [],
1543 'categories' => [],
1544 'custom_post_types' => [],
1545 ],
1546 'specific' => [
1547 'pages' => [],
1548 'categories' => [],
1549 'custom_post_types' => [],
1550 ],
1551 ];
1552
1553 // Convert from old locations format
1554 if (is_array($locations)) {
1555 $locations = $settings['locations'];
1556
1557 // Convert all_pages boolean to display_on string
1558 $visibility['display_on'] = !empty($locations['all_pages']) ? 'all' : 'specific';
1559
1560 // Convert exclude_pages - group by type
1561 if (!empty($locations['exclude_pages']) && is_array($locations['exclude_pages'])) {
1562 foreach ($locations['exclude_pages'] as $item) {
1563 self::add_item_to_visibility_group($item, $visibility['excluded']);
1564 }
1565 }
1566
1567 // Convert specific_pages - group by type
1568 if (!empty($locations['specific_pages']) && is_array($locations['specific_pages'])) {
1569 foreach ($locations['specific_pages'] as $item) {
1570 self::add_item_to_visibility_group($item, $visibility['specific']);
1571 }
1572 }
1573 }
1574
1575 $settings['visibility'] = $visibility;
1576 return $settings;
1577 }
1578
1579 /**
1580 * Add item to visibility group (excluded or specific)
1581 *
1582 * @since 2.5.0
1583 * @param int|array $item Item ID or {id, type} object
1584 * @param array &$group Reference to visibility group (excluded or specific)
1585 */
1586 private static function add_item_to_visibility_group($item, array &$group): void
1587 {
1588 // Handle legacy format: just an integer ID (assume it's a page)
1589 if (is_numeric($item)) {
1590 $id = absint($item);
1591 if ($id > 0 && !in_array($id, $group['pages'], true)) {
1592 $group['pages'][] = $id;
1593 }
1594 return;
1595 }
1596
1597 // Handle new format: {id, type} object
1598 if (!is_array($item) || !isset($item['id'])) {
1599 return;
1600 }
1601
1602 $type = $item['type'] ?? 'page';
1603
1604 // Normalize 'post' to 'page' (WordPress posts are treated as pages in visibility)
1605 if ($type === 'post') {
1606 $type = 'page';
1607 }
1608
1609 switch ($type) {
1610 case 'page':
1611 $id = absint($item['id']);
1612 if ($id > 0 && !in_array($id, $group['pages'], true)) {
1613 $group['pages'][] = $id;
1614 }
1615 break;
1616
1617 case 'category':
1618 $id = absint($item['id']);
1619 if ($id > 0 && !in_array($id, $group['categories'], true)) {
1620 $group['categories'][] = $id;
1621 }
1622 break;
1623
1624 case 'post_type':
1625 // For post types, the ID is actually the slug
1626 $slug = is_numeric($item['id']) ? '' : sanitize_key($item['id']);
1627 if (!empty($slug) && !in_array($slug, $group['custom_post_types'], true)) {
1628 $group['custom_post_types'][] = $slug;
1629 }
1630 break;
1631 }
1632 }
1633
1634 /**
1635 * Convert timestamp to relative date string
1636 *
1637 * @since 2.5.0
1638 * @param int $timestamp Unix timestamp
1639 * @return string Relative date (e.g., "3d ago", "1w ago")
1640 */
1641 private static function get_relative_date(int $timestamp): string
1642 {
1643 if ($timestamp <= 0) {
1644 return '';
1645 }
1646
1647 $diff = time() - $timestamp;
1648
1649 if ($diff < 60) {
1650 return __('just now', 'reviews-feed');
1651 } elseif ($diff < 3600) {
1652 $mins = (int) floor($diff / 60);
1653 return sprintf(_n('%dm ago', '%dm ago', $mins, 'reviews-feed'), $mins);
1654 } elseif ($diff < 86400) {
1655 $hours = (int) floor($diff / 3600);
1656 return sprintf(_n('%dh ago', '%dh ago', $hours, 'reviews-feed'), $hours);
1657 } elseif ($diff < 604800) {
1658 $days = (int) floor($diff / 86400);
1659 return sprintf(_n('%dd ago', '%dd ago', $days, 'reviews-feed'), $days);
1660 } elseif ($diff < 2592000) {
1661 $weeks = (int) floor($diff / 604800);
1662 return sprintf(_n('%dw ago', '%dw ago', $weeks, 'reviews-feed'), $weeks);
1663 } elseif ($diff < 31536000) {
1664 $months = (int) floor($diff / 2592000);
1665 return sprintf(_n('%dmo ago', '%dmo ago', $months, 'reviews-feed'), $months);
1666 } else {
1667 $years = (int) floor($diff / 31536000);
1668 return sprintf(_n('%dy ago', '%dy ago', $years, 'reviews-feed'), $years);
1669 }
1670 }
1671
1672 /**
1673 * Sanitize visibility pages array
1674 * Handles both old format (ID-only) and new format (objects with metadata)
1675 *
1676 * @since 2.5.0
1677 * @param array $pages Array of pages (IDs or objects)
1678 * @return array Sanitized pages array
1679 */
1680 private static function sanitize_visibility_pages(array $pages): array
1681 {
1682 $sanitized = [];
1683 foreach ($pages as $page) {
1684 if (is_array($page)) {
1685 // New format: {id, title, url}
1686 $item = [
1687 'id' => isset($page['id']) ? absint($page['id']) : 0,
1688 ];
1689 if (isset($page['title'])) {
1690 $item['title'] = sanitize_text_field($page['title']);
1691 }
1692 if (isset($page['url'])) {
1693 $item['url'] = esc_url_raw($page['url']);
1694 }
1695 // ID 0 is valid (homepage)
1696 if ($item['id'] >= 0) {
1697 $sanitized[] = $item;
1698 }
1699 } else {
1700 // Old format: just ID
1701 $id = absint($page);
1702 if ($id >= 0) {
1703 $sanitized[] = $id;
1704 }
1705 }
1706 }
1707 return array_values($sanitized);
1708 }
1709
1710 /**
1711 * Sanitize visibility categories array
1712 * Handles both old format (ID-only) and new format (objects with metadata)
1713 *
1714 * @since 2.5.0
1715 * @param array $categories Array of categories (IDs or objects)
1716 * @return array Sanitized categories array
1717 */
1718 private static function sanitize_visibility_categories(array $categories): array
1719 {
1720 $sanitized = [];
1721 foreach ($categories as $category) {
1722 if (is_array($category)) {
1723 // New format: {id, name, url}
1724 $id = isset($category['id']) ? absint($category['id']) : 0;
1725 if ($id > 0) {
1726 $item = ['id' => $id];
1727 if (isset($category['name'])) {
1728 $item['name'] = sanitize_text_field($category['name']);
1729 }
1730 if (isset($category['url'])) {
1731 $item['url'] = esc_url_raw($category['url']);
1732 }
1733 $sanitized[] = $item;
1734 }
1735 } else {
1736 // Old format: just ID
1737 $id = absint($category);
1738 if ($id > 0) {
1739 $sanitized[] = $id;
1740 }
1741 }
1742 }
1743 return array_values($sanitized);
1744 }
1745
1746 /**
1747 * Sanitize visibility custom post types array
1748 * Handles both old format (slug-only) and new format (objects with metadata)
1749 *
1750 * @since 2.5.0
1751 * @param array $post_types Array of post types (slugs or objects)
1752 * @return array Sanitized post types array
1753 */
1754 private static function sanitize_visibility_post_types(array $post_types): array
1755 {
1756 $sanitized = [];
1757 foreach ($post_types as $post_type) {
1758 if (is_array($post_type)) {
1759 // New format: {name (slug), label}
1760 $slug = isset($post_type['name']) ? sanitize_key($post_type['name']) : '';
1761 if (!empty($slug)) {
1762 $item = ['name' => $slug];
1763 if (isset($post_type['label'])) {
1764 $item['label'] = sanitize_text_field($post_type['label']);
1765 }
1766 $sanitized[] = $item;
1767 }
1768 } else {
1769 // Old format: just slug
1770 $slug = sanitize_key($post_type);
1771 if (!empty($slug)) {
1772 $sanitized[] = $slug;
1773 }
1774 }
1775 }
1776 return array_values($sanitized);
1777 }
1778 }
1779