Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.33% covered (success)
93.33%
56 / 60
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
AI_Launchpad_Difm_Offer
93.33% covered (success)
93.33%
56 / 60
50.00% covered (danger)
50.00%
4 / 8
34.34
0.00% covered (danger)
0.00%
0 / 1
 get_offer
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 get_cta_url
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 is_eligible
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
8
 has_eligible_plan
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
 is_recent_site
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
7.05
 is_english_user
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 is_dismissed
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 get_variation
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
1<?php
2/**
3 * AI Launchpad: the free DIFM offer banner's eligibility and experiment assignment.
4 *
5 * @package automattic/jetpack-mu-wpcom
6 */
7
8use Automattic\Jetpack\Jetpack_Mu_Wpcom\Expiry_Notices\Expiry_Data;
9use Automattic\Jetpack\Status\Host;
10
11/**
12 * Decides whether the Site Setup page shows the free DIFM offer banner.
13 *
14 * The rules are the same as the Calypso banners on /themes/<site> and /sites/<site>
15 * (client/dashboard/utils/difm-offer.ts), so one user sees the same offer on every surface.
16 */
17class AI_Launchpad_Difm_Offer {
18
19    /**
20     * The ExPlat experiment, the same one Calypso uses. It is registered on the calypso platform,
21     * which the server-side assigner also accepts.
22     */
23    const EXPERIMENT = 'calypso_difm_free_with_upgrade_wpadmin_202610_v1';
24
25    /**
26     * The copy arms. Each one shows the banner with its own copy, and `control` shows none.
27     * The names match the ExPlat variations.
28     */
29    const ARMS = array( 'expert_help', 'no_time_to_build', 'skip_the_setup' );
30
31    /**
32     * The arm that the override filter forces.
33     */
34    const FORCED_ARM = 'no_time_to_build';
35
36    /**
37     * The Calypso preference the Calypso banners also write, so one dismissal hides the offer everywhere.
38     */
39    const DISMISSED_PREFERENCE = 'hosting-dashboard-difm-offer-dismissed';
40
41    /**
42     * The oldest site, in days, that still gets the offer.
43     */
44    const MAX_SITE_AGE_DAYS = 7;
45
46    /**
47     * Free, Personal and Premium, every term, and the Personal trial. Same list as Calypso's ELIGIBLE_PLAN_SLUGS.
48     */
49    const ELIGIBLE_PLAN_SLUGS = array(
50        'free_plan',
51        'personal-bundle-monthly',
52        'wp-personal-bundle-free-trial-monthly',
53        'personal-bundle',
54        'personal-bundle-2y',
55        'personal-bundle-3y',
56        'value_bundle_monthly',
57        'value_bundle',
58        'value_bundle-2y',
59        'value_bundle-3y',
60    );
61
62    /**
63     * Calypso's englishLocales, in its slug format.
64     */
65    const ENGLISH_LOCALES = array( 'en', 'en-gb' );
66
67    /**
68     * The offer for the current user and site, or null when the banner must not show.
69     *
70     * The user is assigned to the experiment only after every other check passes, so an
71     * ineligible user never enters it.
72     *
73     * @param int|null $now Unix time to judge the site age against. Defaults to time().
74     * @return array{variation:string,image_url:string,cta_url:string}|null
75     */
76    public static function get_offer( $now = null ) {
77        if ( ! self::is_eligible( null === $now ? time() : $now ) ) {
78            return null;
79        }
80
81        $variation = self::get_variation();
82        if ( ! in_array( $variation, self::ARMS, true ) ) {
83            return null;
84        }
85
86        return array(
87            'variation' => $variation,
88            'image_url' => plugins_url( 'images/difm-offer-illustration.webp', __FILE__ ),
89            'cta_url'   => self::get_cta_url(),
90        );
91    }
92
93    /**
94     * The CTA destination: the Calypso site overview, which opens the offer modal from the
95     * `difm-offer` parameter. The base is built as the AI assistant banner builds its CTA.
96     *
97     * Raw escaping, not esc_url(): the URL goes out in JSON, and esc_url() would turn `&` into `&#038;`.
98     *
99     * @return string
100     */
101    private static function get_cta_url() {
102        return esc_url_raw(
103            add_query_arg(
104                array(
105                    'difmOffer' => 'open',
106                    'source'    => 'site-setup',
107                ),
108                wpcom_get_calypso_origin() . '/sites/' . wpcom_get_site_slug()
109            )
110        );
111    }
112
113    /**
114     * Every check that comes before the experiment assignment, cheapest and most decisive first.
115     *
116     * @param int $now Unix time to judge the site age against.
117     * @return bool
118     */
119    private static function is_eligible( $now ) {
120        // The REST route only needs edit_posts, but the page that shows the banner needs
121        // manage_options. Without this, an Author who calls the route would join the experiment.
122        if ( ! current_user_can( 'manage_options' ) ) {
123            return false;
124        }
125
126        // Eligible plans never run on Atomic, and the checks below read Simple-only data.
127        if ( ! ( new Host() )->is_wpcom_simple() ) {
128            return false;
129        }
130
131        $blog_id = get_current_blog_id();
132
133        return self::has_eligible_plan()
134            && self::is_recent_site( $now )
135            && self::is_english_user()
136            && ! wpcom_has_blog_sticker( 'a4a-is-dev-site', $blog_id )
137            && ! wpcom_has_blog_sticker( 'difm-lite-in-progress', $blog_id )
138            && ! self::is_dismissed();
139    }
140
141    /**
142     * Whether the site's plan is one the offer is for. A Simple site with no plan bundle is on the Free plan.
143     *
144     * Fails closed when the purchases helper is missing: an empty list would otherwise read as Free.
145     *
146     * @return bool
147     */
148    private static function has_eligible_plan() {
149        if ( ! function_exists( 'wpcom_expiry_get_purchases' ) ) {
150            return false;
151        }
152
153        // Real plan bundles only, as in wpcom_ai_assistant_banner_tracks_props(): the slug fallback
154        // would also match add-ons such as Professional Email Premium.
155        $bundles = wp_list_filter( (array) wpcom_expiry_get_purchases(), array( 'product_type' => 'bundle' ) );
156        $plan    = Expiry_Data::pick_primary_plan_purchase( $bundles );
157        $slug    = $plan && isset( $plan->product_slug ) && is_string( $plan->product_slug ) ? $plan->product_slug : 'free_plan';
158
159        return in_array( $slug, self::ELIGIBLE_PLAN_SLUGS, true );
160    }
161
162    /**
163     * Whether the site is at most MAX_SITE_AGE_DAYS old.
164     *
165     * Reads the blog's `registered` date, the source of `created_at` in the sites API. It is a GMT
166     * `Y-m-d H:i:s` string. There is no lower bound, as in Calypso.
167     *
168     * @param int $now Unix time to judge the site age against.
169     * @return bool
170     */
171    private static function is_recent_site( $now ) {
172        if ( ! function_exists( 'get_blog_details' ) ) {
173            return false;
174        }
175
176        $details    = get_blog_details( get_current_blog_id() );
177        $registered = is_object( $details ) && isset( $details->registered ) ? (string) $details->registered : '';
178        // strtotime( ' UTC' ) returns the current time, so a missing date would read as a new site.
179        if ( '' === $registered ) {
180            return false;
181        }
182
183        $created_at = strtotime( $registered . ' UTC' );
184
185        // A never-set registered date is all zeros, which parses to a time before the epoch.
186        if ( false === $created_at || $created_at <= 0 ) {
187            return false;
188        }
189
190        return $now - $created_at <= self::MAX_SITE_AGE_DAYS * DAY_IN_SECONDS;
191    }
192
193    /**
194     * Whether the user's interface language is English, in Calypso's slug format.
195     *
196     * WordPress.com stores slugs such as `en` and `en-gb`; core stores `en_US` and `en_GB`. Both map
197     * onto Calypso's set, where `en` is US English.
198     *
199     * @return bool
200     */
201    private static function is_english_user() {
202        $slug = str_replace( '_', '-', strtolower( (string) get_user_locale() ) );
203        if ( 'en-us' === $slug ) {
204            $slug = 'en';
205        }
206
207        return in_array( $slug, self::ENGLISH_LOCALES, true );
208    }
209
210    /**
211     * Whether the user dismissed the offer on any surface. Fails closed when the preferences are unreadable.
212     *
213     * @return bool
214     */
215    private static function is_dismissed() {
216        if ( ! function_exists( 'get_user_attribute' ) ) {
217            return true;
218        }
219
220        $preferences = get_user_attribute( get_current_user_id(), 'calypso_preferences' );
221
222        return is_array( $preferences ) && ! empty( $preferences[ self::DISMISSED_PREFERENCE ] );
223    }
224
225    /**
226     * The user's experiment variation. Assigns the user on first call.
227     *
228     * @return string|null
229     */
230    private static function get_variation() {
231        /**
232         * Overrides the DIFM offer experiment assignment on the Site Setup page, for testing.
233         *
234         * The eligibility checks still apply; only the assignment is replaced.
235         *
236         * @param bool|null $override True to force the `no_time_to_build` arm, false to force control, or null to ask ExPlat.
237         */
238        $override = apply_filters( 'wpcom_ai_launchpad_difm_offer_override_is_treatment', null );
239        if ( null !== $override ) {
240            return $override ? self::FORCED_ARM : 'control';
241        }
242
243        if ( ! function_exists( '\ExPlat\assign_current_user' ) ) {
244            return null;
245        }
246
247        return \ExPlat\assign_current_user( self::EXPERIMENT );
248    }
249}