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