Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.29% covered (success)
95.29%
162 / 170
83.33% covered (warning)
83.33%
25 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_Hosting_Feature_Page
95.86% covered (success)
95.86%
162 / 169
83.33% covered (warning)
83.33%
25 / 30
94
0.00% covered (danger)
0.00%
0 / 1
 has_feature
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has_atomic_feature
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 site_has_feature
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
4.25
 is_atomic
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 init
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_rest_route
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_rest_routes
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 can_read_feature_status
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_feature_status
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 is_checkout_pending
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
4
 should_register
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_page_title
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_menu_title
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 register_page
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 hides_menu_entry
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 hide_menu_entry
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 get_initial_state
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
7
 load_atomic_lib
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 is_transfer_in_progress
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
5.39
 get_latest_transfer_status
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
6
 get_eligibility
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
3.58
 get_state
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
5
 get_transfer_errors
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 get_transfer_warnings
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
11.02
 get_support_url
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 get_upgrade_url
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 get_plan_upgrade_url
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
5
 get_live_feature_url
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 get_post_transfer_host
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 get_activate_url
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Shared base for in-wp-admin pages that prompt WordPress.com Simple and WoA sites
4 * to upgrade, or to transfer to WoA, before a hosting feature can run.
5 *
6 * @package automattic/jetpack-mu-wpcom
7 */
8
9namespace Automattic\Jetpack\Jetpack_Mu_Wpcom;
10
11use Automattic\Jetpack\Constants;
12
13require_once __DIR__ . '/../../common/class-wpcom-wp-build-page.php';
14
15/**
16 * Registers a hosting feature's admin page and its wp-build assets.
17 *
18 * Each page subclasses this, overriding the page constants.
19 */
20abstract class WPCOM_Hosting_Feature_Page extends WPCOM_WP_Build_Page {
21
22    /**
23     * Document title of the page. A product name, so not translated.
24     */
25    const PAGE_TITLE = '';
26
27    /**
28     * Menu entry label. A product name, so not translated.
29     */
30    const MENU_TITLE = '';
31
32    /**
33     * Name of the `WPCOM_Features` constant the site's plan must include.
34     */
35    const FEATURE = '';
36
37    /**
38     * Calypso flow that initiates the transfer, reports progress and returns here.
39     */
40    const TRANSFER_FLOW_URL = 'https://wordpress.com/setup/transferring-hosted-site';
41
42    /**
43     * Tracks context recorded for the WoA transfer.
44     */
45    const TRANSFER_CONTEXT = 'jetpack_product_activation';
46
47    /**
48     * Shared by every hosting feature page, whose boot code is shared too.
49     */
50    const INITIAL_STATE_VAR = 'wpcomHostingFeatureInitialState';
51
52    /**
53     * The pages record their prompts in Tracks.
54     */
55    const ENQUEUE_TRACKS = true;
56
57    /**
58     * Query arg the plan upgrade flow returns a WoA buyer with, set to CHECKOUT_COMPLETE.
59     */
60    const CHECKOUT_ARG = 'checkout';
61
62    /**
63     * Value of CHECKOUT_ARG once checkout has succeeded.
64     */
65    const CHECKOUT_COMPLETE = 'complete';
66
67    /**
68     * Site has no plan that includes the feature. Offer an upgrade.
69     */
70    const STATE_UPGRADE = 'upgrade';
71
72    /**
73     * A transfer to WoA is already running or queued. Offer nothing; report status.
74     */
75    const STATE_IN_PROGRESS = 'in_progress';
76
77    /**
78     * Site has the plan and can transfer. Offer activation.
79     */
80    const STATE_ACTIVATE = 'activate';
81
82    /**
83     * A WoA site has the plan, but the Jetpack plugin's dashboard for it is turned off. Offer to turn it on.
84     */
85    const STATE_ENABLE = 'enable';
86
87    /**
88     * Whether the site's plan includes FEATURE. Without one it offers the upgrade, the one
89     * prompt that is never wrong for a site of unknown plan.
90     *
91     * @return bool
92     */
93    public static function has_feature() {
94        return self::site_has_feature( static::FEATURE );
95    }
96
97    /**
98     * Whether the site's plan includes WordPress.com's Atomic hosting, which these features run on.
99     *
100     * @return bool
101     */
102    public static function has_atomic_feature() {
103        return self::site_has_feature( 'ATOMIC' );
104    }
105
106    /**
107     * Whether the site's plan includes a WordPress.com feature.
108     *
109     * @param string $name Name of a `WPCOM_Features` constant.
110     * @return bool
111     */
112    private static function site_has_feature( $name ) {
113        $constant = '\WPCOM_Features::' . $name;
114
115        if ( '' === $name || ! function_exists( 'wpcom_site_has_feature' ) || ! defined( $constant ) ) {
116            return false;
117        }
118
119        // Called without a blog ID: on WoA the local ID is not the WordPress.com one, so wpcom resolves the current site.
120        return (bool) wpcom_site_has_feature( constant( $constant ) );
121    }
122
123    /**
124     * Whether the site runs on WordPress.com's Atomic infrastructure.
125     *
126     * Read through Constants rather than `defined()` so tests can exercise both
127     * platforms in one process.
128     *
129     * @return bool
130     */
131    public static function is_atomic() {
132        return Constants::is_true( 'IS_ATOMIC' );
133    }
134
135    /**
136     * Boot the page, plus, on WoA, the route its post-checkout screen polls.
137     *
138     * @return void
139     */
140    public static function init() {
141        // Registered even when the page steps aside, so a poll still in flight learns the feature arrived.
142        if ( self::is_atomic() ) {
143            add_action( 'rest_api_init', array( static::class, 'register_rest_routes' ) );
144        }
145
146        parent::init();
147    }
148
149    /**
150     * The route reporting whether the plan includes FEATURE yet, relative to `wpcom/v2`.
151     *
152     * @return string
153     */
154    public static function get_rest_route() {
155        return '/hosting-feature-page/' . static::MENU_SLUG;
156    }
157
158    /**
159     * Register the route that reports whether the plan includes FEATURE yet.
160     *
161     * @return void
162     */
163    public static function register_rest_routes() {
164        register_rest_route(
165            'wpcom/v2',
166            static::get_rest_route(),
167            array(
168                'methods'             => \WP_REST_Server::READABLE,
169                'callback'            => array( static::class, 'get_feature_status' ),
170                'permission_callback' => array( static::class, 'can_read_feature_status' ),
171            )
172        );
173    }
174
175    /**
176     * Whether the current user may poll the feature status, as they may see the page.
177     *
178     * @return bool
179     */
180    public static function can_read_feature_status() {
181        return current_user_can( static::CAPABILITY );
182    }
183
184    /**
185     * Whether the plan includes FEATURE, read from the site's own copy of its purchases, which the page renders from.
186     *
187     * Not WordPress.com's, which knows about a purchase before the copy synced to WoA does.
188     *
189     * @return \WP_REST_Response
190     */
191    public static function get_feature_status() {
192        $has_feature = static::has_feature();
193
194        // The Jetpack dashboard the page reloads into reads Jetpack's stored plan, which can lag that copy.
195        if ( $has_feature && method_exists( '\Automattic\Jetpack\Current_Plan', 'refresh_from_wpcom' ) ) {
196            \Automattic\Jetpack\Current_Plan::refresh_from_wpcom( array( 'timeout' => 5 ) );
197        }
198
199        return rest_ensure_response( array( 'hasFeature' => $has_feature ) );
200    }
201
202    /**
203     * Whether a WoA buyer just returned from checkout to a page that has not seen the purchase yet.
204     *
205     * @param string $state The resolved STATE_* constant.
206     * @return bool
207     */
208    public static function is_checkout_pending( $state ) {
209        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only flag set by the plan upgrade flow's return address.
210        $checkout = isset( $_GET[ self::CHECKOUT_ARG ] ) ? sanitize_key( wp_unslash( $_GET[ self::CHECKOUT_ARG ] ) ) : '';
211
212        return self::STATE_UPGRADE === $state && self::is_atomic() && self::CHECKOUT_COMPLETE === $checkout;
213    }
214
215    /**
216     * Whether this page should claim its slug at all.
217     *
218     * A plan that includes the feature only makes it live on Atomic infrastructure, so on
219     * WoA an entitled site already has it and this page steps aside. A Simple site is never
220     * Atomic, so the page always applies there.
221     *
222     * @return bool
223     */
224    public static function should_register() {
225        if ( ! self::is_atomic() ) {
226            return true;
227        }
228
229        return ! static::has_feature();
230    }
231
232    /**
233     * The page title.
234     *
235     * @return string
236     */
237    public static function get_page_title() {
238        return static::PAGE_TITLE;
239    }
240
241    /**
242     * The menu entry's label.
243     *
244     * @return string
245     */
246    public static function get_menu_title() {
247        return static::MENU_TITLE;
248    }
249
250    /**
251     * Register the page, unless something else already serves the slug, and hide its entry.
252     *
253     * @return void
254     */
255    public static function register_page() {
256        parent::register_page();
257
258        if ( static::owns_page() && static::hides_menu_entry() ) {
259            self::hide_menu_entry();
260        }
261    }
262
263    /**
264     * Whether this page keeps its entry out of the sidebar.
265     *
266     * @return bool
267     */
268    public static function hides_menu_entry() {
269        return true;
270    }
271
272    /**
273     * Keep this page's entry out of the sidebar, since Calypso owns the nav; hidden rather
274     * than removed, so the page stays reachable.
275     *
276     * Done here rather than in wpcom-admin-menu.php, which not every admin loads.
277     *
278     * @return void
279     */
280    private static function hide_menu_entry() {
281        global $submenu;
282
283        foreach ( $submenu[ static::PARENT_SLUG ] ?? array() as $i => $item ) {
284            if ( static::MENU_SLUG !== ( $item[2] ?? null ) || str_contains( $item[4] ?? '', 'hide-if-js' ) ) {
285                continue;
286            }
287
288            // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
289            $submenu[ static::PARENT_SLUG ][ $i ][4] = trim( ( $item[4] ?? '' ) . ' hide-if-js' );
290        }
291    }
292
293    /**
294     * The resolved prompt and everything it links to.
295     *
296     * @return array
297     */
298    public static function get_initial_state() {
299        $blog_id = get_current_blog_id();
300        $domain  = wp_parse_url( home_url(), PHP_URL_HOST );
301        $state   = static::get_state( $blog_id );
302
303        // Only the activation prompt reads eligibility, and both lists come from the
304        // same result, so resolve it once and only for that state.
305        $eligibility     = self::STATE_ACTIVATE === $state
306            ? self::get_eligibility( $blog_id, get_current_user_id() )
307            : null;
308        $warnings        = self::get_transfer_warnings( $eligibility );
309        $transfer_status = self::STATE_IN_PROGRESS === $state ? null : self::get_latest_transfer_status( $blog_id );
310
311        $initial_state = array(
312            'state'               => $state,
313            'domain'              => $domain,
314            // A null result means the library was unavailable, not that the site
315            // failed a check; let the transfer flow reject it instead.
316            'isEligible'          => null === $eligibility || ! empty( $eligibility['is_eligible'] ),
317            'errors'              => self::get_transfer_errors( $eligibility ),
318            'warnings'            => $warnings,
319            'upgradeUrl'          => static::get_upgrade_url( $domain ),
320            'activateUrl'         => static::get_activate_url( $warnings ),
321            'supportUrl'          => self::get_support_url(),
322            // Each applies only to the prompt Calypso pairs it with.
323            'hasRevertedTransfer' => self::STATE_UPGRADE === $state && 'reverted' === $transfer_status,
324            'transferFailed'      => self::STATE_ACTIVATE === $state && 'error' === $transfer_status,
325            'checkoutPending'     => static::is_checkout_pending( $state ),
326        );
327
328        if ( $initial_state['checkoutPending'] ) {
329            $initial_state['featureStatusPath'] = '/wpcom/v2' . static::get_rest_route();
330        }
331
332        return $initial_state;
333    }
334
335    /**
336     * Load wpcom's Atomic library for the transfer checks, which callers still guard with function_exists().
337     *
338     * @return bool False on WoA, where there is nothing left to transfer.
339     */
340    private static function load_atomic_lib() {
341        if ( self::is_atomic() ) {
342            return false;
343        }
344
345        if ( function_exists( 'require_lib' ) ) {
346            require_lib( 'atomic' );
347        }
348
349        return true;
350    }
351
352    /**
353     * Whether a transfer to WoA is already underway.
354     *
355     * @param int $blog_id Blog ID.
356     * @return bool
357     */
358    public static function is_transfer_in_progress( $blog_id ) {
359        if ( ! self::load_atomic_lib() ) {
360            return false;
361        }
362
363        if ( function_exists( '\A8C\Atomic\has_site_pending_automated_transfer' ) ) {
364            if ( \A8C\Atomic\has_site_pending_automated_transfer( $blog_id ) ) {
365                return true;
366            }
367        }
368
369        if ( function_exists( '\A8C\Atomic\is_wpcom_atomic' ) ) {
370            // Second argument includes pending/active/provisioned transfers.
371            return (bool) \A8C\Atomic\is_wpcom_atomic( $blog_id, true );
372        }
373
374        return false;
375    }
376
377    /**
378     * Status of the site's latest transfer to WoA, or null when it has none.
379     *
380     * @param int $blog_id Blog ID.
381     * @return string|null A transfer record status, such as `reverted` or `error`.
382     */
383    public static function get_latest_transfer_status( $blog_id ) {
384        if ( ! self::load_atomic_lib() || ! function_exists( 'woa_get_latest_transfer' ) ) {
385            return null;
386        }
387
388        $transfer = woa_get_latest_transfer( $blog_id );
389
390        if ( is_wp_error( $transfer ) || ! is_object( $transfer ) || ! isset( $transfer->status ) ) {
391            return null;
392        }
393
394        return (string) $transfer->status;
395    }
396
397    /**
398     * Transfer eligibility for the site, or null when it cannot be determined.
399     *
400     * An in-progress transfer is reported as a `transfer_already_exists` error, so
401     * that state is checked first and never reaches here as a blocker.
402     *
403     * @param int $blog_id Blog ID.
404     * @param int $user_id User ID.
405     * @return array|null { is_eligible: bool, errors: array, warnings: array }
406     */
407    public static function get_eligibility( $blog_id, $user_id ) {
408        if ( ! self::load_atomic_lib() ) {
409            return null;
410        }
411
412        if ( ! function_exists( '\A8C\Atomic\Eligibility\get_status_for_site' ) ) {
413            return null;
414        }
415
416        return \A8C\Atomic\Eligibility\get_status_for_site( $blog_id, $user_id );
417    }
418
419    /**
420     * Resolve which prompt the page should show.
421     *
422     * @param int $blog_id Blog ID.
423     * @return string One of the STATE_* constants.
424     */
425    public static function get_state( $blog_id ) {
426        // On WoA the plan is the only question: the site is already on the
427        // infrastructure that runs the feature, so there is nothing to transfer, and
428        // should_register() has already stepped the page aside if it has the plan.
429        // On Simple, activation also needs the hosting platform, as Calypso requires.
430        if ( ! static::has_feature() || ! self::has_atomic_feature() || self::is_atomic() ) {
431            $state = self::STATE_UPGRADE;
432        } elseif ( self::is_transfer_in_progress( $blog_id ) ) {
433            $state = self::STATE_IN_PROGRESS;
434        } else {
435            // Eligibility failures are explained by the activation prompt's own
436            // confirmation step, so they do not get a state of their own.
437            $state = self::STATE_ACTIVATE;
438        }
439
440        return $state;
441    }
442
443    /**
444     * Reasons a site cannot be transferred, in the shape the API returns them.
445     *
446     * The code is kept because the page maps it to its own copy; the API's message comes
447     * along as the fallback the page renders for codes it does not recognize.
448     *
449     * @param array|null $eligibility Result of get_eligibility().
450     * @return array[] Errors as { code, message }.
451     */
452    public static function get_transfer_errors( $eligibility ) {
453        $errors = array();
454
455        if ( ! empty( $eligibility['errors'] ) && is_array( $eligibility['errors'] ) ) {
456            foreach ( $eligibility['errors'] as $error ) {
457                if ( empty( $error['code'] ) ) {
458                    continue;
459                }
460
461                $errors[] = array(
462                    'code'    => (string) $error['code'],
463                    'message' => isset( $error['message'] ) ? (string) $error['message'] : '',
464                );
465            }
466        }
467
468        return $errors;
469    }
470
471    /**
472     * Non-blocking warnings to confirm before a transfer starts.
473     *
474     * The API groups warnings by type; they are flattened here because the page
475     * renders one list and a PHP map would localize as a JSON array once empty.
476     *
477     * @param array|null $eligibility Result of get_eligibility().
478     * @return array[] Warnings in the API's own shape, minus the grouping.
479     */
480    public static function get_transfer_warnings( $eligibility ) {
481        $warnings = array();
482
483        if ( ! empty( $eligibility['warnings'] ) && is_array( $eligibility['warnings'] ) ) {
484            foreach ( $eligibility['warnings'] as $group ) {
485                if ( ! is_array( $group ) ) {
486                    continue;
487                }
488
489                foreach ( $group as $warning ) {
490                    if ( empty( $warning['id'] ) ) {
491                        continue;
492                    }
493
494                    $warnings[] = array(
495                        'id'           => (string) $warning['id'],
496                        'description'  => isset( $warning['description'] ) ? (string) $warning['description'] : '',
497                        'domain_names' => ( ! empty( $warning['domain_names']['current'] ) && ! empty( $warning['domain_names']['new'] ) )
498                            ? array(
499                                'current' => (string) $warning['domain_names']['current'],
500                                'new'     => (string) $warning['domain_names']['new'],
501                            )
502                            : null,
503                        // Rendered as a link, so only a web URL gets through.
504                        'support_url'  => isset( $warning['support_url'] ) ? esc_url_raw( (string) $warning['support_url'], array( 'http', 'https' ) ) : '',
505                    );
506                }
507            }
508        }
509
510        return $warnings;
511    }
512
513    /**
514     * Support's contact page, in the reader's language where WordPress.com has one.
515     *
516     * @return string
517     */
518    public static function get_support_url() {
519        $url = 'https://wordpress.com/help/contact';
520
521        return function_exists( 'localized_wpcom_url' ) ? localized_wpcom_url( $url ) : $url;
522    }
523
524    /**
525     * On Simple, checkout with the Business plan already in the cart; on WoA, the plan upgrade flow.
526     *
527     * `redirect_to` returns a buyer to activate what they just bought, and
528     * `checkoutBackUrl` returns someone who backs out; without it checkout falls back to /plans.
529     *
530     * @param string $domain Site domain.
531     * @return string
532     */
533    public static function get_upgrade_url( $domain ) {
534        // On WoA the purchase makes the feature live, so there is nothing to come back here for.
535        if ( self::is_atomic() ) {
536            return static::get_plan_upgrade_url();
537        }
538
539        $page_url = rawurlencode( static::get_page_url() );
540
541        return add_query_arg(
542            array(
543                'redirect_to'     => $page_url,
544                'checkoutBackUrl' => $page_url,
545            ),
546            'https://wordpress.com/checkout/' . rawurlencode( (string) $domain ) . '/business'
547        );
548    }
549
550    /**
551     * WordPress.com's plan upgrade flow, showing only plans with FEATURE, with Back to the page the link was on.
552     *
553     * The flow and checkout accept `back_to` and `redirect_to` only on the site's own host or WordPress.com.
554     *
555     * @param string $back_url The admin page the link is on; this page when empty or off this site.
556     * @return string
557     */
558    public static function get_plan_upgrade_url( $back_url = '' ) {
559        $back     = '' === $back_url ? '' : wp_validate_redirect( $back_url, '' );
560        $constant = '\WPCOM_Features::' . static::FEATURE;
561        $live_url = static::get_live_feature_url();
562
563        // Only this page reads the arg, and only a completed purchase follows `redirect_to`.
564        if ( static::get_page_url() === $live_url ) {
565            $live_url = add_query_arg( self::CHECKOUT_ARG, self::CHECKOUT_COMPLETE, $live_url );
566        }
567
568        return add_query_arg(
569            array_filter(
570                array(
571                    'siteSlug'    => rawurlencode( wpcom_get_site_slug() ),
572                    'feature'     => defined( $constant ) ? constant( $constant ) : '',
573                    'back_to'     => rawurlencode( $back ? $back : static::get_page_url() ),
574                    'redirect_to' => rawurlencode( $live_url ),
575                )
576            ),
577            'https://wordpress.com/setup/plan-upgrade'
578        );
579    }
580
581    /**
582     * Where a site lands once its plan makes the feature live and this page steps aside.
583     *
584     * By default the slug's next owner serves the feature at this page's own address.
585     *
586     * @param string|null $new_host The site's address after a transfer, when it changes.
587     * @return string
588     */
589    public static function get_live_feature_url( $new_host = null ) {
590        $page_url = static::get_page_url();
591
592        if ( null === $new_host ) {
593            return $page_url;
594        }
595
596        $parts = wp_parse_url( $page_url );
597
598        return 'https://' . $new_host . ( $parts['path'] ?? '' ) . ( isset( $parts['query'] ) ? '?' . $parts['query'] : '' );
599    }
600
601    /**
602     * The site's new address from the address-change warning, if the transfer changes it.
603     *
604     * Used rather than trusting the old address to redirect, which may not have propagated
605     * when the flow sends the reader back.
606     *
607     * @param array[] $warnings Result of get_transfer_warnings().
608     * @return string|null
609     */
610    public static function get_post_transfer_host( array $warnings ) {
611        foreach ( $warnings as $warning ) {
612            $new_host = $warning['domain_names']['new'] ?? '';
613
614            // A bare hostname only, so nothing in the payload can steer the scheme or path.
615            if ( is_string( $new_host ) && filter_var( $new_host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME ) ) {
616                return strtolower( $new_host );
617            }
618        }
619
620        return null;
621    }
622
623    /**
624     * URL of the Calypso flow that transfers the site and switches the feature on.
625     *
626     * The flow waits for the transfer to land before following `redirect_to`.
627     *
628     * @param array[] $warnings Result of get_transfer_warnings(), for the post-transfer address.
629     * @return string
630     */
631    public static function get_activate_url( array $warnings = array() ) {
632        return add_query_arg(
633            array(
634                // Only Simple sites reach activation, and their local blog ID is the WordPress.com one; WoA's is not.
635                'siteId'                    => get_current_blog_id(),
636                'initiate_transfer_context' => self::TRANSFER_CONTEXT,
637                'redirect_to'               => rawurlencode( static::get_live_feature_url( self::get_post_transfer_host( $warnings ) ) ),
638            ),
639            self::TRANSFER_FLOW_URL
640        );
641    }
642}