is_wizard_screen()) {
return;
}
remove_all_actions('user_admin_notices');
remove_all_actions('admin_notices');
remove_all_actions('all_admin_notices');
}
/**
* Whether the wizard has been completed.
*
* @return bool
*/
private function is_completed(): bool {
return (bool) get_option(self::OPT_COMPLETED, false);
}
/**
* Whether the user has dismissed the Start step via "Get Started".
*
* @return bool
*/
private function is_started(): bool {
return (bool) get_option(self::OPT_STARTED, false);
}
/**
* Resolve the step the wizard should open on.
*
* Before "Get Started" the Start step is shown. Once "Get Started" has been
* used it is permanently dismissed: the wizard resumes from the saved step,
* clamped so it can never fall back to Start — even if the progress option
* was manually deleted, in which case it begins at Migration.
*
* @param bool $started Whether the Start step has been dismissed.
* @return int 1-based step index.
*/
public static function resolve_current_step(bool $started): int {
if (!$started) {
return self::STEP_START;
}
$step = (int) get_option(self::OPT_STEP, self::STEP_MIGRATION);
return max(self::STEP_MIGRATION, min(self::TOTAL_STEPS, $step));
}
/**
* Register the wizard page.
*
* The page is registered once under the ThinkRank menu (stable page hook).
* While the wizard is incomplete it appears as a visible "Setup Wizard"
* submenu so the user can resume. Once completed, the menu entry is removed
* but the page stays registered so direct hits still redirect away cleanly.
*
* @return void
*/
public function register_menu(): void {
$this->page_hook = (string) add_submenu_page(
'thinkrank',
__('Setup Wizard', 'thinkrank'),
__('Setup Wizard', 'thinkrank'),
'manage_options',
self::PAGE_SLUG,
[$this, 'render']
);
// Hide the menu entry once completed (page remains addressable).
if ($this->is_completed()) {
remove_submenu_page('thinkrank', self::PAGE_SLUG);
}
}
/**
* One-time redirect to the wizard right after activation.
*
* @return void
*/
public function maybe_redirect_on_activation(): void {
if (!get_transient('thinkrank_setup_wizard_redirect')) {
return;
}
// Consume the flag regardless of whether we redirect.
delete_transient('thinkrank_setup_wizard_redirect');
// Never redirect during AJAX, bulk/network activation, for non-admins,
// or once the wizard is already complete.
if (wp_doing_ajax() || !current_user_can('manage_options') || $this->is_completed()) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only guard against the bulk-activation screen
if (isset($_GET['activate-multi'])) {
return;
}
wp_safe_redirect(admin_url('admin.php?page=' . self::PAGE_SLUG));
exit;
}
/**
* Once completed, bounce any direct hit on the wizard page to the dashboard.
*
* Runs late on admin_menu (before WordPress' page-access check in menu.php),
* so it works even though the menu entry has been removed.
*
* @return void
*/
public function maybe_redirect_completed(): void {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation guard, no state change
$page = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : '';
if ($page !== self::PAGE_SLUG) {
return;
}
if ($this->is_completed() && current_user_can('manage_options')) {
wp_safe_redirect(admin_url('admin.php?page=thinkrank'));
exit;
}
}
/**
* Render the wizard page container.
*
* Guards run here too (defense in depth) because the page is directly
* addressable by URL.
*
* @return void
*/
public function render(): void {
if (!current_user_can('manage_options')) {
wp_die(esc_html__('You do not have permission to access this page.', 'thinkrank'));
}
// Once completed the wizard is no longer accessible.
if ($this->is_completed()) {
wp_safe_redirect(admin_url('admin.php?page=thinkrank'));
exit;
}
?>
is_wizard_screen()) {
$classes .= ' thinkrank-setup-wizard-active';
}
return $classes;
}
/**
* Enqueue the wizard bundle (separate from the main admin bundle).
*
* @param string $hook_suffix Current admin page hook.
* @return void
*/
public function enqueue(string $hook_suffix): void {
if ($hook_suffix !== $this->page_hook || $this->page_hook === '') {
return;
}
$asset_file = THINKRANK_PLUGIN_DIR . 'assets/setup-wizard.asset.php';
$asset = file_exists($asset_file) ? include $asset_file : [
'dependencies' => ['wp-element', 'wp-components', 'wp-api-fetch', 'wp-i18n'],
'version' => THINKRANK_VERSION,
];
wp_enqueue_script(
'thinkrank-setup-wizard',
THINKRANK_PLUGIN_URL . 'assets/setup-wizard.js',
$asset['dependencies'],
$asset['version'],
true
);
wp_enqueue_style(
'thinkrank-setup-wizard',
THINKRANK_PLUGIN_URL . 'assets/setup-wizard.css',
['wp-components'],
$asset['version']
);
// Media library for the logo / social image pickers (step 3).
wp_enqueue_media();
$started = $this->is_started();
$current_step = self::resolve_current_step($started);
// The Migration step is only shown when there is at least one active
// source plugin with data to migrate. Detection is transient-cached, so
// this is cheap. Passing it up-front lets the stepper renumber the
// visible steps deterministically on first paint (no async reflow).
$migration_available = !empty((new Import_Detector())->detect());
// Plugins already migrated inside the wizard — persisted so a completed
// import still shows as "Imported" (button disabled) after a refresh or
// when the user navigates back to the Migration step.
$migrated_plugins = get_option(self::OPT_MIGRATED_PLUGINS, []);
$migrated_plugins = is_array($migrated_plugins) ? array_values($migrated_plugins) : [];
wp_localize_script('thinkrank-setup-wizard', 'thinkrankWizard', [
'apiUrl' => rest_url('thinkrank/v1/'),
'restNonce' => wp_create_nonce('wp_rest'),
'currentStep' => $current_step,
'started' => $started,
'migrationAvailable' => $migration_available,
'migratedPlugins' => $migrated_plugins,
'totalSteps' => self::TOTAL_STEPS,
'steps' => self::STEPS,
'completed' => $this->is_completed(),
'dashboardUrl' => admin_url('admin.php?page=thinkrank'),
'completeUrl' => admin_url('admin.php?page=thinkrank'),
'sitemapUrl' => (new Sitemap_Generator())->get_primary_sitemap_url(),
'googleConnected' => (bool) Settings::instance()->get('google_account_connected', false),
// The wizard is its own entry point and never gets `thinkrankAdmin`,
// so the shared ConnectGoogleButton would find no connect URL here
// and refuse to start the flow. The return URL points back at the
// wizard (the persisted step brings the user to Analytics again)
// instead of the Google Services screen, which would abandon setup.
'googleOAuth' => [
'connectUrl' => Google_OAuth_Proxy::get_connect_url(
admin_url('admin.php?page=' . self::PAGE_SLUG)
),
'reconnectReason' => (string) get_option('thinkrank_google_reconnect_required', ''),
],
'activeEcosystem' => $this->get_active_ecosystem_slugs(),
'siteName' => get_bloginfo('name'),
'siteUrl' => home_url(),
]);
}
/**
* Wizard slugs whose mapped WordPress.org plugin is installed AND active.
*
* The Ecosystem step uses this to auto-check the plugins the user already
* runs, so the checkbox state reflects real activation status on first paint.
* The wizard-slug → wp.org-slug map is owned by the endpoint that performs
* the installs, so both sides stay in sync from a single source.
*
* @return string[] Wizard slugs (keys of the ecosystem map) currently active.
*/
private function get_active_ecosystem_slugs(): array {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
$installed = get_plugins();
$active = [];
foreach (\ThinkRank\API\Setup_Wizard_Endpoint::ECOSYSTEM_PLUGINS as $wizard_slug => $wporg_slug) {
foreach (array_keys($installed) as $plugin_file) {
if (strpos((string) $plugin_file, $wporg_slug . '/') === 0
&& is_plugin_active($plugin_file)
) {
$active[] = $wizard_slug;
break;
}
}
}
return $active;
}
/**
* Flush the import-detection cache when a plugin is (de)activated.
*
* @return void
*/
public function flush_import_detection_cache(): void {
(new Import_Detector())->clear_cache();
}
/**
* Whether the current screen is the wizard page.
*
* @return bool
*/
private function is_wizard_screen(): bool {
if (!function_exists('get_current_screen')) {
return false;
}
$screen = get_current_screen();
return $screen && $this->page_hook !== '' && $screen->id === $this->page_hook;
}
}