| @@ -47,11 +47,17 @@ | ||
| 47 | 47 | // Visible "Setup Wizard" submenu under xSpeed. Stays in the |
| 48 | 48 | // menu even after onboarding is complete so users can re-run |
| 49 | 49 | // the wizard any time (a fresh run sets |
| 50 | 50 | // xspeed_onboarding_complete = false again on completion). |
| 51 | + // Use the white-label brand in the page <title> so an agency's | |
| 52 | + // rebrand carries through to the wizard tab/title, not just the | |
| 53 | + // dashboard. (FBS white-label-onboarding) | |
| 54 | + $brand = Admin::branding(); | |
| 55 | + /* translators: %s: brand name (xSpeed by default, or the white-label name). */ | |
| 56 | + $page_title = sprintf( __( '%s Setup Wizard', 'xspeed' ), $brand['name'] ); | |
| 51 | 57 | add_submenu_page( |
| 52 | 58 | Admin::PAGE_SLUG, |
| 53 | - __( 'xSpeed Setup Wizard', 'xspeed' ), | |
| 59 | + $page_title, | |
| 54 | 60 | __( 'Setup Wizard', 'xspeed' ), |
| 55 | 61 | 'manage_options', |
| 56 | 62 | self::PAGE_SLUG, |
| 57 | 63 | array( $this, 'render' ) |
| @@ -101,18 +107,19 @@ | ||
| 101 | 107 | exit; |
| 102 | 108 | } |
| 103 | 109 | |
| 104 | 110 | public function enqueue( $hook ) { |
| 105 | - // `add_submenu_page` registers this page's hook suffix as | |
| 106 | - // "{parent_slug}_page_{slug}", but because we immediately call | |
| 107 | - // `remove_submenu_page`, WordPress still loads it via the toplevel | |
| 108 | - // page hook when the URL is hit directly. Match both shapes. | |
| 109 | - $wanted = array( | |
| 110 | - 'admin_page_' . self::PAGE_SLUG, | |
| 111 | - Admin::PAGE_SLUG . '_page_' . self::PAGE_SLUG, | |
| 112 | - 'toplevel_page_' . self::PAGE_SLUG, | |
| 113 | - ); | |
| 114 | - if ( ! in_array( $hook, $wanted, true ) ) { | |
| 111 | + unset( $hook ); | |
| 112 | + // Gate on the page SLUG, not the admin hook suffix. WordPress derives | |
| 113 | + // the submenu hook from the *sanitized parent menu title*, so when the | |
| 114 | + // White-Label module renames the menu (e.g. "AcmeSpeed") the hook | |
| 115 | + // becomes "acmespeed_page_xspeed-onboarding" and any check built on | |
| 116 | + // Admin::PAGE_SLUG ("xspeed_page_…") silently stops matching — the | |
| 117 | + // wizard bundle then never enqueues and the page renders blank. | |
| 118 | + // The ?page= slug is brand-independent, so match on that instead. | |
| 119 | + // (FBS-82222) | |
| 120 | + $page = isset( $_GET['page'] ) ? sanitize_key( wp_unslash( $_GET['page'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only screen gate, no state change. | |
| 121 | + if ( self::PAGE_SLUG !== $page ) { | |
| 115 | 122 | return; |
| 116 | 123 | } |
| 117 | 124 | |
| 118 | 125 | $asset_js = XSPEED_DIR . 'assets/admin.js'; |
| @@ -118,27 +125,50 @@ | ||
| 118 | 125 | $asset_js = XSPEED_DIR . 'assets/admin.js'; |
| 119 | 126 | $asset_css = XSPEED_DIR . 'assets/admin.css'; |
| 120 | 127 | |
| 121 | 128 | if ( file_exists( $asset_js ) ) { |
| 129 | + // filemtime() cache-busts on every rebuild (matches Admin::enqueue) | |
| 130 | + // so a stable version between releases never serves a stale bundle | |
| 131 | + // on the wizard — the connected/login state always reflects the | |
| 132 | + // current build. | |
| 122 | 133 | wp_enqueue_script( |
| 123 | 134 | 'xspeed-admin', |
| 124 | 135 | XSPEED_URL . 'assets/admin.js', |
| 125 | - array( 'wp-api-fetch' ), | |
| 126 | - XSPEED_VERSION, | |
| 136 | + array( 'wp-api-fetch', 'wp-i18n' ), | |
| 137 | + XSPEED_VERSION . '.' . filemtime( $asset_js ), | |
| 127 | 138 | true |
| 128 | 139 | ); |
| 140 | + // Load .mo translations into window.wp.i18n so the wizard's React | |
| 141 | + // __() calls resolve (mirrors Admin::enqueue). Without this the | |
| 142 | + // onboarding strings render untranslated even when a locale exists. | |
| 143 | + if ( function_exists( 'wp_set_script_translations' ) ) { | |
| 144 | + wp_set_script_translations( 'xspeed-admin', 'xspeed', XSPEED_DIR . 'languages' ); | |
| 145 | + } | |
| 129 | 146 | } |
| 147 | + // Redesign v2 tokens + fonts (see Admin::enqueue) — the wizard shares | |
| 148 | + // them so it matches the dashboard identity. | |
| 149 | + $theme_css = XSPEED_DIR . 'assets/theme.css'; | |
| 150 | + if ( file_exists( $theme_css ) ) { | |
| 151 | + wp_enqueue_style( | |
| 152 | + 'xspeed-theme', | |
| 153 | + XSPEED_URL . 'assets/theme.css', | |
| 154 | + array(), | |
| 155 | + XSPEED_VERSION . '.' . filemtime( $theme_css ) | |
| 156 | + ); | |
| 157 | + } | |
| 130 | 158 | if ( file_exists( $asset_css ) ) { |
| 131 | 159 | wp_enqueue_style( |
| 132 | 160 | 'xspeed-admin', |
| 133 | 161 | XSPEED_URL . 'assets/admin.css', |
| 134 | - array(), | |
| 135 | - XSPEED_VERSION | |
| 162 | + array( 'xspeed-theme' ), | |
| 163 | + XSPEED_VERSION . '.' . filemtime( $asset_css ) | |
| 136 | 164 | ); |
| 137 | 165 | } |
| 138 | 166 | |
| 139 | - wp_localize_script( | |
| 140 | - 'xspeed-admin', | |
| 167 | + // Same type-preserving path as the dashboard — see | |
| 168 | + // Admin::print_config(). wp_localize_script() would stringify every | |
| 169 | + // scalar in this payload too. (#105) | |
| 170 | + Admin::print_config( | |
| 141 | 171 | 'XSpeedConfig', |
| 142 | 172 | array( |
| 143 | 173 | 'mode' => 'onboarding', |
| 144 | 174 | 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ), |
| @@ -143,12 +173,25 @@ | ||
| 143 | 173 | 'mode' => 'onboarding', |
| 144 | 174 | 'restUrl' => esc_url_raw( rest_url( Rest_Api::NAMESPACE_V1 ) ), |
| 145 | 175 | 'nonce' => wp_create_nonce( 'wp_rest' ), |
| 146 | 176 | 'version' => XSPEED_VERSION, |
| 177 | + // White-label branding must reach the wizard too — without it | |
| 178 | + // the onboarding chrome shows the default "xSpeed" even when an | |
| 179 | + // agency has rebranded. (FBS white-label-onboarding) | |
| 180 | + 'branding' => Admin::branding(), | |
| 147 | 181 | 'dashboardUrl' => admin_url( 'admin.php?page=' . Admin::PAGE_SLUG ), |
| 148 | 182 | 'bootstrap' => array( |
| 149 | 183 | 'settings' => Settings::get(), |
| 184 | + // Live values for every wizard toggle, so re-running the | |
| 185 | + // wizard reflects the site instead of overwriting it. | |
| 186 | + 'current' => self::current_choices(), | |
| 150 | 187 | 'env' => self::env_payload(), |
| 188 | + // xSpeed Hub connection snapshot for the wizard's first | |
| 189 | + // "Connect your account" step. Guarded so core onboarding | |
| 190 | + // never hard-depends on the MCP module — if it's absent the | |
| 191 | + // step falls back to its own /mcp/hub fetch (and simply shows | |
| 192 | + // the not-connected invite). See DESIGN.md §24.x. | |
| 193 | + 'hub' => self::hub_payload(), | |
| 151 | 194 | ), |
| 152 | 195 | ) |
| 153 | 196 | ); |
| 154 | 197 | } |
| @@ -153,8 +196,50 @@ | ||
| 153 | 196 | ); |
| 154 | 197 | } |
| 155 | 198 | |
| 156 | 199 | /** |
| 200 | + * The site's CURRENT values for every toggle the wizard can write, | |
| 201 | + * in the same shape as the wizard's OnboardingChoices. | |
| 202 | + * | |
| 203 | + * The wizard used to seed its toggles from hard-coded preset constants, | |
| 204 | + * so re-running it on a configured site showed a fiction: options the | |
| 205 | + * admin had deliberately switched on rendered as off, and Apply wrote | |
| 206 | + * that fiction back, silently undoing their configuration. Nothing | |
| 207 | + * warned them, and the completion screen still reported success. | |
| 208 | + * | |
| 209 | + * The wizard couldn't have done better on its own — the bootstrap only | |
| 210 | + * carried Settings::get(), which is just cache_enabled. Every other | |
| 211 | + * toggle lives in a per-module option the payload never included, so | |
| 212 | + * this method is what makes "show the site as it actually is" possible. | |
| 213 | + * | |
| 214 | + * Pure read. Mirrors the keys apply() writes, so the two stay in step. | |
| 215 | + * | |
| 216 | + * @return array<string,bool|int> | |
| 217 | + */ | |
| 218 | + public static function current_choices() { | |
| 219 | + $minify = Settings_Manager::get( 'minify' ); | |
| 220 | + $gzip = Settings_Manager::get( 'gzip' ); | |
| 221 | + $cache = Settings_Manager::get( 'cache' ); | |
| 222 | + $lazy = Settings_Manager::get( 'lazy' ); | |
| 223 | + $browser = Settings_Manager::get( 'browser-cache' ); | |
| 224 | + $hints = Settings_Manager::get( 'resource-hints' ); | |
| 225 | + $settings = Settings::get(); | |
| 226 | + | |
| 227 | + return array( | |
| 228 | + 'cache_enabled' => ! empty( $settings['cache_enabled'] ), | |
| 229 | + 'minify_html' => ! empty( $minify['minify_html'] ), | |
| 230 | + 'minify_css' => ! empty( $minify['minify_css'] ), | |
| 231 | + 'minify_js' => ! empty( $minify['minify_js'] ), | |
| 232 | + 'defer_js' => ! empty( $minify['defer_js'] ), | |
| 233 | + 'gzip_enabled' => ! empty( $gzip['gzip_enabled'] ), | |
| 234 | + 'lazy_images' => ! empty( $lazy['lazy_images'] ), | |
| 235 | + 'browser_cache' => ! empty( $browser['enabled'] ), | |
| 236 | + 'resource_hints' => ! empty( $hints['enabled'] ), | |
| 237 | + 'cache_expiry' => isset( $cache['cache_expiry'] ) ? absint( $cache['cache_expiry'] ) : \XSpeed\Modules\Cache\CacheModule::DEFAULT_EXPIRY_HOURS, | |
| 238 | + ); | |
| 239 | + } | |
| 240 | + | |
| 241 | + /** | |
| 157 | 242 | * Environment snapshot rendered as Step 1's health rows. Pure read — |
| 158 | 243 | * never writes to disk, never makes outbound requests. |
| 159 | 244 | */ |
| 160 | 245 | public static function env_payload() { |
| @@ -163,8 +248,32 @@ | ||
| 163 | 248 | // module's dashboard panel consume from there. |
| 164 | 249 | return Health::env_payload(); |
| 165 | 250 | } |
| 166 | 251 | |
| 252 | + /** | |
| 253 | + * xSpeed Hub connection snapshot for the wizard's first step. Identical | |
| 254 | + * shape to the MCP panel's `GET /mcp/hub` response so the Connect step and | |
| 255 | + * the panel's Hub card share one contract. Returns null when the MCP module | |
| 256 | + * is unavailable — the step then renders its own not-connected invite and | |
| 257 | + * re-fetches live from /mcp/hub if the route exists. | |
| 258 | + * | |
| 259 | + * @return array<string,mixed>|null | |
| 260 | + */ | |
| 261 | + public static function hub_payload() { | |
| 262 | + if ( ! class_exists( '\XSpeed\Modules\Mcp\Mcp_Hub' ) ) { | |
| 263 | + return null; | |
| 264 | + } | |
| 265 | + $status = \XSpeed\Modules\Mcp\Mcp_Hub::public_status(); | |
| 266 | + // Override attach_url so the Hub returns the user to the WIZARD (mid-flow) | |
| 267 | + // after they approve — not the default dashboard. The `xspeed_connected` | |
| 268 | + // marker lets the wizard show the connected state + auto-advance on | |
| 269 | + // return. Requires the Hub to honor return_url; if it doesn't yet, the | |
| 270 | + // tab-return reconcile in useHubConnect is the graceful fallback. | |
| 271 | + $return = admin_url( 'admin.php?page=' . self::PAGE_SLUG . '&xspeed_connected=1' ); | |
| 272 | + $status['attach_url'] = \XSpeed\Modules\Mcp\Mcp_Hub::attach_url( $return ); | |
| 273 | + return $status; | |
| 274 | + } | |
| 275 | + | |
| 167 | 276 | public function register_routes() { |
| 168 | 277 | register_rest_route( |
| 169 | 278 | Rest_Api::NAMESPACE_V1, |
| 170 | 279 | '/onboarding/apply', |
| @@ -250,11 +359,40 @@ | ||
| 250 | 359 | 'browser-cache', |
| 251 | 360 | array( 'enabled' => ! empty( $params['browser_cache'] ) ) |
| 252 | 361 | ); |
| 253 | 362 | } |
| 363 | + if ( array_key_exists( 'resource_hints', $params ) ) { | |
| 364 | + Settings_Manager::update( | |
| 365 | + 'resource-hints', | |
| 366 | + array( 'enabled' => ! empty( $params['resource_hints'] ) ) | |
| 367 | + ); | |
| 368 | + } | |
| 254 | 369 | |
| 370 | + // Opt-in usage analytics. Only acted on when the key is present in the | |
| 371 | + // payload (legacy onboarding-complete sites are left untouched). The | |
| 372 | + // consent toggle defaults OFF in the wizard, so the common path is | |
| 373 | + // usage_tracking=false → tracker stays dormant, no outbound HTTP. | |
| 374 | + // (True since #437; before that the toggle shipped pre-checked and | |
| 375 | + // this comment described an intent the UI did not implement. The | |
| 376 | + // same consent is now also writable from Settings → Privacy & usage | |
| 377 | + // data, via PrivacyModule, which routes through the same opt_in().) | |
| 378 | + if ( array_key_exists( 'usage_tracking', $params ) ) { | |
| 379 | + $tracker = Plugin::instance()->usage_tracker(); | |
| 380 | + if ( $tracker ) { | |
| 381 | + $tracker->opt_in( ! empty( $params['usage_tracking'] ) ); | |
| 382 | + } | |
| 383 | + } | |
| 384 | + | |
| 255 | 385 | $install_state = Cache::toggle( $want_cache ); |
| 256 | - Settings::update( array( 'cache_enabled' => $install_state['enabled'] ) ); | |
| 386 | + | |
| 387 | + // Recompute the unified nginx block AFTER cache_enabled is persisted. | |
| 388 | + // Cache::toggle() computes it inline, before the Settings::update() | |
| 389 | + // above writes cache_enabled — so the block in $install_state reflects | |
| 390 | + // the PRE-apply state (CacheModule::nginx_directives() gates on | |
| 391 | + // cache_enabled). Regenerate so the wizard's Done step shows the | |
| 392 | + // snippet for the configuration the user just applied. Mirrors the | |
| 393 | + // same fix in Rest_Api::toggle_cache(). | |
| 394 | + $install_state['nginx_server_block'] = Cache::full_nginx_server_block(); | |
| 257 | 395 | |
| 258 | 396 | return rest_ensure_response( |
| 259 | 397 | array( |
| 260 | 398 | 'settings' => Settings::get(), |