Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
56.94% covered (warning)
56.94%
41 / 72
11.11% covered (danger)
11.11%
1 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Admin_Page
56.94% covered (warning)
56.94%
41 / 72
11.11% covered (danger)
11.11%
1 / 9
85.19
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 add_wp_admin_submenu
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
4
 admin_init
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 maybe_load_wp_build
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 inject_podcast_script_data
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
9
 load_wp_build
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 alias_screen_id_for_wp_build
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 render
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
2
 is_podcast_admin_request
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2/**
3 * Registers the Jetpack Podcast wp-admin page and loads the wp-build dashboard.
4 *
5 * @package automattic/jetpack-podcast
6 */
7
8namespace Automattic\Jetpack\Podcast;
9
10use Automattic\Jetpack\Admin_UI\Admin_Menu;
11use Automattic\Jetpack\Connection\Manager as Connection_Manager;
12use Automattic\Jetpack\Status\Host;
13use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
14
15/**
16 * Adds the "Jetpack > Podcast" wp-admin screen.
17 */
18class Admin_Page {
19
20    const ADMIN_PAGE_SLUG = 'jetpack-podcast';
21
22    /**
23     * Query var the checkout return URL carries so the gate busts its cached
24     * purchases lookup the instant a buyer lands back on the dashboard. Kept in
25     * sync with the `podcast_purchased` literal in `withPurchaseReturnMarker()`
26     * (`src/dashboard/upgrade.ts`).
27     */
28    const PURCHASE_RETURN_QUERY_VAR = 'podcast_purchased';
29
30    /**
31     * Where the Podcast item sits in the Jetpack submenu on self-hosted.
32     *
33     * Placed after content/product items like Newsletter and Search (10), but
34     * before Settings (13).
35     */
36    const MENU_POSITION = 12;
37
38    /**
39     * Slug emitted by `@wordpress/build`. wp-build's auto-generated enqueue
40     * callback only fires when `$screen->id` matches this value, so we alias
41     * the screen id via `current_screen` without changing the user-facing URL.
42     */
43    const WP_BUILD_SLUG = 'jetpack-podcast-dashboard';
44
45    /**
46     * Whether `init()` has already wired its hooks.
47     *
48     * @var bool
49     */
50    private static $initialized = false;
51
52    /**
53     * Wire admin hooks. Idempotent.
54     */
55    public static function init() {
56        if ( self::$initialized ) {
57            return;
58        }
59        self::$initialized = true;
60
61        add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
62
63        // On Simple/Atomic, wpcom-admin-menu.php builds the Jetpack menu at
64        // priority 999999 and calls add_wp_admin_submenu() itself. Self-hosted
65        // has no such file, so we register our own. Priority 999 queues the item
66        // before Admin_Menu's priority-1000 callback.
67        if ( ! ( new Host() )->is_wpcom_platform() ) {
68            add_action( 'admin_menu', array( __CLASS__, 'add_wp_admin_submenu' ), 999 );
69        }
70    }
71
72    /**
73     * Register the Podcast submenu under the Jetpack menu.
74     */
75    public static function add_wp_admin_submenu() {
76        // Prefer the wp-build render function once it's defined (by
77        // maybe_load_wp_build() at admin_menu priority 1); fall back otherwise.
78        $wp_build_render = 'jetpack_podcast_jetpack_podcast_dashboard_wp_admin_render_page';
79        $callback        = function_exists( $wp_build_render ) ? $wp_build_render : array( __CLASS__, 'render' );
80
81        if ( ( new Host() )->is_wpcom_platform() ) {
82            $page_suffix = add_submenu_page(
83                'jetpack',
84                /** "Podcast" is a product name, do not translate. */
85                'Podcast',
86                'Podcast',
87                'manage_options',
88                self::ADMIN_PAGE_SLUG,
89                $callback
90            );
91        } else {
92            $page_suffix = Admin_Menu::add_menu(
93                /** "Podcast" is a product name, do not translate. */
94                'Podcast',
95                'Podcast',
96                'manage_options',
97                self::ADMIN_PAGE_SLUG,
98                $callback,
99                self::MENU_POSITION
100            );
101        }
102
103        if ( $page_suffix ) {
104            add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) );
105        }
106    }
107
108    /**
109     * Wire admin-init actions once we know the Podcast page is loading.
110     */
111    public static function admin_init() {
112        // MediaUpload (cover-image-control) reads wp.media.view — only defined after this runs.
113        add_action( 'admin_enqueue_scripts', 'wp_enqueue_media' );
114    }
115
116    /**
117     * Hooked at admin_menu priority 1 so polyfills register before
118     * `wp_default_scripts` fires and the wp-build render function is defined
119     * before `add_wp_admin_submenu()` runs (priority 999 on self-hosted, 999999
120     * on Simple/Atomic).
121     */
122    public static function maybe_load_wp_build() {
123        if ( ! self::is_podcast_admin_request() ) {
124            return;
125        }
126
127        self::load_wp_build();
128        add_action( 'current_screen', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
129        add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'inject_podcast_script_data' ) );
130    }
131
132    /**
133     * Add the podcast gate boolean to `window.JetpackScriptData`.
134     *
135     * Hooked from `maybe_load_wp_build()` so it only runs when the request is
136     * for the podcast admin page.
137     *
138     * @param array $data Script data being injected.
139     * @return array
140     */
141    public static function inject_podcast_script_data( $data ) {
142        if ( ! is_array( $data ) ) {
143            $data = array();
144        }
145
146        $is_wpcom = ( new Host() )->is_wpcom_platform();
147
148        if ( ! $is_wpcom && empty( $data['site']['wpcom']['blog_id'] ) ) {
149            $blog_id = (int) Connection_Manager::get_site_id( true );
150            if ( $blog_id > 0 ) {
151                $data['site']['wpcom']['blog_id'] = $blog_id;
152            }
153        }
154
155        // A buyer returning from checkout carries the purchase marker; bust the
156        // cached purchases lookup so the gate re-reads `/upgrades` and unlocks
157        // the paid surfaces now instead of after the transient expires.
158        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
159        if ( isset( $_GET[ self::PURCHASE_RETURN_QUERY_VAR ] ) ) {
160            Podcast_Gate::flush_purchases_cache();
161        }
162
163        // Self-hosted upsells the Growth plan; WordPress.com keeps Premium.
164        // `product_slug` is fed straight to the checkout URL; `plan_name` is a
165        // product name shown in the locked-preview copy (not translated).
166        $data['podcast'] = array(
167            'has_product_access'  => Podcast_Gate::has_product_access(),
168            'is_connected'        => $is_wpcom || ( new Connection_Manager( 'jetpack' ) )->is_connected(),
169            'show_url_hosts'      => Settings::SHOW_URL_HOSTS,
170            'show_url_max_length' => Settings::SHOW_URL_MAX_LENGTH,
171            // Settings only: categories rejects per_page=-1 server-side, stats is a live relay.
172            'preload'             => rest_preload_api_request( array(), '/wpcom/v2/podcast/settings' ),
173            'upgrade'             => array(
174                'product_slug' => $is_wpcom ? 'premium' : 'jetpack_growth_yearly',
175                'plan_name'    => $is_wpcom ? 'Premium' : 'Growth',
176            ),
177        );
178
179        return $data;
180    }
181
182    /**
183     * The build artifact may be absent on a fresh checkout before
184     * `pnpm build` has run; in that case `add_wp_admin_submenu()` falls back
185     * to `render()` so the page still loads (just without the React app).
186     */
187    private static function load_wp_build() {
188        $build_index = dirname( __DIR__ ) . '/build/build.php';
189
190        if ( ! file_exists( $build_index ) ) {
191            return;
192        }
193
194        require_once $build_index;
195
196        WP_Build_Polyfills::register(
197            'jetpack-podcast',
198            array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
199        );
200    }
201
202    /**
203     * Alias the current screen id to wp-build's expected slug.
204     *
205     * @param \WP_Screen|null $screen The current screen object (passed by WP).
206     */
207    public static function alias_screen_id_for_wp_build( $screen ) {
208        if ( ! is_object( $screen ) ) {
209            return;
210        }
211
212        $screen->id = self::WP_BUILD_SLUG;
213    }
214
215    /**
216     * Fallback render used when the wp-build artifact is missing.
217     */
218    public static function render() {
219        ?>
220        <div class="wrap">
221            <h1>Podcast</h1>
222        </div>
223        <?php
224    }
225
226    /**
227     * Whether the current request targets the Podcast admin page.
228     */
229    private static function is_podcast_admin_request() {
230        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
231        if ( ! is_admin() || ! isset( $_GET['page'] ) ) {
232            return false;
233        }
234
235        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
236        return self::ADMIN_PAGE_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) );
237    }
238}