diff --git a/server/public/model/cloud.go b/server/public/model/cloud.go index 1d9d169171..8c367240be 100644 --- a/server/public/model/cloud.go +++ b/server/public/model/cloud.go @@ -228,6 +228,8 @@ type InvoiceLineItem struct { Description string `json:"description"` Type string `json:"type"` Metadata map[string]any `json:"metadata"` + PeriodStart int64 `json:"period_start"` + PeriodEnd int64 `json:"period_end"` } type DelinquencyEmailTrigger struct { diff --git a/webapp/channels/src/components/admin_console/admin_console.tsx b/webapp/channels/src/components/admin_console/admin_console.tsx index 9d1219880f..1e583c3df2 100644 --- a/webapp/channels/src/components/admin_console/admin_console.tsx +++ b/webapp/channels/src/components/admin_console/admin_console.tsx @@ -15,7 +15,6 @@ import type {ActionResult} from 'mattermost-redux/types/actions'; import SchemaAdminSettings from 'components/admin_console/schema_admin_settings'; import AnnouncementBarController from 'components/announcement_bar'; import BackstageNavbar from 'components/backstage/components/backstage_navbar'; -import DelinquencyModal from 'components/delinquency_modal'; import DiscardChangesModal from 'components/discard_changes_modal'; import ModalController from 'components/modal_controller'; import SystemNotice from 'components/system_notice'; @@ -229,7 +228,6 @@ export default class AdminConsole extends React.PureComponent { {discardChangesModal} - ); diff --git a/webapp/channels/src/components/admin_console/admin_definition.tsx b/webapp/channels/src/components/admin_console/admin_definition.tsx index 13e23d3374..d3719d80c1 100644 --- a/webapp/channels/src/components/admin_console/admin_definition.tsx +++ b/webapp/channels/src/components/admin_console/admin_definition.tsx @@ -7,7 +7,7 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; import {AccountMultipleOutlineIcon, ChartBarIcon, CogOutlineIcon, CreditCardOutlineIcon, FlaskOutlineIcon, FormatListBulletedIcon, InformationOutlineIcon, PowerPlugOutlineIcon, ServerVariantIcon, ShieldOutlineIcon, SitemapIcon} from '@mattermost/compass-icons/components'; -import type {CloudState, Product, Limits} from '@mattermost/types/cloud'; +import type {CloudState, Product} from '@mattermost/types/cloud'; import type {AdminConfig, ClientLicense} from '@mattermost/types/config'; import type {Job} from '@mattermost/types/jobs'; import type {DeepPartial} from '@mattermost/types/utilities'; @@ -32,7 +32,6 @@ import TeamAnalytics from 'components/analytics/team_analytics'; import ExternalLink from 'components/external_link'; import RestrictedIndicator from 'components/widgets/menu/menu_items/restricted_indicator'; -import {isCloudFreePlan} from 'utils/cloud_utils'; import {Constants, CloudProducts, LicenseSkus, AboutLinks, DocLinks, DeveloperLinks} from 'utils/constants'; import {t} from 'utils/i18n'; import {isCloudLicense} from 'utils/license_utils'; @@ -222,10 +221,7 @@ export const it = { if (!productId) { return false; } - const limits = cloud.limits || {}; - const subscriptionProduct = cloud.products?.[productId]; - const isCloudFreeProduct = isCloudFreePlan(subscriptionProduct, limits as Limits); - return cloud?.subscription?.is_free_trial === 'true' || isCloudFreeProduct; + return cloud?.subscription?.is_free_trial === 'true'; }, userHasReadPermissionOnResource: (key: string) => (config: DeepPartial, state: any, license?: ClientLicense, enterpriseReady?: boolean, consoleAccess?: ConsoleAccess) => (consoleAccess?.read as any)?.[key], userHasReadPermissionOnSomeResources: (key: string | {[key: string]: string}) => Object.values(key).some((resource) => it.userHasReadPermissionOnResource(resource)), diff --git a/webapp/channels/src/components/admin_console/billing/billing_history.test.tsx b/webapp/channels/src/components/admin_console/billing/billing_history.test.tsx index e518500c6f..e1cd6bdd6a 100644 --- a/webapp/channels/src/components/admin_console/billing/billing_history.test.tsx +++ b/webapp/channels/src/components/admin_console/billing/billing_history.test.tsx @@ -32,6 +32,8 @@ const invoiceA = TestHelper.getInvoiceMock({ '1 × Cloud Professional (at $10.00 / month)', type: 'onpremise', metadata: {}, + period_end: 1642330466000, + period_start: 1643540066000, }, ], }); @@ -56,6 +58,8 @@ const invoiceB = TestHelper.getInvoiceMock({ 'Trial period for Cloud Professional', type: 'onpremise', metadata: {}, + period_end: 1642330466000, + period_start: 1643540066000, }, ], }); diff --git a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/billing_subscriptions.test.tsx b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/billing_subscriptions.test.tsx index f7f3995a20..ed6c74ebd2 100644 --- a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/billing_subscriptions.test.tsx +++ b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/billing_subscriptions.test.tsx @@ -69,7 +69,7 @@ describe('CloudAnnualRenewalBanner', () => { }; const {getByText} = renderWithContext(, state); - expect(getByText(/Your annual subscription expires in 31 days. Please renew now to avoid any disruption/)).toBeInTheDocument(); + expect(getByText(/Your annual subscription expires in 30 days. Please renew now to avoid any disruption/)).toBeInTheDocument(); expect(getByText(/Renew/)).toBeInTheDocument(); expect(getByText(/Contact Sales/)).toBeInTheDocument(); @@ -85,7 +85,7 @@ describe('CloudAnnualRenewalBanner', () => { }; const {getByText, getByTestId} = renderWithContext(, state); - expect(getByText(/Your annual subscription expires in 5 days. Please renew now to avoid any disruption/)).toBeInTheDocument(); + expect(getByText(/Your annual subscription expires in 4 days. Please renew now to avoid any disruption/)).toBeInTheDocument(); expect(getByText(/Renew/)).toBeInTheDocument(); expect(getByText(/Contact Sales/)).toBeInTheDocument(); expect(getByTestId('cloud_annual_renewal_alert_banner_danger')).toBeInTheDocument(); @@ -100,7 +100,7 @@ describe('CloudAnnualRenewalBanner', () => { }; const {getByText, getByTestId} = renderWithContext(, state); - expect(getByText(/Your subscription has expired. Your workspace will be deleted in 6 days. Please renew now to avoid any disruption/)).toBeInTheDocument(); + expect(getByText(/Your subscription has expired. Your workspace will be deleted in 5 days. Please renew now to avoid any disruption/)).toBeInTheDocument(); expect(getByText(/Renew/)).toBeInTheDocument(); expect(getByText(/Contact Sales/)).toBeInTheDocument(); expect(getByTestId('cloud_annual_renewal_alert_banner_danger')).toBeInTheDocument(); diff --git a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/billing_subscriptions.tsx b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/billing_subscriptions.tsx index 0d5b3efcd1..7ab34a775b 100644 --- a/webapp/channels/src/components/admin_console/billing/billing_subscriptions/billing_subscriptions.tsx +++ b/webapp/channels/src/components/admin_console/billing/billing_subscriptions/billing_subscriptions.tsx @@ -61,16 +61,15 @@ export const paymentFailedBanner = () => { }; export const CloudAnnualRenewalBanner = () => { - // TODO: Update with renewal modal const openPurchaseModal = useOpenCloudPurchaseModal({}); const subscription = useGetSubscription(); const {formatMessage} = useIntl(); const [openSalesLink] = useOpenSalesLink(); - if (!subscription || !subscription.cancel_at) { + if (!subscription || !subscription.cancel_at || (subscription.will_renew === 'true' && !subscription.delinquent_since)) { return null; } - const daysUntilExpiration = daysToExpiration(subscription?.end_at * 1000); - const daysUntilCancelation = daysToExpiration(subscription?.cancel_at * 1000); + const daysUntilExpiration = daysToExpiration(subscription?.end_at); + const daysUntilCancelation = daysToExpiration(subscription?.cancel_at); const renewButton = ( - ); - - const contactSalesAction = ( - - ); - - const bannerMode = (daysToProMonthlyEnd <= 10) ? 'danger' : 'info'; - - return ( - - ); -}; - -export { - ToYearlyNudgeBanner, - ToYearlyNudgeBannerDismissable, -}; diff --git a/webapp/channels/src/components/admin_console/billing/billing_summary/billing_summary.scss b/webapp/channels/src/components/admin_console/billing/billing_summary/billing_summary.scss index 2ba9ed9ddf..1d6653d67f 100644 --- a/webapp/channels/src/components/admin_console/billing/billing_summary/billing_summary.scss +++ b/webapp/channels/src/components/admin_console/billing/billing_summary/billing_summary.scss @@ -87,6 +87,10 @@ margin: 0 auto; } } + + span { + margin-left: 4px; + } } .BillingSummary__lastInvoice-date { diff --git a/webapp/channels/src/components/admin_console/billing/billing_summary/billing_summary.tsx b/webapp/channels/src/components/admin_console/billing/billing_summary/billing_summary.tsx index d7163ca915..8c9cde5748 100644 --- a/webapp/channels/src/components/admin_console/billing/billing_summary/billing_summary.tsx +++ b/webapp/channels/src/components/admin_console/billing/billing_summary/billing_summary.tsx @@ -5,7 +5,7 @@ import React from 'react'; import {FormattedDate, FormattedMessage, FormattedNumber} from 'react-intl'; import {useDispatch} from 'react-redux'; -import {CheckCircleOutlineIcon} from '@mattermost/compass-icons/components'; +import {CheckCircleOutlineIcon, CheckIcon, ClockOutlineIcon} from '@mattermost/compass-icons/components'; import type {Invoice, InvoiceLineItem, Product} from '@mattermost/types/cloud'; import {Client4} from 'mattermost-redux/client'; @@ -123,36 +123,47 @@ export const freeTrial = (onUpgradeMattermostCloud: (callerInfo: string) => void ); -export const getPaymentStatus = (status: string) => { +export const getPaymentStatus = (status: string, willRenew?: boolean) => { + if (willRenew) { + return ( +
+ {' '} + +
+ ); + } switch (status.toLowerCase()) { case 'failed': return (
+ {' '} -
); case 'paid': return (
+ {' '} -
); default: return (
+ {' '} -
); } @@ -164,9 +175,10 @@ type InvoiceInfoProps = { fullCharges: InvoiceLineItem[]; partialCharges: InvoiceLineItem[]; hasMore?: number; + willRenew?: boolean; } -export const InvoiceInfo = ({invoice, product, fullCharges, partialCharges, hasMore}: InvoiceInfoProps) => { +export const InvoiceInfo = ({invoice, product, fullCharges, partialCharges, hasMore, willRenew}: InvoiceInfoProps) => { const dispatch = useDispatch(); const isUpcomingInvoice = invoice?.status.toLowerCase() === 'upcoming'; const openInvoicePreview = () => { @@ -202,7 +214,7 @@ export const InvoiceInfo = ({invoice, product, fullCharges, partialCharges, hasM
{title()}
- {getPaymentStatus(invoice.status)} + {getPaymentStatus(invoice.status, willRenew)}
item.type === 'full'); - const partialCharges = invoice.line_items.filter((item) => item.type === 'partial'); - if (!partialCharges.length && !fullCharges.length) { - fullCharges = invoice.line_items; - } - let hasMoreLineItems = 0; - if (fullCharges.length > 5) { - hasMoreLineItems = fullCharges.length - 5; - fullCharges = fullCharges.slice(0, 5); - } + const {fullCharges, partialCharges, hasMore} = buildInvoiceSummaryPropsFromLineItems(invoice.line_items); body = ( ); } diff --git a/webapp/channels/src/components/admin_console/billing/invoice_user_count.test.tsx b/webapp/channels/src/components/admin_console/billing/invoice_user_count.test.tsx index c2cace1244..fdc974cd20 100644 --- a/webapp/channels/src/components/admin_console/billing/invoice_user_count.test.tsx +++ b/webapp/channels/src/components/admin_console/billing/invoice_user_count.test.tsx @@ -32,6 +32,8 @@ function makeInvoice(...lines: Array<[number, typeof InvoiceLineItemType[keyof t description: '', type, metadata: {} as Record, + period_end: 1642330466000, + period_start: 1643540066000, }; if (type === InvoiceLineItemType.Full || type === InvoiceLineItemType.Partial) { lineItem.metadata.type = type; diff --git a/webapp/channels/src/components/announcement_bar/announcement_bar_controller.tsx b/webapp/channels/src/components/announcement_bar/announcement_bar_controller.tsx index a16726cd3d..cf8995ca2f 100644 --- a/webapp/channels/src/components/announcement_bar/announcement_bar_controller.tsx +++ b/webapp/channels/src/components/announcement_bar/announcement_bar_controller.tsx @@ -6,7 +6,6 @@ import React from 'react'; import type {ClientLicense, ClientConfig, WarnMetricStatus} from '@mattermost/types/config'; import {ToPaidPlanBannerDismissable} from 'components/admin_console/billing/billing_subscriptions/to_paid_plan_nudge_banner'; -import {ToYearlyNudgeBannerDismissable} from 'components/admin_console/billing/billing_subscriptions/to_yearly_nudge_banner'; import withGetCloudSubscription from 'components/common/hocs/cloud/with_get_cloud_subscription'; import CloudAnnualRenewalAnnouncementBar from './cloud_annual_renewal'; @@ -15,7 +14,6 @@ import CloudTrialAnnouncementBar from './cloud_trial_announcement_bar'; import CloudTrialEndAnnouncementBar from './cloud_trial_ended_announcement_bar'; import ConfigurationAnnouncementBar from './configuration_bar'; import AnnouncementBar from './default_announcement_bar'; -import NotifyAdminDowngradeDelinquencyBar from './notify_admin_downgrade_delinquency_bar'; import OverageUsersBanner from './overage_users_banner'; import PaymentAnnouncementBar from './payment_announcement_bar'; import AutoStartTrialModal from './show_start_trial_modal/show_start_trial_modal'; @@ -72,8 +70,8 @@ class AnnouncementBarController extends React.PureComponent { let cloudTrialEndAnnouncementBar = null; let cloudDelinquencyAnnouncementBar = null; let cloudRenewalAnnouncementBar = null; - let notifyAdminDowngradeDelinquencyBar = null; - let toYearlyNudgeBannerDismissable = null; + const notifyAdminDowngradeDelinquencyBar = null; + const toYearlyNudgeBannerDismissable = null; let toPaidPlanNudgeBannerDismissable = null; if (this.props.license?.Cloud === 'true') { paymentAnnouncementBar = ( @@ -91,10 +89,6 @@ class AnnouncementBarController extends React.PureComponent { cloudRenewalAnnouncementBar = ( ); - notifyAdminDowngradeDelinquencyBar = ( - - ); - toYearlyNudgeBannerDismissable = (); toPaidPlanNudgeBannerDismissable = (); } diff --git a/webapp/channels/src/components/announcement_bar/cloud_annual_renewal/cloud_annual_renewal_announcement_bar.test.tsx b/webapp/channels/src/components/announcement_bar/cloud_annual_renewal/cloud_annual_renewal_announcement_bar.test.tsx index 032c63d7ff..92d043f5b0 100644 --- a/webapp/channels/src/components/announcement_bar/cloud_annual_renewal/cloud_annual_renewal_announcement_bar.test.tsx +++ b/webapp/channels/src/components/announcement_bar/cloud_annual_renewal/cloud_annual_renewal_announcement_bar.test.tsx @@ -9,7 +9,7 @@ import {CloudBanners, CloudProducts, Preferences} from 'utils/constants'; import CloudAnnualRenewalAnnouncementBar, {getCurrentYearAsString} from './index'; -describe('components/announcement_bar/cloud_delinquency', () => { +describe('components/announcement_bar/cloud_annual_renewal', () => { const initialState = { views: { announcementBar: { @@ -110,7 +110,7 @@ describe('components/announcement_bar/cloud_delinquency', () => { state, ); - expect(getByText('Your annual subscription expires in 56 days. Please renew to avoid any disruption.')).toBeInTheDocument(); + expect(getByText('Your annual subscription expires in 55 days. Please renew to avoid any disruption.')).toBeInTheDocument(); }); it('Should NOT show 60 day banner to non-admin when cancel_at time is set accordingly', () => { @@ -149,7 +149,7 @@ describe('components/announcement_bar/cloud_delinquency', () => { state, ); - expect(getByText('Your annual subscription expires in 26 days. Please renew to avoid any disruption.')).toBeInTheDocument(); + expect(getByText('Your annual subscription expires in 25 days. Please renew to avoid any disruption.')).toBeInTheDocument(); }); it('Should NOT show 30 day banner to non-admin when cancel_at time is set accordingly', () => { @@ -188,7 +188,7 @@ describe('components/announcement_bar/cloud_delinquency', () => { state, ); - expect(getByText('Your annual subscription expires in 6 days. Failure to renew will result in your workspace being deleted.')).toBeInTheDocument(); + expect(getByText('Your annual subscription expires in 5 days. Failure to renew will result in your workspace being deleted.')).toBeInTheDocument(); }); it('Should NOT show 7 day banner to non admin when cancel_at time is set accordingly', () => { @@ -309,7 +309,7 @@ describe('components/announcement_bar/cloud_delinquency', () => { state, ); - expect(queryByText('Your annual subscription expires in 56 days. Please renew to avoid any disruption.')).toBeInTheDocument(); + expect(queryByText('Your annual subscription expires in 55 days. Please renew to avoid any disruption.')).toBeInTheDocument(); }); it('Should show 30 day banner to admin in 2023 when they dismissed the banner in 2022', () => { @@ -336,7 +336,7 @@ describe('components/announcement_bar/cloud_delinquency', () => { state, ); - expect(queryByText('Your annual subscription expires in 26 days. Please renew to avoid any disruption.')).toBeInTheDocument(); + expect(queryByText('Your annual subscription expires in 25 days. Please renew to avoid any disruption.')).toBeInTheDocument(); }); it('Should NOT show any banner if renewal date is more than 60 days away"', () => { diff --git a/webapp/channels/src/components/announcement_bar/cloud_annual_renewal/index.tsx b/webapp/channels/src/components/announcement_bar/cloud_annual_renewal/index.tsx index 198660a80c..2ed6af9661 100644 --- a/webapp/channels/src/components/announcement_bar/cloud_annual_renewal/index.tsx +++ b/webapp/channels/src/components/announcement_bar/cloud_annual_renewal/index.tsx @@ -1,12 +1,13 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import React, {useMemo} from 'react'; +import React, {useEffect, useMemo} from 'react'; import {useIntl} from 'react-intl'; import {useDispatch, useSelector} from 'react-redux'; import {InformationOutlineIcon} from '@mattermost/compass-icons/components'; +import {getConfig as adminGetConfig} from 'mattermost-redux/actions/admin'; import {savePreferences} from 'mattermost-redux/actions/preferences'; import {getConfig} from 'mattermost-redux/selectors/entities/admin'; import {get} from 'mattermost-redux/selectors/entities/preferences'; @@ -36,7 +37,6 @@ export const getCurrentYearAsString = () => { const CloudAnnualRenewalAnnouncementBar = () => { const subscription = useGetSubscription(); - // TODO: Update with renewal modal const openPurchaseModal = useOpenCloudPurchaseModal({}); const {formatMessage} = useIntl(); const {isDelinquencySubscription} = useDelinquencySubscription(); @@ -45,14 +45,21 @@ const CloudAnnualRenewalAnnouncementBar = () => { const currentUserId = useSelector(getCurrentUserId); const hasDismissed60DayBanner = useSelector((state: GlobalState) => get(state, Preferences.CLOUD_ANNUAL_RENEWAL_BANNER, `${CloudBanners.ANNUAL_RENEWAL_60_DAY}_${getCurrentYearAsString()}`)) === 'true'; const hasDismissed30DayBanner = useSelector((state: GlobalState) => get(state, Preferences.CLOUD_ANNUAL_RENEWAL_BANNER, `${CloudBanners.ANNUAL_RENEWAL_30_DAY}_${getCurrentYearAsString()}`)) === 'true'; - const cloudAnnualRenewalsEnabled = useSelector(getConfig).FeatureFlags?.CloudAnnualRenewals; + const config = useSelector(getConfig); + const cloudAnnualRenewalsEnabled = config.FeatureFlags?.CloudAnnualRenewals; + + useEffect(() => { + if (!config || !config.FeatureFlags) { + dispatch(adminGetConfig()); + } + }, []); const daysUntilExpiration = useMemo(() => { if (!subscription || !subscription.end_at || !subscription.cancel_at) { return 0; } - return daysToExpiration(subscription.end_at * 1000); + return daysToExpiration(subscription.end_at); }, [subscription]); const handleDismiss = (banner: string) => { diff --git a/webapp/channels/src/components/announcement_bar/notify_admin_downgrade_delinquency_bar/index.tsx b/webapp/channels/src/components/announcement_bar/notify_admin_downgrade_delinquency_bar/index.tsx deleted file mode 100644 index 2e760dc24d..0000000000 --- a/webapp/channels/src/components/announcement_bar/notify_admin_downgrade_delinquency_bar/index.tsx +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React, {useEffect} from 'react'; -import {FormattedMessage} from 'react-intl'; -import {useSelector, useDispatch} from 'react-redux'; - -import {savePreferences} from 'mattermost-redux/actions/preferences'; -import {getSubscriptionProduct} from 'mattermost-redux/selectors/entities/cloud'; -import {makeGetCategory} from 'mattermost-redux/selectors/entities/preferences'; -import {getCurrentUser} from 'mattermost-redux/selectors/entities/users'; -import {isSystemAdmin} from 'mattermost-redux/utils/user_utils'; - -import {trackEvent} from 'actions/telemetry_actions'; - -import {useDelinquencySubscription} from 'components/common/hooks/useDelinquencySubscription'; -import {NotifyStatus, useGetNotifyAdmin} from 'components/common/hooks/useGetNotifyAdmin'; - -import { - AnnouncementBarTypes, CloudProducts, CloudProductToSku, MattermostFeatures, Preferences, TELEMETRY_CATEGORIES, -} from 'utils/constants'; -import {t} from 'utils/i18n'; - -import type {GlobalState} from 'types/store'; - -import AnnouncementBar from '../default_announcement_bar'; - -export const BannerPreferenceName = 'notify_upgrade_workspace_banner'; - -const NotifyAdminDowngradeDelinquencyBar = () => { - const dispatch = useDispatch(); - const getCategory = makeGetCategory(); - const preferences = useSelector((state: GlobalState) => - getCategory(state, Preferences.NOTIFY_ADMIN_REVOKE_DOWNGRADED_WORKSPACE), - ); - const product = useSelector(getSubscriptionProduct); - const currentUser = useSelector((state: GlobalState) => - getCurrentUser(state), - ); - const {isDelinquencySubscriptionHigherThan90Days} = useDelinquencySubscription(); - - const {notifyAdmin, notifyStatus} = useGetNotifyAdmin({}); - - useEffect(() => { - if (notifyStatus === NotifyStatus.Success) { - dispatch(savePreferences(currentUser.id, [{ - category: Preferences.NOTIFY_ADMIN_REVOKE_DOWNGRADED_WORKSPACE, - name: BannerPreferenceName, - user_id: currentUser.id, - value: 'adminNotified', - }])); - } - }, [currentUser, dispatch, notifyStatus]); - - const shouldShowBanner = () => { - if (!isDelinquencySubscriptionHigherThan90Days()) { - return false; - } - - if (isSystemAdmin(currentUser.roles)) { - return false; - } - - if (!preferences) { - return false; - } - - if (preferences.some((pref) => pref.name === BannerPreferenceName)) { - return false; - } - - return true; - }; - - if (!shouldShowBanner() || product == null) { - return null; - } - - const notifyAdminRequestData = { - required_feature: MattermostFeatures.UPGRADE_DOWNGRADED_WORKSPACE, - required_plan: CloudProductToSku[product?.sku] || CloudProductToSku[CloudProducts.PROFESSIONAL], - trial_notification: false, - }; - - const message = ( - ); - - const handleClick = () => { - trackEvent(TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, 'click_notify_admin_upgrade_workspace_banner'); - - notifyAdmin({ - requestData: notifyAdminRequestData, - trackingArgs: { - category: TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, - event: 'notify_admin_downgrade_delinquency_bar', - }, - }); - }; - - const handleClose = () => { - dispatch(savePreferences(currentUser.id, [{ - category: Preferences.NOTIFY_ADMIN_REVOKE_DOWNGRADED_WORKSPACE, - name: BannerPreferenceName, - user_id: currentUser.id, - value: 'dismissBanner', - }])); - }; - - return ( - } - handleClose={handleClose} - /> - ); -}; - -export default NotifyAdminDowngradeDelinquencyBar; diff --git a/webapp/channels/src/components/announcement_bar/notify_admin_downgrade_delinquency_bar/notify_admin_downgrade_delinquency_bar.test.tsx b/webapp/channels/src/components/announcement_bar/notify_admin_downgrade_delinquency_bar/notify_admin_downgrade_delinquency_bar.test.tsx deleted file mode 100644 index a082a013b2..0000000000 --- a/webapp/channels/src/components/announcement_bar/notify_admin_downgrade_delinquency_bar/notify_admin_downgrade_delinquency_bar.test.tsx +++ /dev/null @@ -1,212 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; - -import {savePreferences} from 'mattermost-redux/actions/preferences'; -import {Client4} from 'mattermost-redux/client'; - -import {trackEvent} from 'actions/telemetry_actions'; - -import { - fireEvent, - renderWithContext, - screen, - waitFor, -} from 'tests/react_testing_utils'; -import {CloudProducts, Preferences, TELEMETRY_CATEGORIES} from 'utils/constants'; -import {TestHelper} from 'utils/test_helper'; - -import NotifyAdminDowngradeDeliquencyBar, {BannerPreferenceName} from './index'; - -jest.mock('actions/telemetry_actions', () => ({ - trackEvent: jest.fn(), -})); - -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useDispatch: jest.fn().mockReturnValue(() => {}), -})); - -jest.mock('mattermost-redux/actions/preferences', () => ({ - savePreferences: jest.fn(), -})); - -describe('components/announcement_bar/notify_admin_downgrade_delinquency_bar', () => { - const initialState = { - views: { - announcementBar: { - announcementBarState: { - announcementBarCount: 1, - }, - }, - }, - entities: { - general: { - license: { - IsLicensed: 'true', - Cloud: 'true', - }, - }, - preferences: { - myPreferences: {}, - }, - users: { - currentUserId: 'current_user_id', - profiles: { - current_user_id: {id: 'id', roles: 'system_user'}, - }, - }, - cloud: { - subscription: { - product_id: 'test_prod_1', - trial_end_at: 1652807380, - is_free_trial: 'false', - delinquent_since: 1652807380, // may 17 2022 - }, - products: { - test_prod_1: { - id: 'test_prod_1', - sku: CloudProducts.STARTER, - price_per_seat: 0, - }, - test_prod_2: { - id: 'test_prod_2', - sku: CloudProducts.ENTERPRISE, - price_per_seat: 0, - }, - test_prod_3: { - id: 'test_prod_3', - sku: CloudProducts.PROFESSIONAL, - price_per_seat: 0, - }, - }, - }, - }, - }; - - beforeEach(() => { - jest.clearAllMocks(); - }); - - it('Should not show banner when there isn\'t delinquency', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud.subscription = { - product_id: 'test_prod_1', - trial_end_at: 1652807380, - is_free_trial: 'false', - }; - - renderWithContext(, state); - - expect(screen.queryByText('Your workspace has been downgraded. Notify your admin to fix billing issues')).not.toBeInTheDocument(); - }); - - it('Should not show banner when deliquency is less than 90 days', () => { - jest.useFakeTimers().setSystemTime(new Date('2022-06-20')); - - renderWithContext(, initialState); - - expect(screen.queryByText('Your workspace has been downgraded. Notify your admin to fix billing issues')).not.toBeInTheDocument(); - }); - - it('Should not show banner when the user has notify their admin', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.preferences.myPreferences = TestHelper.getPreferencesMock( - [ - { - category: Preferences.NOTIFY_ADMIN_REVOKE_DOWNGRADED_WORKSPACE, - name: BannerPreferenceName, - value: 'adminNotified', - }, - ], - ); - - renderWithContext(, state); - - expect(screen.queryByText('Your workspace has been downgraded. Notify your admin to fix billing issues')).not.toBeInTheDocument(); - }); - - it('Should not show banner when the user closed the banner', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.preferences.myPreferences = TestHelper.getPreferencesMock( - [ - { - category: Preferences.NOTIFY_ADMIN_REVOKE_DOWNGRADED_WORKSPACE, - name: BannerPreferenceName, - value: 'dismissBanner', - }, - ], - ); - - renderWithContext(, state); - - expect(screen.queryByText('Your workspace has been downgraded. Notify your admin to fix billing issues')).not.toBeInTheDocument(); - }); - - it('Should not show banner when the user is an admin', () => { - jest.useFakeTimers().setSystemTime(new Date('2022-08-17')); - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.users.profiles.current_user_id = {roles: 'system_admin'}; - - renderWithContext(, state); - - expect(screen.queryByText('Your workspace has been downgraded. Notify your admin to fix billing issues')).not.toBeInTheDocument(); - }); - - it('Should not save the preferences if the user can\'t notify', () => { - jest.useFakeTimers().setSystemTime(new Date('2022-08-17')); - Client4.notifyAdmin = jest.fn(); - - renderWithContext(, initialState); - - expect(savePreferences).not.toBeCalled(); - }); - - it('Should show banner when deliquency is higher than 90 days', () => { - jest.useFakeTimers().setSystemTime(new Date('2022-08-17')); - - renderWithContext(, initialState); - - expect(screen.getByText('Your workspace has been downgraded. Notify your admin to fix billing issues')).toBeInTheDocument(); - }); - - it('Should save the preferences if the user close the banner', async () => { - jest.useFakeTimers().setSystemTime(new Date('2022-08-17')); - - renderWithContext(, initialState); - - fireEvent.click(screen.getByRole('link')); - - expect(savePreferences).toBeCalledTimes(1); - expect(savePreferences).toBeCalledWith(initialState.entities.users.profiles.current_user_id.id, [{ - category: Preferences.NOTIFY_ADMIN_REVOKE_DOWNGRADED_WORKSPACE, - name: BannerPreferenceName, - user_id: initialState.entities.users.profiles.current_user_id.id, - value: 'dismissBanner', - }]); - }); - - it('Should save the preferences and track event after notify their admin', async () => { - jest.useFakeTimers().setSystemTime(new Date('2022-08-17')); - Client4.notifyAdmin = jest.fn(); - - renderWithContext(, initialState); - - fireEvent.click(screen.getByText('Notify admin')); - - await waitFor(() => { - expect(savePreferences).toBeCalledTimes(1); - expect(savePreferences).toBeCalledWith(initialState.entities.users.profiles.current_user_id.id, [{ - category: Preferences.NOTIFY_ADMIN_REVOKE_DOWNGRADED_WORKSPACE, - name: BannerPreferenceName, - user_id: initialState.entities.users.profiles.current_user_id.id, - value: 'adminNotified', - }]); - - expect(trackEvent).toBeCalledTimes(2); - expect(trackEvent).toHaveBeenNthCalledWith(1, TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, 'click_notify_admin_upgrade_workspace_banner'); - expect(trackEvent).toHaveBeenNthCalledWith(2, TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, 'notify_admin_downgrade_delinquency_bar', undefined); - }); - }); -}); diff --git a/webapp/channels/src/components/delinquency_modal/delinquency_modal.scss b/webapp/channels/src/components/delinquency_modal/delinquency_modal.scss deleted file mode 100644 index 6cbf121f7b..0000000000 --- a/webapp/channels/src/components/delinquency_modal/delinquency_modal.scss +++ /dev/null @@ -1,120 +0,0 @@ -@import 'sass/utils/_mixins'; - -.DelinquencyModal { - .modal-dialog { - position: absolute; - top: 50%; - left: 50%; - margin: auto; - transform: translate(-50%, -50%) !important; - } - - .modal-content { - max-width: 512px; - max-height: 465px; - border-radius: 12px; - } - - .modal-header { - border-radius: 12px 12px 0 0; - } - - .modal-footer { - padding-top: 24px; - padding-bottom: 24px; - border-radius: 0 0 12px 12px; - } - - .modal-header, - .modal-footer { - display: flex; - padding-right: 32px; - padding-left: 32px; - } - - .DelinquencyModal__header { - align-items: center; - border: none; - - &.modal-header { - background: var(--center-channel-bg); - } - - h3 { - margin: 0; - color: rgba(63, 67, 80, 1); - font-size: 22px; - font-weight: bold; - line-height: 28px; - } - - .icon-close { - @include button-medium; - - padding: 0; - border: none; - margin-left: auto; - background: var(--center-channel-bg); - color: rgba(63, 67, 80, 1); - font-size: 24px; - } - } - - .modal-body { - display: block; - overflow: hidden; - max-height: none; - padding: 0 32px; - margin-bottom: 28px; - color: rgba(63, 67, 80, 1); - } - - &__body { - text-align: center; - - &__freemium { - text-align: left; - } - - &__title { - margin-bottom: 8px; - font-size: 22px; - font-weight: bold; - line-height: 28px; - } - - &__information { - margin: 0; - font-size: 12px; - } - - &__limits-information { - margin: 0; - margin-bottom: 24px; - font-size: 12px; - } - - &__subheader { - display: block; - margin-bottom: 16px; - color: rgba(63, 67, 80, 0.56); - font-size: 10px; - font-weight: bold; - text-transform: uppercase; - } - } - - &__footer { - &--secondary { - @include tertiary-button; - @include button-large; - } - - &--primary { - @include primary-button; - @include button-large; - - margin-left: auto; - } - } -} diff --git a/webapp/channels/src/components/delinquency_modal/delinquency_modal.test.tsx b/webapp/channels/src/components/delinquency_modal/delinquency_modal.test.tsx deleted file mode 100644 index feef3fe25f..0000000000 --- a/webapp/channels/src/components/delinquency_modal/delinquency_modal.test.tsx +++ /dev/null @@ -1,105 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import type {ComponentProps} from 'react'; - -import {savePreferences} from 'mattermost-redux/actions/preferences'; - -import {trackEvent} from 'actions/telemetry_actions'; - -import {fireEvent, renderWithContext, screen} from 'tests/react_testing_utils'; -import {ModalIdentifiers, Preferences, TELEMETRY_CATEGORIES} from 'utils/constants'; - -import DelinquencyModal from './delinquency_modal'; - -jest.mock('mattermost-redux/actions/preferences', () => ({ - savePreferences: jest.fn(), -})); - -jest.mock('actions/telemetry_actions', () => ({ - trackEvent: jest.fn(), -})); - -jest.mock('actions/views/modals', () => ({ - openModal: jest.fn(), -})); - -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useDispatch: jest.fn().mockReturnValue(() => {}), -})); - -describe('components/deliquency_modal/deliquency_modal', () => { - const initialState = { - views: { - modals: { - modalState: { - [ModalIdentifiers.DELINQUENCY_MODAL_DOWNGRADE]: { - open: true, - dialogProps: { - planName: 'plan_name', - onExited: () => {}, - closeModal: () => {}, - isAdminConsole: false, - }, - dialogType: React.Fragment as any, - }, - }, - showLaunchingWorkspace: false, - }, - }, - entities: { - users: { - currentUserId: 'current_user_id', - profiles: { - current_user_id: {roles: 'system_admin', id: 'test'}, - }, - }, - }, - }; - - const baseProps: ComponentProps = { - closeModal: jest.fn(), - onExited: jest.fn(), - planName: 'planName', - isAdminConsole: false, - }; - - it('should save preferences and track stayOnFremium if admin click Stay on Free', () => { - renderWithContext(, initialState); - - fireEvent.click(screen.getByText('Stay on Free')); - - expect(savePreferences).toBeCalledTimes(1); - expect(savePreferences).toBeCalledWith(initialState.entities.users.profiles.current_user_id.id, [{ - category: Preferences.DELINQUENCY_MODAL_CONFIRMED, - name: ModalIdentifiers.DELINQUENCY_MODAL_DOWNGRADE, - user_id: initialState.entities.users.profiles.current_user_id.id, - value: 'stayOnFremium', - }]); - - expect(trackEvent).toBeCalledTimes(1); - expect(trackEvent).toBeCalledWith(TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, 'clicked_stay_on_freemium'); - }); - - it('should save preferences and track update Billing if admin click Update Billing', () => { - renderWithContext(, initialState); - - fireEvent.click(screen.getByText('Update Billing')); - - expect(savePreferences).toBeCalledTimes(1); - expect(savePreferences).toBeCalledWith(initialState.entities.users.profiles.current_user_id.id, [{ - category: Preferences.DELINQUENCY_MODAL_CONFIRMED, - name: ModalIdentifiers.DELINQUENCY_MODAL_DOWNGRADE, - user_id: initialState.entities.users.profiles.current_user_id.id, - value: 'updateBilling', - }]); - - expect(trackEvent).toBeCalledTimes(2); - expect(trackEvent).toHaveBeenNthCalledWith(1, TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, 'clicked_update_billing'); - expect(trackEvent).toHaveBeenNthCalledWith(2, TELEMETRY_CATEGORIES.CLOUD_ADMIN, 'click_open_delinquency_modal', { - callerInfo: 'delinquency_modal_downgrade_admin', - }); - }); -}); diff --git a/webapp/channels/src/components/delinquency_modal/delinquency_modal.tsx b/webapp/channels/src/components/delinquency_modal/delinquency_modal.tsx deleted file mode 100644 index bf97ec9210..0000000000 --- a/webapp/channels/src/components/delinquency_modal/delinquency_modal.tsx +++ /dev/null @@ -1,159 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {Modal} from 'react-bootstrap'; -import {FormattedMessage} from 'react-intl'; -import {useDispatch, useSelector} from 'react-redux'; - -import {savePreferences} from 'mattermost-redux/actions/preferences'; -import {getCurrentUser} from 'mattermost-redux/selectors/entities/common'; -import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; - -import {trackEvent} from 'actions/telemetry_actions'; -import {openModal, closeModal as closeModalAction} from 'actions/views/modals'; -import {isModalOpen} from 'selectors/views/modals'; - -import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal'; -import UpgradeSvg from 'components/common/svg_images_components/upgrade_svg'; -import CompassThemeProvider from 'components/compass_theme_provider/compass_theme_provider'; - -import {ModalIdentifiers, Preferences, TELEMETRY_CATEGORIES} from 'utils/constants'; - -import type {GlobalState} from 'types/store'; - -import {FreemiumModal} from './freemium_modal'; - -import './delinquency_modal.scss'; - -interface DelinquencyModalProps { - planName: string; - onExited: () => void; - closeModal: () => void; - isAdminConsole?: boolean; -} - -const DelinquencyModal = (props: DelinquencyModalProps) => { - const dispatch = useDispatch(); - const show = useSelector((state: GlobalState) => isModalOpen(state, ModalIdentifiers.DELINQUENCY_MODAL_DOWNGRADE)); - const currentUser = useSelector((state: GlobalState) => getCurrentUser(state)); - const {closeModal, onExited, planName, isAdminConsole} = props; - const openPurchaseModal = useOpenCloudPurchaseModal({isDelinquencyModal: true}); - const theme = useSelector(getTheme); - - const handleShowFremium = () => { - trackEvent(TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, 'clicked_stay_on_freemium'); - closeModal(); - dispatch(savePreferences(currentUser.id, [{ - category: Preferences.DELINQUENCY_MODAL_CONFIRMED, - name: ModalIdentifiers.DELINQUENCY_MODAL_DOWNGRADE, - user_id: currentUser.id, - value: 'stayOnFremium', - }])); - dispatch(openModal({ - dialogType: FreemiumModal, - modalId: ModalIdentifiers.CLOUD_LIMITS_DOWNGRADE, - dialogProps: { - onExited, - onClose: () => dispatch(closeModalAction(ModalIdentifiers.CLOUD_LIMITS_DOWNGRADE)), - isAdminConsole, - planName, - }, - })); - }; - - const handleClose = () => { - closeModal(); - onExited(); - }; - - const handleUpdateBilling = () => { - handleClose(); - trackEvent(TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, 'clicked_update_billing'); - openPurchaseModal({ - trackingLocation: 'delinquency_modal_downgrade_admin', - }); - dispatch(savePreferences(currentUser.id, [{ - category: Preferences.DELINQUENCY_MODAL_CONFIRMED, - name: ModalIdentifiers.DELINQUENCY_MODAL_DOWNGRADE, - user_id: currentUser.id, - value: 'updateBilling', - }])); - }; - - const ModalJSX = ( - - - - - - - ); - - if (!isAdminConsole) { - return ModalJSX; - } - - return ( - {ModalJSX} - ); -}; - -export default DelinquencyModal; diff --git a/webapp/channels/src/components/delinquency_modal/delinquency_modal_controller.test.tsx b/webapp/channels/src/components/delinquency_modal/delinquency_modal_controller.test.tsx deleted file mode 100644 index a132cd5599..0000000000 --- a/webapp/channels/src/components/delinquency_modal/delinquency_modal_controller.test.tsx +++ /dev/null @@ -1,333 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; - -import type {DeepPartial} from '@mattermost/types/utilities'; - -import * as cloudActions from 'mattermost-redux/actions/cloud'; - -import * as StorageSelectors from 'selectors/storage'; - -import ModalController from 'components/modal_controller'; - -import {renderWithContext, screen} from 'tests/react_testing_utils'; -import {CloudProducts, ModalIdentifiers, Preferences} from 'utils/constants'; -import {TestHelper} from 'utils/test_helper'; - -import type {GlobalState} from 'types/store'; - -import DelinquencyModalController from './index'; - -jest.mock('selectors/storage'); - -(StorageSelectors.makeGetItem as jest.Mock).mockReturnValue(() => false); - -describe('components/delinquency_modal/delinquency_modal_controller', () => { - const initialState: DeepPartial = { - views: { - modals: { - modalState: {}, - }, - }, - entities: { - general: { - license: { - IsLicensed: 'true', - Cloud: 'true', - }, - }, - preferences: { - myPreferences: {}, - }, - users: { - currentUserId: 'current_user_id', - profiles: { - current_user_id: {roles: 'system_admin'}, - }, - }, - cloud: { - subscription: TestHelper.getSubscriptionMock({ - product_id: 'test_prod_1', - trial_end_at: 1652807380, - is_free_trial: 'false', - delinquent_since: 1652807380, // may 17 2022 - }), - products: { - test_prod_1: TestHelper.getProductMock({ - id: 'test_prod_1', - sku: CloudProducts.STARTER, - price_per_seat: 0, - name: 'testProd1', - }), - test_prod_2: TestHelper.getProductMock({ - id: 'test_prod_2', - sku: CloudProducts.ENTERPRISE, - price_per_seat: 0, - name: 'testProd2', - }), - test_prod_3: TestHelper.getProductMock({ - id: 'test_prod_3', - sku: CloudProducts.PROFESSIONAL, - price_per_seat: 0, - name: 'testProd3', - }), - }, - }, - }, - }; - - it('Should show the modal if the admin hasn\'t a preference', () => { - jest.useFakeTimers().setSystemTime(new Date('2022-12-20')); - - renderWithContext( - <> -
- - - , - initialState, - ); - - expect(screen.queryByText('Your workspace has been downgraded')).toBeInTheDocument(); - }); - - it('Shouldn\'t show the modal if the admin has a preference', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.preferences.myPreferences = TestHelper.getPreferencesMock( - [ - { - category: Preferences.DELINQUENCY_MODAL_CONFIRMED, - name: ModalIdentifiers.DELINQUENCY_MODAL_DOWNGRADE, - value: 'updateBilling', - }, - ], - ); - - jest.useFakeTimers().setSystemTime(new Date('2022-12-20')); - - renderWithContext( - <> -
- - - , - state, - ); - - expect(screen.queryByText('Your workspace has been downgraded')).not.toBeInTheDocument(); - }); - - it('Should show the modal if the deliquency_since is equal 90 days', () => { - jest.useFakeTimers().setSystemTime(new Date('2022-08-16')); - - renderWithContext( - <> -
- - - , - initialState, - ); - - expect(screen.queryByText('Your workspace has been downgraded')).toBeInTheDocument(); - }); - - it('Should show the modal if the deliquency_since is more than 90 days', () => { - jest.useFakeTimers().setSystemTime(new Date('2022-08-17')); - - renderWithContext( - <> -
- - - , - initialState, - ); - - expect(screen.queryByText('Your workspace has been downgraded')).toBeInTheDocument(); - }); - - it('Shouldn\'t show the modal if the deliqeuncy_since is less than 90 days', () => { - jest.useFakeTimers().setSystemTime(new Date('2022-08-15')); - - renderWithContext( - <> -
- - - , - initialState, - ); - - expect(screen.queryByText('Your workspace has been downgraded')).not.toBeInTheDocument(); - }); - - it('Should show the modal if the license is cloud', () => { - jest.useFakeTimers().setSystemTime(new Date('2022-08-17')); - - renderWithContext( - <> -
- - - , - initialState, - ); - - expect(screen.queryByText('Your workspace has been downgraded')).toBeInTheDocument(); - }); - - it('Shouldn\'t show the modal if the license isn\'t cloud', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.general.license = { - ...state.entities.general.license, - Cloud: 'false', - }; - - jest.useFakeTimers().setSystemTime(new Date('2022-12-20')); - - renderWithContext( - <> -
- - - , - state, - ); - - expect(screen.queryByText('Your workspace has been downgraded')).not.toBeInTheDocument(); - }); - - it('Shouldn\'t show the modal if the subscription isn\'t in delinquency state', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.cloud.subscription = { - ...state.entities.cloud.subscription, - delinquent_since: null, - }; - - jest.useFakeTimers().setSystemTime(new Date('2022-12-20')); - - renderWithContext( - <> -
- - - , - state, - ); - - expect(screen.queryByText('Your workspace has been downgraded')).not.toBeInTheDocument(); - }); - - it('Should show the modal if the user is an admin', () => { - jest.useFakeTimers().setSystemTime(new Date('2022-08-17')); - - renderWithContext( - <> -
- - - , - initialState, - ); - - expect(screen.queryByText('Your workspace has been downgraded')).toBeInTheDocument(); - }); - - it('Shouldn\'t show the modal if the user isn\'t an admin', () => { - const state = JSON.parse(JSON.stringify(initialState)); - state.entities.users = { - currentUserId: 'current_user_id', - profiles: { - current_user_id: {roles: 'user'}, - }, - }; - jest.useFakeTimers().setSystemTime(new Date('2022-12-20')); - - renderWithContext( - <> -
- - - , - state, - ); - - expect(screen.queryByText('Your workspace has been downgraded')).not.toBeInTheDocument(); - }); - - it('Should show the modal if the user just logged in', () => { - jest.useFakeTimers().setSystemTime(new Date('2022-08-17')); - - renderWithContext( - <> -
- - - , - initialState, - ); - - expect(screen.queryByText('Your workspace has been downgraded')).toBeInTheDocument(); - }); - it('Shouldn\'t show the modal if we aren\'t log in', () => { - (StorageSelectors.makeGetItem as jest.Mock).mockReturnValue(() => true); - jest.useFakeTimers().setSystemTime(new Date('2022-08-17')); - - renderWithContext( - <> -
- - - , - initialState, - ); - - expect(screen.queryByText('Your workspace has been downgraded')).not.toBeInTheDocument(); - }); - - it('Should fetch cloud products when on cloud', () => { - jest.useFakeTimers().setSystemTime(new Date('2022-12-20')); - - const newState = JSON.parse(JSON.stringify(initialState)); - newState.entities.cloud.products = {}; - - const getCloudProds = jest.spyOn(cloudActions, 'getCloudProducts').mockImplementationOnce(jest.fn().mockReturnValue({type: 'mock_impl'})); - - renderWithContext( - <> -
- - - , - newState, - ); - - expect(getCloudProds).toHaveBeenCalledTimes(1); - }); - - it('Should NOT fetch cloud products when NOT on cloud', () => { - jest.useFakeTimers().setSystemTime(new Date('2022-12-20')); - - const newState = JSON.parse(JSON.stringify(initialState)); - newState.entities.cloud.products = {}; - newState.entities.general.license = { - IsLicensed: 'true', - Cloud: 'false', - }; - - const getCloudProds = jest.spyOn(cloudActions, 'getCloudProducts').mockImplementationOnce(jest.fn().mockReturnValue({type: 'mock_impl'})); - - renderWithContext( - <> -
- - - , - newState, - ); - - expect(getCloudProds).toHaveBeenCalledTimes(0); - }); -}); diff --git a/webapp/channels/src/components/delinquency_modal/delinquency_modal_controller.tsx b/webapp/channels/src/components/delinquency_modal/delinquency_modal_controller.tsx deleted file mode 100644 index 881a88ac73..0000000000 --- a/webapp/channels/src/components/delinquency_modal/delinquency_modal_controller.tsx +++ /dev/null @@ -1,33 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; - -import type {Subscription} from '@mattermost/types/cloud'; -import type {PreferenceType} from '@mattermost/types/preferences'; - -import withGetCloudSubscription from 'components/common/hocs/cloud/with_get_cloud_subscription'; - -import type {ModalData} from 'types/actions'; - -import {useDelinquencyModalController} from './useDelinquencyModalController'; - -interface DelinquencyModalControllerProps { - userIsAdmin: boolean; - subscription?: Subscription; - isCloud: boolean; - actions: { - getCloudSubscription: () => void; - closeModal: () => void; - openModal:

(modalData: ModalData

) => void; - }; - delinquencyModalPreferencesConfirmed: PreferenceType[]; -} - -const DelinquencyModalController = (props: DelinquencyModalControllerProps) => { - useDelinquencyModalController(props); - - return <>; -}; - -export default withGetCloudSubscription(DelinquencyModalController); diff --git a/webapp/channels/src/components/delinquency_modal/freemium_modal.test.tsx b/webapp/channels/src/components/delinquency_modal/freemium_modal.test.tsx deleted file mode 100644 index 0ef03a791b..0000000000 --- a/webapp/channels/src/components/delinquency_modal/freemium_modal.test.tsx +++ /dev/null @@ -1,129 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import type {ComponentProps} from 'react'; - -import type {DeepPartial} from '@mattermost/types/utilities'; - -import {trackEvent} from 'actions/telemetry_actions'; - -import useGetMultiplesExceededCloudLimit from 'components/common/hooks/useGetMultiplesExceededCloudLimit'; - -import {fireEvent, renderWithContext, screen} from 'tests/react_testing_utils'; -import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants'; -import {LimitTypes} from 'utils/limits'; - -import type {GlobalState} from 'types/store'; - -import {FreemiumModal} from './freemium_modal'; - -jest.mock('actions/telemetry_actions', () => ({ - trackEvent: jest.fn(), -})); - -jest.mock('react-redux', () => ({ - ...jest.requireActual('react-redux'), - useDispatch: jest.fn().mockReturnValue(() => {}), -})); - -jest.mock('components/common/hooks/useGetMultiplesExceededCloudLimit'); - -describe('components/delinquency_modal/freemium_modal', () => { - const initialState: DeepPartial = { - views: { - modals: { - modalState: { - [ModalIdentifiers.DELINQUENCY_MODAL_DOWNGRADE]: { - open: true, - dialogProps: { - planName: 'plan_name', - onExited: () => {}, - closeModal: () => {}, - isAdminConsole: false, - }, - dialogType: React.Fragment as any, - }, - }, - showLaunchingWorkspace: false, - }, - }, - entities: { - users: { - currentUserId: 'current_user_id', - profiles: { - current_user_id: {roles: 'system_admin', id: 'test'}, - }, - }, - }, - }; - - const planName = 'Testing'; - const baseProps: ComponentProps = { - onClose: jest.fn(), - planName, - isAdminConsole: false, - onExited: jest.fn(), - }; - - it('should track reactivate plan if admin click Re activate plan', () => { - (useGetMultiplesExceededCloudLimit as jest.Mock).mockReturnValue([LimitTypes.fileStorage]); - renderWithContext( - , - initialState, - ); - - fireEvent.click(screen.getByText(`Re-activate ${planName}`)); - - expect(trackEvent).toBeCalledTimes(2); - expect(trackEvent).toHaveBeenNthCalledWith(1, TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, 'clicked_re_activate_plan'); - expect(trackEvent).toHaveBeenNthCalledWith(2, TELEMETRY_CATEGORIES.CLOUD_ADMIN, 'click_open_delinquency_modal', { - callerInfo: 'delinquency_modal_freemium_admin', - }); - }); - - it('should not show reactivate plan if admin limits isn\'t surpassed', () => { - (useGetMultiplesExceededCloudLimit as jest.Mock).mockReturnValue([]); - renderWithContext( - , - initialState, - ); - - expect(screen.queryByText(`Re-activate ${planName}`)).not.toBeInTheDocument(); - - expect(trackEvent).toBeCalledTimes(0); - }); - - it('should display message history text when only message limit is surpassed', () => { - (useGetMultiplesExceededCloudLimit as jest.Mock).mockReturnValue([LimitTypes.messageHistory]); - renderWithContext( - , - initialState, - ); - - expect(screen.queryByText(`Re-activate ${planName}`)).toBeInTheDocument(); - expect(screen.getByText('Some of your workspace\'s message history are no longer accessible. Upgrade to a paid plan and get unlimited access to your message history.')).toBeInTheDocument(); - }); - - it('should display storage text when only storage is surpassed', () => { - (useGetMultiplesExceededCloudLimit as jest.Mock).mockReturnValue([LimitTypes.fileStorage]); - renderWithContext( - , - initialState, - ); - - expect(screen.queryByText(`Re-activate ${planName}`)).toBeInTheDocument(); - expect(screen.getByText('Some of your workspace\'s files are no longer accessible. Upgrade to a paid plan and get unlimited access to your files.')).toBeInTheDocument(); - }); - - it('should display update to paid plan text when only multiples limits is surpassed', () => { - (useGetMultiplesExceededCloudLimit as jest.Mock).mockReturnValue([LimitTypes.messageHistory, LimitTypes.fileStorage]); - renderWithContext( - , - initialState, - ); - - expect(screen.queryByText(`Re-activate ${planName}`)).toBeInTheDocument(); - expect(screen.getByText('Your workspace has reached free plan limits. Upgrade to a paid plan.')).toBeInTheDocument(); - }); -}); diff --git a/webapp/channels/src/components/delinquency_modal/freemium_modal.tsx b/webapp/channels/src/components/delinquency_modal/freemium_modal.tsx deleted file mode 100644 index 0a779ce21f..0000000000 --- a/webapp/channels/src/components/delinquency_modal/freemium_modal.tsx +++ /dev/null @@ -1,165 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import React from 'react'; -import {FormattedMessage} from 'react-intl'; -import {useSelector} from 'react-redux'; - -import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; - -import {trackEvent} from 'actions/telemetry_actions'; - -import CloudUsageModal from 'components/cloud_usage_modal'; -import useGetLimits from 'components/common/hooks/useGetLimits'; -import useGetMultiplesExceededCloudLimit from 'components/common/hooks/useGetMultiplesExceededCloudLimit'; -import useGetUsage from 'components/common/hooks/useGetUsage'; -import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal'; - -import {TELEMETRY_CATEGORIES} from 'utils/constants'; -import {t} from 'utils/i18n'; -import type {Message} from 'utils/i18n'; -import {LimitTypes} from 'utils/limits'; - -import './delinquency_modal.scss'; - -type FreemiumModalProps = { - onClose: () => void; - onExited: () => void; - planName: string; - isAdminConsole?: boolean; -} - -type ValueOf = T[keyof T]; - -type DescriptionStatusKey = ValueOf | 'noLimits' | 'multipleLimits'; - -const DescriptionMessages: Record = { - noLimits: ( - - {(text) =>

{text}

} - - ), - [LimitTypes.messageHistory]: ( - - {(text) =>

{text}

} -
- ), - [LimitTypes.fileStorage]: ( - - {(text) =>

{text}

} -
- ), - multipleLimits: ( - - {(text) =>

{text}

} -
- ), -}; - -const getDescriptionKey = (limits: Array>): DescriptionStatusKey => { - if (limits.length > 1) { - return 'multipleLimits'; - } - - if (limits.length === 1) { - return limits[0]; - } - - return 'noLimits'; -}; - -export const FreemiumModal = ({onClose, onExited, planName, isAdminConsole}: FreemiumModalProps) => { - const openPurchaseModal = useOpenCloudPurchaseModal({isDelinquencyModal: true}); - const [limits] = useGetLimits(); - const usage = useGetUsage(); - useSelector(getTheme); - const limitsSurpassed = useGetMultiplesExceededCloudLimit(usage, limits); - - const handleClose = () => { - onClose(); - onExited(); - }; - - const handleReactivate = () => { - handleClose(); - trackEvent(TELEMETRY_CATEGORIES.CLOUD_DELINQUENCY, 'clicked_re_activate_plan'); - openPurchaseModal({trackingLocation: 'delinquency_modal_freemium_admin'}); - }; - - const title: Message = { - id: t('cloud_delinquency.modal.workspace_downgraded_freemium_title'), - defaultMessage: 'You now have data limits on your plan', - }; - - const description = (<> - {DescriptionMessages[getDescriptionKey(limitsSurpassed)]} - - {(text) => {text}} - - ); - - if (limitsSurpassed.length === 0) { - const secondaryAction = { - message: { - id: t('cloud_delinquency.modal.stay_on_freemium_close'), - defaultMessage: 'View plans', - }, - onClick: handleClose, - }; - - return ( - ); - } - - const secondaryAction = { - message: { - id: t('cloud_delinquency.modal.stay_on_freemium'), - defaultMessage: 'Stay on Free', - }, - onClick: handleClose, - }; - - const primaryAction = { - message: { - id: t('cloud_delinquency.modal.re_activate_plan'), - defaultMessage: 'Re-activate {planName}', - values: { - planName, - }, - }, - onClick: handleReactivate, - }; - - return ( - - ); -}; diff --git a/webapp/channels/src/components/delinquency_modal/index.ts b/webapp/channels/src/components/delinquency_modal/index.ts deleted file mode 100644 index e345889fdf..0000000000 --- a/webapp/channels/src/components/delinquency_modal/index.ts +++ /dev/null @@ -1,50 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {connect} from 'react-redux'; -import {bindActionCreators} from 'redux'; -import type {Dispatch} from 'redux'; - -import {getCloudSubscription} from 'mattermost-redux/actions/cloud'; -import {getLicense} from 'mattermost-redux/selectors/entities/general'; -import {makeGetCategory} from 'mattermost-redux/selectors/entities/preferences'; -import {isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; -import type {GenericAction} from 'mattermost-redux/types/actions'; - -import {closeModal, openModal} from 'actions/views/modals'; - -import {ModalIdentifiers, Preferences} from 'utils/constants'; - -import type {GlobalState} from 'types/store'; - -import DeliquencyModalController from './delinquency_modal_controller'; - -function makeMapStateToProps() { - const getCategory = makeGetCategory(); - - return function mapStateToProps(state: GlobalState) { - const license = getLicense(state); - const isCloud = license.Cloud === 'true'; - const subscription = state.entities.cloud?.subscription; - const userIsAdmin = isCurrentUserSystemAdmin(state); - - return { - isCloud, - subscription, - userIsAdmin, - delinquencyModalPreferencesConfirmed: getCategory(state, Preferences.DELINQUENCY_MODAL_CONFIRMED), - }; - }; -} - -function mapDispatchToProps(dispatch: Dispatch) { - return { - actions: bindActionCreators({ - getCloudSubscription, - closeModal: () => closeModal(ModalIdentifiers.DELINQUENCY_MODAL_DOWNGRADE), - openModal, - }, dispatch), - }; -} - -export default connect(makeMapStateToProps, mapDispatchToProps)(DeliquencyModalController); diff --git a/webapp/channels/src/components/delinquency_modal/useDelinquencyModalController.tsx b/webapp/channels/src/components/delinquency_modal/useDelinquencyModalController.tsx deleted file mode 100644 index 6a0fb63fbe..0000000000 --- a/webapp/channels/src/components/delinquency_modal/useDelinquencyModalController.tsx +++ /dev/null @@ -1,111 +0,0 @@ -// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. -// See LICENSE.txt for license information. - -import {useEffect, useState} from 'react'; -import {useSelector, useDispatch} from 'react-redux'; - -import type {Subscription} from '@mattermost/types/cloud'; -import type {PreferenceType} from '@mattermost/types/preferences'; - -import {getCloudProducts} from 'mattermost-redux/actions/cloud'; -import {getSubscriptionProduct} from 'mattermost-redux/selectors/entities/cloud'; - -import {setItem} from 'actions/storage'; -import {makeGetItem} from 'selectors/storage'; - -import {StoragePrefixes, ModalIdentifiers} from 'utils/constants'; - -import type {ModalData} from 'types/actions'; - -import DelinquencyModal from './delinquency_modal'; - -const SESSION_MODAL_ITEM = `${StoragePrefixes.DELINQUENCY}hide_downgrade_modal`; - -type UseDelinquencyModalController = { - userIsAdmin: boolean; - subscription?: Subscription; - isCloud: boolean; - actions: { - getCloudSubscription: () => void; - closeModal: () => void; - openModal:

(modalData: ModalData

) => void; - }; - delinquencyModalPreferencesConfirmed: PreferenceType[]; -} - -export const useDelinquencyModalController = (props: UseDelinquencyModalController) => { - const {isCloud, userIsAdmin, subscription, actions, delinquencyModalPreferencesConfirmed} = props; - const product = useSelector(getSubscriptionProduct); - const sessionModalItem = useSelector(makeGetItem(SESSION_MODAL_ITEM, '')); - const dispatch = useDispatch(); - const [showModal, setShowModal] = useState(false); - const {openModal} = actions; - const [requestedProducts, setRequestedProducts] = useState(false); - - const handleOnExit = () => { - setShowModal(() => false); - dispatch(setItem(SESSION_MODAL_ITEM, 'true')); - }; - - useEffect(() => { - if (delinquencyModalPreferencesConfirmed.length === 0 && product === undefined && !requestedProducts && isCloud) { - dispatch(getCloudProducts()); - setRequestedProducts(true); - } - }, []); - - useEffect(() => { - if (showModal || !isCloud) { - return; - } - - if (delinquencyModalPreferencesConfirmed.length > 0) { - return; - } - - if (subscription == null) { - return; - } - - const isClosed = Boolean(sessionModalItem) === true; - - if (isClosed) { - return; - } - - if (subscription.delinquent_since == null) { - return; - } - - const delinquencyDate = new Date(subscription.delinquent_since * 1000); - - const oneDay = 24 * 60 * 60 * 1000; // hours*minutes*seconds*milliseconds - const today = new Date(); - const diffDays = Math.round( - Math.abs((today.valueOf() - delinquencyDate.valueOf()) / oneDay), - ); - if (diffDays < 90) { - return; - } - - if (!userIsAdmin) { - return; - } - - setShowModal(true); - }, [delinquencyModalPreferencesConfirmed.length, isCloud, openModal, showModal, subscription, userIsAdmin]); - - useEffect(() => { - if (showModal && product != null) { - openModal({ - modalId: ModalIdentifiers.DELINQUENCY_MODAL_DOWNGRADE, - dialogType: DelinquencyModal, - dialogProps: { - closeModal: actions.closeModal, - onExited: handleOnExit, - planName: product.name, - }, - }); - } - }, [actions.closeModal, openModal, product, showModal]); -}; diff --git a/webapp/channels/src/components/purchase_modal/delinquency_card.tsx b/webapp/channels/src/components/purchase_modal/delinquency_card.tsx new file mode 100644 index 0000000000..09a7a6a33c --- /dev/null +++ b/webapp/channels/src/components/purchase_modal/delinquency_card.tsx @@ -0,0 +1,119 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import {trackEvent} from 'actions/telemetry_actions'; + +import {TELEMETRY_CATEGORIES, CloudLinks} from 'utils/constants'; + +import type {ButtonDetails} from './purchase_modal'; + +type DelinquencyCardProps = { + topColor: string; + price: string; + buttonDetails: ButtonDetails; + onViewBreakdownClick: () => void; + isCloudDelinquencyGreaterThan90Days: boolean; + users: number; + cost: number; +}; + +export default function DelinquencyCard(props: DelinquencyCardProps) { + const handleSeeHowBillingWorksClick = ( + e: React.MouseEvent, + ) => { + e.preventDefault(); + trackEvent( + TELEMETRY_CATEGORIES.CLOUD_ADMIN, + 'click_see_how_billing_works', + ); + window.open(CloudLinks.DELINQUENCY_DOCS, '_blank'); + }; + + const seeHowBillingWorks = ( + + + + ); + + return ( +

+
+
+
+
+
+ + {':'} +
+
{props.price}
+
+ +
+
+
+
+ +
+
+ {Boolean(!props.isCloudDelinquencyGreaterThan90Days) && ( + + )} + {Boolean(props.isCloudDelinquencyGreaterThan90Days) && ( + + )} +
+
+
+ ); +} + diff --git a/webapp/channels/src/components/purchase_modal/index.ts b/webapp/channels/src/components/purchase_modal/index.ts index 6c31e36f2c..946d452e60 100644 --- a/webapp/channels/src/components/purchase_modal/index.ts +++ b/webapp/channels/src/components/purchase_modal/index.ts @@ -8,7 +8,7 @@ import type {Dispatch} from 'redux'; import {getCloudProducts, getCloudSubscription, getInvoices} from 'mattermost-redux/actions/cloud'; import {getClientConfig} from 'mattermost-redux/actions/general'; -import {getAdminAnalytics} from 'mattermost-redux/selectors/entities/admin'; +import {getAdminAnalytics, getConfig} from 'mattermost-redux/selectors/entities/admin'; import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentTeam} from 'mattermost-redux/selectors/entities/teams'; @@ -21,6 +21,7 @@ import {makeAsyncComponent} from 'components/async_load'; import withGetCloudSubscription from 'components/common/hocs/cloud/with_get_cloud_subscription'; import {getStripePublicKey} from 'components/payment_form/stripe'; +import {daysToExpiration} from 'utils/cloud_utils'; import {ModalIdentifiers} from 'utils/constants'; import {getCloudContactSalesLink, getCloudSupportLink} from 'utils/contact_support_sales'; import {findOnlyYearlyProducts} from 'utils/products'; @@ -33,6 +34,7 @@ function mapStateToProps(state: GlobalState) { const subscription = state.entities.cloud.subscription; const isDelinquencyModal = Boolean(state.entities.cloud.subscription?.delinquent_since); + const isRenewalModal = daysToExpiration(Number(state.entities.cloud.subscription?.end_at)) <= 60 && !isDelinquencyModal && getConfig(state).FeatureFlags?.CloudAnnualRenewals; const products = state.entities.cloud!.products; const yearlyProducts = findOnlyYearlyProducts(products || {}); @@ -61,8 +63,10 @@ function mapStateToProps(state: GlobalState) { currentTeam: getCurrentTeam(state), theme: getTheme(state), isDelinquencyModal, + isRenewalModal, usersCount: Number(getAdminAnalytics(state)!.TOTAL_USERS) || 1, stripePublicKey, + subscription, }; } diff --git a/webapp/channels/src/components/purchase_modal/purchase_modal.tsx b/webapp/channels/src/components/purchase_modal/purchase_modal.tsx index b952e04ce2..07939c240a 100644 --- a/webapp/channels/src/components/purchase_modal/purchase_modal.tsx +++ b/webapp/channels/src/components/purchase_modal/purchase_modal.tsx @@ -13,10 +13,11 @@ import type {ReactNode} from 'react'; import {FormattedMessage, injectIntl} from 'react-intl'; import type {IntlShape} from 'react-intl'; -import type {Address, CloudCustomer, Product, Invoice, Feedback} from '@mattermost/types/cloud'; +import type {Address, CloudCustomer, Product, Invoice, Feedback, Subscription, InvoiceLineItem} from '@mattermost/types/cloud'; import {areShippingDetailsValid} from '@mattermost/types/cloud'; import type {Team} from '@mattermost/types/teams'; +import {Client4} from 'mattermost-redux/client'; import type {Theme} from 'mattermost-redux/selectors/entities/preferences'; import type {ActionResult} from 'mattermost-redux/types/actions'; @@ -24,12 +25,12 @@ import {trackEvent, pageVisited} from 'actions/telemetry_actions'; import BillingHistoryModal from 'components/admin_console/billing/billing_history_modal'; import PaymentDetails from 'components/admin_console/billing/payment_details'; +import CloudInvoicePreview from 'components/cloud_invoice_preview'; import PlanLabel from 'components/common/plan_label'; import ComplianceScreenFailedSvg from 'components/common/svg_images_components/access_denied_happy_svg'; import BackgroundSvg from 'components/common/svg_images_components/background_svg'; import UpgradeSvg from 'components/common/svg_images_components/upgrade_svg'; import ExternalLink from 'components/external_link'; -import OverlayTrigger from 'components/overlay_trigger'; import AddressForm from 'components/payment_form/address_form'; import PaymentForm from 'components/payment_form/payment_form'; import {STRIPE_CSS_SRC} from 'components/payment_form/stripe'; @@ -39,12 +40,12 @@ import SeatsCalculator, {errorInvalidNumber} from 'components/seats_calculator'; import type {Seats} from 'components/seats_calculator'; import Consequences from 'components/seats_calculator/consequences'; import SwitchToYearlyPlanConfirmModal from 'components/switch_to_yearly_plan_confirm_modal'; -import Tooltip from 'components/tooltip'; import StarMarkSvg from 'components/widgets/icons/star_mark_icon'; import FullScreenModal from 'components/widgets/modals/full_screen_modal'; +import 'components/payment_form/payment_form.scss'; +import {buildInvoiceSummaryPropsFromLineItems} from 'utils/cloud_utils'; import { - Constants, TELEMETRY_CATEGORIES, CloudLinks, CloudProducts, @@ -60,13 +61,13 @@ import type {ModalData} from 'types/actions'; import type {BillingDetails} from 'types/cloud/sku'; import {areBillingDetailsValid} from 'types/cloud/sku'; +import DelinquencyCard from './delinquency_card'; import IconMessage from './icon_message'; import ProcessPaymentSetup from './process_payment_setup'; - -import 'components/payment_form/payment_form.scss'; +import RenewalCard from './renewal_card'; +import {findProductInDictionary, getSelectedProduct} from './utils'; import './purchase.scss'; - let stripePromise: Promise; export enum ButtonCustomiserClasses { @@ -75,23 +76,13 @@ export enum ButtonCustomiserClasses { special = 'special', } -type ButtonDetails = { +export type ButtonDetails = { action: () => void; text: string; disabled?: boolean; customClass?: ButtonCustomiserClasses; } -type DelinquencyCardProps = { - topColor: string; - price: string; - buttonDetails: ButtonDetails; - onViewBreakdownClick: () => void; - isCloudDelinquencyGreaterThan90Days: boolean; - users: number; - cost: number; -}; - type CardProps = { topColor?: string; plan: string; @@ -117,11 +108,13 @@ type Props = { intl: IntlShape; theme: Theme; isDelinquencyModal?: boolean; + isRenewalModal?: boolean; invoices?: Invoice[]; isCloudDelinquencyGreaterThan90Days: boolean; usersCount: number; isComplianceBlocked: boolean; contactSupportLink: string; + subscription: Subscription | undefined; // callerCTA is information about the cta that opened this modal. This helps us provide a telemetry path // showing information about how the modal was opened all the way to more CTAs within the modal itself @@ -167,55 +160,6 @@ type State = { isSwitchingToAnnual: boolean; } -/** - * - * @param products Record | undefined - the list of current cloud products - * @param productId String - a valid product id used to find a particular product in the dictionary - * @param productSku String - the sku value of the product of type either cloud-starter | cloud-professional | cloud-enterprise - * @returns Product - */ -function findProductInDictionary(products: Record | undefined, productId?: string | null, productSku?: string, productRecurringInterval?: string): Product | null { - if (!products) { - return null; - } - const keys = Object.keys(products); - if (!keys.length) { - return null; - } - if (!productId && !productSku) { - return products[keys[0]]; - } - let currentProduct = products[keys[0]]; - if (keys.length > 1) { - // here find the product by the provided id or name, otherwise return the one with Professional in the name - keys.forEach((key) => { - if (productId && products[key].id === productId) { - currentProduct = products[key]; - } else if (productSku && products[key].sku === productSku && products[key].recurring_interval === productRecurringInterval) { - currentProduct = products[key]; - } - }); - } - return currentProduct; -} - -function getSelectedProduct( - products: Record | undefined, - yearlyProducts: Record, - currentProductId?: string | null, - isDelinquencyModal?: boolean, - isCloudDelinquencyGreaterThan90Days?: boolean) { - if (isDelinquencyModal && !isCloudDelinquencyGreaterThan90Days) { - const currentProduct = findProductInDictionary(products, currentProductId, undefined, RecurringIntervals.MONTH); - - // if the account hasn't been delinquent for more than 90 days, then we will allow them to settle up and stay on their current product - return currentProduct; - } - - // Otherwise, we will default to upgrading them to the yearly professional plan - return findProductInDictionary(yearlyProducts, null, CloudProducts.PROFESSIONAL, RecurringIntervals.YEAR); -} - export function Card(props: CardProps) { const cardContent = (
@@ -249,111 +193,6 @@ export function Card(props: CardProps) { ); } -function DelinquencyCard(props: DelinquencyCardProps) { - const seeHowBillingWorks = ( - e: React.MouseEvent, - ) => { - e.preventDefault(); - trackEvent( - TELEMETRY_CATEGORIES.CLOUD_ADMIN, - 'click_see_how_billing_works', - ); - window.open(CloudLinks.DELINQUENCY_DOCS, '_blank'); - }; - return ( -
-
-
-
-
-
- - {':'} -
-
{props.price}
-
- -
-
-
-
- -
-
- {Boolean(!props.isCloudDelinquencyGreaterThan90Days) && ( - - - - ), - }} - /> - )} - {Boolean(props.isCloudDelinquencyGreaterThan90Days) && ( - - - - ), - }} - /> - )} -
-
-
- ); -} - class PurchaseModal extends React.PureComponent { modal = React.createRef(); @@ -375,15 +214,11 @@ class PurchaseModal extends React.PureComponent { props.productId, ), selectedProduct: getSelectedProduct( - props.products, - props.yearlyProducts, - props.productId, - props.isDelinquencyModal, - props.isCloudDelinquencyGreaterThan90Days, + props.products!, ), isUpgradeFromTrial: props.isFreeTrial, buttonClickedInfo: '', - selectedProductPrice: getSelectedProduct(props.products, props.yearlyProducts, props.productId, props.isDelinquencyModal, props.isCloudDelinquencyGreaterThan90Days)?.price_per_seat.toString() || null, + selectedProductPrice: getSelectedProduct(props.products!)?.price_per_seat.toString() || null, usersCount: this.props.usersCount, seats: { quantity: this.props.usersCount.toString(), @@ -398,8 +233,8 @@ class PurchaseModal extends React.PureComponent { await this.props.actions.getCloudProducts(); this.setState({ currentProduct: findProductInDictionary(this.props.products, this.props.productId), - selectedProduct: getSelectedProduct(this.props.products, this.props.yearlyProducts, this.props.productId, this.props.isDelinquencyModal, this.props.isCloudDelinquencyGreaterThan90Days), - selectedProductPrice: getSelectedProduct(this.props.products, this.props.yearlyProducts, this.props.productId, false)?.price_per_seat.toString() ?? null, + selectedProduct: getSelectedProduct(this.props.products!), + selectedProductPrice: getSelectedProduct(this.props.products!)?.price_per_seat.toString() ?? null, }); } @@ -565,7 +400,7 @@ class PurchaseModal extends React.PureComponent { }; paymentFooterText = () => { - const normalPaymentText = ( + return (
{ />
); - - let payment = normalPaymentText; - if (!this.props.isFreeTrial && this.state.currentProduct?.billing_scheme === BillingSchemes.FLAT_FEE && - this.state.selectedProduct?.billing_scheme === BillingSchemes.PER_SEAT) { - const announcementTooltip = ( - -
- -
-
- -
-
- ); - - const announcementIcon = ( - -
{'\uF5D6'}
-
- - ); - const prorratedPaymentText = ( -
- {announcementIcon} - - {this.learnMoreLink()} -
- ); - payment = prorratedPaymentText; - } - return payment; }; getPlanNameFromProductName = (productName: string): string => { @@ -658,24 +438,30 @@ class PurchaseModal extends React.PureComponent { }; handleViewBreakdownClick = () => { - this.props.actions.openModal({ - modalId: ModalIdentifiers.BILLING_HISTORY, - dialogType: BillingHistoryModal, - dialogProps: { - invoices: this.props.invoices, - }, - }); + // If there is only one invoice, we can skip the summary and go straight to the invoice PDF preview for this singular invoice. + if (this.props.invoices?.length === 1) { + this.props.actions.openModal({ + modalId: ModalIdentifiers.CLOUD_INVOICE_PREVIEW, + dialogType: CloudInvoicePreview, + dialogProps: { + url: Client4.getInvoicePdfUrl(this.props.invoices[0].id), + }, + }); + } else { + this.props.actions.openModal({ + modalId: ModalIdentifiers.BILLING_HISTORY, + dialogType: BillingHistoryModal, + dialogProps: { + invoices: this.props.invoices, + }, + }); + } }; purchaseScreenCard = () => { if (this.props.isDelinquencyModal) { return ( - <> - {this.props.isCloudDelinquencyGreaterThan90Days ? null : ( -
- {this.comparePlan} -
- )} +
{ cost={parseInt(this.state.selectedProductPrice || '', 10) * this.props.usersCount} users={this.props.usersCount} /> +
+ ); + } + + if (this.props.isRenewalModal) { + if (!this.props.subscription || !this.props.subscription.upcoming_invoice) { + return null; + } + const invoice = this.props.subscription?.upcoming_invoice; + const invoiceSummaryProps = buildInvoiceSummaryPropsFromLineItems(invoice?.line_items || []); + if (this.state.seats.quantity !== this.props.usersCount.toString()) { + // If the user has changed the number of seats, the stripe invoice won't yet reflect that new seat count + // We must look for the invoice item that occurs in the future (ie, the invoice item for the next billing period) + // And adjust the quantity, so the summary equates properly + invoiceSummaryProps.fullCharges = invoiceSummaryProps.fullCharges.map((lineitem: InvoiceLineItem) => { + if (new Date(lineitem.period_start * 1000) > new Date()) { + return { + ...lineitem, + quantity: parseInt(this.state.seats.quantity, 10), + total: parseInt(this.state.seats.quantity, 10) * lineitem.price_per_unit, + }; + } + + return lineitem; + }); + } + return ( + <> + this.setState({seats})} + buttonDisabled={!this.state.paymentInfoIsValid} + onButtonClick={() => this.handleSubmitClick(this.props.callerCTA + '> purchase_modal > renew_button_click')} + {...invoiceSummaryProps} + /> ); } @@ -709,14 +533,10 @@ class PurchaseModal extends React.PureComponent { const yearlyProductMonthlyPrice = formatNumber(parseInt(this.state.selectedProductPrice || '0', 10) / 12, {maximumFractionDigits: 2}); - const currentProductMonthly = this.state.currentProduct?.recurring_interval === RecurringIntervals.MONTH; - const currentProductProfessional = this.state.currentProduct?.sku === CloudProducts.PROFESSIONAL; - const currentProductMonthlyProfessional = currentProductMonthly && currentProductProfessional; - - const cardBtnText = currentProductMonthlyProfessional ? formatMessage({id: 'pricing_modal.btn.switch_to_annual', defaultMessage: 'Switch to annual billing'}) : formatMessage({id: 'pricing_modal.btn.upgrade', defaultMessage: 'Upgrade'}); + const cardBtnText = formatMessage({id: 'pricing_modal.btn.upgrade', defaultMessage: 'Upgrade'}); return ( - <> +
@@ -739,11 +559,7 @@ class PurchaseModal extends React.PureComponent { planBriefing={<>} buttonDetails={{ action: () => { - if (currentProductMonthlyProfessional) { - this.confirmSwitchToAnnual(); - } else { - this.handleSubmitClick(this.props.callerCTA + '> purchase_modal > upgrade_button_click'); - } + this.handleSubmitClick(this.props.callerCTA + '> purchase_modal > upgrade_button_click'); }, text: cardBtnText, customClass: @@ -788,7 +604,7 @@ class PurchaseModal extends React.PureComponent { /> } /> - +
); }; @@ -912,7 +728,7 @@ class PurchaseModal extends React.PureComponent { /> )}
-
{this.purchaseScreenCard()}
+ {this.purchaseScreenCard()}
); }; diff --git a/webapp/channels/src/components/purchase_modal/renewal_card.scss b/webapp/channels/src/components/purchase_modal/renewal_card.scss new file mode 100644 index 0000000000..14ebb171c8 --- /dev/null +++ b/webapp/channels/src/components/purchase_modal/renewal_card.scss @@ -0,0 +1,257 @@ +.PurchaseModal { + .RenewalRHS { + position: sticky; + display: flex; + width: 25%; + flex-direction: column; + padding-top: 80px; + + .RenewalCard { + max-width: 353px; + padding: 28px 32px; + border: 1px solid var(--light-8-center-channel-text, rgba(61, 60, 64, 0.08)); + background: var(--light-center-channel-bg, #fff); + border-radius: 4px; + box-shadow: 0 2px 3px 0 rgba(0, 0, 0, 0.08); + + .SeatsCalculator { + padding: 0; + + .SeatsCalculator__seats-item.SeatsCalculator__seats-item--input { + padding: 0; + } + } + + .RenewalSummary { + width: 332px; + padding: 28px 32px; + border: 1px solid rgba(var(--sys-center-channel-color-rgb), 0.08); + margin-left: 20px; + background-color: var(--sys-center-channel-bg); + border-radius: 4px; + box-shadow: 0 2px 3px rgba(0, 0, 0, 0.08); + } + + .RenewalSummary__noBillingHistory { + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: flex-start; + } + + .RenewalSummary__noBillingHistory-title { + margin-top: 32px; + font-size: 16px; + font-weight: 600; + line-height: 24px; + } + + .RenewalSummary__noBillingHistory-message { + margin-top: 8px; + color: var(--sys-center-channel-color); + font-size: 14px; + line-height: 20px; + } + + .RenewalSummary__noBillingHistory-link { + margin-top: 16px; + margin-bottom: 24px; + color: var(--sys-button-bg); + font-size: 12px; + font-weight: 600; + } + + .RenewalSummary__lastInvoice { + color: var(--sys-center-channel-color); + + hr { + border-color: rgba(var(--sys-center-channel-color-rgb), 0.32); + margin: 12px 0; + } + } + + .RenewalSummary__lastInvoice-header { + display: flex; + align-items: center; + } + + .RenewalSummary__lastInvoice-headerTitle { + font-size: 20px; + font-weight: 600; + line-height: 28px; + } + + .BillingSummary__lastInvoice-headerStatus { + display: flex; + align-items: center; + margin-left: auto; + font-size: 12px; + font-weight: 600; + line-height: 16px; + + &.paid { + color: var(--sys-online-indicator); + } + + &.failed { + color: var(--sys-error-text); + } + + &.pending { + color: #f58b00; + } + + svg { + width: 16px; + height: 16px; + margin-left: 4px; + font-size: 14.4px; + + &::before { + margin: 0 auto; + } + } + + span { + margin-left: 4px; + } + } + + .RenewalSummary__upcomingInvoice-due-date { + margin-top: 8px; + color: var(--light-72-center-channel-text, rgba(61, 60, 64, 0.72)); + font-size: 14px; + font-style: normal; + font-weight: 400; + line-height: 20px; + } + + .RenewalSummary__lastInvoice-productName { + margin-top: 32px; + font-size: 16px; + font-weight: 600; + line-height: 24px; + } + + .RenewalSummary__upcomingInvoice-charge { + display: flex; + margin: 12px 0; + color: var(--center-channel-color); + font-size: 12px; + font-weight: 400; + line-height: 20px; + + &.total { + font-size: 14px; + font-weight: 600; + } + } + + .RenewalSummary__upcomingInvoice-hasMoreItems { + display: flex; + align-items: center; + color: rgba(var(--center-channel-color-rgb), 0.72); + font-size: 14px; + font-weight: 400; + + .RenewalSummary__upcomingInvoice-chargeDescription { + &:hover { + cursor: pointer; + text-decoration: underline; + } + } + } + + .RenewalSummary__upcomingInvoice-ellipses { + display: flex; + align-items: center; + align-self: stretch; + justify-content: center; + fill: #d9d9d9; + } + + .RenewalSummary__lastInvoice-chargeAmount { + margin-left: auto; + } + + .RenewalSummary__lastInvoice-partialCharges { + color: rgba(var(--sys-center-channel-color-rgb), 0.56); + font-size: 12px; + line-height: 16px; + + & + .RenewalSummary__lastInvoice-charge { + margin-top: 4px; + } + } + + .RenewalSummary__lastInvoice-download { + margin-top: 32px; + margin-bottom: 16px; + } + + .RenewalSummary__upcomingInvoice-renew-button { + display: flex; + width: 100%; + align-items: center; + justify-content: center; + padding: 11px 20px; + background: var(--sys-button-bg); + border-radius: 4px; + color: var(--sys-button-color); + + &:hover:not(:disabled) { + background: linear-gradient(0deg, rgba(var(--sys-center-channel-color-rgb), 0.16), rgba(var(--sys-center-channel-color-rgb), 0.16)), var(--sys-button-bg); + color: var(--sys-button-color); + cursor: pointer; + text-decoration: none; + } + + &:disabled { + background: rgba(var(--sys-center-channel-color-rgb), 0.08); + color: rgba(var(--sys-center-channel-color-rgb), 0.32); + cursor: not-allowed; + } + + >span { + margin-left: 6px; + font-size: 14px; + font-weight: 600; + line-height: 14px; + } + } + + .RenewalSummary__upcomingInvoice-viewInvoiceLink { + display: block; + width: 100%; + height: 16px; + margin-bottom: 12px; + background: none; + color: var(--link-color, #1c58d9); + font-size: 12px; + font-weight: 600; + line-height: 9.5px; + text-align: center; + } + + .RenewalSummary__upcomingInvoice-viewInvoice { + display: block; + width: 100%; + background: none; + color: var(--link-color, #1c58d9); + font-size: 12px; + font-weight: 600; + line-height: 9.5px; + text-align: center; + } + + .RenewalSummary__disclaimer { + margin-top: 16px; + color: rgba(var(--center-channel-color-rgb), 0.72); + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 16px; + } + } + } +} diff --git a/webapp/channels/src/components/purchase_modal/renewal_card.tsx b/webapp/channels/src/components/purchase_modal/renewal_card.tsx new file mode 100644 index 0000000000..d5f945fc99 --- /dev/null +++ b/webapp/channels/src/components/purchase_modal/renewal_card.tsx @@ -0,0 +1,318 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {FormattedMessage, FormattedNumber, FormattedDate} from 'react-intl'; +import {useDispatch} from 'react-redux'; + +import type {Invoice, InvoiceLineItem, Product} from '@mattermost/types/cloud'; + +import {Client4} from 'mattermost-redux/client'; + +import {trackEvent} from 'actions/telemetry_actions'; +import {openModal} from 'actions/views/modals'; + +import {getPaymentStatus} from 'components/admin_console/billing/billing_summary/billing_summary'; +import CloudInvoicePreview from 'components/cloud_invoice_preview'; +import OverlayTrigger from 'components/overlay_trigger'; +import type {Seats} from 'components/seats_calculator'; +import SeatsCalculator from 'components/seats_calculator'; +import Tooltip from 'components/tooltip'; +import EllipsisHorizontalIcon from 'components/widgets/icons/ellipsis_h_icon'; + +import {BillingSchemes, ModalIdentifiers, TELEMETRY_CATEGORIES, CloudLinks} from 'utils/constants'; + +import './renewal_card.scss'; + +type RenewalCardProps = { + invoice: Invoice; + product?: Product; + hasMore?: number; + fullCharges: InvoiceLineItem[]; + partialCharges: InvoiceLineItem[]; + seats: Seats; + existingUsers: number; + onSeatChange: (seats: Seats) => void; + buttonDisabled?: boolean; + onButtonClick?: () => void; +}; + +export default function RenewalCard({invoice, product, hasMore, fullCharges, partialCharges, seats, onSeatChange, existingUsers, buttonDisabled, onButtonClick}: RenewalCardProps) { + const dispatch = useDispatch(); + const openInvoicePreview = () => { + dispatch( + openModal({ + modalId: ModalIdentifiers.CLOUD_INVOICE_PREVIEW, + dialogType: CloudInvoicePreview, + dialogProps: { + url: Client4.getInvoicePdfUrl(invoice.id), + }, + }), + ); + }; + + const seeHowBillingWorks = ( + e: React.MouseEvent, + ) => { + e.preventDefault(); + trackEvent( + TELEMETRY_CATEGORIES.CLOUD_ADMIN, + 'click_see_how_billing_works', + ); + window.open(CloudLinks.DELINQUENCY_DOCS, '_blank'); + }; + + return ( +
+
+
+
+ +
+ {getPaymentStatus(invoice.status)} +
+
+ + ), + }} + /> +
+
+ {product?.name} +
+
+ { + onSeatChange(seats); + }} + isCloud={true} + existingUsers={existingUsers} + excludeTotal={true} + /> + {fullCharges.map((charge: any) => ( +
+
+ <> + + + {(' ')} + {'('} + + {')'} + + +
+
+ +
+
+ ))} + {Boolean(hasMore) && ( +
+
+ {product?.billing_scheme === BillingSchemes.FLAT_FEE ? ( + + ) : ( + <> + + + )} +
+
+ )} + {Boolean(partialCharges.length) && ( + <> +
+ + +
+ +
+
+ +
+ + } + > + +
+
+ {partialCharges.map((charge: any) => ( +
+
+ +
+
+ +
+
+ ))} + + )} + {Boolean(hasMore) && ( +
+ +
+ )} + {Boolean(invoice.tax) && ( +
+
+ +
+
+ +
+
+ )} + +
+
+
+ +
+
+ sum + item.total, 0) / 100.0} + // eslint-disable-next-line react/style-prop-object + style='currency' + currency='USD' + /> +
+
+ + +
+ + + + ), + }} + /> +
+ +
+
+ ); +} diff --git a/webapp/channels/src/components/purchase_modal/utils.ts b/webapp/channels/src/components/purchase_modal/utils.ts new file mode 100644 index 0000000000..d38d375a32 --- /dev/null +++ b/webapp/channels/src/components/purchase_modal/utils.ts @@ -0,0 +1,42 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import type {Product} from '@mattermost/types/cloud'; + +import {CloudProducts, RecurringIntervals} from 'utils/constants'; + +/** + * + * @param products Record | undefined - the list of current cloud products + * @param productId String - a valid product id used to find a particular product in the dictionary + * @param productSku String - the sku value of the product of type either cloud-starter | cloud-professional | cloud-enterprise + * @returns Product + */ +export function findProductInDictionary(products: Record | undefined, productId?: string | null, productSku?: string, productRecurringInterval?: string): Product | null { + if (!products) { + return null; + } + const keys = Object.keys(products); + if (!keys.length) { + return null; + } + if (!productId && !productSku) { + return products[keys[0]]; + } + let currentProduct = products[keys[0]]; + if (keys.length > 1) { + // here find the product by the provided id or name, otherwise return the one with Professional in the name + keys.forEach((key) => { + if (productId && products[key].id === productId) { + currentProduct = products[key]; + } else if (productSku && products[key].sku === productSku && products[key].recurring_interval === productRecurringInterval) { + currentProduct = products[key]; + } + }); + } + return currentProduct; +} + +export function getSelectedProduct(yearlyProducts: Record) { + return findProductInDictionary(yearlyProducts, null, CloudProducts.PROFESSIONAL, RecurringIntervals.YEAR); +} diff --git a/webapp/channels/src/components/root/__snapshots__/root.test.tsx.snap b/webapp/channels/src/components/root/__snapshots__/root.test.tsx.snap index 1495099598..2528a58fb8 100644 --- a/webapp/channels/src/components/root/__snapshots__/root.test.tsx.snap +++ b/webapp/channels/src/components/root/__snapshots__/root.test.tsx.snap @@ -98,7 +98,6 @@ exports[`components/Root Routes Should mount public product routes 1`] = ` - import('components/create_team')); const LazyMfa = React.lazy(() => import('components/mfa/mfa_controller')); const LazyPreparingWorkspace = React.lazy(() => import('components/preparing_workspace')); const LazyTeamController = React.lazy(() => import('components/team_controller')); -const LazyDelinquencyModalController = React.lazy(() => import('components/delinquency_modal')); const LazyOnBoardingTaskList = React.lazy(() => import('components/onboarding_tasklist')); const CreateTeam = makeAsyncComponent('CreateTeam', LazyCreateTeam); @@ -107,7 +106,6 @@ const Authorize = makeAsyncComponent('Authorize', LazyAuthorize); const Mfa = makeAsyncComponent('Mfa', LazyMfa); const PreparingWorkspace = makeAsyncComponent('PreparingWorkspace', LazyPreparingWorkspace); const TeamController = makeAsyncComponent('TeamController', LazyTeamController); -const DelinquencyModalController = makeAsyncComponent('DelinquencyModalController', LazyDelinquencyModalController); const OnBoardingTaskList = makeAsyncComponent('OnboardingTaskList', LazyOnBoardingTaskList); type LoggedInRouteProps = { @@ -583,7 +581,6 @@ export default class Root extends React.PureComponent { - {this.props.products?.filter((product) => Boolean(product.publicComponent)).map((product) => ( void; + excludeTotal?: boolean; } export interface Seats { @@ -210,31 +211,35 @@ export default function SeatsCalculator(props: Props) {
-
-
- -
-
- {total} -
-
-
-
- -
-
- {total} -
-
+ {!props.excludeTotal && ( + <> +
+
+ +
+
+ {total} +
+
+
+
+ +
+
+ {total} +
+
+ + )}
diff --git a/webapp/channels/src/components/widgets/icons/ellipsis_h_icon.tsx b/webapp/channels/src/components/widgets/icons/ellipsis_h_icon.tsx index cdc8dc7c2c..a7a74e2186 100644 --- a/webapp/channels/src/components/widgets/icons/ellipsis_h_icon.tsx +++ b/webapp/channels/src/components/widgets/icons/ellipsis_h_icon.tsx @@ -4,13 +4,18 @@ import React from 'react'; import {useIntl} from 'react-intl'; -export default function EllipsisHorizontalIcon(props: React.HTMLAttributes) { +type Props = React.HTMLAttributes & { + width?: string; + height?: string; +}; + +export default function EllipsisHorizontalIcon(props: Props) { const {formatMessage} = useIntl(); return ( See how billing works.", "cloud_subscribe.contact_support": "Compare plans", "cloud_upgrade.error_min_seats": "Minimum of 10 seats required", "cloud.fetch_error": "Error fetching billing data. Please try again later.", "cloud.fetch_error.retry": "Retry", "cloud.invoice_pdf_preview.download": "Download this page for your records", + "cloud.renewal.andMoreItems": "+ {count} more items", + "cloud.renewal.renew": "Renew", + "cloud.renewal.tobepaid": "To be paid on {date}", + "cloud.renewal.viewInvoice": "View Invoice", "cloud.startTrial.modal.btn": "Start trial", "collapsed_reply_threads_modal.confirm": "Got it", "collapsed_reply_threads_modal.description": "Threads have been revamped to help you create organized conversation around specific messages. Now, channels will appear less cluttered as replies are collapsed under the original message, and all the conversations you're following are available in your **Threads** view. Take the tour to see what's new.", diff --git a/webapp/channels/src/tests/helpers/date.ts b/webapp/channels/src/tests/helpers/date.ts index a9e1ed73b5..511c0b467c 100644 --- a/webapp/channels/src/tests/helpers/date.ts +++ b/webapp/channels/src/tests/helpers/date.ts @@ -30,5 +30,5 @@ export const fakeDate = (expected: Date): () => void => { export const unixTimestampFromNow = (daysFromNow: number) => { const now = new Date(); - return Math.ceil(new Date(now.getTime() + (daysFromNow * 24 * 60 * 60 * 1000)).getTime() / 1000); + return Math.ceil(new Date(now.getTime() + (daysFromNow * 24 * 60 * 60 * 1000)).getTime()); }; diff --git a/webapp/channels/src/utils/cloud_utils.ts b/webapp/channels/src/utils/cloud_utils.ts index 7f8384f68d..8cb99f56c8 100644 --- a/webapp/channels/src/utils/cloud_utils.ts +++ b/webapp/channels/src/utils/cloud_utils.ts @@ -1,12 +1,26 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import type {Product, CloudCustomer, Limits} from '@mattermost/types/cloud'; +import type {CloudCustomer, InvoiceLineItem} from '@mattermost/types/cloud'; import {trackEvent} from 'actions/telemetry_actions'; -import {CloudProducts, CloudLinks} from 'utils/constants'; -import {hasSomeLimits} from 'utils/limits'; +import {CloudLinks} from 'utils/constants'; + +export function buildInvoiceSummaryPropsFromLineItems(lineItems: InvoiceLineItem[]) { + let fullCharges = lineItems.filter((item) => item.type === 'full'); + const partialCharges = lineItems.filter((item) => item.type === 'partial'); + if (!partialCharges.length && !fullCharges.length) { + fullCharges = lineItems; + } + let hasMoreLineItems = 0; + if (fullCharges.length > 5) { + hasMoreLineItems = fullCharges.length - 5; + fullCharges = fullCharges.slice(0, 5); + } + + return {partialCharges, fullCharges, hasMore: hasMoreLineItems}; +} export function isCustomerCardExpired(customer?: CloudCustomer): boolean { if (!customer) { @@ -31,10 +45,6 @@ export function openExternalPricingLink() { window.open(CloudLinks.PRICING, '_blank'); } -export function isCloudFreePlan(product: Product | undefined, limits: Limits): boolean { - return product?.sku === CloudProducts.STARTER && hasSomeLimits(limits); -} - export const FREEMIUM_TO_ENTERPRISE_TRIAL_LENGTH_DAYS = 30; export function daysToExpiration(expirationDate: number): number { diff --git a/webapp/platform/types/src/cloud.ts b/webapp/platform/types/src/cloud.ts index 5e78ac4716..567a4a8398 100644 --- a/webapp/platform/types/src/cloud.ts +++ b/webapp/platform/types/src/cloud.ts @@ -187,6 +187,8 @@ export type InvoiceLineItem = { description: string; type: typeof InvoiceLineItemType[keyof typeof InvoiceLineItemType]; metadata: Record; + period_start: number; + period_end: number; } export type Limits = {