*/
namespace ThemeAtelier\Darkify\Admin;
use ThemeAtelier\Darkify\Admin\Schema\SchemaRegistry;
if (! defined('ABSPATH')) {
die;
}
class Menu
{
/**
* The option key whose page args drive the menu.
*/
const OPTION_KEY = 'darkify';
/**
* The main SPA page slug (unchanged from the old options screen).
*/
const PAGE_SLUG = 'darkify';
/**
* The Help page's own slug. It is a separate WordPress menu entry — not a tab,
* modal or panel — so Help is reachable in one click from anywhere in wp-admin.
*/
const HELP_SLUG = 'darkify-help';
public function __construct()
{
\add_action('admin_menu', [$this, 'register']);
// Old bookmarks / links to the retired "Get Help" screen are forwarded to
// the SPA's own Help route (see redirect_legacy_help_page()).
\add_action('admin_page_access_denied', [$this, 'redirect_legacy_help_page']);
}
/**
* Register the top-level menu page, plus its submenu entries: an explicit
* "Darkify" entry, "Get Help", and the free plugin's existing
* "Upgrade To Pro! 👑" link.
*
* Every settings section is reached from the React admin's own header nav
* instead of WP submenus, but Get Help gets its own submenu entry (matching
* Pro's Menu.php) so it is reachable in one click from anywhere in wp-admin,
* not only from inside the settings screen. The upgrade submenu is the same
* external pricing link the free plugin has always registered — the
* upgrade flow is deliberately unchanged by the React migration.
*/
public function register(): void
{
$args = SchemaRegistry::options(self::OPTION_KEY);
$menu_title = $args['menu_title'] ?? \esc_html__('Darkify', 'darkify');
$capability = \apply_filters('darkify_ui_permission', $args['menu_capability'] ?? 'manage_options');
$position = $args['menu_position'] ?? null;
$icon = $args['menu_icon'] ?? 'dashicons-lightbulb';
\add_menu_page(
$menu_title,
$menu_title,
$capability,
self::PAGE_SLUG,
[$this, 'render'],
$icon,
$position
);
// Registered explicitly with the parent's own slug so it reads "Darkify"
// instead of WordPress auto-duplicating the top-level menu title as the
// first submenu item — that auto-duplication only happens when nothing
// else claims the parent slug as a submenu (see Pro's Menu.php, same fix).
\add_submenu_page(
self::PAGE_SLUG,
$menu_title,
\esc_html__('Darkify', 'darkify'),
$capability,
self::PAGE_SLUG,
[$this, 'render']
);
// Get Help is no longer a WP submenu — it lives in the SPA rail's footer
// (see AppSidebar). The retired `?page=darkify-help` still forwards to the
// SPA's /help route (see below).
\add_submenu_page(
self::PAGE_SLUG,
\__('Upgrade To Pro! 👑', 'darkify'),
\sprintf('%s', \__('Upgrade To Pro! 👑', 'darkify')),
'manage_options',
'https://darkifywp.com/pricing/?utm_source=darkify_plugin&utm_medium=submenu_page&utm_campaign=regular'
);
}
/**
* Forward the retired `?page=darkify-help` screen to the SPA's Help route.
*
* "Get Help" used to be its own menu page; it is now a route inside the one
* SPA page. Without this, an existing bookmark (or any third-party link) to
* the old slug would hit WordPress's "you are not allowed to access this
* page" wall, since the slug is no longer a registered page.
*
* Hooked on `admin_page_access_denied` — which wp-admin/includes/menu.php
* fires immediately before that `wp_die( …, 403 )` — because that check runs
* while menu.php is being required, i.e. BEFORE `admin_init`; an admin_init
* hook would never get the chance to redirect. The slug guard keeps this
* scoped to our own retired page, so every other denied page still dies
* normally.
*/
public function redirect_legacy_help_page(): void
{
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen check, no state is changed.
$page = isset($_GET['page']) ? \sanitize_key(\wp_unslash($_GET['page'])) : '';
if ($page !== self::HELP_SLUG) {
return;
}
\wp_safe_redirect(\admin_url('admin.php?page=' . self::PAGE_SLUG . '#/help'));
exit;
}
/**
* The page body: just the node React mounts on. Every menu entry renders this
* same root — the SPA reads the `?page=` slug to decide which route to open.
*
* The `darkify_ignore` class opts the whole React admin out of Darkify's own
* dark-mode engine. When "Admin Panel Dark Mode" is on, that engine runs on
* every admin screen and force-rewrites element backgrounds/colors with
* `!important` (classifying nodes as `darkify_style_bg` / `darkify_style_button`
* etc. — see src/assets/css/client_main.css). On our settings screen that
* stomped the app's own theme — most visibly the colour-preset swatches, whose
* inline background was overwritten so every preview looked empty. The React
* admin already themes itself via the `.dark` class, so the engine must leave
* its subtree alone; `.darkify_ignore` / `.darkify_ignore *` is that engine's
* built-in exclusion hook.
*
* `translate="no"` / `.notranslate` opts the subtree out of browser page
* translation (Chrome, Edge). Those translators replace each text node with
* a `` wrapper; React later tries to remove the original node, hits
* "Failed to execute 'removeChild' on 'Node'", and the whole app unmounts.
* Because saving re-renders (spinner, then toast), the crash landed exactly
* on Save and looked like settings failing to save. The panel ships real
* translations instead — see languages/ — so nothing is lost by declining
* the browser's.
*/
public function render(): void
{
echo '';
}
}