*/ const DEFAULT_STATE = [ 'banner' => [ 'status' => 'pending', 'queued_by' => null, 'queued_at' => null, 'snoozed_until' => null, 'shown_at' => null, 'last_shown_at' => null, 'message' => '', 'cta_url' => '', 'cta_label' => '', ], 'toast' => [ 'status' => 'pending', 'queued_by' => null, 'queued_at' => null, 'snoozed_until' => null, 'shown_at' => null, 'last_shown_at' => null, 'message' => '', 'cta_url' => '', 'cta_label' => '', ], ]; /** * Singleton instance. * * @var self|null */ private static $instance = null; /** * Notification selected to render during the current request. * * @var string|null */ private $active_display = null; /** * Whether the toast is enabled for the current request. * * @var bool */ private $toast_enabled = true; /** * Get the shared instance. */ public static function get_instance(): self { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * Request that the shared review banner be queued. * * Calling modules decide when to make this request. Requests made before the * plugin has been active for 14 days are rejected. * * @param string $source Short identifier for the requesting module. * @param string $message Optional custom message to display in the banner. */ public static function request_banner( string $source, string $message = '' ): void { self::get_instance()->queue( $source, [ 'display' => 'banner', 'message' => $message, 'cta_url' => '', 'cta_label' => '', ] ); } /** * Request that a shared toast be queued (e.g. a Pro-upgrade or coupon nudge). * * Calling modules decide when to make this request. Requests made before the * plugin has been active for 14 days are rejected. * * @param string $source Short identifier for the requesting module. * @param string $message Body text to display in the toast. * @param string $cta_url URL the primary call-to-action button links to. * @param string $cta_label Label for the primary call-to-action button. */ public static function request_toast( string $source, string $message, string $cta_url, string $cta_label ): void { self::get_instance()->queue( $source, [ 'display' => 'toast', 'message' => $message, 'cta_url' => $cta_url, 'cta_label' => $cta_label, ] ); } /** * Shared queuing logic for both the banner and the toast. * * @param string $source Short identifier for the requesting module. * @param array $content Display mode plus message/cta_url/cta_label. */ private function queue( string $source, array $content ): void { $now = time(); $display = 'toast' === $content['display'] ? 'toast' : 'banner'; $test_mode = 'toast' === $display && $this->is_toast_test_mode(); if ( ! $test_mode && $now - $this->activation_time < self::DAY_AFTER_ACTIVATION ) { return; } $state = $this->get_state(); $notice = $state[ $display ]; $can_queue = $test_mode || 'pending' === $notice['status'] || ( 'shown' === $notice['status'] && ( 'toast' !== $display || $this->is_toast_frequency_elapsed( $notice, $now ) ) ) || ( 'snoozed' === $notice['status'] && is_numeric( $notice['snoozed_until'] ) && (int) $notice['snoozed_until'] <= $now ); if ( ! $can_queue ) { return; } $notice['status'] = 'queued'; $notice['queued_by'] = sanitize_key( $source ); $notice['queued_at'] = $now; $notice['snoozed_until'] = null; $message = $this->filter_message( $source, (string) $content['message'], $display ); $notice['message'] = '' !== $message ? wp_kses( $message, [ 'strong' => [] ] ) : ''; $notice['cta_url'] = '' !== $content['cta_url'] ? esc_url_raw( $content['cta_url'] ) : ''; $notice['cta_label'] = '' !== $content['cta_label'] ? sanitize_text_field( $content['cta_label'] ) : ''; $state[ $display ] = $notice; $this->save_state( $state ); } /** * Determine whether persistent toast rendering is enabled for development. */ private function is_toast_test_mode(): bool { return defined( 'WP_DEBUG' ) && WP_DEBUG && defined( 'ADMINOPTIMIZER_NOTIFICATION_TOAST_TEST_MODE' ) && true === ADMINOPTIMIZER_NOTIFICATION_TOAST_TEST_MODE; } /** * Determine whether enough time has passed since the toast was last shown. * * @param array $notice Toast state. * @param int $now Current timestamp. */ private function is_toast_frequency_elapsed( array $notice, int $now ): bool { if ( ! is_numeric( $notice['last_shown_at'] ) ) { return true; } /** * Filter the minimum interval between automatically displayed toasts. * * @param int $frequency Frequency in seconds. */ $frequency = max( 0, (int) apply_filters( 'adminoptim_notification_toast_frequency', self::TOAST_FREQUENCY ) ); return (int) $notice['last_shown_at'] + $frequency <= $now; } /** * Filter a notification message for a specific source. * * The dynamic portion of the hook name is the sanitized notification source, * for example `adminoptim_notification_message_form-builder`. * * @param string $source Short identifier for the requesting module. * @param string $message Default notification message. * @param string $display Notification display mode: banner or toast. * @return string */ private function filter_message( string $source, string $message, string $display ): string { /** * Filter the message for a notification source. * * @param string $message Default notification message. * @param string $display Notification display mode: banner or toast. */ return (string) apply_filters( 'adminoptim_notification_message_' . sanitize_key( $source ), $message, $display ); } /** * Queue the default banner and toast on Admin Optimizer screens. * * @param \WP_Screen $screen Current admin screen. */ public function maybe_queue_ao_notifications( $screen ): void { global $plugin_page; if ( ! current_user_can( 'manage_options' ) || ! $this->is_ao_screen( $screen ) ) { return; } $source = $this->get_screen_source( $screen ); $default_toast_enabled = ADMINOPTIMIZER_MODULES_MENU_SLUG !== $plugin_page; /** * Filter whether the toast is enabled on the current Admin Optimizer screen. * * @param bool $enabled Whether the toast is enabled. * @param \WP_Screen $screen Current admin screen. * @param string $source Notification source for the screen. */ $this->toast_enabled = (bool) apply_filters( 'adminoptim_notification_toast_enabled', $default_toast_enabled, $screen, $source ); self::request_banner( $source ); if ( $this->toast_enabled ) { self::request_toast( $source, __( 'Unlock more powerful features and get even more from Admin Optimizer Pro.', 'admin-optimizer' ), self::UPGRADE_URL, __( 'View Pro', 'admin-optimizer' ) ); } $this->active_display = $this->select_active_display( $this->get_state() ); } /** * Select one queued notification for the current request. * * Notifications that have never been shown take precedence. Otherwise, the * notification with the oldest last-shown timestamp is selected. Banner wins an * exact tie to keep the initial selection deterministic. * * @param array $state Notification state. * @return string|null */ private function select_active_display( array $state ) { if ( $this->is_toast_test_mode() && $this->toast_enabled && 'queued' === $state['toast']['status'] ) { return 'toast'; } $selected = null; $selected_last_shown_at = null; foreach ( [ 'banner', 'toast' ] as $display ) { if ( 'toast' === $display && ! $this->toast_enabled ) { continue; } $notice = $state[ $display ]; if ( 'queued' !== $notice['status'] ) { continue; } $last_shown_at = is_numeric( $notice['last_shown_at'] ) ? (int) $notice['last_shown_at'] : 0; if ( null === $selected || $last_shown_at < $selected_last_shown_at ) { $selected = $display; $selected_last_shown_at = $last_shown_at; } } return $selected; } /** * Get the notification selected for the current request. * * @return string|null */ private function get_active_display() { if ( null === $this->active_display ) { $this->active_display = $this->select_active_display( $this->get_state() ); } return $this->active_display; } /** * Resolve a stable notification source for the current screen. * * @param \WP_Screen $screen Current admin screen. * @return string */ private function get_screen_source( $screen ): string { $source = Module_Registry::get_module_slug_for_screen( $screen ); if ( null === $source ) { $source = ! empty( $screen->post_type ) ? $screen->post_type : $screen->id; } /** * Filter the notification source used for an Admin Optimizer screen. * * @param string $source Sanitized default source. * @param \WP_Screen $screen Current admin screen. */ return sanitize_key( (string) apply_filters( 'adminoptim_notification_source', sanitize_key( $source ), $screen ) ); } /** * Register hooks and initialize the single compact option. */ private function __construct() { add_option( self::OPTION_NAME, self::DEFAULT_STATE, '', 'no' ); $activation_time = get_option( 'adminoptim_activated_at' ); if ( false === $activation_time ) { $activation_time = time(); add_option( 'adminoptim_activated_at', $activation_time, '', 'no' ); } $this->activation_time = (int) $activation_time; add_action( 'admin_notices', [ $this, 'maybe_render' ] ); add_action( 'admin_footer', [ $this, 'maybe_render_toast' ] ); add_action( 'current_screen', [ $this, 'maybe_queue_ao_notifications' ] ); add_action( 'wp_ajax_adminoptim_review_notice_snooze', [ $this, 'ajax_snooze' ] ); add_action( 'wp_ajax_adminoptim_review_notice_close', [ $this, 'ajax_close' ] ); add_action( 'wp_ajax_adminoptim_review_notice_dismiss', [ $this, 'ajax_dismiss' ] ); add_filter( 'admin_footer_text', [ $this, 'maybe_render_footer_text' ] ); } /** * Determine whether the notification is ready to display to the current user. */ public function is_ready_to_display( string $display = 'banner' ): bool { $state = $this->get_state(); $notice = $state[ 'toast' === $display ? 'toast' : 'banner' ]; if ( 'dismissed' === $notice['status'] ) { return false; } return 'queued' === $notice['status'] && current_user_can( 'manage_options' ); } /** * Enqueue assets and render the banner notice when it is ready. */ public function maybe_render(): void { if ( 'banner' !== $this->get_active_display() ) { return; } $state = $this->get_state(); $notice = $state['banner']; // Keep permanently dismissed installs on the cheapest possible path. if ( 'dismissed' === $notice['status'] || ! $this->is_ready_to_display( 'banner' ) ) { return; } $this->enqueue_assets(); $now = time(); $message = $this->get_message( $notice ); ?>

[] ] ); ?>

   | 

record_shown( 'banner', $state, $now ); } /** * Enqueue assets and render the toast when it is ready. * * Unlike the banner, the toast has no fixed subject: the message and CTA are * always supplied by the calling module via request_toast() (e.g. a Pro-upgrade * or coupon nudge), so there is no default copy to fall back to here. */ public function maybe_render_toast(): void { if ( 'toast' !== $this->get_active_display() ) { return; } $state = $this->get_state(); $notice = $state['toast']; if ( 'dismissed' === $notice['status'] || ! $this->is_ready_to_display( 'toast' ) ) { return; } $this->enqueue_assets(); $now = time(); ?>

[] ] ); ?>

   | 

record_shown( 'toast', $state, $now ); } /** * Resolve the message to display for the banner. * * @param array $state Notification state. */ private function get_message( array $state ): string { return ! empty( $state['message'] ) ? $state['message'] // For the message, it should state the specific win, keep it under ~20 words and always end with the same call-to-action phrasing. /* translators: 1: Opening strong tag, 2: Closing strong tag. */ : sprintf( __( '%1$sEnjoying Admin Optimizer?%2$s Leave a ★★★★★ review on WordPress.org and help other WordPress users discover a smarter way to manage WordPress.', 'admin-optimizer' ), '', '' ); } /** * Enqueue the shared notification script. */ private function enqueue_assets(): void { wp_enqueue_style( 'adminoptim-notification', ADMINOPTIMIZER_URI . 'admin/assets/css/notification.min.css', [], ADMINOPTIMIZER_VERSION ); wp_enqueue_script( 'adminoptim-notification', ADMINOPTIMIZER_URI . 'admin/assets/js/notification.min.js', [], ADMINOPTIMIZER_VERSION, true ); wp_localize_script( 'adminoptim-notification', 'adminoptimNotice', [ 'ajaxUrl' => admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( self::NONCE_ACTION ), ] ); } /** * Record that the notification was shown, transitioning state to 'shown'. * * @param string $display Notification display mode. * @param array $state Notification state. * @param int $now Current timestamp. */ private function record_shown( string $display, array $state, int $now ): void { $notice = $state[ $display ]; $is_first_display = null === $notice['shown_at']; $notice['status'] = 'shown'; if ( $is_first_display ) { $notice['shown_at'] = $now; } $notice['last_shown_at'] = $now; $state[ $display ] = $notice; $this->save_state( $state ); } /** * Snooze the notification for the configured duration. */ public function ajax_snooze(): void { if ( ! $this->is_valid_ajax_request() ) { wp_send_json_error( [ 'message' => __( 'You are not allowed to do that.', 'admin-optimizer' ) ], 403 ); } $display = $this->get_requested_display(); $state = $this->get_state(); $state[ $display ]['status'] = 'snoozed'; $state[ $display ]['snoozed_until'] = time() + self::SNOOZE_DURATION; $this->save_state( $state ); wp_send_json_success(); } /** * Snooze a closed toast for 30 days. */ public function ajax_close(): void { if ( ! $this->is_valid_ajax_request() ) { wp_send_json_error( [ 'message' => __( 'You are not allowed to do that.', 'admin-optimizer' ) ], 403 ); } $display = $this->get_requested_display(); if ( 'toast' !== $display ) { wp_send_json_error( [ 'message' => __( 'Invalid notification type.', 'admin-optimizer' ) ], 400 ); } $state = $this->get_state(); $state['toast']['status'] = 'snoozed'; $state['toast']['snoozed_until'] = time() + self::CLOSE_SNOOZE_DURATION; $this->save_state( $state ); wp_send_json_success(); } /** * Permanently dismiss the notification. */ public function ajax_dismiss(): void { if ( ! $this->is_valid_ajax_request() ) { wp_send_json_error( [ 'message' => __( 'You are not allowed to do that.', 'admin-optimizer' ) ], 403 ); } $display = $this->get_requested_display(); $state = $this->get_state(); $state[ $display ]['status'] = 'dismissed'; $state[ $display ]['snoozed_until'] = null; $this->save_state( $state ); wp_send_json_success(); } /** * Validate the feature nonce and required capability. */ private function is_valid_ajax_request(): bool { return (bool) check_ajax_referer( self::NONCE_ACTION, 'nonce', false ) && current_user_can( 'manage_options' ); } /** * Read and validate the notification type supplied by an AJAX action. */ private function get_requested_display(): string { $display = isset( $_POST['notification_type'] ) ? sanitize_key( wp_unslash( $_POST['notification_type'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Missing return 'toast' === $display ? 'toast' : 'banner'; } /** * Read and normalize the stored state. * * @return array */ private function get_state(): array { $state = get_option( self::OPTION_NAME, self::DEFAULT_STATE ); if ( ! is_array( $state ) ) { return self::DEFAULT_STATE; } // Normalize the current nested state. if ( isset( $state['banner'] ) || isset( $state['toast'] ) ) { return [ 'banner' => is_array( $state['banner'] ?? null ) ? array_merge( self::DEFAULT_STATE['banner'], $state['banner'] ) : self::DEFAULT_STATE['banner'], 'toast' => is_array( $state['toast'] ?? null ) ? array_merge( self::DEFAULT_STATE['toast'], $state['toast'] ) : self::DEFAULT_STATE['toast'], ]; } // Preserve the old flat notification state in its original display slot. $display = isset( $state['display'] ) && 'toast' === $state['display'] ? 'toast' : 'banner'; $legacy = array_intersect_key( $state, self::DEFAULT_STATE[ $display ] ); $normalized = self::DEFAULT_STATE; $normalized[ $display ] = array_merge( self::DEFAULT_STATE[ $display ], $legacy ); return $normalized; } /** * Save the complete state in the shared option. * * @param array $state Notification state. */ private function save_state( array $state ): void { update_option( self::OPTION_NAME, $state, false ); } /** * Determine whether the current admin screen belongs to Admin Optimizer. */ private function is_ao_pages(): bool { if ( ! function_exists( 'get_current_screen' ) ) { return false; } $screen = get_current_screen(); return $screen ? $this->is_ao_screen( $screen ) : false; } /** * Determine whether a screen belongs to Admin Optimizer. * * @param \WP_Screen $screen Admin screen. * @return bool */ private function is_ao_screen( $screen ): bool { return false !== strpos( $screen->id, 'adminoptim' ) || false !== strpos( $screen->id, 'admin-optimizer' ); } /** * Add Admin Footer Text * * @param string $text Footer text. * @return string */ public function maybe_render_footer_text( $text ) { /* translators: 1: Opening strong tag, 2: Closing strong tag, 3: Opening review link tag, 4: Closing review link tag. */ $message = sprintf( __( '%1$sEnjoying Admin Optimizer?%2$s %3$sLeave a ★★★★★ review on WordPress.org%4$s to help support future development.', 'admin-optimizer' ), '', '', '', '' ); if ( $this->is_ao_pages() ) { return $message; } else { return $text; } } }