Skip to content

Commit

Permalink
My Jetpack: Add red bubble and warning Notice when paid plans are exp…
Browse files Browse the repository at this point in the history
…ired or expiring soon. (#40115)

* Add expired/expiring statuses to products.

* changelog

* Add logic in remaining product classes for expired/expiring functionality.

* Add error/warning border color to expired/expiring product cards.

* Trigger red bubble and Notice for expired or expiring paid products.

* Refactor/improve how we determine 'has_paid_plan_for_product()'.

* Add option to hide expiration status.

* Make expired CTA goto purchase management page, and expiring soon CTA goto straight to checkout.

* "Your plans" section: Highlight expiring plans (red or yellow) & add 'Renew subscription' link.
  • Loading branch information
elliottprogrammer authored Dec 2, 2024
1 parent fd10edd commit e447304
Show file tree
Hide file tree
Showing 14 changed files with 549 additions and 14 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Significance: patch
Type: changed
Comment: Just adding a couple additional props to the NoticeActionButton.


Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ const ActionButton = props => {
isDisabled,
displayError = false,
errorMessage = __( 'An error occurred. Please try again.', 'jetpack-components' ),
variant = 'primary',
isExternalLink = false,
customClass,
} = props;

Expand All @@ -43,7 +45,8 @@ const ActionButton = props => {
className={ clsx( styles.button, 'jp-action-button--button', customClass ) }
label={ label }
onClick={ onClick }
variant="primary"
variant={ isExternalLink ? 'link' : variant }
isExternalLink={ isExternalLink }
disabled={ isLoading || isDisabled }
>
{ isLoading ? loadingContent : label }
Expand All @@ -70,6 +73,10 @@ ActionButton.propTypes = {
displayError: PropTypes.bool,
/** The error message string */
errorMessage: PropTypes.oneOfType( [ PropTypes.string, PropTypes.element ] ),
/** The type/variant of button */
variant: PropTypes.arrayOf( PropTypes.oneOf( [ 'primary', 'secondary', 'link' ] ) ),
/** Will display the button as a link with an external icon. */
isExternalLink: PropTypes.bool,
};

export default ActionButton;
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
position: relative;
margin: 0;
font-size: 16px;
line-height: 22px;
line-height: 24px;
background-color: var( --jp-white );

// notice content
Expand All @@ -28,6 +28,7 @@
color: var( --jp-black );
font-size: 16px;
font-weight: 600;
padding: var(--spacing-base) calc(var(--spacing-base)* 3) !important;
}

// X close button
Expand Down Expand Up @@ -85,6 +86,7 @@
}
}


.message {
margin-right: var( --spacing-base ); // 8px
flex-grow: 1;
Expand All @@ -106,6 +108,17 @@
fill: none;
}
}

& :global(.products-list) {
margin: 10px 0;
& :global(.product-badge) {
display: inline-block;
padding: 2px 8px;
margin: 4px 8px 4px 0;
border-radius: 6px;
background-color: #f1f1f1
}
}
}

.separator {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { Text, H3, Title, Button } from '@automattic/jetpack-components';
import { __, _n } from '@wordpress/i18n';
import { dateI18n } from '@wordpress/date';
import { __, _n, sprintf } from '@wordpress/i18n';
import clsx from 'clsx';
import { useCallback } from 'react';
import { MyJetpackRoutes } from '../../constants';
import { MyJetpackRoutes, PRODUCT_STATUSES } from '../../constants';
import { QUERY_PURCHASES_KEY, REST_API_SITE_PURCHASES_ENDPOINT } from '../../data/constants';
import useSimpleQuery from '../../data/use-simple-query';
import { getMyJetpackWindowInitialState } from '../../data/utils/get-my-jetpack-window-state';
Expand All @@ -24,10 +26,10 @@ import styles from './style.module.scss';
function PlanSection( { purchase = {} } ) {
const { product_name } = purchase;
return (
<>
<div className={ styles[ 'plan-container' ] }>
<Title>{ product_name }</Title>
<PlanExpiry { ...purchase } />
</>
</div>
);
}

Expand All @@ -42,7 +44,67 @@ function PlanSection( { purchase = {} } ) {
* @return {object} - A plan expiry component.
*/
function PlanExpiry( purchase ) {
const { expiry_message, product_name, subscribed_date } = purchase;
const { ID, expiry_date, expiry_status, product_name, product_slug, subscribed_date, domain } =
purchase;

const managePurchaseUrl = `https://wordpress.com/me/purchases/${ domain }/${ ID }`;
const renewUrl = `https://wordpress.com/checkout/${ product_slug }/renew/${ ID }/${ domain }`;

const isExpired = PRODUCT_STATUSES.EXPIRED === expiry_status;
const isExpiringSoon = PRODUCT_STATUSES.EXPIRING_SOON === expiry_status;
const isExpiringPurchase = isExpired || isExpiringSoon;

const expiryMessageClassName = clsx( {
[ styles[ 'is-expired' ] ]: isExpired,
[ styles[ 'is-expiring-soon' ] ]: isExpiringSoon,
} );

const expiryMessage = useCallback( () => {
const displayDate = dateI18n( 'F jS, Y', expiry_date );
if ( isExpiringPurchase ) {
// Expiring soon
if ( isExpiringSoon ) {
return sprintf(
// translators: %1$s is the formatted date to display, i.e.- November 24th, 2024
__( 'Expiring soon on %1$s', 'jetpack-my-jetpack' ),
displayDate
);
}

// Expired
return sprintf(
// translators: %1$s is the formatted date to display, i.e.- November 24th, 2024
__( 'Expired on %1$s', 'jetpack-my-jetpack' ),
displayDate
);
}

return sprintf(
// translators: %1$s is the formatted date to display, i.e.- November 24th, 2024
__( 'Expires on %1$s', 'jetpack-my-jetpack' ),
displayDate
);
}, [ expiry_date, isExpiringPurchase, isExpiringSoon ] );

const expiryAction = useCallback( () => {
if ( ! isExpiringPurchase ) {
return null;
}

if ( isExpiringSoon ) {
return (
<Button href={ renewUrl } isExternalLink={ true } variant="link" weight="regular">
{ __( 'Renew subscription', 'jetpack-my-jetpack' ) }
</Button>
);
}

return (
<Button href={ managePurchaseUrl } isExternalLink={ true } variant="link" weight="regular">
{ __( 'Resume subscription', 'jetpack-my-jetpack' ) }
</Button>
);
}, [ isExpiringPurchase, isExpiringSoon, managePurchaseUrl, renewUrl ] );

if ( isLifetimePurchase( purchase ) ) {
return (
Expand All @@ -56,9 +118,12 @@ function PlanExpiry( purchase ) {
}

return (
<Text variant="body" className={ styles[ 'expire-date' ] }>
{ expiry_message }
</Text>
<>
<Text variant="body" className={ clsx( styles[ 'expire-date' ], expiryMessageClassName ) }>
{ expiryMessage() }
</Text>
{ isExpiringPurchase && <Text>{ expiryAction() }</Text> }
</>
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,26 @@
}
}

.expire-date {
.plan-container {
margin-bottom: calc( var(--spacing-base ) * 3 );
}

.expire-date {
display: flex;

&--with-icon {
margin-right: 8px;
}
}

.is-expired {
color: var(--jp-red-60);
}

.is-expiring-soon {
color: var(--jp-yellow-40);
}

.actions-list-item {
line-height: calc( var( --spacing-base ) * 3 );
margin: 0 0 var( --spacing-base ) 0;
Expand Down
1 change: 1 addition & 0 deletions projects/packages/my-jetpack/_inc/context/notices/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export type NoticeButtonAction = NoticeAction & {
isLoading?: boolean;
loadingText?: string;
isDisabled?: boolean;
isExternalLink?: boolean;
};

export type Notice = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { getMyJetpackWindowInitialState } from '../../data/utils/get-my-jetpack-
import useBadInstallNotice from './use-bad-install-notice';
import useConnectionErrorsNotice from './use-connection-errors-notice';
import useDeprecateFeatureNotice from './use-deprecate-feature-notice';
import useExpiringPlansNotice from './use-expiring-plans-notice';
import useSiteConnectionNotice from './use-site-connection-notice';

const useNotificationWatcher = () => {
Expand All @@ -11,6 +12,7 @@ const useNotificationWatcher = () => {
useSiteConnectionNotice( redBubbleAlerts );
useConnectionErrorsNotice();
useDeprecateFeatureNotice( redBubbleAlerts );
useExpiringPlansNotice( redBubbleAlerts );
};

export default useNotificationWatcher;
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export const ProductsList = ( { products }: { products: string[] } ) => {
if ( ! products ) {
return null;
}
return (
<ul className="products-list">
{ products.map( ( product, index ) => (
<li key={ index } className="product-badge">
{ product }
</li>
) ) }
</ul>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { __ } from '@wordpress/i18n';
import { useContext, useEffect, useCallback } from 'react';
import { NOTICE_PRIORITY_MEDIUM } from '../../context/constants';
import { NoticeContext } from '../../context/notices/noticeContext';
import useAnalytics from '../use-analytics';
import { useGetExpiringNoticeContent } from './use-get-expiring-notice-content';
import type { NoticeOptions } from '../../context/notices/types';

type RedBubbleAlerts = Window[ 'myJetpackInitialState' ][ 'redBubbleAlerts' ];

const useExpiringPlansNotice = ( redBubbleAlerts: RedBubbleAlerts ) => {
const { setNotice } = useContext( NoticeContext );
const { recordEvent } = useAnalytics();

const planExpiredAlerts = Object.keys( redBubbleAlerts ).filter(
key => key.endsWith( '--plan_expiring_soon' ) || key.endsWith( '--plan_expired' )
) as Array< `${ string }--plan_expiring_soon` | `${ string }--plan_expired` >;

const expiredAlerts =
planExpiredAlerts.length &&
planExpiredAlerts.filter( alert => alert.endsWith( '--plan_expired' ) );
const expiringSoonAlerts =
planExpiredAlerts.length &&
planExpiredAlerts.filter( alert => alert.endsWith( '--plan_expiring_soon' ) );

// Already expired alerts take precidence over expiring alerts.
// i.e.- Display 'expired' alert if there is one, otherwise display 'expiring soon' alert.
const alertToDisplay = expiredAlerts.length ? expiredAlerts[ 0 ] : expiringSoonAlerts[ 0 ];
const isExpiredAlert = alertToDisplay && alertToDisplay.endsWith( '--plan_expired' );
const expiredAlertType = isExpiredAlert ? 'expired' : 'expiring-soon';

const {
product_slug: productSlug,
product_name: productName,
expiry_date: expiryDate,
manage_url: manageUrl,
products_effected: productsEffected,
} = redBubbleAlerts[ alertToDisplay ] || {};

const { noticeTitle, noticeMessage, learnMoreUrl } =
useGetExpiringNoticeContent( {
productSlug,
expiredAlertType,
productName,
expiryDate,
productsEffected,
} ) || {};

const onPrimaryCtaClick = useCallback( () => {
window.location.href = manageUrl;
recordEvent(
isExpiredAlert
? 'jetpack_my_jetpack_plan_expired_notice_primary_cta_click'
: 'jetpack_my_jetpack_plan_expiring_soon_notice_primary_cta_click',
{
product_slug: productSlug,
}
);
}, [ isExpiredAlert, manageUrl, productSlug, recordEvent ] );

const onSecondaryCtaClick = useCallback( () => {
window.open( learnMoreUrl );
recordEvent(
isExpiredAlert
? 'jetpack_my_jetpack_plan_expired_notice_secondary_cta_click'
: 'jetpack_my_jetpack_plan_expiring_soon_notice_secondary_cta_click',
{
product_slug: productSlug,
}
);
}, [ learnMoreUrl, isExpiredAlert, productSlug, recordEvent ] );

useEffect( () => {
if ( ! alertToDisplay ) {
return;
}

const resumePlanText = __( 'Resume my plan', 'jetpack-my-jetpack' );
const renewPlanText = __( 'Renew my plan', 'jetpack-my-jetpack' );

const noticeOptions: NoticeOptions = {
id: isExpiredAlert ? 'plan-expired-notice' : 'plan-expiring-soon-notice',
level: isExpiredAlert ? 'error' : 'warning',
actions: [
{
label: isExpiredAlert ? resumePlanText : renewPlanText,
onClick: onPrimaryCtaClick,
noDefaultClasses: true,
},
{
label: __( 'Learn more', 'jetpack-my-jetpack' ),
onClick: onSecondaryCtaClick,
isExternalLink: true,
},
],
priority: NOTICE_PRIORITY_MEDIUM,
};

setNotice( {
title: noticeTitle,
message: noticeMessage,
options: noticeOptions,
} );
}, [
redBubbleAlerts,
setNotice,
recordEvent,
alertToDisplay,
onPrimaryCtaClick,
onSecondaryCtaClick,
noticeTitle,
noticeMessage,
isExpiredAlert,
] );
};

export default useExpiringPlansNotice;
Loading

0 comments on commit e447304

Please sign in to comment.