diff --git a/classes/Admin.php b/classes/Admin.php index 96b4c268c..5a19f2e22 100644 --- a/classes/Admin.php +++ b/classes/Admin.php @@ -10,34 +10,125 @@ class PUM_Admin { public static function init() { PUM_Admin_BlockEditor::init(); + self::register_request_actions(); + self::register_lazy_save_hooks(); if ( is_admin() ) { + self::register_upsell_preview_hooks(); + + if ( ! wp_doing_ajax() ) { + PUM_Admin_Shortcode_UI::init(); + } + PUM_Admin_Pages::init(); - PUM_Admin_Extend::init(); PUM_Admin_Ajax::init(); PUM_Admin_Assets::init(); PUM_Admin_Notices::init(); + PUM_Admin_Onboarding::init(); } - if ( is_admin() ) { + add_filter( 'user_has_cap', [ __CLASS__, 'prevent_default_theme_deletion' ], 10, 3 ); + add_action( 'admin_init', [ __CLASS__, 'after_install' ] ); + add_action( 'admin_head', [ __CLASS__, 'clean_ui' ] ); + } + + /** + * Register request actions before the init dispatcher runs. + * + * Keeping these lightweight class-string callbacks here avoids loading the + * Tools screen while ensuring PUM_Site::actions() can dispatch them on init. + * + * @return void + */ + private static function register_request_actions() { + add_action( 'pum_save_enabled_betas', [ 'PUM_Admin_Tools', 'save_enabled_betas' ] ); + add_action( 'pum_empty_error_log', [ 'PUM_Admin_Tools', 'error_log_empty' ] ); + } + + /** + * Register premium preview hooks without loading the screen UI implementation. + * + * Trigger and condition registries may be materialized before admin_menu, so + * these class-string callbacks must exist during the initial admin bootstrap. + * + * @return void + */ + private static function register_upsell_preview_hooks() { + if ( defined( 'POPUP_MAKER_DISABLE_UPSELLS' ) && POPUP_MAKER_DISABLE_UPSELLS ) { + return; + } + + if ( \PopupMaker\plugin()->is_pro_active() ) { + return; + } + + add_filter( 'pum_registered_triggers', [ 'PUM_Upsell', 'register_preview_triggers' ] ); + add_filter( 'pum_registered_conditions', [ 'PUM_Upsell', 'register_preview_conditions' ] ); + add_filter( 'popup_maker/cta_types_as_array', [ 'PUM_Upsell', 'register_preview_cta_types' ] ); + add_action( 'pum_popup_analytics_metabox_after', [ 'PUM_Upsell', 'render_analytics_teaser' ] ); + add_filter( 'pum_admin_vars', [ 'PUM_Upsell', 'localize_premium_preview_data' ] ); + } + + /** + * Initialize admin components used by the current request. + * + * @param array $page_slugs Resolved admin page slugs. + * + * @return void + */ + public static function init_request_components( $page_slugs ) { + $post_type = pum_typenow(); + $page = self::requested_page(); + $page_slugs = is_array( $page_slugs ) ? $page_slugs : []; + + if ( 'popup' === $post_type ) { PUM_Admin_Popups::init(); + } + + if ( 'popup_theme' === $post_type ) { PUM_Admin_Themes::init(); + } + + if ( isset( $page_slugs['subscribers'] ) && $page_slugs['subscribers'] === $page ) { PUM_Admin_Subscribers::init(); + } + + if ( isset( $page_slugs['settings'] ) && $page_slugs['settings'] === $page ) { PUM_Admin_Settings::init(); + } + + if ( isset( $page_slugs['tools'] ) && $page_slugs['tools'] === $page ) { PUM_Admin_Tools::init(); - PUM_Admin_Shortcode_UI::init(); + } + + if ( isset( $page_slugs['extensions'] ) && $page_slugs['extensions'] === $page ) { + PUM_Admin_Extend::init(); + } + + if ( in_array( $post_type, [ 'popup', 'popup_theme' ], true ) || in_array( $page, $page_slugs, true ) || 'popup-maker-call-to-actions' === $page ) { PUM_Upsell::init(); - PUM_Admin_Onboarding::init(); - } else { - // Preserve programmatic saves without loading the editor classes on every frontend request. - add_action( 'save_post', [ 'PUM_Admin_Popups', 'save' ], 10, 2 ); - add_filter( 'wp_insert_post_data', [ 'PUM_Admin_Popups', 'set_slug' ], 99, 2 ); - add_action( 'save_post', [ 'PUM_Admin_Themes', 'save' ], 10, 2 ); } + } - add_filter( 'user_has_cap', [ __CLASS__, 'prevent_default_theme_deletion' ], 10, 3 ); - add_action( 'admin_init', [ __CLASS__, 'after_install' ] ); - add_action( 'admin_head', [ __CLASS__, 'clean_ui' ] ); + /** + * Register save callbacks without loading their editor implementations. + * + * @return void + */ + private static function register_lazy_save_hooks() { + add_action( 'save_post', [ 'PUM_Admin_Popups', 'save' ], 10, 2 ); + add_filter( 'wp_insert_post_data', [ 'PUM_Admin_Popups', 'set_slug' ], 99, 2 ); + add_action( 'save_post', [ 'PUM_Admin_Themes', 'save' ], 10, 2 ); + } + + /** + * Get the requested plugin page slug. + * + * @return string + */ + private static function requested_page() { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + return isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; } /** diff --git a/classes/Admin/Ajax.php b/classes/Admin/Ajax.php index c16aaf286..8ab50276f 100644 --- a/classes/Admin/Ajax.php +++ b/classes/Admin/Ajax.php @@ -24,6 +24,8 @@ public static function init() { add_action( 'wp_ajax_pum_object_search', [ __CLASS__, 'object_search' ] ); add_action( 'wp_ajax_pum_process_batch_request', [ __CLASS__, 'process_batch_request' ] ); add_action( 'wp_ajax_pum_save_enabled_state', [ __CLASS__, 'save_popup_enabled_state' ] ); + add_action( 'wp_ajax_pum_get_css_styles', [ 'PUM_Admin_Settings', 'ajax_get_css_styles' ] ); + add_action( 'wp_ajax_pum_do_shortcode', [ 'PUM_Admin_Shortcode_UI', 'do_shortcode' ] ); } /** diff --git a/classes/Admin/Assets.php b/classes/Admin/Assets.php index b1dc52dba..3c61b14fb 100644 --- a/classes/Admin/Assets.php +++ b/classes/Admin/Assets.php @@ -45,12 +45,36 @@ public static function init() { self::$css_url = Popup_Maker::$URL . 'dist/assets/'; add_action( 'admin_enqueue_scripts', [ __CLASS__, 'register_admin_scripts' ] ); + add_action( 'admin_print_scripts', [ __CLASS__, 'maybe_localize_and_templates' ], - 1 ); add_action( 'admin_print_footer_scripts', [ __CLASS__, 'maybe_localize_and_templates' ], - 1 ); + self::add_localization_before_printer( 'admin_print_scripts', 'print_head_scripts' ); + self::add_localization_before_printer( 'admin_print_footer_scripts', '_wp_footer_scripts' ); + add_action( 'admin_enqueue_scripts', [ __CLASS__, 'register_admin_styles' ], 100 ); add_action( 'admin_enqueue_scripts', [ __CLASS__, 'fix_broken_extension_scripts' ], 100 ); } + /** + * Recheck after all earlier-priority enqueues and immediately before WordPress prints scripts. + * + * @param string $hook Script printing hook. + * @param string $printer WordPress printer callback. + * + * @return void + */ + private static function add_localization_before_printer( $hook, $printer ) { + $priority = has_action( $hook, $printer ); + + if ( false === $priority ) { + return; + } + + remove_action( $hook, $printer, $priority ); + add_action( $hook, [ __CLASS__, 'maybe_localize_and_templates' ], $priority ); + add_action( $hook, $printer, $priority ); + } + public static function fix_broken_extension_scripts() { if ( wp_script_is( 'pum-mci-admin' ) && class_exists( 'PUM_MCI' ) && version_compare( PUM_MCI::$VER, '1.3.0', '<' ) && ! pum_is_settings_page() ) { @@ -62,39 +86,7 @@ public static function fix_broken_extension_scripts() { * Load Admin Scripts */ public static function register_admin_scripts() { - $admin_vars = apply_filters( - 'pum_admin_vars', - apply_filters( - 'pum_admin_var', - [ - // phpcs:ignore WordPress.Security.NonceVerification.Recommended - 'post_id' => ! empty( $_GET['post'] ) ? intval( $_GET['post'] ) : null, - 'pm_dir_url' => Popup_Maker::$URL, - 'default_provider' => pum_get_option( 'newsletter_default_provider', 'none' ), - 'homeurl' => home_url(), - 'object_search_nonce' => wp_create_nonce( 'pum_ajax_object_search_nonce' ), - 'rest_nonce' => wp_create_nonce( 'wp_rest' ), - 'I10n' => [ - 'preview_popup' => __( 'Preview', 'popup-maker' ), - 'add' => __( 'Add', 'popup-maker' ), - 'save' => __( 'Save', 'popup-maker' ), - 'update' => __( 'Update', 'popup-maker' ), - 'insert' => __( 'Insert', 'popup-maker' ), - 'cancel' => __( 'Cancel', 'popup-maker' ), - 'confirm_delete_trigger' => __( 'Are you sure you want to delete this trigger?', 'popup-maker' ), - 'confirm_delete_cookie' => __( 'Are you sure you want to delete this cookie?', 'popup-maker' ), - 'no_cookie' => __( 'None', 'popup-maker' ), - 'confirm_count_reset' => __( 'Are you sure you want to reset the open count?', 'popup-maker' ), - 'shortcode_ui_button_tooltip' => __( 'Popup Maker Shortcodes', 'popup-maker' ), - 'error_loading_shortcode_preview' => __( 'There was an error in generating the preview', 'popup-maker' ), - ], - ] - ) - ); - wp_register_script( 'pum-admin-general', self::$js_url . 'admin-general.js', [ 'jquery', 'wp-color-picker', 'jquery-ui-slider', 'wp-util' ], Popup_Maker::$VER, true ); - wp_localize_script( 'pum-admin-general', 'pum_admin_vars', $admin_vars ); - wp_register_script( 'pum-admin-batch', self::$js_url . 'admin-batch.js', [ 'pum-admin-general' ], Popup_Maker::$VER, true ); wp_register_script( 'pum-admin-popup-editor', self::$js_url . 'admin-popup-editor.js', [ 'pum-admin-general' ], Popup_Maker::$VER, true ); wp_register_script( 'pum-admin-theme-editor', self::$js_url . 'admin-theme-editor.js', [ 'pum-admin-general' ], Popup_Maker::$VER, true ); @@ -102,7 +94,6 @@ public static function register_admin_scripts() { wp_register_script( 'pum-admin-shortcode-ui', self::$js_url . 'admin-shortcode-ui.js', [ 'pum-admin-general' ], Popup_Maker::$VER, true ); // @deprecated handle. Currently loads empty file and admin-general as dependency. wp_register_script( 'popup-maker-admin', self::$js_url . 'admin-deprecated.js', [ 'pum-admin-general' ], Popup_Maker::$VER, true ); - wp_localize_script( 'pum-admin-general', 'pum_admin', $admin_vars ); if ( PUM_Utils_Upgrades::instance()->has_uncomplete_upgrades() ) { wp_enqueue_script( 'pum-admin-batch' ); @@ -140,26 +131,192 @@ public static function register_admin_scripts() { } } + /** + * Build variables for the legacy admin script only when it is enqueued. + * + * @return array + */ + private static function get_admin_vars() { + return apply_filters( + 'pum_admin_vars', + apply_filters( + 'pum_admin_var', + [ + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + 'post_id' => ! empty( $_GET['post'] ) ? intval( $_GET['post'] ) : null, + 'pm_dir_url' => Popup_Maker::$URL, + 'default_provider' => pum_get_option( 'newsletter_default_provider', 'none' ), + 'homeurl' => home_url(), + 'object_search_nonce' => wp_create_nonce( 'pum_ajax_object_search_nonce' ), + 'rest_nonce' => wp_create_nonce( 'wp_rest' ), + 'I10n' => [ + 'preview_popup' => __( 'Preview', 'popup-maker' ), + 'add' => __( 'Add', 'popup-maker' ), + 'save' => __( 'Save', 'popup-maker' ), + 'update' => __( 'Update', 'popup-maker' ), + 'insert' => __( 'Insert', 'popup-maker' ), + 'cancel' => __( 'Cancel', 'popup-maker' ), + 'confirm_delete_trigger' => __( 'Are you sure you want to delete this trigger?', 'popup-maker' ), + 'confirm_delete_cookie' => __( 'Are you sure you want to delete this cookie?', 'popup-maker' ), + 'no_cookie' => __( 'None', 'popup-maker' ), + 'confirm_count_reset' => __( 'Are you sure you want to reset the open count?', 'popup-maker' ), + 'shortcode_ui_button_tooltip' => __( 'Popup Maker Shortcodes', 'popup-maker' ), + 'error_loading_shortcode_preview' => __( 'There was an error in generating the preview', 'popup-maker' ), + ], + ] + ) + ); + } + /** * */ public static function maybe_localize_and_templates() { - if ( wp_script_is( 'pum-admin-general' ) || wp_script_is( 'popup-maker-admin' ) ) { - // Register Templates. - PUM_Admin_Templates::init(); + $admin_script_enqueued = self::script_is_enqueued( 'pum-admin-general' ); + $style_only_compat = ! $admin_script_enqueued && wp_style_is( 'pum-admin-general' ); + + if ( $style_only_compat ) { + wp_enqueue_script( 'pum-admin-general' ); + wp_script_add_data( 'pum-admin-general', 'pum_style_only_compat', true ); } - if ( wp_script_is( 'pum-admin-batch' ) ) { - wp_localize_script( - 'pum-admin-batch', - 'pum_batch_vars', - [ - 'complete' => __( 'You are all set, the upgrades completed successfully!', 'popup-maker' ), - 'unsupported_browser' => __( 'We are sorry but your browser is not compatible with this kind of file upload. Please upgrade your browser.', 'popup-maker' ), - 'import_field_required' => 'This field must be mapped for the import to proceed.', - ] - ); + if ( $admin_script_enqueued || $style_only_compat ) { + $localized_data = wp_scripts()->get_data( 'pum-admin-general', 'data' ); + $needs_admin_vars = ! self::has_localized_variable( $localized_data, 'pum_admin_vars' ); + $needs_admin = ! self::has_localized_variable( $localized_data, 'pum_admin' ); + + if ( $needs_admin_vars || $needs_admin ) { + $admin_vars = self::get_admin_vars(); + + if ( $needs_admin_vars ) { + wp_localize_script( 'pum-admin-general', 'pum_admin_vars', $admin_vars ); + } + + if ( $needs_admin ) { + wp_localize_script( 'pum-admin-general', 'pum_admin', $admin_vars ); + } + } + + // Style-only legacy consumers need globals, not the field templates. + $style_only_script = wp_scripts()->get_data( 'pum-admin-general', 'pum_style_only_compat' ); + if ( $style_only_script && self::admin_general_has_queued_consumer() ) { + wp_script_add_data( 'pum-admin-general', 'pum_style_only_compat', false ); + $style_only_script = false; + } + + $templates_initialized = wp_scripts()->get_data( 'pum-admin-general', 'pum_templates_initialized' ); + + if ( ! $style_only_script && ! $templates_initialized ) { + // Register Templates. + PUM_Admin_Templates::init(); + wp_script_add_data( 'pum-admin-general', 'pum_templates_initialized', true ); + } + } + + if ( self::script_is_enqueued( 'pum-admin-batch' ) ) { + $batch_data = wp_scripts()->get_data( 'pum-admin-batch', 'data' ); + + if ( ! self::has_localized_variable( $batch_data, 'pum_batch_vars' ) ) { + wp_localize_script( + 'pum-admin-batch', + 'pum_batch_vars', + [ + 'complete' => __( 'You are all set, the upgrades completed successfully!', 'popup-maker' ), + 'unsupported_browser' => __( 'We are sorry but your browser is not compatible with this kind of file upload. Please upgrade your browser.', 'popup-maker' ), + 'import_field_required' => 'This field must be mapped for the import to proceed.', + ] + ); + } + } + } + + /** + * Check for a complete wp_localize_script variable declaration. + * + * @param mixed $data Existing inline script data. + * @param string $variable JavaScript variable name. + * + * @return bool + */ + private static function has_localized_variable( $data, $variable ) { + if ( ! is_string( $data ) || '' === $data ) { + return false; + } + + $pattern = '/(?:^|[;\r\n])\s*var\s+' . preg_quote( $variable, '/' ) . '\s*=/m'; + + return 1 === preg_match( $pattern, $data ); + } + + /** + * Check the queued dependency tree for a script handle. + * + * @param string $target Script handle. + * + * @return bool + */ + private static function script_is_enqueued( $target ) { + $wp_scripts = wp_scripts(); + $pending = $wp_scripts->queue; + $checked = []; + + while ( ! empty( $pending ) ) { + $handle = array_pop( $pending ); + + if ( $target === $handle ) { + return true; + } + + if ( isset( $checked[ $handle ] ) ) { + continue; + } + + $checked[ $handle ] = true; + + if ( isset( $wp_scripts->registered[ $handle ] ) ) { + $pending = array_merge( $pending, $wp_scripts->registered[ $handle ]->deps ); + } + } + + return false; + } + + /** + * Check whether a queued script depends on the legacy general admin script. + * + * @return bool + */ + private static function admin_general_has_queued_consumer() { + $wp_scripts = wp_scripts(); + + foreach ( $wp_scripts->queue as $queued_handle ) { + if ( 'pum-admin-general' === $queued_handle ) { + continue; + } + + $pending = [ $queued_handle ]; + $checked = []; + + while ( ! empty( $pending ) ) { + $handle = array_pop( $pending ); + + if ( isset( $checked[ $handle ] ) || ! isset( $wp_scripts->registered[ $handle ] ) ) { + continue; + } + + $checked[ $handle ] = true; + + foreach ( $wp_scripts->registered[ $handle ]->deps as $dependency ) { + if ( 'pum-admin-general' === $dependency ) { + return true; + } + + $pending[] = $dependency; + } + } } + + return false; } /** diff --git a/classes/Admin/Pages.php b/classes/Admin/Pages.php index 2723f2379..a6f9c54cf 100644 --- a/classes/Admin/Pages.php +++ b/classes/Admin/Pages.php @@ -59,11 +59,11 @@ public static function get_submenu_capability( $key, $fallback = 'manage_options } /** - * Creates the admin submenu pages under the Popup Maker menu and assigns their - * links to global variables + * Get the filtered Popup Maker admin page definitions. + * + * @return array|null> */ - public static function register_pages() { - + public static function get_page_definitions() { $admin_pages = apply_filters( 'pum_admin_pages', [ @@ -97,6 +97,40 @@ public static function register_pages() { ] ); + return is_array( $admin_pages ) ? $admin_pages : []; + } + + /** + * Get admin page slugs keyed by page definition. + * + * @param array|null> $admin_pages Filtered page definitions. + * + * @return array + */ + public static function get_page_slugs( $admin_pages ) { + $page_slugs = []; + + foreach ( $admin_pages as $key => $page ) { + if ( ! is_array( $page ) ) { + continue; + } + + $menu_slug = ! empty( $page['menu_slug'] ) ? $page['menu_slug'] : 'pum-' . $key; + $page_slugs[ $key ] = sanitize_key( $menu_slug ); + } + + return $page_slugs; + } + + /** + * Creates the admin submenu pages under the Popup Maker menu and assigns their + * links to global variables + */ + public static function register_pages() { + + $admin_pages = self::get_page_definitions(); + $page_slugs = self::get_page_slugs( $admin_pages ); + foreach ( $admin_pages as $key => $page ) { // Skip pages removed by an integration. if ( null === $page ) { @@ -133,6 +167,8 @@ public static function register_pages() { $GLOBALS[ 'popmake_' . $key . '_page' ] = self::$pages[ $key ]; } + PUM_Admin::init_request_components( $page_slugs ); + // Add shortcut to theme editor from Appearance menu. add_theme_page( __( 'Popup Themes', 'popup-maker' ), __( 'Popup Themes', 'popup-maker' ), 'edit_posts', 'edit.php?post_type=popup_theme' ); } diff --git a/classes/Admin/Settings.php b/classes/Admin/Settings.php index 960bf2418..39ee25fcf 100644 --- a/classes/Admin/Settings.php +++ b/classes/Admin/Settings.php @@ -26,8 +26,13 @@ class PUM_Admin_Settings { public static function init() { add_action( 'admin_notices', [ __CLASS__, 'notices' ] ); add_action( 'admin_init', [ __CLASS__, 'save' ] ); - add_action( 'wp_ajax_pum_get_css_styles', [ __CLASS__, 'ajax_get_css_styles' ] ); - add_action( 'plugins_loaded', [ __CLASS__, 'maybe_register_legacy_license_operation' ], 100 ); + + if ( did_action( 'plugins_loaded' ) ) { + // The handler rechecks compatibility before processing the request. + add_action( 'pum_save_settings', [ __CLASS__, 'process_license_operation' ], 10, 1 ); + } else { + add_action( 'plugins_loaded', [ __CLASS__, 'maybe_register_legacy_license_operation' ], 100 ); + } } /** diff --git a/classes/Admin/Tools.php b/classes/Admin/Tools.php index 71a490490..5a13df746 100644 --- a/classes/Admin/Tools.php +++ b/classes/Admin/Tools.php @@ -30,8 +30,6 @@ public static function init() { add_action( 'pum_tools_page_tab_error_log', [ __CLASS__, 'errorlog_display' ] ); add_action( 'pum_tools_page_tab_action_scheduler', [ __CLASS__, 'action_scheduler_display' ] ); add_action( 'pum_tools_page_tab_import', [ __CLASS__, 'import_display' ] ); - add_action( 'pum_save_enabled_betas', [ __CLASS__, 'save_enabled_betas' ] ); - add_action( 'pum_empty_error_log', [ __CLASS__, 'error_log_empty' ] ); } /** diff --git a/classes/Controllers/WP/Dashboard.php b/classes/Controllers/WP/Dashboard.php index 1b727bff8..7e0ef0c4c 100644 --- a/classes/Controllers/WP/Dashboard.php +++ b/classes/Controllers/WP/Dashboard.php @@ -26,7 +26,7 @@ class Dashboard extends Controller { * @return void */ public function init() { - // Register dashboard widgets + // Register dashboard widgets. add_action( 'wp_dashboard_setup', [ $this, 'register_dashboard_widgets' ] ); add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_dashboard_scripts' ] ); @@ -41,7 +41,7 @@ public function register_dashboard_widgets() { // Basic Analytics Widget (will be moved to free plugin). wp_add_dashboard_widget( 'pum_analytics_basic', - __( 'Popup Analytics','popup-maker' ), + __( 'Popup Analytics', 'popup-maker' ), [ $this, 'render_basic_analytics_widget' ] ); } @@ -54,7 +54,7 @@ public function register_dashboard_widgets() { public function enqueue_dashboard_scripts() { $current_screen = get_current_screen(); - if ( ! $current_screen || $current_screen->id !== 'dashboard' ) { + if ( ! $current_screen || 'dashboard' !== $current_screen->id ) { return; } @@ -64,7 +64,7 @@ public function enqueue_dashboard_scripts() { wp_enqueue_script( 'popup-maker-dashboard' ); wp_enqueue_style( 'popup-maker-dashboard' ); - } + } /** * Render basic analytics widget. @@ -77,39 +77,38 @@ public function render_basic_analytics_widget() { return; } - $upgrade_link = 'https://wppopupmaker.com/pricing/?utm_source=wp-dashboard&utm_medium=dashboard&utm_campaign=upgrade-to-pro'; - // Get analytics data - - $stats = $this->get_dashboard_stats(); - $total_views = $stats['total_views']; + $upgrade_link = 'https://wppopupmaker.com/pricing/?utm_source=wp-dashboard&utm_medium=dashboard&utm_campaign=upgrade-to-pro'; + $stats = $this->get_dashboard_stats(); + $total_views = $stats['total_views']; $total_conversions = $stats['total_conversions']; - $conversion_rate = $stats['conversion_rate']; - $top_performer = $stats['top_performer']; + $conversion_rate = $stats['conversion_rate']; + $top_performer = $stats['top_performer']; ?>
- +
-
+
-
+
%
-
+
- @@ -118,11 +117,11 @@ public function render_basic_analytics_widget() {
👑 - +
-

+

- +
@@ -133,81 +132,15 @@ public function render_basic_analytics_widget() { /** * Get top performing popup. * - * @return \PUM_Model_Popup|null + * @return array{ + * total_views: int, + * total_conversions: int, + * conversion_rate: float, + * top_performer: \WP_Post|null, + * top_performer_rate: float + * } */ private function get_dashboard_stats() { - $popups = pum_get_all_popups([ - 'status' => 'publish', - 'posts_per_page' => -1, - 'meta_query' => [ - 'relation' => 'AND', - [ - 'key' => 'enabled', - 'value' => 1, - 'compare' => '=', - ], - [ - 'key' => 'popup_open_count', - 'value' => 0, - 'compare' => '>', - 'type' => 'NUMERIC', - ], - ], - ]); - - if ( empty( $popups ) ) { - return [ - 'total_views' => 0, - 'total_conversions' => 0, - 'conversion_rate' => 0, - 'top_performer' => null, - 'top_performer_rate' => 0, - ]; - } - - // Sort by priority: conversion rate > conversions > views - usort( $popups, function( $a, $b ) { - $a_rate = (float) $a->get_meta( 'popup_conversion_rate' ); - $b_rate = (float) $b->get_meta( 'popup_conversion_rate' ); - - // First priority: conversion rate - if ( $a_rate !== $b_rate ) { - return $b_rate <=> $a_rate; // Descending - } - - $a_conversions = (int) $a->get_meta( 'popup_conversion_count' ); - $b_conversions = (int) $b->get_meta( 'popup_conversion_count' ); - - // Second priority: conversions - if ( $a_conversions !== $b_conversions ) { - return $b_conversions <=> $a_conversions; // Descending - } - - // Third priority: views - $a_views = (int) $a->get_meta( 'popup_open_count' ); - $b_views = (int) $b->get_meta( 'popup_open_count' ); - - return $b_views <=> $a_views; // Descending - }); - - $top_performer = $popups[0]; - $top_performer_rate = ( (int) $top_performer->get_meta( 'popup_conversion_count' ) / (int) $top_performer->get_meta( 'popup_open_count' ) ) * 100; - - $popup_views = 0; - $popup_conversions = 0; - - foreach ( $popups as $popup ) { - $popup_views += (int) $popup->get_meta( 'popup_open_count' ); - $popup_conversions += (int) $popup->get_meta( 'popup_conversion_count' ); - } - - return [ - 'total_views' => $popup_views, - 'total_conversions' => $popup_conversions, - 'conversion_rate' => $popup_conversions / $popup_views * 100, - 'top_performer' => $top_performer, - 'top_performer_rate' => $top_performer_rate, - ]; + return $this->container->get( 'popups' )->get_dashboard_stats(); } - } diff --git a/classes/Services/Repository/Popups.php b/classes/Services/Repository/Popups.php index 798fdac0c..219f8ff63 100644 --- a/classes/Services/Repository/Popups.php +++ b/classes/Services/Repository/Popups.php @@ -146,6 +146,147 @@ public function get_title_choices( $ids ) { return $this->filter_title_choices( $title_choices ); } + /** + * Get aggregate analytics for the WordPress Dashboard widget. + * + * @return array{ + * total_views: int, + * total_conversions: int, + * conversion_rate: float, + * top_performer: \WP_Post|null, + * top_performer_rate: float + * } + */ + public function get_dashboard_stats() { + global $wpdb; + + $query = new \WP_Query( + [ + 'post_type' => $this->post_type, + 'post_status' => 'publish', + 'posts_per_page' => -1, + 'fields' => 'ids', + 'no_found_rows' => true, + 'orderby' => 'modified', + 'order' => 'DESC', + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + // Restrict the ID-only query before filling the shared meta cache. + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query + 'meta_query' => [ + 'relation' => 'AND', + [ + 'key' => 'enabled', + 'value' => 1, + 'compare' => '=', + ], + [ + 'key' => 'popup_open_count', + 'value' => 0, + 'compare' => '>', + 'type' => 'NUMERIC', + ], + ], + ] + ); + + $popup_ids = array_map( 'absint', $query->posts ); + + if ( empty( $popup_ids ) ) { + return [ + 'total_views' => 0, + 'total_conversions' => 0, + 'conversion_rate' => 0.0, + 'top_performer' => null, + 'top_performer_rate' => 0.0, + ]; + } + + $meta_keys = [ 'popup_open_count', 'popup_conversion_count', 'popup_conversion_rate' ]; + $id_placeholders = implode( ', ', array_fill( 0, count( $popup_ids ), '%d' ) ); + $key_placeholders = implode( ', ', array_fill( 0, count( $meta_keys ), '%s' ) ); + $meta_query = "SELECT post_id, meta_key, meta_value FROM %i WHERE post_id IN ($id_placeholders) AND meta_key IN ($key_placeholders) ORDER BY meta_id ASC"; + $meta_query_args = array_merge( [ $wpdb->postmeta ], $popup_ids, $meta_keys ); + + // Read only the counters used by the dashboard instead of priming all popup metadata. + // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared + $meta_rows = $wpdb->get_results( $wpdb->prepare( $meta_query, $meta_query_args ), ARRAY_A ); + $meta_rows = is_array( $meta_rows ) ? $meta_rows : []; + $meta_map = []; + + foreach ( $meta_rows as $meta_row ) { + $popup_id = isset( $meta_row['post_id'] ) ? absint( $meta_row['post_id'] ) : 0; + $meta_key = isset( $meta_row['meta_key'] ) ? (string) $meta_row['meta_key'] : ''; + + // Match get_post_meta( ..., true ) by retaining the first stored value. + if ( 0 === $popup_id || ! in_array( $meta_key, $meta_keys, true ) || isset( $meta_map[ $popup_id ][ $meta_key ] ) ) { + continue; + } + + $meta_map[ $popup_id ][ $meta_key ] = isset( $meta_row['meta_value'] ) ? $meta_row['meta_value'] : ''; + } + + $total_views = 0; + $total_conversions = 0; + $top_id = 0; + $top_stored_rate = 0.0; + $top_conversions = 0; + $top_views = 0; + + foreach ( $popup_ids as $popup_id ) { + $popup_meta = isset( $meta_map[ $popup_id ] ) ? $meta_map[ $popup_id ] : []; + $views = (int) $this->get_dashboard_meta_value( $popup_id, 'popup_open_count', $popup_meta ); + $conversions = (int) $this->get_dashboard_meta_value( $popup_id, 'popup_conversion_count', $popup_meta ); + $stored_rate = (float) $this->get_dashboard_meta_value( $popup_id, 'popup_conversion_rate', $popup_meta ); + + $total_views += $views; + $total_conversions += $conversions; + + if ( + 0 === $top_id + || $stored_rate > $top_stored_rate + || ( $stored_rate === $top_stored_rate && $conversions > $top_conversions ) + || ( $stored_rate === $top_stored_rate && $conversions === $top_conversions && $views > $top_views ) + ) { + $top_id = $popup_id; + $top_stored_rate = $stored_rate; + $top_conversions = $conversions; + $top_views = $views; + } + } + + return [ + 'total_views' => $total_views, + 'total_conversions' => $total_conversions, + 'conversion_rate' => $total_views > 0 ? ( $total_conversions / $total_views ) * 100 : 0.0, + 'top_performer' => get_post( $top_id ), + 'top_performer_rate' => $top_views > 0 ? ( $top_conversions / $top_views ) * 100 : 0.0, + ]; + } + + /** + * Apply WordPress metadata filters to a projected Dashboard value. + * + * @param int $popup_id Popup ID. + * @param string $meta_key Analytics metadata key. + * @param array $meta_map Projected raw metadata for the popup. + * + * @return mixed + */ + private function get_dashboard_meta_value( $popup_id, $meta_key, $meta_map ) { + $filtered_value = apply_filters( 'get_post_metadata', null, $popup_id, $meta_key, true, 'post' ); + + if ( null !== $filtered_value ) { + return is_array( $filtered_value ) ? ( isset( $filtered_value[0] ) ? $filtered_value[0] : null ) : $filtered_value; + } + + if ( array_key_exists( $meta_key, $meta_map ) ) { + return maybe_unserialize( $meta_map[ $meta_key ] ); + } + + return apply_filters( 'default_post_metadata', '', $popup_id, $meta_key, true, 'post' ); + } + /** * Allow per-request filtering of a raw ID => title map. * diff --git a/tests/php/tests/Dashboard_Controller_Test.php b/tests/php/tests/Dashboard_Controller_Test.php new file mode 100644 index 000000000..fd73d504d --- /dev/null +++ b/tests/php/tests/Dashboard_Controller_Test.php @@ -0,0 +1,93 @@ + 25, + 'total_conversions' => 5, + 'conversion_rate' => 20.0, + 'top_performer' => null, + 'top_performer_rate' => 0.0, + ]; + $repository = new class( $expected_stats ) { + /** + * @var array + */ + private $stats; + + /** + * @var int + */ + public $calls = 0; + + /** + * @param array $stats Dashboard stats. + */ + public function __construct( $stats ) { + $this->stats = $stats; + } + + /** + * @return array + */ + public function get_dashboard_stats() { + ++$this->calls; + + return $this->stats; + } + }; + $container = new class( $repository ) { + /** + * @var object + */ + private $repository; + + /** + * @var array + */ + public $requested_services = []; + + /** + * @param object $repository Popup repository. + */ + public function __construct( $repository ) { + $this->repository = $repository; + } + + /** + * @param string $service Service key. + * @return object + */ + public function get( $service ) { + $this->requested_services[] = $service; + + return $this->repository; + } + }; + + $controller = new \PopupMaker\Controllers\WP\Dashboard( $container ); + $method = new ReflectionMethod( $controller, 'get_dashboard_stats' ); + + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + + $this->assertSame( $expected_stats, $method->invoke( $controller ) ); + $this->assertSame( [ 'popups' ], $container->requested_services ); + $this->assertSame( 1, $repository->calls ); + } +} diff --git a/tests/php/tests/PUM_Admin_Assets_Test.php b/tests/php/tests/PUM_Admin_Assets_Test.php new file mode 100644 index 000000000..217c58b49 --- /dev/null +++ b/tests/php/tests/PUM_Admin_Assets_Test.php @@ -0,0 +1,288 @@ +done = array_values( array_diff( $wp_scripts->done, $handles ) ); + $wp_scripts->to_do = array_values( array_diff( $wp_scripts->to_do, $handles ) ); + + wp_dequeue_style( 'pum-admin-general' ); + remove_action( 'admin_footer', [ 'PUM_Admin_Templates', 'render' ] ); + + parent::tearDown(); + } + + /** + * @return void + */ + public function test_admin_vars_are_deferred_until_general_script_is_needed() { + $filter_calls = 0; + + add_filter( + 'pum_admin_vars', + function ( $vars ) use ( &$filter_calls ) { + ++$filter_calls; + + return $vars; + } + ); + + PUM_Admin_Assets::init(); + PUM_Admin_Assets::register_admin_scripts(); + + $this->assertSame( 0, $filter_calls ); + + wp_enqueue_script( 'pum-admin-popup-editor' ); + PUM_Admin_Assets::maybe_localize_and_templates(); + + $this->assertSame( 1, $filter_calls ); + + $localized_data = wp_scripts()->get_data( 'pum-admin-general', 'data' ); + + $this->assertIsString( $localized_data ); + $this->assertStringContainsString( 'var pum_admin_vars', $localized_data ); + $this->assertStringContainsString( 'var pum_admin', $localized_data ); + } + + /** + * Legacy extensions (e.g. Popup Analytics) enqueue only the + * pum-admin-general STYLE on their own admin pages while their JS reads + * window.pum_admin_vars. The style enqueue must count as a signal. + * + * @return void + */ + public function test_admin_vars_localize_when_only_general_style_is_enqueued() { + PUM_Admin_Assets::init(); + PUM_Admin_Assets::register_admin_scripts(); + PUM_Admin_Assets::register_admin_styles(); + + $template_priority = has_action( 'admin_footer', [ 'PUM_Admin_Templates', 'render' ] ); + + wp_enqueue_style( 'pum-admin-general' ); + PUM_Admin_Assets::maybe_localize_and_templates(); + + $localized_data = wp_scripts()->get_data( 'pum-admin-general', 'data' ); + + $this->assertIsString( $localized_data ); + $this->assertStringContainsString( 'var pum_admin_vars', $localized_data ); + $this->assertTrue( wp_script_is( 'pum-admin-general', 'enqueued' ) ); + $this->assertSame( $template_priority, has_action( 'admin_footer', [ 'PUM_Admin_Templates', 'render' ] ) ); + + $output = wp_scripts()->print_extra_script( 'pum-admin-general', false ); + + $this->assertIsString( $output ); + $this->assertStringContainsString( 'var pum_admin_vars', $output ); + } + + /** + * Late script consumers restore the templates skipped for style-only usage. + * + * @return void + */ + public function test_late_script_consumer_loads_templates_after_style_only_compatibility() { + PUM_Admin_Assets::init(); + PUM_Admin_Assets::register_admin_scripts(); + PUM_Admin_Assets::register_admin_styles(); + + wp_enqueue_style( 'pum-admin-general' ); + PUM_Admin_Assets::maybe_localize_and_templates(); + + $this->assertTrue( wp_scripts()->get_data( 'pum-admin-general', 'pum_style_only_compat' ) ); + $this->assertFalse( has_action( 'admin_footer', [ 'PUM_Admin_Templates', 'render' ] ) ); + + wp_register_script( 'pum-admin-late-consumer', 'https://example.com/late.js', [ 'pum-admin-general' ], '1.0.0', true ); + wp_enqueue_script( 'pum-admin-late-consumer' ); + PUM_Admin_Assets::maybe_localize_and_templates(); + + $this->assertFalse( wp_scripts()->get_data( 'pum-admin-general', 'pum_style_only_compat' ) ); + $this->assertSame( 10, has_action( 'admin_footer', [ 'PUM_Admin_Templates', 'render' ] ) ); + } + + /** + * Header consumers receive legacy globals before their scripts print. + * + * @return void + */ + public function test_admin_vars_print_before_header_script_consumers() { + PUM_Admin_Assets::init(); + PUM_Admin_Assets::register_admin_scripts(); + + $enqueue_consumer = function () { + wp_register_script( 'pum-admin-header-consumer', 'https://example.com/header.js', [ 'pum-admin-general' ], '1.0.0', false ); + wp_script_add_data( 'pum-admin-general', 'group', 0 ); + wp_enqueue_script( 'pum-admin-header-consumer' ); + }; + + add_action( 'admin_print_scripts', $enqueue_consumer, 19 ); + + $this->assertSame( -1, has_action( 'admin_print_scripts', [ 'PUM_Admin_Assets', 'maybe_localize_and_templates' ] ) ); + + ob_start(); + do_action( 'admin_print_scripts' ); + $output = ob_get_clean(); + + remove_action( 'admin_print_scripts', $enqueue_consumer, 19 ); + + $this->assertIsString( $output ); + $this->assertStringContainsString( 'var pum_admin_vars', $output ); + $this->assertLessThan( strpos( $output, 'header.js' ), strpos( $output, 'var pum_admin_vars' ) ); + } + + /** + * Footer consumers enqueued after the early localization pass still receive globals. + * + * @return void + */ + public function test_admin_vars_print_for_scripts_enqueued_during_footer_hook() { + PUM_Admin_Assets::init(); + PUM_Admin_Assets::register_admin_scripts(); + + $enqueue_consumer = function () { + wp_register_script( 'pum-admin-footer-consumer', 'https://example.com/footer.js', [ 'pum-admin-general' ], '1.0.0', true ); + wp_enqueue_script( 'pum-admin-footer-consumer' ); + }; + $printer_priority = has_action( 'admin_print_footer_scripts', '_wp_footer_scripts' ); + + $this->assertIsInt( $printer_priority ); + $enqueue_priority = $printer_priority - 1; + add_action( 'admin_print_footer_scripts', $enqueue_consumer, $enqueue_priority ); + + $this->assertFalse( wp_scripts()->get_data( 'pum-admin-general', 'data' ) ); + + ob_start(); + do_action( 'admin_footer', '' ); + ob_end_clean(); + + ob_start(); + do_action( 'admin_print_footer_scripts' ); + $output = ob_get_clean(); + + remove_action( 'admin_print_footer_scripts', $enqueue_consumer, $enqueue_priority ); + + $localized_data = wp_scripts()->get_data( 'pum-admin-general', 'data' ); + + $this->assertIsString( $localized_data ); + $this->assertStringContainsString( 'var pum_admin_vars', $localized_data ); + $this->assertIsString( $output ); + $this->assertStringContainsString( 'var pum_admin_vars', $output ); + $this->assertStringContainsString( 'footer.js', $output ); + $this->assertLessThan( strpos( $output, 'footer.js' ), strpos( $output, 'var pum_admin_vars' ) ); + $this->assertSame( 1, substr_count( $output, 'id="tmpl-pum-field-text"' ) ); + } + + /** + * Batch variables are localized when the batch script is a queued dependency. + * + * @return void + */ + public function test_batch_vars_localize_for_queued_dependency() { + PUM_Admin_Assets::init(); + PUM_Admin_Assets::register_admin_scripts(); + + wp_register_script( 'pum-admin-batch-consumer', 'https://example.com/batch.js', [ 'pum-admin-batch' ], '1.0.0', true ); + wp_enqueue_script( 'pum-admin-batch-consumer' ); + + PUM_Admin_Assets::maybe_localize_and_templates(); + + $localized_data = wp_scripts()->get_data( 'pum-admin-batch', 'data' ); + + $this->assertIsString( $localized_data ); + $this->assertStringContainsString( 'var pum_batch_vars', $localized_data ); + } + + /** + * Similar variable names do not suppress the required legacy globals. + * + * @return void + */ + public function test_admin_vars_require_complete_variable_declarations() { + PUM_Admin_Assets::init(); + PUM_Admin_Assets::register_admin_scripts(); + + wp_localize_script( 'pum-admin-general', 'pum_admin_vars_extra', [] ); + wp_enqueue_script( 'pum-admin-general' ); + + PUM_Admin_Assets::maybe_localize_and_templates(); + + $localized_data = wp_scripts()->get_data( 'pum-admin-general', 'data' ); + + $this->assertIsString( $localized_data ); + $this->assertSame( 1, preg_match_all( '/(?:^|[;\r\n])\s*var\s+pum_admin_vars\s*=/m', $localized_data ) ); + $this->assertSame( 1, preg_match_all( '/(?:^|[;\r\n])\s*var\s+pum_admin\s*=/m', $localized_data ) ); + } + + /** + * Repeated print hooks add each legacy global at most once. + * + * @return void + */ + public function test_admin_vars_are_deduplicated_independently() { + PUM_Admin_Assets::init(); + PUM_Admin_Assets::register_admin_scripts(); + + wp_localize_script( 'pum-admin-general', 'pum_admin_vars', [ 'existing' => true ] ); + wp_enqueue_script( 'pum-admin-general' ); + + PUM_Admin_Assets::maybe_localize_and_templates(); + PUM_Admin_Assets::maybe_localize_and_templates(); + + $localized_data = wp_scripts()->get_data( 'pum-admin-general', 'data' ); + + $this->assertIsString( $localized_data ); + $this->assertSame( 1, preg_match_all( '/(?:^|[;\r\n])\s*var\s+pum_admin_vars\s*=/m', $localized_data ) ); + $this->assertSame( 1, preg_match_all( '/(?:^|[;\r\n])\s*var\s+pum_admin\s*=/m', $localized_data ) ); + } + + /** + * Test cleanup preserves WordPress-owned script registrations. + * + * @return void + */ + public function test_wordpress_color_picker_registration_is_preserved() { + $this->assertTrue( wp_script_is( 'wp-color-picker', 'registered' ) ); + } +} diff --git a/tests/php/tests/PUM_Admin_Loader_Test.php b/tests/php/tests/PUM_Admin_Loader_Test.php index b34379f5c..a5149d9f1 100644 --- a/tests/php/tests/PUM_Admin_Loader_Test.php +++ b/tests/php/tests/PUM_Admin_Loader_Test.php @@ -13,22 +13,38 @@ class PUM_Admin_Loader_Test extends WP_UnitTestCase { /** - * Admin requests retain the complete admin bootstrap. + * Generic admin requests load shared components but defer screen components. * * @runInSeparateProcess * @preserveGlobalState disabled * * @return void */ - public function test_admin_loads_all_admin_components() { + public function test_generic_admin_loads_only_shared_admin_components() { + global $pagenow; + + $pagenow = 'index.php'; set_current_screen( 'dashboard' ); + remove_all_actions( 'admin_menu' ); PUM_Admin::init(); + $this->assertSame( 20, has_action( 'admin_init', [ 'PUM_Admin_Shortcode_UI', 'init_editor' ] ) ); + $this->assertTrue( class_exists( 'PUM_Admin_Shortcode_UI', false ) ); + do_action( 'admin_menu' ); $this->assertTrue( is_admin() ); $this->assertSame( 10, has_action( 'admin_menu', [ 'PUM_Admin_Pages', 'register_pages' ] ) ); $this->assertSame( 10, has_action( 'wp_ajax_pum_object_search', [ 'PUM_Admin_Ajax', 'object_search' ] ) ); - $this->assertSame( 10, has_action( 'admin_init', [ 'PUM_Admin_Settings', 'save' ] ) ); + $this->assertSame( 10, has_action( 'save_post', [ 'PUM_Admin_Popups', 'save' ] ) ); + $this->assertSame( 10, has_action( 'save_post', [ 'PUM_Admin_Themes', 'save' ] ) ); + $this->assertFalse( has_action( 'admin_init', [ 'PUM_Admin_Settings', 'save' ] ) ); + $this->assertFalse( class_exists( 'PUM_Admin_Settings', false ) ); + $this->assertSame( 10, has_action( 'pum_save_enabled_betas', [ 'PUM_Admin_Tools', 'save_enabled_betas' ] ) ); + $this->assertSame( 10, has_action( 'pum_empty_error_log', [ 'PUM_Admin_Tools', 'error_log_empty' ] ) ); + $this->assertFalse( class_exists( 'PUM_Admin_Tools', false ) ); + $this->assertSame( 20, has_action( 'admin_init', [ 'PUM_Admin_Shortcode_UI', 'init_editor' ] ) ); + $this->assertTrue( class_exists( 'PUM_Admin_Shortcode_UI', false ) ); + $this->assertFalse( class_exists( 'PUM_Upsell', false ) ); $container = new PUM_Test_Controller_Container(); $admin = new \PopupMaker\Controllers\Admin( $container ); @@ -40,10 +56,365 @@ public function test_admin_loads_all_admin_components() { $this->assertArrayHasKey( 'Admin\CallToActions', $container->registered ); } + /** + * Tools request callbacks exist before the init request dispatcher runs. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + * + * @return void + */ + public function test_tools_request_actions_register_before_init_dispatch() { + global $pagenow; + + $pagenow = 'index.php'; + set_current_screen( 'dashboard' ); + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + PUM_Utils_Options::delete( 'enabled_betas' ); + + remove_action( 'pum_save_enabled_betas', [ 'PUM_Admin_Tools', 'save_enabled_betas' ] ); + remove_action( 'pum_empty_error_log', [ 'PUM_Admin_Tools', 'error_log_empty' ] ); + + PUM_Admin::init(); + + $this->assertFalse( class_exists( 'PUM_Admin_Tools', false ) ); + $this->assertSame( 10, has_action( 'pum_save_enabled_betas', [ 'PUM_Admin_Tools', 'save_enabled_betas' ] ) ); + + $_REQUEST['pum_action'] = 'save_enabled_betas'; + $_POST['pum_save_betas_nonce'] = wp_create_nonce( 'pum_save_betas_nonce' ); + $_POST['enabled_betas'] = [ 'example-extension' => 'true' ]; + + PUM_Site::actions(); + + $this->assertSame( [ 'example-extension' => true ], pum_get_option( 'enabled_betas' ) ); + } + + /** + * Popup save callbacks exist before earlier init callbacks can write posts. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + * + * @return void + */ + public function test_popup_save_hooks_register_before_init_writers() { + global $pagenow; + + $pagenow = 'index.php'; + set_current_screen( 'dashboard' ); + remove_action( 'save_post', [ 'PUM_Admin_Popups', 'save' ], 10 ); + remove_filter( 'wp_insert_post_data', [ 'PUM_Admin_Popups', 'set_slug' ], 99 ); + remove_action( 'save_post', [ 'PUM_Admin_Themes', 'save' ], 10 ); + + PUM_Admin::init(); + + $this->assertSame( 10, has_action( 'save_post', [ 'PUM_Admin_Popups', 'save' ] ) ); + $this->assertSame( 99, has_filter( 'wp_insert_post_data', [ 'PUM_Admin_Popups', 'set_slug' ] ) ); + $this->assertSame( 10, has_action( 'save_post', [ 'PUM_Admin_Themes', 'save' ] ) ); + } + + /** + * Request components use the single filtered admin_menu definition pass. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + * + * @return void + */ + public function test_request_components_use_single_admin_menu_definition_pass() { + global $pagenow; + + $pagenow = 'index.php'; + set_current_screen( 'dashboard' ); + remove_all_actions( 'admin_menu' ); + $filter_calls = 0; + $page_filter = function ( $pages ) use ( &$filter_calls ) { + ++$filter_calls; + + return $pages; + }; + + add_filter( 'pum_admin_pages', $page_filter ); + + PUM_Admin::init(); + + $this->assertSame( 0, $filter_calls ); + $this->assertFalse( has_action( 'init', [ 'PUM_Admin', 'init_request_components' ] ) ); + + do_action( 'admin_menu' ); + + $this->assertSame( 1, $filter_calls ); + $this->assertTrue( class_exists( 'PUM_Admin_Shortcode_UI', false ) ); + } + + /** + * Premium previews remain available when registries initialize before admin_menu. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + * + * @return void + */ + public function test_preview_registry_hooks_exist_before_screen_ui_initializes() { + global $pagenow; + + $pagenow = 'index.php'; + set_current_screen( 'dashboard' ); + remove_all_actions( 'admin_menu' ); + remove_all_filters( 'pum_registered_triggers' ); + remove_all_filters( 'pum_registered_conditions' ); + + PUM_Admin::init(); + + $this->assertFalse( class_exists( 'PUM_Upsell', false ) ); + $this->assertSame( 10, has_filter( 'pum_registered_triggers', [ 'PUM_Upsell', 'register_preview_triggers' ] ) ); + $this->assertSame( 10, has_filter( 'pum_registered_conditions', [ 'PUM_Upsell', 'register_preview_conditions' ] ) ); + $this->assertFalse( has_action( 'in_admin_header', [ 'PUM_Upsell', 'notice_bar_display' ] ) ); + + $triggers = PUM_Triggers::instance()->get_triggers(); + $conditions = PUM_Conditions::instance()->get_conditions(); + + $this->assertArrayHasKey( 'exit_intent', $triggers ); + $this->assertArrayHasKey( 'user_is_logged_in', $conditions ); + $this->assertTrue( $triggers['exit_intent']['pro_required'] ); + $this->assertTrue( $conditions['user_is_logged_in']['pro_required'] ); + } + + /** + * Request matching uses the same translated definitions as menu registration. + * + * @return void + */ + public function test_page_slug_resolution_uses_translated_filter_input() { + $translate = function ( $translation, $text, $domain ) { + return 'popup-maker' === $domain && 'Settings' === $text ? 'Einstellungen' : $translation; + }; + $pages = function ( $definitions ) { + $definitions['settings']['menu_slug'] = sanitize_title( $definitions['settings']['page_title'] ); + + return $definitions; + }; + + add_filter( 'gettext', $translate, 10, 3 ); + add_filter( 'pum_admin_pages', $pages ); + + $page_slugs = PUM_Admin_Pages::get_page_slugs( PUM_Admin_Pages::get_page_definitions() ); + + remove_filter( 'pum_admin_pages', $pages ); + remove_filter( 'gettext', $translate, 10 ); + + $this->assertSame( 'einstellungen', $page_slugs['settings'] ); + } + + /** + * Admin AJAX keeps the shortcode preview callback available without the editor UI. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + * + * @return void + */ + public function test_admin_ajax_registers_shortcode_preview_callback_without_editor_ui() { + global $pagenow; + + $pagenow = 'admin-ajax.php'; + set_current_screen( 'admin-ajax' ); + $page_filter_calls = 0; + $page_filter = function ( $pages ) use ( &$page_filter_calls ) { + ++$page_filter_calls; + + return $pages; + }; + + add_filter( 'wp_doing_ajax', '__return_true' ); + add_filter( 'pum_admin_pages', $page_filter ); + + PUM_Admin::init(); + + remove_filter( 'pum_admin_pages', $page_filter ); + remove_filter( 'wp_doing_ajax', '__return_true' ); + + $this->assertSame( 0, $page_filter_calls ); + $this->assertSame( 10, has_action( 'wp_ajax_pum_do_shortcode', [ 'PUM_Admin_Shortcode_UI', 'do_shortcode' ] ) ); + $this->assertSame( 10, has_action( 'wp_ajax_pum_get_css_styles', [ 'PUM_Admin_Settings', 'ajax_get_css_styles' ] ) ); + $this->assertFalse( class_exists( 'PUM_Admin_Shortcode_UI', false ) ); + $this->assertFalse( class_exists( 'PUM_Admin_Settings', false ) ); + } + + /** + * Popup Maker settings requests initialize their screen-specific callbacks. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + * + * @return void + */ + public function test_settings_request_loads_settings_and_upsell_components() { + global $pagenow; + + $pagenow = 'edit.php'; + $_GET['page'] = 'pum-settings'; + set_current_screen( 'popup_page_pum-settings' ); + remove_all_actions( 'admin_menu' ); + + PUM_Admin::init(); + do_action( 'admin_menu' ); + + $this->assertSame( 10, has_action( 'admin_init', [ 'PUM_Admin_Settings', 'save' ] ) ); + $this->assertSame( 10, has_action( 'in_admin_header', [ 'PUM_Upsell', 'notice_bar_display' ] ) ); + $this->assertTrue( class_exists( 'PUM_Admin_Settings', false ) ); + $this->assertTrue( class_exists( 'PUM_Upsell', false ) ); + } + + /** + * Filtered page slugs retain their screen-specific callbacks. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + * + * @return void + */ + public function test_filtered_settings_page_slug_loads_settings_and_upsell_components() { + global $pagenow; + + add_filter( + 'pum_admin_pages', + function ( $pages ) { + $pages['settings']['menu_slug'] = 'custom-popup-settings'; + + return $pages; + } + ); + + $pagenow = 'edit.php'; + $_GET['page'] = 'custom-popup-settings'; + set_current_screen( 'popup_page_custom-popup-settings' ); + remove_all_actions( 'admin_menu' ); + + PUM_Admin::init(); + do_action( 'admin_menu' ); + + $this->assertSame( 10, has_action( 'admin_init', [ 'PUM_Admin_Settings', 'save' ] ) ); + $this->assertSame( 10, has_action( 'in_admin_header', [ 'PUM_Upsell', 'notice_bar_display' ] ) ); + $this->assertTrue( class_exists( 'PUM_Admin_Settings', false ) ); + $this->assertTrue( class_exists( 'PUM_Upsell', false ) ); + } + + /** + * Late page filters initialize handlers before the normal admin_init pass. + * + * WordPress loads wp-admin/menu.php, which fires admin_menu, before firing + * admin_init in wp-admin/admin.php. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + * + * @return void + */ + public function test_admin_menu_page_filter_initializes_without_manual_request_replay() { + global $pagenow; + + $pagenow = 'edit.php'; + $_GET['page'] = 'late-popup-admin'; + $_GET['post_type'] = 'popup'; + set_current_screen( 'popup_page_late-popup-admin' ); + remove_all_actions( 'admin_menu' ); + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + $_POST['pum_settings_nonce'] = wp_create_nonce( 'pum_settings_nonce' ); + $_POST['pum_settings'] = [ 'google_fonts_api_key' => 'late-filter-key' ]; + $save_calls = 0; + $page_filter_calls = 0; + + add_action( + 'pum_save_settings', + function () use ( &$save_calls ) { + ++$save_calls; + } + ); + + add_action( + 'admin_menu', + function () use ( &$page_filter_calls ) { + add_filter( + 'pum_admin_pages', + function ( $pages ) use ( &$page_filter_calls ) { + ++$page_filter_calls; + $pages['settings']['menu_slug'] = 'late-popup-admin'; + $pages['tools']['menu_slug'] = 'late-popup-admin'; + + return $pages; + } + ); + }, + 1 + ); + + PUM_Admin::init(); + do_action( 'admin_menu' ); + + $this->assertSame( 10, has_action( 'admin_init', [ 'PUM_Admin_Settings', 'save' ] ) ); + $this->assertSame( 10, has_action( 'admin_init', [ 'PUM_Admin_Tools', 'emodal_process_import' ] ) ); + $this->assertTrue( class_exists( 'PUM_Admin_Settings', false ) ); + $this->assertTrue( class_exists( 'PUM_Admin_Tools', false ) ); + $this->assertArrayHasKey( 'settings', PUM_Admin_Pages::$pages ); + $this->assertArrayHasKey( 'tools', PUM_Admin_Pages::$pages ); + $this->assertSame( 1, $page_filter_calls ); + $this->assertSame( 0, $save_calls ); + $this->assertNotSame( 'late-filter-key', pum_get_option( 'google_fonts_api_key' ) ); + + do_action( 'admin_init' ); + + $this->assertSame( 1, $save_calls ); + $this->assertSame( 'late-filter-key', pum_get_option( 'google_fonts_api_key' ) ); + } + + /** + * Shortcode editor integration remains available on non-post editor screens. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + * @dataProvider editor_screen_provider + * + * @param string $screen_file Admin screen filename. + * + * @return void + */ + public function test_shortcode_ui_supports_nonstandard_editor_screens( $screen_file ) { + global $pagenow; + + $pagenow = $screen_file; + set_current_screen( sanitize_key( $screen_file ) ); + remove_all_actions( 'admin_menu' ); + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + update_user_option( get_current_user_id(), 'rich_editing', 'true' ); + + PUM_Admin::init(); + do_action( 'admin_menu' ); + do_action( 'admin_init' ); + + $this->assertSame( 10, has_filter( 'mce_buttons', [ 'PUM_Admin_Shortcode_UI', 'mce_buttons' ] ) ); + $this->assertSame( 10, has_filter( 'mce_external_plugins', [ 'PUM_Admin_Shortcode_UI', 'mce_external_plugins' ] ) ); + } + + /** + * @return array + */ + public function editor_screen_provider() { + return [ + 'site editor' => [ 'site-editor.php' ], + 'widgets' => [ 'widgets.php' ], + 'plugin editor' => [ 'vendor-editor.php' ], + ]; + } + /** * @return void */ public function tearDown(): void { + unset( $_GET['page'] ); + unset( $_GET['post_type'] ); + unset( $_POST['pum_settings_nonce'], $_POST['pum_settings'] ); + wp_set_current_user( 0 ); parent::tearDown(); diff --git a/tests/php/tests/PUM_Admin_Settings_Test.php b/tests/php/tests/PUM_Admin_Settings_Test.php index 6546ebb51..dcf45239b 100644 --- a/tests/php/tests/PUM_Admin_Settings_Test.php +++ b/tests/php/tests/PUM_Admin_Settings_Test.php @@ -75,6 +75,22 @@ public function test_css_viewer_does_not_embed_styles_eagerly() { $this->assertLessThan( 3000, strlen( $html ) ); } + /** + * Settings initialized after plugins_loaded retain legacy Pro operations. + * + * @return void + */ + public function test_init_registers_legacy_license_operation_after_plugins_loaded() { + try { + PUM_Admin_Settings::init(); + + $this->assertSame( 10, has_action( 'pum_save_settings', [ 'PUM_Admin_Settings', 'process_license_operation' ] ) ); + $this->assertFalse( has_action( 'plugins_loaded', [ 'PUM_Admin_Settings', 'maybe_register_legacy_license_operation' ] ) ); + } finally { + remove_action( 'pum_save_settings', [ 'PUM_Admin_Settings', 'process_license_operation' ] ); + } + } + /** * CSS data is loaded from build artifacts only when requested. */ diff --git a/tests/php/tests/Popups_Repository_Dashboard_Stats_Test.php b/tests/php/tests/Popups_Repository_Dashboard_Stats_Test.php new file mode 100644 index 000000000..bef192285 --- /dev/null +++ b/tests/php/tests/Popups_Repository_Dashboard_Stats_Test.php @@ -0,0 +1,211 @@ +create_popup_with_stats( 'First', 100, 25, 0.25 ); + $top_id = $this->create_popup_with_stats( 'Top', 10, 3, 0.30 ); + + $this->create_popup_with_stats( 'Disabled', 500, 500, 1.0, false ); + $this->create_popup_with_stats( 'No views', 0, 0, 1.0 ); + $this->create_popup_with_stats( 'Draft', 1000, 1000, 1.0, true, 'draft' ); + + $stats = \PopupMaker\plugin( 'popups' )->get_dashboard_stats(); + + $this->assertSame( 110, $stats['total_views'] ); + $this->assertSame( 28, $stats['total_conversions'] ); + $this->assertEqualsWithDelta( ( 28 / 110 ) * 100, $stats['conversion_rate'], 0.0001 ); + $this->assertInstanceOf( WP_Post::class, $stats['top_performer'] ); + $this->assertSame( $top_id, $stats['top_performer']->ID ); + $this->assertEqualsWithDelta( 30.0, $stats['top_performer_rate'], 0.0001 ); + $this->assertNotSame( $first_id, $stats['top_performer']->ID ); + } + + /** + * Historical ranking uses stored conversion rate before current counters. + * + * @return void + */ + public function test_dashboard_stats_rank_by_stored_rate_before_current_ratio() { + $stored_rate_winner = $this->create_popup_with_stats( 'Stored winner', 10, 1, 0.80 ); + $this->create_popup_with_stats( 'Current ratio winner', 10, 9, 0.10 ); + + $stats = \PopupMaker\plugin( 'popups' )->get_dashboard_stats(); + + $this->assertSame( $stored_rate_winner, $stats['top_performer']->ID ); + $this->assertEqualsWithDelta( 10.0, $stats['top_performer_rate'], 0.0001 ); + } + + /** + * Conversion count breaks a stored-rate tie. + * + * @return void + */ + public function test_dashboard_stats_break_stored_rate_tie_by_conversions() { + $this->create_popup_with_stats( 'Fewer conversions', 10, 2, 0.50 ); + $top_id = $this->create_popup_with_stats( 'More conversions', 100, 3, 0.50 ); + + $stats = \PopupMaker\plugin( 'popups' )->get_dashboard_stats(); + + $this->assertSame( $top_id, $stats['top_performer']->ID ); + } + + /** + * View count breaks a stored-rate and conversion-count tie. + * + * @return void + */ + public function test_dashboard_stats_break_conversion_tie_by_views() { + $this->create_popup_with_stats( 'Fewer views', 10, 2, 0.50 ); + $top_id = $this->create_popup_with_stats( 'More views', 100, 2, 0.50 ); + + $stats = \PopupMaker\plugin( 'popups' )->get_dashboard_stats(); + + $this->assertSame( $top_id, $stats['top_performer']->ID ); + } + + /** + * Empty and zero-view result sets return stable zero values. + * + * @return void + */ + public function test_dashboard_stats_return_zero_values_without_eligible_popups() { + $this->create_popup_with_stats( 'No views', 0, 8, 1.0 ); + + $stats = \PopupMaker\plugin( 'popups' )->get_dashboard_stats(); + + $this->assertSame( 0, $stats['total_views'] ); + $this->assertSame( 0, $stats['total_conversions'] ); + $this->assertSame( 0.0, $stats['conversion_rate'] ); + $this->assertNull( $stats['top_performer'] ); + $this->assertSame( 0.0, $stats['top_performer_rate'] ); + } + + /** + * The lightweight query never instantiates Popup Maker models. + * + * @return void + */ + public function test_dashboard_stats_do_not_hydrate_popup_models() { + $this->create_popup_with_stats( 'Model-free', 10, 2, 0.20 ); + + $repository = new class( \PopupMaker\plugin() ) extends \PopupMaker\Services\Repository\Popups { + /** + * @var int + */ + public $model_hydrations = 0; + + /** + * @param WP_Post $post Post object. + * @return PUM_Model_Popup|null + */ + public function instantiate_model_from_post( $post ) { + ++$this->model_hydrations; + + return parent::instantiate_model_from_post( $post ); + } + }; + + $stats = $repository->get_dashboard_stats(); + + $this->assertSame( 0, $repository->model_hydrations ); + $this->assertInstanceOf( WP_Post::class, $stats['top_performer'] ); + } + + /** + * Dashboard aggregation does not load unrelated popup metadata. + * + * @return void + */ + public function test_dashboard_stats_do_not_prime_all_popup_metadata() { + $popup_id = $this->create_popup_with_stats( 'Metadata-light', 10, 2, 0.20 ); + + update_post_meta( $popup_id, 'unrelated_dashboard_payload', str_repeat( 'x', 1024 ) ); + wp_cache_delete( $popup_id, 'post_meta' ); + + $stats = \PopupMaker\plugin( 'popups' )->get_dashboard_stats(); + + $this->assertSame( 10, $stats['total_views'] ); + $this->assertSame( 2, $stats['total_conversions'] ); + $this->assertFalse( wp_cache_get( $popup_id, 'post_meta' ) ); + } + + /** + * WordPress metadata overrides still affect totals and ranking. + * + * @return void + */ + public function test_dashboard_stats_preserve_filtered_metadata_values() { + $this->create_popup_with_stats( 'Stored winner', 100, 10, 0.20 ); + $filtered_id = $this->create_popup_with_stats( 'Filtered winner', 10, 1, 0.10 ); + + $filter = function ( $value, $object_id, $meta_key, $single ) use ( $filtered_id ) { + if ( $filtered_id !== (int) $object_id || ! $single ) { + return $value; + } + + $overrides = [ + 'popup_open_count' => 50, + 'popup_conversion_count' => 25, + 'popup_conversion_rate' => 0.90, + ]; + + return array_key_exists( $meta_key, $overrides ) ? [ $overrides[ $meta_key ] ] : $value; + }; + + add_filter( 'get_post_metadata', $filter, 10, 4 ); + + try { + $stats = \PopupMaker\plugin( 'popups' )->get_dashboard_stats(); + } finally { + remove_filter( 'get_post_metadata', $filter, 10 ); + } + + $this->assertSame( 150, $stats['total_views'] ); + $this->assertSame( 35, $stats['total_conversions'] ); + $this->assertSame( $filtered_id, $stats['top_performer']->ID ); + $this->assertEqualsWithDelta( 50.0, $stats['top_performer_rate'], 0.0001 ); + } + + /** + * Create a popup with analytics metadata. + * + * @param string $title Popup title. + * @param int $views Open count. + * @param int $conversions Conversion count. + * @param float $rate Stored conversion rate used for ranking. + * @param bool $enabled Whether the popup is enabled. + * @param string $status Post status. + * + * @return int + */ + private function create_popup_with_stats( $title, $views, $conversions, $rate, $enabled = true, $status = 'publish' ) { + $popup_id = self::factory()->post->create( + [ + 'post_type' => 'popup', + 'post_status' => $status, + 'post_title' => $title, + ] + ); + + update_post_meta( $popup_id, 'enabled', $enabled ? 1 : 0 ); + update_post_meta( $popup_id, 'popup_open_count', $views ); + update_post_meta( $popup_id, 'popup_conversion_count', $conversions ); + update_post_meta( $popup_id, 'popup_conversion_rate', $rate ); + + return $popup_id; + } +}