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
metasync / includes / class-metasync-admin-navigation.php

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

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