Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Partner Compatibility: Adding a unique connection screen for customers who receive a coupon from a Jetpack partner #21813

Merged
merged 39 commits into from
Nov 29, 2021
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
ade160a
[not verified] Adding a unique connection screen for customers who re…
Nov 18, 2021
6ddbc18
Adding changelog entry
Nov 18, 2021
c2037ef
Correct code standards
kallehauge Nov 19, 2021
2922ac2
Merge branch 'master' into add/partner-coupon-connection
kallehauge Nov 19, 2021
81b8709
Refactor partner coupon logic
kallehauge Nov 19, 2021
c25ba93
Add new partner coupons to Jetpack_Options
kallehauge Nov 19, 2021
1166843
Use existing image for connect screen
Nov 22, 2021
455d685
Merge branch 'master' into add/partner-coupon-connection
kallehauge Nov 22, 2021
53fcb6b
Partner coupon: uniform redeem button text
kallehauge Nov 22, 2021
306db06
Make already connected ActionButton redirect
kallehauge Nov 22, 2021
d00fb48
Refactor "Partner Coupon" to be an individual component
kallehauge Nov 23, 2021
817c71c
Add tracking to PartnerCouponRedeem
kallehauge Nov 23, 2021
e14072d
Remove unrelated class from list
kallehauge Nov 23, 2021
4dea4da
Fix missing ToS link styling issue
kallehauge Nov 23, 2021
66dd804
Limit Pre-connection styling overrides to PartnerCouponRedeem only
kallehauge Nov 23, 2021
23b647e
Merge branch 'master' into add/partner-coupon-connection
kallehauge Nov 23, 2021
768c956
Use retina version of connection image
Nov 23, 2021
0e1818f
Merge branch 'master' into add/partner-coupon-connection
kallehauge Nov 24, 2021
ec8a2b6
Compress partner connection image
kallehauge Nov 24, 2021
8cfd50c
Remove background color from partner coupon connection image
kallehauge Nov 24, 2021
404a3ba
Fix partner coupon connection image path
kallehauge Nov 24, 2021
d891cc3
Create uniform action button between connection status component
kallehauge Nov 24, 2021
1edcf3d
Merge branch 'master' into add/partner-coupon-connection
kallehauge Nov 25, 2021
72cc873
Update styling after #21875 update
kallehauge Nov 25, 2021
7326a80
Refactor Jetpack_Partner_Coupon_Helper to Partner package
kallehauge Nov 25, 2021
560b064
Add basic Partner_Coupon test
kallehauge Nov 25, 2021
8d91fa4
Bump package/options in package dependencies
kallehauge Nov 25, 2021
aa1ddb6
Refactor: Move <PartnerCouponRedeem> to js-package/partner-coupon
kallehauge Nov 25, 2021
26ef703
Add changelog file for js-package/partner-coupon
kallehauge Nov 25, 2021
a279dd8
Merge branch 'master' into add/partner-coupon-connection
kallehauge Nov 25, 2021
3c4e1bc
PartnerRedeemCoupon: add from context
kallehauge Nov 26, 2021
c4df089
Jetpack Partner Coupon: add unit tests
kallehauge Nov 26, 2021
60380e2
Conditionally show ToS
kallehauge Nov 26, 2021
31c3e5a
Add pre-connection partner coupon JITM (#21817)
kallehauge Nov 26, 2021
77ece71
Ignore wordbless' wordpress directory in packages/partner
kallehauge Nov 26, 2021
fb9ea3f
Make partner coupon JITMs compatible with packages/partner
kallehauge Nov 26, 2021
82414c4
Avoid using "assertIsArray" to satisfy Team City tests
kallehauge Nov 26, 2021
76c922f
Merge branch 'master' into add/partner-coupon-connection
kallehauge Nov 26, 2021
10191d3
Update Jetpack plugin with latest packages/partner dependencies
kallehauge Nov 26, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 72 additions & 1 deletion projects/plugins/jetpack/_inc/client/main.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import React from 'react';
import { connect } from 'react-redux';
import { withRouter, Prompt } from 'react-router-dom';
import { __, sprintf } from '@wordpress/i18n';
import { getRedirectUrl } from '@automattic/jetpack-components';
import { ActionButton, getRedirectUrl } from '@automattic/jetpack-components';
import { ConnectScreen, CONNECTION_STORE_ID } from '@automattic/jetpack-connection';
import { Dashicon } from '@wordpress/components';
import { withDispatch } from '@wordpress/data';
Expand Down Expand Up @@ -43,6 +43,7 @@ import {
getTracksUserData,
showRecommendations,
getPluginBaseUrl,
getPartnerCoupon,
isWoASite,
} from 'state/initial-state';
import { areThereUnsavedSettings, clearUnsavedSettingsFlag } from 'state/settings';
Expand Down Expand Up @@ -198,6 +199,75 @@ class Main extends React.Component {
}

renderMainContent = route => {
/**
* There are two conditions (groups of conditions, really) where we would want to
* show the partner connection screen.
*
* 1. The site is not yet connected to WPCOM, but has the jetpack_partner_coupon
* option set in the database (this.props.partnerCoupon in redux). This is likely a
* partner-user who has just arrived here from a CTA within a partner's dashboard
* or other ecosystem.
*
* 2. The site is already connected to WPCOM, but the jetpack_partner_coupon option
* is still set in the database. This means the user connected their site, but never
* redeemed the coupon. If this is the case, we don't want to override the dashboard
* or at a glance pages with the redemption screen. Instead, we'll catch a URL
* parameter that JITMs will set (showCouponRedemption=true), and show the screen only
* when the user came from a a JITM.
*/
if ( this.props.partnerCoupon ) {
kallehauge marked this conversation as resolved.
Show resolved Hide resolved
const forceShow = new URLSearchParams( window.location.search ).get( 'showCouponRedemption' );
const partnerCoupon = this.props.partnerCoupon;

if ( ! this.props.isSiteConnected || forceShow ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the site connection take priority, so if the site is connected, we do not display the banner even if you use the query string?

Copy link
Contributor

@kallehauge kallehauge Nov 26, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's on purpose that we've added a query string override. It's so we can link directly to the coupon component in "remote JITMs" where we're going to do some coupon validation on the server-side (e.g.: has the coupon already been redeemed?).
It might be, that you have a use-case in mind that we've missed but it should be OK 😄

return (
<ConnectScreen
apiNonce={ this.props.apiNonce }
registrationNonce={ this.props.registrationNonce }
apiRoot={ this.props.apiRoot }
images={ [ '/images/connect-right-partner.png' ] }
kallehauge marked this conversation as resolved.
Show resolved Hide resolved
assetBaseUrl={ this.props.pluginBaseUrl }
title={ sprintf(
/* translators: %s: Jetpack partner name. */
__( 'Welcome to Jetpack %s traveler!', 'jetpack' ),
partnerCoupon.partner
) }
buttonLabel={ sprintf(
/* translators: %s: Name of a Jetpack product. */
__( 'Set up & redeem %s', 'jetpack' ),
partnerCoupon.product.title
) }
redirectUri={ `admin.php?page=jetpack&partnerCoupon=${ partnerCoupon.coupon_code }` }
connectionStatus={ this.props.connectionStatus }
>
<p>
{ sprintf(
/* translators: %s: Name of a Jetpack product. */
__(
'Redeem your coupon and get started with %s for free the first year!',
'jetpack'
),
partnerCoupon.product.title
) }
</p>
<ul>
{ partnerCoupon.product.features.map( ( feature, key ) => (
<li className="jp-recommendations-product-purchased__feature" key={ key }>
{ feature }
</li>
) ) }
</ul>
{ this.props.connectionStatus.hasConnectedOwner && (
<ActionButton
kallehauge marked this conversation as resolved.
Show resolved Hide resolved
label={ __( 'Redeem coupon', 'jetpack' ) }
kallehauge marked this conversation as resolved.
Show resolved Hide resolved
href={ `https://wordpress.com/checkout/${ this.props.siteRawUrl }/${ partnerCoupon.product.slug }?coupon=${ partnerCoupon.coupon_code }` }
kallehauge marked this conversation as resolved.
Show resolved Hide resolved
/>
) }
</ConnectScreen>
);
}
}

if (
this.isUserConnectScreen() &&
( this.props.userCanManageModules || this.props.hasConnectedOwner )
Expand Down Expand Up @@ -556,6 +626,7 @@ export default connect(
connectUrl: getConnectUrl( state ),
connectingUserFeatureLabel: getConnectingUserFeatureLabel( state ),
isWoaSite: isWoASite( state ),
partnerCoupon: getPartnerCoupon( state ),
};
},
dispatch => ( {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,16 @@ export function getPartnerSubsidiaryId( state ) {
return get( state.jetpack.initialState, 'partnerSubsidiaryId', '' );
}

/**
* Returns the partner coupon associated with this site, if any.
*
* @param {object} state - Global state tree
* @returns {object|boolean} partner coupon if exists or false.
*/
export function getPartnerCoupon( state ) {
return get( state.jetpack.initialState, 'partnerCoupon' );
}

/**
* Return an upgrade URL
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ public static function get_initial_state() {
'WP_API_nonce' => wp_create_nonce( 'wp_rest' ),
'registrationNonce' => wp_create_nonce( 'jetpack-registration-nonce' ),
'purchaseToken' => self::get_purchase_token(),
'partnerCoupon' => Jetpack_Partner_Coupon_Helper::get_coupon(),
'pluginBaseUrl' => plugins_url( '', JETPACK__PLUGIN_FILE ),
'connectionStatus' => $connection_status,
'connectUrl' => false == $current_user_data['isConnected'] // phpcs:ignore WordPress.PHP.StrictComparisons.LooseComparison
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
<?php
kallehauge marked this conversation as resolved.
Show resolved Hide resolved
/**
* Class for the Jetpack partner coupon logic.
*
* @package automattic/jetpack
*/

/**
* Disable direct access.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}

/**
* Class Jetpack_Partner_Coupon_Helper
*
* @since 10.4.0
*/
class Jetpack_Partner_Coupon_Helper {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we're introducing a completely new class, it would be nice to have tests for all of this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


/**
* Jetpack_Partner_Coupon_Helper
*
* @var Jetpack_Partner_Coupon_Helper|null
**/
private static $instance = null;

/**
* A list of supported partners.
*
* @var array
*/
private static $supported_partners = array(
'IONOS' => 'IONOS',
);

/**
* A list of supported presets.
*
* @var array
*/
private static $supported_presets = array(
'IONA' => 'jetpack_backup_daily',
);

/**
* Initialize class.
*/
public static function init() {
if ( is_null( self::$instance ) ) {
self::$instance = new Jetpack_Partner_Coupon_Helper();
}

return self::$instance;
}

/**
* Constructor.
*/
private function __construct() {
add_action( 'admin_init', array( $this, 'catch_coupon' ) );
}

/**
* Catch partner coupon and redirect to claim component.
*/
public function catch_coupon() {
// Accept and store a partner coupon if present, and redirect to Jetpack connection screen.
$partner_coupon = isset( $_GET['jetpack-partner-coupon'] ) ? sanitize_text_field( $_GET['jetpack-partner-coupon'] ) : false; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( $partner_coupon ) {
update_option( 'jetpack_partner_coupon', $partner_coupon );

if ( Jetpack::connection()->is_connected() ) {
kallehauge marked this conversation as resolved.
Show resolved Hide resolved
wp_safe_redirect( Jetpack::admin_url( 'showCouponRedemption=1' ) );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we move the lib to the Partner package, we'll need to move away from the dependency on the Jetpack class here as well, and instead build the admin URL ourselves.

Copy link
Contributor

@kallehauge kallehauge Nov 25, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My implementation isn't suuuuper clean, but we need allow plugins to register:

  1. Which plugin slug that should be used in the connection package (jetpack, jetpack-backup...)
  2. Where we should redirect the customer to as part of the logic you've made this comment for.

I've created an anonymous function so we can accept dynamic argument values:

// We have to use an anonymous function, so we can pass along relevant information
// and not have to hardcode values for a single plugin.
// This open up the opportunity for e.g. the "all-in-one" and backup plugins
// to both implement partner coupon logic.
add_action(
'admin_init',
function () use ( $plugin_slug, $redirect_location, $instance ) {
$instance->catch_coupon( $plugin_slug, $redirect_location );
}
);

This means that the plugin that wish to register the admin hooks and tell us which URL we should redirect to, can just add: Jetpack_Partner_Coupon::register_coupon_admin_hooks( 'jetpack', Jetpack::admin_url() );

} else {
wp_safe_redirect( Jetpack::admin_url() );
}
}
}

/**
* Get partner coupon data.
*
* @return array|bool
*/
public static function get_coupon() {
$coupon_code = get_option( 'jetpack_partner_coupon', '' );

if ( ! is_string( $coupon_code ) || empty( $coupon_code ) ) {
return false;
}

$partner = self::$instance->get_coupon_partner_name( $coupon_code );

if ( ! $partner ) {
return false;
}

$product = self::$instance->get_coupon_product( $coupon_code );

if ( ! $product ) {
return false;
}

return array(
'coupon_code' => $coupon_code,
'partner' => $partner,
'product' => $product,
);
}

/**
* Get coupon partner name.
*
* @param string $coupon_code Coupon code to go through.
* @return string|bool
*/
private function get_coupon_partner_name( $coupon_code ) {
if ( ! is_string( $coupon_code ) || false === strpos( $coupon_code, '_' ) ) {
return false;
}

$partner = strtok( $coupon_code, '_' );
$supported_partners = $this->get_supported_partners();

return isset( $supported_partners[ $partner ] ) ? $supported_partners[ $partner ] : false;
}

/**
* Get coupon product.
*
* @param string $coupon_code Coupon code to go through.
* @return array|bool
*/
private function get_coupon_product( $coupon_code ) {
$coupon_preset = $this->get_coupon_preset( $coupon_code );

if ( ! $coupon_preset ) {
return false;
}

$product_details = Jetpack::get_products_for_purchase( true );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that this will be more difficult to replace, and that may make it harder to move the whole class to the Partner package. :(

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like the "compromise" fits well with the spirit of WordPress. I just moved it to a filter instead.

The most awesome experience here would be an API request to fetch products etc, but because we have a scenario where the user haven't connected their site yet (pre-connection), we're not allowed to make external requests yet (source: Jesse Friedman, based on JITM experience).

// We add the filter in `Jetpack_Redux_State_Helper` before we get the coupon values for `initialState`:
add_filter( 'jetpack_partner_coupon_products', array( self::class, 'get_partner_coupon_product_descriptions' ) );
// And then just queue in the products by wrapping "get_products_for_purchase":

public static function get_partner_coupon_product_descriptions() {
	return Jetpack::get_products_for_purchase( true );
}

$product_slug = $this->get_supported_presets()[ $coupon_preset ];

foreach ( $product_details as $product ) {
if ( $product_slug === $product['slug'] ) {
return $product;
}
}

return false;
}

/**
* Get coupon preset.
*
* @param string $coupon_code Coupon code to go through.
* @return string|bool
*/
private function get_coupon_preset( $coupon_code ) {
if ( ! is_string( $coupon_code ) ) {
return false;
}

$regex = '/^.*?_(?P<slug>.*?)_.+$/';
$matches = array();

if ( ! preg_match( $regex, $coupon_code, $matches ) ) {
return false;
}

return isset( $this->get_supported_presets()[ $matches['slug'] ] ) ? $matches['slug'] : false;
}

/**
* Get supported partners.
*
* @return array
*/
private function get_supported_partners() {
/**
* Allow external code to add additional supported partners.
*
* @since 10.4.0
*
* @param array $supported_partners A list of supported partners.
* @return array
*/
return apply_filters( 'jetpack_partner_coupon_supported_partners', self::$supported_partners );
}

/**
* Get supported presets.
*
* @return array
*/
private function get_supported_presets() {
/**
* Allow external code to add additional supported presets.
*
* @since 10.4.0
*
* @param array $supported_presets A list of supported presets.
* @return array
*/
return apply_filters( 'jetpack_partner_coupon_supported_presets', self::$supported_presets );
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: compat

Adding a unique connection screen for customers who receive a coupon from a Jetpack partner
32 changes: 30 additions & 2 deletions projects/plugins/jetpack/class.jetpack.php
Original file line number Diff line number Diff line change
Expand Up @@ -6695,8 +6695,11 @@ public static function is_active_and_not_offline_mode() {
* This method will not take current purchases or upgrades into account
* but is instead a static list of products Jetpack offers with some
* corresponding sales text/materials.
*
* @param bool $show_legacy Determine if we should include legacy product/plan details.
* @return array
*/
public static function get_products_for_purchase() {
public static function get_products_for_purchase( $show_legacy = false ) {
$products = array();

$products['backup'] = array(
Expand Down Expand Up @@ -6789,7 +6792,32 @@ public static function get_products_for_purchase() {
),
);

return $products;
if ( $show_legacy ) {
$products['jetpack_backup_daily'] = array(
'title' => __( 'Jetpack Backup', 'jetpack' ),
'slug' => 'jetpack_backup_daily',
'description' => __( 'Never lose a word, image, page, or time worrying about your site with automated backups & one-click restores.', 'jetpack' ),
'show_promotion' => false,
'discount_percent' => 0,
'included_in_plans' => array(),
'features' => array(
_x( 'Automated daily backups (off-site)', 'Backup Product Feature', 'jetpack' ),
_x( 'One-click restores', 'Backup Product Feature', 'jetpack' ),
_x( 'Unlimited backup storage', 'Backup Product Feature', 'jetpack' ),
),
);
}

/**
* Allow for product details modifications.
*
* @since 10.4.0
kallehauge marked this conversation as resolved.
Show resolved Hide resolved
*
* @param array $products A list of product details.
* @param array $show_legacy Determine if the list should include legacy plans/products.
* @return array
*/
return apply_filters( 'jetpack_get_products_for_purchase', $products, $show_legacy );
}

/**
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions projects/plugins/jetpack/load-jetpack.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ function jetpack_should_use_minified_assets() {
jetpack_require_lib( 'class-jetpack-recommendations' );
require_once JETPACK__PLUGIN_DIR . 'class-jetpack-recommendations-banner.php';

jetpack_require_lib( 'class-jetpack-partner-coupon-helper' );
Jetpack_Partner_Coupon_Helper::init();

if ( is_admin() ) {
require_once JETPACK__PLUGIN_DIR . 'class.jetpack-admin.php';
jetpack_require_lib( 'debugger' );
Expand Down