=> $popups['total'], 'deletedCount' => $deleted_count, 'message' => sprintf( /* translators: %d: number of deleted popups */ _n('%d popup deleted.', '%d popups deleted.', $deleted_count, 'reviews-feed'), $deleted_count ), ]); } /** * AJAX handler: List popups * * @since 2.5.0 * @return void */ public static function ajax_list(): void { check_ajax_referer('sbr-admin', 'nonce'); if (! sbr_current_user_can('manage_reviews_feed_options')) { wp_send_json_error(['message' => __('Unauthorized access.', 'reviews-feed')], 403); } $page = isset($_POST['page']) ? absint($_POST['page']) : 1; $popups = self::get_popups([ 'paged' => $page, ]); wp_send_json_success([ 'popupsList' => $popups['popups'], 'popupsCount' => $popups['total'], 'totalPages' => $popups['total_pages'], ]); } /** * AJAX handler: Duplicate popup * * @since 2.5.0 * @return void */ public static function ajax_duplicate(): void { check_ajax_referer('sbr-admin', 'nonce'); if (! sbr_current_user_can('manage_reviews_feed_options')) { wp_send_json_error(['message' => __('Unauthorized access.', 'reviews-feed')], 403); } $id = isset($_POST['id']) ? absint($_POST['id']) : 0; if ($id <= 0) { wp_send_json_error(['message' => __('Invalid popup ID.', 'reviews-feed')], 400); } // Check popup limit for non-Pro Plus users (only 1 popup allowed) $is_pro_plus = Util::sbr_is_pro_plus(); if (!$is_pro_plus) { $existing = self::get_popups(['posts_per_page' => 1]); if ($existing['total'] >= 1) { wp_send_json_error([ 'message' => __('Upgrade to Pro Plus to create multiple review alerts.', 'reviews-feed'), 'upsell_key' => 'reviewAlertMultiple', ], 403); } } $result = self::duplicate_popup($id); if (is_wp_error($result)) { wp_send_json_error(['message' => $result->get_error_message()], 400); } // Return updated list $popups = self::get_popups(); wp_send_json_success([ 'popupsList' => $popups['popups'], 'popupsCount' => $popups['total'], 'newPopupId' => $result, 'message' => __('Popup duplicated.', 'reviews-feed'), ]); } /** * AJAX handler: Toggle popup status (active/inactive) * * @since 2.5.0 * @return void */ public static function ajax_toggle_status(): void { check_ajax_referer('sbr-admin', 'nonce'); if (! sbr_current_user_can('manage_reviews_feed_options')) { wp_send_json_error(['message' => __('Unauthorized access.', 'reviews-feed')], 403); } $id = isset($_POST['id']) ? absint($_POST['id']) : 0; if ($id <= 0) { wp_send_json_error(['message' => __('Invalid popup ID.', 'reviews-feed')], 400); } // Get current popup $popup = self::get_popup($id); if (!$popup) { wp_send_json_error(['message' => __('Popup not found.', 'reviews-feed')], 404); } // Toggle status $new_status = $popup['status'] === 'active' ? 'inactive' : 'active'; $new_post_status = $new_status === 'active' ? 'publish' : 'draft'; // Update post status $result = wp_update_post([ 'ID' => $id, 'post_status' => $new_post_status, ], true); if (is_wp_error($result)) { wp_send_json_error(['message' => $result->get_error_message()], 400); } // Return updated list $popups = self::get_popups(); wp_send_json_success([ 'popupsList' => $popups['popups'], 'popupsCount' => $popups['total'], 'newStatus' => $new_status, 'message' => $new_status === 'active' ? __('Popup activated.', 'reviews-feed') : __('Popup deactivated.', 'reviews-feed'), ]); } /** * Get active popups for frontend display * * @since 2.5.0 * @return array List of active popups */ public static function get_active_popups(): array { $result = self::get_popups([ 'posts_per_page' => -1, 'post_status' => 'publish', ]); return $result['popups']; } /** * AJAX handler: Get preview reviews for popup editor * * Fetches reviews based on popup settings (sources, filters, sort) * for live preview in the customizer. * * @since 2.5.0 * @return void */ public static function ajax_preview_reviews(): void { check_ajax_referer('sbr-admin', 'nonce'); if (! sbr_current_user_can('manage_reviews_feed_options')) { wp_send_json_error(['message' => __('Unauthorized access.', 'reviews-feed')], 403); } // Get settings from POST $settings = isset($_POST['settings']) ? json_decode(wp_unslash($_POST['settings']), true) : []; if (!is_array($settings)) { wp_send_json_error(['message' => __('Invalid settings data.', 'reviews-feed')], 400); } // Sanitize settings $settings = self::sanitize_settings($settings); // Get reviews using the same logic as frontend $result = self::get_preview_reviews($settings); wp_send_json_success([ 'reviews' => $result['reviews'], 'totalReviews' => $result['totalReviews'], 'unfilteredTotal' => $result['unfilteredTotal'], 'averageRating' => $result['averageRating'], ]); } /** * Get reviews for popup preview * * Uses the same logic as SBR_Review_Alert_Frontend::get_reviews_for_popup() * but accessible as a static method for the AJAX handler. * * @since 2.5.0 * @param array $popup_settings Popup settings with sources, filters, sort * @return array{reviews: array, totalReviews: int, unfilteredTotal: int, averageRating: float} Array containing reviews, filtered count, unfiltered total, and average rating */ public static function get_preview_reviews(array $popup_settings): array { $source_db_ids = $popup_settings['sources'] ?? []; // Filter out invalid values (0, empty strings, non-numeric) // This handles edge cases from failed conversions or corrupted data $source_db_ids = array_filter($source_db_ids, function ($id) { return is_numeric($id) && (int) $id > 0; }); $source_db_ids = array_values($source_db_ids); // Re-index array // If no sources specified, return empty - no fallback to all sources // User must explicitly select sources for the popup if (empty($source_db_ids)) { return [ 'reviews' => [], 'totalReviews' => 0, 'unfilteredTotal' => 0, 'averageRating' => 0, ]; } // Convert database IDs to account_ids for Feed class compatibility // Following PR #418 pattern: store database IDs to avoid URL encoding issues $source_account_ids = self::convert_db_ids_to_account_ids($source_db_ids); if (empty($source_account_ids)) { return [ 'reviews' => [], 'totalReviews' => 0, 'unfilteredTotal' => 0, 'averageRating' => 0, ]; } // Get filter settings $filters = $popup_settings['filters'] ?? []; $sort = $popup_settings['sort'] ?? []; // Use Pro Feed if available, otherwise Common Feed $feed_class = Util::sbr_is_pro() ? '\\SmashBalloon\\Reviews\\Pro\\Feed' : '\\SmashBalloon\\Reviews\\Common\\Feed'; // First, fetch ALL reviews WITHOUT user filters to get unfiltered total // This gives us the total "complete" reviews before filtering $unfiltered_settings = array_merge(sbr_settings_defaults(), [ 'sources' => $source_account_ids, 'numPostDesktop' => 500, 'numPostTablet' => 500, 'numPostMobile' => 500, // No filters applied - we want all reviews from sources 'includedStarFilters' => [], 'includeWords' => '', 'excludeWords' => '', 'filterCharCountMin' => 0, 'filterCharCountMax' => '', 'sortByDateEnabled' => true, 'sortByDate' => 'latest', 'sortByRatingEnabled' => false, 'sortByRating' => '', 'sortRandomEnabled' => false, ]); $unfiltered_cache_id = 'popup_preview_unfiltered_' . md5(wp_json_encode(['sources' => $source_db_ids])); $unfiltered_feed = new $feed_class($unfiltered_settings, $unfiltered_cache_id, new FeedCache($unfiltered_cache_id, 300)); $unfiltered_feed->init(); $unfiltered_feed->get_set_cache(); $unfiltered_reviews = $unfiltered_feed->get_post_set_page(); if (isset($unfiltered_reviews['data'])) { $unfiltered_reviews = $unfiltered_reviews['data']; } // Count complete reviews (with rating, text, name) for unfiltered total $unfiltered_total = self::count_complete_reviews($unfiltered_reviews); // Now fetch filtered reviews with user's filter settings $feed_settings = array_merge(sbr_settings_defaults(), [ 'sources' => $source_account_ids, 'numPostDesktop' => 500, 'numPostTablet' => 500, 'numPostMobile' => 500, 'includedStarFilters' => $filters['includedStarFilters'] ?? [], 'includeWords' => $filters['includeWords'] ?? '', 'excludeWords' => $filters['excludeWords'] ?? '', 'filterCharCountMin' => $filters['filterCharCountMin'] ?? 0, 'filterCharCountMax' => $filters['filterCharCountMax'] ?? '', 'sortByDateEnabled' => $sort['sortByDateEnabled'] ?? true, 'sortByDate' => $sort['sortByDate'] ?? 'latest', 'sortByRatingEnabled' => $sort['sortByRatingEnabled'] ?? false, 'sortByRating' => $sort['sortByRating'] ?? '', 'sortRandomEnabled' => $sort['sortRandomEnabled'] ?? false, ]); // Create unique cache ID for preview (short TTL for admin preview) $cache_key = md5(wp_json_encode([ 'sources' => $source_db_ids, 'filters' => $filters, 'sort' => $sort, 'preview' => true, ])); $cache_id = 'popup_preview_' . $cache_key; $feed = new $feed_class($feed_settings, $cache_id, new FeedCache($cache_id, 300)); // 5 min cache for preview $feed->init(); $feed->get_set_cache(); $all_reviews = $feed->get_post_set_page(); // Handle nested data structure if (isset($all_reviews['data'])) { $all_reviews = $all_reviews['data']; } // Filter for complete reviews and format for preview // Uses same logic as SBR_Review_Alert_Frontend::filter_complete_reviews() $complete_reviews = []; $total_matching = 0; $total_rating = 0; // Get provider filter - if explicitly set, only show reviews from these providers // Note: null/not set = no filter (show all), empty array = show none (all deselected) $allowed_providers = $filters['providers'] ?? null; $has_provider_filter = isset($filters['providers']); foreach ($all_reviews as $review) { // Filter by provider if provider filter is explicitly set // Extract provider and reviewer safely (avoid PHP 8.0+ warnings on non-array access) $provider = $review['provider'] ?? ''; $review_provider = is_array($provider) ? ($provider['name'] ?? '') : $provider; $reviewer = $review['reviewer'] ?? []; if ($has_provider_filter) { // If providers array is empty, no reviews should show (all providers deselected) if (empty($allowed_providers)) { continue; } if (!in_array($review_provider, $allowed_providers, true)) { continue; } } // Must have valid rating (1-5) $rating = isset($review['rating']) ? (int) $review['rating'] : 0; if ($rating < 1 || $rating > 5) { continue; } // Must have review text (non-empty) $text = trim($review['text'] ?? ''); if (empty($text)) { continue; } // Must have reviewer name (not empty or "Anonymous") $reviewer_name = is_array($reviewer) ? trim($reviewer['name'] ?? '') : ''; if (empty($reviewer_name) || strtolower($reviewer_name) === 'anonymous') { continue; } // Count all matching reviews for total and sum ratings $total_matching++; $total_rating += $rating; // Add to preview array up to 150 reviews (matches frontend limit) if (count($complete_reviews) < 150) { // Decode HTML entities for special characters (e.g., & -> &, ' -> ') $decoded_text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $decoded_name = html_entity_decode($reviewer_name, ENT_QUOTES | ENT_HTML5, 'UTF-8'); $reviewer_avatar = is_array($reviewer) ? ($reviewer['avatar'] ?? '') : ''; $complete_reviews[] = [ 'id' => $review['review_id'] ?? $review['id'] ?? uniqid(), 'reviewer' => [ 'name' => $decoded_name, 'avatar' => $reviewer_avatar, ], 'rating' => (int) $rating, 'text' => $decoded_text, 'relativeDate' => self::get_relative_date($review['time'] ?? 0), 'provider' => $review_provider ?: 'unknown', ]; } } // Calculate average rating from ALL matching reviews (same as frontend) $average_rating = $total_matching > 0 ? round($total_rating / $total_matching, 1) : 5.0; return [ 'reviews' => $complete_reviews, 'totalReviews' => $total_matching, 'unfilteredTotal' => $unfiltered_total, 'averageRating' => $average_rating, ]; } /** * Count complete reviews (have rating 1-5, non-empty text, valid reviewer name) * * @since 2.5.0 * @param array $reviews Array of reviews * @return int Count of complete reviews */ private static function count_complete_reviews(array $reviews): int { $count = 0; foreach ($reviews as $review) { // Must have valid rating (1-5) $rating = isset($review['rating']) ? (int) $review['rating'] : 0; if ($rating < 1 || $rating > 5) { continue; } // Must have review text $text = trim($review['text'] ?? ''); if (empty($text)) { continue; } // Must have reviewer name (not empty or "Anonymous") $reviewer = $review['reviewer'] ?? []; $reviewer_name = is_array($reviewer) ? trim($reviewer['name'] ?? '') : ''; if (empty($reviewer_name) || strtolower($reviewer_name) === 'anonymous') { continue; } $count++; } return $count; } /** * Convert database source IDs to account_ids for Feed class compatibility * * Review Alerts stores database IDs instead of account_ids to avoid URL encoding * issues with special characters (Danish æ, ø, å). This follows PR #418 pattern. * * @since 2.5.0 * @param array $db_ids Array of database source IDs (integers) * @return array Array of account_ids (strings) */ private static function convert_db_ids_to_account_ids(array $db_ids): array { if (empty($db_ids)) { return []; } global $wpdb; $sources_table = $wpdb->prefix . 'sbr_sources'; // Convert to integers for safety $db_ids = array_map('absint', $db_ids); $placeholders = implode(',', array_fill(0, count($db_ids), '%d')); // Query account_ids for given database IDs // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table name and placeholders are safely generated $results = $wpdb->get_col($wpdb->prepare("SELECT account_id FROM {$sources_table} WHERE id IN ({$placeholders})", ...$db_ids)); return $results ?: []; } /** * Convert account_ids to database IDs for backward compatibility * * Existing popups may have account_id strings stored in settings.sources. * This converts them to database IDs (integers) for the new format. * * @since 2.5.0 * @param array $account_ids Array of account_id strings * @return array Array of database IDs (integers) */ private static function convert_account_ids_to_db_ids(array $account_ids): array { if (empty($account_ids)) { return []; } global $wpdb; $sources_table = $wpdb->prefix . 'sbr_sources'; // Sanitize account_ids $account_ids = array_map('sanitize_text_field', $account_ids); $placeholders = implode(',', array_fill(0, count($account_ids), '%s')); // Query database IDs for given account_ids // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table name and placeholders are safely generated $results = $wpdb->get_col($wpdb->prepare("SELECT id FROM {$sources_table} WHERE account_id IN ({$placeholders})", ...$account_ids)); return array_map('absint', $results ?: []); } /** * Convert old locations structure to new visibility structure for React UI * * Old format (locations): * - all_pages: bool * - exclude_pages: [{id, type}] or [id, id, ...] * - specific_pages: [{id, type}] or [id, id, ...] * * New format (visibility): * - display_on: 'all' | 'specific' * - excluded: {pages: [], categories: [], custom_post_types: []} * - specific: {pages: [], categories: [], custom_post_types: []} * * @since 2.5.0 * @param array $settings Popup settings * @return array Settings with visibility structure */ private static function convert_locations_to_visibility(array $settings): array { // Check if old locations format has data that needs conversion $locations = $settings['locations'] ?? []; $has_old_data = !empty($locations['exclude_pages']) || !empty($locations['specific_pages']); // Only convert if there's old data - new data uses visibility structure directly if (!$has_old_data) { return $settings; } // Initialize new visibility structure $visibility = [ 'display_on' => !empty($locations['all_pages']) ? 'all' : 'specific', 'excluded' => [ 'pages' => [], 'categories' => [], 'custom_post_types' => [], ], 'specific' => [ 'pages' => [], 'categories' => [], 'custom_post_types' => [], ], ]; // Convert from old locations format if (is_array($locations)) { $locations = $settings['locations']; // Convert all_pages boolean to display_on string $visibility['display_on'] = !empty($locations['all_pages']) ? 'all' : 'specific'; // Convert exclude_pages - group by type if (!empty($locations['exclude_pages']) && is_array($locations['exclude_pages'])) { foreach ($locations['exclude_pages'] as $item) { self::add_item_to_visibility_group($item, $visibility['excluded']); } } // Convert specific_pages - group by type if (!empty($locations['specific_pages']) && is_array($locations['specific_pages'])) { foreach ($locations['specific_pages'] as $item) { self::add_item_to_visibility_group($item, $visibility['specific']); } } } $settings['visibility'] = $visibility; return $settings; } /** * Add item to visibility group (excluded or specific) * * @since 2.5.0 * @param int|array $item Item ID or {id, type} object * @param array &$group Reference to visibility group (excluded or specific) */ private static function add_item_to_visibility_group($item, array &$group): void { // Handle legacy format: just an integer ID (assume it's a page) if (is_numeric($item)) { $id = absint($item); if ($id > 0 && !in_array($id, $group['pages'], true)) { $group['pages'][] = $id; } return; } // Handle new format: {id, type} object if (!is_array($item) || !isset($item['id'])) { return; } $type = $item['type'] ?? 'page'; // Normalize 'post' to 'page' (WordPress posts are treated as pages in visibility) if ($type === 'post') { $type = 'page'; } switch ($type) { case 'page': $id = absint($item['id']); if ($id > 0 && !in_array($id, $group['pages'], true)) { $group['pages'][] = $id; } break; case 'category': $id = absint($item['id']); if ($id > 0 && !in_array($id, $group['categories'], true)) { $group['categories'][] = $id; } break; case 'post_type': // For post types, the ID is actually the slug $slug = is_numeric($item['id']) ? '' : sanitize_key($item['id']); if (!empty($slug) && !in_array($slug, $group['custom_post_types'], true)) { $group['custom_post_types'][] = $slug; } break; } } /** * Convert timestamp to relative date string * * @since 2.5.0 * @param int $timestamp Unix timestamp * @return string Relative date (e.g., "3d ago", "1w ago") */ private static function get_relative_date(int $timestamp): string { if ($timestamp <= 0) { return ''; } $diff = time() - $timestamp; if ($diff < 60) { return __('just now', 'reviews-feed'); } elseif ($diff < 3600) { $mins = (int) floor($diff / 60); return sprintf(_n('%dm ago', '%dm ago', $mins, 'reviews-feed'), $mins); } elseif ($diff < 86400) { $hours = (int) floor($diff / 3600); return sprintf(_n('%dh ago', '%dh ago', $hours, 'reviews-feed'), $hours); } elseif ($diff < 604800) { $days = (int) floor($diff / 86400); return sprintf(_n('%dd ago', '%dd ago', $days, 'reviews-feed'), $days); } elseif ($diff < 2592000) { $weeks = (int) floor($diff / 604800); return sprintf(_n('%dw ago', '%dw ago', $weeks, 'reviews-feed'), $weeks); } elseif ($diff < 31536000) { $months = (int) floor($diff / 2592000); return sprintf(_n('%dmo ago', '%dmo ago', $months, 'reviews-feed'), $months); } else { $years = (int) floor($diff / 31536000); return sprintf(_n('%dy ago', '%dy ago', $years, 'reviews-feed'), $years); } } /** * Sanitize visibility pages array * Handles both old format (ID-only) and new format (objects with metadata) * * @since 2.5.0 * @param array $pages Array of pages (IDs or objects) * @return array Sanitized pages array */ private static function sanitize_visibility_pages(array $pages): array { $sanitized = []; foreach ($pages as $page) { if (is_array($page)) { // New format: {id, title, url} $item = [ 'id' => isset($page['id']) ? absint($page['id']) : 0, ]; if (isset($page['title'])) { $item['title'] = sanitize_text_field($page['title']); } if (isset($page['url'])) { $item['url'] = esc_url_raw($page['url']); } // ID 0 is valid (homepage) if ($item['id'] >= 0) { $sanitized[] = $item; } } else { // Old format: just ID $id = absint($page); if ($id >= 0) { $sanitized[] = $id; } } } return array_values($sanitized); } /** * Sanitize visibility categories array * Handles both old format (ID-only) and new format (objects with metadata) * * @since 2.5.0 * @param array $categories Array of categories (IDs or objects) * @return array Sanitized categories array */ private static function sanitize_visibility_categories(array $categories): array { $sanitized = []; foreach ($categories as $category) { if (is_array($category)) { // New format: {id, name, url} $id = isset($category['id']) ? absint($category['id']) : 0; if ($id > 0) { $item = ['id' => $id]; if (isset($category['name'])) { $item['name'] = sanitize_text_field($category['name']); } if (isset($category['url'])) { $item['url'] = esc_url_raw($category['url']); } $sanitized[] = $item; } } else { // Old format: just ID $id = absint($category); if ($id > 0) { $sanitized[] = $id; } } } return array_values($sanitized); } /** * Sanitize visibility custom post types array * Handles both old format (slug-only) and new format (objects with metadata) * * @since 2.5.0 * @param array $post_types Array of post types (slugs or objects) * @return array Sanitized post types array */ private static function sanitize_visibility_post_types(array $post_types): array { $sanitized = []; foreach ($post_types as $post_type) { if (is_array($post_type)) { // New format: {name (slug), label} $slug = isset($post_type['name']) ? sanitize_key($post_type['name']) : ''; if (!empty($slug)) { $item = ['name' => $slug]; if (isset($post_type['label'])) { $item['label'] = sanitize_text_field($post_type['label']); } $sanitized[] = $item; } } else { // Old format: just slug $slug = sanitize_key($post_type); if (!empty($slug)) { $sanitized[] = $slug; } } } return array_values($sanitized); } }