PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / trunk
Search Atlas SEO – OTTO AI SEO Automation for WordPress vtrunk
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 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / includes / class-metasync-admin-navigation.php

class-metasync-admin-navigation.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress trunk, at includes/class-metasync-admin-navigation.php

2,011 lines 102.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if (!defined('ABSPATH')) {
3 exit;
4 }
5
6 /**
7 * Admin navigation header, nav tabs, and admin-bar status indicator.
8 *
9 * Extracted from Metasync_Admin to keep the admin class focused on
10 * non-UI concerns. All header/nav rendering and admin-bar badge
11 * logic lives here.
12 *
13 * @package Metasync
14 * @subpackage Metasync/includes
15 */
16 class Metasync_Admin_Navigation
17 {
18 /** @var self|null */
19 private static $instance = null;
20
21 const CACHE_KEY = 'metasync_admin_bar_status';
22
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 /**
123 * Get singleton instance.
124 *
125 * @return self
126 */
127 public static function instance()
128 {
129 if (self::$instance === null) {
130 self::$instance = new self();
131 }
132 return self::$instance;
133 }
134
135 private function __construct() {}
136
137 /**
138 * Whether the Dashboard is hidden by the "Hide Dashboard" general setting
139 * (hide_dashboard_framework).
140 *
141 * When enabled this removes the Dashboard from the WordPress admin submenu
142 * and from the in-plugin SEO feature navigation, matching the setting's
143 * description "Hide the main dashboard from the WordPress admin menu".
144 *
145 * @return bool
146 */
147 public static function is_dashboard_hidden_by_framework()
148 {
149 $general_options = Metasync::get_option('general') ?? [];
150 return filter_var($general_options['hide_dashboard_framework'] ?? false, FILTER_VALIDATE_BOOLEAN);
151 }
152
153 // ------------------------------------------------------------------
154 // Logo resolution helper
155 // ------------------------------------------------------------------
156
157 /**
158 * Resolve which logo(s) to display based on whitelabel settings.
159 *
160 * @return array{show_logo: bool, use_dual: bool, url: string, light_url: string, dark_url: string, is_default: bool}
161 */
162 private function resolve_logo_data()
163 {
164 $wl_settings = Metasync::get_whitelabel_settings();
165 $is_whitelabel = !empty($wl_settings['is_whitelabel']);
166
167 $light_raw = Metasync::get_whitelabel_logo_light();
168 $dark_raw = Metasync::get_whitelabel_logo_dark();
169 $has_light = !empty($light_raw) && filter_var($light_raw, FILTER_VALIDATE_URL);
170 $has_dark = !empty($dark_raw) && filter_var($dark_raw, FILTER_VALIDATE_URL);
171 $use_dual = ($has_light && $has_dark && $light_raw !== $dark_raw);
172
173 $data = [
174 'show_logo' => false,
175 'use_dual' => false,
176 'url' => '',
177 'light_url' => '',
178 'dark_url' => '',
179 'is_default' => false,
180 ];
181
182 if ($use_dual) {
183 $data['show_logo'] = true;
184 $data['use_dual'] = true;
185 $data['light_url'] = $light_raw;
186 $data['dark_url'] = $dark_raw;
187 } else {
188 $single = $has_light ? $light_raw : ($has_dark ? $dark_raw : null);
189 if ($single) {
190 $data['show_logo'] = true;
191 $data['url'] = $single;
192 } elseif (!$is_whitelabel) {
193 $data['show_logo'] = true;
194 $data['url'] = plugin_dir_url(dirname(__FILE__)) . 'assets/images/searchatlas-logo.svg';
195 $data['is_default'] = true;
196 }
197 }
198
199 return $data;
200 }
201
202 // ------------------------------------------------------------------
203 // Header + Nav rendering
204 // ------------------------------------------------------------------
205
206 /**
207 * Render the standard header followed by the navigation tabs.
208 */
209 public function render_standard_header_nav($page_title = null, $current_page = null)
210 {
211 $this->render_static_header($page_title);
212 $this->render_static_navigation($current_page);
213 }
214
215 /**
216 * Render the page header (logo, connection badge, theme toggle).
217 */
218 public function render_static_header($page_title = null)
219 {
220 $effective_plugin_name = Metasync::get_effective_plugin_name();
221 $display_title = $page_title ?: $effective_plugin_name;
222 $logo = $this->resolve_logo_data();
223
224 $current_theme = get_option('metasync_theme', 'dark');
225 $general_settings = Metasync::get_option('general');
226 // derive the badge from confirmed heartbeat health, not from
227 // mere API-key presence (which stays set after Search Atlas revokes it).
228 $badge = Metasync_Heartbeat_Manager::instance()->get_connection_badge($general_settings);
229 ?>
230 <div class="metasync-header" data-current-theme="<?php echo esc_attr($current_theme); ?>">
231 <div class="metasync-header-left">
232 <?php if ($logo['show_logo'] && $logo['use_dual']): ?>
233 <div class="metasync-logo-container">
234 <img src="<?php echo esc_url($logo['light_url']); ?>" alt="Logo" class="metasync-logo metasync-logo-light" />
235 <img src="<?php echo esc_url($logo['dark_url']); ?>" alt="Logo" class="metasync-logo metasync-logo-dark" />
236 </div>
237 <?php elseif ($logo['show_logo'] && !empty($logo['url'])): ?>
238 <div class="metasync-logo-container">
239 <img src="<?php echo esc_url($logo['url']); ?>" alt="Logo" class="metasync-logo<?php echo !empty($logo['is_default']) ? ' metasync-logo-default' : ''; ?>" />
240 </div>
241 <?php endif; ?>
242 </div>
243 <div class="metasync-header-right">
244 <div class="metasync-status <?php echo esc_attr($badge['class']); ?>">
245 <span class="status-dot"></span>
246 <span class="status-text"><?php echo esc_html($badge['text']); ?></span>
247 </div>
248 <button type="button" class="metasync-theme-toggle" onclick="toggleMetasyncTheme()" title="Toggle theme">
249 <span class="theme-icon-light">&#9728;</span>
250 <span class="theme-icon-dark">&#9790;</span>
251 </button>
252 </div>
253 </div>
254 <?php
255 }
256
257 /**
258 * Render the horizontal navigation tabs.
259 */
260 public function render_static_navigation($current_page = null)
261 {
262 $general_options = Metasync::get_option('general') ?? [];
263 $whitelabel_settings = Metasync::get_whitelabel_settings();
264 $page_slug = Metasync_Admin::$page_slug;
265
266 $menu_items = [];
267 $menu_icons = [
268 'dashboard' => 'dashboard',
269 'seo_controls' => 'search',
270 'instant_index' => 'performance',
271 'google_console' => 'chart-area',
272 'compatibility' => 'admin-tools',
273 'sync_log' => 'list-view',
274 'redirections' => 'undo',
275 'robots_txt' => 'shield',
276 'xml_sitemap' => 'networking',
277 'custom_pages' => 'admin-page',
278 'report_issue' => 'sos',
279 'general' => 'admin-settings',
280 ];
281
282 if (empty($whitelabel_settings['hide_dashboard']) && !self::is_dashboard_hidden_by_framework()) {
283 $menu_items['dashboard'] = ['title' => 'Dashboard', 'slug_suffix' => '-dashboard'];
284 }
285
286 if (empty($whitelabel_settings['hide_indexation_control'])) {
287 $menu_items['seo_controls'] = ['title' => 'Indexation Control', 'slug_suffix' => '-seo-controls'];
288 }
289
290 if ($general_options['enable_googleinstantindex'] ?? false) {
291 $menu_items['instant_index'] = ['title' => 'Instant Indexing', 'slug_suffix' => '-instant-index'];
292 }
293
294 if ($general_options['enable_google_console'] ?? false) {
295 $menu_items['google_console'] = ['title' => 'Google Console', 'slug_suffix' => '-google-console'];
296 }
297
298 if (empty($whitelabel_settings['hide_compatibility'])) {
299 $menu_items['compatibility'] = ['title' => 'Compatibility', 'slug_suffix' => '-compatibility'];
300 }
301
302 if (empty($whitelabel_settings['hide_sync_log'])) {
303 $menu_items['sync_log'] = ['title' => 'Changes Log', 'slug_suffix' => '-sync-log'];
304 }
305
306 if (empty($whitelabel_settings['hide_redirections'])) {
307 $menu_items['redirections'] = ['title' => 'Redirections', 'slug_suffix' => '-redirections'];
308 }
309
310 if (empty($whitelabel_settings['hide_robots'])) {
311 $menu_items['robots_txt'] = ['title' => 'Robots.txt', 'slug_suffix' => '-robots-txt'];
312 }
313
314 $menu_items['xml_sitemap'] = ['title' => 'XML Sitemap', 'slug_suffix' => '-xml-sitemap'];
315 ?>
316 <div class="metasync-nav-wrapper">
317 <div class="metasync-nav-tabs">
318 <div class="metasync-nav-left">
319 <?php foreach ($menu_items as $key => $menu_item):
320 $is_active = ($current_page === $key);
321 $icon = $menu_icons[$key];
322 $page_url = '?page=' . $page_slug . $menu_item['slug_suffix'];
323 ?>
324 <a href="<?php echo esc_url($page_url); ?>" class="metasync-nav-tab <?php echo $is_active ? 'active' : ''; ?>">
325 <span class="tab-icon"><span class="dashicons dashicons-<?php echo esc_attr($icon); ?>"></span></span>
326 <span class="tab-text"><?php echo esc_html($menu_item['title']); ?></span>
327 </a>
328 <?php endforeach; ?>
329 </div>
330 <div class="metasync-nav-right">
331 <a href="?page=<?php echo esc_attr( $page_slug ); ?>-custom-pages" class="metasync-nav-tab <?php echo $current_page === 'custom_pages' ? 'active' : ''; ?>" style="margin-right: 10px;">
332 <span class="tab-icon"><span class="dashicons dashicons-admin-page"></span></span>
333 <span class="tab-text">Custom Pages</span>
334 </a>
335 <a href="?page=<?php echo esc_attr( $page_slug ); ?>-report-issue" class="metasync-nav-tab <?php echo $current_page === 'report_issue' ? 'active' : ''; ?>">
336 <span class="tab-icon"><span class="dashicons dashicons-sos"></span></span>
337 <span class="tab-text">Report Issue</span>
338 </a>
339 <div class="metasync-simple-dropdown">
340 <button type="button" class="metasync-seo-btn" id="metasync-seo-btn" onclick="toggleSeoMenuPortal(event)" aria-expanded="false">
341 <span class="tab-icon"><span class="dashicons dashicons-search"></span></span>
342 <span class="tab-text">SEO</span>
343 <span class="dropdown-arrow"></span>
344 </button>
345 </div>
346 <div class="metasync-simple-dropdown">
347 <button type="button" class="metasync-settings-btn" id="metasync-settings-btn" onclick="toggleSettingsMenuPortal(event)" aria-expanded="false">
348 <span class="tab-icon"><span class="dashicons dashicons-admin-settings"></span></span>
349 <span class="tab-text">Settings</span>
350 <span class="dropdown-arrow"></span>
351 </button>
352 </div>
353 </div>
354 </div>
355 </div>
356
357 <script>
358 function toggleSeoMenuPortal(event) {
359 event.preventDefault();
360 event.stopPropagation();
361 var button = event.currentTarget;
362 var existingMenu = document.getElementById('metasync-seo-portal-menu');
363 if (existingMenu) {
364 existingMenu.remove();
365 button.classList.remove('active');
366 button.setAttribute('aria-expanded', 'false');
367 return;
368 }
369 var menu = document.createElement('div');
370 menu.id = 'metasync-seo-portal-menu';
371 menu.className = 'metasync-portal-menu';
372
373 var pageSlug = '<?php echo esc_js( $page_slug ); ?>';
374 var seoLinks = [
375 { href: '?page=' + pageSlug + '-seo-controls', text: 'Indexation Control' },
376 { href: '?page=' + pageSlug + '-xml-sitemap', text: 'XML Sitemap' },
377 { href: '?page=' + pageSlug + '-robots-txt', text: 'Robots.txt' },
378 { href: '?page=' + pageSlug + '-redirections', text: 'Redirections' },
379 ];
380 seoLinks.forEach(function(item) {
381 var link = document.createElement('a');
382 link.href = item.href;
383 link.className = 'metasync-portal-item';
384 link.textContent = item.text;
385 menu.appendChild(link);
386 });
387
388 var rect = button.getBoundingClientRect();
389 menu.style.position = 'fixed';
390 menu.style.top = (rect.bottom + 8) + 'px';
391 menu.style.right = (window.innerWidth - rect.right) + 'px';
392 menu.style.zIndex = '999999999';
393 document.body.appendChild(menu);
394 button.classList.add('active');
395 button.setAttribute('aria-expanded', 'true');
396 }
397
398 function toggleSettingsMenuPortal(event) {
399 event.preventDefault();
400 event.stopPropagation();
401 var button = event.currentTarget;
402 var existingMenu = document.getElementById('metasync-portal-menu');
403 if (existingMenu) {
404 existingMenu.remove();
405 button.classList.remove('active');
406 button.setAttribute('aria-expanded', 'false');
407 return;
408 }
409 var menu = document.createElement('div');
410 menu.id = 'metasync-portal-menu';
411 menu.className = 'metasync-portal-menu';
412
413 var hideAdvanced = <?php echo !empty($whitelabel_settings['hide_advanced']) ? 'true' : 'false'; ?>;
414 var showGeneral = <?php echo Metasync_Access_Control::user_can_access('hide_settings') ? 'true' : 'false'; ?>;
415
416 if (showGeneral) {
417 var generalLink = document.createElement('a');
418 generalLink.href = '?page=<?php echo esc_js( $page_slug ); ?>&tab=general';
419 generalLink.className = 'metasync-portal-item';
420 generalLink.textContent = 'General';
421 menu.appendChild(generalLink);
422 }
423
424 var whitelabelLink = document.createElement('a');
425 whitelabelLink.href = '?page=<?php echo esc_js( $page_slug ); ?>&tab=whitelabel';
426 whitelabelLink.className = 'metasync-portal-item';
427 whitelabelLink.textContent = 'White Label';
428 menu.appendChild(whitelabelLink);
429
430 if (!hideAdvanced) {
431 var advancedLink = document.createElement('a');
432 advancedLink.href = '?page=<?php echo esc_js( $page_slug ); ?>&tab=advanced';
433 advancedLink.className = 'metasync-portal-item';
434 advancedLink.textContent = 'Advanced';
435 menu.appendChild(advancedLink);
436 }
437
438
439 var rect = button.getBoundingClientRect();
440 menu.style.position = 'fixed';
441 menu.style.top = (rect.bottom + 8) + 'px';
442 menu.style.right = (window.innerWidth - rect.right) + 'px';
443 menu.style.zIndex = '999999999';
444 document.body.appendChild(menu);
445 button.classList.add('active');
446 button.setAttribute('aria-expanded', 'true');
447 }
448 document.addEventListener('click', function(event) {
449 var seoButton = document.getElementById('metasync-seo-btn');
450 var seoMenu = document.getElementById('metasync-seo-portal-menu');
451 if (seoMenu && seoButton && !seoButton.contains(event.target) && !seoMenu.contains(event.target)) {
452 seoMenu.remove();
453 seoButton.classList.remove('active');
454 seoButton.setAttribute('aria-expanded', 'false');
455 }
456
457 var button = document.getElementById('metasync-settings-btn');
458 var menu = document.getElementById('metasync-portal-menu');
459 if (menu && button && !button.contains(event.target) && !menu.contains(event.target)) {
460 menu.remove();
461 button.classList.remove('active');
462 button.setAttribute('aria-expanded', 'false');
463 }
464 });
465 </script>
466 <?php
467 }
468
469 // ------------------------------------------------------------------
470 // Menu registration + enhanced navigation (moved from Metasync_Admin)
471 // ------------------------------------------------------------------
472
473 /**
474 * Helper method to get available menu items based on configuration.
475 * Items are grouped into 'seo' (SEO Features) and 'plugin' (Plugin) categories.
476 */
477 public function get_available_menu_items()
478 {
479 $general_options = Metasync::get_option('general') ?? [];
480 $has_api_key = !empty($general_options['searchatlas_api_key'] ?? '');
481 $has_uuid = !empty($general_options['otto_pixel_uuid'] ?? '');
482 $is_fully_connected = Metasync_Heartbeat_Manager::instance()->is_heartbeat_connected($general_options);
483
484 $menu_items = [];
485
486 // === SEO FEATURES GROUP ===
487
488 // Dashboard (check access control + "Hide Dashboard" general setting)
489 if (Metasync_Access_Control::user_can_access('hide_dashboard') && !self::is_dashboard_hidden_by_framework()) {
490 $menu_items['dashboard'] = [
491 'title' => 'Dashboard',
492 'slug_suffix' => '-dashboard',
493 'callback' => 'create_admin_dashboard_iframe',
494 'internal_nav' => 'Dashboard',
495 'group' => 'seo'
496 ];
497 }
498
499 // Indexation Control (check access control)
500 if (Metasync_Access_Control::user_can_access('hide_indexation_control')) {
501 $menu_items['seo_controls'] = [
502 'title' => 'Indexation',
503 'slug_suffix' => '-seo-controls',
504 'callback' => 'create_admin_seo_controls_page',
505 'internal_nav' => 'Indexation Control',
506 'group' => 'seo'
507 ];
508 }
509
510 // Instant Indexing - setting now stored in seo_controls (moved from Settings to Indexation Control)
511 $seo_controls = Metasync::get_option('seo_controls');
512 if ($seo_controls['enable_googleinstantindex'] ?? false) {
513 $menu_items['instant_index'] = [
514 'title' => 'Instant Indexing',
515 'slug_suffix' => '-instant-index',
516 'callback' => 'create_admin_google_instant_index_page',
517 'internal_nav' => 'Instant Indexing',
518 'group' => 'seo'
519 ];
520 }
521
522 if ($general_options['enable_google_console'] ?? false) {
523 $menu_items['google_console'] = [
524 'title' => 'Google Console',
525 'slug_suffix' => '-google-console',
526 'callback' => 'create_admin_google_console_page',
527 'internal_nav' => 'Google Console',
528 'group' => 'seo'
529 ];
530 }
531
532 if ($general_options['enable_bing_console'] ?? false) {
533 $menu_items['bing_console'] = [
534 'title' => 'Bing Console',
535 'slug_suffix' => '-bing-console',
536 'callback' => 'create_admin_bing_console_page',
537 'internal_nav' => 'Bing Console',
538 'group' => 'seo'
539 ];
540 }
541
542 // Redirections page (check access control)
543 if (Metasync_Access_Control::user_can_access('hide_redirections')) {
544 $menu_items['redirections'] = [
545 'title' => 'Redirections',
546 'slug_suffix' => '-redirections',
547 'callback' => 'create_admin_redirections_page',
548 'internal_nav' => 'Redirections',
549 'group' => 'seo'
550 ];
551 }
552
553 // Robots.txt page (check access control)
554 if (Metasync_Access_Control::user_can_access('hide_robots')) {
555 $menu_items['robots_txt'] = [
556 'title' => 'Robots.txt',
557 'slug_suffix' => '-robots-txt',
558 'callback' => 'create_admin_robots_txt_page',
559 'internal_nav' => 'Robots.txt',
560 'group' => 'seo'
561 ];
562 }
563
564 // XML Sitemap page (check access control)
565 if (Metasync_Access_Control::user_can_access('hide_xml_sitemap')) {
566 $menu_items['xml_sitemap'] = [
567 'title' => 'XML Sitemap',
568 'slug_suffix' => '-xml-sitemap',
569 'callback' => 'create_admin_xml_sitemap_page',
570 'internal_nav' => 'XML Sitemap',
571 'group' => 'seo'
572 ];
573 }
574
575 // Schema Markup
576 $menu_items['schema_markup'] = [
577 'title' => 'Schema Markup',
578 'slug_suffix' => '-schema-markup',
579 'callback' => 'create_admin_schema_markup_page',
580 'internal_nav' => 'Schema Markup',
581 'group' => 'seo'
582 ];
583
584 // 404 Monitor (always available — SEO monitoring tool)
585 $menu_items['monitor_404'] = [
586 'title' => '404 Monitor',
587 'slug_suffix' => '-404-monitor',
588 'callback' => 'create_admin_404_monitor_page',
589 'internal_nav' => '404 Monitor',
590 'group' => 'seo'
591 ];
592
593 // Site Verification
594 $menu_items['site_verification'] = [
595 'title' => 'Site Verification',
596 'slug_suffix' => '-search-engine-verify',
597 'callback' => 'create_admin_search_engine_verification_page',
598 'internal_nav' => 'Site Verification',
599 'group' => 'seo'
600 ];
601
602 // SEO Health dashboard (always available)
603 $menu_items['seo_health'] = [
604 'title' => 'SEO Health',
605 'slug_suffix' => '-seo-health',
606 'callback' => 'create_admin_seo_health_page',
607 'internal_nav' => 'SEO Health',
608 'group' => 'seo'
609 ];
610
611 // Import SEO Data page (check access control)
612 if (Metasync_Access_Control::user_can_access('hide_import_seo')) {
613 $menu_items['import_seo'] = [
614 'title' => 'Import SEO Data',
615 'slug_suffix' => '-import-external',
616 'callback' => 'render_import_external_data_page',
617 'internal_nav' => 'Import SEO Data',
618 'group' => 'seo'
619 ];
620 }
621
622 // === PLUGIN GROUP ===
623
624 // Settings (check access control)
625 if (Metasync_Access_Control::user_can_access('hide_settings')) {
626 $menu_items['general'] = [
627 'title' => 'Settings',
628 'slug_suffix' => '',
629 'callback' => 'create_admin_settings_page',
630 'internal_nav' => 'General Settings',
631 'group' => 'plugin'
632 ];
633 }
634
635 // Local Business
636 $menu_items['local_business'] = [
637 'title' => 'Local Business',
638 'slug_suffix' => '-local-business',
639 'callback' => 'create_admin_local_business_page',
640 'internal_nav' => 'Local Business',
641 'group' => 'seo'
642 ];
643
644 // Breadcrumbs
645 $menu_items['breadcrumbs'] = [
646 'title' => 'Breadcrumbs',
647 'slug_suffix' => '-breadcrumbs',
648 'callback' => 'create_admin_breadcrumbs_page',
649 'internal_nav' => 'Breadcrumbs',
650 'group' => 'seo'
651 ];
652
653 // Code Snippets
654 $menu_items['code_snippets'] = [
655 'title' => 'Code Snippets',
656 'slug_suffix' => '-code-snippets',
657 'callback' => 'create_admin_code_snippets_page',
658 'internal_nav' => 'Code Snippets',
659 'group' => 'plugin'
660 ];
661
662 // Code Minification
663 $menu_items['code_minification'] = [
664 'title' => 'Code Minification',
665 'slug_suffix' => '-code-minification',
666 'callback' => 'create_admin_code_minification_page',
667 'internal_nav' => 'Code Minification',
668 'group' => 'plugin'
669 ];
670
671 // Media Optimization
672 $menu_items['media_optimization'] = [
673 'title' => 'Media Optimization',
674 'slug_suffix' => '-media-optimization',
675 'callback' => 'create_admin_media_optimization_page',
676 'internal_nav' => 'Media Optimization',
677 'group' => 'plugin'
678 ];
679
680 // Custom HTML Pages (check access control)
681 if (Metasync_Access_Control::user_can_access('hide_custom_pages')) {
682 $menu_items['custom_pages'] = [
683 'title' => 'Custom Pages',
684 'slug_suffix' => '-custom-pages',
685 'callback' => 'create_admin_custom_pages_page',
686 'internal_nav' => 'Custom HTML Pages',
687 'group' => 'plugin'
688 ];
689 }
690
691 // Compatibility page (check access control)
692 if (Metasync_Access_Control::user_can_access('hide_compatibility')) {
693 $menu_items['compatibility'] = [
694 'title' => 'Compatibility',
695 'slug_suffix' => '-compatibility',
696 'callback' => 'create_admin_compatibility_page',
697 'internal_nav' => 'Compatibility',
698 'group' => 'plugin'
699 ];
700 }
701
702 // Sync Log page (check access control)
703 if (Metasync_Access_Control::user_can_access('hide_sync_log')) {
704 $menu_items['sync_log'] = [
705 'title' => 'Changes Log',
706 'slug_suffix' => '-sync-log',
707 'callback' => 'create_admin_sync_log_page',
708 'internal_nav' => 'Changes Log',
709 'group' => 'plugin'
710 ];
711 }
712
713 // Bot Statistics
714 $menu_items['bot_statistics'] = [
715 'title' => 'Bot Statistics',
716 'slug_suffix' => '-bot-statistics',
717 'callback' => 'create_admin_bot_statistics_page',
718 'internal_nav' => 'Bot Statistics',
719 'group' => 'plugin'
720 ];
721
722 // Report Issue page (check access control)
723 if (Metasync_Access_Control::user_can_access('hide_report_issue')) {
724 $menu_items['report_issue'] = [
725 'title' => 'Report Issue',
726 'slug_suffix' => '-report-issue',
727 'callback' => 'create_admin_report_issue_page',
728 'internal_nav' => 'Report Issue',
729 'group' => 'plugin'
730 ];
731 }
732
733 return $menu_items;
734 }
735
736 /**
737 * Register all WordPress admin menu / submenu pages.
738 *
739 * @param Metasync_Admin $admin The admin instance whose callbacks WordPress will invoke.
740 */
741 public function add_plugin_settings_page($admin)
742 {
743 if (!Metasync::current_user_has_plugin_access()) {
744 return;
745 }
746
747 $data = Metasync::get_option('general');
748 $plugin_name = Metasync::get_effective_plugin_name();
749 $menu_name = $plugin_name;
750 $menu_title = $plugin_name;
751 // Sanitize so a URL-shaped legacy value still yields a valid WP menu slug
752 $menu_slug = !isset($data['white_label_plugin_menu_slug']) || $data['white_label_plugin_menu_slug'] == "" ? Metasync_Admin::$page_slug : sanitize_title($data['white_label_plugin_menu_slug']);
753 $menu_slug = $menu_slug === '' ? Metasync_Admin::$page_slug : $menu_slug;
754 $menu_icon = !isset($data['white_label_plugin_menu_icon']) || $data['white_label_plugin_menu_icon'] == "" ? 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMzIiIGhlaWdodD0iMzQiIHZpZXdCb3g9IjAgMCAzMiAzNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzE4NzlfMTcyNzcpIj4KPHBhdGggZD0iTTI5LjAyMTUgMi4xNjc2NkwzMC4wMTAxIDIuMTIyNDFMMjkuMTYyNyA4LjcyNzA1TDI5LjExNTcgOC44MTc0M0w1LjI5NTA0IDMzLjI0NTJMNC43MzAxMiAzMy4yOTA1TDQuMDcxMDQgMzMuOTY5MUgxLjk1MjYyTDIuMjM1MDggMzIuMjk1M0wyLjA5Mzg0IDMyLjM0MDZMMi4xODggMzEuNjYySDEuNjIzMDhDMS41NzYgMzEuNjYyIDEuNDgxODUgMzEuNjYyIDEuNDgxODUgMzEuNjE2N0MxLjQzNDc4IDMxLjU3MTYgMS4zODc3IDMxLjQ4MSAxLjM4NzcgMzEuNDM1OEwyLjUxNzU0IDI1LjEwMjdDMi41MTc1NCAyNS4wNTc1IDIuNTY0NiAyNS4wMTIyIDIuNTY0NiAyNS4wMTIyTDI2LjE5NjkgMC42Mjk2MDdDMjYuMjQ0IDAuNTg0MzY2IDI2LjI5MTEgMC41MzkxMjUgMjYuMzM4MSAwLjUzOTEyNUgyOC4zNjI1QzI4LjQwOTUgMC40OTM4ODQgMjguNDU2NiAwLjUzOTEyNSAyOC41MDM3IDAuNTg0MzY2QzI4LjU1MDcgMC42Mjk2MDcgMjguNTk3OSAwLjcyMDA3MSAyOC41OTc5IDAuNzY1MzExTDI4LjUwMzcgMS4zNTMzOUgyOS4xMTU3TDI5LjAyMTUgMi4xNjc2NlpNMS45MDU1NCAzMS4yMDk2SDIuMjgyMTZMMy4yMjM2OCAyNS43ODEySDMuMjcwNzZMNi44MDE1IDIyLjI1MjhMNi44NDg1MSAyMi4yOTgxTDIwLjIxODMgOC40NTU1N0wyNi45MDMxIDEuMzUzMzlIMjguMDMyOUwyOC4wNzk5IDAuOTkxNDk5SDI2LjQ3OTNMMi45ODgzIDI1LjIzODRMMS45MDU1NCAzMS4yMDk2Wk0yOC42OTE5IDguNTQ1OTVMMjkuNDQ1MiAyLjYyMDAxSDI4Ljk3NDVMMjguMzE1MyA3Ljc3Njk2TDI4LjI2ODMgNy44MjIyNEwyOC4xMjcxIDcuOTU3ODlMMjcuOTM4NyA5LjMxNTExTDI4LjY5MTkgOC41NDU5NVoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0zMS41NTQ0IDE4LjE5MzdDMzEuNzM0OSAxOC42NjYyIDMxLjgwMjggMTkuMjA2NCAzMS42ODk4IDE5Ljc2OUwyOS42NTgyIDMyLjEwMTNIMjkuMDkzOUwyOS4wMjYyIDMyLjQzODhIMjUuOTMzNkwyNi4wNjkxIDMxLjYwNjJIMjYuMDAxM0wyNi4wNjkxIDMxLjI2ODZIMjUuNzk4MUMyNS43NTMgMzEuMjY4NiAyNS43MzA1IDMxLjI2ODYgMjUuNzA3OSAzMS4yNDYxQzI1LjY4NTIgMzEuMjIzNyAyNS42ODUyIDMxLjE3ODYgMjUuNjg1MiAzMS4xNTZMMjYuMzYyNCAyNy4zOThIMTguNDE2N0wxNy40MjM0IDMyLjA3ODdIMTYuODM2NUwxNi43Njg3IDMyLjQxNjNIMTMuNjk4OEwxMy45MDE5IDMxLjU4MzZIMTMuODM0M0wxMy45MDE5IDMxLjI2ODZIMTMuNjMxQzEzLjYwODQgMzEuMjY4NiAxMy41NjMzIDMxLjI2ODYgMTMuNTQwOCAzMS4yNDYxQzEzLjU0MDggMzEuMjAxMSAxMy41MTgyIDMxLjE3ODYgMTMuNTQwOCAzMS4xMzM2TDE2LjM2MjQgMTguOTEzOEMxNi40OTc5IDE4LjM1MTIgMTYuNzQ2MiAxNy44MzM2IDE3LjEyOTkgMTcuMzM4NUMxNy40OTEyIDE2Ljg2NiAxNy45NDI2IDE2LjQ4MzUgMTguNDYxOCAxNi4yMTM0QzE4Ljk4MSAxNS45MjA3IDE5LjUwMDIgMTUuNzg1OCAyMC4wNDE5IDE1Ljc4NThIMjguNTk3MkMyOS4xMzkgMTUuNzg1OCAyOS42MTMxIDE1LjkyMDcgMzAuMDE5MyAxNi4yMTM0QzMwLjM1NzkgMTYuNDYwOSAzMC42Mjg5IDE2Ljc5ODUgMzAuODA5NSAxNy4xODFDMzEuMTI1NSAxNy40NTExIDMxLjM5NjMgMTcuNzg4NiAzMS41NTQ0IDE4LjE5MzdaTTI3LjQ5MTIgMjMuMzY5N0wyOC4wNTU1IDIwLjI2NDFDMjguMDMyOSAyMC4yNDE1IDI4LjAzMjkgMjAuMjE5MSAyOC4wMTA0IDIwLjIxOTFIMjcuODk3NUwyNy4zNzgzIDIzLjA5OTZDMjcuMzc4MyAyMy4xNDQ3IDI3LjMxMDYgMjMuMTg5NiAyNy4yNjU1IDIzLjE4OTZIMTkuMzQyMUwxOS4yOTcgMjMuMzY5N0gyNy40OTEyWk0xOS4wOTM5IDIzLjE4OTZIMTguODQ1NUwxOC44MjI5IDIzLjM2OTdIMTkuMDcxM0wxOS4wOTM5IDIzLjE4OTZaTTE5LjUwMDIgMjAuMjY0MUwxOC45MTMzIDIyLjk2NDZIMTkuMTYxNUwxOS43NDg0IDIwLjIxOTFIMTkuNTQ1M0MxOS41NDUzIDIwLjIxOTEgMTkuNTIyNyAyMC4yNDE1IDE5LjUwMDIgMjAuMjY0MVpNMjcuNjcxOCAyMC4yMTkxSDE5Ljk3NDJMMTkuMzg3MiAyMi45NjQ2SDI3LjE3NTFMMjcuNjcxOCAyMC4yMTkxWk0xMy43ODkgMzEuMDQzNkgxMy45NDdMMTUuMDk4NCAyNi4xMTUySDE1LjE2NkwxNi43MjM2IDE5LjI5NjRDMTYuODU5MSAxOC43NTYzIDE3LjEwNzMgMTguMjM4OCAxNy40Njg1IDE3Ljc2NjFDMTcuODI5NiAxNy4zMTYxIDE4LjI4MTIgMTYuOTMzNCAxOC43Nzc4IDE2LjY2MzNDMTkuMDI2MSAxNi41Mjg0IDE5LjI3NDUgMTYuNDM4NCAxOS41MjI3IDE2LjM0ODNMMTkuNTAwMiAxNi4zMDM0QzE5Ljc0ODQgMTYuMjEzNCAyMC4wMTkzIDE2LjE5MDggMjAuMjkwMyAxNi4xOTA4SDI4Ljg2ODJDMjkuMjI5NCAxNi4xOTA4IDI5LjU5MDUgMTYuMjU4MyAyOS44ODQgMTYuMzkzNEMyOS41MjI5IDE2LjEyMzMgMjkuMDkzOSAxNi4wMTA4IDI4LjU5NzIgMTYuMDEwOEgyMC4wNDE5QzE5LjU0NTMgMTYuMDEwOCAxOS4wNDg2IDE2LjE0NTkgMTguNTc0NyAxNi4zOTM0QzE4LjA3OCAxNi42NjMzIDE3LjY0OTEgMTcuMDIzNSAxNy4zMTA0IDE3LjQ5NkMxNi45NDkzIDE3Ljk0NjIgMTYuNzAxIDE4LjQ0MTIgMTYuNTg4MSAxOC45NTg5TDEzLjc4OSAzMS4wNDM2Wk0xNy4yMjAyIDMxLjg1MzdMMTguMTkxIDI3LjM5OEgxNy45NDI2TDE3LjAxNzEgMzEuNTgzNkgxNi45NDkzTDE2LjkwNDIgMzEuODUzN0gxNy4yMjAyWk0yNS45MzM2IDMxLjA0MzZIMjYuMTE0MkwyNi43Njg5IDI3LjM5OEgyNi41ODgzTDI1LjkzMzYgMzEuMDQzNlpNMzEuNDg2NyAxOS43NDY0QzMxLjU3NjkgMTkuMjA2NCAzMS41MDkzIDE4LjcxMTMgMzEuMzI4NyAxOC4yNjEyQzMxLjI4MzYgMTguMTQ4OCAzMS4yMTU4IDE4LjAzNjIgMzEuMTQ4MSAxNy45MjM2QzMxLjI4MzYgMTguMzUxMiAzMS4zMjg3IDE4LjgwMTQgMzEuMjM4MyAxOS4yOTY0TDMwLjA4NzIgMjYuMTE1MkwzMC4xNTQ4IDI2LjEzNzdMMjkuMjUxOSAzMS42MDYySDI5LjE4NDNMMjkuMTM5IDMxLjg3NjNIMjkuNDU1TDMxLjQ4NjcgMTkuNzQ2NFoiIGZpbGw9IndoaXRlIi8+CjxwYXRoIGQ9Ik0xNy42ODQ3IDIuNDI3MTdIMTcuNjYxOEMxNy44NjgyIDIuODk5MTIgMTcuOTE0IDMuNDM4NDkgMTcuODIyMiA0LjAwMDMzTDE3LjU5MjkgNS4zOTM3SDE3LjA0MjNMMTYuOTczNSA1LjczMDhIMTMuOTY4NEwxNC4xMjkxIDQuODk5MjhIMTQuMDM3M0wxNC4xMDYyIDQuNTg0NjRIMTMuODUzOEMxMy44MDggNC41ODQ2NCAxMy43ODUxIDQuNTYyMTcgMTMuNzYyIDQuNTM5NjlDMTMuNzM5MSA0LjUxNzIzIDEzLjczOTEgNC40OTQ3NSAxMy43MzkxIDQuNDQ5OEg1LjkxNjg2TDUuNTQ5ODcgNi4wOTAzOUgxMy41Nzg1QzE0LjEyOTEgNi4wOTAzOSAxNC42MzM3IDYuMjQ3NzEgMTUuMDQ2NyA2LjUzOTg3QzE1LjM2NzggNi43NDIxMyAxNS41OTcxIDcuMDU2NzcgMTUuODAzNiA3LjM3MTQxQzE1Ljg0OTUgNy40Mzg4OSAxNS44NzI0IDcuNDgzODIgMTUuOTE4NCA3LjU1MTEyQzE2LjIxNjYgNy43OTgzMiAxNi40Njg4IDguMTEyOTkgMTYuNjI5NSA4LjQ5NTE0QzE2LjgzNTkgOC45NjY5OCAxNi45MDQ4IDkuNTA2NDcgMTYuNzkwMSAxMC4wOTA3TDE2LjI4NTMgMTMuMTY5NkMxNi4xOTM1IDEzLjczMTUgMTUuOTQxMyAxNC4yNDg0IDE1LjU3NDMgMTQuNzQyOEMxNS4yMDcyIDE1LjIxNDcgMTQuNzQ4NCAxNS41OTY4IDE0LjIyMDggMTUuODg4OUMxNC4xNTIgMTUuOTExNSAxNC4wNjAyIDE1Ljk1NjQgMTMuOTkxNSAxNS45Nzg4QzEzLjg3NjcgMTYuMDY4OCAxMy43NjIgMTYuMTU4NyAxMy42MjQ1IDE2LjIyNkMxMy4wOTY4IDE2LjQ5NTcgMTIuNTY5MiAxNi42NTMxIDExLjk5NTcgMTYuNjUzMUgyLjYzNjYxQzIuMDg2MDcgMTYuNjUzMSAxLjU4MTQxIDE2LjQ5NTcgMS4xNjg1IDE2LjIyNkMwLjc1NTYxIDE1Ljk1NjQgMC40ODAzMzEgMTUuNTc0MyAwLjMxOTc1IDE1LjEyNDhDMC4xODIxMiAxNC43NjUyIDAuMTU5MTg3IDE0LjQwNTggMC4xODIxMTkgMTQuMDAxMkMwLjE4MjExOSAxMy45NTYxIDAuMTM2MjU0IDEzLjkzMzggMC4xMzYyNTQgMTMuODg4OEMtMC4wMjQzMjYxIDEzLjQxNjggLTAuMDQ3MjU4MSAxMi44Nzc1IDAuMDkwMzcyNSAxMi4zMTU2TDAuMzg4NTgzIDExLjAxMjJDMC4zODg1ODMgMTAuOTY3MyAwLjQ1NzM5OCAxMC45MjIzIDAuNTAzMjY0IDEwLjkyMjNIMy41NTQxN0MzLjU3NzExIDEwLjkyMjMgMy42MDAwNCAxMC45NDQ3IDMuNjIyOTkgMTAuOTY3M0MzLjY0NTkyIDEwLjk4OTYgMy42Njg4NyAxMS4wMzQ2IDMuNjQ1OTIgMTEuMDU3MUwzLjYwMDA0IDExLjMyNjlIMy44OTgyNUwzLjgwNjUgMTEuNzMxNEg0LjMxMTE2TDQuMjE5MzkgMTIuMjAzMkgxMi4yNzFDMTIuMjcxIDEyLjIwMzIgMTIuMjcxIDEyLjIwMzIgMTIuMjkzOSAxMi4xODA4QzEyLjI5MzkgMTIuMTU4MyAxMi4zMTY4IDEyLjE1ODMgMTIuMzE2OCAxMi4xNTgzTDEyLjU5MjEgMTAuNTYyN0gzLjk5MDAyQzMuNDM5NDggMTAuNTYyNyAyLjk1Nzc1IDEwLjQyNzggMi41Njc3OSAxMC4xMzU2QzIuMTU0ODggOS44NjU5IDEuODc5NjIgOS41MDY0NyAxLjcxOTA0IDkuMDM0NDZDMS42MDQzNCA4LjY5NzQxIDEuNTU4NDYgOC4zMzc4MSAxLjYwNDM0IDcuOTMzMjhDMS42MDQzNCA3Ljg4ODM1IDEuNTU4NDYgNy44NjU4IDEuNTU4NDYgNy44MjA4N0MxLjM5NzkgNy4zNDg4NiAxLjM3NDk3IDYuODA5NTYgMS41MTI2IDYuMjI1MjNMMi4yNDY2NSAzLjE0NjM0QzIuMzg0MjggMi41ODQ0OSAyLjYzNjYxIDIuMDY3NTggMy4wMDM2MyAxLjU3MzE2QzMuMzcwNjYgMS4xMDEyMiAzLjgyOTQ0IDAuNzE5MTUyIDQuMzU3MDQgMC40NDk0NzZDNC44ODQ2MyAwLjE1NzMxOSA1LjQxMjI0IDAgNS45NjI4MyAwSDE0LjcwMjZDMTUuMjMwMSAwIDE1LjcxMTggMC4xNTczMTkgMTYuMTI0OCAwLjQ0OTQ3NkMxNi40NDU5IDAuNjc0MjA2IDE2LjY3NTMgMC45NjYzOCAxNi44NTg4IDEuMzAzNDhDMTYuOTA0OCAxLjM0ODQzIDE2LjkyNzcgMS4zOTMzNyAxNi45NzM1IDEuNDYwOEMxNy4yNzE4IDEuNzMwNDggMTcuNTI0IDIuMDIyNjQgMTcuNjg0NyAyLjQyNzE3Wk0zLjY0NTkyIDEyLjU4NTRWMTIuNjA3OEgzLjg5ODI1TDMuOTIxMiAxMi40MjhIMy42Njg4N0wzLjY0NTkyIDEyLjU2MjhDMy42MjI5OSAxMi41NjI4IDMuNjIyOTkgMTIuNTg1NCAzLjY0NTkyIDEyLjU4NTRaTTQuOTk5MzMgNi41NjIzNUg1LjIwNTc4TDUuMjc0NjEgNi4zMTUxNEg0Ljk1MzQ1TDQuOTA3NTggNi40NDk5N0M0LjkwNzU4IDYuNDk0OTIgNC45MDc1OCA2LjUxNzQgNC45MzA1MSA2LjUzOTg3QzQuOTUzNDUgNi41NjIzNSA0Ljk3NjQgNi41NjIzNSA0Ljk5OTMzIDYuNTYyMzVaTTEuNzQxOTcgNi4yNzAxN0MxLjY1MDIzIDYuNjc0NyAxLjY1MDIzIDcuMDU2NzcgMS43MTkwNCA3LjM5Mzc5TDEuNzQxOTcgNy4yMzY1NUMxLjc2NDkyIDcuMDExODEgMS43ODc4NiA2LjgwOTU2IDEuODMzNzQgNi41ODQ4MUwyLjU0NDg0IDMuNTI4MzlDMi42ODI0OSAyLjk2NjU0IDIuOTM0ODIgMi40NDk2NSAzLjMwMTg1IDEuOTc3NjlDMy4zNDc3MSAxLjkzMjc0IDMuMzcwNjYgMS44ODc4IDMuMzkzNTkgMS44NDI4NUwzLjQ2MjQxIDEuOTEwMjhDMy44MDY1IDEuNDgzMjcgNC4xOTY0NiAxLjE0NjE2IDQuNjc4MTkgMC44OTg5NTNDNS4xODI4NCAwLjYyOTI2IDUuNjg3NSAwLjQ5NDQyMyA2LjIxNTA2IDAuNDk0NDIzSDE0Ljk3NzlDMTUuMTg0MyAwLjQ5NDQyMyAxNS4zOTA3IDAuNTE2OTA0IDE1LjU5NzEgMC41NjE4NUwxNS42MiAwLjQ5NDQyM0MxNS43MzQ5IDAuNTE2OTA0IDE1Ljg0OTUgMC41NjE4NSAxNS45NjQyIDAuNjA2Nzk2QzE1LjU5NzEgMC4zNTk1ODUgMTUuMTg0MyAwLjI0NzIxMSAxNC43MDI2IDAuMjQ3MjExSDUuOTYyODNDNS40NTgxMiAwLjI0NzIxMSA0Ljk1MzQ1IDAuMzU5NTg0IDQuNDcxNzQgMC42MjkyNkMzLjk2NzA3IDAuODk4OTUzIDMuNTU0MTcgMS4yNTg1NCAzLjE4NzE1IDEuNzA4MDFDMi44NDMwNSAyLjE3OTk1IDIuNTkwNzIgMi42NzQzOCAyLjQ3NjAzIDMuMTkxMjhMMS43NDE5NyA2LjI3MDE3Wk0xMi4yNzEgMTIuNDI4SDQuMTczNTNMNC4xMjc2NSAxMi42MDc4SDcuNzI5MVYxMi42NzUySDEyLjU2OTJDMTIuNTkyMSAxMi42NzUyIDEyLjYxNSAxMi42NTI3IDEyLjY2MSAxMi42MzAzQzEyLjY4MzkgMTIuNjA3OCAxMi43MDY4IDEyLjU2MjggMTIuNzA2OCAxMi41NDAzTDEyLjg0NDUgMTEuODIxMkwxMy4wNTEgMTAuNjc1QzEzLjA3MzkgMTAuNjMgMTMuMDUxIDEwLjYwNzcgMTMuMDI4MSAxMC41ODUxQzEzLjAwNSAxMC41NjI3IDEyLjk4MjEgMTAuNTYyNyAxMi45NTkyIDEwLjU2MjdIMTIuODQ0NUwxMi41MjMzIDEyLjIwMzJDMTIuNTIzMyAxMi4yNDgyIDEyLjUwMDQgMTIuMzE1NiAxMi40MzE3IDEyLjM2MDZDMTIuMzg1NyAxMi40MDU1IDEyLjMxNjggMTIuNDI4IDEyLjI3MSAxMi40MjhaTTQuMDM1OSAxMS45NTZIMy43NjA2MkwzLjcxNDc0IDEyLjIwMzJIMy45NjcwN0w0LjAzNTkgMTEuOTU2Wk0wLjI5NjgxNyAxMi4zNjA2QzAuMjA1MDY5IDEyLjc2NTEgMC4yMDUwNyAxMy4xNjk2IDAuMjczODg2IDEzLjUyOTJMMC4zMTk3NSAxMy4zMDQ0QzAuMzE5NzUgMTMuMTAyMSAwLjM0MjcgMTIuODk5OSAwLjQxMTUxNiAxMi42NzUyTDAuNzA5NzI3IDExLjMyNjlIMy4zNzA2NkwzLjM5MzU5IDExLjE0N0gwLjU5NTAyOUwwLjI5NjgxNyAxMi4zNjA2Wk0xNi41ODM1IDEwLjA0NThDMTYuNjc1MyA5LjUwNjQ3IDE2LjYwNjYgOS4wMTE5MSAxNi40MjMgOC41ODVDMTYuNDAwMSA4LjU0MDA3IDE2LjM3NzEgOC40OTUxNCAxNi4zNTQyIDguNDUwMjFDMTYuNDAwMSA4LjY1MjQ4IDE2LjQyMyA4Ljg1NDc0IDE2LjQyMyA5LjA3OTM5QzE2LjQyMyA5LjI1OTI3IDE2LjQyMyA5LjQzODk5IDE2LjM3NzEgOS42MTg3TDE1Ljg3MjQgMTIuNjk3NkMxNS44MjY2IDEyLjkyMjQgMTUuNzU3OCAxMy4xMjQ3IDE1LjY4ODkgMTMuMzI3TDE1LjY0MzEgMTMuNTk2N0MxNS41NTE0IDE0LjA2ODUgMTUuMzQ0OSAxNC41MTggMTUuMDQ2NyAxNC45NDUxQzE1LjE2MTQgMTQuODMyNyAxNS4yOTkgMTQuNzIwMyAxNS4zOTA3IDE0LjYwOEMxNS43MzQ5IDE0LjE1ODQgMTUuOTY0MiAxMy42NDE2IDE2LjA1NiAxMy4xMjQ3TDE2LjU4MzUgMTAuMDQ1OFpNMTMuNTc4NSA2LjMxNTE0SDUuNTAzOTlMNS40NTgxMiA2LjU2MjM1SDEwLjA5MTdWNi40OTQ5MkgxMy44NTM4QzE0LjI0MzcgNi40OTQ5MiAxNC41ODc5IDYuNTYyMzUgMTQuOTA5IDYuNzE5NjVDMTQuNTE5IDYuNDQ5OTcgMTQuMDgzMyA2LjMxNTE0IDEzLjU3ODUgNi4zMTUxNFpNNS4zMjA0NyA2LjA5MDM5TDUuNjg3NSA0LjQ0OThINS40NTgxMkM1LjQzNTE3IDQuNDQ5OCA1LjQxMjI0IDQuNDcyMjggNS4zODkyOSA0LjQ5NDc1QzUuMzQzNDIgNC41MTcyMyA1LjM0MzQyIDQuNTYyMTggNS4zMjA0NyA0LjU4NDY0TDQuOTk5MzMgNi4wOTAzOUg1LjMyMDQ3Wk0xNy41OTI5IDMuOTc3ODZDMTcuNjg0NyAzLjQzODQ5IDE3LjYzODcgMi45NDQwNyAxNy40NTUyIDIuNDk0NTlDMTcuNDU1MiAyLjQ3MjExIDE3LjQwOTQgMi40NDk2NSAxNy40MDk0IDIuNDA0NjhDMTcuNDc4MyAyLjc2NDI3IDE3LjUwMTEgMy4xNDYzNCAxNy40MzIzIDMuNTUwODVMMTcuMjAzIDQuODk5MjhIMTcuMTM0MUwxNy4wODgzIDUuMTY4OTdIMTcuNDA5NEwxNy41OTI5IDMuOTc3ODZaIiBmaWxsPSJ3aGl0ZSIvPgo8L2c+CjxkZWZzPgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzE4NzlfMTcyNzciPgo8cmVjdCB3aWR0aD0iMzEuNzQ0OSIgaGVpZ2h0PSIzNCIgZmlsbD0id2hpdGUiLz4KPC9jbGlwUGF0aD4KPC9kZWZzPgo8L3N2Zz4K' : $data['white_label_plugin_menu_icon'];
755
756 // Use 'read' capability since actual access is controlled by current_user_has_plugin_access() check above
757 $menu_capability = 'read';
758
759 // Separator just before the plugin entry, after all other plugins (Yoast etc. ~99)
760 add_action('admin_menu', function() {
761 global $menu;
762 $menu['100'] = array('', 'read', 'separator-metasync-before', '', 'wp-menu-separator');
763 }, 5);
764
765 // Main menu page at position 100.1 — bottom of the menu after all standard items.
766 // The top-level callback is the floor: when Settings is reachable it renders
767 // Settings; when Settings is hidden it falls back to the first reachable
768 // keep-list page so the menu never leads to a blank page.
769 $top_level_callback = self::resolve_top_level_callback($admin);
770 add_menu_page(
771 $menu_name,
772 $menu_title,
773 $menu_capability,
774 $menu_slug,
775 $top_level_callback,
776 $menu_icon,
777 '100.1'
778 );
779
780 // Check connection status for submenu availability
781 $general_options = Metasync::get_option('general');
782 $has_api_key = !empty($general_options['searchatlas_api_key']);
783 $has_uuid = !empty($general_options['otto_pixel_uuid']);
784 $is_fully_connected = Metasync_Heartbeat_Manager::instance()->is_heartbeat_connected($general_options);
785
786 $seo_controls = Metasync::get_option('seo_controls');
787
788 // ── Connect slug ──────────────────────────────────────────────────
789 $connect_slug = $menu_slug . '-connect';
790
791 // Keep a slug-to-title map so hidden pages can still provide WordPress
792 // with a browser-tab title when they are registered without a parent.
793 $admin_page_titles = array();
794
795 // Local helper: register a submenu page under the plugin parent slug
796 // when the item keeps its WordPress admin sidebar row, or under an
797 // empty parent slug (hidden from the sidebar but still reachable)
798 // otherwise. The Settings page (general) is registered against the
799 // bare $menu_slug so it doubles as the parent menu callback.
800 $register_submenu = function($item_key, $page_title, $menu_title, $capability, $menu_page_slug, $callback) use ($menu_slug, &$admin_page_titles) {
801 $admin_page_titles[$menu_page_slug] = $page_title;
802 $parent = self::item_keeps_wp_sidebar_row($item_key) ? $menu_slug : '';
803 add_submenu_page($parent, $page_title, $menu_title, $capability, $menu_page_slug, $callback);
804 };
805
806 // Dashboard (check access control + "Hide Dashboard" general setting)
807 if (Metasync_Access_Control::user_can_access('hide_dashboard') && !self::is_dashboard_hidden_by_framework()) {
808 $register_submenu('dashboard', 'Dashboard', 'Dashboard', $menu_capability, $menu_slug . '-dashboard', array($admin, 'create_admin_dashboard_iframe'));
809 }
810
811 // Indexation Control
812 if (Metasync_Access_Control::user_can_access('hide_indexation_control')) {
813 $register_submenu('seo_controls', 'Indexation Control', 'Indexation Control', $menu_capability, $menu_slug . '-seo-controls', array($admin, 'create_admin_seo_controls_page'));
814 }
815
816 // 404 Monitor
817 $register_submenu('monitor_404', '404 Monitor', '404 Monitor', $menu_capability, $menu_slug . '-404-monitor', array($admin, 'create_admin_404_monitor_page'));
818
819 // Redirections
820 if (Metasync_Access_Control::user_can_access('hide_redirections')) {
821 $register_submenu('redirections', 'Redirections', 'Redirections', $menu_capability, $menu_slug . '-redirections', array($admin, 'create_admin_redirections_page'));
822 }
823
824 // XML Sitemap
825 if (Metasync_Access_Control::user_can_access('hide_xml_sitemap')) {
826 $register_submenu('xml_sitemap', 'XML Sitemap', 'XML Sitemap', $menu_capability, $menu_slug . '-xml-sitemap', array($admin, 'create_admin_xml_sitemap_page'));
827 }
828
829 // Robots.txt
830 if (Metasync_Access_Control::user_can_access('hide_robots')) {
831 $register_submenu('robots_txt', 'Robots.txt', 'Robots.txt', $menu_capability, $menu_slug . '-robots-txt', array($admin, 'create_admin_robots_txt_page'));
832 }
833
834 // Site Verification (in-page sidebar only in short mode)
835 $register_submenu('site_verification', 'Site Verification', 'Site Verification', $menu_capability, $menu_slug . '-search-engine-verify', array($admin, 'create_admin_search_engine_verification_page'));
836
837 // Instant Indexing (conditional)
838 if ($seo_controls['enable_googleinstantindex'] ?? false) {
839 $register_submenu('instant_index', 'Instant Indexing', 'Instant Indexing', $menu_capability, $menu_slug . '-instant-index', array($admin, 'create_admin_google_instant_index_page'));
840 }
841
842 // Google Console (conditional)
843 if ($general_options['enable_google_console'] ?? false) {
844 $register_submenu('google_console', 'Google Console', 'Google Console', $menu_capability, $menu_slug . '-google-console', array($admin, 'create_admin_google_console_page'));
845 }
846
847 // Bing Console (conditional)
848 if ($seo_controls['enable_binginstantindex'] ?? false) {
849 $register_submenu('bing_console', 'Bing Console', 'Bing Console', $menu_capability, $menu_slug . '-bing-console', array($admin, 'create_admin_bing_console_page'));
850 }
851
852 // Schema Markup
853 $register_submenu('schema_markup', 'Schema Markup', 'Schema Markup', $menu_capability, $menu_slug . '-schema-markup', array($admin, 'create_admin_schema_markup_page'));
854
855 // Import SEO Data
856 if (Metasync_Access_Control::user_can_access('hide_import_seo')) {
857 $register_submenu('import_seo', 'Import SEO Data', 'Import SEO Data', $menu_capability, $menu_slug . '-import-external', array($admin, 'render_import_external_data_page'));
858 }
859
860 // Settings — registered against the bare $menu_slug (parent menu floor).
861 // It always keeps its sidebar row so the plugin always has at least
862 // one working entry point (see is_settings_reachable()).
863 if (Metasync_Access_Control::user_can_access('hide_settings')) {
864 $register_submenu('general', 'Settings', 'Settings', $menu_capability, $menu_slug, array($admin, 'create_admin_settings_page'));
865 }
866
867 // Local Business (in-page sidebar only in short mode)
868 $register_submenu('local_business', 'Local Business', 'Local Business', $menu_capability, $menu_slug . '-local-business', array($admin, 'create_admin_local_business_page'));
869
870 // Breadcrumbs (in-page sidebar only in short mode)
871 $register_submenu('breadcrumbs', 'Breadcrumbs', 'Breadcrumbs', $menu_capability, $menu_slug . '-breadcrumbs', array($admin, 'create_admin_breadcrumbs_page'));
872
873 // Code Snippets (in-page sidebar only in short mode)
874 $register_submenu('code_snippets', 'Code Snippets', 'Code Snippets', $menu_capability, $menu_slug . '-code-snippets', array($admin, 'create_admin_code_snippets_page'));
875
876 // Code Minification (in-page sidebar only in short mode)
877 $register_submenu('code_minification', 'Code Minification', 'Code Minification', $menu_capability, $menu_slug . '-code-minification', array($admin, 'create_admin_code_minification_page'));
878
879 // Media Optimization (in-page sidebar only in short mode)
880 $register_submenu('media_optimization', 'Media Optimization', 'Media Optimization', $menu_capability, $menu_slug . '-media-optimization', array($admin, 'create_admin_media_optimization_page'));
881
882 // Custom Pages
883 if (Metasync_Access_Control::user_can_access('hide_custom_pages')) {
884 $register_submenu('custom_pages', 'Custom Pages', 'Custom Pages', $menu_capability, $menu_slug . '-custom-pages', array($admin, 'create_admin_custom_pages_page'));
885 }
886
887 // Bot Statistics (in-page sidebar only in short mode)
888 $register_submenu('bot_statistics', 'Bot Statistics', 'Bot Statistics', $menu_capability, $menu_slug . '-bot-statistics', array($admin, 'create_admin_bot_statistics_page'));
889
890 // SEO Health dashboard
891 $register_submenu('seo_health', 'SEO Health', 'SEO Health', $menu_capability, $menu_slug . '-seo-health', array($admin, 'create_admin_seo_health_page'));
892
893 // Compatibility
894 if (Metasync_Access_Control::user_can_access('hide_compatibility')) {
895 $register_submenu('compatibility', 'Compatibility', 'Compatibility', $menu_capability, $menu_slug . '-compatibility', array($admin, 'create_admin_compatibility_page'));
896 }
897
898 // Changes Log
899 if (Metasync_Access_Control::user_can_access('hide_sync_log')) {
900 $register_submenu('sync_log', 'Changes Log', 'Changes Log', $menu_capability, $menu_slug . '-sync-log', array($admin, 'create_admin_sync_log_page'));
901 }
902
903 // Report Issue
904 if (Metasync_Access_Control::user_can_access('hide_report_issue')) {
905 $register_submenu('report_issue', 'Report Issue', 'Report Issue', $menu_capability, $menu_slug . '-report-issue', array($admin, 'create_admin_report_issue_page'));
906 }
907
908 // ── Connect CTA (shown when not authenticated) ────────────────────
909 // Kept as a sidebar row so an unconnected site always has a visible
910 // entry point alongside the top-level menu item.
911 if (!$is_fully_connected) {
912 $connect_label = sprintf('Connect to %s', Metasync::get_effective_plugin_name());
913 add_submenu_page($menu_slug, $connect_label, $connect_label, $menu_capability, $connect_slug, array($admin, 'create_admin_settings_page'));
914 }
915
916 // ── Hidden pages (no sidebar entry needed) ────────────────────────
917 $admin_page_titles[$menu_slug . '-setup-wizard'] = 'Setup Wizard';
918 add_submenu_page('', 'Setup Wizard', 'Setup Wizard', $menu_capability, $menu_slug . '-setup-wizard', array($admin->setup_wizard, 'render_wizard_page'));
919
920 // WordPress cannot resolve titles for pages registered under an empty
921 // parent, even though those pages remain reachable by URL. Restore the
922 // standard admin title format for our registered pages only.
923 add_filter('admin_title', function($admin_title, $title) use (&$admin_page_titles) {
924 $current_page = isset($_GET['page']) && is_string($_GET['page'])
925 ? sanitize_text_field(wp_unslash($_GET['page']))
926 : '';
927
928 if (empty($title) && isset($admin_page_titles[$current_page])) {
929 $site_title = is_network_admin() || is_user_admin()
930 ? get_network()->site_name
931 : get_bloginfo('name');
932
933 $admin_title = sprintf(
934 /* translators: 1: Admin page title, 2: Site or network title. */
935 __('%1$s &lsaquo; %2$s &#8212; WordPress'),
936 $admin_page_titles[$current_page],
937 $site_title
938 );
939 }
940
941 return $admin_title;
942 }, 10, 2);
943
944
945 // Rename auto-generated first submenu from plugin name to "Settings" and reorder
946 add_action('admin_menu', function() use ($menu_slug) {
947 global $submenu;
948 if (!isset($submenu[$menu_slug])) {
949 return;
950 }
951 // Remove the auto-duplicate of the main menu item (same slug as parent)
952 foreach ($submenu[$menu_slug] as $key => $item) {
953 if ($item[2] === $menu_slug) {
954 unset($submenu[$menu_slug][$key]);
955 break;
956 }
957 }
958 }, 999);
959
960 }
961
962 /**
963 * Render the grouped navigation menu with Dashboard + SEO/Plugin dropdowns.
964 */
965 public function render_navigation_menu($current_page = null)
966 {
967 $page_slug = Metasync_Admin::$page_slug;
968 $available_menu_items = $this->get_available_menu_items();
969
970 $menu_icons = [
971 'general' => 'admin-settings',
972 'dashboard' => 'dashboard',
973 'compatibility' => 'admin-tools',
974 'sync_log' => 'list-view',
975 'seo_controls' => 'search',
976 'instant_index' => 'performance',
977 'google_console' => 'chart-area',
978 'bing_console' => 'chart-bar',
979 'redirections' => 'undo',
980 'robots_txt' => 'shield',
981 'xml_sitemap' => 'networking',
982 'schema_markup' => 'tag',
983 'site_verification'=> 'yes-alt',
984 'import_seo' => 'download',
985 'custom_pages' => 'admin-page',
986 'code_minification'=> 'media-code',
987 'bot_statistics' => 'visibility',
988 'breadcrumbs' => 'menu',
989 'monitor_404' => 'warning',
990 'local_business' => 'building',
991 'code_snippets' => 'editor-code',
992 'report_issue' => 'sos',
993 'error_log' => 'warning',
994 'seo_health' => 'heart'
995
996 ];
997
998 $seo_items = [];
999 $plugin_items = [];
1000
1001 foreach ($available_menu_items as $key => $menu_item) {
1002 $group = $menu_item['group'] ?? 'plugin';
1003 if ($group === 'seo') {
1004 $seo_items[$key] = $menu_item;
1005 } else {
1006 $plugin_items[$key] = $menu_item;
1007 }
1008 }
1009 ?>
1010 <!-- Plugin Navigation Menu with Dashboard + Grouped Dropdowns -->
1011 <div class="metasync-nav-wrapper">
1012 <div class="metasync-nav-tabs metasync-nav-grouped">
1013 <?php
1014 // Dashboard tab (standalone)
1015 if (isset($seo_items['dashboard'])) {
1016 $is_active = ($current_page === 'dashboard');
1017 $icon = $menu_icons['dashboard'];
1018 $page_url = '?page=' . $page_slug . $seo_items['dashboard']['slug_suffix'];
1019 ?>
1020 <a href="<?php echo esc_url($page_url); ?>" class="metasync-nav-tab <?php echo $is_active ? 'active' : ''; ?>">
1021 <span class="tab-icon"><span class="dashicons dashicons-<?php echo esc_attr($icon); ?>"></span></span>
1022 <span class="tab-text"><?php echo esc_html($seo_items['dashboard']['title']); ?></span>
1023 </a>
1024 <?php
1025 }
1026 ?>
1027
1028 <!-- SEO Features Dropdown (excluding Dashboard) -->
1029 <div class="metasync-nav-dropdown">
1030 <?php
1031 $has_active_seo = false;
1032 foreach ($seo_items as $key => $menu_item) {
1033 if ($key !== 'dashboard' && $current_page === $key) {
1034 $has_active_seo = true;
1035 break;
1036 }
1037 }
1038 ?>
1039 <button type="button" class="metasync-nav-dropdown-btn <?php echo $has_active_seo ? 'active' : ''; ?>" aria-haspopup="true" aria-expanded="false">
1040 <span class="tab-icon"><span class="dashicons dashicons-search"></span></span>
1041 <span class="tab-text">SEO</span>
1042 <span class="dropdown-arrow"></span>
1043 </button>
1044 <div class="metasync-nav-dropdown-menu">
1045 <?php
1046 foreach ($seo_items as $key => $menu_item) {
1047 if ($key === 'dashboard') {
1048 continue;
1049 }
1050
1051 $is_active = ($current_page === $key);
1052 $icon = $menu_icons[$key] ?? 'admin-generic';
1053 $page_url = '?page=' . $page_slug . $menu_item['slug_suffix'];
1054 ?>
1055 <a href="<?php echo esc_url($page_url); ?>" class="metasync-nav-dropdown-item <?php echo $is_active ? 'active' : ''; ?>">
1056 <span class="tab-icon"><span class="dashicons dashicons-<?php echo esc_attr($icon); ?>"></span></span>
1057 <span class="tab-text"><?php echo esc_html($menu_item['title']); ?></span>
1058 </a>
1059 <?php
1060 }
1061 ?>
1062 </div>
1063 </div>
1064
1065 <!-- Plugin Dropdown -->
1066 <div class="metasync-nav-dropdown">
1067 <?php
1068 $has_active_plugin = false;
1069 foreach ($plugin_items as $key => $menu_item) {
1070 if ($key !== 'report_issue' && $current_page === $key) {
1071 $has_active_plugin = true;
1072 break;
1073 }
1074 }
1075 ?>
1076 <button type="button" class="metasync-nav-dropdown-btn <?php echo $has_active_plugin ? 'active' : ''; ?>" aria-haspopup="true" aria-expanded="false">
1077 <span class="tab-icon"><span class="dashicons dashicons-admin-settings"></span></span>
1078 <span class="tab-text">Plugin</span>
1079 <span class="dropdown-arrow"></span>
1080 </button>
1081 <div class="metasync-nav-dropdown-menu">
1082 <?php
1083 foreach ($plugin_items as $key => $menu_item) {
1084 if ($key === 'report_issue') {
1085 continue;
1086 }
1087
1088 $is_active = ($current_page === $key);
1089 $icon = $menu_icons[$key] ?? 'admin-generic';
1090 $page_url = '?page=' . $page_slug . $menu_item['slug_suffix'];
1091 ?>
1092 <a href="<?php echo esc_url($page_url); ?>" class="metasync-nav-dropdown-item <?php echo $is_active ? 'active' : ''; ?>">
1093 <span class="tab-icon"><span class="dashicons dashicons-<?php echo esc_attr($icon); ?>"></span></span>
1094 <span class="tab-text"><?php echo esc_html($menu_item['title']); ?></span>
1095 </a>
1096 <?php
1097 }
1098 ?>
1099 </div>
1100 </div>
1101
1102 <!-- Right side - Report Issue and Settings dropdown -->
1103 <div class="metasync-nav-right">
1104 <?php
1105 if (isset($available_menu_items['report_issue'])) {
1106 $is_active = ($current_page === 'report_issue');
1107 $page_url = '?page=' . $page_slug . $available_menu_items['report_issue']['slug_suffix'];
1108 ?>
1109 <a href="<?php echo esc_url($page_url); ?>" class="metasync-nav-tab <?php echo $is_active ? 'active' : ''; ?>">
1110 <span class="tab-icon"><span class="dashicons dashicons-sos"></span></span>
1111 <span class="tab-text">Report Issue</span>
1112 </a>
1113 <?php } ?>
1114 <div class="metasync-simple-dropdown">
1115 <button type="button" class="metasync-settings-btn" id="metasync-settings-btn" onclick="toggleSettingsMenuPortal(event)" aria-expanded="false">
1116 <span class="tab-icon"><span class="dashicons dashicons-admin-settings"></span></span>
1117 <span class="tab-text">Settings</span>
1118 <span class="dropdown-arrow"></span>
1119 </button>
1120 </div>
1121 </div>
1122 </div>
1123 </div>
1124
1125 <script>
1126 // Handle navigation dropdowns using portal pattern for reliable positioning
1127 (function() {
1128 var dropdowns = document.querySelectorAll('.metasync-nav-dropdown');
1129 var activePortal = null;
1130 var activeButton = null;
1131 var activeDropdown = null;
1132 var scrollHandler = null;
1133 var resizeHandler = null;
1134
1135 function positionPortalMenu(button, portalMenu) {
1136 var rect = button.getBoundingClientRect();
1137 var menuRect = portalMenu.getBoundingClientRect();
1138 var viewportWidth = window.innerWidth;
1139 var viewportHeight = window.innerHeight;
1140
1141 var left = rect.left;
1142 if (left + menuRect.width > viewportWidth - 10) {
1143 left = Math.max(10, viewportWidth - menuRect.width - 10);
1144 }
1145
1146 var top = rect.bottom + 8;
1147 if (top + menuRect.height > viewportHeight - 10 && rect.top > menuRect.height + 10) {
1148 top = rect.top - menuRect.height - 8;
1149 }
1150
1151 portalMenu.style.position = 'fixed';
1152 portalMenu.style.top = top + 'px';
1153 portalMenu.style.left = left + 'px';
1154 portalMenu.style.zIndex = '999999999';
1155 }
1156
1157 function closeActivePortal() {
1158 if (activePortal && activePortal.parentNode) {
1159 activePortal.parentNode.removeChild(activePortal);
1160 }
1161 if (activeButton) {
1162 activeButton.setAttribute('aria-expanded', 'false');
1163 }
1164 if (activeDropdown) {
1165 activeDropdown.classList.remove('active');
1166 }
1167 if (scrollHandler) {
1168 window.removeEventListener('scroll', scrollHandler, true);
1169 scrollHandler = null;
1170 }
1171 if (resizeHandler) {
1172 window.removeEventListener('resize', resizeHandler);
1173 resizeHandler = null;
1174 }
1175 activePortal = null;
1176 activeButton = null;
1177 activeDropdown = null;
1178 }
1179
1180 function openAsPortal(dropdown, button, menu) {
1181 closeActivePortal();
1182
1183 var portalMenu = menu.cloneNode(true);
1184 portalMenu.id = 'metasync-nav-portal-menu';
1185 portalMenu.style.opacity = '1';
1186 portalMenu.style.visibility = 'visible';
1187 portalMenu.style.transform = 'none';
1188
1189 document.body.appendChild(portalMenu);
1190
1191 positionPortalMenu(button, portalMenu);
1192
1193 activePortal = portalMenu;
1194 activeButton = button;
1195 activeDropdown = dropdown;
1196 dropdown.classList.add('active');
1197 button.setAttribute('aria-expanded', 'true');
1198
1199 scrollHandler = function() {
1200 if (activePortal && activeButton) {
1201 positionPortalMenu(activeButton, activePortal);
1202 }
1203 };
1204 resizeHandler = scrollHandler;
1205
1206 window.addEventListener('scroll', scrollHandler, true);
1207 window.addEventListener('resize', resizeHandler);
1208
1209 portalMenu.addEventListener('click', function(e) {
1210 var link = e.target.closest('a');
1211 if (link) {
1212 setTimeout(closeActivePortal, 0);
1213 }
1214 });
1215 }
1216
1217 dropdowns.forEach(function(dropdown) {
1218 var button = dropdown.querySelector('.metasync-nav-dropdown-btn');
1219 var menu = dropdown.querySelector('.metasync-nav-dropdown-menu');
1220
1221 if (!button || !menu) return;
1222
1223 button.addEventListener('click', function(e) {
1224 e.preventDefault();
1225 e.stopPropagation();
1226
1227 var isExpanded = button.getAttribute('aria-expanded') === 'true';
1228
1229 if (isExpanded) {
1230 closeActivePortal();
1231 } else {
1232 openAsPortal(dropdown, button, menu);
1233 }
1234 });
1235 });
1236
1237 document.addEventListener('click', function(e) {
1238 if (activePortal && activeButton) {
1239 if (!activePortal.contains(e.target) && !activeButton.contains(e.target)) {
1240 closeActivePortal();
1241 }
1242 }
1243 });
1244
1245 document.addEventListener('keydown', function(e) {
1246 if (e.key === 'Escape' && activePortal) {
1247 closeActivePortal();
1248 if (activeButton) {
1249 activeButton.focus();
1250 }
1251 }
1252 });
1253 })();
1254
1255 // Portal-style dropdown that bypasses stacking contexts
1256 function toggleSettingsMenuPortal(event) {
1257 event.preventDefault();
1258 event.stopPropagation();
1259
1260 var button = event.currentTarget;
1261 var existingMenu = document.getElementById('metasync-portal-menu');
1262
1263 if (existingMenu) {
1264 existingMenu.remove();
1265 button.classList.remove('active');
1266 button.setAttribute('aria-expanded', 'false');
1267 return;
1268 }
1269
1270 var menu = document.createElement('div');
1271 menu.id = 'metasync-portal-menu';
1272 menu.className = 'metasync-portal-menu';
1273
1274 var currentUrl = window.location.href;
1275 var isGeneralActive = currentUrl.indexOf('tab=general') > -1 || currentUrl.indexOf('tab=') === -1;
1276 var isAdvancedActive = currentUrl.indexOf('tab=advanced') > -1;
1277 var isWhitelabelActive = currentUrl.indexOf('tab=whitelabel') > -1;
1278
1279 menu.textContent = '';
1280
1281 var hideAdvanced = <?php
1282 echo !Metasync_Access_Control::user_can_access('hide_advanced') ? 'true' : 'false';
1283 ?>;
1284 var showGeneral = <?php
1285 echo Metasync_Access_Control::user_can_access('hide_settings') ? 'true' : 'false';
1286 ?>;
1287
1288 if (showGeneral) {
1289 const generalLink = document.createElement('a');
1290 generalLink.href = '?page=<?php echo esc_js( $page_slug ); ?>&tab=general';
1291 generalLink.className = 'metasync-portal-item' + (isGeneralActive ? ' active' : '');
1292 generalLink.textContent = 'General';
1293 menu.appendChild(generalLink);
1294 }
1295
1296 const whitelabelLink = document.createElement('a');
1297 whitelabelLink.href = '?page=<?php echo esc_js( $page_slug ); ?>&tab=whitelabel';
1298 whitelabelLink.className = 'metasync-portal-item' + (isWhitelabelActive ? ' active' : '');
1299 whitelabelLink.textContent = 'White label';
1300 menu.appendChild(whitelabelLink);
1301
1302 if (!hideAdvanced) {
1303 const advancedLink = document.createElement('a');
1304 advancedLink.href = '?page=<?php echo esc_js( $page_slug ); ?>&tab=advanced';
1305 advancedLink.className = 'metasync-portal-item' + (isAdvancedActive ? ' active' : '');
1306 advancedLink.textContent = 'Advanced';
1307 menu.appendChild(advancedLink);
1308 }
1309
1310
1311 var rect = button.getBoundingClientRect();
1312 menu.style.position = 'fixed';
1313 menu.style.top = (rect.bottom + 8) + 'px';
1314 menu.style.right = (window.innerWidth - rect.right) + 'px';
1315 menu.style.zIndex = '999999999';
1316
1317 document.body.appendChild(menu);
1318
1319 button.classList.add('active');
1320 button.setAttribute('aria-expanded', 'true');
1321 }
1322
1323 document.addEventListener('click', function(event) {
1324 var button = document.getElementById('metasync-settings-btn');
1325 var menu = document.getElementById('metasync-portal-menu');
1326
1327 if (menu && button && !button.contains(event.target) && !menu.contains(event.target)) {
1328 menu.remove();
1329 button.classList.remove('active');
1330 button.setAttribute('aria-expanded', 'false');
1331 }
1332 });
1333 </script>
1334 <?php
1335 }
1336
1337 /**
1338 * Render the plugin header with logo, theme toggle, and connection badge.
1339 */
1340 public function render_plugin_header($page_title = null)
1341 {
1342 $general_settings = Metasync::get_option('general');
1343
1344 $effective_plugin_name = Metasync::get_effective_plugin_name();
1345 $display_title = $page_title ?: $effective_plugin_name;
1346 $logo = $this->resolve_logo_data();
1347
1348 $searchatlas_api_key = isset($general_settings['searchatlas_api_key']) ? $general_settings['searchatlas_api_key'] : '';
1349 $otto_pixel_uuid = isset($general_settings['otto_pixel_uuid']) ? $general_settings['otto_pixel_uuid'] : '';
1350
1351 $is_integrated = Metasync_Heartbeat_Manager::instance()->is_heartbeat_connected($general_settings);
1352
1353 $current_theme = get_option('metasync_theme', 'dark');
1354 ?>
1355
1356 <!-- Plugin Header with Logo -->
1357 <div class="metasync-header" data-current-theme="<?php echo esc_attr($current_theme); ?>">
1358 <div class="metasync-header-left">
1359 <?php if ($logo['show_logo'] && $logo['use_dual']): ?>
1360 <div class="metasync-logo-container">
1361 <img src="<?php echo esc_url($logo['light_url']); ?>" alt="Logo" class="metasync-logo metasync-logo-light" />
1362 <img src="<?php echo esc_url($logo['dark_url']); ?>" alt="Logo" class="metasync-logo metasync-logo-dark" />
1363 </div>
1364 <?php elseif ($logo['show_logo'] && !empty($logo['url'])): ?>
1365 <div class="metasync-logo-container">
1366 <img src="<?php echo esc_url($logo['url']); ?>" alt="Logo" class="metasync-logo<?php echo !empty($logo['is_default']) ? ' metasync-logo-default' : ''; ?>" />
1367 </div>
1368 <?php endif; ?>
1369 </div>
1370
1371 <div class="metasync-header-right">
1372 <!-- Theme Toggle -->
1373 <div class="metasync-theme-toggle" role="group" aria-label="Theme Selector">
1374 <button class="metasync-theme-option <?php echo ($current_theme === 'light') ? 'active' : ''; ?>" data-theme="light" aria-label="Light Theme" type="button">
1375 <span class="metasync-theme-icon">&#9728;</span>
1376 <span class="theme-label">Light</span>
1377 </button>
1378 <button class="metasync-theme-option <?php echo ($current_theme === 'dark') ? 'active' : ''; ?>" data-theme="dark" aria-label="Dark Theme" type="button">
1379 <span class="metasync-theme-icon">&#9790;</span>
1380 <span class="theme-label">Dark</span>
1381 </button>
1382 </div>
1383
1384 <!-- Integration Status -->
1385 <?php
1386 if ($is_integrated && !empty($otto_pixel_uuid)) {
1387 $status_class_header = 'integrated';
1388 $status_title_header = 'Synced - Heartbeat API connectivity verified';
1389 $status_text_header = 'Synced';
1390 } elseif ($is_integrated && empty($otto_pixel_uuid)) {
1391 $status_class_header = 'warning';
1392 $status_title_header = 'Connected but OTTO UUID is missing — deploys will not work. Please reconnect.';
1393 $status_text_header = 'Warning';
1394 } else {
1395 $status_class_header = 'not-integrated';
1396 $status_title_header = 'Not Synced - Heartbeat API not responding or unreachable';
1397 $status_text_header = 'Not Synced';
1398 }
1399 ?>
1400 <div class="metasync-integration-status <?php echo $status_class_header; ?>"
1401 title="<?php echo esc_attr($status_title_header); ?>">
1402 <span class="status-indicator"></span>
1403 <span class="status-text"><?php echo esc_html($status_text_header); ?></span>
1404 </div>
1405 </div>
1406 </div>
1407
1408 <!-- Page Title Below Header -->
1409 <div class="metasync-page-title">
1410 <h1><?php echo esc_html($display_title); ?></h1>
1411 </div>
1412
1413 <?php
1414 }
1415
1416 // ------------------------------------------------------------------
1417 // Admin bar status indicator
1418 // ------------------------------------------------------------------
1419
1420 /**
1421 * Output inline CSS (and optional JS) for the admin-bar status node.
1422 */
1423 public function metasync_admin_bar_style()
1424 {
1425 if (!is_admin_bar_showing()) {
1426 return;
1427 }
1428
1429 # For backward compatibility, constant takes precedence.
1430 if (defined('METASYNC_SHOW_ADMIN_BAR_STATUS') && !METASYNC_SHOW_ADMIN_BAR_STATUS) {
1431 return;
1432 }
1433
1434
1435 # Check if admin bar status is enabled via setting
1436 $general_settings = Metasync::get_option('general');
1437 $show_admin_bar = $general_settings['show_admin_bar_status'] ?? true;
1438 if (!$show_admin_bar) {
1439 return;
1440 }
1441 ?>
1442 <style type="text/css">
1443 #wp-admin-bar-searchatlas-status .ab-item {
1444 font-weight: 500 !important;
1445 transition: all 0.2s ease !important;
1446 }
1447
1448 #wp-admin-bar-searchatlas-status:hover .ab-item {
1449 background-color: rgba(255, 255, 255, 0.1) !important;
1450 }
1451
1452 #wp-admin-bar-searchatlas-status.searchatlas-synced .ab-item {
1453 color: #46b450 !important; /* WordPress green for text */
1454 }
1455
1456 #wp-admin-bar-searchatlas-status.searchatlas-not-synced .ab-item {
1457 color: #dc3232 !important; /* WordPress red for text */
1458 }
1459
1460 #wp-admin-bar-searchatlas-status.searchatlas-warning .ab-item {
1461 color: #ffb900 !important; /* WordPress yellow for warning */
1462 }
1463
1464 /* Ensure emojis maintain their natural colors */
1465 #wp-admin-bar-searchatlas-status .ab-item {
1466 filter: none !important;
1467 }
1468
1469 /* Make status visible but subtle */
1470 #wp-admin-bar-searchatlas-status .ab-item {
1471 opacity: 0.9;
1472 }
1473
1474 #wp-admin-bar-searchatlas-status:hover .ab-item {
1475 opacity: 1;
1476 }
1477 </style>
1478
1479 <?php
1480 $page_slug = Metasync_Admin::$page_slug;
1481 $is_plugin_page = (
1482 (isset($_GET['page']) && strpos($_GET['page'], $page_slug) !== false) ||
1483 (isset($_GET['page']) && strpos($_GET['page'], 'searchatlas') !== false)
1484 );
1485 if ($is_plugin_page):
1486 ?>
1487 <script type="text/javascript">
1488 // Pass PHP variables to JavaScript
1489 window.MetasyncConfig = {
1490 pluginName: '<?php echo esc_js(Metasync::get_effective_plugin_name()); ?>',
1491 ottoName: '<?php echo esc_js(Metasync::get_whitelabel_otto_name()); ?>'
1492 };
1493
1494 jQuery(document).ready(function($) {
1495
1496 // Function to sync admin bar status
1497 function syncAdminBarStatus() {
1498 var pluginPageStatus = $('.metasync-integration-status .status-text').text();
1499 var adminBarItem = $('#wp-admin-bar-searchatlas-status .ab-item');
1500 var adminBarContainer = $('#wp-admin-bar-searchatlas-status');
1501 var pluginName = window.MetasyncConfig.pluginName;
1502
1503 if (pluginPageStatus && adminBarItem.length) {
1504 var allClasses = 'searchatlas-synced searchatlas-not-synced searchatlas-warning';
1505
1506 // Helper to update emoji in admin bar
1507 function updateAdminBarEmoji(targetEmoji, targetSvgCode) {
1508 var emojiImg = adminBarItem.find('img.emoji');
1509 if (emojiImg.length > 0) {
1510 emojiImg.attr('alt', targetEmoji);
1511 var currentSrc = emojiImg.attr('src');
1512 var updatedSrc = currentSrc.replace(/1f7e2\.svg|1f534\.svg|1f7e1\.svg/, targetSvgCode + '.svg');
1513 emojiImg.attr('src', updatedSrc);
1514 } else {
1515 var newHtml = adminBarItem.html().replace(/🟢|🔴|🟡/, targetEmoji);
1516 if (!newHtml.includes(targetEmoji) && newHtml.includes(pluginName)) {
1517 newHtml = newHtml.replace(pluginName, pluginName + ' ' + targetEmoji);
1518 }
1519 adminBarItem.html(newHtml);
1520 }
1521 }
1522
1523 if (pluginPageStatus.includes('Synced') && !pluginPageStatus.includes('Not Synced')) {
1524 // Update admin bar to synced (GREEN)
1525 updateAdminBarEmoji('🟢', '1f7e2');
1526 adminBarContainer.removeClass(allClasses).addClass('searchatlas-synced');
1527 var syncTitle = pluginName + ' - Synced (Heartbeat API connectivity verified)';
1528 adminBarContainer.attr('title', syncTitle);
1529 adminBarItem.attr('title', syncTitle);
1530
1531 } else if (pluginPageStatus.includes('Warning')) {
1532 // Update admin bar to warning (YELLOW)
1533 updateAdminBarEmoji('🟡', '1f7e1');
1534 adminBarContainer.removeClass(allClasses).addClass('searchatlas-warning');
1535 var warnTitle = pluginName + ' - Connected but OTTO UUID is missing — deploys will not work. Please reconnect.';
1536 adminBarContainer.attr('title', warnTitle);
1537 adminBarItem.attr('title', warnTitle);
1538
1539 } else if (pluginPageStatus.includes('Not Synced')) {
1540 // Update admin bar to not synced (RED)
1541 updateAdminBarEmoji('🔴', '1f534');
1542 adminBarContainer.removeClass(allClasses).addClass('searchatlas-not-synced');
1543 var notSyncTitle = pluginName + ' - Not Synced (Heartbeat API not responding or unreachable)';
1544 adminBarContainer.attr('title', notSyncTitle);
1545 adminBarItem.attr('title', notSyncTitle);
1546 }
1547 }
1548 }
1549
1550 // Sync when tabs are switched (for General/Advanced tabs)
1551 $(document).on('click', 'a[href*="tab="]', function() {
1552 setTimeout(syncAdminBarStatus, 200);
1553 });
1554
1555 // Also check every 5 seconds to keep it in sync
1556 setInterval(syncAdminBarStatus, 5000);
1557 });
1558 </script>
1559 <?php endif; ?>
1560 <?php
1561 }
1562
1563 /**
1564 * Add Search Atlas status indicator to WordPress admin bar.
1565 */
1566 public function add_searchatlas_admin_bar_status($wp_admin_bar)
1567 {
1568 if (!Metasync::current_user_has_plugin_access()) {
1569 return;
1570 }
1571
1572 # For backward compatibility, constant takes precedence.
1573 if (defined('METASYNC_SHOW_ADMIN_BAR_STATUS') && !METASYNC_SHOW_ADMIN_BAR_STATUS) {
1574 return;
1575 }
1576
1577 # Check if admin bar status is disabled via setting
1578 $general_settings = Metasync::get_option('general');
1579 if (!is_array($general_settings)) {
1580 $general_settings = [];
1581 }
1582 $show_admin_bar = $general_settings['show_admin_bar_status'] ?? true;
1583 if (!$show_admin_bar) {
1584 return;
1585 }
1586
1587 if (!is_admin() && !apply_filters('metasync_show_admin_bar_status_frontend', false)) {
1588 return;
1589 }
1590
1591 $node = get_transient(self::CACHE_KEY);
1592
1593 if ($node === false) {
1594 $is_synced = Metasync_Heartbeat_Manager::instance()->is_heartbeat_connected($general_settings);
1595
1596 $plugin_name = Metasync::get_effective_plugin_name();
1597
1598 if ($is_synced) {
1599 $otto_uuid = $general_settings['otto_pixel_uuid'] ?? '';
1600 if (!empty($otto_uuid)) {
1601 $status_emoji = '🟢'; // Green circle for synced
1602 $title = $plugin_name . ' - Synced (Heartbeat API connectivity verified)';
1603 $status_class = 'searchatlas-synced';
1604 } else {
1605 $status_emoji = '🟡'; // Yellow circle for warning
1606 $title = $plugin_name . ' - Connected but OTTO UUID is missing — deploys will not work. Please reconnect.';
1607 $status_class = 'searchatlas-warning';
1608 }
1609 } else {
1610 $status_emoji = '🔴'; // Red circle for not synced
1611 $title = $plugin_name . ' - Not Synced (Heartbeat API not responding or unreachable)';
1612 $status_class = 'searchatlas-not-synced';
1613 }
1614
1615 $admin_bar_title = $plugin_name . ' ' . $status_emoji;
1616
1617 $node = array(
1618 'title' => $admin_bar_title,
1619 'href' => admin_url('admin.php?page=' . Metasync_Admin::$page_slug),
1620 'meta_title' => $title,
1621 'meta_class' => $status_class,
1622 );
1623
1624 set_transient(self::CACHE_KEY, $node, apply_filters('metasync_admin_bar_status_cache_ttl', 5 * MINUTE_IN_SECONDS));
1625 }
1626
1627 $wp_admin_bar->add_node(array(
1628 'id' => 'searchatlas-status',
1629 'title' => $node['title'],
1630 'href' => $node['href'],
1631 'meta' => array(
1632 'title' => $node['meta_title'],
1633 'class' => $node['meta_class'],
1634 ),
1635 ));
1636 }
1637
1638 /**
1639 * Invalidate the admin bar status cache so the next page load recomputes status.
1640 */
1641 public static function invalidate_admin_bar_status_cache()
1642 {
1643 delete_transient(self::CACHE_KEY);
1644 }
1645
1646 // ------------------------------------------------------------------
1647 // Yoast-style 3-column layout helpers
1648 // ------------------------------------------------------------------
1649
1650 /**
1651 * Open the 3-column page layout.
1652 * Call this at the start of every admin page callback instead of
1653 * render_plugin_header() + render_navigation_menu().
1654 * Close with render_layout_close().
1655 *
1656 * @param string $page_title Human-readable page title.
1657 * @param string $current_page Key matching get_available_menu_items() (e.g. 'general').
1658 * @param string $description Optional subtitle shown below the title.
1659 */
1660 public function render_layout_open($page_title = '', $current_page = '', $description = '')
1661 {
1662 $theme = esc_attr(get_option('metasync_theme', 'dark'));
1663 $plugin_name = Metasync::get_effective_plugin_name();
1664 $general = Metasync::get_option('general') ?? [];
1665 $is_connected = Metasync_Heartbeat_Manager::instance()->is_heartbeat_connected($general);
1666 // badge reflects confirmed heartbeat health (connected / stale /
1667 // disconnected), not raw API-key presence.
1668 $badge = Metasync_Heartbeat_Manager::instance()->get_connection_badge($general);
1669 $logo = $this->resolve_logo_data();
1670
1671 $current_theme = get_option('metasync_theme', 'dark');
1672 ?>
1673 <div class="wrap metasync-dashboard-wrap" data-theme="<?php echo $theme; ?>">
1674
1675 <?php
1676 // Inject whitelabel color palette overrides
1677 $wl_settings = Metasync::get_whitelabel_settings();
1678 $wl_palette = isset($wl_settings['color_palette']) && is_array($wl_settings['color_palette']) ? $wl_settings['color_palette'] : array();
1679 if (!empty($wl_palette)) {
1680 echo '<style id="metasync-whitelabel-palette">';
1681 foreach (array('dark', 'light') as $t) {
1682 if (!empty($wl_palette[$t]) && is_array($wl_palette[$t])) {
1683 $selector = ($t === 'dark') ? ':root, [data-theme="dark"]' : '[data-theme="light"]';
1684 $vars = '';
1685 foreach ($wl_palette[$t] as $var_name => $color) {
1686 // Skip gradient partial keys — handled below
1687 if (strpos($var_name, 'gradient-') !== false) continue;
1688 if (preg_match('/^dashboard-[a-z-]+$/', $var_name) && preg_match('/^#[0-9a-fA-F]{3,8}$/', $color)) {
1689 $vars .= '--' . esc_attr($var_name) . ':' . esc_attr($color) . ';';
1690 }
1691 }
1692 // Build gradient overrides from paired from/to colors
1693 $gradient_pairs = array(
1694 'dashboard-gradient-primary' => array('dashboard-gradient-primary-from', 'dashboard-gradient-primary-to'),
1695 'dashboard-gradient-accent' => array('dashboard-gradient-accent-from', 'dashboard-gradient-accent-to'),
1696 );
1697 foreach ($gradient_pairs as $grad_var => $pair) {
1698 $from = isset($wl_palette[$t][$pair[0]]) ? $wl_palette[$t][$pair[0]] : '';
1699 $to = isset($wl_palette[$t][$pair[1]]) ? $wl_palette[$t][$pair[1]] : '';
1700 if ($from && $to && preg_match('/^#[0-9a-fA-F]{3,8}$/', $from) && preg_match('/^#[0-9a-fA-F]{3,8}$/', $to)) {
1701 $vars .= '--' . esc_attr($grad_var) . ':linear-gradient(135deg,' . esc_attr($from) . ' 0%,' . esc_attr($to) . ' 100%);';
1702 }
1703 }
1704 if ($vars) {
1705 echo $selector . '{' . $vars . '}';
1706 }
1707 }
1708 }
1709 echo '</style>';
1710 }
1711 ?>
1712
1713 <!-- Compact top header -->
1714 <div class="metasync-header-compact">
1715 <div style="display:flex;align-items:center;gap:10px;">
1716 <?php if ($logo['show_logo'] && $logo['use_dual']): ?>
1717 <img src="<?php echo esc_url($logo['light_url']); ?>" alt="<?php echo esc_attr($plugin_name); ?>" class="metasync-logo metasync-logo-light" style="height:28px;width:auto;">
1718 <img src="<?php echo esc_url($logo['dark_url']); ?>" alt="<?php echo esc_attr($plugin_name); ?>" class="metasync-logo metasync-logo-dark" style="height:28px;width:auto;">
1719 <?php elseif ($logo['show_logo'] && $logo['url']): ?>
1720 <img src="<?php echo esc_url($logo['url']); ?>" alt="<?php echo esc_attr($plugin_name); ?>" class="metasync-logo<?php echo !empty($logo['is_default']) ? ' metasync-logo-default' : ''; ?>" style="height:28px;width:auto;">
1721 <?php else: ?>
1722 <strong style="font-size:15px;color:var(--dashboard-text-primary);"><?php echo esc_html($plugin_name); ?></strong>
1723 <?php endif; ?>
1724 </div>
1725 <div class="metasync-header-compact-right">
1726 <div class="metasync-status <?php echo esc_attr($badge['class']); ?>">
1727 <span class="status-dot"></span>
1728 <span class="status-text"><?php echo esc_html($badge['text']); ?></span>
1729 </div>
1730 <button type="button" class="metasync-theme-toggle" onclick="toggleMetasyncTheme()" title="Toggle theme">
1731 <span class="theme-icon-light">&#9728;</span>
1732 <span class="theme-icon-dark">&#9790;</span>
1733 </button>
1734 </div>
1735 </div>
1736
1737 <!-- 3-column layout -->
1738 <div class="metasync-layout">
1739
1740 <!-- Left sidenav -->
1741 <aside class="metasync-layout-nav">
1742 <?php $this->render_sidenav($current_page); ?>
1743 </aside>
1744
1745 <!-- Main content -->
1746 <main class="metasync-layout-main">
1747 <?php
1748 // Anchor for WP core's notice relocation (common.js). Without it, core
1749 // moves admin notices after the first .wrap h1 only on DOM-ready, after
1750 // the server has already painted them at the top of #wpbody-content —
1751 // causing a flash above the header and layout shift. Providing
1752 // an explicit .wp-header-end inside this (FOUC-hidden) wrap makes core
1753 // relocate notices here deterministically, so they fade in with the page.
1754 ?>
1755 <hr class="wp-header-end">
1756 <?php if ($page_title || $description): ?>
1757 <div class="metasync-page-header">
1758 <?php if ($page_title): ?>
1759 <h1><?php echo esc_html($page_title); ?></h1>
1760 <?php endif; ?>
1761 <?php if ($description): ?>
1762 <p><?php echo esc_html($description); ?></p>
1763 <?php endif; ?>
1764 </div>
1765 <?php endif; ?>
1766 <?php
1767 // Note: render_layout_close() closes </main>, renders promo sidebar, closes </div>.metasync-layout and </div>.wrap
1768 }
1769
1770 /**
1771 * Close the 3-column layout opened by render_layout_open().
1772 *
1773 * @param bool $show_promo Whether to render the right promo sidebar. Default true.
1774 * Pass false for full-width pages (e.g. dashboard iframe).
1775 */
1776 public function render_layout_close($show_promo = true)
1777 {
1778 ?>
1779 </main><!-- /.metasync-layout-main -->
1780
1781 <?php
1782 // The sidebar exists to get an unconnected site connected. Once the
1783 // site is linked it has nothing further to offer, so the whole
1784 // column is dropped and the grid reflows to two columns.
1785 $show_promo = $show_promo && ! Metasync_Heartbeat_Manager::instance()
1786 ->is_heartbeat_connected(Metasync::get_option('general') ?? []);
1787 if ($show_promo):
1788 ?>
1789 <!-- Right promo sidebar -->
1790 <aside class="metasync-layout-promo">
1791 <?php $this->render_promo_sidebar(); ?>
1792 </aside>
1793 <?php endif; ?>
1794
1795 </div><!-- /.metasync-layout -->
1796 </div><!-- /.metasync-dashboard-wrap -->
1797 <?php
1798 }
1799
1800 /**
1801 * Render the left sticky sidenav with grouped items.
1802 *
1803 * @param string $current_page Active page key.
1804 */
1805 public function render_sidenav($current_page = '')
1806 {
1807 $page_slug = Metasync_Admin::$page_slug;
1808 $menu_items = $this->get_available_menu_items();
1809
1810 // Dashicons names (without 'dashicons-' prefix) — monochrome, color set by CSS
1811 $icons = [
1812 'dashboard' => 'dashboard',
1813 'seo_controls' => 'search',
1814 'monitor_404' => 'warning',
1815 'redirections' => 'undo',
1816 'xml_sitemap' => 'networking',
1817 'robots_txt' => 'shield',
1818 'site_verification'=> 'yes-alt',
1819 'instant_index' => 'performance',
1820 'google_console' => 'chart-area',
1821 'bing_console' => 'chart-bar',
1822 'import_seo' => 'download',
1823 'general' => 'admin-settings',
1824 'schema_markup' => 'tag',
1825 'local_business' => 'building',
1826 'breadcrumbs' => 'menu',
1827 'code_snippets' => 'editor-code',
1828 'code_minification'=> 'media-code',
1829 'custom_pages' => 'admin-page',
1830 'compatibility' => 'admin-tools',
1831 'sync_log' => 'list-view',
1832 'bot_statistics' => 'visibility',
1833 'report_issue' => 'sos',
1834 'media_optimization'=> 'images-alt2',
1835 'seo_health' => 'heart'
1836 ];
1837
1838 $seo_items = [];
1839 $plugin_items = [];
1840 foreach ($menu_items as $key => $item) {
1841 if (($item['group'] ?? 'plugin') === 'seo') {
1842 $seo_items[$key] = $item;
1843 } else {
1844 $plugin_items[$key] = $item;
1845 }
1846 }
1847
1848 $total_items = count($seo_items) + count($plugin_items);
1849
1850 ?>
1851 <nav class="metasync-sidenav">
1852
1853 <?php if ($total_items === 0): ?>
1854 <div class="metasync-sidenav-empty">
1855 <?php esc_html_e('No pages are available.', 'metasync'); ?>
1856 </div>
1857 <?php else: ?>
1858
1859 <!-- SEO Features group -->
1860 <div class="metasync-sidenav-group">
1861 <div class="metasync-sidenav-group-title">SEO Features</div>
1862 <ul>
1863 <?php foreach ($seo_items as $key => $item):
1864 $is_active = ($current_page === $key);
1865 $icon = $icons[$key] ?? 'admin-generic';
1866 $url = esc_url(admin_url('admin.php?page=' . $page_slug . $item['slug_suffix']));
1867 ?>
1868 <li class="<?php echo $is_active ? 'metasync-sidenav-active' : ''; ?>">
1869 <a href="<?php echo $url; ?>">
1870 <span class="metasync-sidenav-icon"><span class="dashicons dashicons-<?php echo esc_attr($icon); ?>"></span></span>
1871 <?php echo esc_html($item['title']); ?>
1872 </a>
1873 </li>
1874 <?php endforeach; ?>
1875 </ul>
1876 </div>
1877
1878 <!-- Plugin group -->
1879 <div class="metasync-sidenav-group">
1880 <div class="metasync-sidenav-group-title">Plugin</div>
1881 <ul>
1882 <?php foreach ($plugin_items as $key => $item):
1883 if ($key === 'report_issue') continue;
1884 $is_active = ($current_page === $key);
1885 $icon = $icons[$key] ?? 'admin-generic';
1886 $url = esc_url(admin_url('admin.php?page=' . $page_slug . $item['slug_suffix']));
1887 ?>
1888 <li class="<?php echo $is_active ? 'metasync-sidenav-active' : ''; ?>">
1889 <a href="<?php echo $url; ?>">
1890 <span class="metasync-sidenav-icon"><span class="dashicons dashicons-<?php echo esc_attr($icon); ?>"></span></span>
1891 <?php echo esc_html($item['title']); ?>
1892 </a>
1893 </li>
1894 <?php if ($key === 'general'): ?>
1895 <li class="<?php echo $current_page === 'advanced_settings' ? 'metasync-sidenav-active' : ''; ?>" style="padding-left: 8px;">
1896 <a href="<?php echo esc_url(admin_url('admin.php?page=' . $page_slug . '&tab=advanced')); ?>">
1897 <span class="metasync-sidenav-icon"><span class="dashicons dashicons-admin-generic"></span></span>
1898 Advanced Settings
1899 </a>
1900 </li>
1901 <li class="<?php echo $current_page === 'whitelabel' ? 'metasync-sidenav-active' : ''; ?>" style="padding-left: 8px;">
1902 <a href="<?php echo esc_url(admin_url('admin.php?page=' . $page_slug . '&tab=whitelabel')); ?>">
1903 <span class="metasync-sidenav-icon"><span class="dashicons dashicons-tag"></span></span>
1904 Whitelabel
1905 </a>
1906 </li>
1907 <?php endif; ?>
1908 <?php endforeach; ?>
1909 </ul>
1910 </div>
1911
1912 <!-- Report Issue at the bottom -->
1913 <?php if (isset($menu_items['report_issue'])): ?>
1914 <div class="metasync-sidenav-group">
1915 <ul>
1916 <li class="<?php echo $current_page === 'report_issue' ? 'metasync-sidenav-active' : ''; ?>">
1917 <a href="<?php echo esc_url(admin_url('admin.php?page=' . $page_slug . $menu_items['report_issue']['slug_suffix'])); ?>">
1918 <span class="metasync-sidenav-icon"><span class="dashicons dashicons-sos"></span></span>
1919 Report Issue
1920 </a>
1921 </li>
1922 </ul>
1923 </div>
1924 <?php endif; ?>
1925
1926 <?php endif; ?>
1927
1928 </nav>
1929 <?php
1930 }
1931
1932 /**
1933 * Render the right promotional sidebar.
1934 *
1935 * Only reached while the site is unconnected; the caller drops the
1936 * whole column once the plugin is linked.
1937 */
1938 public function render_promo_sidebar()
1939 {
1940 $plugin_name = Metasync::get_effective_plugin_name();
1941 $otto_name = Metasync::get_whitelabel_otto_name();
1942 $settings_url = esc_url(admin_url('admin.php?page=' . Metasync_Admin::$page_slug));
1943 $homepage = Metasync::HOMEPAGE_DOMAIN;
1944 $docs_url = Metasync::DOCUMENTATION_DOMAIN;
1945 // These links point at Search Atlas properties, so they only belong
1946 // here while the plugin runs under its own name AND branding — a
1947 // reseller can leave the name untouched and still be whitelabeled.
1948 $is_default_brand = ( $plugin_name === 'Search Atlas' ) && ! Metasync::is_whitelabel_enabled();
1949 $whitelabel = Metasync::get_whitelabel_settings();
1950 $custom_links = isset($whitelabel['quick_links']) && is_array($whitelabel['quick_links'])
1951 ? array_filter($whitelabel['quick_links'], function($l) { return !empty($l['url']); })
1952 : [];
1953 ?>
1954
1955 <!-- Connect CTA card -->
1956 <div class="metasync-promo-card metasync-promo-card--connect">
1957 <div class="metasync-promo-card-header">
1958 <div class="metasync-promo-card-icon">
1959 <span class="dashicons dashicons-admin-links"></span>
1960 </div>
1961 <div>
1962 <h3>Connect <?php echo esc_html($plugin_name); ?></h3>
1963 </div>
1964 </div>
1965 <p class="metasync-promo-tagline">Link your site to <?php echo esc_html($plugin_name); ?> to unlock <?php echo esc_html($otto_name); ?>, keyword data, and automated SEO.</p>
1966 <ul class="metasync-promo-benefits">
1967 <li><span class="promo-check">&#10003;</span> <?php echo esc_html($otto_name); ?> hands-free on-page SEO</li>
1968 <li><span class="promo-check">&#10003;</span> Real-time keyword tracking</li>
1969 <li><span class="promo-check">&#10003;</span> Automated schema markup</li>
1970 <li><span class="promo-check">&#10003;</span> Instant Google indexing</li>
1971 </ul>
1972 <a href="<?php echo $settings_url; ?>" class="metasync-promo-btn metasync-promo-btn--primary">
1973 Connect Now
1974 </a>
1975 </div>
1976
1977 <?php
1978 // Quick Links: show default links for default brand, custom links if whitelabeled + provided, hide if whitelabeled + none
1979 $show_quick_links = $is_default_brand || !empty($custom_links);
1980 if ($show_quick_links):
1981 ?>
1982 <!-- Quick links card -->
1983 <div class="metasync-promo-card">
1984 <h3 style="margin:0 0 12px;font-size:13px;font-weight:700;color:var(--dashboard-text-primary);">Quick Links</h3>
1985 <ul class="metasync-promo-links">
1986 <?php if ($is_default_brand): ?>
1987 <li><a href="<?php echo esc_url($docs_url); ?>" target="_blank" rel="noopener"><span class="dashicons dashicons-book-alt"></span> Documentation</a></li>
1988 <li><a href="<?php echo esc_url($homepage . '/blog/'); ?>" target="_blank" rel="noopener"><span class="dashicons dashicons-rss"></span> SEO Blog</a></li>
1989 <li><a href="<?php echo esc_url($homepage . '/contact/'); ?>" target="_blank" rel="noopener"><span class="dashicons dashicons-email-alt"></span> Contact Support</a></li>
1990 <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>
1991 <?php
1992 // Mirrors the gate that decides whether the page is registered
1993 // at all. Without it the link outlives the page for any user
1994 // the access rules hide it from, and leads nowhere.
1995 if (Metasync_Access_Control::user_can_access('hide_report_issue')):
1996 ?>
1997 <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>
1998 <?php endif; ?>
1999 <?php else: ?>
2000 <?php foreach ($custom_links as $link): ?>
2001 <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>
2002 <?php endforeach; ?>
2003 <?php endif; ?>
2004 </ul>
2005 </div>
2006 <?php endif; ?>
2007
2008 <?php
2009 }
2010 }
2011