*/ private const TAB_MODULES = array( 'ai' => 'ai', 'integrations' => 'analytics', 'license' => 'license', ); private FormControls $ui; /** * slug => tab, in the NAV ORDER (a user decision — Dashboard → Kreator → * Schema → Treści → robots.txt → llms.txt → Integracje → Custom Code → * Narzędzia → AI Providers → Ustawienia → Licencja last; new tabs go * before AI). llms.txt got its OWN tab in M57 (previously a card at the * bottom of robots.txt, M38) — placed right after robots.txt, its closest * cousin in the "what bots may do" family. Kreator (M41, first increment) * sits right after Dashboard — a permanent tab, not the originally-planned * hidden page + one-time redirect (user decision 2026-07-06). * * @var array */ private array $tabs = array(); private string $hook_suffix = ''; public function __construct( SettingsRepositoryInterface $post_type_settings, GlobalSettingsRepositoryInterface $global_settings, LocationRulesRepositoryInterface $location_rules, ArchiveSettingsRepositoryInterface $archive_settings, RobotsTxtRepositoryInterface $robots_txt, AiSettingsRepositoryInterface $ai_settings, AiProviderRegistry $ai_providers, IntegrationsRepositoryInterface $integrations, ImporterRegistry $importers, FeaturesRepositoryInterface $features, LicenseRepositoryInterface $license, OnboardingRepositoryInterface $onboarding, ?\AcezSeo\Data\AiBotsRepositoryInterface $ai_bots = null, ?\AcezSeo\Data\SitemapRepositoryInterface $sitemap = null ) { $this->ui = new FormControls(); $sitemap = $sitemap ?? new \AcezSeo\Data\SitemapRepository(); $this->tabs = array( 'dashboard' => new Tabs\DashboardTab( $this->ui, $features, $ai_settings, $post_type_settings, $global_settings, $robots_txt, $integrations, $onboarding, $ai_bots ), 'onboarding' => new Tabs\OnboardingTab( $this->ui, $onboarding, $global_settings, $post_type_settings, $importers, $ai_providers, $ai_settings, $features, $robots_txt, $integrations, $ai_bots ), 'global' => new Tabs\SchemaTab( $this->ui, $global_settings ), 'content' => new Tabs\ContentTab( $this->ui, $post_type_settings, $archive_settings, $location_rules, $global_settings ), 'robots' => new Tabs\RobotsTab( $this->ui, $robots_txt ), 'llms' => new Tabs\LlmsTab( $this->ui, $ai_bots, $global_settings, $features ), 'sitemap' => new Tabs\SitemapTab( $this->ui, $sitemap ), 'integrations' => new Tabs\IntegrationsTab( $this->ui, $integrations ), 'tools' => new Tabs\ToolsTab( $this->ui, $importers ), 'ai' => new Tabs\AiProvidersTab( $this->ui, $ai_providers, $ai_settings ), 'settings' => new Tabs\SettingsTab( $this->ui, $global_settings ), 'license' => new Tabs\LicenseTab( $this->ui, $license ), ); } public function register(): void { add_action( 'admin_menu', array( $this, 'register_menu' ) ); add_action( 'admin_post_acez_seo_save_settings', array( $this, 'handle_save' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); } public function register_menu(): void { $hook_suffix = add_menu_page( __( 'Acez SEO AI', 'acez-seo' ), __( 'Acez SEO AI', 'acez-seo' ), Capabilities::manage(), FormControls::PAGE_SLUG, array( $this, 'render' ), 'data:image/svg+xml;base64,' . base64_encode( $this->ui->menu_icon_svg() ) ); $this->hook_suffix = is_string( $hook_suffix ) ? $hook_suffix : ''; } public function enqueue_assets( string $hook ): void { if ( '' === $this->hook_suffix || $hook !== $this->hook_suffix ) { return; } wp_enqueue_style( 'acez-seo-admin-settings', ACEZ_SEO_URL . 'assets/css/admin-settings.css', array(), ACEZ_SEO_VERSION ); wp_enqueue_style( 'acez-seo-help-popover', ACEZ_SEO_URL . 'assets/css/help-popover.css', array(), ACEZ_SEO_VERSION ); wp_enqueue_script( 'acez-seo-help-popover', ACEZ_SEO_URL . 'assets/js/help-popover.js', array(), ACEZ_SEO_VERSION, true ); wp_localize_script( 'acez-seo-help-popover', 'AcezSeoHelp', HelpContent::all() ); wp_localize_script( 'acez-seo-help-popover', 'AcezSeoHelpStrings', array( 'watchVideo' => __( 'Watch video ↗', 'acez-seo' ) ) ); // M44 — collapsible cards (chevron in the card header). wp_enqueue_script( 'acez-seo-card-collapse', ACEZ_SEO_URL . 'assets/js/card-collapse.js', array(), ACEZ_SEO_VERSION, true ); wp_localize_script( 'acez-seo-card-collapse', 'AcezSeoCards', array( 'collapseAll' => __( 'Collapse all', 'acez-seo' ), 'expandAll' => __( 'Expand all', 'acez-seo' ), ) ); // M46 — confirmation modal for destructive "clear log/counter" actions. wp_enqueue_style( 'acez-seo-confirm-dialog', ACEZ_SEO_URL . 'assets/css/confirm-dialog.css', array(), ACEZ_SEO_VERSION ); wp_enqueue_script( 'acez-seo-confirm-dialog', ACEZ_SEO_URL . 'assets/js/confirm-dialog.js', array(), ACEZ_SEO_VERSION, true ); wp_localize_script( 'acez-seo-confirm-dialog', 'AcezSeoConfirm', array( 'title' => __( 'Confirm', 'acez-seo' ), 'confirm' => __( 'Confirm', 'acez-seo' ), 'cancel' => __( 'Cancel', 'acez-seo' ), ) ); // Tab-specific assets. Deliberately keyed by the RAW ?tab= value — // the ?tab=archives alias enqueues nothing, exactly as before M38. $active_tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation state (which tab's assets to enqueue); nothing is written, so a nonce would be meaningless. Value is sanitize_key()'d and only used as an array key against the registered tab list. if ( isset( $this->tabs[ $active_tab ] ) ) { $this->tabs[ $active_tab ]->enqueue(); } } public function render(): void { if ( ! current_user_can( Capabilities::manage() ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'acez-seo' ) ); } $active_tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'dashboard'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation state (which tab to render); nothing is written. The capability check above already gated this screen, and the value is sanitize_key()'d before being matched against the registered tab list. // Backward compat: the former top-level "Archiwa" tab was merged into // "Treści" (taxonomy archives are the second sidebar group there) — // old ?tab=archives links land on that group instead of a 404-ish // dashboard fallback. ContentTab reads the alias from $_GET itself. if ( 'archives' === $active_tab ) { $active_tab = 'content'; } if ( ! isset( $this->tabs[ $active_tab ] ) ) { $active_tab = 'dashboard'; } $module_class = isset( self::TAB_MODULES[ $active_tab ] ) ? ' acez-seo-wrap--mod-' . self::TAB_MODULES[ $active_tab ] : ''; echo '
'; echo '
'; echo '' . wp_kses( $this->ui->menu_icon_svg(), KsesTags::svg() ) . ''; echo '

' . esc_html__( 'Acez SEO AI', 'acez-seo' ) . '

'; echo '
'; // WP core's own admin.js inserts EVERY admin_notices output (ours and // other plugins', e.g. a conflicting SEO plugin's own banner) right // after the first `.wrap h1`/`h2` it finds when no `.wp-header-end` // marker exists — since our own

sits inside the flex // `.acez-seo-header`, notices would land INSIDE that flex row // instead of below it. This marker is the documented WP convention // to redirect that insertion point; core CSS already hides it. echo '
'; if ( isset( $_GET['updated'] ) && ! isset( $_GET['takeover_error'] ) ) // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- presence-only check on a redirect flag this plugin sets itself after the nonce-verified save in handle_save(); the value is never read and only a static, translated notice is printed. { echo '

' . esc_html__( 'Settings saved.', 'acez-seo' ) . '

'; } $this->render_tabs( $active_tab ); $this->tabs[ $active_tab ]->render(); echo '

'; } private function render_tabs( string $active_tab ): void { echo ''; } public function handle_save(): void { if ( ! current_user_can( Capabilities::manage() ) ) { wp_die( esc_html__( 'You do not have permission to perform this action.', 'acez-seo' ) ); } check_admin_referer( FormControls::NONCE_ACTION ); $raw_tab = isset( $_POST['tab'] ) ? sanitize_text_field( wp_unslash( $_POST['tab'] ) ) : 'global'; $redirect_args = array( 'page' => FormControls::PAGE_SLUG, 'tab' => sanitize_key( $raw_tab ), ); // ContentTab is asked LAST on purpose: it claims bare post-type slugs, // and a CPT named like another tab's form key must lose to the named // key — the same precedence the original if/elseif chain had. $dispatch_order = array_keys( $this->tabs ); usort( $dispatch_order, static fn( string $a, string $b ): int => ( 'content' === $a ) <=> ( 'content' === $b ) ); foreach ( $dispatch_order as $slug ) { if ( $this->tabs[ $slug ]->handles_form_key( $raw_tab ) ) { $redirect_args = array_merge( $redirect_args, $this->tabs[ $slug ]->save( $raw_tab ) ); break; } } $redirect_args['updated'] = '1'; wp_safe_redirect( add_query_arg( $redirect_args, admin_url( 'admin.php' ) ) ); exit; } }