. * - 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 ) ) { 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, // 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(), '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'] ) ) ); } // 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 ) ); } }