PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / trunk
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO vtrunk
2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 1.11.0 All 47 releases
thinkrank / includes / admin / class-setup-wizard.php

class-setup-wizard.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO trunk, at includes/admin/class-setup-wizard.php

466 lines 17.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Setup Wizard Controller
5 *
6 * Registers the onboarding Setup Wizard admin page, handles the post-activation
7 * redirect, the conditional "resume" submenu, and the React app enqueue.
8 *
9 * The wizard is a full-screen standalone React experience (its own webpack entry
10 * `setup-wizard`) rendered into `#thinkrank-setup-wizard`. Reaching the final step
11 * marks the wizard completed, after which the page is no longer accessible.
12 *
13 * @package ThinkRank\Admin
14 * @since 1.0.0
15 */
16
17 declare(strict_types=1);
18
19 namespace ThinkRank\Admin;
20
21 use ThinkRank\Core\Settings;
22 use ThinkRank\Admin\Importers\Import_Detector;
23 use ThinkRank\SEO\Sitemap_Generator;
24 use ThinkRank\Integrations\Google_OAuth_Proxy;
25
26 // Prevent direct access
27 if (!defined('ABSPATH')) {
28 exit;
29 }
30
31 /**
32 * Setup Wizard Controller
33 *
34 * Single Responsibility: wire up the Setup Wizard admin page and its lifecycle.
35 *
36 * @since 1.0.0
37 */
38 class Setup_Wizard {
39
40 /**
41 * Admin page slug.
42 */
43 public const PAGE_SLUG = 'thinkrank_setup_wizard';
44
45 /**
46 * Option flag: wizard completed.
47 */
48 public const OPT_COMPLETED = 'thinkrank_setup_wizard_completed';
49
50 /**
51 * Option: last viewed step (1-based), used to resume.
52 */
53 public const OPT_STEP = 'thinkrank_setup_wizard_step';
54
55 /**
56 * Option: list of source-plugin slugs already migrated from inside the
57 * wizard. Used to disable the Import button and show an "Imported" status so
58 * the same plugin can't be imported twice during onboarding. This is wizard-
59 * only — the standalone Migration page ignores it and always allows re-runs.
60 *
61 * @var string
62 */
63 public const OPT_MIGRATED_PLUGINS = 'thinkrank_setup_wizard_migrated_plugins';
64
65 /**
66 * Option flag: the user clicked "Get Started" on the Start step.
67 *
68 * Once set, the Start step is permanently dismissed — future visits resume
69 * from saved progress (or Migration if progress was wiped), never Start.
70 * Clicking "Skip" on the Start step deliberately does NOT set this, so the
71 * Start step can be shown again on subsequent visits.
72 */
73 public const OPT_STARTED = 'thinkrank_setup_wizard_started';
74
75 /**
76 * 1-based index of the Start step.
77 */
78 public const STEP_START = 1;
79
80 /**
81 * 1-based index of the Migration step — the earliest step shown once the
82 * Start step has been dismissed via "Get Started".
83 */
84 public const STEP_MIGRATION = 2;
85
86 /**
87 * Total number of wizard steps.
88 */
89 public const TOTAL_STEPS = 8;
90
91 /**
92 * Ordered step slugs (must match the React STEPS config).
93 *
94 * @var string[]
95 */
96 private const STEPS = ['start', 'migration', 'site-setup', 'mcp', 'analytics', 'ecosystem', 'help', 'ready'];
97
98 /**
99 * Captured page hook suffix for the hidden wizard page.
100 *
101 * @var string
102 */
103 private string $page_hook = '';
104
105 /**
106 * Initialize hooks.
107 *
108 * @return void
109 */
110 public function init(): void {
111 // Priority 20 so the parent `thinkrank` menu (registered at default 10) exists.
112 add_action('admin_menu', [$this, 'register_menu'], 20);
113 // Late on admin_menu (priority 999) so it runs after register_menu but before
114 // WordPress' page-access check (which happens when wp-admin/menu.php finishes,
115 // before admin_init) — letting completed users bounce cleanly to the dashboard.
116 add_action('admin_menu', [$this, 'maybe_redirect_completed'], 999);
117 add_action('admin_init', [$this, 'maybe_redirect_on_activation']);
118 add_action('admin_enqueue_scripts', [$this, 'enqueue']);
119 add_filter('admin_body_class', [$this, 'add_body_class']);
120 // Import detection is cached for an hour; activating or deactivating a
121 // source SEO plugin changes what's migratable, so flush the cache to
122 // keep the Migration step's visibility (and its rows) in sync.
123 add_action('activated_plugin', [$this, 'flush_import_detection_cache']);
124 add_action('deactivated_plugin', [$this, 'flush_import_detection_cache']);
125 // Keep the onboarding screen clean — suppress unrelated admin notices.
126 add_action('in_admin_header', [$this, 'suppress_admin_notices'], 1);
127 }
128
129 /**
130 * Remove unrelated admin notices on the wizard screen for a clean onboarding.
131 *
132 * @return void
133 */
134 public function suppress_admin_notices(): void {
135 if (!$this->is_wizard_screen()) {
136 return;
137 }
138 remove_all_actions('user_admin_notices');
139 remove_all_actions('admin_notices');
140 remove_all_actions('all_admin_notices');
141 }
142
143 /**
144 * Whether the wizard has been completed.
145 *
146 * @return bool
147 */
148 private function is_completed(): bool {
149 return (bool) get_option(self::OPT_COMPLETED, false);
150 }
151
152 /**
153 * Whether the user has dismissed the Start step via "Get Started".
154 *
155 * @return bool
156 */
157 private function is_started(): bool {
158 return (bool) get_option(self::OPT_STARTED, false);
159 }
160
161 /**
162 * Resolve the step the wizard should open on.
163 *
164 * Before "Get Started" the Start step is shown. Once "Get Started" has been
165 * used it is permanently dismissed: the wizard resumes from the saved step,
166 * clamped so it can never fall back to Start — even if the progress option
167 * was manually deleted, in which case it begins at Migration.
168 *
169 * @param bool $started Whether the Start step has been dismissed.
170 * @return int 1-based step index.
171 */
172 public static function resolve_current_step(bool $started): int {
173 if (!$started) {
174 return self::STEP_START;
175 }
176
177 $step = (int) get_option(self::OPT_STEP, self::STEP_MIGRATION);
178
179 return max(self::STEP_MIGRATION, min(self::TOTAL_STEPS, $step));
180 }
181
182 /**
183 * Register the wizard page.
184 *
185 * The page is registered once under the ThinkRank menu (stable page hook).
186 * While the wizard is incomplete it appears as a visible "Setup Wizard"
187 * submenu so the user can resume. Once completed, the menu entry is removed
188 * but the page stays registered so direct hits still redirect away cleanly.
189 *
190 * @return void
191 */
192 public function register_menu(): void {
193 $this->page_hook = (string) add_submenu_page(
194 'thinkrank',
195 __('Setup Wizard', 'thinkrank'),
196 __('Setup Wizard', 'thinkrank'),
197 'manage_options',
198 self::PAGE_SLUG,
199 [$this, 'render']
200 );
201
202 // Hide the menu entry once completed (page remains addressable).
203 if ($this->is_completed()) {
204 remove_submenu_page('thinkrank', self::PAGE_SLUG);
205 }
206 }
207
208 /**
209 * One-time redirect to the wizard right after activation.
210 *
211 * @return void
212 */
213 public function maybe_redirect_on_activation(): void {
214 if (!get_transient('thinkrank_setup_wizard_redirect')) {
215 return;
216 }
217
218 // Consume the flag regardless of whether we redirect.
219 delete_transient('thinkrank_setup_wizard_redirect');
220
221 // Never redirect during AJAX, bulk/network activation, for non-admins,
222 // or once the wizard is already complete.
223 if (wp_doing_ajax() || !current_user_can('manage_options') || $this->is_completed()) {
224 return;
225 }
226 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only guard against the bulk-activation screen
227 if (isset($_GET['activate-multi'])) {
228 return;
229 }
230
231 wp_safe_redirect(admin_url('admin.php?page=' . self::PAGE_SLUG));
232 exit;
233 }
234
235 /**
236 * Once completed, bounce any direct hit on the wizard page to the dashboard.
237 *
238 * Runs late on admin_menu (before WordPress' page-access check in menu.php),
239 * so it works even though the menu entry has been removed.
240 *
241 * @return void
242 */
243 public function maybe_redirect_completed(): void {
244 // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation guard, no state change
245 $page = isset($_GET['page']) ? sanitize_key(wp_unslash($_GET['page'])) : '';
246 if ($page !== self::PAGE_SLUG) {
247 return;
248 }
249 if ($this->is_completed() && current_user_can('manage_options')) {
250 wp_safe_redirect(admin_url('admin.php?page=thinkrank'));
251 exit;
252 }
253 }
254
255 /**
256 * Render the wizard page container.
257 *
258 * Guards run here too (defense in depth) because the page is directly
259 * addressable by URL.
260 *
261 * @return void
262 */
263 public function render(): void {
264 if (!current_user_can('manage_options')) {
265 wp_die(esc_html__('You do not have permission to access this page.', 'thinkrank'));
266 }
267
268 // Once completed the wizard is no longer accessible.
269 if ($this->is_completed()) {
270 wp_safe_redirect(admin_url('admin.php?page=thinkrank'));
271 exit;
272 }
273 ?>
274 <div id="thinkrank-setup-wizard" class="thinkrank-wizard-root">
275 <div class="tr-wizard tr-wizard-skeleton" role="status" aria-live="polite">
276 <span class="screen-reader-text"><?php esc_html_e('Loading Setup Wizard…', 'thinkrank'); ?></span>
277 <div class="tr-wizard__panel" aria-hidden="true">
278 <div class="tr-wizard__stepper-card">
279 <ol class="tr-wizard__stepper">
280 <?php for ($i = 0; $i < 8; $i++) : ?>
281 <li class="tr-wizard__step">
282 <span class="tr-skel tr-skel--marker"></span>
283 <span class="tr-skel tr-skel--step-label"></span>
284 </li>
285 <?php endfor; ?>
286 </ol>
287 </div>
288
289 <div class="tr-wizard__card">
290 <div class="tr-wizard__header">
291 <span class="tr-skel tr-skel--title"></span>
292 <span class="tr-skel tr-skel--subtitle"></span>
293 </div>
294 <div class="tr-wizard__divider"></div>
295 <div class="tr-wizard__body">
296 <div class="tr-wizard-skeleton__rows">
297 <?php for ($i = 0; $i < 3; $i++) : ?>
298 <div class="tr-wizard-skeleton__row">
299 <span class="tr-skel tr-skel--row-icon"></span>
300 <span class="tr-wizard-skeleton__row-text">
301 <span class="tr-skel tr-skel--row-title"></span>
302 <span class="tr-skel tr-skel--row-detail"></span>
303 </span>
304 <span class="tr-skel tr-skel--row-badge"></span>
305 </div>
306 <?php endfor; ?>
307 </div>
308 </div>
309 <div class="tr-wizard__footer">
310 <span class="tr-skel tr-skel--btn tr-skel--btn-ghost"></span>
311 <span class="tr-skel tr-skel--btn"></span>
312 </div>
313 </div>
314 </div>
315 </div>
316 </div>
317 <?php
318 }
319
320 /**
321 * Add a body class on the wizard page so styles can take over the screen.
322 *
323 * @param string $classes Existing body classes.
324 * @return string
325 */
326 public function add_body_class(string $classes): string {
327 if ($this->is_wizard_screen()) {
328 $classes .= ' thinkrank-setup-wizard-active';
329 }
330 return $classes;
331 }
332
333 /**
334 * Enqueue the wizard bundle (separate from the main admin bundle).
335 *
336 * @param string $hook_suffix Current admin page hook.
337 * @return void
338 */
339 public function enqueue(string $hook_suffix): void {
340 if ($hook_suffix !== $this->page_hook || $this->page_hook === '') {
341 return;
342 }
343
344 $asset_file = THINKRANK_PLUGIN_DIR . 'assets/setup-wizard.asset.php';
345 $asset = file_exists($asset_file) ? include $asset_file : [
346 'dependencies' => ['wp-element', 'wp-components', 'wp-api-fetch', 'wp-i18n'],
347 'version' => THINKRANK_VERSION,
348 ];
349
350 wp_enqueue_script(
351 'thinkrank-setup-wizard',
352 THINKRANK_PLUGIN_URL . 'assets/setup-wizard.js',
353 $asset['dependencies'],
354 $asset['version'],
355 true
356 );
357
358 wp_enqueue_style(
359 'thinkrank-setup-wizard',
360 THINKRANK_PLUGIN_URL . 'assets/setup-wizard.css',
361 ['wp-components'],
362 $asset['version']
363 );
364
365 // Media library for the logo / social image pickers (step 3).
366 wp_enqueue_media();
367
368 $started = $this->is_started();
369 $current_step = self::resolve_current_step($started);
370
371 // The Migration step is only shown when there is at least one active
372 // source plugin with data to migrate. Detection is transient-cached, so
373 // this is cheap. Passing it up-front lets the stepper renumber the
374 // visible steps deterministically on first paint (no async reflow).
375 $migration_available = !empty((new Import_Detector())->detect());
376
377 // Plugins already migrated inside the wizard — persisted so a completed
378 // import still shows as "Imported" (button disabled) after a refresh or
379 // when the user navigates back to the Migration step.
380 $migrated_plugins = get_option(self::OPT_MIGRATED_PLUGINS, []);
381 $migrated_plugins = is_array($migrated_plugins) ? array_values($migrated_plugins) : [];
382
383 wp_localize_script('thinkrank-setup-wizard', 'thinkrankWizard', [
384 'apiUrl' => rest_url('thinkrank/v1/'),
385 'restNonce' => wp_create_nonce('wp_rest'),
386 'currentStep' => $current_step,
387 'started' => $started,
388 'migrationAvailable' => $migration_available,
389 'migratedPlugins' => $migrated_plugins,
390 'totalSteps' => self::TOTAL_STEPS,
391 'steps' => self::STEPS,
392 'completed' => $this->is_completed(),
393 'dashboardUrl' => admin_url('admin.php?page=thinkrank'),
394 'completeUrl' => admin_url('admin.php?page=thinkrank'),
395 'sitemapUrl' => (new Sitemap_Generator())->get_primary_sitemap_url(),
396 'googleConnected' => (bool) Settings::instance()->get('google_account_connected', false),
397 // The wizard is its own entry point and never gets `thinkrankAdmin`,
398 // so the shared ConnectGoogleButton would find no connect URL here
399 // and refuse to start the flow. The return URL points back at the
400 // wizard (the persisted step brings the user to Analytics again)
401 // instead of the Google Services screen, which would abandon setup.
402 'googleOAuth' => [
403 'connectUrl' => Google_OAuth_Proxy::get_connect_url(
404 admin_url('admin.php?page=' . self::PAGE_SLUG)
405 ),
406 'reconnectReason' => (string) get_option('thinkrank_google_reconnect_required', ''),
407 ],
408 'activeEcosystem' => $this->get_active_ecosystem_slugs(),
409 'siteName' => get_bloginfo('name'),
410 'siteUrl' => home_url(),
411 ]);
412 }
413
414 /**
415 * Wizard slugs whose mapped WordPress.org plugin is installed AND active.
416 *
417 * The Ecosystem step uses this to auto-check the plugins the user already
418 * runs, so the checkbox state reflects real activation status on first paint.
419 * The wizard-slug → wp.org-slug map is owned by the endpoint that performs
420 * the installs, so both sides stay in sync from a single source.
421 *
422 * @return string[] Wizard slugs (keys of the ecosystem map) currently active.
423 */
424 private function get_active_ecosystem_slugs(): array {
425 require_once ABSPATH . 'wp-admin/includes/plugin.php';
426
427 $installed = get_plugins();
428 $active = [];
429
430 foreach (\ThinkRank\API\Setup_Wizard_Endpoint::ECOSYSTEM_PLUGINS as $wizard_slug => $wporg_slug) {
431 foreach (array_keys($installed) as $plugin_file) {
432 if (strpos((string) $plugin_file, $wporg_slug . '/') === 0
433 && is_plugin_active($plugin_file)
434 ) {
435 $active[] = $wizard_slug;
436 break;
437 }
438 }
439 }
440
441 return $active;
442 }
443
444 /**
445 * Flush the import-detection cache when a plugin is (de)activated.
446 *
447 * @return void
448 */
449 public function flush_import_detection_cache(): void {
450 (new Import_Detector())->clear_cache();
451 }
452
453 /**
454 * Whether the current screen is the wizard page.
455 *
456 * @return bool
457 */
458 private function is_wizard_screen(): bool {
459 if (!function_exists('get_current_screen')) {
460 return false;
461 }
462 $screen = get_current_screen();
463 return $screen && $this->page_hook !== '' && $screen->id === $this->page_hook;
464 }
465 }
466