PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.7.2
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.7.2
2.7.1 2.7.2 2.7.0 2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 All 141 releases
← All changes | includes/class-metasync-admin-navigation.php +228 -102 2.6.20 → 2.7.2 View file →
@@ -20,8 +20,107 @@
20 20
21 21 const CACHE_KEY = 'metasync_admin_bar_status';
22 22
23 23 /**
24 + * Menu item keys that retain a WordPress admin sidebar row in short mode.
25 + *
26 + * Everything else is registered with an empty parent slug
27 + * (add_submenu_page('', ...)) so the page stays reachable at its
28 + * existing URL but does not claim a sidebar row. The in-page sidebar
29 + * (render_sidenav) continues to show every item.
30 + */
31 + const WP_MENU_KEEP_LIST = [
32 + 'dashboard', // Dashboard
33 + 'seo_controls', // Indexation Control
34 + 'redirections', // Redirections
35 + 'monitor_404', // 404 Monitor
36 + 'xml_sitemap', // XML Sitemap
37 + 'robots_txt', // Robots.txt
38 + 'schema_markup', // Schema Markup
39 + 'seo_health', // SEO Health
40 + 'general', // Settings
41 + ];
42 +
43 + /**
44 + * Whether the Settings page (general) is reachable for the current user.
45 + *
46 + * Settings is the floor — the top-level "Search Atlas" menu item is
47 + * registered with the Settings callback, so it must always point at a
48 + * page the user is permitted to see. When Settings is hidden by access
49 + * control, the top-level callback falls back to the first reachable
50 + * keep-list page (see resolve_top_level_callback()).
51 + *
52 + * @return bool
53 + */
54 + public static function is_settings_reachable()
55 + {
56 + return Metasync_Access_Control::user_can_access('hide_settings');
57 + }
58 +
59 + /**
60 + * Resolve the callback the top-level menu item should use.
61 + *
62 + * The top-level "Search Atlas" menu item is the floor — the plugin must
63 + * always present at least one working entry point. When Settings is
64 + * reachable it is the floor (its callback also renders the Settings
65 + * page). When Settings is hidden, fall back to the first reachable
66 + * keep-list page callback so the parent never leads to a permission
67 + * error or blank page.
68 + *
69 + * @param Metasync_Admin $admin The admin instance whose callbacks are used.
70 + * @return array WordPress-style callback array ([$admin, 'method']).
71 + */
72 + public static function resolve_top_level_callback($admin)
73 + {
74 + if (self::is_settings_reachable()) {
75 + return array($admin, 'create_admin_settings_page');
76 + }
77 +
78 + // Fall back to the first reachable keep-list item callback. The
79 + // keep-list is ordered by priority (Dashboard, Indexation Control,
80 + // Redirections, 404 Monitor, XML Sitemap, Robots.txt, SEO Health,
81 + // Settings). We reuse get_available_menu_items() so access-control
82 + // and connection-state gates are respected identically to the
83 + // sidebar.
84 + $available = self::instance()->get_available_menu_items();
85 + foreach (self::WP_MENU_KEEP_LIST as $key) {
86 + if (isset($available[$key])) {
87 + return array($admin, $available[$key]['callback']);
88 + }
89 + }
90 +
91 + // No keep-list item is reachable — keep Settings as the last resort
92 + // so the menu never leads to a blank page. Settings itself handles
93 + // its own access-control rendering.
94 + return array($admin, 'create_admin_settings_page');
95 + }
96 +
97 + /**
98 + * The ordered keep-list of menu item keys that always retain a
99 + * WordPress admin sidebar row (in short mode).
100 + *
101 + * @return string[]
102 + */
103 + public static function get_wp_menu_keep_list()
104 + {
105 + return self::WP_MENU_KEEP_LIST;
106 + }
107 +
108 + /**
109 + * Whether a given menu item key retains a WordPress admin sidebar row.
110 + *
111 + * Only keep-list items retain a WordPress sidebar row. All other pages
112 + * remain registered and available from the in-page navigation.
113 + *
114 + * @param string $item_key Menu item key from get_available_menu_items().
115 + * @return bool
116 + */
117 + public static function item_keeps_wp_sidebar_row($item_key)
118 + {
119 + return in_array($item_key, self::WP_MENU_KEEP_LIST, true);
120 + }
121 +
122 + /**
24 123 * Get singleton instance.
25 124 *
26 125 * @return self
27 126 */
@@ -187,12 +286,8 @@
187 286 if (empty($whitelabel_settings['hide_indexation_control'])) {
188 287 $menu_items['seo_controls'] = ['title' => 'Indexation Control', 'slug_suffix' => '-seo-controls'];
189 288 }
190 289
191 - if ($general_options['enable_googleinstantindex'] ?? false) {
192 - $menu_items['instant_index'] = ['title' => 'Instant Indexing', 'slug_suffix' => '-instant-index'];
193 - }
194 -
195 290 if ($general_options['enable_google_console'] ?? false) {
196 291 $menu_items['google_console'] = ['title' => 'Google Console', 'slug_suffix' => '-google-console'];
197 292 }
198 293
@@ -407,20 +502,8 @@
407 502 'group' => 'seo'
408 503 ];
409 504 }
410 505
411 - // Instant Indexing - setting now stored in seo_controls (moved from Settings to Indexation Control)
412 - $seo_controls = Metasync::get_option('seo_controls');
413 - if ($seo_controls['enable_googleinstantindex'] ?? false) {
414 - $menu_items['instant_index'] = [
415 - 'title' => 'Instant Indexing',
416 - 'slug_suffix' => '-instant-index',
417 - 'callback' => 'create_admin_google_instant_index_page',
418 - 'internal_nav' => 'Instant Indexing',
419 - 'group' => 'seo'
420 - ];
421 - }
422 -
423 506 if ($general_options['enable_google_console'] ?? false) {
424 507 $menu_items['google_console'] = [
425 508 'title' => 'Google Console',
426 509 'slug_suffix' => '-google-console',
@@ -662,15 +745,19 @@
662 745 global $menu;
663 746 $menu['100'] = array('', 'read', 'separator-metasync-before', '', 'wp-menu-separator');
664 747 }, 5);
665 748
666 - // Main menu page at position 100.1 — bottom of the menu after all standard items
749 + // Main menu page at position 100.1 — bottom of the menu after all standard items.
750 + // The top-level callback is the floor: when Settings is reachable it renders
751 + // Settings; when Settings is hidden it falls back to the first reachable
752 + // keep-list page so the menu never leads to a blank page.
753 + $top_level_callback = self::resolve_top_level_callback($admin);
667 754 add_menu_page(
668 755 $menu_name,
669 756 $menu_title,
670 757 $menu_capability,
671 758 $menu_slug,
672 - array($admin, 'create_admin_settings_page'),
759 + $top_level_callback,
673 760 $menu_icon,
674 761 '100.1'
675 762 );
676 763
@@ -684,109 +771,123 @@
684 771
685 772 // ── Connect slug ──────────────────────────────────────────────────
686 773 $connect_slug = $menu_slug . '-connect';
687 774
775 + // Keep a slug-to-title map so hidden pages can still provide WordPress
776 + // with a browser-tab title when they are registered without a parent.
777 + $admin_page_titles = array();
778 +
779 + // Local helper: register a submenu page under the plugin parent slug
780 + // when the item keeps its WordPress admin sidebar row, or under an
781 + // empty parent slug (hidden from the sidebar but still reachable)
782 + // otherwise. The Settings page (general) is registered against the
783 + // bare $menu_slug so it doubles as the parent menu callback.
784 + $register_submenu = function($item_key, $page_title, $menu_title, $capability, $menu_page_slug, $callback) use ($menu_slug, &$admin_page_titles) {
785 + $admin_page_titles[$menu_page_slug] = $page_title;
786 + $parent = self::item_keeps_wp_sidebar_row($item_key) ? $menu_slug : '';
787 + add_submenu_page($parent, $page_title, $menu_title, $capability, $menu_page_slug, $callback);
788 + };
789 +
688 790 // Dashboard (check access control + "Hide Dashboard" general setting)
689 791 if (Metasync_Access_Control::user_can_access('hide_dashboard') && !self::is_dashboard_hidden_by_framework()) {
690 - add_submenu_page($menu_slug, 'Dashboard', 'Dashboard', $menu_capability, $menu_slug . '-dashboard', array($admin, 'create_admin_dashboard_iframe'));
792 + $register_submenu('dashboard', 'Dashboard', 'Dashboard', $menu_capability, $menu_slug . '-dashboard', array($admin, 'create_admin_dashboard_iframe'));
691 793 }
692 794
693 795 // Indexation Control
694 796 if (Metasync_Access_Control::user_can_access('hide_indexation_control')) {
695 - add_submenu_page($menu_slug, 'Indexation Control', 'Indexation Control', $menu_capability, $menu_slug . '-seo-controls', array($admin, 'create_admin_seo_controls_page'));
797 + $register_submenu('seo_controls', 'Indexation Control', 'Indexation Control', $menu_capability, $menu_slug . '-seo-controls', array($admin, 'create_admin_seo_controls_page'));
696 798 }
697 799
698 800 // 404 Monitor
699 - add_submenu_page($menu_slug, '404 Monitor', '404 Monitor', $menu_capability, $menu_slug . '-404-monitor', array($admin, 'create_admin_404_monitor_page'));
801 + $register_submenu('monitor_404', '404 Monitor', '404 Monitor', $menu_capability, $menu_slug . '-404-monitor', array($admin, 'create_admin_404_monitor_page'));
700 802
701 803 // Redirections
702 804 if (Metasync_Access_Control::user_can_access('hide_redirections')) {
703 - add_submenu_page($menu_slug, 'Redirections', 'Redirections', $menu_capability, $menu_slug . '-redirections', array($admin, 'create_admin_redirections_page'));
805 + $register_submenu('redirections', 'Redirections', 'Redirections', $menu_capability, $menu_slug . '-redirections', array($admin, 'create_admin_redirections_page'));
704 806 }
705 807
706 808 // XML Sitemap
707 809 if (Metasync_Access_Control::user_can_access('hide_xml_sitemap')) {
708 - add_submenu_page($menu_slug, 'XML Sitemap', 'XML Sitemap', $menu_capability, $menu_slug . '-xml-sitemap', array($admin, 'create_admin_xml_sitemap_page'));
810 + $register_submenu('xml_sitemap', 'XML Sitemap', 'XML Sitemap', $menu_capability, $menu_slug . '-xml-sitemap', array($admin, 'create_admin_xml_sitemap_page'));
709 811 }
710 812
711 813 // Robots.txt
712 814 if (Metasync_Access_Control::user_can_access('hide_robots')) {
713 - add_submenu_page($menu_slug, 'Robots.txt', 'Robots.txt', $menu_capability, $menu_slug . '-robots-txt', array($admin, 'create_admin_robots_txt_page'));
815 + $register_submenu('robots_txt', 'Robots.txt', 'Robots.txt', $menu_capability, $menu_slug . '-robots-txt', array($admin, 'create_admin_robots_txt_page'));
714 816 }
715 817
716 - // Site Verification
717 - add_submenu_page($menu_slug, 'Site Verification', 'Site Verification', $menu_capability, $menu_slug . '-search-engine-verify', array($admin, 'create_admin_search_engine_verification_page'));
818 + // Site Verification (in-page sidebar only in short mode)
819 + $register_submenu('site_verification', 'Site Verification', 'Site Verification', $menu_capability, $menu_slug . '-search-engine-verify', array($admin, 'create_admin_search_engine_verification_page'));
718 820
719 - // Instant Indexing (conditional)
720 - if ($seo_controls['enable_googleinstantindex'] ?? false) {
721 - add_submenu_page($menu_slug, 'Instant Indexing', 'Instant Indexing', $menu_capability, $menu_slug . '-instant-index', array($admin, 'create_admin_google_instant_index_page'));
722 - }
723 -
724 821 // Google Console (conditional)
725 822 if ($general_options['enable_google_console'] ?? false) {
726 - add_submenu_page($menu_slug, 'Google Console', 'Google Console', $menu_capability, $menu_slug . '-google-console', array($admin, 'create_admin_google_console_page'));
823 + $register_submenu('google_console', 'Google Console', 'Google Console', $menu_capability, $menu_slug . '-google-console', array($admin, 'create_admin_google_console_page'));
727 824 }
728 825
729 826 // Bing Console (conditional)
730 827 if ($seo_controls['enable_binginstantindex'] ?? false) {
731 - add_submenu_page($menu_slug, 'Bing Console', 'Bing Console', $menu_capability, $menu_slug . '-bing-console', array($admin, 'create_admin_bing_console_page'));
828 + $register_submenu('bing_console', 'Bing Console', 'Bing Console', $menu_capability, $menu_slug . '-bing-console', array($admin, 'create_admin_bing_console_page'));
732 829 }
733 830
734 831 // Schema Markup
735 - add_submenu_page($menu_slug, 'Schema Markup', 'Schema Markup', $menu_capability, $menu_slug . '-schema-markup', array($admin, 'create_admin_schema_markup_page'));
832 + $register_submenu('schema_markup', 'Schema Markup', 'Schema Markup', $menu_capability, $menu_slug . '-schema-markup', array($admin, 'create_admin_schema_markup_page'));
736 833
737 834 // Import SEO Data
738 835 if (Metasync_Access_Control::user_can_access('hide_import_seo')) {
739 - add_submenu_page($menu_slug, 'Import SEO Data', 'Import SEO Data', $menu_capability, $menu_slug . '-import-external', array($admin, 'render_import_external_data_page'));
836 + $register_submenu('import_seo', 'Import SEO Data', 'Import SEO Data', $menu_capability, $menu_slug . '-import-external', array($admin, 'render_import_external_data_page'));
740 837 }
741 838
742 - // Settings
839 + // Settings — registered against the bare $menu_slug (parent menu floor).
840 + // It always keeps its sidebar row so the plugin always has at least
841 + // one working entry point (see is_settings_reachable()).
743 842 if (Metasync_Access_Control::user_can_access('hide_settings')) {
744 - add_submenu_page($menu_slug, 'Settings', 'Settings', $menu_capability, $menu_slug, array($admin, 'create_admin_settings_page'));
843 + $register_submenu('general', 'Settings', 'Settings', $menu_capability, $menu_slug, array($admin, 'create_admin_settings_page'));
745 844 }
746 845
747 - // Local Business
748 - add_submenu_page($menu_slug, 'Local Business', 'Local Business', $menu_capability, $menu_slug . '-local-business', array($admin, 'create_admin_local_business_page'));
846 + // Local Business (in-page sidebar only in short mode)
847 + $register_submenu('local_business', 'Local Business', 'Local Business', $menu_capability, $menu_slug . '-local-business', array($admin, 'create_admin_local_business_page'));
749 848
750 - // Breadcrumbs
751 - add_submenu_page($menu_slug, 'Breadcrumbs', 'Breadcrumbs', $menu_capability, $menu_slug . '-breadcrumbs', array($admin, 'create_admin_breadcrumbs_page'));
849 + // Breadcrumbs (in-page sidebar only in short mode)
850 + $register_submenu('breadcrumbs', 'Breadcrumbs', 'Breadcrumbs', $menu_capability, $menu_slug . '-breadcrumbs', array($admin, 'create_admin_breadcrumbs_page'));
752 851
753 - // Code Snippets
754 - add_submenu_page($menu_slug, 'Code Snippets', 'Code Snippets', $menu_capability, $menu_slug . '-code-snippets', array($admin, 'create_admin_code_snippets_page'));
852 + // Code Snippets (in-page sidebar only in short mode)
853 + $register_submenu('code_snippets', 'Code Snippets', 'Code Snippets', $menu_capability, $menu_slug . '-code-snippets', array($admin, 'create_admin_code_snippets_page'));
755 854
756 - // Code Minification
757 - add_submenu_page($menu_slug, 'Code Minification', 'Code Minification', $menu_capability, $menu_slug . '-code-minification', array($admin, 'create_admin_code_minification_page'));
855 + // Code Minification (in-page sidebar only in short mode)
856 + $register_submenu('code_minification', 'Code Minification', 'Code Minification', $menu_capability, $menu_slug . '-code-minification', array($admin, 'create_admin_code_minification_page'));
758 857
759 - // Media Optimization
760 - add_submenu_page($menu_slug, 'Media Optimization', 'Media Optimization', $menu_capability, $menu_slug . '-media-optimization', array($admin, 'create_admin_media_optimization_page'));
858 + // Media Optimization (in-page sidebar only in short mode)
859 + $register_submenu('media_optimization', 'Media Optimization', 'Media Optimization', $menu_capability, $menu_slug . '-media-optimization', array($admin, 'create_admin_media_optimization_page'));
761 860
762 861 // Custom Pages
763 862 if (Metasync_Access_Control::user_can_access('hide_custom_pages')) {
764 - add_submenu_page($menu_slug, 'Custom Pages', 'Custom Pages', $menu_capability, $menu_slug . '-custom-pages', array($admin, 'create_admin_custom_pages_page'));
863 + $register_submenu('custom_pages', 'Custom Pages', 'Custom Pages', $menu_capability, $menu_slug . '-custom-pages', array($admin, 'create_admin_custom_pages_page'));
765 864 }
766 865
767 - // Bot Statistics
768 - add_submenu_page($menu_slug, 'Bot Statistics', 'Bot Statistics', $menu_capability, $menu_slug . '-bot-statistics', array($admin, 'create_admin_bot_statistics_page'));
866 + // Bot Statistics (in-page sidebar only in short mode)
867 + $register_submenu('bot_statistics', 'Bot Statistics', 'Bot Statistics', $menu_capability, $menu_slug . '-bot-statistics', array($admin, 'create_admin_bot_statistics_page'));
769 868
770 869 // SEO Health dashboard
771 - add_submenu_page($menu_slug, 'SEO Health', 'SEO Health', $menu_capability, $menu_slug . '-seo-health', array($admin, 'create_admin_seo_health_page'));
870 + $register_submenu('seo_health', 'SEO Health', 'SEO Health', $menu_capability, $menu_slug . '-seo-health', array($admin, 'create_admin_seo_health_page'));
772 871
773 872 // Compatibility
774 873 if (Metasync_Access_Control::user_can_access('hide_compatibility')) {
775 - add_submenu_page($menu_slug, 'Compatibility', 'Compatibility', $menu_capability, $menu_slug . '-compatibility', array($admin, 'create_admin_compatibility_page'));
874 + $register_submenu('compatibility', 'Compatibility', 'Compatibility', $menu_capability, $menu_slug . '-compatibility', array($admin, 'create_admin_compatibility_page'));
776 875 }
777 876
778 877 // Changes Log
779 878 if (Metasync_Access_Control::user_can_access('hide_sync_log')) {
780 - add_submenu_page($menu_slug, 'Changes Log', 'Changes Log', $menu_capability, $menu_slug . '-sync-log', array($admin, 'create_admin_sync_log_page'));
879 + $register_submenu('sync_log', 'Changes Log', 'Changes Log', $menu_capability, $menu_slug . '-sync-log', array($admin, 'create_admin_sync_log_page'));
781 880 }
782 881
783 882 // Report Issue
784 883 if (Metasync_Access_Control::user_can_access('hide_report_issue')) {
785 - add_submenu_page($menu_slug, 'Report Issue', 'Report Issue', $menu_capability, $menu_slug . '-report-issue', array($admin, 'create_admin_report_issue_page'));
884 + $register_submenu('report_issue', 'Report Issue', 'Report Issue', $menu_capability, $menu_slug . '-report-issue', array($admin, 'create_admin_report_issue_page'));
786 885 }
787 886
788 887 // ── Connect CTA (shown when not authenticated) ────────────────────
888 + // Kept as a sidebar row so an unconnected site always has a visible
889 + // entry point alongside the top-level menu item.
789 890 if (!$is_fully_connected) {
790 891 $connect_label = sprintf('Connect to %s', Metasync::get_effective_plugin_name());
791 892 add_submenu_page($menu_slug, $connect_label, $connect_label, $menu_capability, $connect_slug, array($admin, 'create_admin_settings_page'));
792 893 }
@@ -791,11 +892,36 @@
791 892 add_submenu_page($menu_slug, $connect_label, $connect_label, $menu_capability, $connect_slug, array($admin, 'create_admin_settings_page'));
792 893 }
793 894
794 895 // ── Hidden pages (no sidebar entry needed) ────────────────────────
896 + $admin_page_titles[$menu_slug . '-setup-wizard'] = 'Setup Wizard';
795 897 add_submenu_page('', 'Setup Wizard', 'Setup Wizard', $menu_capability, $menu_slug . '-setup-wizard', array($admin->setup_wizard, 'render_wizard_page'));
796 898
899 + // WordPress cannot resolve titles for pages registered under an empty
900 + // parent, even though those pages remain reachable by URL. Restore the
901 + // standard admin title format for our registered pages only.
902 + add_filter('admin_title', function($admin_title, $title) use (&$admin_page_titles) {
903 + $current_page = isset($_GET['page']) && is_string($_GET['page']) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page slug for the admin title
904 + ? sanitize_text_field(wp_unslash($_GET['page'])) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page slug for the admin title
905 + : '';
797 906
907 + if (empty($title) && isset($admin_page_titles[$current_page])) {
908 + $site_title = is_network_admin() || is_user_admin()
909 + ? get_network()->site_name
910 + : get_bloginfo('name');
911 +
912 + $admin_title = sprintf(
913 + /* translators: 1: Admin page title, 2: Site or network title. */
914 + __('%1$s ‹ %2$s — WordPress', 'metasync'),
915 + $admin_page_titles[$current_page],
916 + $site_title
917 + );
918 + }
919 +
920 + return $admin_title;
921 + }, 10, 2);
922 +
923 +
798 924 // Rename auto-generated first submenu from plugin name to "Settings" and reorder
799 925 add_action('admin_menu', function() use ($menu_slug) {
800 926 global $submenu;
801 927 if (!isset($submenu[$menu_slug])) {
@@ -1249,9 +1375,9 @@
1249 1375 $status_title_header = 'Not Synced - Heartbeat API not responding or unreachable';
1250 1376 $status_text_header = 'Not Synced';
1251 1377 }
1252 1378 ?>
1253 - <div class="metasync-integration-status <?php echo $status_class_header; ?>"
1379 + <div class="metasync-integration-status <?php echo esc_attr($status_class_header); ?>"
1254 1380 title="<?php echo esc_attr($status_title_header); ?>">
1255 1381 <span class="status-indicator"></span>
1256 1382 <span class="status-text"><?php echo esc_html($status_text_header); ?></span>
1257 1383 </div>
@@ -1331,10 +1457,10 @@
1331 1457
1332 1458 <?php
1333 1459 $page_slug = Metasync_Admin::$page_slug;
1334 1460 $is_plugin_page = (
1335 - (isset($_GET['page']) && strpos($_GET['page'], $page_slug) !== false) ||
1336 - (isset($_GET['page']) && strpos($_GET['page'], 'searchatlas') !== false)
1461 + (isset($_GET['page']) && strpos($_GET['page'], $page_slug) !== false) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check for an inline config script
1462 + (isset($_GET['page']) && strpos($_GET['page'], 'searchatlas') !== false) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only page check for an inline config script
1337 1463 );
1338 1464 if ($is_plugin_page):
1339 1465 ?>
1340 1466 <script type="text/javascript">
@@ -1511,9 +1637,9 @@
1511 1637 * @param string $description Optional subtitle shown below the title.
1512 1638 */
1513 1639 public function render_layout_open($page_title = '', $current_page = '', $description = '')
1514 1640 {
1515 - $theme = esc_attr(get_option('metasync_theme', 'dark'));
1641 + $theme = get_option('metasync_theme', 'dark');
1516 1642 $plugin_name = Metasync::get_effective_plugin_name();
1517 1643 $general = Metasync::get_option('general') ?? [];
1518 1644 $is_connected = Metasync_Heartbeat_Manager::instance()->is_heartbeat_connected($general);
1519 1645 // badge reflects confirmed heartbeat health (connected / stale /
@@ -1522,9 +1648,9 @@
1522 1648 $logo = $this->resolve_logo_data();
1523 1649
1524 1650 $current_theme = get_option('metasync_theme', 'dark');
1525 1651 ?>
1526 - <div class="wrap metasync-dashboard-wrap" data-theme="<?php echo $theme; ?>">
1652 + <div class="wrap metasync-dashboard-wrap" data-theme="<?php echo esc_attr($theme); ?>">
1527 1653
1528 1654 <?php
1529 1655 // Inject whitelabel color palette overrides
1530 1656 $wl_settings = Metasync::get_whitelabel_settings();
@@ -1554,8 +1680,9 @@
1554 1680 $vars .= '--' . esc_attr($grad_var) . ':linear-gradient(135deg,' . esc_attr($from) . ' 0%,' . esc_attr($to) . ' 100%);';
1555 1681 }
1556 1682 }
1557 1683 if ($vars) {
1684 + // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- CSS custom properties inside a raw <style> element; every value is a regex-validated hex color and HTML entities are not decoded in raw text elements
1558 1685 echo $selector . '{' . $vars . '}';
1559 1686 }
1560 1687 }
1561 1688 }
@@ -1630,15 +1757,19 @@
1630 1757 {
1631 1758 ?>
1632 1759 </main><!-- /.metasync-layout-main -->
1633 1760
1634 - <?php if ($show_promo):
1635 - $general = Metasync::get_option('general') ?? [];
1636 - $is_connected = Metasync_Heartbeat_Manager::instance()->is_heartbeat_connected($general);
1761 + <?php
1762 + // The sidebar exists to get an unconnected site connected. Once the
1763 + // site is linked it has nothing further to offer, so the whole
1764 + // column is dropped and the grid reflows to two columns.
1765 + $show_promo = $show_promo && ! Metasync_Heartbeat_Manager::instance()
1766 + ->is_heartbeat_connected(Metasync::get_option('general') ?? []);
1767 + if ($show_promo):
1637 1768 ?>
1638 1769 <!-- Right promo sidebar -->
1639 1770 <aside class="metasync-layout-promo">
1640 - <?php $this->render_promo_sidebar($is_connected); ?>
1771 + <?php $this->render_promo_sidebar(); ?>
1641 1772 </aside>
1642 1773 <?php endif; ?>
1643 1774
1644 1775 </div><!-- /.metasync-layout -->
@@ -1692,11 +1823,20 @@
1692 1823 } else {
1693 1824 $plugin_items[$key] = $item;
1694 1825 }
1695 1826 }
1827 +
1828 + $total_items = count($seo_items) + count($plugin_items);
1829 +
1696 1830 ?>
1697 1831 <nav class="metasync-sidenav">
1698 1832
1833 + <?php if ($total_items === 0): ?>
1834 + <div class="metasync-sidenav-empty">
1835 + <?php esc_html_e('No pages are available.', 'metasync'); ?>
1836 + </div>
1837 + <?php else: ?>
1838 +
1699 1839 <!-- SEO Features group -->
1700 1840 <div class="metasync-sidenav-group">
1701 1841 <div class="metasync-sidenav-group-title">SEO Features</div>
1702 1842 <ul>
@@ -1702,12 +1842,12 @@
1702 1842 <ul>
1703 1843 <?php foreach ($seo_items as $key => $item):
1704 1844 $is_active = ($current_page === $key);
1705 1845 $icon = $icons[$key] ?? 'admin-generic';
1706 - $url = esc_url(admin_url('admin.php?page=' . $page_slug . $item['slug_suffix']));
1846 + $url = admin_url('admin.php?page=' . $page_slug . $item['slug_suffix']);
1707 1847 ?>
1708 1848 <li class="<?php echo $is_active ? 'metasync-sidenav-active' : ''; ?>">
1709 - <a href="<?php echo $url; ?>">
1849 + <a href="<?php echo esc_url($url); ?>">
1710 1850 <span class="metasync-sidenav-icon"><span class="dashicons dashicons-<?php echo esc_attr($icon); ?>"></span></span>
1711 1851 <?php echo esc_html($item['title']); ?>
1712 1852 </a>
1713 1853 </li>
@@ -1722,12 +1862,12 @@
1722 1862 <?php foreach ($plugin_items as $key => $item):
1723 1863 if ($key === 'report_issue') continue;
1724 1864 $is_active = ($current_page === $key);
1725 1865 $icon = $icons[$key] ?? 'admin-generic';
1726 - $url = esc_url(admin_url('admin.php?page=' . $page_slug . $item['slug_suffix']));
1866 + $url = admin_url('admin.php?page=' . $page_slug . $item['slug_suffix']);
1727 1867 ?>
1728 1868 <li class="<?php echo $is_active ? 'metasync-sidenav-active' : ''; ?>">
1729 - <a href="<?php echo $url; ?>">
1869 + <a href="<?php echo esc_url($url); ?>">
1730 1870 <span class="metasync-sidenav-icon"><span class="dashicons dashicons-<?php echo esc_attr($icon); ?>"></span></span>
1731 1871 <?php echo esc_html($item['title']); ?>
1732 1872 </a>
1733 1873 </li>
@@ -1762,8 +1902,10 @@
1762 1902 </ul>
1763 1903 </div>
1764 1904 <?php endif; ?>
1765 1905
1906 + <?php endif; ?>
1907 +
1766 1908 </nav>
1767 1909 <?php
1768 1910 }
1769 1911
@@ -1769,17 +1911,22 @@
1769 1911
1770 1912 /**
1771 1913 * Render the right promotional sidebar.
1772 1914 *
1773 - * @param bool $is_connected Whether the plugin is authenticated.
1915 + * Only reached while the site is unconnected; the caller drops the
1916 + * whole column once the plugin is linked.
1774 1917 */
1775 - public function render_promo_sidebar($is_connected = false)
1918 + public function render_promo_sidebar()
1776 1919 {
1777 1920 $plugin_name = Metasync::get_effective_plugin_name();
1778 1921 $otto_name = Metasync::get_whitelabel_otto_name();
1779 - $settings_url = esc_url(admin_url('admin.php?page=' . Metasync_Admin::$page_slug));
1922 + $settings_url = admin_url('admin.php?page=' . Metasync_Admin::$page_slug);
1780 1923 $homepage = Metasync::HOMEPAGE_DOMAIN;
1781 - $is_default_brand = ( $plugin_name === 'Search Atlas' );
1924 + $docs_url = Metasync::DOCUMENTATION_DOMAIN;
1925 + // These links point at Search Atlas properties, so they only belong
1926 + // here while the plugin runs under its own name AND branding — a
1927 + // reseller can leave the name untouched and still be whitelabeled.
1928 + $is_default_brand = ( $plugin_name === 'Search Atlas' ) && ! Metasync::is_whitelabel_enabled();
1782 1929 $whitelabel = Metasync::get_whitelabel_settings();
1783 1930 $custom_links = isset($whitelabel['quick_links']) && is_array($whitelabel['quick_links'])
1784 1931 ? array_filter($whitelabel['quick_links'], function($l) { return !empty($l['url']); })
1785 1932 : [];
@@ -1784,9 +1931,8 @@
1784 1931 ? array_filter($whitelabel['quick_links'], function($l) { return !empty($l['url']); })
1785 1932 : [];
1786 1933 ?>
1787 1934
1788 - <?php if (!$is_connected): ?>
1789 1935 <!-- Connect CTA card -->
1790 1936 <div class="metasync-promo-card metasync-promo-card--connect">
1791 1937 <div class="metasync-promo-card-header">
1792 1938 <div class="metasync-promo-card-icon">
@@ -1802,40 +1948,12 @@
1802 1948 <li><span class="promo-check">&#10003;</span> Real-time keyword tracking</li>
1803 1949 <li><span class="promo-check">&#10003;</span> Automated schema markup</li>
1804 1950 <li><span class="promo-check">&#10003;</span> Instant Google indexing</li>
1805 1951 </ul>
1806 - <a href="<?php echo $settings_url; ?>" class="metasync-promo-btn metasync-promo-btn--primary">
1952 + <a href="<?php echo esc_url($settings_url); ?>" class="metasync-promo-btn metasync-promo-btn--primary">
1807 1953 Connect Now
1808 1954 </a>
1809 1955 </div>
1810 - <?php else: ?>
1811 - <!-- Connected — feature highlights -->
1812 - <div class="metasync-promo-card metasync-promo-card--accent">
1813 - <div class="metasync-promo-card-header">
1814 - <div class="metasync-promo-card-icon">
1815 - <span class="dashicons dashicons-performance"></span>
1816 - </div>
1817 - <div>
1818 - <h3><?php echo esc_html($otto_name); ?> Active</h3>
1819 - </div>
1820 - </div>
1821 - <p class="metasync-promo-tagline">Your site is connected and <?php echo esc_html($otto_name); ?> is optimizing pages automatically.</p>
1822 - <ul class="metasync-promo-benefits">
1823 - <li><span class="promo-check">&#10003;</span> Schema markup auto-applied</li>
1824 - <li><span class="promo-check">&#10003;</span> Meta titles &amp; descriptions optimized</li>
1825 - <li><span class="promo-check">&#10003;</span> Internal linking suggestions active</li>
1826 - </ul>
1827 - <?php if ($is_default_brand): ?>
1828 - <a href="<?php echo esc_url($homepage); ?>" target="_blank" rel="noopener" class="metasync-promo-btn metasync-promo-btn--outline">
1829 - View <?php echo esc_html($plugin_name); ?> Dashboard
1830 - </a>
1831 - <?php elseif (!empty($whitelabel['domain'])): ?>
1832 - <a href="<?php echo esc_url($whitelabel['domain']); ?>" target="_blank" rel="noopener" class="metasync-promo-btn metasync-promo-btn--outline">
1833 - View <?php echo esc_html($plugin_name); ?> Dashboard
1834 - </a>
1835 - <?php endif; ?>
1836 - </div>
1837 - <?php endif; ?>
1838 1956
1839 1957 <?php
1840 1958 // Quick Links: show default links for default brand, custom links if whitelabeled + provided, hide if whitelabeled + none
1841 1959 $show_quick_links = $is_default_brand || !empty($custom_links);
@@ -1845,12 +1963,20 @@
1845 1963 <div class="metasync-promo-card">
1846 1964 <h3 style="margin:0 0 12px;font-size:13px;font-weight:700;color:var(--dashboard-text-primary);">Quick Links</h3>
1847 1965 <ul class="metasync-promo-links">
1848 1966 <?php if ($is_default_brand): ?>
1967 + <li><a href="<?php echo esc_url($docs_url); ?>" target="_blank" rel="noopener"><span class="dashicons dashicons-book-alt"></span> Documentation</a></li>
1849 1968 <li><a href="<?php echo esc_url($homepage . '/blog/'); ?>" target="_blank" rel="noopener"><span class="dashicons dashicons-rss"></span> SEO Blog</a></li>
1850 - <li><a href="<?php echo esc_url($homepage . '/academy/'); ?>" target="_blank" rel="noopener"><span class="dashicons dashicons-welcome-learn-more"></span> SEO Academy</a></li>
1969 + <li><a href="<?php echo esc_url($homepage . '/contact/'); ?>" target="_blank" rel="noopener"><span class="dashicons dashicons-email-alt"></span> Contact Support</a></li>
1851 1970 <li><a href="<?php echo esc_url(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-setup-wizard')); ?>"><span class="dashicons dashicons-admin-customizer"></span> Setup Wizard</a></li>
1971 + <?php
1972 + // Mirrors the gate that decides whether the page is registered
1973 + // at all. Without it the link outlives the page for any user
1974 + // the access rules hide it from, and leads nowhere.
1975 + if (Metasync_Access_Control::user_can_access('hide_report_issue')):
1976 + ?>
1852 1977 <li><a href="<?php echo esc_url(admin_url('admin.php?page=' . Metasync_Admin::$page_slug . '-report-issue')); ?>"><span class="dashicons dashicons-sos"></span> Report Issue</a></li>
1978 + <?php endif; ?>
1853 1979 <?php else: ?>
1854 1980 <?php foreach ($custom_links as $link): ?>
1855 1981 <li><a href="<?php echo esc_url($link['url']); ?>" <?php echo !empty($link['external']) ? 'target="_blank" rel="noopener"' : ''; ?>><span class="dashicons dashicons-admin-links"></span> <?php echo esc_html($link['label'] ?: $link['url']); ?></a></li>
1856 1982 <?php endforeach; ?>