[MM-51412] - In-product banner to communicate Cloud Free deprecation (#22622)

* [MM-51412] - In-product banner to communicate Cloud Free deprecation

* fix lint

* make improvements

* make improvements

* put banners behind feature flag

* cloud free overage case

* add unit tests

* feedback impl

* fix test

* rename and enable feature flag by default

* fix alert banner mode

* read end date from env var

* disable ff by default

* enter cloud free deprecation date

* remove env var

* fix text

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
Allan Guwatudde
2023-04-12 18:08:37 +03:00
коммит произвёл GitHub
родитель b00ca20abe
Коммит 155e896dcf
11 изменённых файлов: 538 добавлений и 0 удалений

Просмотреть файл

@@ -75,6 +75,8 @@ type FeatureFlags struct {
OnboardingTourTips bool
DeprecateCloudFree bool
AppsSidebarCategory bool
}
@@ -101,6 +103,7 @@ func (f *FeatureFlags) SetDefaults() {
f.ReduceOnBoardingTaskList = false
f.ThreadsEverywhere = false
f.GlobalDrafts = true
f.DeprecateCloudFree = false
f.WysiwygEditor = false
f.OnboardingAutoShowLinkedBoard = false
f.OnboardingTourTips = true

Просмотреть файл

@@ -48,6 +48,7 @@ import {
import LimitReachedBanner from './limit_reached_banner';
import CancelSubscription from './cancel_subscription';
import {ToYearlyNudgeBanner} from './to_yearly_nudge_banner';
import {ToPaidNudgeBanner} from './to_paid_plan_nudge_banner';
import './billing_subscriptions.scss';
@@ -136,6 +137,7 @@ const BillingSubscriptions = () => {
/>
{shouldShowPaymentFailedBanner() && paymentFailedBanner()}
{<ToYearlyNudgeBanner/>}
{<ToPaidNudgeBanner/>}
{showCreditCardBanner &&
isCardExpired &&
creditCardExpiredBanner(setShowCreditCardBanner)}

Просмотреть файл

@@ -0,0 +1,28 @@
@import 'utils/mixins';
.ToPaidNudgeBanner {
&__actions {
padding-top: 12px;
}
&__primary {
font-size: 12px;
@include primary-button;
&:hover {
color: var(--button-color);
}
}
&__secondary {
margin-left: 4px;
font-size: 12px;
@include tertiary-button;
&:hover {
color: var(--button-bg);
}
}
}

Просмотреть файл

@@ -0,0 +1,239 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {screen} from '@testing-library/react';
import {renderWithIntlAndStore} from 'tests/react_testing_utils';
import {CloudProducts} from 'utils/constants';
import {ToPaidNudgeBanner, ToPaidPlanBannerDismissable} from './to_paid_plan_nudge_banner';
const initialState = {
views: {
announcementBar: {
announcementBarState: {
announcementBarCount: 1,
},
},
},
entities: {
general: {
config: {
CWSURL: '',
FeatureFlagDeprecateCloudFree: 'true',
},
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_user'},
},
},
preferences: {
myPreferences: {},
},
cloud: {},
},
};
describe('ToPaidPlanBannerDismissable', () => {
test('should only show for admins on cloud free', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles = {
current_user_id: {roles: 'system_admin'},
};
state.entities.cloud = {
subscription: {
product_id: 'prod_starter',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_starter: {
id: 'prod_starter',
sku: CloudProducts.STARTER,
},
},
};
renderWithIntlAndStore(<ToPaidPlanBannerDismissable/>, state);
screen.getByTestId('cloud-free-deprecation-announcement-bar');
});
test('should NOT show for NON admins', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles = {
current_user_id: {roles: 'system_user'},
};
state.entities.cloud = {
subscription: {
product_id: 'prod_starter',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_starter: {
id: 'prod_starter',
sku: CloudProducts.STARTER,
},
},
};
renderWithIntlAndStore(<ToPaidPlanBannerDismissable/>, state);
expect(() => screen.getByTestId('cloud-free-deprecation-announcement-bar')).toThrow();
});
test('should NOT show for admins on cloud pro', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles = {
current_user_id: {roles: 'system_admin'},
};
state.entities.cloud = {
subscription: {
product_id: 'prod_pro',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_pro: {
id: 'prod_pro',
sku: CloudProducts.PROFESSIONAL,
},
},
};
renderWithIntlAndStore(<ToPaidPlanBannerDismissable/>, state);
expect(() => screen.getByTestId('cloud-free-deprecation-announcement-bar')).toThrow();
});
test('should NOT show for admins on cloud enterprise', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles = {
current_user_id: {roles: 'system_admin'},
};
state.entities.cloud = {
subscription: {
product_id: 'prod_enterprise',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_enterprise: {
id: 'prod_enterprise',
sku: CloudProducts.ENTERPRISE,
},
},
};
renderWithIntlAndStore(<ToPaidPlanBannerDismissable/>, state);
expect(() => screen.getByTestId('cloud-free-deprecation-announcement-bar')).toThrow();
});
test('should NOT show for admins when banner was dismissed in preferences', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.users.profiles = {
current_user_id: {roles: 'system_admin'},
};
state.entities.preferences = {
myPreferences: {
'to_paid_plan_nudge--nudge_to_paid_plan_snoozed': {
category: 'to_paid_plan_nudge',
name: 'nudge_to_paid_plan_snoozed',
value: '{"range": 0, "show": false}',
},
},
};
state.entities.cloud = {
subscription: {
product_id: 'prod_starter',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_starter: {
id: 'prod_starter',
sku: CloudProducts.STARTER,
},
},
};
renderWithIntlAndStore(<ToPaidPlanBannerDismissable/>, state);
expect(() => screen.getByTestId('cloud-free-deprecation-announcement-bar')).toThrow();
});
});
describe('ToPaidNudgeBanner', () => {
test('should show only for cloud free', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud = {
subscription: {
product_id: 'prod_starter',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_starter: {
id: 'prod_starter',
sku: CloudProducts.STARTER,
},
},
};
renderWithIntlAndStore(<ToPaidNudgeBanner/>, state);
screen.getByTestId('cloud-free-deprecation-alert-banner');
});
test('should NOT show for cloud professional', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud = {
subscription: {
product_id: 'prod_pro',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_pro: {
id: 'prod_pro',
sku: CloudProducts.PROFESSIONAL,
},
},
};
renderWithIntlAndStore(<ToPaidNudgeBanner/>, state);
expect(() => screen.getByTestId('cloud-free-deprecation-alert-banner')).toThrow();
});
test('should NOT show for cloud enterprise', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud = {
subscription: {
product_id: 'prod_ent',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_ent: {
id: 'prod_ent',
sku: CloudProducts.ENTERPRISE,
},
},
};
renderWithIntlAndStore(<ToPaidNudgeBanner/>, state);
expect(() => screen.getByTestId('cloud-free-deprecation-alert-banner')).toThrow();
});
});

Просмотреть файл

@@ -0,0 +1,246 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import {useIntl, FormattedMessage} from 'react-intl';
import moment from 'moment';
import AlertBanner from 'components/alert_banner';
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import AnnouncementBar from 'components/announcement_bar/default_announcement_bar';
import {getSubscriptionProduct as selectSubscriptionProduct} from 'mattermost-redux/selectors/entities/cloud';
import {getCurrentUser, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
import {savePreferences} from 'mattermost-redux/actions/preferences';
import {deprecateCloudFree, get as getPreference} from 'mattermost-redux/selectors/entities/preferences';
import {AnnouncementBarTypes, CloudBanners, CloudProducts, Preferences} from 'utils/constants';
import {t} from 'utils/i18n';
import {GlobalState} from '@mattermost/types/store';
import './to_paid_plan_nudge_banner.scss';
enum DismissShowRange {
GreaterThanEqual90 = '>=90',
BetweenNinetyAnd60 = '89-61',
SixtyTo31 = '60-31',
ThirtyTo11 = '30-11',
TenTo1 = '10-1',
Zero = '0'
}
const cloudFreeCloseMoment = '20230727';
interface ToPaidPlanDismissPreference {
// range represents the range for the days to the deprecation of cloud free e.g. in 30 to 10 days to deprecate cloud free
// Incase of dismissing the banner, range represents the time (days) period when this banner was dismissed.
// This is important because in case the banner was dismissed for a certain period, it helps us know that we should not show it again for that period.
range: DismissShowRange;
show: boolean;
}
export const ToPaidPlanBannerDismissable = () => {
const dispatch = useDispatch();
const openPricingModal = useOpenPricingModal();
const currentUser = useSelector(getCurrentUser);
const isAdmin = useSelector(isCurrentUserSystemAdmin);
const product = useSelector(selectSubscriptionProduct);
const cloudFreeDeprecated = useSelector(deprecateCloudFree);
const currentProductStarter = product?.sku === CloudProducts.STARTER;
const now = moment(Date.now());
const cloudFreeEndDate = moment(cloudFreeCloseMoment, 'YYYYMMDD');
const daysToCloudFreeEnd = cloudFreeEndDate.diff(now, 'days');
const snoozePreferenceVal = useSelector((state: GlobalState) => getPreference(state, Preferences.TO_PAID_PLAN_NUDGE, CloudBanners.NUDGE_TO_PAID_PLAN_SNOOZED, '{"range": 0, "show": true}'));
const snoozeInfo = JSON.parse(snoozePreferenceVal) as ToPaidPlanDismissPreference;
const show = snoozeInfo.show;
const snoozedForRange = (range: DismissShowRange) => {
return snoozeInfo.range === range;
};
useEffect(() => {
if (!snoozeInfo.show) {
if (daysToCloudFreeEnd >= 90 && !snoozedForRange(DismissShowRange.GreaterThanEqual90)) {
showBanner(true);
}
if (daysToCloudFreeEnd < 90 && daysToCloudFreeEnd > 60 && !snoozedForRange(DismissShowRange.BetweenNinetyAnd60)) {
showBanner(true);
}
if (daysToCloudFreeEnd <= 60 && daysToCloudFreeEnd > 30 && !snoozedForRange(DismissShowRange.SixtyTo31)) {
showBanner(true);
}
if (daysToCloudFreeEnd <= 30 && daysToCloudFreeEnd > 10 && !snoozedForRange(DismissShowRange.ThirtyTo11)) {
showBanner(true);
}
if (daysToCloudFreeEnd <= 10) {
showBanner(true);
}
}
}, []);
const showBanner = (show = false) => {
let dRange = DismissShowRange.Zero;
if (daysToCloudFreeEnd >= 90) {
dRange = DismissShowRange.GreaterThanEqual90;
}
if (daysToCloudFreeEnd < 90 && daysToCloudFreeEnd > 60) {
dRange = DismissShowRange.BetweenNinetyAnd60;
}
if (daysToCloudFreeEnd <= 60 && daysToCloudFreeEnd > 30) {
dRange = DismissShowRange.SixtyTo31;
}
if (daysToCloudFreeEnd <= 30 && daysToCloudFreeEnd > 10) {
dRange = DismissShowRange.ThirtyTo11;
}
// ideally this case should not happen because snooze button is not shown when TenTo1 days are remaining
if (daysToCloudFreeEnd <= 10 && daysToCloudFreeEnd > 0) {
dRange = DismissShowRange.TenTo1;
}
const snoozeInfo: ToPaidPlanDismissPreference = {
range: dRange,
show,
};
dispatch(savePreferences(currentUser.id, [{
category: Preferences.TO_PAID_PLAN_NUDGE,
name: CloudBanners.NUDGE_TO_PAID_PLAN_SNOOZED,
user_id: currentUser.id,
value: JSON.stringify(snoozeInfo),
}]));
};
if (!cloudFreeDeprecated) {
return null;
}
if (!show) {
return null;
}
if (!isAdmin) {
return null;
}
if (!currentProductStarter) {
return null;
}
let message = {
id: 'cloud_billing.nudge_to_paid.announcement_bar',
defaultMessage: 'Cloud Free will be deprecated on {date}. To keep your workspace, upgrade to a paid plan',
values: {
date: moment(cloudFreeCloseMoment, 'YYYYMMDD').format('MMMM DD, YYYY'),
},
};
if (daysToCloudFreeEnd < 0) {
message = {
id: 'cloud_billing.nudge_to_paid.announcement_bar_deprecated',
defaultMessage: 'Cloud Free was deprecated. To keep your workspace, upgrade to a paid plan',
} as any;
}
const announcementType = (daysToCloudFreeEnd <= 10) ? AnnouncementBarTypes.CRITICAL : AnnouncementBarTypes.ANNOUNCEMENT;
return (
<AnnouncementBar
id='cloud-free-deprecation-announcement-bar'
type={announcementType}
showCloseButton={daysToCloudFreeEnd > 10}
onButtonClick={openPricingModal}
modalButtonText={t('cloud_billing.nudge_to_paid.view_plans')}
modalButtonDefaultText='View plans'
message={<FormattedMessage {...message}/>}
showLinkAsButton={true}
handleClose={showBanner}
/>
);
};
export const ToPaidNudgeBanner = () => {
const {formatMessage} = useIntl();
const [openSalesLink] = useOpenSalesLink();
const openPurchaseModal = useOpenCloudPurchaseModal({});
const product = useSelector(selectSubscriptionProduct);
const cloudFreeDeprecated = useSelector(deprecateCloudFree);
const currentProductStarter = product?.sku === CloudProducts.STARTER;
if (!cloudFreeDeprecated) {
return null;
}
if (!currentProductStarter) {
return null;
}
const now = moment(Date.now());
const cloudFreeEndDate = moment(cloudFreeCloseMoment, 'YYYYMMDD');
const daysToCloudFreeEnd = cloudFreeEndDate.diff(now, 'days');
const title = (
<FormattedMessage
id='cloud_billing.nudge_to_paid.title'
defaultMessage='Upgrade to paid plan to keep your workspace'
/>
);
const description = (
<FormattedMessage
id='cloud_billing.nudge_to_paid.description'
defaultMessage='Cloud Free will be deprecated in {days} days. Upgrade to a paid plan or contact sales.'
values={{days: daysToCloudFreeEnd < 0 ? 0 : daysToCloudFreeEnd}}
/>
);
const viewPlansAction = (
<button
onClick={() => openPurchaseModal({trackingLocation: 'to_paid_plan_nudge_banner'})}
className='btn ToPaidNudgeBanner__primary'
>
{formatMessage({id: 'cloud_billing.nudge_to_paid.learn_more', defaultMessage: 'Upgrade'})}
</button>
);
const contactSalesAction = (
<button
onClick={openSalesLink}
className='btn ToPaidNudgeBanner__secondary'
>
{formatMessage({id: 'cloud_billing.nudge_to_paid.contact_sales', defaultMessage: 'Contact sales'})}
</button>
);
const bannerMode = (daysToCloudFreeEnd <= 10) ? 'danger' : 'info';
return (
<AlertBanner
id='cloud-free-deprecation-alert-banner'
mode={bannerMode}
title={title}
message={description}
className='ToYearlyNudgeBanner'
actionButtonLeft={viewPlansAction}
actionButtonRight={contactSalesAction}
/>
);
};

Просмотреть файл

@@ -21,6 +21,7 @@ import './alert_banner.scss';
export type ModeType = 'danger' | 'warning' | 'info' | 'success';
export type AlertBannerProps = {
id?: string;
mode: ModeType;
title?: React.ReactNode;
message?: React.ReactNode;
@@ -35,6 +36,7 @@ export type AlertBannerProps = {
}
const AlertBanner = ({
id,
mode,
title,
message,
@@ -70,6 +72,7 @@ const AlertBanner = ({
return (
<div
data-testid={id}
className={classNames(
'AlertBanner',
mode,

Просмотреть файл

@@ -4,6 +4,7 @@
import React from 'react';
import {ToYearlyNudgeBannerDismissable} from 'components/admin_console/billing/billing_subscriptions/to_yearly_nudge_banner';
import {ToPaidPlanBannerDismissable} from 'components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner';
import {ClientLicense, ClientConfig, WarnMetricStatus} from '@mattermost/types/config';
import withGetCloudSubscription from '../common/hocs/cloud/with_get_cloud_subscription';
@@ -72,6 +73,7 @@ class AnnouncementBarController extends React.PureComponent<Props> {
let cloudDelinquencyAnnouncementBar = null;
let notifyAdminDowngradeDelinquencyBar = null;
let toYearlyNudgeBannerDismissable = null;
let toPaidPlanNudgeBannerDismissable = null;
if (this.props.license?.Cloud === 'true') {
paymentAnnouncementBar = (
<PaymentAnnouncementBar/>
@@ -89,6 +91,7 @@ class AnnouncementBarController extends React.PureComponent<Props> {
<NotifyAdminDowngradeDelinquencyBar/>
);
toYearlyNudgeBannerDismissable = (<ToYearlyNudgeBannerDismissable/>);
toPaidPlanNudgeBannerDismissable = (<ToPaidPlanBannerDismissable/>);
}
let autoStartTrialModal = null;
@@ -108,6 +111,7 @@ class AnnouncementBarController extends React.PureComponent<Props> {
{cloudDelinquencyAnnouncementBar}
{notifyAdminDowngradeDelinquencyBar}
{toYearlyNudgeBannerDismissable}
{toPaidPlanNudgeBannerDismissable}
{this.props.license?.Cloud !== 'true' && <OverageUsersBanner/>}
{autoStartTrialModal}
<ShowThreeDaysLeftTrialModal/>

Просмотреть файл

@@ -19,6 +19,7 @@ import ToggleModalButton from 'components/toggle_modal_button';
import {trackEvent} from 'actions/telemetry_actions.jsx';
type Props = {
id?: string;
showCloseButton: boolean;
color: string;
textColor: string;
@@ -172,6 +173,7 @@ export default class AnnouncementBar extends React.PureComponent<Props, State> {
style={barStyle}
// eslint-disable-next-line react/no-unknown-property
css={{gridArea: 'announcement'}}
data-testid={this.props.id}
>
<OverlayTrigger
delayShow={Constants.OVERLAY_TIME_DELAY}

Просмотреть файл

@@ -3031,6 +3031,11 @@
"cloud_archived.error.access": "Permalink belongs to a message that has been archived because of {planName} limits. Upgrade to access message again.",
"cloud_archived.error.title": "Message Archived",
"cloud_billing_history_modal.title": "Invoice(s)",
"cloud_billing.nudge_to_paid.contact_sales": "Contact sales",
"cloud_billing.nudge_to_paid.description": "Cloud Free will be deprecated in {days} days. Upgrade to a paid plan or contact sales.",
"cloud_billing.nudge_to_paid.learn_more": "Upgrade",
"cloud_billing.nudge_to_paid.title": "Upgrade to paid plan to keep your workspace",
"cloud_billing.nudge_to_paid.view_plans": "View plans",
"cloud_billing.nudge_to_yearly.contact_sales": "Contact sales",
"cloud_billing.nudge_to_yearly.description": "Simplify your billing by switching to an annual subscription.",
"cloud_billing.nudge_to_yearly.learn_more": "Learn more",

Просмотреть файл

@@ -300,6 +300,10 @@ export function onboardingTourTipsEnabled(state: GlobalState): boolean {
return getFeatureFlagValue(state, 'OnboardingTourTips') === 'true';
}
export function deprecateCloudFree(state: GlobalState): boolean {
return getFeatureFlagValue(state, 'DeprecateCloudFree') === 'true';
}
export function appsSidebarCategoryEnabled(state: GlobalState): boolean {
return getFeatureFlagValue(state, 'AppsSidebarCategory') === 'true';
}

Просмотреть файл

@@ -158,6 +158,7 @@ export const Preferences = {
NOTIFY_ADMIN_REVOKE_DOWNGRADED_WORKSPACE: 'admin_revoke_downgraded_instance',
OVERAGE_USERS_BANNER: 'overage_users_banner',
CLOUD_YEARLY_NUDGE_BANNER: 'cloud_yearly_nudge_banner',
TO_PAID_PLAN_NUDGE: 'to_paid_plan_nudge',
};
// For one off things that have a special, attention-grabbing UI until you interact with them
@@ -726,6 +727,7 @@ export const CloudBanners = {
UPGRADE_FROM_TRIAL: 'upgrade_from_trial',
THREE_DAYS_LEFT_TRIAL_MODAL_DISMISSED: 'dismiss_3_days_left_trial_modal',
NUDGE_TO_YEARLY_BANNER_DISMISSED: 'nudge_to_yearly_banner_dismissed',
NUDGE_TO_PAID_PLAN_SNOOZED: 'nudge_to_paid_plan_snoozed',
};
export const ConfigurationBanners = {