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
jetpack / _inc / lib / admin-pages / class-jetpack-ai-page.php

class-jetpack-ai-page.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3-beta, at _inc/lib/admin-pages/class-jetpack-ai-page.php

654 lines 23.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 );
654