PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.5
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.5
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 13.8.3 All 506 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-a.5, at _inc/lib/admin-pages/class-jetpack-ai-page.php

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