Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.51% covered (success)
90.51%
124 / 137
63.16% covered (warning)
63.16%
12 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
Initial_State
90.51% covered (success)
90.51%
124 / 137
63.16% covered (warning)
63.16%
12 / 19
47.81
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 render
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_initial_state
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
3
 get_wp_api_root
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 get_reader_chat_guidelines_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_ai_agent_access_guidelines_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_guidelines_url
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 is_ai_agent_access_available
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_block_template_overlay_config
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 get_product_overlay_template_config
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 get_search_template_config
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 get_product_search_template_config
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 build_singleton_template_config
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 is_private_site
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 current_user_data
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
3
 get_post_types_with_labels
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 get_purchase_token
28.57% covered (danger)
28.57%
2 / 7
0.00% covered (danger)
0.00%
0 / 1
6.28
 generate_purchase_token
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 current_user_can_purchase
40.00% covered (danger)
40.00%
2 / 5
0.00% covered (danger)
0.00%
0 / 1
4.94
1<?php
2/**
3 * The React initial state.
4 *
5 * @package automattic/jetpack-search
6 */
7
8namespace Automattic\Jetpack\Search;
9
10use Automattic\Jetpack\Connection\Manager as Connection_Manager;
11use Automattic\Jetpack\Status;
12use Jetpack_Options;
13
14/**
15 * The React initial state.
16 */
17class Initial_State {
18
19    /**
20     * Connection Manager
21     *
22     * @var Connection_Manager
23     */
24    protected $connection_manager;
25
26    /**
27     * Search Module Control
28     *
29     * @var Module_Control
30     */
31    protected $module_control;
32
33    /**
34     * Constructor
35     *
36     * @param Connection_Manager $connection_manager - Connection mananger instance.
37     * @param Module_Control     $module_control - Module control instance.
38     */
39    public function __construct( $connection_manager = null, $module_control = null ) {
40        $this->connection_manager = $connection_manager ? $connection_manager : new Connection_Manager( Package::SLUG );
41        $this->module_control     = $module_control ? $module_control : new Module_Control();
42    }
43
44    /**
45     * Render JS for the initial state
46     *
47     * @return string - JS string.
48     */
49    public function render() {
50        return 'var JETPACK_SEARCH_DASHBOARD_INITIAL_STATE=' . wp_json_encode( $this->get_initial_state(), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';';
51    }
52
53    /**
54     * Get the initial state data.
55     *
56     * @return array
57     */
58    public function get_initial_state() {
59        return array(
60            'siteData'        => array(
61                'WP_API_root'                => esc_url_raw( rest_url() ),
62                'wpcomOriginApiUrl'          => $this->get_wp_api_root(),
63                'WP_API_nonce'               => wp_create_nonce( 'wp_rest' ),
64                'registrationNonce'          => wp_create_nonce( 'jetpack-registration-nonce' ),
65                'purchaseToken'              => $this->get_purchase_token(),
66                /**
67                 * Whether promotions are visible or not.
68                 *
69                 * @param bool $are_promotions_active Status of promotions visibility. True by default.
70                 */
71                'showPromotions'             => apply_filters( 'jetpack_show_promotions', true ),
72                'adminUrl'                   => esc_url( admin_url() ),
73                'readerChatGuidelinesUrl'    => $this->get_reader_chat_guidelines_url(),
74                'aiAgentAccessAvailable'     => $this->is_ai_agent_access_available(),
75                'aiAgentAccessGuidelinesUrl' => $this->get_ai_agent_access_guidelines_url(),
76                'blogId'                     => Jetpack_Options::get_option( 'id', 0 ),
77                'version'                    => Package::VERSION,
78                'calypsoSlug'                => ( new Status() )->get_site_suffix(),
79                'title'                      => get_bloginfo( 'name' ),
80                'postTypes'                  => $this->get_post_types_with_labels(),
81                'isWpcom'                    => Helper::is_wpcom(),
82                // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
83                'isPlanJustUpgraded'         => isset( $_GET['just_upgraded'] ) && wp_unslash( $_GET['just_upgraded'] ),
84                /**
85                 * Whether the Jetpack Search 3.0 Interactivity API blocks are enabled.
86                 * Mirrors the `jetpack_search_blocks_enabled` server-side filter so the
87                 * dashboard React app can gate the new feature-selection UI on the
88                 * same flag the back end uses to register the blocks themselves.
89                 */
90                'searchBlocksEnabled'        => (bool) apply_filters( 'jetpack_search_blocks_enabled', true ),
91                /**
92                 * Whether the experimental blocks-powered Overlay search experience
93                 * is available in the Experience Selector. Mirrors the
94                 * `jetpack_search_overlay_block_template_enabled` server-side filter
95                 * so the dashboard React app can gate the new card on the same flag
96                 * the back end uses to enable the runtime swap. Defaults to true so
97                 * the Beta card ships to every site; operators that pin the filter
98                 * to false fall back to the original four-card selector.
99                 */
100                'blockOverlayEnabled'        => (bool) apply_filters( 'jetpack_search_overlay_block_template_enabled', true ),
101                /**
102                 * Editor affordances for the experimental blocks-powered overlay.
103                 * Surfaces in the new Overlay search card so admins can edit the
104                 * rendered template via the standard block editor on `post.php` â€”
105                 * works on both block and classic themes. URLs are null for any
106                 * visitor without `manage_options`; the action handlers also
107                 * enforce that capability server-side.
108                 */
109                'blockTemplateOverlay'       => $this->get_block_template_overlay_config(),
110                /**
111                 * Same as `blockTemplateOverlay` but for the WooCommerce product
112                 * variant of the overlay â€” the Overlay card surfaces a second
113                 * "Edit the product Search overlay" entry on Woo stores so the
114                 * product-search overlay template is customizable too.
115                 */
116                'productOverlayTemplate'     => $this->get_product_overlay_template_config(),
117                /**
118                 * Editor affordances for the classic-theme search-template
119                 * singleton CPT. The Embedded card surfaces these on
120                 * classic themes (which can't reach the Site Editor) so
121                 * admins still get an "Edit search template" entry â€” same
122                 * shape and same React link as `blockTemplateOverlay`.
123                 */
124                'searchTemplate'             => $this->get_search_template_config(),
125                /**
126                 * Same as `searchTemplate` but for the WooCommerce product
127                 * search shim â€” the `WooCommerceProductSearchControl`
128                 * surfaces this on classic themes so the "Edit the product
129                 * search template" link routes to `post.php` on the hidden
130                 * CPT instead of a useless Site Editor URL.
131                 */
132                'productSearchTemplate'      => $this->get_product_search_template_config(),
133                // Gates the WooCommerce Product Search control to stores.
134                'isWooCommerceActive'        => Search_Blocks::woocommerce_blocks_enabled(),
135                /**
136                 * Active theme stylesheet â€” used by the experience-selector to deep-link
137                 * the "Edit search template" action to the right Site Editor entry
138                 * (`?p=/wp_template/<stylesheet>//jetpack-search`).
139                 */
140                'activeThemeStylesheet'      => get_stylesheet(),
141                /**
142                 * Whether the active theme is a block theme. Embedded itself
143                 * works on every theme â€” block themes through the FSE
144                 * `search_template_hierarchy` route, classic themes through
145                 * the singleton-CPT shim â€” but the Embedded card's
146                 * customization affordances diverge: block themes get the
147                 * Site-Editor entry points ("Edit search template" / "Insert
148                 * pattern"), classic themes get the block-editor-on-a-hidden-
149                 * CPT path via `searchTemplate`. This flag is the dashboard's
150                 * branch selector for which to render.
151                 */
152                'themeSupportsBlocks'        => wp_is_block_theme(),
153            ),
154            'userData'        => array(
155                'currentUser' => $this->current_user_data(),
156            ),
157            'jetpackSettings' => array(
158                'search'                 => $this->module_control->is_active(),
159                'instant_search_enabled' => $this->module_control->is_instant_search_enabled(),
160                'experience'             => $this->module_control->get_experience(),
161            ),
162            'features'        => array_map(
163                'sanitize_text_field',
164                // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
165                isset( $_GET['features'] ) ? explode( ',', wp_unslash( $_GET['features'] ) ) : array()
166            ),
167        );
168    }
169
170    /**
171     * Get API root.
172     *
173     * It return first party API root for WPCOM simple sites.
174     */
175    protected function get_wp_api_root() {
176        if ( ! Helper::is_wpcom() ) {
177            return esc_url_raw( rest_url() );
178        }
179        // First party API prefix for WPCOM.
180        return esc_url_raw( site_url( '/wp-json/wpcom-origin/' ) );
181    }
182
183    /**
184     * Get the Reader Chat guidelines admin page URL when it is registered.
185     *
186     * The guidelines page is controlled outside Jetpack. Returning an empty
187     * URL lets the dashboard hide the link when that experiment is unavailable.
188     *
189     * @return string Guidelines admin URL, or an empty string when unavailable.
190     */
191    protected function get_reader_chat_guidelines_url() {
192        return $this->get_guidelines_url();
193    }
194
195    /**
196     * Get the AI Agent Access guidelines admin page URL when it is registered.
197     *
198     * The guidelines page is controlled outside Jetpack. Returning an empty
199     * URL lets the dashboard hide the link when that page is unavailable.
200     *
201     * @return string Guidelines admin URL, or an empty string when unavailable.
202     */
203    protected function get_ai_agent_access_guidelines_url() {
204        return $this->get_guidelines_url();
205    }
206
207    /**
208     * Get the Guidelines admin page URL when it is registered.
209     *
210     * @return string Guidelines admin URL, or an empty string when unavailable.
211     */
212    protected function get_guidelines_url() {
213        if ( ! function_exists( 'menu_page_url' ) ) {
214            return '';
215        }
216
217        return esc_url_raw( menu_page_url( 'guidelines-wp-admin', false ) );
218    }
219
220    /**
221     * Check whether the AI Agent Access toggle should be available.
222     *
223     * Private sites are not eligible because external AI agents cannot read
224     * their public content.
225     *
226     * @return bool
227     */
228    protected function is_ai_agent_access_available() {
229        return ! $this->is_private_site();
230    }
231
232    /**
233     * Build the block-template overlay editor config exposed to the dashboard.
234     *
235     * `enabled` mirrors `Search_Blocks::is_block_template_overlay_enabled()`
236     * â€” true only when the user is currently *on* the blocks Overlay arm.
237     * The editor URLs, by contrast, are gated on the operator filter alone
238     * so admins can edit the template from the Beta card even before
239     * activating it (otherwise the React store, computed at page load
240     * before any switch, would carry a null `editorUrl` and the link would
241     * become a no-op once the card flipped to Active without a refresh).
242     *
243     * @return array{enabled: bool, editorUrl: string|null, postType: string|null, isCustomized: bool}
244     */
245    protected function get_block_template_overlay_config(): array {
246        return $this->build_singleton_template_config(
247            Search_Blocks::is_block_template_overlay_enabled(),
248            Search_Blocks::is_block_template_overlay_filter_on() && current_user_can( 'manage_options' ),
249            Overlay_Template::class
250        );
251    }
252
253    /**
254     * Build the product-overlay editor config exposed to the dashboard.
255     * Counterpart of `get_block_template_overlay_config()` for the WooCommerce
256     * product variant. `enabled` adds the override-on + WC check on top of the
257     * overlay-arm gate â€” it signals the product overlay's front-end render path
258     * is actually wired. `$can_edit` mirrors the init gate: the editable CPT
259     * only registers on Woo stores with the overlay filter on (see
260     * `Search_Blocks::init()`), so exposing the editor URL off Woo would produce
261     * a link that silently does nothing.
262     *
263     * @return array{enabled: bool, editorUrl: string|null, postType: string|null, isCustomized: bool}
264     */
265    protected function get_product_overlay_template_config(): array {
266        $wc_enabled = Search_Blocks::woocommerce_blocks_enabled();
267        return $this->build_singleton_template_config(
268            Search_Blocks::is_block_template_overlay_enabled()
269                && $wc_enabled
270                && Search_Blocks::woocommerce_search_template_override_enabled(),
271            Search_Blocks::is_block_template_overlay_filter_on()
272                && $wc_enabled
273                && current_user_can( 'manage_options' ),
274            Product_Overlay_Template::class
275        );
276    }
277
278    /**
279     * Build the classic-theme search-template editor config exposed to the
280     * dashboard. Counterpart of `get_block_template_overlay_config()` â€”
281     * same `{enabled, editorUrl, postType, isCustomized}` shape and the
282     * same "expose URLs before activation" rule: admins can edit the
283     * template from the Embedded card on any classic theme, even before
284     * actually switching to Embedded.
285     *
286     * @return array{enabled: bool, editorUrl: string|null, postType: string|null, isCustomized: bool}
287     */
288    protected function get_search_template_config(): array {
289        $is_classic = ! wp_is_block_theme();
290        return $this->build_singleton_template_config(
291            $is_classic && Module_Control::EXPERIENCE_EMBEDDED === $this->module_control->get_experience(),
292            $is_classic && current_user_can( 'manage_options' ),
293            Search_Template::class
294        );
295    }
296
297    /**
298     * Build the classic-theme product-search-template editor config exposed
299     * to the dashboard. Counterpart of `get_search_template_config()` for the
300     * WooCommerce product shim â€” same `{enabled, editorUrl, postType, isCustomized}`
301     * shape. `enabled` adds the override-on check on top of the classic +
302     * server-rendered-experience gate â€” it signals "the front-end render path
303     * is actually wired", distinct from "the override toggle is on" in
304     * `jetpackSettings.override_woocommerce_search_template`. `$can_edit`
305     * mirrors the init gate: `Product_Search_Template::init()` (in
306     * `Search_Blocks::init()`) registers `maybe_handle_editor_request` on
307     * classic Embedded **and** classic Inline (both route the product shim),
308     * so the editor URL surfaces for both â€” but not on a block theme, where
309     * the Site Editor link is used instead.
310     *
311     * @return array{enabled: bool, editorUrl: string|null, postType: string|null, isCustomized: bool}
312     */
313    protected function get_product_search_template_config(): array {
314        $experience = $this->module_control->get_experience();
315        $is_classic = ! wp_is_block_theme();
316        // Embedded and Inline both route the classic product shim, so both
317        // expose the CPT editor.
318        $is_classic_product_override = $is_classic
319            && in_array(
320                $experience,
321                array( Module_Control::EXPERIENCE_EMBEDDED, Module_Control::EXPERIENCE_INLINE ),
322                true
323            );
324        return $this->build_singleton_template_config(
325            $is_classic_product_override && Search_Blocks::woocommerce_search_template_override_enabled(),
326            $is_classic_product_override && current_user_can( 'manage_options' ),
327            Product_Search_Template::class
328        );
329    }
330
331    /**
332     * Shared assembly for a {@see Singleton_Template_Cpt}-backed editor
333     * config block. Both `blockTemplateOverlay` and `searchTemplate` ship
334     * the same `{enabled, editorUrl, postType, isCustomized}` shape to
335     * the React dashboard. The `$enabled` and `$can_edit` flags are
336     * intentionally separate: the card lights up as "active" on
337     * `$enabled`, but the URLs / `postType` surface as soon as `$can_edit`
338     * is true so admins can pre-customize the template before activating
339     * the experience â€” without forcing a page reload after the switch.
340     *
341     * `postType` (rather than a pre-built REST path) is what the dashboard
342     * needs: the React "Restore default" handler builds the
343     * `${wpcomOriginApiUrl}jetpack/v4/search/templates/<post_type>` URL
344     * itself, which is how the request reaches the right route on wpcom
345     * Simple sites (the local /wp-json/ surface there doesn't expose
346     * Jetpack routes). The handler re-checks `manage_options` server-side,
347     * so omitting the URLs / postType here just keeps the wire payload
348     * tight for non-admins.
349     *
350     * @param bool   $enabled   Whether the CPT-backed route is currently live on the site.
351     * @param bool   $can_edit  Whether to expose the editor URL + postType (operator-level gate + capability).
352     * @param string $cpt_class Concrete `Singleton_Template_Cpt` subclass to query.
353     * @return array{enabled: bool, editorUrl: string|null, postType: string|null, isCustomized: bool}
354     */
355    protected function build_singleton_template_config( bool $enabled, bool $can_edit, string $cpt_class ): array {
356        return array(
357            'enabled'      => $enabled,
358            'editorUrl'    => $can_edit ? $cpt_class::get_editor_url() : null,
359            'postType'     => $can_edit ? $cpt_class::POST_TYPE : null,
360            'isCustomized' => $can_edit && $cpt_class::is_customized(),
361        );
362    }
363
364    /**
365     * Check whether the current site is private.
366     *
367     * @return bool
368     */
369    protected function is_private_site() {
370        if ( function_exists( 'is_private_blog' ) ) {
371            return (bool) \is_private_blog();
372        }
373
374        return -1 === (int) get_option( 'blog_public', 1 );
375    }
376
377    /**
378     * Gather data about the current user.
379     *
380     * @return array
381     */
382    protected function current_user_data() {
383        $current_user      = wp_get_current_user();
384        $is_user_connected = $this->connection_manager->is_user_connected( $current_user->ID );
385        $is_master_user    = $is_user_connected && (int) $current_user->ID && (int) Jetpack_Options::get_option( 'master_user' ) === (int) $current_user->ID;
386        $dotcom_data       = $this->connection_manager->get_connected_user_data();
387
388        $current_user_data = array(
389            'isConnected' => $is_user_connected,
390            'isMaster'    => $is_master_user,
391            'username'    => $current_user->user_login,
392            'id'          => $current_user->ID,
393            'wpcomUser'   => $dotcom_data,
394            'permissions' => array(
395                'manage_options' => current_user_can( 'manage_options' ),
396            ),
397        );
398
399        return $current_user_data;
400    }
401
402    /**
403     * Gets the post type labels for all of the site's post types (including custom post types)
404     *
405     * @return array
406     */
407    protected function get_post_types_with_labels() {
408
409        $args = array(
410            'public' => true,
411        );
412
413        $post_types_with_labels = array();
414
415        $post_types = get_post_types( $args, 'objects' );
416
417        // We don't need all the additional post_type data, just the slug & label
418        foreach ( $post_types as $post_type ) {
419            $post_type_with_label = array(
420                'slug'  => $post_type->name,
421                'label' => $post_type->label,
422            );
423
424            $post_types_with_labels[ $post_type->name ] = $post_type_with_label;
425        }
426        return $post_types_with_labels;
427    }
428
429    /**
430     * Gets a purchase token that is used for Jetpack logged out visitor checkout.
431     * The purchase token should be appended to all CTA url's that lead to checkout.
432     *
433     * @return string|boolean
434     */
435    protected function get_purchase_token() {
436        if ( ! $this->current_user_can_purchase() ) {
437            return false;
438        }
439
440        $purchase_token = Jetpack_Options::get_option( 'purchase_token', false );
441
442        if ( $purchase_token ) {
443            return $purchase_token;
444        }
445        // If the purchase token is not saved in the options table yet, then add it.
446        Jetpack_Options::update_option( 'purchase_token', $this->generate_purchase_token(), true );
447        return Jetpack_Options::get_option( 'purchase_token', false );
448    }
449
450    /**
451     * Generates a purchase token that is used for Jetpack logged out visitor checkout.
452     *
453     * @return string
454     */
455    protected function generate_purchase_token() {
456        return wp_generate_password( 12, false );
457    }
458
459    /**
460     * Determine if the current user is allowed to make Jetpack purchases without
461     * a WordPress.com account
462     *
463     * @return boolean True if the user can make purchases, false if not
464     */
465    public function current_user_can_purchase() {
466        // The site must be site-connected to Jetpack (no users connected).
467        if ( ! $this->connection_manager->is_site_connection() ) {
468            return false;
469        }
470
471        // Make sure only administrators can make purchases.
472        if ( ! current_user_can( 'manage_options' ) ) {
473            return false;
474        }
475
476        return true;
477    }
478}