From 155e896dcfb152fa2bef06cc316aaab11c160039 Mon Sep 17 00:00:00 2001 From: Allan Guwatudde Date: Wed, 12 Apr 2023 18:08:37 +0300 Subject: [PATCH] [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 --- model/feature_flags.go | 3 + .../billing/billing_subscriptions/index.tsx | 2 + .../to_paid_plan_nudge_banner.scss | 28 ++ .../to_paid_plan_nudge_banner.test.tsx | 239 +++++++++++++++++ .../to_paid_plan_nudge_banner.tsx | 246 ++++++++++++++++++ .../channels/src/components/alert_banner.tsx | 3 + .../announcement_bar_controller.tsx | 4 + .../announcement_bar.tsx | 2 + webapp/channels/src/i18n/en.json | 5 + .../src/selectors/entities/preferences.ts | 4 + webapp/channels/src/utils/constants.tsx | 2 + 11 files changed, 538 insertions(+) create mode 100644 webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner.scss create mode 100644 webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner.test.tsx create mode 100644 webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner.tsx diff --git a/model/feature_flags.go b/model/feature_flags.go index 792ae834e9..f835aab621 100644 --- a/model/feature_flags.go +++ b/model/feature_flags.go @@ -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 diff --git a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/index.tsx b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/index.tsx index ac6bdb6ef1..2f46aeb588 100644 --- a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/index.tsx +++ b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/index.tsx @@ -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()} {} + {} {showCreditCardBanner && isCardExpired && creditCardExpiredBanner(setShowCreditCardBanner)} diff --git a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner.scss b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner.scss new file mode 100644 index 0000000000..f49b2f2ab7 --- /dev/null +++ b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner.scss @@ -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); + } + } +} diff --git a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner.test.tsx b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner.test.tsx new file mode 100644 index 0000000000..ee783bcaaf --- /dev/null +++ b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner.test.tsx @@ -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(, 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(, 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(, 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(, 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(, 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(, 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(, 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(, state); + + expect(() => screen.getByTestId('cloud-free-deprecation-alert-banner')).toThrow(); + }); +}); diff --git a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner.tsx b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner.tsx new file mode 100644 index 0000000000..06fda8eb9a --- /dev/null +++ b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner.tsx @@ -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 ( + 10} + onButtonClick={openPricingModal} + modalButtonText={t('cloud_billing.nudge_to_paid.view_plans')} + modalButtonDefaultText='View plans' + 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 = ( + + ); + + const description = ( + + ); + + const viewPlansAction = ( + + ); + + const contactSalesAction = ( + + ); + + const bannerMode = (daysToCloudFreeEnd <= 10) ? 'danger' : 'info'; + + return ( + + ); +}; diff --git a/webapp/channels/src/components/alert_banner.tsx b/webapp/channels/src/components/alert_banner.tsx index ebd3adaae8..b091a555ec 100644 --- a/webapp/channels/src/components/alert_banner.tsx +++ b/webapp/channels/src/components/alert_banner.tsx @@ -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 (
{ let cloudDelinquencyAnnouncementBar = null; let notifyAdminDowngradeDelinquencyBar = null; let toYearlyNudgeBannerDismissable = null; + let toPaidPlanNudgeBannerDismissable = null; if (this.props.license?.Cloud === 'true') { paymentAnnouncementBar = ( @@ -89,6 +91,7 @@ class AnnouncementBarController extends React.PureComponent { ); toYearlyNudgeBannerDismissable = (); + toPaidPlanNudgeBannerDismissable = (); } let autoStartTrialModal = null; @@ -108,6 +111,7 @@ class AnnouncementBarController extends React.PureComponent { {cloudDelinquencyAnnouncementBar} {notifyAdminDowngradeDelinquencyBar} {toYearlyNudgeBannerDismissable} + {toPaidPlanNudgeBannerDismissable} {this.props.license?.Cloud !== 'true' && } {autoStartTrialModal} diff --git a/webapp/channels/src/components/announcement_bar/default_announcement_bar/announcement_bar.tsx b/webapp/channels/src/components/announcement_bar/default_announcement_bar/announcement_bar.tsx index a911ad0561..f93791291a 100644 --- a/webapp/channels/src/components/announcement_bar/default_announcement_bar/announcement_bar.tsx +++ b/webapp/channels/src/components/announcement_bar/default_announcement_bar/announcement_bar.tsx @@ -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 { style={barStyle} // eslint-disable-next-line react/no-unknown-property css={{gridArea: 'announcement'}} + data-testid={this.props.id} >