PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / trunk
Jetpack – WP Security, Backup, Speed, & Growth vtrunk
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 13.9.2 14.0.1 14.1.1 14.2.2 14.3.1 14.4.2 14.5.1 14.6.1 14.7.1 14.8.1 14.9.2 15.0.3 15.1.2 15.2.1 15.3.2 15.4.1 15.5.1 15.6.1 15.7.2 15.8.1 15.9.2 16.0.2 16.1.3 16.2-a.5 16.2-a.3 16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / _inc / lib / admin-pages / class-jetpack-ai-page.php
jetpack / _inc / lib / admin-pages Last commit date
class-akismet-admin-chrome.php 1 week ago class-jetpack-about-page.php 3 months ago class-jetpack-ai-page.php 9 hours ago class-jetpack-redux-state-helper.php 9 hours ago class.jetpack-admin-page.php 4 weeks ago class.jetpack-landing-page.php 2 years ago class.jetpack-react-page.php 7 months ago class.jetpack-settings-page.php 4 weeks ago
class-jetpack-ai-page.php
530 lines
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\Redirect;
17 use Automattic\Jetpack\Status;
18 use Automattic\Jetpack\Status\Host;
19 use Automattic\Jetpack\Terms_Of_Service;
20 use Automattic\Jetpack\Tracking;
21
22 if ( ! defined( 'ABSPATH' ) ) {
23 exit( 0 );
24 }
25
26 require_once dirname( __DIR__ ) . '/class-jetpack-ai-feature-flags.php';
27
28 /**
29 * Builds the Jetpack AI admin page and its sidebar menu entry.
30 */
31 class Jetpack_AI_Page {
32
33 /**
34 * Register the page and its page-specific hooks.
35 *
36 * The AI Hub owns its full React layout, so it does not need the legacy
37 * Jetpack_Admin_Page lifecycle. Keeping this controller independent also
38 * lets WordPress.com Simple load the same page without replacing its
39 * request-wide Jetpack_Admin_Page compatibility stub.
40 */
41 public function add_actions() {
42 $is_offline_mode = ( new Status() )->is_offline_mode();
43
44 if ( ! current_user_can( 'manage_options' ) && ( $is_offline_mode || ! Jetpack::is_connection_ready() ) ) {
45 return;
46 }
47
48 if ( ! Jetpack::is_connection_ready() && ! $is_offline_mode ) {
49 return;
50 }
51
52 $hook = $this->get_page_hook();
53 if ( ! $hook ) {
54 return;
55 }
56
57 add_action( 'admin_print_scripts-' . $hook, array( $this, 'page_admin_scripts' ) );
58
59 // Preserve the standalone Jetpack page's existing base stylesheet. Simple
60 // never loaded it for the Hub because it conflicts with wpcom admin pages.
61 if ( ! ( new Host() )->is_wpcom_simple() ) {
62 add_action( 'admin_print_styles-' . $hook, array( $this, 'admin_styles' ) );
63 }
64
65 $this->add_page_actions( $hook );
66 }
67
68 /**
69 * Register the "AI" submenu under the Jetpack top-level menu.
70 *
71 * @return string|false Hook returned by Admin_Menu::add_menu().
72 */
73 public function get_page_hook() {
74 return Admin_Menu::add_menu(
75 // "Jetpack AI" is a product name and should not be translated.
76 'Jetpack AI',
77 'Jetpack AI',
78 'manage_options',
79 'jetpack-ai',
80 array( $this, 'render' ),
81 4
82 );
83 }
84
85 /**
86 * Attach page-specific actions.
87 *
88 * @param string $hook The page hook returned by get_page_hook().
89 */
90 public function add_page_actions( $hook ) {
91 add_action( 'load-' . $hook, array( $this, 'load_agents_manager' ) );
92 }
93
94 /**
95 * Enqueue the stylesheet historically supplied by Jetpack_Admin_Page.
96 */
97 public function admin_styles() {
98 $min = ( defined( 'SCRIPT_DEBUG' ) && SCRIPT_DEBUG ) ? '' : '.min';
99
100 wp_enqueue_style( 'jetpack-admin', plugins_url( "css/jetpack-admin{$min}.css", JETPACK__PLUGIN_FILE ), array( 'genericons', 'jetpack-connection' ), JETPACK__VERSION . '-20121016' );
101 wp_style_add_data( 'jetpack-admin', 'rtl', 'replace' );
102 wp_style_add_data( 'jetpack-admin', 'suffix', $min );
103 }
104
105 /**
106 * Request the existing Agents Manager shell for this page.
107 */
108 public function load_agents_manager() {
109 if ( ! self::is_scheduled_tasks_enabled() ) {
110 return;
111 }
112
113 Agents_Manager::init();
114
115 add_filter( 'agents_manager_should_load', '__return_true' );
116 add_filter( 'agents_manager_agent_id', array( $this, 'get_agents_manager_agent_id' ) );
117 add_filter( 'agents_manager_agent_providers', array( $this, 'add_scheduled_tasks_provider' ) );
118 add_filter( 'jetpack_ai_sidebar_agents_manager_data', array( $this, 'add_scheduled_tasks_data' ) );
119 }
120
121 /**
122 * Use the generic WP Orchestrator agent in AI Hub.
123 *
124 * @return string Agent ID.
125 */
126 public function get_agents_manager_agent_id() {
127 return 'wp-orchestrator';
128 }
129
130 /**
131 * Add the AI Hub provider that supplies scheduled task starter prompts.
132 *
133 * @param array $providers Existing provider module URLs.
134 * @return array Updated provider module URLs.
135 */
136 public function add_scheduled_tasks_provider( $providers ) {
137 $providers[] = add_query_arg(
138 'ver',
139 JETPACK__VERSION,
140 plugins_url( '_inc/jetpack-ai-scheduled-tasks-provider.js', JETPACK__PLUGIN_FILE )
141 );
142
143 return $providers;
144 }
145
146 /**
147 * Customize Agents Manager's empty view for the Scheduled tasks page.
148 *
149 * @param array $data Existing Agents Manager data.
150 * @return array Updated Agents Manager data.
151 */
152 public function add_scheduled_tasks_data( $data ) {
153 $current_user = wp_get_current_user();
154
155 $data['emptyViewHeading'] = sprintf(
156 /* translators: %s: Current user's display name. */
157 __( 'Howdy %s! Let’s schedule a task.', 'jetpack' ),
158 $current_user->display_name
159 );
160 $data['emptyViewHelp'] = __( 'Got a different request? Ask away.', 'jetpack' );
161 $data['scheduledTaskEmptyViewSuggestions'] = array(
162 array(
163 'id' => 'create-daily-reminder',
164 'label' => __( 'Create a daily reminder', 'jetpack' ),
165 'prompt' => __( 'Create a daily reminder', 'jetpack' ),
166 'autoSubmit' => true,
167 ),
168 array(
169 'id' => 'draft-weekly-post',
170 'label' => __( 'Draft a weekly post', 'jetpack' ),
171 'prompt' => __( 'Draft a weekly post', 'jetpack' ),
172 'autoSubmit' => true,
173 ),
174 array(
175 'id' => 'schedule-monthly-report',
176 'label' => __( 'Schedule a monthly report', 'jetpack' ),
177 'prompt' => __( 'Schedule a monthly report', 'jetpack' ),
178 'autoSubmit' => true,
179 ),
180 );
181
182 return $data;
183 }
184
185 /**
186 * Whether the Scheduled tasks tab and its Agents Manager sidebar are enabled.
187 *
188 * @since 16.2
189 *
190 * @return bool
191 */
192 private static function is_scheduled_tasks_enabled() {
193 return Feature_Flags::is_enabled( Jetpack_AI_Feature_Flags::SCHEDULED_TASKS );
194 }
195
196 /**
197 * Enqueue scripts and styles for the AI admin page.
198 */
199 public function page_admin_scripts() {
200 $script_path = JETPACK__PLUGIN_DIR . '_inc/build/jetpack-ai-admin.asset.php';
201 $script_deps = array( 'wp-element', 'wp-components', 'wp-i18n', 'wp-polyfill' );
202 $script_version = JETPACK__VERSION;
203
204 if ( file_exists( $script_path ) ) {
205 $asset_manifest = include $script_path;
206 $script_deps = $asset_manifest['dependencies'];
207 $script_version = $asset_manifest['version'];
208 }
209
210 $blog_id = Connection_Manager::get_site_id( true );
211 $status = new Status();
212 $site_suffix = $status->get_site_suffix();
213 // Use the plain hostname for the Atomic activity log URL — get_site_suffix() can
214 // include '::' for subdirectory installs, which would break the URL. This matches
215 // the approach used by jetpack-mu-wpcom for the sidebar Activity Log link.
216 $site_host = wp_parse_url( home_url(), PHP_URL_HOST );
217 $activity_log_site = ( is_string( $site_host ) && '' !== $site_host ) ? $site_host : $site_suffix;
218 // On Atomic link to WPCOM activity log; on self-hosted link to the local wp-admin page.
219 $activity_log_url = ( new Host() )->is_woa_site()
220 ? 'https://wordpress.com/activity-log/' . $activity_log_site
221 : admin_url( 'admin.php?page=jetpack-activity-log' );
222
223 /*
224 * Link SEO settings to the dedicated Jetpack SEO page where it exists,
225 * falling back to the Traffic settings card. Checking the `rsm_jetpack_seo`
226 * filter is required in addition to the cohort check: is_seo_surface_visible()
227 * alone returns true on all of wpcom-platform even while the flag is off —
228 * it answers only the cohort half, and page registration requires both
229 * (see packages/seo Initializer::init()).
230 */
231 $seo_settings_url = admin_url( 'admin.php?page=jetpack#/traffic' );
232 $is_internal_test = jetpack_is_internal_testing_environment();
233 $show_scheduled_tasks_view = self::is_scheduled_tasks_enabled();
234 if (
235 // The exact-symbol guard matters: the autoloader can select an older
236 // jetpack-seo copy from another plugin that has the class but not
237 // this method, and class_exists alone would then fatal here.
238 method_exists( '\Automattic\Jetpack\SEO\Initializer', 'is_seo_surface_visible' )
239 && (bool) apply_filters( 'rsm_jetpack_seo', false )
240 && \Automattic\Jetpack\SEO\Initializer::is_seo_surface_visible()
241 ) {
242 $seo_settings_url = admin_url( 'admin.php?page=jetpack-seo' );
243 }
244
245 wp_enqueue_script(
246 'jetpack-ai-admin',
247 plugins_url( '_inc/build/jetpack-ai-admin.js', JETPACK__PLUGIN_FILE ),
248 $script_deps,
249 $script_version,
250 true
251 );
252
253 wp_set_script_translations( 'jetpack-ai-admin', 'jetpack' );
254
255 // The Tracks sender (w.js); without it, queued events never leave the
256 // browser. Consent-gated like the other surfaces that load it.
257 $can_send_tracks = ( new Tracking( 'jetpack', new Connection_Manager() ) )->should_enable_tracking( new Terms_Of_Service(), $status );
258 if ( $can_send_tracks ) {
259 Tracking::register_tracks_functions_scripts( true );
260 }
261
262 if ( $show_scheduled_tasks_view ) {
263 Connection_Initial_State::render_script( 'jetpack-ai-admin' );
264 }
265
266 /**
267 * Filters the host-specific AI Hub configuration.
268 *
269 * @since 16.2
270 *
271 * @param array $config AI Hub host configuration.
272 */
273 $config = apply_filters(
274 'jetpack_ai_admin_config',
275 array(
276 // Pre-release gate for the Overview and Features views. When opening
277 // them to everyone, also drop the matching gate in My Jetpack's
278 // Jetpack_Ai::get_manage_url() so its links land here too.
279 'showGatedViews' => $is_internal_test,
280 'isUserConnected' => ( new Connection_Manager() )->is_user_connected(),
281 'mcpSettingsApi' => array(
282 'path' => '/wpcom/v2/jetpack-ai/mcp-settings',
283 'format' => 'jetpack',
284 ),
285 )
286 );
287
288 $show_gated_views = ! empty( $config['showGatedViews'] );
289
290 $plan_info = $show_gated_views ? self::get_ai_plan_info() : array(
291 'name' => '',
292 'renews_on' => '',
293 'auto_renew' => true,
294 );
295
296 $settings = array(
297 'blogId' => $blog_id ? (int) $blog_id : 0,
298 'activityLogUrl' => $activity_log_url,
299 'seoSettingsUrl' => $seo_settings_url,
300 'siteAdminUrl' => admin_url(),
301 'apiRoot' => esc_url_raw( rest_url() ),
302 'apiNonce' => wp_create_nonce( 'wp_rest' ),
303 'pluginUrl' => plugins_url( '', JETPACK__PLUGIN_FILE ),
304 // The redirect entry bakes in the jetpack_ai_yearly product and
305 // a post-checkout return to this page, so both can be
306 // retargeted without shipping a code change.
307 'upgradeUrl' => Redirect::get_url( 'jetpack-ai-hub-upgrade' ),
308 // The purchase granting AI, for the Overview usage card — the
309 // usage endpoint cannot name it. Only looked up when a gated
310 // view can render it.
311 'planName' => $plan_info['name'],
312 // The plan's own renewal date, matching My Jetpack — the
313 // usage-period rollover is a different, monthly date.
314 'planRenewsOn' => $plan_info['renews_on'],
315 // The same date reads "Renews on" or "Expires on" depending on
316 // auto-renew, matching My Jetpack and the wpcom subscriptions page.
317 'planAutoRenew' => $plan_info['auto_renew'],
318 'showFeaturesView' => $show_gated_views,
319 // The tab and its Agents Manager sidebar ship disabled by default.
320 'featureFlags' => array(
321 Jetpack_AI_Feature_Flags::SCHEDULED_TASKS => $show_scheduled_tasks_view,
322 ),
323 // The usage endpoint proxies as the current user, which needs
324 // their own WordPress.com account linked — not just the site.
325 'isUserConnected' => ! empty( $config['isUserConnected'] ),
326 // Tracks audience properties for the jetpack_mcp_* events, per the
327 // Tracks standards for AI product events (AIINT-586). The client
328 // sends them as the strings 'true'/'false' (AIINT-576).
329 'isA11n' => self::is_current_user_automattician(),
330 'isTest' => $is_internal_test,
331 // Identity for Tracks; the lookup can call WordPress.com on a
332 // cache miss, so it shares the sender's guard.
333 'tracksUserData' => $can_send_tracks ? self::get_tracks_user_data() : null,
334 'mcpSettingsApi' => $config['mcpSettingsApi'],
335 );
336
337 wp_add_inline_script(
338 'jetpack-ai-admin',
339 'var jetpackAiSettings = ' . wp_json_encode(
340 $settings,
341 JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP
342 ) . ';',
343 'before'
344 );
345
346 /*
347 * `@automattic/jetpack-analytics` reads `window.jpTracksContext.blog_id` at
348 * event-fire time and attaches it to every Tracks event fired from this page.
349 * Without it, JS-fired events from self-hosted sites carry no blog_id — the
350 * Tracks pixel cannot resolve the site — so the events cannot be joined to
351 * plan or site data. Mirrors Connection\Initial_State::render().
352 */
353 wp_add_inline_script(
354 'jetpack-ai-admin',
355 sprintf(
356 'window.jpTracksContext = window.jpTracksContext || {}; window.jpTracksContext.blog_id = %s;',
357 absint( $blog_id )
358 ),
359 'before'
360 );
361
362 wp_enqueue_style(
363 'jetpack-ai-admin',
364 plugins_url( '_inc/build/jetpack-ai-admin.css', JETPACK__PLUGIN_FILE ),
365 array( 'wp-components' ),
366 $script_version
367 );
368 }
369
370 /**
371 * Connected-user identity for Tracks; null when no WordPress.com account is
372 * linked. Two keys only, so email and locale stay out of the page HTML.
373 *
374 * @return array{userid:int, username:string}|null
375 */
376 private static function get_tracks_user_data() {
377 $identity = \Jetpack_Tracks_Client::get_connected_user_tracks_identity();
378 if ( ! is_array( $identity ) || ! isset( $identity['userid'] ) || ! isset( $identity['username'] ) ) {
379 return null;
380 }
381
382 return array(
383 'userid' => (int) $identity['userid'],
384 'username' => (string) $identity['username'],
385 );
386 }
387
388 /**
389 * Whether the current user is an Automattician.
390 *
391 * Identity check for the Tracks `is_a11n` audience property — it answers
392 * "who is this", not "may they use the tool", so it deliberately does not
393 * consult the MCP allowlist: allowlisted external testers are not a11ns.
394 *
395 * On wpcom Simple/Atomic the platform's is_automattician() is authoritative.
396 * Self-hosted Jetpack has no platform check; there the Tracks identity of a
397 * connected user is their WordPress.com account, so the connected account's
398 * email domain is the identity signal.
399 *
400 * @return bool
401 */
402 private static function is_current_user_automattician() {
403 if ( function_exists( 'is_automattician' ) ) {
404 return (bool) is_automattician( get_current_user_id() );
405 }
406
407 $user_data = ( new Connection_Manager() )->get_connected_user_data();
408 $email = is_array( $user_data ) && ! empty( $user_data['email'] )
409 ? strtolower( (string) $user_data['email'] )
410 : '';
411
412 return '' !== $email && '@automattic.com' === substr( $email, -15 );
413 }
414
415 /**
416 * Name and renewal date of the purchase granting this site AI ("Jetpack
417 * Complete"), from My Jetpack's purchase data — its Plans section's source.
418 *
419 * The renewal is the purchase's expiry date, matching what My Jetpack
420 * shows — not the AI usage-period rollover, which is a different date.
421 *
422 * @return array{name: string, renews_on: string} Empty strings when nothing
423 * paid grants AI or the data
424 * is unavailable.
425 */
426 private static function get_ai_plan_info() {
427 $empty = array(
428 'name' => '',
429 'renews_on' => '',
430 'auto_renew' => true,
431 );
432
433 if ( ! class_exists( '\Automattic\Jetpack\My_Jetpack\Products\Jetpack_Ai' ) ) {
434 return $empty;
435 }
436
437 // The purchase lookup can make remote requests; cache the outcome
438 // (empty included) so the admin page pays that cost at most hourly.
439 $cached = get_transient( 'jetpack_ai_overview_plan_info' );
440 if ( is_array( $cached ) ) {
441 return array_merge( $empty, $cached );
442 }
443
444 // A failed lookup is not "no purchase": skip the hour-long cache so the
445 // next page load can try again instead of pinning a blank plan cell.
446 if ( is_wp_error( \Automattic\Jetpack\My_Jetpack\Wpcom_Products::get_site_current_purchases() ) ) {
447 return $empty;
448 }
449
450 $purchase = \Automattic\Jetpack\My_Jetpack\Products\Jetpack_Ai::get_paid_plan_purchase_for_product();
451
452 // A WordPress.com site names its own plan, never a Jetpack one.
453 if ( self::is_jetpack_purchase( $purchase ) && ( new Host() )->is_woa_site() ) {
454 $purchase = self::get_wpcom_plan_purchase();
455 }
456
457 $info = $empty;
458 if ( $purchase && ! empty( $purchase->product_name ) && 'expired' !== ( $purchase->expiry_status ?? '' ) ) {
459 // The design shows the bare plan name ("Complete", "Business"), so
460 // trim the store names' brand prefixes; they are untranslated.
461 $info['name'] = (string) preg_replace( '/^(Jetpack|WordPress\.com) /', '', (string) $purchase->product_name );
462 $info['renews_on'] = (string) ( $purchase->expiry_date ?? '' );
463 // Absent means unknown, not off: only a purchase that positively
464 // reports auto-renew off should relabel the date as an expiry.
465 $info['auto_renew'] = ! isset( $purchase->is_auto_renew_enabled )
466 || (bool) $purchase->is_auto_renew_enabled;
467 }
468
469 set_transient( 'jetpack_ai_overview_plan_info', $info, HOUR_IN_SECONDS );
470
471 return $info;
472 }
473
474 /**
475 * Whether a purchase was bought from the Jetpack store.
476 *
477 * @param object|null $purchase Purchase from My Jetpack.
478 * @return bool
479 */
480 private static function is_jetpack_purchase( $purchase ) {
481 return (bool) $purchase && 0 === strpos( (string) ( $purchase->product_slug ?? '' ), 'jetpack_' );
482 }
483
484 /**
485 * The purchase behind the site's current WordPress.com plan.
486 *
487 * The plan record carries only a slug and a display name lives on purchases,
488 * so the slug is matched back to the purchase that created it.
489 *
490 * @return object|null Null when the plan or its purchase cannot be found.
491 */
492 private static function get_wpcom_plan_purchase() {
493 $current_plan = \Automattic\Jetpack\My_Jetpack\Wpcom_Products::get_site_current_plan();
494 $plan_slug = is_array( $current_plan ) && ! empty( $current_plan['product_slug'] )
495 ? (string) $current_plan['product_slug']
496 : '';
497
498 // An empty slug simply matches nothing below, so it needs no guard.
499 foreach ( (array) \Automattic\Jetpack\My_Jetpack\Wpcom_Products::get_site_current_purchases() as $purchase ) {
500 if ( $plan_slug === ( $purchase->product_slug ?? '' ) ) {
501 return $purchase;
502 }
503 }
504
505 return null;
506 }
507
508 /**
509 * Override the base render() to skip wrap_ui entirely.
510 *
511 * Wrap_ui renders the Jetpack masthead header and static footer, which
512 * duplicate the header/footer that AdminPage (React) already provides.
513 * Calling page_render() directly lets AdminPage own the full layout.
514 */
515 public function render() {
516 $this->page_render();
517 }
518
519 /**
520 * Render the page container. The React app mounts into this div.
521 *
522 * AdminPage from @automattic/jetpack-components handles the full-page layout.
523 */
524 public function page_render() {
525 ?>
526 <div id="jetpack-ai-root"></div>
527 <?php
528 }
529 }
530