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