# xspeed/1.0.6/includes/class-onboarding.php

xSpeed Cache: AI-Powered Performance Hub with MCP, Caching &amp; CDN, version 1.0.6. 307 lines.

- Page: https://pluginprobe.com/plugins/xspeed/1.0.6/code/includes/class-onboarding.php
- Raw: https://pluginprobe.com/plugins/xspeed/1.0.6/raw/includes/class-onboarding.php
- Modified: 2026-06-25T12:53:32+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/xspeed/1.0.6/code/includes/class-onboarding.php#L10-L20`.

```php
<?php
/**
 * First-run onboarding wizard.
 *
 * Owns:
 *   - The hidden submenu page (`xspeed-onboarding`) and its single root <div>.
 *   - 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 <title> 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 ) );
	}
}

```
