. * - The activation-triggered redirect to that page. * - The completion flag. * - The environment-check payload + REST routes (`/onboarding/apply`, * `/onboarding/complete`). * * Behavior contract is documented in DESIGN.md §25. * * @package XSpeed */ namespace XSpeed; defined( 'ABSPATH' ) || exit; class Onboarding { const PAGE_SLUG = 'xspeed-onboarding'; const OPT_REDIRECT = 'xspeed_redirect_to_onboarding'; const OPT_COMPLETE = 'xspeed_onboarding_complete'; public function __construct() { add_action( 'admin_menu', array( $this, 'register_menu' ), 20 ); add_action( 'admin_init', array( $this, 'maybe_redirect' ) ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue' ) ); add_action( 'rest_api_init', array( $this, 'register_routes' ) ); } /** * Mark the next admin_init to redirect this user to the wizard. Called * from Plugin::activate(). Idempotent. */ public static function flag_redirect() { update_option( self::OPT_REDIRECT, 1, false ); } public static function is_complete() { return (bool) get_option( self::OPT_COMPLETE, 0 ); } public function register_menu() { // Visible "Setup Wizard" submenu under xSpeed. Stays in the // menu even after onboarding is complete so users can re-run // the wizard any time (a fresh run sets // xspeed_onboarding_complete = false again on completion). // Use the white-label brand in the page so an agency's // rebrand carries through to the wizard tab/title, not just the // dashboard. (FBS white-label-onboarding) $brand = Admin::branding(); /* translators: %s: brand name (xSpeed by default, or the white-label name). */ $page_title = sprintf( __( '%s Setup Wizard', 'xspeed' ), $brand['name'] ); add_submenu_page( Admin::PAGE_SLUG, $page_title, __( 'Setup Wizard', 'xspeed' ), 'manage_options', self::PAGE_SLUG, array( $this, 'render' ) ); } public function render() { // Reuse the dashboard's mount ID so the Tailwind `important` selector // keeps working (utilities are scoped to `#xspeed-app`). The host // class strips the dashboard-only fixed-height/flex shell so the // wizard can lay out as a centered card. See src/styles.css. $dark = 'dark' === Admin::user_theme() ? ' dark' : ''; printf( '<div id="xspeed-app" class="xspeed-root xspeed-onboarding-host%s"></div>', esc_attr( $dark ) ); } /** * Send the user to the wizard on the first admin_init after activation. * Skipped for AJAX/REST/cron, bulk-activate flows, and when the wizard * has already been completed. */ public function maybe_redirect() { if ( ! get_option( self::OPT_REDIRECT ) ) { return; } if ( wp_doing_ajax() || wp_doing_cron() ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing decision; we don't process form data here. if ( isset( $_GET['activate-multi'] ) ) { delete_option( self::OPT_REDIRECT ); return; } if ( ! current_user_can( 'manage_options' ) ) { return; } if ( self::is_complete() ) { delete_option( self::OPT_REDIRECT ); return; } delete_option( self::OPT_REDIRECT ); wp_safe_redirect( admin_url( 'admin.php?page=' . self::PAGE_SLUG ) ); exit; } public function enqueue( $hook ) { unset( $hook ); // Gate on the page SLUG, not the admin hook suffix. WordPress derives // the submenu hook from the *sanitized parent menu title*, so when the // White-Label module renames the menu (e.g. "AcmeSpeed") the hook // becomes "acmespeed_page_xspeed-onboarding" and any check built on // Admin::PAGE_SLUG ("xspeed_page_…") silently stops matching — the // wizard bundle then never enqueues and the page renders blank. // The ?page= slug is brand-independent, so match on that instead. // (FBS-82222) $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen gate, no state change. if ( self::PAGE_SLUG !== $page ) { return; } $asset_js = XSPEED_DIR . 'assets/admin.js'; $asset_css = XSPEED_DIR . 'assets/admin.css'; if ( file_exists( $asset_js ) ) { // filemtime() cache-busts on every rebuild (matches Admin::enqueue) // so a stable version between releases never serves a stale bundle // on the wizard — the connected/login state always reflects the // current build. wp_enqueue_script( 'xspeed-admin', XSPEED_URL . 'assets/admin.js', array( 'wp-api-fetch', 'wp-i18n' ), XSPEED_VERSION . '.' . filemtime( $asset_js ), true ); // Load .mo translations into window.wp.i18n so the wizard's React // __() calls resolve (mirrors Admin::enqueue). Without this the // onboarding strings render untranslated even when a locale exists. if ( function_exists( 'wp_set_script_translations' ) ) { wp_set_script_translations( 'xspeed-admin', 'xspeed', XSPEED_DIR . 'languages' ); } } // Redesign v2 tokens + fonts (see Admin::enqueue) — the wizard shares // them so it matches the dashboard identity. $theme_css = XSPEED_DIR . 'assets/theme.css'; if ( file_exists( $theme_css ) ) { wp_enqueue_style( 'xspeed-theme', XSPEED_URL . 'assets/theme.css', array(), XSPEED_VERSION . '.' . filemtime( $theme_css ) ); } if ( file_exists( $asset_css ) ) { wp_enqueue_style( 'xspeed-admin', XSPEED_URL . 'assets/admin.css', array( 'xspeed-theme' ), XSPEED_VERSION . '.' . filemtime( $asset_css ) ); } // Same type-preserving path as the dashboard — see // Admin::print_config(). wp_localize_script() would stringify every // scalar in this payload too. (#105) Admin::print_config( 'XSpeedConfig', array( 'mode' => 'onboarding', 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'version' => XSPEED_VERSION, // White-label branding must reach the wizard too — without it // the onboarding chrome shows the default "xSpeed" even when an // agency has rebranded. (FBS white-label-onboarding) 'branding' => Admin::branding(), 'dashboardUrl' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ), 'bootstrap' => array( 'settings' => Settings::get(), // Live values for every wizard toggle, so re-running the // wizard reflects the site instead of overwriting it. 'current' => self::current_choices(), 'env' => self::env_payload(), // xSpeed Hub connection snapshot for the wizard's first // "Connect your account" step. Guarded so core onboarding // never hard-depends on the MCP module — if it's absent the // step falls back to its own /mcp/hub fetch (and simply shows // the not-connected invite). See DESIGN.md §24.x. 'hub' => self::hub_payload(), ), ) ); } /** * The site's CURRENT values for every toggle the wizard can write, * in the same shape as the wizard's OnboardingChoices. * * The wizard used to seed its toggles from hard-coded preset constants, * so re-running it on a configured site showed a fiction: options the * admin had deliberately switched on rendered as off, and Apply wrote * that fiction back, silently undoing their configuration. Nothing * warned them, and the completion screen still reported success. * * The wizard couldn't have done better on its own — the bootstrap only * carried Settings::get(), which is just cache_enabled. Every other * toggle lives in a per-module option the payload never included, so * this method is what makes "show the site as it actually is" possible. * * Pure read. Mirrors the keys apply() writes, so the two stay in step. * * @return array<string,bool|int> */ public static function current_choices() { $minify = Settings_Manager::get( 'minify' ); $gzip = Settings_Manager::get( 'gzip' ); $cache = Settings_Manager::get( 'cache' ); $lazy = Settings_Manager::get( 'lazy' ); $browser = Settings_Manager::get( 'browser-cache' ); $hints = Settings_Manager::get( 'resource-hints' ); $settings = Settings::get(); return array( 'cache_enabled' => ! empty( $settings['cache_enabled'] ), 'minify_html' => ! empty( $minify['minify_html'] ), 'minify_css' => ! empty( $minify['minify_css'] ), 'minify_js' => ! empty( $minify['minify_js'] ), 'defer_js' => ! empty( $minify['defer_js'] ), 'gzip_enabled' => ! empty( $gzip['gzip_enabled'] ), 'lazy_images' => ! empty( $lazy['lazy_images'] ), 'browser_cache' => ! empty( $browser['enabled'] ), 'resource_hints' => ! empty( $hints['enabled'] ), 'cache_expiry' => isset( $cache['cache_expiry'] ) ? absint( $cache['cache_expiry'] ) : 24, ); } /** * Environment snapshot rendered as Step 1's health rows. Pure read — * never writes to disk, never makes outbound requests. */ public static function env_payload() { // Single source of truth for environment checks lives in // XSpeed\Health. Both the Onboarding wizard and the Health // module's dashboard panel consume from there. return Health::env_payload(); } /** * xSpeed Hub connection snapshot for the wizard's first step. Identical * shape to the MCP panel's `GET /mcp/hub` response so the Connect step and * the panel's Hub card share one contract. Returns null when the MCP module * is unavailable — the step then renders its own not-connected invite and * re-fetches live from /mcp/hub if the route exists. * * @return array<string,mixed>|null */ public static function hub_payload() { if ( ! class_exists( '\XSpeed\Modules\Mcp\Mcp_Hub' ) ) { return null; } $status = \XSpeed\Modules\Mcp\Mcp_Hub::public_status(); // Override attach_url so the Hub returns the user to the WIZARD (mid-flow) // after they approve — not the default dashboard. The `xspeed_connected` // marker lets the wizard show the connected state + auto-advance on // return. Requires the Hub to honor return_url; if it doesn't yet, the // tab-return reconcile in useHubConnect is the graceful fallback. $return = admin_url( 'admin.php?page=' . self::PAGE_SLUG . '&xspeed_connected=1' ); $status['attach_url'] = \XSpeed\Modules\Mcp\Mcp_Hub::attach_url( $return ); return $status; } public function register_routes() { register_rest_route( Rest_Api::NAMESPACE_V1, '/onboarding/apply', array( 'methods' => 'POST', 'callback' => array( $this, 'apply' ), 'permission_callback' => array( $this, 'permissions' ), ) ); register_rest_route( Rest_Api::NAMESPACE_V1, '/onboarding/complete', array( 'methods' => 'POST', 'callback' => array( $this, 'complete' ), 'permission_callback' => array( $this, 'permissions' ), ) ); register_rest_route( Rest_Api::NAMESPACE_V1, '/onboarding/reset', array( 'methods' => 'POST', 'callback' => array( $this, 'reset' ), 'permission_callback' => array( $this, 'permissions' ), ) ); } public function permissions() { return current_user_can( 'manage_options' ); } /** * Apply the wizard's selected settings + flip the cache drop-in on if * requested. Single REST round-trip so the wizard never lands in a * half-applied state. */ public function apply( \WP_REST_Request $request ) { $params = $request->get_json_params(); if ( ! is_array( $params ) ) { $params = $request->get_params(); } $want_cache = ! empty( $params['cache_enabled'] ); // Per-module settings go through Settings_Manager (the schema- // validated authority for each module). Legacy Settings::update // is reserved for fields still in xspeed_options (cache_expiry, // excluded_urls) until the Cache module migration lands. Settings_Manager::update( 'minify', array( 'minify_html' => ! empty( $params['minify_html'] ), 'minify_css' => ! empty( $params['minify_css'] ), 'minify_js' => ! empty( $params['minify_js'] ), 'defer_js' => ! empty( $params['defer_js'] ), ) ); Settings_Manager::update( 'gzip', array( 'gzip_enabled' => ! empty( $params['gzip_enabled'] ), ) ); Settings_Manager::update( 'cache', array( 'cache_expiry' => isset( $params['cache_expiry'] ) ? absint( $params['cache_expiry'] ) : 24, ) ); // New optional modules surfaced in the wizard — keys are // only updated when present in the payload so legacy // onboarding-complete sites aren't disturbed. if ( array_key_exists( 'lazy_images', $params ) ) { Settings_Manager::update( 'lazy', array( 'lazy_images' => ! empty( $params['lazy_images'] ) ) ); } if ( array_key_exists( 'browser_cache', $params ) ) { Settings_Manager::update( 'browser-cache', array( 'enabled' => ! empty( $params['browser_cache'] ) ) ); } if ( array_key_exists( 'resource_hints', $params ) ) { Settings_Manager::update( 'resource-hints', array( 'enabled' => ! empty( $params['resource_hints'] ) ) ); } // Opt-in usage analytics. Only acted on when the key is present in the // payload (legacy onboarding-complete sites are left untouched). The // consent toggle defaults OFF in the wizard, so the common path is // usage_tracking=false → tracker stays dormant, no outbound HTTP. if ( array_key_exists( 'usage_tracking', $params ) ) { $tracker = Plugin::instance()->usage_tracker(); if ( $tracker ) { $tracker->opt_in( ! empty( $params['usage_tracking'] ) ); } } $install_state = Cache::toggle( $want_cache ); Settings::update( array( 'cache_enabled' => $install_state['enabled'] ) ); // Recompute the unified nginx block AFTER cache_enabled is persisted. // Cache::toggle() computes it inline, before the Settings::update() // above writes cache_enabled — so the block in $install_state reflects // the PRE-apply state (CacheModule::nginx_directives() gates on // cache_enabled). Regenerate so the wizard's Done step shows the // snippet for the configuration the user just applied. Mirrors the // same fix in Rest_Api::toggle_cache(). $install_state['nginx_server_block'] = Cache::full_nginx_server_block(); return rest_ensure_response( array( 'settings' => Settings::get(), 'install_state' => $install_state, ) ); } public function complete() { update_option( self::OPT_COMPLETE, 1, false ); return rest_ensure_response( array( 'ok' => true ) ); } public function reset() { delete_option( self::OPT_COMPLETE ); return rest_ensure_response( array( 'ok' => true ) ); } }