catalog = $catalog ?? new Catalog();
}
/**
* Register hooks for the recommended-plugins menu.
*
* @since 4.9.0
*/
public function hooks() {
add_action( 'admin_init', [ $this, 'register_pages' ] );
add_action( 'activated_plugin', [ $this, 'record_activation' ], 10, 2 );
}
/**
* Instantiate every product landing page so each can register its own
* AJAX and asset hooks.
*
* Runs on `admin_init` rather than synchronously: the catalog labels are
* translated, and building it before `init` triggers a textdomain-too-early
* notice on Pro, where the email log boots the admin during `plugins_loaded`.
*
* @since 4.9.0
*/
public function register_pages() {
foreach ( $this->get_products() as $product ) {
if ( class_exists( $product['page_class'] ) ) {
new $product['page_class']();
}
}
}
/**
* Register the rotating submenu item for the currently surfaced product.
*
* Called by Area during its menu build so the item lands in the right
* position; does nothing once every product is adopted.
*
* @since 4.9.0
*
* @param string $access_capability Capability required to view the page.
*/
public function add_submenu_item( $access_capability ) {
$product = $this->get_current_product();
if ( $product === null ) {
return;
}
add_submenu_page(
Area::SLUG,
$product['label'],
$product['label'],
$access_capability,
$product['page_class']::SLUG,
[ $this, 'render_current_page' ]
);
$short_slug = substr( $product['page_class']::SLUG, strlen( Area::SLUG . '-' ) );
if ( ! in_array( $short_slug, Area::$pages_registered, true ) ) {
Area::$pages_registered[] = $short_slug;
}
}
/**
* Render the landing page for the currently surfaced product.
*
* @since 4.9.0
*/
public function render_current_page() {
$product = $this->get_current_product();
if ( $product === null ) {
return;
}
$page = new $product['page_class']();
// The id is required: all `wpms:` Tailwind utilities are build-scoped
// under `#wp-mail-smtp`, so the landing markup must live beneath it.
echo '
';
$page->output();
echo '
';
}
/**
* Ordered product catalog.
*
* @since 4.9.0
*
* @return array>
*/
public function get_products() {
$products = [
[
'slug' => 'wpconsent',
'label' => esc_html__( 'Privacy Compliance', 'wp-mail-smtp' ),
'page_class' => Pages\WPConsent::class,
'activation_option' => 'wpconsent_activated',
],
[
'slug' => 'activelayer',
'label' => esc_html__( 'Spam Protection', 'wp-mail-smtp' ),
'page_class' => Pages\ActiveLayer::class,
'activation_option' => '', // No activation-time option exposed by the plugin.
],
[
'slug' => 'duplicator',
'label' => esc_html__( 'Backups', 'wp-mail-smtp' ),
'page_class' => Pages\Duplicator::class,
'activation_option' => 'duplicator_install_info', // migration reads ['time'].
],
[
'slug' => 'wpvibe',
'label' => esc_html__( 'AI MCP', 'wp-mail-smtp' ),
'page_class' => Pages\WPVibe::class,
'activation_option' => '', // No activation-time option exposed by the plugin.
],
[
'slug' => 'universally',
'label' => esc_html__( 'Translations', 'wp-mail-smtp' ),
'page_class' => Pages\Universally::class,
'activation_option' => '', // No activation-time option exposed by the plugin.
],
[
'slug' => 'wpcode',
'label' => esc_html__( 'Code Snippets', 'wp-mail-smtp' ),
'page_class' => Pages\WPCode::class,
'activation_option' => 'ihaf_activated', // migration reads ['wpcode'].
],
];
foreach ( $products as $index => $product ) {
$plugin = $this->catalog->get( $product['slug'] );
if ( $plugin === null ) {
continue;
}
// `name`, `plugin` and `plugin_pro` shipped in this array before the
// catalog owned them, so they stay in the returned shape.
$products[ $index ]['name'] = $plugin->get_name();
$products[ $index ]['plugin'] = $plugin->get_basename();
if ( $plugin->get_basename_pro() !== '' ) {
$products[ $index ]['plugin_pro'] = $plugin->get_basename_pro();
}
}
return $products;
}
/**
* The plugin behind a rotation slug.
*
* @since 4.10.0
*
* @param string $slug Rotation product slug.
*
* @return PartnerPlugin|null
*/
public function get_plugin( $slug ): ?PartnerPlugin {
return $this->catalog->get( $slug );
}
/**
* Record first activation time of a catalog plugin (write-once).
*
* @since 4.9.0
*
* @param string $plugin Plugin file relative to the plugins dir.
* @param bool $network_wide Whether activation is network-wide.
*/
public function record_activation( $plugin, $network_wide ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
$catalog_plugin = $this->catalog->get_by_basename( $plugin );
if ( $catalog_plugin === null ) {
return;
}
$slug = $catalog_plugin->get_slug();
if ( ! in_array( $slug, wp_list_pluck( $this->get_products(), 'slug' ), true ) ) {
return;
}
$activated = (array) get_option( self::ACTIVATED_OPTION, [] );
if ( isset( $activated[ $slug ] ) ) {
return;
}
$activated[ $slug ] = time();
update_option( self::ACTIVATED_OPTION, $activated );
}
/**
* The product to surface right now, or null once every product is adopted.
*
* @since 4.9.0
*
* @return array|null
*/
public function get_current_product() {
$products = $this->get_products();
$activated = (array) get_option( self::ACTIVATED_OPTION, [] );
$now = time();
foreach ( $products as $product ) {
$timestamp = isset( $activated[ $product['slug'] ] ) ? (int) $activated[ $product['slug'] ] : 0;
if ( $timestamp === 0 || ( $now - $timestamp ) < self::ADOPTED_AFTER ) {
return $product;
}
}
// Everything adopted: surface nothing. The standard "About Us" submenu
// remains as the trailing item.
return null;
}
}