';
$greetings .= sprintf(
/* translators: %s: plugin name */
esc_html__( 'This is Andre from the %s team. Thanks for joining our SEO community of 2,400+ happy users – we appreciate having you on board!', 'ai-for-seo' ),
esc_html( AI4SEO_PLUGIN_NAME )
);
$greetings .= '
';
return $avatar . $greetings;
}
/**
* Filter and customize the footer for notifications
*
* @param string $notification_index The notification index.
* @param array $notification The notification data.
* @return string The filtered footer HTML
*/
function ai4seo_get_notification_buttons( string $notification_index, array $notification ): string {
if ( ai4seo_prevent_loops( __FUNCTION__ ) ) {
ai4seo_debug_message( 470991053, 'Prevented loop', true );
return '';
}
$can_administer_plugin = ai4seo_can_administer_plugin();
$show_dismiss_button = $can_administer_plugin && ! ( isset( $notification['is_permanent'] ) && $notification['is_permanent'] );
$show_not_now_button = $can_administer_plugin && (bool) ( $notification['not_now_button'] ?? false ); // replaces dismiss button if set.
$show_contact_us_button = (bool) ( $notification['contact_us'] ?? false );
$show_set_up_seo_autopilot_button = $can_administer_plugin && (bool) ( $notification['set_up_seo_autopilot_button'] ?? false );
$show_get_a_get_more_credits_button = $can_administer_plugin && (bool) ( $notification['get_more_credits_button'] ?? false );
$show_customize_payg_button = $can_administer_plugin && (bool) ( $notification['customize_payg_button'] ?? false );
$show_increase_payg_budget_button = $can_administer_plugin && (bool) ( $notification['increase_payg_budget_button'] ?? false ); // same as customize payg.
// Button override options let specific notices reuse shared rendering while changing label, class, or order.
$show_sync_account_button = $can_administer_plugin && (bool) ( $notification['sync_account_button'] ?? false );
$show_sync_account_button_first = (bool) ( $notification['sync_account_button_first'] ?? false );
$sync_account_button_label = sanitize_text_field( (string) ( $notification['sync_account_button_label'] ?? __( 'Refresh', 'ai-for-seo' ) ) );
$sync_account_button_css_class = sanitize_text_field( (string) ( $notification['sync_account_button_css_class'] ?? '' ) );
$show_get_a_custom_quote_button = $can_administer_plugin && (bool) ( $notification['get_a_custom_quote_button'] ?? false );
$show_grab_deal_button = $can_administer_plugin && (bool) ( $notification['grab_deal_button'] ?? false );
$show_claim_bonus_button = $can_administer_plugin && (bool) ( $notification['claim_bonus_button'] ?? false );
$show_rate_us_button = $can_administer_plugin && (bool) ( $notification['rate_us_button'] ?? false );
$show_go_to_account_settings_button = $can_administer_plugin && (bool) ( $notification['go_to_account_settings_button'] ?? false );
$go_to_account_settings_button_label = sanitize_text_field( (string) ( $notification['go_to_account_settings_button_label'] ?? __( 'Account Settings', 'ai-for-seo' ) ) );
$go_to_account_settings_button_css_class = sanitize_text_field( (string) ( $notification['go_to_account_settings_button_css_class'] ?? 'ai4seo-primary-button' ) );
$show_lost_licence_key_button = $can_administer_plugin && (bool) ( $notification['lost_licence_key_button'] ?? false );
$lost_licence_key_button_label = sanitize_text_field( (string) ( $notification['lost_licence_key_button_label'] ?? __( 'Lost your license data?', 'ai-for-seo' ) ) );
$show_update_plugin_button = $can_administer_plugin && (bool) ( $notification['update_plugin_button'] ?? false );
$show_go_to_settings_button = $can_administer_plugin && (bool) ( $notification['go_to_settings_button'] ?? false );
$show_go_to_help_button = (bool) ( $notification['go_to_help_button'] ?? false );
$show_see_whats_new_button = $can_administer_plugin && (bool) ( $notification['see_whats_new_button'] ?? false );
$account_url = ai4seo_get_subpage_url( 'account' );
$settings_url = ai4seo_get_subpage_url( 'settings' );
$help_url = ai4seo_get_subpage_url( 'help' );
$wp_admin_plugins_list_url = admin_url( 'plugins.php' );
$notification_buttons = '';
$sync_account_button = '';
// Build the refresh button once so account notices can place it before or after the common button set.
if ( $show_sync_account_button ) {
$sync_account_button = ai4seo_get_icon_button_tag(
'rotate',
esc_html( $sync_account_button_label ),
$sync_account_button_css_class,
'ai4seo_refresh_robhub_account(this); return false;',
$sync_account_button_label
);
}
// SPECIFIC NOTIFICATION BUTTONS ============================================================================.
// Some account recovery notices need the refresh action to be the primary first step.
if ( $show_sync_account_button_first && $sync_account_button ) {
$notification_buttons .= $sync_account_button;
}
// plugin-update notification -> add "See what's new" button.
if ( $show_see_whats_new_button ) {
$notification_buttons .= ai4seo_get_a_tag_icon_button_tag( '#ai4seo_recent_plugin_updates', '', '', 'arrow-up-right-from-square', __( "See what's new", 'ai-for-seo' ), 'ai4seo-notification-dismiss-button', 'jQuery(".ai4seo-recent-plugin-updates-content").removeClass("ai4seo-display-none")' );
}
// ADDITIONAL GENERIC BUTTONS ===============================================================================.
// Show a "Rate us" Button.
if ( $show_rate_us_button ) {
$notification_buttons .= ai4seo_get_a_tag_icon_button_tag( AI4SEO_OFFICIAL_RATE_US_URL, '', '', 'star', __( 'Rate us', 'ai-for-seo' ), 'ai4seo-unicorn-button', '', '_blank' );
}
// Show a "Grab Deal" Button.
if ( $show_grab_deal_button ) {
$notification_buttons .= ai4seo_get_icon_button_tag( 'gift', __( 'Grab Deal', 'ai-for-seo' ), 'ai4seo-unicorn-button', 'ai4seo_open_get_more_credits_modal();' );
}
// Show a "Claim Bonus" Button.
if ( $show_claim_bonus_button ) {
$notification_buttons .= ai4seo_get_icon_button_tag( 'arrow-up-right-from-square', __( 'Claim Bonus', 'ai-for-seo' ), 'ai4seo-unicorn-button', 'ai4seo_open_get_more_credits_modal();' );
}
// Show a "Get more Credits" Button.
if ( $show_get_a_get_more_credits_button ) {
$notification_buttons .= ai4seo_get_icon_button_tag( 'circle-plus', __( 'Get more Credits', 'ai-for-seo' ), 'ai4seo-primary-button', 'ai4seo_open_get_more_credits_modal();' );
}
// Show a "Customize PAYG" Button.
if ( $show_customize_payg_button ) {
$notification_buttons .= ai4seo_get_icon_button_tag( 'sliders', esc_html__( 'Customize Pay-As-You-Go', 'ai-for-seo' ), '', 'ai4seo_handle_open_customize_payg_modal();' );
}
// Show a "Increase Budget" Button (same as customize payg).
if ( $show_increase_payg_budget_button ) {
$notification_buttons .= ai4seo_get_icon_button_tag( 'sliders', esc_html__( 'Increase Budget', 'ai-for-seo' ), '', 'ai4seo_handle_open_customize_payg_modal();' );
}
// Show a "Get an exclusive quote" Button.
if ( $show_get_a_custom_quote_button ) {
$notification_buttons .= ai4seo_get_a_tag_icon_button_tag( AI4SEO_OFFICIAL_CONTACT_URL, '', '_blank', 'handshake', __( 'Get an exclusive quote', 'ai-for-seo' ) );
}
// show a Set up SEO Autopilot button.
if ( $show_set_up_seo_autopilot_button ) {
$notification_buttons .= ai4seo_get_icon_button_tag( 'paper-plane', esc_html__( 'Set up SEO Autopilot', 'ai-for-seo' ), '', 'ai4seo_open_modal_from_schema("seo-autopilot", {modal_size: "small", unsaved_changes_warnings: true});' );
}
// Show a "Go to Settings" button.
if ( $show_go_to_settings_button ) {
$notification_buttons .= ai4seo_get_a_tag_icon_button_tag( $settings_url, '', '', 'gear', __( 'Go to Settings', 'ai-for-seo' ), 'ai4seo-primary-button' );
}
// Show an "Account Settings" button.
if ( $show_go_to_account_settings_button ) {
$notification_buttons .= ai4seo_get_a_tag_icon_button_tag(
$account_url,
'',
'',
'key',
esc_html( $go_to_account_settings_button_label ),
$go_to_account_settings_button_css_class,
'',
$go_to_account_settings_button_label
);
}
// Show an "Update Plugin" button.
if ( $show_update_plugin_button ) {
$notification_buttons .= ai4seo_get_a_tag_icon_button_tag( $wp_admin_plugins_list_url, '', '', 'circle-up', __( 'Update Plugin', 'ai-for-seo' ), 'ai4seo-primary-button' );
}
// Show a lost license key button.
if ( $show_lost_licence_key_button ) {
$notification_buttons .= ai4seo_get_icon_button_tag(
'key-slash',
esc_html( $lost_licence_key_button_label ),
'',
'ai4seo_open_lost_key_modal();',
$lost_licence_key_button_label
);
}
// Show a "Go to Help section" button.
if ( $show_go_to_help_button ) {
$notification_buttons .= ai4seo_get_a_tag_icon_button_tag( $help_url, '', '', 'circle-question', __( 'Go to Help', 'ai-for-seo' ), 'ai4seo-primary-button' );
}
// Append the refresh action in the default position for notices that do not request first placement.
if ( ! $show_sync_account_button_first && $sync_account_button ) {
$notification_buttons .= $sync_account_button;
}
// contact us button.
if ( $show_contact_us_button ) {
$notification_buttons .= ai4seo_get_contact_us_button();
}
// dismiss / not now button.
if ( $show_dismiss_button || $show_not_now_button ) {
$notification_buttons .= ai4seo_get_notification_dismiss_button_tag( $notification_index, $show_not_now_button );
}
return $notification_buttons;
}
/**
* Render the dismissal action after the caller has checked its visibility.
*
* @param string $notification_index Notification identifier.
* @param bool $show_not_now_button Whether to prefer the deferred-dismissal label.
* @return string Escaped dismissal button markup.
*/
function ai4seo_get_notification_dismiss_button_tag( string $notification_index, bool $show_not_now_button ): string {
// The welcome changes presentation only; the shared dismissal class keeps its existing action.
$dismiss_button_class = AI4SEO_WELCOME_NOTIFICATION_INDEX === $notification_index ? 'ai4seo-primary-button' : 'ai4seo-abort-button';
$button = '';
return $button;
}
/**
* Determine whether all configured notification conditions are satisfied.
*
* @param string $notification_index Notification identifier.
* @param array $additional_fields Additional notification fields and conditions.
* @param bool $skip_num_displayable_notification_condition Whether to skip the visible-notification-count condition.
* @return bool Whether the notification conditions are satisfied.
*/
function ai4seo_check_notification_conditions( string $notification_index, array $additional_fields = array(), bool $skip_num_displayable_notification_condition = false ): bool {
if ( isset( $additional_fields['activation_offer_id'] ) ) {
$offer = ai4seo_get_activation_offer();
if ( ! ai4seo_can_administer_plugin() || ! $offer || $offer['id'] !== $additional_fields['activation_offer_id'] ) {
return false;
}
}
if ( ai4seo_prevent_loops( __FUNCTION__, 3 ) ) {
ai4seo_debug_message( 283333336, 'Prevented loop', true );
return false;
}
$conditions = array();
$debug = false; // set to true to enable debug logging.
// go through each $additional_fields and check if one is suffixed with "_condition", filter them out.
foreach ( $additional_fields as $field_name => $field_value ) {
if ( substr( $field_name, -10 ) === '_condition' ) {
$conditions[ $field_name ] = $field_value;
}
}
if ( ! $conditions ) {
if ( $debug ) {
ai4seo_debug_message( 472507849, $notification_index . '>' . ai4seo_stringify( 'No conditions to check, passing by default.' ) );
}
return true;
}
// go through each condition and check if it is met.
foreach ( $conditions as $condition_name => $condition_value ) {
if ( 'true' === $condition_value ) {
$condition_value = true; // convert "true" string to boolean true.
} elseif ( 'false' === $condition_value ) {
$condition_value = false; // convert "false" string to boolean false.
} else {
$condition_value = ai4seo_deep_sanitize( $condition_value ); // sanitize string values.
}
switch ( $condition_name ) {
case 'min_num_missing_entries_condition':
// check if the number of missing entries is less than the condition value.
$min_num_missing_entries_condition = (int) $condition_value;
$num_missing_posts = 0;
$num_missing_posts_by_post_type = ai4seo_get_num_missing_posts_by_post_type();
if ( $num_missing_posts_by_post_type ) {
$num_missing_posts = array_sum( $num_missing_posts_by_post_type );
}
if ( $num_missing_posts < $condition_value ) {
if ( $debug ) {
ai4seo_debug_message( 225223351, $notification_index . ' >' . ai4seo_stringify( $condition_name . ': ' . $num_missing_posts . ' < ' . $condition_value ) );
}
return false; // condition not met.
}
break;
case 'max_credits_balance_condition':
// check if the credits balance is less than the condition value.
$max_credits_balance_condition = (int) $condition_value;
$credits_balance = ai4seo_robhub_api()->get_credits_balance();
if ( $credits_balance > $max_credits_balance_condition ) {
if ( $debug ) {
ai4seo_debug_message( 104350543, $notification_index . ' >' . ai4seo_stringify( $condition_name . ': ' . $credits_balance . ' > ' . $max_credits_balance_condition ) );
}
return false; // condition not met.
}
break;
case 'min_credits_balance_condition':
// check if the credits balance is greater than the condition value.
$min_credits_balance_condition = (int) $condition_value;
$credits_balance = ai4seo_robhub_api()->get_credits_balance();
if ( $credits_balance < $min_credits_balance_condition ) {
if ( $debug ) {
ai4seo_debug_message( 574266499, $notification_index . ' >' . ai4seo_stringify( $condition_name . ': ' . $credits_balance . ' < ' . $min_credits_balance_condition ) );
}
return false; // condition not met.
}
break;
case 'do_credits_cover_all_missing_entries_condition':
// check if the credits cover all missing entries.
$do_credits_cover_all_missing_entries_condition = (bool) $condition_value;
$credits_balance = ai4seo_robhub_api()->get_credits_balance();
$needed_amount_of_credits_to_cover_all_missing_entries = ai4seo_get_approximate_credits_needed();
$do_credits_cover_all_missing_entries = $credits_balance >= $needed_amount_of_credits_to_cover_all_missing_entries;
if ( $do_credits_cover_all_missing_entries_condition !== $do_credits_cover_all_missing_entries ) {
if ( $debug ) {
ai4seo_debug_message( 665345926, $notification_index . ' >' . ai4seo_stringify( $condition_name . ': credits_balance = ' . $credits_balance . ', needed = ' . $needed_amount_of_credits_to_cover_all_missing_entries . ', condition = ' . ( $do_credits_cover_all_missing_entries_condition ? 'true' : 'false' ) . ', actual = ' . ( $do_credits_cover_all_missing_entries ? 'true' : 'false' ) ) );
}
return false; // condition not met.
}
break;
case 'has_purchased_something_condition':
// check if the user has purchased something.
$has_purchased_something_condition = (bool) $condition_value;
$has_purchased_something = (bool) ai4seo_read_environmental_variable( AI4SEO_ENVIRONMENTAL_VARIABLE_HAS_PURCHASED_SOMETHING );
if ( $has_purchased_something_condition !== $has_purchased_something ) {
if ( $debug ) {
ai4seo_debug_message( 530710183, $notification_index . ' >' . ai4seo_stringify( $condition_name . ': ' . ( $has_purchased_something ? 'true' : 'false' ) . ' != ' . ( $has_purchased_something_condition ? 'true' : 'false' ) ) );
}
return false; // condition not met.
}
break;
case 'max_num_unread_notifications_condition':
// check if the number of unread notifications is less than the condition value.
$max_num_unread_notifications_condition = (int) $condition_value;
$num_unread_notifications = ai4seo_get_num_unread_notification();
if ( $num_unread_notifications > $max_num_unread_notifications_condition ) {
if ( $debug ) {
ai4seo_debug_message( 386181290, $notification_index . ' >' . ai4seo_stringify( $condition_name . ': ' . $num_unread_notifications . ' > ' . $max_num_unread_notifications_condition ) );
}
return false; // condition not met.
}
break;
case 'max_num_visible_notifications_condition':
if ( $skip_num_displayable_notification_condition ) {
// skip this condition if we are not checking the number of displayable notifications to prevent a loop.
break;
}
// check if the number of undismissed notifications is less than the condition value.
$max_num_visible_notifications_condition = (int) $condition_value;
$num_visible_notifications = count( ai4seo_get_displayable_notifications( true, false ) ) - 1; // account for this notification itself.
if ( $num_visible_notifications > $max_num_visible_notifications_condition ) {
if ( $debug ) {
ai4seo_debug_message( 637430938, $notification_index . ' >' . ai4seo_stringify( $condition_name . ': ' . $num_visible_notifications . ' > ' . $max_num_visible_notifications_condition ) );
}
return false; // condition not met.
}
break;
case 'is_robhub_account_synced_condition':
// check if the RobHub account is synced.
$is_robhub_account_synced_condition = (bool) $condition_value;
$is_robhub_account_synced = ai4seo_get_account_readiness()['credits_ready'];
if ( $is_robhub_account_synced_condition !== $is_robhub_account_synced ) {
if ( $debug ) {
ai4seo_debug_message( 689873061, $notification_index . ' >' . ai4seo_stringify( $condition_name . ': ' . ( $is_robhub_account_synced ? 'true' : 'false' ) . ' != ' . ( $is_robhub_account_synced_condition ? 'true' : 'false' ) ) );
}
return false; // condition not met.
}
break;
case 'min_product_version_condition':
// check if the product version is at least the condition value.
$min_product_version_condition = ai4seo_deep_sanitize( $condition_value );
$current_product_version = AI4SEO_PLUGIN_VERSION_NUMBER;
if ( version_compare( $current_product_version, $min_product_version_condition, '<' ) ) {
if ( $debug ) {
ai4seo_debug_message( 200593735, $notification_index . ' >' . ai4seo_stringify( $condition_name . ': ' . $current_product_version . ' < ' . $min_product_version_condition ) );
}
return false; // condition not met.
}
break;
// unknown condition -> always opt out.
default:
if ( $debug ) {
ai4seo_debug_message( 498983924, $notification_index . ' >' . ai4seo_stringify( 'Unknown condition: ' . $condition_name . ', opting out.' ) );
}
return false;
}
}
if ( $debug ) {
ai4seo_debug_message( 136156803, $notification_index . ' > All conditions met' );
}
return true;
}
/**
* Persist the unread count represented by one final displayable notification map.
*
* @param array $displayable_notifications Displayable notification map.
* @return bool Whether the derived environmental value was synchronized.
*/
function ai4seo_store_unread_count_from_displayable_notifications( array $displayable_notifications ): bool {
if ( ai4seo_notification_unread_synchronization_is_active() ) {
return false;
}
$expected_notifications = array();
if ( ! ai4seo_get_notification_request_cache_for_current_site( $expected_notifications ) ) {
return false;
}
ai4seo_notification_unread_synchronization_is_active( true );
try {
for ( $attempt = 0; $attempt < AI4SEO_NOTIFICATION_CAS_MAX_ATTEMPTS; ++$attempt ) {
$unread_count = 0;
foreach ( $displayable_notifications as $notification ) {
if ( is_array( $notification ) && empty( $notification['read'] ) ) {
++$unread_count;
}
}
$stored_unread_count = (int) ai4seo_read_environmental_variable(
AI4SEO_ENVIRONMENTAL_VARIABLE_UNREAD_NOTIFICATIONS_COUNT,
false
);
if ( $stored_unread_count !== $unread_count
&& ! ai4seo_update_environmental_variable(
AI4SEO_ENVIRONMENTAL_VARIABLE_UNREAD_NOTIFICATIONS_COUNT,
$unread_count
) ) {
ai4seo_mark_notification_unread_refresh_pending_for_current_site();
return false;
}
// Verify that the canonical notification map did not change while its derived count was written.
$verification_snapshot = ai4seo_get_raw_option_snapshot( AI4SEO_NOTIFICATIONS_OPTION_NAME );
if ( null === $verification_snapshot ) {
ai4seo_mark_notification_unread_refresh_pending_for_current_site();
return false;
}
$verification_made_changes = false;
$verified_notifications = $verification_snapshot['exists']
? ai4seo_normalize_stored_notifications(
$verification_snapshot['value'],
$verification_made_changes
)
: array();
$verified_state_matches = ! $verification_made_changes
&& $verified_notifications === $expected_notifications
&& ( $verification_snapshot['exists'] || array() === $expected_notifications );
if ( $verified_state_matches ) {
ai4seo_complete_notification_unread_refresh_for_current_site();
return true;
}
// Rebuild displayability from the newer authoritative map before retrying the derived count.
ai4seo_mark_notification_unread_refresh_pending_for_current_site();
ai4seo_reset_notification_request_cache_for_current_site();
$storage_ready = false;
$displayable_notifications = ai4seo_get_displayable_notifications( false, false, $storage_ready );
if ( ! $storage_ready
|| ! ai4seo_get_notification_request_cache_for_current_site( $expected_notifications ) ) {
return false;
}
}
ai4seo_mark_notification_unread_refresh_pending_for_current_site();
return false;
} finally {
ai4seo_notification_unread_synchronization_is_active( false );
}
}
/**
* Push a trusted local notification through the current routine-check batch when available.
*
* @param string $notification_index The notification identifier.
* @param string $message The notification message.
* @param bool $force Whether to force replace existing notification.
* @param array $additional_fields Additional notification fields, such as notice type and permanence.
* @return bool True if the notification was staged or durably stored, false otherwise.
*/
function ai4seo_push_routine_notification( string $notification_index, string $message, bool $force = false, array $additional_fields = array() ): bool {
return ai4seo_store_notification(
$notification_index,
$message,
$force,
$additional_fields,
AI4SEO_NOTIFICATION_MESSAGE_SOURCE_LOCAL,
true
);
}
/**
* Function to refresh the unread notification counter from AI4SEO_NOTIFICATIONS_OPTION_NAME
*
* @return void
*/
function ai4seo_refresh_unread_notifications_count() {
if ( ai4seo_prevent_loops( __FUNCTION__ ) ) {
ai4seo_debug_message( 847616570, 'Prevented loop', true );
return;
}
if ( ai4seo_notification_unread_synchronization_is_active() ) {
return;
}
$storage_ready = false;
ai4seo_notification_unread_synchronization_is_active( true );
try {
$displayable_notifications = ai4seo_get_displayable_notifications( false, false, $storage_ready );
} finally {
ai4seo_notification_unread_synchronization_is_active( false );
}
if ( $storage_ready ) {
ai4seo_store_unread_count_from_displayable_notifications( $displayable_notifications );
}
}
/**
* Function to get the amount of unread notifications
*
* @return int The number of unread notifications
*/
function ai4seo_get_num_unread_notification(): int {
if ( ai4seo_prevent_loops( __FUNCTION__ ) ) {
ai4seo_debug_message( 769273087, 'Prevented loop', true );
return 0;
}
return (int) ai4seo_read_environmental_variable( AI4SEO_ENVIRONMENTAL_VARIABLE_UNREAD_NOTIFICATIONS_COUNT );
}
/**
* Calculate the auto-dismiss deadline captured before notification mutation retries.
*
* @return int The auto-dismiss Unix timestamp.
*/
function ai4seo_get_notification_auto_dismiss_time(): int {
return time() + ( AI4SEO_NOTIFICATION_AUTO_DISMISS_DAYS * DAY_IN_SECONDS );
}
/**
* Function to mark all notifications as read
*
* @return bool True if all notifications were marked as read, false otherwise
*/
function ai4seo_mark_all_displayable_notifications_as_read(): bool {
if ( ai4seo_prevent_loops( __FUNCTION__ ) ) {
ai4seo_debug_message( 527064110, 'Prevented loop', true );
return false;
}
// Capture only the rows visible to this user; fresh CAS retries leave concurrently added rows untouched.
$displayable_notifications = ai4seo_get_displayable_notifications( false, true );
if ( empty( $displayable_notifications ) ) {
return false; // no notifications to mark as read.
}
$displayable_notification_indexes = array_keys( $displayable_notifications );
$auto_dismiss_time = ai4seo_get_notification_auto_dismiss_time();
// Apply read state only to the captured visible identities from each fresh authoritative retry snapshot.
return ai4seo_commit_notification_mutation(
static function ( array $notifications ) use ( $displayable_notification_indexes, $auto_dismiss_time ): array {
$original_notifications = $notifications;
foreach ( $displayable_notification_indexes as $notification_index ) {
if ( ! isset( $notifications[ $notification_index ] ) || ! is_array( $notifications[ $notification_index ] ) ) {
continue;
}
if ( ! empty( $notifications[ $notification_index ]['read'] ) ) {
continue;
}
$notifications[ $notification_index ]['read'] = true;
$notifications[ $notification_index ]['time_auto_dismiss'] = $auto_dismiss_time;
}
return array(
'notifications' => $notifications,
'delete_option' => false,
'result' => true,
'refresh_unread_count' => $notifications !== $original_notifications,
);
}
);
}
/**
* Function to mark a notification as read by index
*
* @param string $notification_index The notification identifier.
* @return bool True if notification was marked as read, false otherwise
*/
function ai4seo_mark_notification_as_read( string $notification_index ): bool {
if ( ai4seo_prevent_loops( __FUNCTION__ ) ) {
ai4seo_debug_message( 715015911, 'Prevented loop', true );
return false;
}
if ( empty( $notification_index ) ) {
return false;
}
$auto_dismiss_time = ai4seo_get_notification_auto_dismiss_time();
// Derive read state from each fresh snapshot so concurrent notification fields remain intact.
return ai4seo_commit_notification_mutation(
static function ( array $notifications ) use ( $notification_index, $auto_dismiss_time ): array {
$original_notifications = $notifications;
$result = isset( $notifications[ $notification_index ] );
if ( $result && empty( $notifications[ $notification_index ]['read'] ) ) {
$notifications[ $notification_index ]['read'] = true;
if ( empty( $notifications[ $notification_index ]['is_permanent'] ) ) {
$notifications[ $notification_index ]['time_auto_dismiss'] = $auto_dismiss_time;
}
}
return array(
'notifications' => $notifications,
'delete_option' => false,
'result' => $result,
'refresh_unread_count' => $notifications !== $original_notifications,
);
}
);
}
/**
* Function to mark a notification as dismissed by index
*
* @param string $index The notification identifier.
* @return bool True if notification was marked as dismissed, false otherwise
*/
function ai4seo_mark_notification_as_dismissed( string $index ): bool {
if ( ai4seo_prevent_loops( __FUNCTION__ ) ) {
ai4seo_debug_message( 398944775, 'Prevented loop', true );
return false;
}
if ( empty( $index ) ) {
return false;
}
$dismissed_at = time();
// Preserve authenticated provenance while blanking dismissed content on each fresh retry snapshot.
return ai4seo_commit_notification_mutation(
static function ( array $notifications ) use ( $index, $dismissed_at ): array {
$original_notifications = $notifications;
$result = isset( $notifications[ $index ] );
if ( $result && empty( $notifications[ $index ]['dismissed'] ) ) {
$was_trusted_local = ai4seo_is_trusted_local_notification_message( $index, $notifications[ $index ] );
// Blank dismissed payloads preserve history without retaining unnecessary remote markup.
$notifications[ $index ]['dismissed'] = true;
$notifications[ $index ]['time_dismissed'] = $dismissed_at;
$notifications[ $index ]['message'] = '';
ai4seo_apply_notification_message_source(
$notifications[ $index ],
$index,
'',
$was_trusted_local ? AI4SEO_NOTIFICATION_MESSAGE_SOURCE_LOCAL : AI4SEO_NOTIFICATION_MESSAGE_SOURCE_REMOTE
);
}
return array(
'notifications' => $notifications,
'delete_option' => false,
'result' => $result,
'refresh_unread_count' => $notifications !== $original_notifications,
);
}
);
}
/**
* Determine whether a notification has been dismissed.
*
* @param string $notification_index Notification identifier.
* @return bool Whether the notification is dismissed.
*/
function ai4seo_is_notification_dismissed( string $notification_index ): bool {
// Make sure that $notification_index-parameter has content.
if ( ! $notification_index ) {
ai4seo_debug_message( 511301024, 'Notification index is empty.', true );
return false;
}
// Read repaired state so forged provenance cannot survive ancillary notification checks.
$notifications = ai4seo_get_repaired_notifications();
if ( ! isset( $notifications[ $notification_index ] ) ) {
return false;
}
return isset( $notifications[ $notification_index ]['dismissed'] ) && $notifications[ $notification_index ]['dismissed'];
}
/**
* Remove one notification through either the public immediate or routine batched writer.
*
* @param string $notification_index Notification identifier.
* @param bool $routine_mutation Whether to use the active routine-check batch.
* @return bool Whether the notification was removed.
*/
function ai4seo_remove_notification_with_commit( string $notification_index, bool $routine_mutation ): bool {
if ( empty( $notification_index ) ) {
return false;
}
$batched_target_exists = ai4seo_batched_raw_notification_index_exists( $notification_index );
$target_seen = true === $batched_target_exists;
// The selected minimal compatibility fix uses one raw pre-read outside routine batches.
if ( null === $batched_target_exists ) {
$option_snapshot = ai4seo_get_raw_option_snapshot( AI4SEO_NOTIFICATIONS_OPTION_NAME );
$target_seen = null !== $option_snapshot
&& $option_snapshot['exists']
&& is_array( $option_snapshot['value'] )
&& array_key_exists( $notification_index, $option_snapshot['value'] );
}
$commit_notification_mutation = $routine_mutation
? 'ai4seo_commit_routine_notification_mutation'
: 'ai4seo_commit_notification_mutation';
// Track target presence across retries so a lost CAS cannot turn a completed removal into a false negative.
return $commit_notification_mutation(
static function ( array $notifications ) use ( $notification_index, &$target_seen ): array {
$original_notifications = $notifications;
if ( isset( $notifications[ $notification_index ] ) ) {
$target_seen = true;
unset( $notifications[ $notification_index ] );
}
return array(
'notifications' => $notifications,
'delete_option' => false,
'result' => $target_seen,
'refresh_unread_count' => $notifications !== $original_notifications,
);
}
);
}
/**
* Function to remove a notification entry by index.
*
* @param string $notification_index The notification identifier.
* @return bool True if notification was removed, false otherwise.
*/
function ai4seo_remove_notification( string $notification_index ): bool {
if ( ai4seo_prevent_loops( __FUNCTION__ ) ) {
ai4seo_debug_message( 831312893, 'Prevented loop', true );
return false;
}
return ai4seo_remove_notification_with_commit( $notification_index, false );
}
/**
* Remove a notification through the current routine-check batch when available.
*
* @param string $notification_index The notification identifier.
* @return bool True if notification was staged or removed, false otherwise.
*/
function ai4seo_remove_routine_notification( string $notification_index ): bool {
if ( ai4seo_prevent_loops( __FUNCTION__ ) ) {
ai4seo_debug_message( 831312894, 'Prevented routine notification removal loop.', true );
return false;
}
return ai4seo_remove_notification_with_commit( $notification_index, true );
}
/**
* Function to remove all notifications
*/
function ai4seo_remove_all_notifications() {
// Delete only the exact authoritative snapshot supplied to the callback so concurrent additions survive a retry.
ai4seo_commit_notification_mutation(
static function ( array $notifications ): array {
return array(
'notifications' => array(),
'delete_option' => true,
'result' => true,
'refresh_unread_count' => array() !== $notifications,
);
}
);
}
// NOTIFICATION CHECKS ===================================================================.
/**
* Evaluate and enqueue notifications relevant to the current request.
*
* @return void
*/
function ai4seo_check_for_new_notifications() {
if ( ! ai4seo_can_run_dashboard_or_cron_tasks() ) {
return;
}
if ( ! ai4seo_singleton( __FUNCTION__ ) ) {
return;
}
// Stage every routine intent against one authoritative safe snapshot and replay the batch on conflict.
if ( ! ai4seo_begin_notification_mutation_batch() ) {
return;
}
$notification_batch_scope = ai4seo_get_site_options_request_cache_scope();
$notification_checks_ran = false;
try {
$is_user_on_our_dashboard = ai4seo_is_plugin_page_active( 'dashboard' );
// push these notifications, only on our dashboard to save resources.
if ( $is_user_on_our_dashboard ) {
// present a fresh missing entries notification on the dashboard page.
ai4seo_check_for_missing_entries_notification();
// check for wpml plugin heads up notification.
ai4seo_check_for_wpml_heads_up_notification();
// check for rate us notification.
ai4seo_check_for_rate_us_notification();
}
// push these notifications, even when the user is not inside our plugin admin pages ->.
ai4seo_check_for_low_credits_balance_notification();
ai4seo_check_activation_notification();
ai4seo_check_for_inefficient_cron_jobs_notification();
ai4seo_check_for_finished_seo_autopilot_notification();
ai4seo_check_for_unfinished_posts_table_analysis_notification( true );
ai4seo_check_for_heavy_db_operations_disabled_notification();
$notification_checks_ran = true;
} finally {
if ( $notification_checks_ran
&& ai4seo_get_site_options_request_cache_scope() === $notification_batch_scope ) {
ai4seo_flush_notification_mutation_batch();
} else {
ai4seo_abort_notification_mutation_batch( $notification_batch_scope );
}
}
// Welcome completion requires durable storage, so keep it outside the staged routine batch.
if ( $is_user_on_our_dashboard && ai4seo_get_site_options_request_cache_scope() === $notification_batch_scope ) {
ai4seo_check_for_welcome_notification();
}
}
/**
* Deliver a durably enrolled welcome once without replacing read or dismissed history.
*
* @return void
*/
function ai4seo_check_for_welcome_notification(): void {
// No writer may finalize pending eligibility against a staged notification batch or a gated user.
if ( ! ai4seo_can_administer_plugin() || ai4seo_does_user_need_to_accept_tos_toc_and_pp()
|| 'pending' !== ai4seo_read_environmental_variable( AI4SEO_ENVIRONMENTAL_VARIABLE_WELCOME_NOTIFICATION_STATE )
|| ai4seo_get_active_notification_mutation_batch_for_current_site() ) {
return;
}
// Confirm eligibility and notification membership together without trusting optimistic request state.
$scope = ai4seo_get_site_options_request_cache_scope();
$snapshots = ai4seo_get_raw_option_snapshots( array( AI4SEO_ENVIRONMENTAL_VARIABLES_OPTION_NAME, AI4SEO_NOTIFICATIONS_OPTION_NAME ) );
if ( null === $snapshots || '' === $scope || ai4seo_get_site_options_request_cache_scope() !== $scope ) {
return;
}
// The version milestone is written only after all required installation migrations succeed.
$environment = $snapshots[ AI4SEO_ENVIRONMENTAL_VARIABLES_OPTION_NAME ]['value'];
if ( ! is_array( $environment )
|| 'pending' !== ( $environment[ AI4SEO_ENVIRONMENTAL_VARIABLE_WELCOME_NOTIFICATION_STATE ] ?? '' )
|| AI4SEO_PLUGIN_VERSION_NUMBER !== ( $environment[ AI4SEO_ENVIRONMENTAL_VARIABLE_LAST_KNOWN_PLUGIN_VERSION ] ?? '' ) ) {
return;
}
// Unreadable history must not be repaired into permission to restart the welcome.
$notification_snapshot = $snapshots[ AI4SEO_NOTIFICATIONS_OPTION_NAME ];
if ( $notification_snapshot['exists'] && ! is_array( $notification_snapshot['value'] ) ) {
return;
}
// Even malformed history under this stable index is not permission to restart its lifecycle.
if ( ! array_key_exists( AI4SEO_WELCOME_NOTIFICATION_INDEX, $notification_snapshot['value'] ?? array() ) ) {
// Keep translated text escaped separately from trusted local emphasis and list markup.
$heading = sprintf(
/* translators: %s: Plugin name. */
__( 'Welcome to %s!', 'ai-for-seo' ),
AI4SEO_PLUGIN_NAME
);
$message = '' . esc_html( $heading ) . '
';
$message .= esc_html__( 'Start with these 3 steps:', 'ai-for-seo' ) . '';
$message .= '
' . esc_html__( 'Get an overview', 'ai-for-seo' ) . ': ' . esc_html__( 'Check the Dashboard for content missing SEO data.', 'ai-for-seo' ) . '
';
$message .= '
' . esc_html__( 'Give it a try', 'ai-for-seo' ) . ': ' . esc_html__( 'Generate metadata for a page or attributes for an image, then review the result.', 'ai-for-seo' ) . '
';
$message .= '
' . esc_html__( 'Make it fit your needs', 'ai-for-seo' ) . ': ' . esc_html__( 'Adjust Settings as needed, then set up SEO Autopilot.', 'ai-for-seo' ) . '
';
$message .= esc_html__( 'We’ve continuously optimized our AI prompts for 4 years for the best possible results. Customize AI generation with at least 35 Settings options, some under advanced settings or dependent on your plan and configuration.', 'ai-for-seo' );
$message .= '
' . esc_html__( 'Unclear or not working? Contact our agile support team — we usually reply within a few hours.', 'ai-for-seo' );
// Reuse the normal temporary lifecycle with no navigation or generation action fields.
if ( ! ai4seo_push_notification(
AI4SEO_WELCOME_NOTIFICATION_INDEX,
$message,
false,
array( 'notice_type' => 'notice-info' )
) ) {
return;
}
// A failed readback leaves a retry which recognizes the existing row on its next attempt.
$notification_snapshot = ai4seo_get_raw_option_snapshot( AI4SEO_NOTIFICATIONS_OPTION_NAME );
if ( null === $notification_snapshot || ! is_array( $notification_snapshot['value'] )
|| ! array_key_exists( AI4SEO_WELCOME_NOTIFICATION_INDEX, $notification_snapshot['value'] ) ) {
return;
}
}
// Finalize only pending state; concurrent terminal decisions must never be overwritten.
if ( ai4seo_get_site_options_request_cache_scope() === $scope ) {
ai4seo_mutate_environmental_variable_value(
AI4SEO_ENVIRONMENTAL_VARIABLE_WELCOME_NOTIFICATION_STATE,
static function ( $state ) {
return 'pending' === $state ? 'handled' : $state;
}
);
}
}
/**
* Update the notification for an unfinished posts-table analysis.
*
* @param bool $force Whether to force the notification to be refreshed.
* @return void
*/
function ai4seo_check_for_unfinished_posts_table_analysis_notification( $force = false ) {
if ( ai4seo_prevent_loops( __FUNCTION__, 1, 5 ) ) {
return;
}
$notification_index = 'unfinished-posts-table-analysis';
$notification = ai4seo_get_analysis_notification( ai4seo_get_posts_table_analysis_status(), ai4seo_get_analysis_update_presentation() );
if ( ! $notification ) {
ai4seo_remove_routine_notification( $notification_index );
return;
}
// Preserve dismissal until a caller explicitly requests the notice again.
if ( ! $force && ai4seo_is_routine_notification_dismissed( $notification_index ) ) {
return;
}
ai4seo_push_routine_notification( $notification_index, $notification['message'], $force, $notification );
}
/**
* Determine the severity of an analysis notification.
*
* @param array $analysis Current analysis state.
* @param array $update Update presentation details.
* @return string Notice severity class.
*/
function ai4seo_get_analysis_notification_notice_type( array $analysis, array $update ): string {
// Keep intentional pauses distinct from failures while retaining the existing permanent notice.
if ( 'failed' === $analysis['status'] ) {
$notice_type = 'notice-error';
} elseif ( $update['warning'] || ( 'deferred' === $analysis['status'] && empty( $analysis['preparing'] ) ) ) {
$notice_type = 'notice-warning';
} else {
$notice_type = 'notice-info';
}
return $notice_type;
}
/**
* Build one notification for preparation, analysis and unresolved update problems.
*
* @param array $analysis Authoritative analysis snapshot.
* @param array $update Resolved update presentation.
* @return array Notification fields, or empty after completion and during routine background work.
*/
function ai4seo_get_analysis_notification( array $analysis, array $update ): array {
$update_details = ai4seo_get_plugin_update_notice_html( true, $update );
$analysis['update_details'] = $update_details;
$analysis['update_warning'] = $update['warning'];
if ( '' === $update_details && (
'completed' === $analysis['status']
|| ( ! empty( $analysis['background'] ) && 'incomplete' === $analysis['status'] && empty( $analysis['readiness']['blocked_options'] ) )
) ) {
return array();
}
$message = ai4seo_get_posts_table_analysis_notice_message( $analysis );
$notice_type = ai4seo_get_analysis_notification_notice_type( $analysis, $update );
return array(
'message' => $message,
'notice_type' => $notice_type,
'is_permanent' => true,
'ignore_during_dashboard_refresh' => false,
);
}
/**
* Update the notification describing changes since the last known plugin version.
*
* @param string $last_known_plugin_version Last plugin version known to the user.
* @param bool $force Whether to force the notification to be refreshed.
* @return void
*/
function ai4seo_check_for_plugin_update_notification( $last_known_plugin_version, $force = false ) {
if ( ! ai4seo_singleton( __FUNCTION__ ) ) {
return;
}
$notification_index = 'plugin-update';
// go through change log and collect useful data.
$full_change_log = ai4seo_get_change_log();
$total_num_changes = 0;
$change_log_examples = array();
$missed_plugin_versions = array();
foreach ( $full_change_log as $change_log_entry ) {
if ( ! isset( $change_log_entry['important'] ) || ! $change_log_entry['important'] ) {
continue;
}
// we can break here since there are no versions lower than the last known plugin version left.
if ( ! isset( $change_log_entry['version'] ) || version_compare( $change_log_entry['version'], $last_known_plugin_version, '<=' ) ) {
break;
}
if ( ! isset( $change_log_entry['updates'] ) || ! $change_log_entry['updates'] ) {
continue;
}
$missed_plugin_versions[] = $change_log_entry['version'];
$total_num_changes += count( $change_log_entry['updates'] );
foreach ( $change_log_entry['updates'] as $this_update ) {
if ( count( $change_log_examples ) < 3 ) {
$change_log_examples[] = $this_update;
}
}
}
// if we only have one entry and this contains "maintenance" updates, we skip showing this notification.
if ( count( $change_log_examples ) === 1 ) {
$first_change_log_example = reset( $change_log_examples );
if ( stripos( $first_change_log_example, 'Maintenance' ) !== false ) {
ai4seo_remove_notification( $notification_index );
return;
}
}
if ( ! $missed_plugin_versions ) {
ai4seo_remove_notification( $notification_index );
return;
}
// if we have dismissed this notification before, we don't show it again.
if ( ! $force && ai4seo_is_notification_dismissed( $notification_index ) ) {
return;
}
$remaining_changes = ( $total_num_changes - count( $change_log_examples ) );
// build message.
$message = sprintf(
/* translators: 1: Plugin name, 2: Plugin version, 3: Plugin versions */
esc_html__( 'Heads up! %1$s has been updated from version %2$s to version %3$s, and it includes %4$s important improvements:', 'ai-for-seo' ),
'' . AI4SEO_PLUGIN_NAME . '',
$last_known_plugin_version,
'' . AI4SEO_PLUGIN_VERSION_NUMBER . '',
'' . $total_num_changes . ''
);
$message .= '
';
$message .= sprintf(
/* translators: %s: Number of additional changelog improvements. */
esc_html__( 'And %1$s more improvements!', 'ai-for-seo' ),
"{$remaining_changes}",
);
$message .= '
';
}
$message .= '
';
if ( $remaining_changes > 0 ) {
$message .= esc_html__( '👉 Check out the full changelog by clicking the button below.', 'ai-for-seo' );
}
// push the notification.
ai4seo_push_notification(
$notification_index,
$message,
$force,
array(
'notice_type' => 'notice-info',
'max_num_visible_notifications_condition' => 1, // prevent spam.
'see_whats_new_button' => $remaining_changes > 0,
)
);
}
// phpcs:disable Generic.CodeAnalysis.UnusedFunctionParameter.Found -- Preserve the public force parameter.
/**
* Update the warning shown while heavy database operations are disabled.
*
* @param bool $force Whether to force the notification to be refreshed.
* @return void
*/
function ai4seo_check_for_heavy_db_operations_disabled_notification( bool $force = false ) {
// phpcs:enable Generic.CodeAnalysis.UnusedFunctionParameter.Found
if ( ! ai4seo_singleton( __FUNCTION__ ) ) {
return;
}
$notification_index = 'heavy-db-operations-disabled';
if ( ! ai4seo_get_setting( AI4SEO_SETTING_DISABLE_HEAVY_DB_OPERATIONS ) ) {
ai4seo_remove_routine_notification( $notification_index );
return;
}
$help_troubleshooting_url = ai4seo_get_subpage_url( 'help' ) . '#ai4seo-troubleshooting-section';
$message = sprintf(
/* translators: %s: Help & Troubleshooting URL */
__( 'Heavy database refresh operations are currently disabled for debugging. Coverage statistics and generation summaries may be outdated until you re-enable this option under Help > Troubleshooting.', 'ai-for-seo' ),
esc_url( $help_troubleshooting_url )
);
ai4seo_push_routine_notification(
$notification_index,
$message,
true,
array(
'notice_type' => 'notice-warning',
'is_permanent' => true,
'go_to_help_button' => true,
)
);
}
/**
* Update the informational notification shown when WPML is active.
*
* @param bool $force Whether to force the notification to be refreshed.
* @return void
*/
function ai4seo_check_for_wpml_heads_up_notification( $force = false ) {
if ( ! ai4seo_singleton( __FUNCTION__ ) ) {
return;
}
$notification_index = 'wpml-heads-up';
// check if this notification is already dismissed.
if ( ! $force && ai4seo_is_routine_notification_dismissed( $notification_index ) ) {
return;
}
// check if WPML plugin is active.
if ( ! ai4seo_is_plugin_or_theme_active( AI4SEO_THIRD_PARTY_PLUGIN_WPML ) ) {
ai4seo_remove_routine_notification( $notification_index );
return;
}
$message = sprintf(
/* translators: 1: Plugin name “WPML”, 2: Your plugin name */
esc_html__( 'Just a heads-up — this isn’t a warning. %1$s is currently active on your website, and %2$s is fully compatible with it. Here are a few useful tips tailored to your setup:', 'ai-for-seo' ),
'WPML',
'' . AI4SEO_PLUGIN_NAME . ''
);
$message .= '
';
$message .= '
1. ' . esc_html__( 'Metadata and media attributes should be generated for each entry in every language. For this reason, the total number displayed on the dashboard appears higher, as each entry is processed separately for each language.', 'ai-for-seo' ) . '
';
$message .= '
2. ' . esc_html__( "For best results, we recommend keeping the language settings at \"automatic\", as this ensures the metadata is generated correctly for each language using WPML's language detection.", 'ai-for-seo' ) . '
';
$message .= '
';
$message .= esc_html__( 'You may safely dismiss this notification, once you are aware of the above.', 'ai-for-seo' );
// push the notification.
ai4seo_push_routine_notification( $notification_index, $message, $force );
}
/**
* Update the rating-request notification for eligible users.
*
* @param bool $force Whether to force the notification to be refreshed.
* @return void
*/
function ai4seo_check_for_rate_us_notification( $force = false ) {
if ( ! ai4seo_singleton( __FUNCTION__ ) ) {
return;
}
$notification_index = 'rate-us';
// check if this notification is already dismissed.
if ( ! $force && ai4seo_is_routine_notification_dismissed( $notification_index ) ) {
return;
}
$ai4seo_has_purchased_something = (bool) ai4seo_read_environmental_variable( AI4SEO_ENVIRONMENTAL_VARIABLE_HAS_PURCHASED_SOMETHING );
// user has not purchased anything, no need to show the rate us notification yet.
if ( ! $ai4seo_has_purchased_something ) {
return;
}
// todo: user already rated us, no need to show the rate us notification again.
$message = esc_html__( "We hope you're enjoying the plugin and we'd love to hear your feedback and thoughts on your experience. Leaving a comment and rating our plugin using the button below truly helps us with further development and allows us to maintain high support standards. Your input is greatly appreciated!", 'ai-for-seo' );
$message .= '
' . sprintf(
/* translators: %s: plugin name */
esc_html__( 'On behalf of the entire %s team, thank you for your support!', 'ai-for-seo' ),
esc_html( AI4SEO_PLUGIN_NAME )
);
$message .= ' ❤️';
ai4seo_push_routine_notification(
$notification_index,
$message,
$force,
array(
'notice_type' => 'notice-success',
'show_avatar' => true,
'rate_us_button' => true,
'max_num_visible_notifications_condition' => 0, // prevent spam, catch a focused moment.
'is_robhub_account_synced_condition' => true, // only show this notification if the RobHub account is synced.
'has_purchased_something_condition' => true, // only show this notification if the user has purchased something.
)
);
}
/**
* Update the notification for a low RobHub credit balance.
*
* @param bool $force Whether to force the notification to be refreshed.
* @return void
*/
function ai4seo_check_for_low_credits_balance_notification( $force = false ) {
if ( ! ai4seo_singleton( __FUNCTION__ ) ) {
return;
}
$notification_index = 'low-credits-balance';
if ( ! ai4seo_get_account_readiness()['credits_ready'] ) {
return;
}
// check credits balance.
$current_credits_balance = ai4seo_robhub_api()->get_credits_balance();
// everything is fine, no need to show a notice.
if ( $current_credits_balance >= AI4SEO_LOW_CREDITS_THRESHOLD ) {
ai4seo_remove_routine_notification( $notification_index );
return;
}
// check if this notification is already dismissed.
if ( ! $force && ai4seo_is_routine_notification_dismissed( $notification_index ) ) {
return;
}
// very low credits balance, show notice-error.
if ( $current_credits_balance < AI4SEO_VERY_LOW_CREDITS_THRESHOLD ) {
$notice_type = 'notice-error';
$message = sprintf(
/* translators: %s: Remaining credits balance. */
__( "Remaining Credits: %s. Your Credits are running very low.", 'ai-for-seo' ),
ai4seo_format_number_i18n( $current_credits_balance )
);
} else {
$notice_type = 'notice-warning';
$message = sprintf(
/* translators: %s: Remaining credits balance. */
__( 'Remaining Credits: %s. Your Credits are running low.', 'ai-for-seo' ),
ai4seo_format_number_i18n( $current_credits_balance )
);
}
$message .= '
' . __( 'To continue improving your remaining content, please consider purchasing more Credits using the Get more Credits button below. You can also activate Pay-As-You-Go to ensure you never run out of Credits.', 'ai-for-seo' );
$message .= '
' . __( "Have questions or need a custom quote? Just click the Get an exclusive quote button to contact us. We're happy to find a solution that fits your needs.", 'ai-for-seo' );
ai4seo_push_routine_notification(
$notification_index,
$message,
$force,
array(
'notice_type' => $notice_type,
'is_permanent' => true,
'get_more_credits_button' => true,
'get_a_custom_quote_button' => true,
'is_robhub_account_synced_condition' => true, // only show this notification if the RobHub account is synced.
'ignore_during_dashboard_refresh' => false, // refersh this notification even during dashboard refreshes.
)
);
}
/**
* Function to add the performance notice. ATTENTION: Make sure to add the admin notices if the user got the rights to see them
*
* @param bool $force The force value.
* @return void
*/
function ai4seo_check_for_missing_entries_notification( $force = false ) {
if ( ! ai4seo_singleton( __FUNCTION__ ) ) {
return;
}
$notification_index = 'missing-entries';
$posts_table_analysis_state = ai4seo_read_environmental_variable( AI4SEO_ENVIRONMENTAL_VARIABLE_POSTS_TABLE_ANALYSIS_STATE );
// don't show missing entries notification while analysis is still ongoing.
if ( 'completed' !== $posts_table_analysis_state ) {
ai4seo_remove_routine_notification( $notification_index );
return;
}
// check if this notification is already dismissed.
if ( ! $force && ai4seo_is_routine_notification_dismissed( $notification_index ) ) {
return;
}
$messages = array();
// check current credits balance.
$current_credits_balance = ai4seo_robhub_api()->get_credits_balance();
// MISSING POSTS
// do we even have missing posts? If not, we can skip the notice.
$num_missing_posts_by_post_type = ai4seo_get_num_missing_posts_by_post_type();
$num_failed_posts_by_post_type = ai4seo_get_num_failed_posts_by_post_type();
// remove all empty post types.
foreach ( $num_missing_posts_by_post_type as $post_type => $num_posts ) {
// check for failed entries (to subtract them).
if ( isset( $num_failed_posts_by_post_type[ $post_type ] ) && $num_failed_posts_by_post_type[ $post_type ] ) {
$num_posts -= $num_failed_posts_by_post_type[ $post_type ];
}
if ( $num_posts <= 0 ) {
unset( $num_missing_posts_by_post_type[ $post_type ] );
}
// also remove if this post type is auto generated, and we have enough credits left, as it will be fully generated soon.
if ( ai4seo_is_bulk_generation_enabled( $post_type ) && $current_credits_balance >= AI4SEO_VERY_LOW_CREDITS_THRESHOLD ) {
unset( $num_missing_posts_by_post_type[ $post_type ] );
}
}
// if there are no missing posts, return.
if ( empty( $num_missing_posts_by_post_type ) ) {
ai4seo_remove_routine_notification( $notification_index );
return;
}
// GENERATED POSTS
// check ai4seo_get_generation_status_summary_entry for generated posts.
$num_generated_by_post_type = ai4seo_get_num_generated_posts_by_post_type();
// remove empty post types.
foreach ( $num_generated_by_post_type as $post_type => $num_posts ) {
if ( 0 === $num_posts ) {
unset( $num_generated_by_post_type[ $post_type ] );
}
}
// YOU'RE DOING GREAT SO FAR! NOTICE.
if ( $num_generated_by_post_type ) {
$generated_post_types_strings_parts = array();
foreach ( $num_generated_by_post_type as $post_type => $num_posts ) {
if ( AI4SEO_TAXONOMY_PROXY_POST_TYPE === $post_type ) {
$generated_post_types_strings_parts = array_merge( $generated_post_types_strings_parts, ai4seo_get_taxonomy_proxy_count_labels( $num_posts, AI4SEO_GENERATED_METADATA_POST_IDS_OPTION_NAME ) );
continue;
}
// attachment -> media workaround.
if ( 'attachment' === $post_type ) {
$post_type = 'media file';
}
$generated_post_types_strings_parts[] = ai4seo_get_post_type_translation( $post_type, $num_posts );
}
// build $post_types_to_mention_string by separating with commas and the last one with "and".
if ( count( $generated_post_types_strings_parts ) > 1 ) {
$generated_post_types_complete_string = implode( ', ', array_slice( $generated_post_types_strings_parts, 0, -1 ) ) . ' ' . __( 'and', 'ai-for-seo' ) . ' ' . end( $generated_post_types_strings_parts );
} else {
$generated_post_types_complete_string = $generated_post_types_strings_parts[0];
}
$messages[] = sprintf(
/* Translators: %1$s is replaced with bold text. */
__( 'You\'re doing great so far! You already generated SEO-relevant data for %1$s.', 'ai-for-seo' ),
'' . esc_html( $generated_post_types_complete_string ) . ''
);
}
// ROOM FOR IMPROVEMENT! NOTICE.
$missing_post_types_strings_parts = array();
foreach ( $num_missing_posts_by_post_type as $post_type => $num_posts ) {
if ( AI4SEO_TAXONOMY_PROXY_POST_TYPE === $post_type ) {
$missing_post_types_strings_parts = array_merge( $missing_post_types_strings_parts, ai4seo_get_taxonomy_proxy_count_labels( $num_posts, AI4SEO_MISSING_METADATA_POST_IDS_OPTION_NAME ) );
continue;
}
// attachment -> media workaround.
if ( 'attachment' === $post_type ) {
$post_type = 'media file';
}
$missing_post_types_strings_parts[] = ai4seo_get_post_type_translation( $post_type, $num_posts );
}
// build $post_types_to_mention_string by separating with commas and the last one with "and"
// only, when we already have generated posts.
if ( $missing_post_types_strings_parts && $num_generated_by_post_type ) {
if ( count( $missing_post_types_strings_parts ) > 1 ) {
$missing_post_types_complete_string = implode( ', ', array_slice( $missing_post_types_strings_parts, 0, -1 ) ) . ' ' . __( 'and', 'ai-for-seo' ) . ' ' . end( $missing_post_types_strings_parts );
} else {
$missing_post_types_complete_string = $missing_post_types_strings_parts[0];
}
$messages[] = sprintf(
/* Translators: %1$s plugin name, %2$s is replaced with bold text of missing post types. */
__( 'However, there is still room for improvement. %1$s has found missing or problematic data in %2$s. Please check the statistics below and consider generating the missing data to enhance your SEO performance.', 'ai-for-seo' ),
esc_html( AI4SEO_PLUGIN_NAME ),
'' . esc_html( $missing_post_types_complete_string ) . '',
);
}
// NO NOTICES COLLECTED SO FAR? RETURN.
if ( ! $messages ) {
ai4seo_remove_routine_notification( $notification_index );
return;
}
// PUSH NOTIFICATION.
ai4seo_push_routine_notification(
$notification_index,
implode( ' ', $messages ),
$force,
array(
'set_up_seo_autopilot_button' => true,
'max_num_visible_notifications_condition' => 1, // prevent spam.
'is_robhub_account_synced_condition' => true, // only show this notification if the RobHub account is synced.
)
);
}
/**
* Update the account-recovery notification for a failed RobHub response.
*
* @param array $api_response RobHub API response data.
* @param bool $force Whether to force the notification to be refreshed.
* @return void
*/
function ai4seo_check_for_robhub_account_error_notification( $api_response, $force = false ) {
if ( ! ai4seo_singleton( __FUNCTION__ ) ) {
return;
}
$notification_index = 'robhub-account-error';
// if we have a successful response, potentially remove the notification.
if ( isset( $api_response['success'] ) && $api_response['success'] ) {
ai4seo_remove_notification( $notification_index );
return;
}
// check if this notification is already dismissed.
if ( ! $force && ai4seo_is_notification_dismissed( $notification_index ) ) {
return;
}
// Collect API diagnostics separately so the visible notice can stay action-oriented.
$technical_details = array();
if ( isset( $api_response['message'] ) && $api_response['message'] ) {
$technical_details[] = sprintf(
/* translators: %s: Technical API error message. */
__( 'API response: %s', 'ai-for-seo' ),
(string) $api_response['message']
);
}
if ( isset( $api_response['code'] ) && $api_response['code'] ) {
$technical_details[] = sprintf(
/* translators: %s: Internal support reference code. */
__( 'Support reference: #%s', 'ai-for-seo' ),
(string) $api_response['code']
);
}
// Lead with the recovery path; technical API data is available below only when RobHub sent it.
$message = '' . esc_html__( 'License verification needs attention.', 'ai-for-seo' ) . ' ';
$message .= sprintf(
/* translators: %s: Refresh license status button label. */
esc_html__( 'First try %s to reconnect this website with the saved license data. If the issue remains, open Account settings to check the saved license or review the secure recovery guidance.', 'ai-for-seo' ),
'' . esc_html__( 'Refresh license status', 'ai-for-seo' ) . ''
);
if ( $technical_details ) {
// Keep raw support data collapsed by default so the admin notice does not become visually noisy.
$technical_details_content = '
';
$message .= ai4seo_get_collapsible_tag(
esc_html__( 'Technical details', 'ai-for-seo' ),
$technical_details_content,
'ai4seo-notice-technical-details',
'ai4seo-notice-technical-details-content'
);
}
// Configure account recovery actions with the refresh button first, then account and recovery links.
ai4seo_push_notification(
$notification_index,
$message,
$force,
array(
'notice_type' => 'notice-error',
'is_permanent' => true,
'go_to_account_settings_button' => true,
'go_to_account_settings_button_label' => __( 'Open Account settings', 'ai-for-seo' ),
'go_to_account_settings_button_css_class' => '',
'lost_licence_key_button' => true,
'lost_licence_key_button_label' => __( 'License recovery help', 'ai-for-seo' ),
'sync_account_button' => true,
'sync_account_button_first' => true,
'sync_account_button_label' => __( 'Refresh license status', 'ai-for-seo' ),
'sync_account_button_css_class' => 'ai4seo-primary-button',
'contact_us' => true,
'contact_us_info' => false,
)
);
}
/**
* Update the notification for an available plugin release.
*
* @param string $latest_plugin_version Latest available plugin version.
* @param bool $force Whether to force the notification to be refreshed.
* @return void
*/
function ai4seo_check_for_plugin_update_available( $latest_plugin_version, $force = false ) {
if ( ! ai4seo_singleton( __FUNCTION__ ) ) {
return;
}
$notification_index = 'plugin-update-available';
if ( ! $latest_plugin_version ) {
ai4seo_remove_notification( $notification_index );
return;
}
// if we have the latest version, potentially remove the notification.
if ( version_compare( AI4SEO_PLUGIN_VERSION_NUMBER, $latest_plugin_version, '>=' ) ) {
ai4seo_remove_notification( $notification_index );
return;
}
// check if this notification is already dismissed.
if ( ! $force && ai4seo_is_notification_dismissed( $notification_index ) ) {
return;
}
// build message.
$message = sprintf(
/* translators: 1: Plugin name, 2: Plugin version */
esc_html__( 'A new version of %1$s is available: %2$s. Your current version is %3$s. Please update to the latest version to enjoy new features and improvements.', 'ai-for-seo' ),
'' . esc_html( AI4SEO_PLUGIN_NAME ) . '',
'' . esc_html( $latest_plugin_version ) . '',
esc_html( AI4SEO_PLUGIN_VERSION_NUMBER )
);
// push the notification.
ai4seo_push_notification(
$notification_index,
$message,
$force,
array(
'notice_type' => 'notice-info',
'update_plugin_button' => true,
'max_num_visible_notifications_condition' => 1, // prevent spam.
)
);
}
/**
* Normalize a Pay-As-You-Go status before notification routing.
*
* @param mixed $payg_status Raw Pay-As-You-Go status.
* @return string Canonical status, or an empty string for non-scalar input.
*/
function ai4seo_normalize_payg_notification_status( $payg_status ): string {
// Reject compound external values before canonicalization can obscure their original type.
if ( ! is_scalar( $payg_status ) ) {
return '';
}
// Accept only values already in WordPress key form so sanitation cannot create a recognized status.
$payg_status = (string) $payg_status;
$normalized_payg_status = sanitize_key( $payg_status );
return $payg_status === $normalized_payg_status ? $normalized_payg_status : '';
}
/**
* Update the notification for a Pay-As-You-Go error status.
*
* @param mixed $payg_status Current Pay-As-You-Go status.
* @param bool $force Whether to force the notification to be refreshed.
* @return void
*/
function ai4seo_check_for_payg_status_errors( $payg_status, $force = false ) {
// Normalize once so removal, routing, and message selection share one exact status representation.
$payg_status = ai4seo_normalize_payg_notification_status( $payg_status );
$notification_index = 'payg-status-error';
$payg_failure_reason = ai4seo_read_environmental_variable( AI4SEO_ENVIRONMENTAL_VARIABLE_PAYG_FAILURE_REASON );
// if we have a successful response, potentially remove the notification.
if ( ! in_array( $payg_status, array( 'budget-limit-reached', 'payment-pending', 'payment-failed', 'payment-method-failed', 'error' ), true ) ) {
ai4seo_remove_notification( $notification_index );
return;
}
// A later successful sync can clear an earlier notice in the same request.
if ( ! ai4seo_singleton( __FUNCTION__ ) ) {
return;
}
$show_increase_payg_budget_button = false;
$show_contact_us_button = false;
$show_get_more_credits_button = false;
$show_sync_account_button = false;
switch ( $payg_status ) {
case 'budget-limit-reached':
$notice_type = 'notice-warning';
$message = __( 'Budget limit reached. New usage is paused. Increase your limit to resume immediately, or wait for the next cycle.', 'ai-for-seo' );
$show_increase_payg_budget_button = true;
break;
case 'payment-pending':
$notice_type = 'notice-warning';
$message = sprintf(
/* translators: %s: Refresh link label. */
__( "Your Pay-As-You-Go payment is still pending. New usage is paused until the payment is completed. Click '%s' to check if the payment has arrived. If it takes longer than expected, please contact us for assistance.", 'ai-for-seo' ),
'' . esc_html__( 'Refresh', 'ai-for-seo' ) . ''
);
$show_contact_us_button = true;
$show_sync_account_button = true;
$force = true;
break;
case 'payment-method-failed':
$notice_type = 'notice-error';
$message = __( 'Your saved payment method could not be used for your Pay-As-You-Go refill. New usage is paused until the issue is resolved.', 'ai-for-seo' );
if ( 'payment-method-expired' === $payg_failure_reason ) {
$message .= ' ' . __( 'The saved payment method appears to be expired.', 'ai-for-seo' );
} elseif ( 'payment-method-currency-mismatch' === $payg_failure_reason ) {
$message .= ' ' . __( 'The saved payment method is not available for the required billing currency.', 'ai-for-seo' );
} elseif ( 'payment-method-not-off-session-capable' === $payg_failure_reason ) {
$message .= ' ' . __( 'The saved payment method does not support automatic off-session charges.', 'ai-for-seo' );
} elseif ( 'no-payment-method' === $payg_failure_reason ) {
$message .= ' ' . __( 'No saved payment method is currently available for automatic refills.', 'ai-for-seo' );
}
$message .= ' ' . __( 'The fastest way to continue immediately is to manually purchase a Credits Pack using the button below. This will fix most payment-method-related errors.', 'ai-for-seo' );
$show_contact_us_button = true;
$show_get_more_credits_button = true;
$show_sync_account_button = true;
$force = true;
break;
case 'payment-failed':
$notice_type = 'notice-error';
$message = __( 'Your Pay-As-You-Go payment has failed. New usage is paused until the payment issue is resolved.', 'ai-for-seo' );
if ( 'payment-timeout' === $payg_failure_reason ) {
$message .= ' ' . __( 'The payment confirmation took too long to arrive.', 'ai-for-seo' );
}
$message .= ' ' . __( 'The fastest way to continue immediately is to manually purchase a Credits Pack using the button below. This will fix most payment-method-related errors.', 'ai-for-seo' );
$show_contact_us_button = true;
$show_get_more_credits_button = true;
$show_sync_account_button = true;
$force = true;
break;
case 'error':
$notice_type = 'notice-error';
$message = __( 'There was an error with your Pay-As-You-Go refill. New usage is paused until the issue is resolved.', 'ai-for-seo' );
$show_contact_us_button = true;
$show_sync_account_button = true;
$force = true;
break;
default:
ai4seo_remove_notification( $notification_index );
return;
}
// check if this notification is already dismissed.
if ( ! $force && ai4seo_is_notification_dismissed( $notification_index ) ) {
return;
}
// push the notification.
ai4seo_push_notification(
$notification_index,
$message,
$force,
array(
'notice_type' => $notice_type,
'is_permanent' => true,
'contact_us' => $show_contact_us_button,
'get_more_credits_button' => $show_get_more_credits_button,
'increase_payg_budget_button' => $show_increase_payg_budget_button,
'sync_account_button' => $show_sync_account_button,
'is_robhub_account_synced_condition' => true, // only show this notification if the RobHub account is synced.
)
);
}
/**
* Determine whether any supplied post type has outstanding generation work for notifications.
*
* @param array $post_types Post types relevant to the notification.
* @return bool Whether a supplied post type has a positive numeric missing-post count.
*/
function ai4seo_notification_post_types_have_missing_posts( array $post_types ): bool {
$num_missing_posts_by_post_type = ai4seo_get_num_missing_posts_by_post_type();
foreach ( $post_types as $this_post_type ) {
if ( ! empty( $num_missing_posts_by_post_type[ $this_post_type ] )
&& is_numeric( $num_missing_posts_by_post_type[ $this_post_type ] )
&& $num_missing_posts_by_post_type[ $this_post_type ] > 0 ) {
return true;
}
}
return false;
}
/**
* Function to eventually output a notice about inefficient cron jobs
*
* @param bool $force The force value.
* @return void
*/
function ai4seo_check_for_inefficient_cron_jobs_notification( $force = false ) {
if ( ! ai4seo_singleton( __FUNCTION__ ) ) {
return;
}
$notification_index = 'inefficient-cron-jobs';
// no need to check cron job efficiency if seo autopilot is not enabled.
$active_bulk_generation_post_types = ai4seo_get_enabled_bulk_generation_post_types();
if ( ! $active_bulk_generation_post_types ) {
ai4seo_remove_routine_notification( $notification_index );
return;
}
// check if the SEO Autopilot was set up at least X seconds ago.
if ( ! ai4seo_was_seo_autopilot_set_up_at_least_x_seconds_ago() ) {
ai4seo_remove_routine_notification( $notification_index );
return;
}
// notification dismissed and no force -> return.
if ( ! $force && ai4seo_is_routine_notification_dismissed( $notification_index ) ) {
return;
}
// check if the last bulk generation cron job status update time is older than XX minutes.
$bulk_generation_cron_job_status_update_time = ai4seo_get_cron_job_status_update_time( AI4SEO_BULK_GENERATION_CRON_JOB_NAME );
if ( ! $bulk_generation_cron_job_status_update_time ) {
ai4seo_remove_routine_notification( $notification_index );
return;
}
if ( $bulk_generation_cron_job_status_update_time >= ( time() - MINUTE_IN_SECONDS * 10 ) ) {
ai4seo_remove_routine_notification( $notification_index );
return;
}
// no need to check cron job efficiency if we don't have any missing posts.
$we_got_any_missing_posts = ai4seo_notification_post_types_have_missing_posts( $active_bulk_generation_post_types );
if ( ! $we_got_any_missing_posts ) {
// no missing posts for the active post types, remove the notification.
ai4seo_remove_routine_notification( $notification_index );
return;
}
// cron job to slow notification.
if ( ai4seo_is_wordpress_cron_disabled() ) {
$message = sprintf(
/* translators: %s: plugin name */
esc_html__( "Your server cron jobs do not appear to be functioning properly, limiting %s automation. Please ensure that server cron jobs run at least every 5 minutes (1 minute for best results) or (not recommended) enable WordPress' internal cron system.", 'ai-for-seo' ),
esc_html( AI4SEO_PLUGIN_NAME )
);
} else {
$message = esc_html__( 'Heads up: You’re currently using WordPress’ built-in cron system. While it works reliably, automation may run more slowly, especially on websites with low traffic. If you’re satisfied with the current automation speed, you can safely dismiss this notice. If you’d like to speed things up, search for “cron” in Help → F.A.Q. for guidance.', 'ai-for-seo' );
}
// push the notification.
ai4seo_push_routine_notification(
$notification_index,
$message,
$force,
array(
'notice_type' => 'notice-warning',
'go_to_help_button' => true,
'max_num_visible_notifications_condition' => 2, // prevent spam.
'is_robhub_account_synced_condition' => true, // only show this notification if the RobHub account is synced.
'ignore_during_dashboard_refresh' => false,
)
);
}
/**
* Format an ordered list of translated content type labels for a notification.
*
* @param array $labels Sequential translated content type labels.
* @return string Unescaped label list; the caller escapes the final output.
*/
function ai4seo_format_notification_content_type_labels( array $labels ): string {
if ( ! $labels ) {
return '';
}
if ( count( $labels ) > 1 ) {
return implode( ', ', array_slice( $labels, 0, -1 ) ) . ' ' . __( 'and', 'ai-for-seo' ) . ' ' . end( $labels );
}
return $labels[0];
}
/**
* Function to eventually push a notification about SEO Autopilot being finished
*
* @param bool $force The force value.
* @return void
*/
function ai4seo_check_for_finished_seo_autopilot_notification( $force = false ) {
if ( ! ai4seo_singleton( __FUNCTION__ ) ) {
return;
}
$notification_index = 'seo-autopilot-finished';
// check if the SEO Autopilot was set up at least X seconds ago.
if ( ! ai4seo_was_seo_autopilot_set_up_at_least_x_seconds_ago( 10 ) ) {
ai4seo_remove_routine_notification( $notification_index );
return;
}
// no need to check if seo autopilot is not enabled.
$active_bulk_generation_post_types = ai4seo_get_enabled_bulk_generation_post_types();
if ( ! $active_bulk_generation_post_types ) {
ai4seo_remove_routine_notification( $notification_index );
return;
}
// notification dismissed and no force -> return.
if ( ! $force && ai4seo_is_routine_notification_dismissed( $notification_index ) ) {
return;
}
$translated_post_types = array();
foreach ( $active_bulk_generation_post_types as $this_post_type ) {
if ( AI4SEO_TAXONOMY_PROXY_POST_TYPE === $this_post_type ) {
foreach ( ai4seo_get_enabled_bulk_generation_taxonomies() as $taxonomy_name ) {
$taxonomy = get_taxonomy( $taxonomy_name );
$translated_post_types[] = $taxonomy ? $taxonomy->labels->name : $taxonomy_name;
}
continue;
}
// Custom content uses its registered plural label, including third-party post types.
$post_type_object = get_post_type_object( $this_post_type );
if ( $post_type_object && ! $post_type_object->_builtin ) {
$translated_post_types[] = $post_type_object->labels->name;
continue;
}
// attachment -> media workaround.
if ( 'attachment' === $this_post_type ) {
$this_post_type = 'media file';
}
$translated_post_types[] = ai4seo_get_post_type_translation( $this_post_type, true );
}
$finished_seo_autopilot_post_types_message = ai4seo_format_notification_content_type_labels( $translated_post_types );
// no need to check if we still have any missing posts left.
$we_got_any_missing_posts = ai4seo_notification_post_types_have_missing_posts( $active_bulk_generation_post_types );
if ( $we_got_any_missing_posts ) {
// we have missing posts for the active post types, remove the notification.
ai4seo_remove_routine_notification( $notification_index );
return;
}
// check for failed generation in active post types.
$num_failed_posts = 0;
$num_failed_posts_by_post_type = ai4seo_get_num_failed_posts_by_post_type();
foreach ( $active_bulk_generation_post_types as $this_post_type ) {
// check if we have any failed posts for the current post type.
if ( ! empty( $num_failed_posts_by_post_type[ $this_post_type ] )
&& is_numeric( $num_failed_posts_by_post_type[ $this_post_type ] )
&& $num_failed_posts_by_post_type[ $this_post_type ] > 0 ) {
$num_failed_posts += $num_failed_posts_by_post_type[ $this_post_type ];
break;
}
}
// check AI4SEO_LATEST_ACTIVITY_OPTION_NAME for a successful bulk-generated entry
// not older than the SEO Autopilot setup time, so we only show the notification
// when at least one metadata or attachment-attributes generation actually succeeded.
$ai4seo_seo_autopilot_start_time = (int) ai4seo_read_environmental_variable( AI4SEO_ENVIRONMENTAL_VARIABLE_LAST_SEO_AUTOPILOT_SET_UP_TIME );
$latest_activity = ai4seo_get_option( AI4SEO_LATEST_ACTIVITY_OPTION_NAME, array() );
$found_a_recent_successful_bulk_generated_entry = false;
foreach ( $latest_activity as $ai4seo_this_latest_activity_entry ) {
if ( isset( $ai4seo_this_latest_activity_entry['action'] )
&& strstr( $ai4seo_this_latest_activity_entry['action'], 'bulk-generated' )
&& ( $ai4seo_this_latest_activity_entry['status'] ?? 'error' ) === 'success'
&& isset( $ai4seo_this_latest_activity_entry['timestamp'] )
&& $ai4seo_this_latest_activity_entry['timestamp'] >= $ai4seo_seo_autopilot_start_time ) {
$found_a_recent_successful_bulk_generated_entry = true;
break;
}
}
// no recent successful generation found, remove the notification.
if ( ! $found_a_recent_successful_bulk_generated_entry ) {
ai4seo_remove_routine_notification( $notification_index );
return;
}
// finished with failed generations.
if ( $num_failed_posts ) {
$notice_type = 'notice-warning';
// build message.
$message = sprintf(
/* translators: 1: Finished post types, 2: Num failed entries */
esc_html__( 'The SEO Autopilot has finished processing all %1$s. However, generation failed for %2$s entries. Check the “Recent Activity” section or the relevant content pages (e.g. Posts, Media) for details.', 'ai-for-seo' ),
'' . esc_html( $finished_seo_autopilot_post_types_message ) . '',
'' . esc_html( ai4seo_format_number_i18n( $num_failed_posts ) ) . ''
);
} else {
$notice_type = 'notice-success';
// build message.
$message = sprintf(
/* translators: 1: Finished post types */
esc_html__( 'Congratulations! The SEO Autopilot has successfully finished processing all %1$s.', 'ai-for-seo' ),
'' . esc_html( $finished_seo_autopilot_post_types_message ) . ''
);
}
// push the notification.
ai4seo_push_routine_notification(
$notification_index,
$message,
$force,
array(
'notice_type' => $notice_type,
'is_robhub_account_synced_condition' => true, // only show this notification if the RobHub account is synced.
'ignore_during_dashboard_refresh' => false,
)
);
}
/**
* Update the active discount notification from RobHub discount data.
*
* @param array $discount Discount configuration received from RobHub.
* @param bool $allow_notification_force Retained for existing callers; dismissals are never overridden.
* @return void
*/
function ai4seo_check_discount_notification( $discount, $allow_notification_force = false ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- Keep the compatibility parameter.
if ( ! ai4seo_singleton( __FUNCTION__ ) ) {
return;
}
if ( ! is_array( $discount ) ) {
return;
}
// Normalize API-provided content and display flags before composing the stored notification.
$notification_index = 'discount';
$discount_name = isset( $discount['name'] ) && is_string( $discount['name'] ) ? $discount['name'] : '';
$discount_description = isset( $discount['description'] ) && is_string( $discount['description'] )
? ai4seo_sanitize_remote_notification_message( $discount['description'] )
: '';
$discount_percentage = isset( $discount['percentage'] ) && is_numeric( $discount['percentage'] )
? (int) $discount['percentage']
: 0;
$discount_expire_in = isset( $discount['expire_in'] ) && is_numeric( $discount['expire_in'] )
? max( 0, (int) $discount['expire_in'] )
: 0;
$expire_at = isset( $discount['expires_at'] ) ? (int) $discount['expires_at'] : ( $discount_expire_in > 0 ? time() + $discount_expire_in : 0 );
$discount_first_purchase_only = isset( $discount['first_purchase_only'] ) && is_scalar( $discount['first_purchase_only'] )
? (bool) filter_var( $discount['first_purchase_only'], FILTER_VALIDATE_BOOLEAN )
: false;
$discount_voucher_code = isset( $discount['voucher_code'] ) && is_string( $discount['voucher_code'] )
? sanitize_text_field( $discount['voucher_code'] )
: '';
$discount_image_url = ai4seo_sanitize_remote_notification_image_url( $discount['image'] ?? null );
// Preserve the historical presentation flags only when a separately validated banner can satisfy them.
$show_generic_coupon_text = ! isset( $discount['show_generic_coupon_text'] )
|| ! is_scalar( $discount['show_generic_coupon_text'] )
|| (bool) filter_var( $discount['show_generic_coupon_text'], FILTER_VALIDATE_BOOLEAN );
if ( '' === $discount_image_url ) {
$show_generic_coupon_text = true;
}
$show_buttons_row = ! isset( $discount['show_buttons_row'] )
|| ( is_scalar( $discount['show_buttons_row'] )
&& (bool) filter_var( $discount['show_buttons_row'], FILTER_VALIDATE_BOOLEAN ) );
$is_permanent = isset( $discount['is_permanent'] )
&& is_scalar( $discount['is_permanent'] )
&& (bool) filter_var( $discount['is_permanent'], FILTER_VALIDATE_BOOLEAN );
// Only a different server-issued cycle may reset dismissal. Never resurrect an expiring offer.
$existing = ai4seo_get_repaired_notifications()['discount'] ?? array();
$instance = is_string( $discount['instance_id'] ?? null ) ? $discount['instance_id'] : '';
$force = '' !== $instance && ! empty( $existing['offer_instance'] ) && $existing['offer_instance'] !== $instance;
// Pre-defined discount descriptions.
if ( 'early-bird' === $discount_name ) {
$discount_description = sprintf(
/* translators: 1: Discount percentage. 2: Remaining discount time. */
esc_html__( 'We hope you\'re enjoying your first steps with our plugin! We understand that getting started can be challenging, so to support you, we\'re offering a special Early Bird discount of %1$s%% off all your upcoming purchases within the next %2$s.', 'ai-for-seo' ),
'' . esc_html( ai4seo_format_number_i18n( $discount_percentage ) ) . '',
'{{EXPIRE_COUNTDOWN}}'
);
}
// exclusive offer description.
if ( 'exclusive-offer' === $discount_name ) {
$discount_description = sprintf(
/* translators: 1: Discount percentage. 2: Remaining discount time. */
esc_html__( 'As a token of our appreciation, we\'re excited to offer you an exclusive discount of %1$s%% on all your Credits Pack purchases for the next %2$s.', 'ai-for-seo' ),
'' . esc_html( ai4seo_format_number_i18n( $discount_percentage ) ) . '',
'{{EXPIRE_COUNTDOWN}}'
);
}
// build generic description.
if ( ! $discount_description ) {
if ( $discount_first_purchase_only ) {
$discount_description = sprintf(
/* translators: %s: Discount percentage. */
esc_html__( "As a welcome gift, we're offering you a %s discount on your first purchase.", 'ai-for-seo' ),
'' . esc_html( ai4seo_format_number_i18n( $discount_percentage ) ) . '%'
);
$discount_description .= ' 👉 ';
$discount_description .= sprintf(
/* translators: %s: Discount countdown placeholder. */
esc_html__( 'This offer is only valid for the next %1$s, so make sure to claim it before it expires.', 'ai-for-seo' ),
'{{EXPIRE_COUNTDOWN}}'
);
} else {
$discount_description = sprintf(
/* translators: %s: Discount percentage. */
esc_html__( 'We\'re happily offering you a %1$s discount.', 'ai-for-seo' ),
'' . esc_html( ai4seo_format_number_i18n( $discount_percentage ) ) . '%',
);
$discount_description .= ' 👉 ';
$discount_description .= ' ' . sprintf(
/* translators: %s: Discount countdown placeholder. */
esc_html__( 'You can use this discount for ALL your purchases within the next %1$s.', 'ai-for-seo' ),
'{{EXPIRE_COUNTDOWN}}'
);
}
}
// Mention the action only when the corresponding button row will actually be rendered.
$discount_call_to_action = '';
if ( $show_buttons_row ) {
$discount_call_to_action = sprintf(
/* translators: %s: Call-to-action button label. */
esc_html__( 'Click %1$s below to apply the discount now.', 'ai-for-seo' ),
'"' . esc_html__( 'Grab Deal', 'ai-for-seo' ) . '"'
);
}
$generic_coupon_text = $discount_description;
if ( $discount_call_to_action ) {
$generic_coupon_text .= '
' . $discount_call_to_action;
}
// Store an image-free text fallback; rendering creates the typed banner locally from validated metadata.
$message = $generic_coupon_text;
$additional_fields = array(
'notice_type' => 'notice-success',
'offer_instance' => $instance,
'image' => $discount_image_url,
'show_generic_coupon_text' => $show_generic_coupon_text,
'show_buttons_row' => $show_buttons_row,
'is_permanent' => $is_permanent,
'show_avatar' => $show_generic_coupon_text,
'grab_deal_button' => true,
'not_now_button' => true,
'expire_at' => $expire_at,
'voucher_code' => $discount_voucher_code,
'is_robhub_account_synced_condition' => true, // only show this notification if the RobHub account is synced
// "min_num_missing_entries_condition" => 50, # todo: use this, if we can distinguish between agency and non-agency users
// "do_credits_cover_all_missing_entries_condition" => false, # todo: use this, if we can distinguish between agency and non-agency users.
);
// add has_purchased_something_condition dynamically based on if this discount is for first purchase only.
if ( $discount_first_purchase_only ) {
$additional_fields['has_purchased_something_condition'] = false;
}
// add missing entries condition if the discount depends on remaining generation work.
if ( isset( $discount['min_num_missing_entries_condition'] )
&& is_numeric( $discount['min_num_missing_entries_condition'] )
&& (int) $discount['min_num_missing_entries_condition'] > 0 ) {
$additional_fields['min_num_missing_entries_condition'] = (int) $discount['min_num_missing_entries_condition'];
}
// RobHub discount descriptions share the same remote provenance and narrow HTML policy as account notices.
ai4seo_push_remote_notification( $notification_index, $message, $force, $additional_fields );
}
// endregion
// ___________________________________________________________________________________________.