PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.3-beta
16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 All 507 releases
← All changes | _inc/lib/admin-pages/class-jetpack-ai-page.php +653 -0 16.2-beta → 16.3-beta View file →
@@ -1,0 +1,653 @@
1 +<?php
2 +/**
3 + * Jetpack AI admin page.
4 + *
5 + * Registers the "AI" submenu item under Jetpack and mounts the React-based
6 + * MCP settings interface.
7 + *
8 + * @package automattic/jetpack
9 + */
10 +
11 +use Automattic\Jetpack\Admin_UI\Admin_Menu;
12 +use Automattic\Jetpack\Agents_Manager\Agents_Manager;
13 +use Automattic\Jetpack\Connection\Initial_State as Connection_Initial_State;
14 +use Automattic\Jetpack\Connection\Manager as Connection_Manager;
15 +use Automattic\Jetpack\Feature_Flags\Feature_Flags;
16 +use Automattic\Jetpack\Modules;
17 +use Automattic\Jetpack\Redirect;
18 +use Automattic\Jetpack\Status;
19 +use Automattic\Jetpack\Status\Host;
20 +use Automattic\Jetpack\Terms_Of_Service;
21 +use Automattic\Jetpack\Tracking;
22 +
23 +if ( ! defined( 'ABSPATH' ) ) {
24 + exit( 0 );
25 +}
26 +
27 +require_once dirname( __DIR__ ) . '/class-jetpack-ai-feature-flags.php';
28 +require_once dirname( __DIR__ ) . '/class-jetpack-ai-settings.php';
29 +require_once __DIR__ . '/class-jetpack-wp-build-page.php';
30 +
31 +/**
32 + * Builds the Jetpack AI admin page and its sidebar menu entry.
33 + */
34 +class Jetpack_AI_Page {
35 +
36 + /**
37 + * The wp-build route's page id, which must not be the `jetpack-ai` menu slug.
38 + *
39 + * @var string
40 + */
41 + const WP_BUILD_PAGE_ID = 'jetpack-ai-hub';
42 +
43 + /**
44 + * Whether this request renders through wp-build.
45 + *
46 + * Checks the render function too: if the build output is missing, the request would
47 + * otherwise get no bundle at all now that the legacy entry is gone.
48 + *
49 + * @since 16.3
50 + *
51 + * @return bool
52 + */
53 + public static function should_render_wp_build() {
54 + return function_exists( 'jetpack_plugin_jetpack_ai_hub_wp_admin_render_page' );
55 + }
56 +
57 + /**
58 + * Whether the current request targets the AI Hub admin page.
59 + *
60 + * @since 16.3
61 + *
62 + * @return bool
63 + */
64 + private static function is_ai_admin_request() {
65 + if ( ! is_admin() ) {
66 + return false;
67 + }
68 +
69 + // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading the page slug only.
70 + return isset( $_GET['page'] ) && 'jetpack-ai' === sanitize_text_field( wp_unslash( $_GET['page'] ) );
71 + }
72 +
73 + /**
74 + * Load wp-build for the AI Hub page.
75 + *
76 + * @since 16.3
77 + *
78 + * @return void
79 + */
80 + public static function maybe_load_wp_build() {
81 + if ( self::is_ai_admin_request() ) {
82 + Jetpack_WP_Build_Page::load( self::WP_BUILD_PAGE_ID );
83 + }
84 + }
85 +
86 + /**
87 + * Register the page and its page-specific hooks.
88 + *
89 + * The AI Hub owns its full React layout, so it does not need the legacy
90 + * Jetpack_Admin_Page lifecycle. Keeping this controller independent also
91 + * lets WordPress.com Simple load the same page without replacing its
92 + * request-wide Jetpack_Admin_Page compatibility stub.
93 + */
94 + public function add_actions() {
95 + $is_offline_mode = ( new Status() )->is_offline_mode();
96 +
97 + if ( ! current_user_can( 'manage_options' ) && ( $is_offline_mode || ! Jetpack::is_connection_ready() ) ) {
98 + return;
99 + }
100 +
101 + if ( ! Jetpack::is_connection_ready() && ! $is_offline_mode ) {
102 + return;
103 + }
104 +
105 + $hook = $this->get_page_hook();
106 + if ( ! $hook ) {
107 + return;
108 + }
109 +
110 + add_action( 'admin_print_scripts-' . $hook, array( $this, 'page_admin_scripts' ) );
111 +
112 + // Preserve the standalone Jetpack page's existing base stylesheet. Simple
113 + // never loaded it for the Hub because it conflicts with wpcom admin pages.
114 + if ( ! ( new Host() )->is_wpcom_simple() ) {
115 + add_action( 'admin_print_styles-' . $hook, array( $this, 'admin_styles' ) );
116 + }
117 +
118 + $this->add_page_actions( $hook );
119 + }
120 +
121 + /**
122 + * Register the "AI" submenu under the Jetpack top-level menu.
123 + *
124 + * @return string|false Hook returned by Admin_Menu::add_menu().
125 + */
126 + public function get_page_hook() {
127 + return Admin_Menu::add_menu(
128 + // "Jetpack AI" is a product name and should not be translated.
129 + 'Jetpack AI',
130 + 'Jetpack AI',
131 + 'manage_options',
132 + 'jetpack-ai',
133 + array( $this, 'render' ),
134 + null,
135 + // No product gate: the Hub also holds the MCP and Connectors tab, which works with AI off.
136 + array( 'key' => 'jetpack-ai' )
137 + );
138 + }
139 +
140 + /**
141 + * Attach page-specific actions.
142 + *
143 + * @param string $hook The page hook returned by get_page_hook().
144 + */
145 + public function add_page_actions( $hook ) {
146 + add_action( 'load-' . $hook, array( $this, 'load_agents_manager' ) );
147 + }
148 +
149 + /**
150 + * Enqueue the stylesheet historically supplied by Jetpack_Admin_Page.
151 + */
152 + public function admin_styles() {
153 + $min = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min';
154 +
155 + wp_enqueue_style( 'jetpack-admin', plugins_url( "css/jetpack-admin{$min}.css", JETPACK__PLUGIN_FILE ), array( 'genericons', 'jetpack-connection' ), JETPACK__VERSION . '-20121016' );
156 + wp_style_add_data( 'jetpack-admin', 'rtl', 'replace' );
157 + wp_style_add_data( 'jetpack-admin', 'suffix', $min );
158 + }
159 +
160 + /**
161 + * Request the existing Agents Manager shell for this page.
162 + */
163 + public function load_agents_manager() {
164 + if ( ! self::is_scheduled_tasks_enabled() ) {
165 + return;
166 + }
167 +
168 + Agents_Manager::init();
169 +
170 + add_filter( 'agents_manager_should_load', '__return_true' );
171 + add_filter( 'agents_manager_agent_id', array( $this, 'get_agents_manager_agent_id' ) );
172 + add_filter( 'agents_manager_agent_providers', array( $this, 'add_scheduled_tasks_provider' ) );
173 + add_filter( 'jetpack_ai_sidebar_agents_manager_data', array( $this, 'add_scheduled_tasks_data' ) );
174 + }
175 +
176 + /**
177 + * Use the generic WP Orchestrator agent in AI Hub.
178 + *
179 + * @return string Agent ID.
180 + */
181 + public function get_agents_manager_agent_id() {
182 + return 'wp-orchestrator';
183 + }
184 +
185 + /**
186 + * Add the AI Hub provider that supplies scheduled task starter prompts.
187 + *
188 + * @param array $providers Existing provider module URLs.
189 + * @return array Updated provider module URLs.
190 + */
191 + public function add_scheduled_tasks_provider( $providers ) {
192 + $providers[] = add_query_arg(
193 + 'ver',
194 + JETPACK__VERSION,
195 + plugins_url( '_inc/jetpack-ai-scheduled-tasks-provider.js', JETPACK__PLUGIN_FILE )
196 + );
197 +
198 + return $providers;
199 + }
200 +
201 + /**
202 + * Customize Agents Manager's empty view for the Scheduled tasks page.
203 + *
204 + * @param array $data Existing Agents Manager data.
205 + * @return array Updated Agents Manager data.
206 + */
207 + public function add_scheduled_tasks_data( $data ) {
208 + $current_user = wp_get_current_user();
209 +
210 + $data['emptyViewHeading'] = sprintf(
211 + /* translators: %s: Current user's display name. */
212 + __( 'Howdy %s! Let’s schedule a task.', 'jetpack' ),
213 + $current_user->display_name
214 + );
215 + $data['emptyViewHelp'] = __( 'Got a different request? Ask away.', 'jetpack' );
216 + $data['scheduledTaskEmptyViewSuggestions'] = array(
217 + array(
218 + 'id' => 'create-daily-reminder',
219 + 'label' => __( 'Create a daily reminder', 'jetpack' ),
220 + 'prompt' => __( 'Create a daily reminder', 'jetpack' ),
221 + 'autoSubmit' => true,
222 + ),
223 + array(
224 + 'id' => 'draft-weekly-post',
225 + 'label' => __( 'Draft a weekly post', 'jetpack' ),
226 + 'prompt' => __( 'Draft a weekly post', 'jetpack' ),
227 + 'autoSubmit' => true,
228 + ),
229 + array(
230 + 'id' => 'schedule-monthly-report',
231 + 'label' => __( 'Schedule a monthly report', 'jetpack' ),
232 + 'prompt' => __( 'Schedule a monthly report', 'jetpack' ),
233 + 'autoSubmit' => true,
234 + ),
235 + );
236 +
237 + return $data;
238 + }
239 +
240 + /**
241 + * Whether the Scheduled tasks tab and its Agents Manager sidebar are enabled.
242 + *
243 + * @since 16.2
244 + *
245 + * @return bool
246 + */
247 + private static function is_scheduled_tasks_enabled() {
248 + return Feature_Flags::is_enabled( Jetpack_AI_Feature_Flags::SCHEDULED_TASKS );
249 + }
250 +
251 + /**
252 + * Enqueue scripts and styles for the AI admin page.
253 + */
254 + public function page_admin_scripts() {
255 + // wp-build owns the route bundle and its dependencies; this handle only carries the
256 + // inline settings below, so the plugin version is version enough to bust its cache.
257 + $script_version = JETPACK__VERSION;
258 +
259 + $blog_id = Connection_Manager::get_site_id( true );
260 + $status = new Status();
261 + $site_suffix = $status->get_site_suffix();
262 + // Use the plain hostname for the Atomic activity log URL — get_site_suffix() can
263 + // include '::' for subdirectory installs, which would break the URL. This matches
264 + // the approach used by jetpack-mu-wpcom for the sidebar Activity Log link.
265 + $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
266 + $activity_log_site = ( is_string( $site_host ) && '' !== $site_host ) ? $site_host : $site_suffix;
267 +
268 + /*
269 + * On Atomic link to WPCOM activity log; on self-hosted link to the local
270 + * wp-admin page, which only exists while the `activity-log` module is on.
271 + * An empty URL hides the row rather than linking to an unregistered page.
272 + */
273 + if ( ( new Host() )->is_woa_site() ) {
274 + $activity_log_url = 'https://wordpress.com/activity-log/' . $activity_log_site;
275 + } elseif ( ( new Modules() )->is_active( 'activity-log' ) ) {
276 + $activity_log_url = admin_url( 'admin.php?page=jetpack-activity-log' );
277 + } else {
278 + $activity_log_url = '';
279 + }
280 +
281 + /*
282 + * Link SEO settings to the dedicated Jetpack SEO page where it exists,
283 + * falling back to the Traffic settings card. Checking the `rsm_jetpack_seo`
284 + * filter is required in addition to the cohort check: is_seo_surface_visible()
285 + * alone returns true on all of wpcom-platform even while the flag is off —
286 + * it answers only the cohort half, and page registration requires both
287 + * (see packages/seo Initializer::init()).
288 + */
289 + $seo_settings_url = admin_url( 'admin.php?page=jetpack-settings#/traffic' );
290 + $is_internal_test = jetpack_is_internal_testing_environment();
291 + $show_scheduled_tasks_view = self::is_scheduled_tasks_enabled();
292 + if (
293 + // The exact-symbol guard matters: the autoloader can select an older
294 + // jetpack-seo copy from another plugin that has the class but not
295 + // this method, and class_exists alone would then fatal here.
296 + method_exists( '\Automattic\Jetpack\SEO\Initializer', 'is_seo_surface_visible' )
297 + && (bool) apply_filters( 'rsm_jetpack_seo', false )
298 + && \Automattic\Jetpack\SEO\Initializer::is_seo_surface_visible()
299 + ) {
300 + $seo_settings_url = admin_url( 'admin.php?page=jetpack-seo' );
301 + }
302 +
303 + // The route bundle is registered by wp-build; this handle exists only to carry the
304 + // inline settings below, which the app reads from `window.jetpackAiSettings`.
305 + wp_register_script( 'jetpack-ai-admin', false, array(), $script_version, true );
306 + wp_enqueue_script( 'jetpack-ai-admin' );
307 +
308 + // The Tracks sender (w.js); without it, queued events never leave the
309 + // browser. Consent-gated like the other surfaces that load it.
310 + $can_send_tracks = ( new Tracking( 'jetpack', new Connection_Manager() ) )->should_enable_tracking( new Terms_Of_Service(), $status );
311 + if ( $can_send_tracks ) {
312 + Tracking::register_tracks_functions_scripts( true );
313 + }
314 +
315 + // Unconditional, as on the other Jetpack admin pages: the connection store
316 + // reads it, and only Scheduled tasks used to need it here.
317 + Connection_Initial_State::render_script( 'jetpack-ai-admin' );
318 +
319 + $host = new Host();
320 + $has_my_jetpack = self::has_my_jetpack();
321 + $is_offline_mode = $status->is_offline_mode();
322 +
323 + /**
324 + * Filters the host-specific AI Hub configuration.
325 + *
326 + * @since 16.2
327 + *
328 + * @param array $config AI Hub host configuration.
329 + */
330 + $config = apply_filters(
331 + 'jetpack_ai_admin_config',
332 + array(
333 + // The Overview and Features views launch on non-VIP self-hosted sites first.
334 + // Keep this filterable so hosts can close them independently.
335 + 'showGatedViews' => ! $host->is_vip_site()
336 + && ( ! $host->is_wpcom_platform() || ( $host->is_woa_site() && $is_internal_test ) ),
337 + 'showA12sBadge' => $host->is_woa_site() && $is_internal_test,
338 + // The same verdicts the feature-settings endpoint reports. That call
339 + // exists for the AI Features toggles; the notice must not wait on it.
340 + 'isUserConnected' => Jetpack_AI_Settings::user_is_connected(),
341 + 'isConnected' => Jetpack_AI_Settings::site_is_connected(),
342 + 'hostAllowsAi' => Jetpack_AI_Settings::host_allows_ai(),
343 + 'masterEnabled' => Jetpack_AI_Settings::is_master_enabled(),
344 + // The route, not a flag: each one documents a different hook.
345 + 'masterForcedOff' => Jetpack_AI_Settings::get_master_forced_off_route(),
346 + 'isOfflineMode' => $is_offline_mode,
347 + 'canConnectSite' => current_user_can( 'jetpack_connect' ),
348 + // These three answer one question; a filter changing one alone leaves
349 + // a label pointing at a page that is not there.
350 + 'hasMyJetpack' => $has_my_jetpack,
351 + 'userConnectionUrl' => $has_my_jetpack
352 + ? 'admin.php?page=my-jetpack#/connection'
353 + : 'admin.php?page=jetpack-settings#/connect-user',
354 + 'manageUrl' => $has_my_jetpack
355 + ? 'admin.php?page=my-jetpack#/products'
356 + : 'admin.php?page=jetpack_modules',
357 + 'mcpSettingsApi' => array(
358 + 'path' => '/wpcom/v2/jetpack-ai/mcp-settings',
359 + 'format' => 'jetpack',
360 + ),
361 + )
362 + );
363 +
364 + $show_gated_views = ! empty( $config['showGatedViews'] );
365 +
366 + $plan_info = $show_gated_views ? self::get_ai_plan_info() : array( 'name' => '' );
367 +
368 + $settings = array(
369 + 'blogId' => $blog_id ? (int) $blog_id : 0,
370 + 'activityLogUrl' => $activity_log_url,
371 + 'seoSettingsUrl' => $seo_settings_url,
372 + 'searchSettingsUrl' => self::get_search_settings_url(),
373 + 'siteAdminUrl' => admin_url(),
374 + 'userConnectionUrl' => esc_url_raw( $config['userConnectionUrl'] ?? '' ),
375 + 'manageUrl' => esc_url_raw( $config['manageUrl'] ?? '' ),
376 + 'hasMyJetpack' => ! empty( $config['hasMyJetpack'] ),
377 + 'isConnected' => ! empty( $config['isConnected'] ),
378 + 'hostAllowsAi' => ! empty( $config['hostAllowsAi'] ),
379 + 'masterEnabled' => ! empty( $config['masterEnabled'] ),
380 + 'masterForcedOff' => in_array(
381 + $config['masterForcedOff'] ?? '',
382 + array(
383 + Jetpack_AI_Settings::FORCED_OFF_ROUTE_FILTER,
384 + Jetpack_AI_Settings::FORCED_OFF_ROUTE_FILTER_VIP,
385 + Jetpack_AI_Settings::FORCED_OFF_ROUTE_MODULES,
386 + ),
387 + true
388 + ) ? $config['masterForcedOff'] : '',
389 + 'isOfflineMode' => ! empty( $config['isOfflineMode'] ),
390 + 'canConnectSite' => ! empty( $config['canConnectSite'] ),
391 + 'apiRoot' => esc_url_raw( rest_url() ),
392 + 'apiNonce' => wp_create_nonce( 'wp_rest' ),
393 + 'pluginUrl' => plugins_url( '', JETPACK__PLUGIN_FILE ),
394 + // Images ship from the plugin directory, so the plugin version is what busts their cache.
395 + 'assetsVersion' => JETPACK__VERSION,
396 + // The redirect entry bakes in the jetpack_ai_yearly product and
397 + // a post-checkout return to this page, so both can be
398 + // retargeted without shipping a code change.
399 + 'upgradeUrl' => Redirect::get_url( 'jetpack-ai-hub-upgrade' ),
400 + // The purchase granting AI — the usage card only uses it to pick
401 + // the right loading-skeleton shape before the usage fetch lands.
402 + // Only looked up when a gated view can render the card.
403 + 'planName' => $plan_info['name'],
404 + 'showFeaturesView' => $show_gated_views,
405 + 'showA12sBadge' => ! empty( $config['showA12sBadge'] ),
406 + // The tab and its Agents Manager sidebar ship disabled by default.
407 + 'featureFlags' => array(
408 + Jetpack_AI_Feature_Flags::SCHEDULED_TASKS => $show_scheduled_tasks_view,
409 + ),
410 + // The usage endpoint proxies as the current user, which needs
411 + // their own WordPress.com account linked — not just the site.
412 + 'isUserConnected' => ! empty( $config['isUserConnected'] ),
413 + // Tracks audience properties for the jetpack_mcp_* events, per the
414 + // Tracks standards for AI product events (AIINT-586). The client
415 + // sends them as the strings 'true'/'false' (AIINT-576).
416 + 'isA11n' => self::is_current_user_automattician(),
417 + 'isTest' => $is_internal_test,
418 + // Identity for Tracks; the lookup can call WordPress.com on a
419 + // cache miss, so it shares the sender's guard.
420 + 'tracksUserData' => $can_send_tracks ? self::get_tracks_user_data() : null,
421 + 'mcpSettingsApi' => $config['mcpSettingsApi'] ?? array(),
422 + );
423 +
424 + wp_add_inline_script(
425 + 'jetpack-ai-admin',
426 + 'var jetpackAiSettings = ' . wp_json_encode(
427 + $settings,
428 + JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
429 + ) . ';',
430 + 'before'
431 + );
432 +
433 + /*
434 + * `@automattic/jetpack-analytics` reads `window.jpTracksContext.blog_id` at
435 + * event-fire time and attaches it to every Tracks event fired from this page.
436 + * Without it, JS-fired events from self-hosted sites carry no blog_id — the
437 + * Tracks pixel cannot resolve the site — so the events cannot be joined to
438 + * plan or site data. Mirrors Connection\Initial_State::render().
439 + */
440 + wp_add_inline_script(
441 + 'jetpack-ai-admin',
442 + sprintf(
443 + 'window.jpTracksContext = window.jpTracksContext || {}; window.jpTracksContext.blog_id = %s;',
444 + absint( $blog_id )
445 + ),
446 + 'before'
447 + );
448 + }
449 +
450 + /**
451 + * Connected-user identity for Tracks; null when no WordPress.com account is
452 + * linked. Two keys only, so email and locale stay out of the page HTML.
453 + *
454 + * @return array{userid:int, username:string}|null
455 + */
456 + private static function get_tracks_user_data() {
457 + $identity = \Jetpack_Tracks_Client::get_connected_user_tracks_identity();
458 + if ( ! is_array( $identity ) || ! isset( $identity['userid'] ) || ! isset( $identity['username'] ) ) {
459 + return null;
460 + }
461 +
462 + return array(
463 + 'userid' => (int) $identity['userid'],
464 + 'username' => (string) $identity['username'],
465 + );
466 + }
467 +
468 + /**
469 + * Whether the current user is an Automattician.
470 + *
471 + * Identity check for the Tracks `is_a11n` audience property — it answers
472 + * "who is this", not "may they use the tool", so it deliberately does not
473 + * consult the MCP allowlist: allowlisted external testers are not a11ns.
474 + *
475 + * On wpcom Simple/Atomic the platform's is_automattician() is authoritative.
476 + * Self-hosted Jetpack has no platform check; there the Tracks identity of a
477 + * connected user is their WordPress.com account, so the connected account's
478 + * email domain is the identity signal.
479 + *
480 + * @return bool
481 + */
482 + private static function is_current_user_automattician() {
483 + if ( function_exists( 'is_automattician' ) ) {
484 + return (bool) is_automattician( get_current_user_id() );
485 + }
486 +
487 + $user_data = ( new Connection_Manager() )->get_connected_user_data();
488 + $email = is_array( $user_data ) && ! empty( $user_data['email'] )
489 + ? strtolower( (string) $user_data['email'] )
490 + : '';
491 +
492 + return '' !== $email && '@automattic.com' === substr( $email, -15 );
493 + }
494 +
495 + /**
496 + * The Search dashboard page the AI Answers row links to, or '' once a host
497 + * has removed it. Removal is remove_submenu_page() on `admin_menu`, which only
498 + * unsets the registry entry — menu_page_url() still answers for the page and
499 + * wp-admin then denies it — so the registry is read after that hook has run.
500 + *
501 + * @return string
502 + */
503 + private static function get_search_settings_url() {
504 + global $submenu;
505 +
506 + // Search registers under the Jetpack menu, or menu-less (parent '') when
507 + // `jetpack_search_should_add_search_submenu` says no; both are reachable.
508 + foreach ( array( 'jetpack', '' ) as $parent ) {
509 + foreach ( (array) ( $submenu[ $parent ] ?? array() ) as $item ) {
510 + if ( isset( $item[2] ) && 'jetpack-search' === $item[2] ) {
511 + return admin_url( 'admin.php?page=jetpack-search#/ai-answers' );
512 + }
513 + }
514 + }
515 +
516 + return '';
517 + }
518 +
519 + /**
520 + * Whether My Jetpack is loaded on this host.
521 + *
522 + * Hosts drop it with the `jetpack_my_jetpack_should_initialize` filter, and
523 + * VIP removes it from outside this codebase, where that filter cannot answer.
524 + *
525 + * @return bool
526 + */
527 + private static function has_my_jetpack() {
528 + if ( ( new Host() )->is_vip_site() ) {
529 + return false;
530 + }
531 +
532 + return class_exists( 'Automattic\\Jetpack\\My_Jetpack\\Initializer' )
533 + && method_exists( 'Automattic\\Jetpack\\My_Jetpack\\Initializer', 'should_initialize' )
534 + && \Automattic\Jetpack\My_Jetpack\Initializer::should_initialize();
535 + }
536 +
537 + /**
538 + * Name of the purchase granting this site AI ("Jetpack Complete"), from
539 + * My Jetpack's purchase data — its Plans section's source.
540 + *
541 + * @return array{name: string} An empty string when nothing paid grants AI
542 + * or the data is unavailable.
543 + */
544 + private static function get_ai_plan_info() {
545 + $empty = array( 'name' => '' );
546 +
547 + if ( ! class_exists( '\Automattic\Jetpack\My_Jetpack\Products\Jetpack_Ai' ) ) {
548 + return $empty;
549 + }
550 +
551 + // The purchase lookup can make remote requests; cache the outcome
552 + // (empty included) so the admin page pays that cost at most hourly.
553 + $cached = get_transient( 'jetpack_ai_overview_plan_info' );
554 + if ( is_array( $cached ) ) {
555 + return array_merge( $empty, $cached );
556 + }
557 +
558 + // A failed lookup is not "no purchase": skip the hour-long cache so the
559 + // next page load can try again instead of pinning a blank name.
560 + if ( is_wp_error( \Automattic\Jetpack\My_Jetpack\Wpcom_Products::get_site_current_purchases() ) ) {
561 + return $empty;
562 + }
563 +
564 + $purchase = \Automattic\Jetpack\My_Jetpack\Products\Jetpack_Ai::get_paid_plan_purchase_for_product();
565 +
566 + // A WordPress.com site names its own plan, never a Jetpack one.
567 + if ( self::is_jetpack_purchase( $purchase ) && ( new Host() )->is_woa_site() ) {
568 + $purchase = self::get_wpcom_plan_purchase();
569 + }
570 +
571 + $info = $empty;
572 + if ( $purchase && ! empty( $purchase->product_name ) && 'expired' !== ( $purchase->expiry_status ?? '' ) ) {
573 + // The design shows the bare plan name ("Complete", "Business"), so
574 + // trim the store names' brand prefixes; they are untranslated.
575 + $info['name'] = (string) preg_replace( '/^(Jetpack|WordPress\.com) /', '', (string) $purchase->product_name );
576 + }
577 +
578 + set_transient( 'jetpack_ai_overview_plan_info', $info, HOUR_IN_SECONDS );
579 +
580 + return $info;
581 + }
582 +
583 + /**
584 + * Whether a purchase was bought from the Jetpack store.
585 + *
586 + * @param object|null $purchase Purchase from My Jetpack.
587 + * @return bool
588 + */
589 + private static function is_jetpack_purchase( $purchase ) {
590 + return (bool) $purchase && 0 === strpos( (string) ( $purchase->product_slug ?? '' ), 'jetpack_' );
591 + }
592 +
593 + /**
594 + * The purchase behind the site's current WordPress.com plan.
595 + *
596 + * The plan record carries only a slug and a display name lives on purchases,
597 + * so the slug is matched back to the purchase that created it.
598 + *
599 + * @return object|null Null when the plan or its purchase cannot be found.
600 + */
601 + private static function get_wpcom_plan_purchase() {
602 + $current_plan = \Automattic\Jetpack\My_Jetpack\Wpcom_Products::get_site_current_plan();
603 + $plan_slug = is_array( $current_plan ) && ! empty( $current_plan['product_slug'] )
604 + ? (string) $current_plan['product_slug']
605 + : '';
606 +
607 + // An empty slug simply matches nothing below, so it needs no guard.
608 + foreach ( (array) \Automattic\Jetpack\My_Jetpack\Wpcom_Products::get_site_current_purchases() as $purchase ) {
609 + if ( $plan_slug === ( $purchase->product_slug ?? '' ) ) {
610 + return $purchase;
611 + }
612 + }
613 +
614 + return null;
615 + }
616 +
617 + /**
618 + * Override the base render() to skip wrap_ui entirely.
619 + *
620 + * Wrap_ui renders the Jetpack masthead header and static footer, which
621 + * duplicate the header/footer that AdminPage (React) already provides.
622 + * Calling page_render() directly lets AdminPage own the full layout.
623 + */
624 + public function render() {
625 + $this->page_render();
626 + }
627 +
628 + /**
629 + * Render the page, or say why it could not be rendered.
630 + *
631 + * The generated wp-build page owns the markup the app mounts into.
632 + */
633 + public function page_render() {
634 + if ( self::should_render_wp_build() ) {
635 + jetpack_plugin_jetpack_ai_hub_wp_admin_render_page(); // @phan-suppress-current-line PhanUndeclaredFunction -- should_render_wp_build() checks function_exists(); defined in the generated build/pages/, which Phan excludes.
636 + return;
637 + }
638 +
639 + // The build output is missing; say so rather than leaving a silent blank page.
640 + printf(
641 + '<div class="wrap"><h1>%s</h1><div class="notice notice-error"><p>%s</p></div></div>',
642 + esc_html__( 'Jetpack AI', 'jetpack' ),
643 + esc_html__( 'Jetpack AI could not be loaded because its assets are missing. Reinstalling or updating the plugin usually fixes this. If it keeps happening, contact your site administrator or host.', 'jetpack' )
644 + );
645 + }
646 +}
647 +
648 +/*
649 + * wp-build must load before add_actions() runs on any host, so hook it here: Jetpack_Admin and
650 + * mu-wpcom's WordPress.com Simple integration both require this file before `admin_menu`, and
651 + * add_action() dedupes.
652 + */
653 +add_action( 'admin_menu', array( 'Jetpack_AI_Page', 'maybe_load_wp_build' ), 1 );