PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.1.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.1.1
2.7.0 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 All 48 releases
thinkrank / includes / admin / class-manager.php

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

968 lines 34.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Admin Manager Class
5 *
6 * Handles WordPress admin interface integration
7 *
8 * @package ThinkRank\Admin
9 * @since 1.0.0
10 */
11
12 declare(strict_types=1);
13
14 namespace ThinkRank\Admin;
15
16 use ThinkRank\Core\Settings;
17 use ThinkRank\Core\Database;
18 use ThinkRank\Core\Plan_Config;
19 use ThinkRank\Core\Capability_Manager;
20 use ThinkRank\Admin\Metabox_Manager;
21 use ThinkRank\Admin\Elementor_Metabox;
22 use ThinkRank\Admin\Oxygen_Metabox;
23 use ThinkRank\Admin\Divi_Metabox;
24 use ThinkRank\Admin\Bulk_Action_Manager;
25 use ThinkRank\Admin\Post_List_Filters;
26
27 // Prevent direct access
28 if (!defined('ABSPATH')) {
29 exit;
30 }
31
32 /**
33 * Admin Manager Class
34 *
35 * Single Responsibility: Manage WordPress admin interface
36 *
37 * @since 1.0.0
38 */
39 class Manager {
40
41 /**
42 * Settings instance
43 *
44 * @var Settings
45 */
46 private Settings $settings;
47
48 /**
49 * Database instance
50 *
51 * @var Database
52 */
53 private Database $database;
54
55 /**
56 * Metabox manager instance
57 *
58 * @var Metabox_Manager
59 */
60 private Metabox_Manager $metabox_manager;
61
62 /**
63 * Elementor editor metabox integration instance
64 *
65 * @var Elementor_Metabox
66 */
67 private Elementor_Metabox $elementor_metabox;
68
69 /**
70 * Oxygen / Breakdance editor metabox integration instance
71 *
72 * @var Oxygen_Metabox
73 */
74 private Oxygen_Metabox $oxygen_metabox;
75
76 /**
77 * Divi Visual Builder metabox integration instance
78 *
79 * @var Divi_Metabox
80 */
81 private Divi_Metabox $divi_metabox;
82
83 /**
84 * Post list columns instance
85 *
86 * @var Post_List_Columns
87 */
88 private Post_List_Columns $post_list_columns;
89
90 /**
91 * Focus keyword AJAX handler instance
92 *
93 * @var Focus_Keyword_Ajax
94 */
95 private Focus_Keyword_Ajax $focus_keyword_ajax;
96
97 /**
98 * SEO Quick Edit modal AJAX handler instance
99 *
100 * @var Seo_Quick_Edit_Ajax
101 */
102 private Seo_Quick_Edit_Ajax $seo_quick_edit_ajax;
103
104 /**
105 * Bulk action manager instance
106 *
107 * @var Bulk_Action_Manager
108 */
109 private Bulk_Action_Manager $bulk_action_manager;
110
111 /**
112 * Post list filters instance
113 *
114 * @var Post_List_Filters
115 */
116 private Post_List_Filters $post_list_filters;
117
118 /**
119 * Admin pages
120 *
121 * @var array
122 */
123 private array $pages = [];
124
125 /**
126 * Constructor
127 *
128 * @param Settings $settings Settings instance
129 * @param Database $database Database instance
130 */
131 public function __construct(?Settings $settings = null, ?Database $database = null) {
132 $this->settings = $settings ?? Settings::instance();
133 $this->database = $database ?? new Database();
134 $this->metabox_manager = new Metabox_Manager($this->settings);
135 $this->elementor_metabox = new Elementor_Metabox($this->metabox_manager);
136 $this->oxygen_metabox = new Oxygen_Metabox($this->metabox_manager);
137 $this->divi_metabox = new Divi_Metabox($this->metabox_manager);
138 $this->post_list_columns = new Post_List_Columns();
139 $this->focus_keyword_ajax = new Focus_Keyword_Ajax();
140 $this->seo_quick_edit_ajax = new Seo_Quick_Edit_Ajax();
141 $this->bulk_action_manager = new Bulk_Action_Manager();
142 $this->post_list_filters = new Post_List_Filters();
143 }
144
145 /**
146 * Initialize admin interface
147 *
148 * @return void
149 */
150 public function init(): void {
151 add_action('admin_menu', [$this, 'add_admin_menu']);
152 add_action('admin_enqueue_scripts', [$this, 'enqueue_admin_scripts']);
153 add_action('admin_init', [$this, 'handle_admin_init']);
154 add_action('admin_notices', [$this, 'show_admin_notices']);
155 add_action('thinkrank_admin_notices', [$this, 'show_admin_notices']);
156 add_action( 'in_admin_header', [ $this, 'remove_admin_notice' ], 99 );
157
158 // AJAX handlers
159 add_action('wp_ajax_thinkrank_dismiss_notice', [$this, 'dismiss_notice']);
160
161 // Initialize metabox manager
162 $this->metabox_manager->init();
163
164 // Initialize Elementor editor integration (hooks no-op without Elementor)
165 $this->elementor_metabox->init();
166
167 // Initialize Oxygen / Breakdance editor integration (hooks gate on the
168 // builder request, so they no-op without Oxygen)
169 $this->oxygen_metabox->init();
170
171 // Initialize Divi Visual Builder integration (hooks gate on the VB
172 // request, so they no-op without Divi)
173 $this->divi_metabox->init();
174
175 // Initialize post list columns
176 $this->post_list_columns->init();
177
178 // Initialize focus keyword AJAX handler
179 $this->focus_keyword_ajax->init();
180
181 // Initialize SEO Quick Edit modal AJAX handler
182 $this->seo_quick_edit_ajax->init();
183
184 // Initialize Bulk Action Manager
185 $this->bulk_action_manager->init();
186
187 // Initialize Post List Filters
188 $this->post_list_filters->init();
189
190 // Initialize Setup Wizard (onboarding) controller
191 (new Setup_Wizard())->init();
192
193 // Initialize the wp-admin Dashboard widget (ThinkRank Website Insights)
194 (new Dashboard_Widget())->init();
195 }
196
197 /**
198 * Add admin menu pages
199 *
200 * @return void
201 */
202 public function add_admin_menu(): void {
203 // Main menu page
204 $this->pages['dashboard'] = add_menu_page(
205 __('ThinkRank', 'thinkrank'),
206 __('ThinkRank', 'thinkrank'),
207 Capability_Manager::ACCESS,
208 'thinkrank',
209 [$this, 'render_dashboard_page'],
210 $this->get_menu_icon(),
211 30
212 );
213
214 // Dashboard submenu (same as main)
215 $this->pages['dashboard_sub'] = add_submenu_page(
216 'thinkrank',
217 __('Dashboard', 'thinkrank'),
218 __('Dashboard', 'thinkrank'),
219 Capability_Manager::ACCESS,
220 'thinkrank',
221 [$this, 'render_dashboard_page']
222 );
223
224 // Essential SEO page (React tabbed interface)
225 $this->pages['essential_seo'] = add_submenu_page(
226 'thinkrank',
227 __('Essential SEO', 'thinkrank'),
228 __('Essential SEO', 'thinkrank'),
229 Capability_Manager::ACCESS,
230 'thinkrank-essential-seo',
231 [$this, 'render_essential_seo_page']
232 );
233
234 // AI Tools page — gated by the AI Tools section capability.
235 $this->pages['ai_tools'] = add_submenu_page(
236 'thinkrank',
237 __('AI Tools', 'thinkrank'),
238 __('AI Tools', 'thinkrank'),
239 'thinkrank_content_tools',
240 'thinkrank-ai-tools',
241 [$this, 'render_ai_tools_page']
242 );
243
244 // Usages page — gated by the Analytics section capability.
245 $this->pages['analytics'] = add_submenu_page(
246 'thinkrank',
247 __('Usages', 'thinkrank'),
248 __('Usages', 'thinkrank'),
249 'thinkrank_analytics',
250 'thinkrank-usages',
251 [$this, 'render_analytics_page']
252 );
253
254 // Settings page — gated by the Settings & API Keys section capability.
255 $this->pages['settings'] = add_submenu_page(
256 'thinkrank',
257 __('Settings', 'thinkrank'),
258 __('Settings', 'thinkrank'),
259 'thinkrank_settings',
260 'thinkrank-settings',
261 [$this, 'render_settings_page']
262 );
263
264 // Migration page — re-run SEO data imports from other plugins after
265 // setup. Hidden by default; shown only when the "Enable Migration Tools"
266 // advanced setting is on. Capability matches the import REST endpoints
267 // (`manage_options`) so the UI and API stay in agreement.
268 if (Settings::instance()->get('enable_migration_tools', false)) {
269 $this->pages['migration'] = add_submenu_page(
270 'thinkrank',
271 __('Migration', 'thinkrank'),
272 __('Migration', 'thinkrank'),
273 'manage_options',
274 'thinkrank-migration',
275 [$this, 'render_migration_page']
276 );
277 }
278
279 // Hook for page-specific initialization
280 foreach ($this->pages as $page_hook) {
281 add_action("load-{$page_hook}", [$this, 'load_admin_page']);
282 }
283 }
284
285 /**
286 * Enqueue admin scripts and styles
287 *
288 * APPROACH: Manual enqueuing with disabled webpack code splitting
289 * - All dependencies bundled into main admin.js (677KB)
290 * - Chart.js separated into charts.js (138KB) for performance
291 * - No dynamic chunks - predictable loading order
292 *
293 * @param string $hook_suffix Current admin page hook
294 * @return void
295 */
296 public function enqueue_admin_scripts(string $hook_suffix): void {
297 // Only load on our admin pages
298 if (!in_array($hook_suffix, $this->pages, true)) {
299 return;
300 }
301
302 // Get asset files for cache busting
303 $admin_asset_file = THINKRANK_PLUGIN_DIR . 'assets/admin.asset.php';
304 $admin_asset_data = file_exists($admin_asset_file) ? include $admin_asset_file : [
305 'dependencies' => [],
306 'version' => THINKRANK_VERSION,
307 ];
308
309 // Enqueue the Chart.js bundle on every ThinkRank page: the admin app
310 // is a SPA, so chart pages (Usages, Essential SEO Performance) are
311 // reachable from any other ThinkRank page without a reload.
312 $charts_asset_file = THINKRANK_PLUGIN_DIR . 'assets/charts.asset.php';
313 $should_enqueue_charts = true;
314
315 if ($should_enqueue_charts && file_exists($charts_asset_file)) {
316 $charts_asset_data = include $charts_asset_file;
317 wp_enqueue_script(
318 'thinkrank-charts',
319 THINKRANK_PLUGIN_URL . 'assets/charts.js',
320 $charts_asset_data['dependencies'],
321 $charts_asset_data['version'],
322 true
323 );
324 // Add charts as dependency for admin script to ensure registration before use
325 $admin_dependencies = array_merge($admin_asset_data['dependencies'], ['thinkrank-charts']);
326 } else {
327 // Do not load charts on pages that don't need it
328 $admin_dependencies = $admin_asset_data['dependencies'];
329 }
330
331 wp_enqueue_script(
332 'thinkrank-admin',
333 THINKRANK_PLUGIN_URL . 'assets/admin.js',
334 $admin_dependencies,
335 $admin_asset_data['version'],
336 true
337 );
338
339 // Add defer attribute for better performance
340 wp_script_add_data('thinkrank-admin', 'defer', true);
341
342 // Enqueue admin styles
343 wp_enqueue_style(
344 'thinkrank-admin',
345 THINKRANK_PLUGIN_URL . 'assets/admin.css',
346 ['wp-components'],
347 $admin_asset_data['version']
348 );
349
350 // Enqueue WordPress media library for MediaPicker component
351 wp_enqueue_media();
352
353 // Preload the REST responses every ThinkRank admin page requests on
354 // mount (Site Kit pattern: rest_preload_api_request piped into an
355 // apiFetch preloading middleware) so first paint needs zero
356 // round-trips for them. Only cheap, local settings endpoints belong
357 // here — never Google-backed report data.
358 $preload_paths = apply_filters('thinkrank_apifetch_preload_paths', [
359 '/thinkrank/v1/site-identity/settings',
360 '/thinkrank/v1/site-identity/title/templates',
361 '/thinkrank/v1/site-identity/breadcrumbs/types',
362 ]);
363 $preload_data = array_reduce($preload_paths, 'rest_preload_api_request', []);
364 wp_add_inline_script(
365 'thinkrank-admin',
366 sprintf('window.thinkrankApiPreload = %s;', wp_json_encode((object) $preload_data)),
367 'before'
368 );
369
370 // Site info saved in ThinkRank Site Identity takes precedence over
371 // the WordPress defaults so previews reflect what the user saved.
372 $site_identity_settings = (new \ThinkRank\SEO\Site_Identity_Manager())->get_settings('site');
373
374 // Localize script with data
375 wp_localize_script('thinkrank-admin', 'thinkrankAdmin', [
376 'apiUrl' => rest_url('thinkrank/v1/'),
377 'restNonce' => wp_create_nonce('wp_rest'),
378 'adminNonce' => wp_create_nonce('thinkrank_admin'),
379 'currentUser' => wp_get_current_user()->ID,
380 'displayName' => wp_get_current_user()->display_name,
381 'capabilities' => $this->get_user_capabilities(),
382 'settings' => $this->get_admin_settings(),
383 'i18n' => $this->get_i18n_strings(),
384 'isAdmin' => current_user_can('manage_options'),
385 // Whether any AI provider API key is configured — used to gate
386 // "Generate with AI" buttons in the UI
387 'aiConfigured' => $this->is_ai_configured(),
388 // Plugin version - directly available without API call
389 'version' => THINKRANK_VERSION,
390 // Site information for default values
391 'siteName' => !empty($site_identity_settings['site_name']) ? $site_identity_settings['site_name'] : get_bloginfo('name'),
392 'siteDescription' => !empty($site_identity_settings['site_description']) ? $site_identity_settings['site_description'] : get_bloginfo('description'),
393 'siteUrl' => home_url(),
394 'faviconUrl' => get_site_icon_url(64) ?: '',
395 'adminEmail' => get_option('admin_email'),
396 // Post types for Global SEO navigation
397 'postTypes' => $this->get_public_post_types(),
398 // Role Manager: capabilities the current user holds + the
399 // section → capability map, so the SPA can hide sections a role
400 // cannot access. Administrators receive every capability.
401 'caps' => Capability_Manager::user_capabilities(),
402 'sectionCaps' => Capability_Manager::section_map(),
403 'canManageRoles' => Capability_Manager::current_user_can(Capability_Manager::MANAGE_ROLES),
404 // Pro detection flag
405 'isPro' => Plan_Config::is_pro(),
406 // Data update frequency (Pro: daily, Free: every 3 days)
407 'dataUpdateFrequency' => Plan_Config::is_pro() ? 'daily' : '3days',
408 // NB: no per-feature capability maps here. Email Reporting reads
409 // its capabilities from GET /thinkrank/v1/email-report/config;
410 // localizing a second copy only invited the two to drift.
411 // MCP (Model Context Protocol) connection details for the MCP page.
412 'mcp' => $this->get_mcp_globals(),
413 // Google OAuth: JS only ever gets a nonce-signed admin-post URL.
414 // The consent URL, client ID, and scopes are assembled by the proxy,
415 // so no Google app credentials reach the browser or the bundle.
416 'googleOAuth' => [
417 'connectUrl' => \ThinkRank\Integrations\Google_OAuth_Proxy::get_connect_url(),
418 // '' when fine, otherwise why re-authorization is needed:
419 // 'contract' (upgraded off the old token flow) or
420 // 'credentials' (stored tokens no longer decryptable).
421 'reconnectReason' => (string) get_option('thinkrank_google_reconnect_required', ''),
422 ],
423 // Setup Wizard state — the dashboard Quick Access widget surfaces a
424 // "Complete Setup" shortcut while onboarding is unfinished.
425 'setupWizard' => [
426 'completed' => (bool) get_option(Setup_Wizard::OPT_COMPLETED, false),
427 'url' => admin_url('admin.php?page=' . Setup_Wizard::PAGE_SLUG),
428 ],
429 ]);
430
431 }
432
433 /**
434 * Handle admin initialization
435 *
436 * @return void
437 */
438 public function handle_admin_init(): void {
439 // Show welcome screen for new installations (only if no API key configured)
440 if (get_option('thinkrank_show_welcome') && !$this->has_api_key_configured()) {
441 add_action('admin_notices', [$this, 'show_welcome_notice']);
442 }
443
444 // Check for plugin updates
445 $this->check_plugin_updates();
446 }
447
448 /**
449 * Load admin page
450 *
451 * @return void
452 */
453 public function load_admin_page(): void {
454 // Add screen options
455 $this->add_screen_options();
456 }
457
458 /**
459 * Render dashboard page
460 *
461 * @return void
462 */
463 public function render_dashboard_page(): void {
464 $this->render_admin_page('dashboard', [
465 'title' => __('ThinkRank Dashboard', 'thinkrank'),
466 'description' => __('AI-powered SEO optimization for WordPress', 'thinkrank'),
467 ]);
468 }
469
470 /**
471 * Render Essential SEO page
472 *
473 * @return void
474 */
475 public function render_essential_seo_page(): void {
476 $this->render_admin_page('essential-seo', [
477 'title' => __('Essential SEO', 'thinkrank'),
478 'description' => __('Configure your site-wide SEO settings with AI-powered optimization', 'thinkrank'),
479 ]);
480 }
481
482 /**
483 * Render AI Tools page
484 *
485 * @return void
486 */
487 public function render_ai_tools_page(): void {
488 $this->render_admin_page('ai-tools', [
489 'title' => __('AI Tools', 'thinkrank'),
490 'description' => __('AI-powered content tools including Content Planner and Metadata Generator', 'thinkrank'),
491 ]);
492 }
493
494 /**
495 * Render settings page
496 *
497 * @return void
498 */
499 public function render_settings_page(): void {
500 $this->render_admin_page('settings', [
501 'title' => __('ThinkRank Settings', 'thinkrank'),
502 'description' => __('Configure your AI SEO settings', 'thinkrank'),
503 ]);
504 }
505
506 /**
507 * Build the MCP connection globals passed to the admin app.
508 *
509 * The MCP page fetches live connection state from the
510 * /thinkrank/v1/mcp/connection route; these globals only carry what the
511 * page needs before that request resolves (endpoint URLs and whether the
512 * bundled Abilities API — the tool catalog — is available).
513 *
514 * @return array<string, mixed>
515 */
516 private function get_mcp_globals(): array {
517 // The tool catalog is the abilities registry; wp_register_ability
518 // comes from the bundled Abilities API under dependencies/. When it's
519 // missing (bundle not built), the MCP server has no tools to serve.
520 $abilities_api_available = function_exists('wp_register_ability');
521
522 return [
523 'abilities_api_available' => $abilities_api_available,
524 'mcp_endpoint' => \ThinkRank\Mcp\Mcp_Pairing::site_endpoint(),
525 'mcp_endpoint_rest' => \ThinkRank\Mcp\Mcp_Pairing::site_endpoint_fallback(),
526 ];
527 }
528
529
530
531 /**
532 * Render usage analytics page
533 *
534 * @return void
535 */
536 public function render_analytics_page(): void {
537 $this->render_admin_page('analytics', [
538 'title' => __('Usage Analytics', 'thinkrank'),
539 'description' => __('Track your AI usage, costs, and plugin performance analytics', 'thinkrank'),
540 ]);
541 }
542
543 /**
544 * Render import/export page
545 *
546 * @return void
547 */
548 public function render_import_export_page(): void {
549 $this->render_admin_page('import-export', [
550 'page_title' => __('Import / Export', 'thinkrank'),
551 ]);
552 }
553
554 /**
555 * Render the Migration page (re-run SEO data imports).
556 *
557 * Defense in depth: the submenu is only registered when the setting is on,
558 * but re-check here so a direct hit on the page URL can't bypass the gate.
559 *
560 * @return void
561 */
562 public function render_migration_page(): void {
563 if (!Settings::instance()->get('enable_migration_tools', false)) {
564 wp_die(esc_html__('The Migration tools are not enabled.', 'thinkrank'));
565 }
566 $this->render_admin_page('migration', [
567 'page_title' => __('Migration', 'thinkrank'),
568 ]);
569 }
570
571 /**
572 * Render admin page template
573 *
574 * @param string $page Page identifier
575 * @param array $data Page data
576 * @return void
577 */
578 private function render_admin_page(string $page, array $data): void {
579 ?>
580 <div class="wrap">
581 <div id="thinkrank-<?php echo esc_attr($page); ?>" class="thinkrank-admin-page"></div>
582 </div>
583 <?php
584 }
585
586 /**
587 * Add meta boxes to post edit screens
588 *
589 * @return void
590 */
591 public function add_meta_boxes(): void {
592 $post_types = get_post_types(['public' => true]);
593
594 foreach ($post_types as $post_type) {
595 add_meta_box(
596 'thinkrank-seo',
597 __('ThinkRank SEO', 'thinkrank'),
598 [$this, 'render_seo_meta_box'],
599 $post_type,
600 'normal',
601 'high'
602 );
603 }
604 }
605
606 /**
607 * Render SEO meta box
608 *
609 * @param \WP_Post $post Current post object
610 * @return void
611 */
612 public function render_seo_meta_box(\WP_Post $post): void {
613 wp_nonce_field('thinkrank_meta_box', 'thinkrank_meta_box_nonce');
614
615 echo '<div id="thinkrank-meta-box" data-post-id="' . esc_attr($post->ID) . '">';
616 echo '</div>';
617 }
618
619 /**
620 * Save meta box data
621 *
622 * @param int $post_id Post ID
623 * @return void
624 */
625 public function save_meta_boxes(int $post_id): void {
626 // Verify nonce
627 if (!isset($_POST['thinkrank_meta_box_nonce'])) {
628 return;
629 }
630
631 $nonce = sanitize_text_field(wp_unslash($_POST['thinkrank_meta_box_nonce']));
632 if (!wp_verify_nonce($nonce, 'thinkrank_meta_box')) {
633 return;
634 }
635
636 // Check permissions
637 if (!current_user_can('edit_post', $post_id)) {
638 return;
639 }
640
641 // Save meta data (handled by AJAX in React components)
642 // Pass only sanitized ThinkRank-related fields to action hook
643 $sanitized_data = [];
644 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified above
645 foreach ($_POST as $key => $value) {
646 if (strpos($key, 'thinkrank_') === 0 || strpos($key, '_thinkrank_') === 0) {
647 $sanitized_data[$key] = is_array($value)
648 ? array_map('sanitize_text_field', wp_unslash($value))
649 : sanitize_text_field(wp_unslash($value));
650 }
651 }
652 do_action('thinkrank_save_post_meta', $post_id, $sanitized_data);
653 }
654
655 /**
656 * Show admin notices
657 *
658 * @return void
659 */
660 public function show_admin_notices(): void {
661 // Implementation will be added in next iteration
662 }
663
664 /**
665 * Show welcome notice
666 *
667 * @return void
668 */
669 public function show_welcome_notice(): void {
670 wp_enqueue_style(
671 'thinkrank-admin-notices',
672 THINKRANK_PLUGIN_URL . 'static/css/admin-notices.css',
673 [],
674 THINKRANK_VERSION
675 );
676 ?>
677 <div class="notice notice-success is-dismissible thinkrank-notice thinkrank-welcome-notice">
678 <div class="thinkrank-notice__inner">
679 <div class="thinkrank-notice__body">
680 <p class="thinkrank-notice__title"><?php esc_html_e('Welcome to ThinkRank!', 'thinkrank'); ?></p>
681 <p class="thinkrank-notice__text"><?php esc_html_e('Thanks for installing ThinkRank. Add an AI provider key to unlock automatic titles, descriptions, and SEO scoring.', 'thinkrank'); ?></p>
682 <p class="thinkrank-notice__actions">
683 <a href="<?php echo esc_url(admin_url('admin.php?page=thinkrank-settings')); ?>" class="button button-primary">
684 <?php esc_html_e('Configure Settings', 'thinkrank'); ?>
685 </a>
686 <a href="#" class="thinkrank-notice__dismiss thinkrank-dismiss-welcome" data-nonce="<?php echo esc_attr(wp_create_nonce('thinkrank_admin')); ?>">
687 <?php esc_html_e('Dismiss', 'thinkrank'); ?>
688 </a>
689 </p>
690 </div>
691 </div>
692 </div>
693 <?php
694 // The notice renders on every admin screen, so the dismiss handler must
695 // ship with it — the thinkrank-admin bundle only loads on ThinkRank pages.
696 // Persist the dismissal for both our "Dismiss" link and core's × button.
697 wp_print_inline_script_tag(
698 '( function () {
699 document.addEventListener( "click", function ( event ) {
700 var notice = event.target.closest( ".thinkrank-welcome-notice" );
701 if ( ! notice ) {
702 return;
703 }
704 var link = event.target.closest( ".thinkrank-dismiss-welcome" );
705 if ( ! link && ! event.target.closest( ".notice-dismiss" ) ) {
706 return;
707 }
708 if ( link ) {
709 event.preventDefault();
710 notice.style.display = "none";
711 }
712 window.fetch( window.ajaxurl, {
713 method: "POST",
714 credentials: "same-origin",
715 body: new URLSearchParams( {
716 action: "thinkrank_dismiss_notice",
717 notice_type: "welcome",
718 nonce: notice.querySelector( ".thinkrank-dismiss-welcome" ).dataset.nonce,
719 } ),
720 } );
721 } );
722 } )();'
723 );
724 }
725
726 /**
727 * Dismiss notice via AJAX
728 *
729 * @return void
730 */
731 public function dismiss_notice(): void {
732 check_ajax_referer('thinkrank_admin', 'nonce');
733
734 // The nonce proves intent, not authorization — dismissing a site-wide
735 // notice deletes an option, so require a capability as well.
736 if (!current_user_can('edit_posts')) {
737 wp_die(-1, 403);
738 }
739
740 $notice_type = sanitize_key($_POST['notice_type'] ?? '');
741
742 if ($notice_type === 'welcome') {
743 delete_option('thinkrank_show_welcome');
744 }
745
746 wp_die();
747 }
748
749 /**
750 * Check if API key is configured
751 *
752 * @return bool True if at least one API key is configured
753 */
754 private function has_api_key_configured(): bool {
755 $settings = \ThinkRank\Core\Settings::instance();
756
757 return !empty($settings->get('openai_api_key'))
758 || !empty($settings->get('claude_api_key'))
759 || !empty($settings->get('gemini_api_key'))
760 || !empty($settings->get('openrouter_api_key'));
761 }
762
763 /**
764 * Get menu icon
765 *
766 * @return string Menu icon
767 */
768 private function get_menu_icon(): string {
769 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- a data: URI for the menu icon must be base64.
770 return 'data:image/svg+xml;base64,' . base64_encode(
771 '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
772 <g clipPath="url(#thinkrank-clip)">
773 <g filter="url(#thinkrank-shadow)">
774 <circle cx="14" cy="9.2" r="1.3" fill="#a7aaad"/>
775 </g>
776 <path d="M19.5 7v5.3c0 2.4 0 3.6-.5 4.5-.4.8-1 1.4-1.8 1.8-.9.5-2.1.5-4.5.5H7.3c-2.4 0-3.6 0-4.5-.5-.8-.4-1.4-1-1.8-1.8-.2-.3-.3-.7-.4-1.1.5-.4.9-.6 1.3-.7.5-.2.9-.2 1.4-.2.7.1 1.3.2 2 .3.7.1 1.4.2 2.1.1 1.5-.3 2.5-1 3.4-2 .5-.5.9-1 1.4-1.5.3-.3.6-.7.9-1 .3.1.6.2.9.2.9 0 1.7-.8 1.7-1.7 0-.2 0-.4-.1-.6.7-.4 1.4-.8 2.1-1.1.6-.3 1.2-.6 1.6-.8v.4zM12.5 0c2.4 0 3.6 0 4.5.5.8.4 1.4 1 1.8 1.8.4.7.5 1.6.5 3.1-.1 0-.2.1-.3.1-.5.2-1.1.5-1.8.8-.7.3-1.5.7-2.2 1.1-.3-.3-.7-.4-1.1-.4-.9 0-1.7.8-1.7 1.7 0 .3.1.6.3.9-.3.4-.6.7-.9 1-.5.6-.9 1.1-1.3 1.5-.9.9-1.8 1.5-3 1.7-.6.1-1.2.1-1.8 0-.6-.1-1.3-.3-2-.4-.6-.1-1.2-.1-1.8.1-.3.1-.7.3-1 .5 0-.6 0-1.4 0-2.3V7c0-2.4 0-3.6.5-4.5.4-.8 1-1.4 1.8-1.8C3.9 0 5.1 0 7.5 0h5zm-5.8 8.2c0-.1-.1-.1-.2 0l-.2.9c0 0 0 .1-.1.1l-.9.2c-.1 0-.1.1 0 .1l.9.2c0 0 .1 0 .1.1l.2.9c0 .1.1.1.2 0l.2-.9c0 0 0-.1.1-.1l.9-.2c.1 0 .1-.1 0-.1l-.9-.2c0 0-.1 0-.1-.1l-.2-.9zm8.8-.4c0 .1 0 .2 0 .3 0 .7-.6 1.3-1.3 1.3-.2 0-.4 0-.5-.1.1-.1.2-.2.3-.3.4-.4.9-.8 1.5-1.2zm-1.3-1c.3 0 .5.1.7.2-.6.4-1.1.8-1.5 1.2l-.1.1c-.1.1-.2.2-.3.3-.1-.2-.1-.4-.1-.6 0-.7.6-1.2 1.3-1.2zM8.6 2.5c-.1-.2-.4-.2-.4 0l-.4 1.4c0 .1-.1.1-.1.1L6.2 4.4c-.2.1-.2.4 0 .4l1.4.4c.1 0 .1.1.1.1l.4 1.4c.1.2.4.2.4 0l.4-1.4c0-.1.1-.1.1-.1l1.4-.4c.2-.1.2-.4 0-.4L8.9 4c-.1 0-.1-.1-.1-.1L8.6 2.5z" fill="#a7aaad"/>
777 </g>
778 <defs>
779 <filter id="thinkrank-shadow" x="11.2" y="7.2" width="5.6" height="5.6" filterUnits="userSpaceOnUse" colorInterpolationFilters="sRGB">
780 <feFlood floodOpacity="0" result="BackgroundImageFix"/>
781 <feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
782 <feOffset dy="0.7"/>
783 <feGaussianBlur stdDeviation="0.7"/>
784 <feComposite in2="hardAlpha" operator="out"/>
785 <feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.15 0"/>
786 <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow"/>
787 <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape"/>
788 </filter>
789 <clipPath id="thinkrank-clip">
790 <rect width="20" height="20" rx="5.5" fill="white"/>
791 </clipPath>
792 </defs>
793 </svg>'
794 );
795 }
796
797 /**
798 * Get user capabilities for current user
799 *
800 * @return array User capabilities
801 */
802 private function get_user_capabilities(): array {
803 return [
804 'manage_settings' => current_user_can('manage_options'),
805 'view_analytics' => current_user_can('edit_posts'),
806
807 'use_ai_features' => current_user_can('edit_posts'),
808 ];
809 }
810
811 /**
812 * Get admin settings for JavaScript
813 *
814 * @return array Admin settings
815 */
816 private function get_admin_settings(): array {
817 return [
818
819 'ai_provider' => $this->settings->get('ai_provider', 'openai'),
820 'cache_duration' => $this->settings->get('cache_duration', 3600),
821 ];
822 }
823
824 /**
825 * Whether any AI provider API key is configured
826 *
827 * Mirrors the check used by ThinkRank\AI\Manager.
828 *
829 * @return bool
830 */
831 private function is_ai_configured(): bool {
832 foreach (['openai_api_key', 'claude_api_key', 'gemini_api_key', 'openrouter_api_key'] as $key) {
833 if (!empty($this->settings->get($key, ''))) {
834 return true;
835 }
836 }
837
838 return false;
839 }
840
841 /**
842 * Get internationalization strings
843 *
844 * @return array I18n strings
845 */
846 private function get_i18n_strings(): array {
847 return [
848 'loading' => __('Loading...', 'thinkrank'),
849 'error' => __('An error occurred', 'thinkrank'),
850 'success' => __('Success!', 'thinkrank'),
851 'confirm' => __('Are you sure?', 'thinkrank'),
852 'cancel' => __('Cancel', 'thinkrank'),
853 'save' => __('Save', 'thinkrank'),
854 ];
855 }
856
857 /**
858 * Get all public post types for SEO configuration
859 *
860 * @return array Post types data
861 */
862 private function get_public_post_types(): array {
863 // Get all public post types (both built-in and custom)
864 $post_types = get_post_types([
865 'public' => true
866 ], 'objects');
867
868 $post_types_data = [];
869
870 foreach ($post_types as $post_type) {
871 // Shared eligibility policy (viewable + deny list) so the UI list and
872 // the REST/ability write paths agree on which types are SEO targets.
873 if (!\ThinkRank\SEO\Global_SEO_Post_Types::is_allowed($post_type)) {
874 continue;
875 }
876
877 $post_types_data[] = [
878 'name' => $post_type->name,
879 'slug' => $post_type->name,
880 'label' => $post_type->label,
881 'singular_name' => $post_type->labels->singular_name ?? $post_type->label,
882 'plural_name' => $post_type->label,
883 'public' => $post_type->public,
884 'has_archive' => $post_type->has_archive,
885 'hierarchical' => $post_type->hierarchical,
886 ];
887 }
888
889 return $post_types_data;
890 }
891
892 /**
893 * Add help tabs
894 *
895 * @return void
896 */
897 private function add_help_tabs(): void {
898 $screen = get_current_screen();
899
900 $screen->add_help_tab([
901 'id' => 'thinkrank-overview',
902 'title' => __('Overview', 'thinkrank'),
903 'content' => '<p>' . __('ThinkRank.ai helps you optimize your content with AI-powered SEO suggestions.', 'thinkrank') . '</p>',
904 ]);
905
906 $screen->set_help_sidebar(
907 '<p><strong>' . __('For more information:', 'thinkrank') . '</strong></p>' .
908 '<p><a href="https://thinkrank.ai/docs" target="_blank">' . __('Documentation', 'thinkrank') . '</a></p>' .
909 '<p><a href="https://wpdeveloper.com/support/new-ticket/" target="_blank">' . __('Support', 'thinkrank') . '</a></p>'
910 );
911 }
912
913 /**
914 * Add screen options
915 *
916 * @return void
917 */
918 private function add_screen_options(): void {
919 // Screen options will be added as needed
920 }
921
922 /**
923 * Check for plugin updates
924 *
925 * @return void
926 */
927 private function check_plugin_updates(): void {
928 $current_version = get_option('thinkrank_version');
929
930 if (false === $current_version) {
931 // Fresh install or missing option — record version without firing the update hook.
932 update_option('thinkrank_version', THINKRANK_VERSION);
933 return;
934 }
935
936 if (version_compare($current_version, THINKRANK_VERSION, '<')) {
937 // Handle plugin update
938 do_action('thinkrank_plugin_updated', $current_version, THINKRANK_VERSION);
939 update_option('thinkrank_version', THINKRANK_VERSION);
940 }
941 }
942
943
944 public function remove_admin_notice() {
945 $current_screen = get_current_screen();
946 if ( in_array( $current_screen->id, [
947 'toplevel_page_thinkrank',
948 'thinkrank_page_thinkrank-essential-seo',
949 'thinkrank_page_thinkrank-ai-tools',
950 'thinkrank_page_thinkrank-settings',
951 'thinkrank_page_thinkrank-usages',
952 'thinkrank_page_thinkrank-license',
953 'thinkrank_page_thinkrank-migration'
954 ] , true) ) {
955
956 remove_all_actions( 'user_admin_notices' );
957 remove_all_actions( 'admin_notices' );
958 remove_all_actions( 'all_admin_notices' );
959 remove_all_actions( 'network_admin_notices' );
960
961 // To showing notice in EA settings page we have to use 'eael_admin_notices' action hook
962 add_action( 'admin_notices', function () {
963 do_action( 'thinkrank_admin_notices' );
964 } );
965 }
966 }
967 }
968