. * - 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). add_submenu_page( Admin::PAGE_SLUG, __( 'xSpeed Setup Wizard', 'xspeed' ), __( '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( '
', 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 ) { // `add_submenu_page` registers this page's hook suffix as // "{parent_slug}_page_{slug}", but because we immediately call // `remove_submenu_page`, WordPress still loads it via the toplevel // page hook when the URL is hit directly. Match both shapes. $wanted = array( 'admin_page_' . self::PAGE_SLUG, Admin::PAGE_SLUG . '_page_' . self::PAGE_SLUG, 'toplevel_page_' . self::PAGE_SLUG, ); if ( ! in_array( $hook, $wanted, true ) ) { return; } $asset_js = XSPEED_DIR . 'assets/admin.js'; $asset_css = XSPEED_DIR . 'assets/admin.css'; if ( file_exists( $asset_js ) ) { wp_enqueue_script( 'xspeed-admin', XSPEED_URL . 'assets/admin.js', array( 'wp-api-fetch' ), XSPEED_VERSION, true ); } if ( file_exists( $asset_css ) ) { wp_enqueue_style( 'xspeed-admin', XSPEED_URL . 'assets/admin.css', array(), XSPEED_VERSION ); } wp_localize_script( 'xspeed-admin', 'XSpeedConfig', array( 'mode' => 'onboarding', 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), 'version' => XSPEED_VERSION, 'dashboardUrl' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ), 'bootstrap' => array( 'settings' => Settings::get(), 'env' => self::env_payload(), ), ) ); } /** * 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(); } 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'] ) ) ); } $install_state = Cache::toggle( $want_cache ); Settings::update( array( 'cache_enabled' => $install_state['enabled'] ) ); 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 ) ); } }