mirror of
https://github.com/mattermost/mattermost.git
synced 2025-02-25 18:55:24 -06:00
[CLD-6538] Renewal Card for Cloud Purchase Modal, remove unnecessary components (#25606)
* Add renewal card component to purchase modal, remove some unneeded components * Add back commented code * Fixes for pipelines * Delinquency modal skips invoice summary table if there is only one invoice * Updates * Fixes from ms precision changes * Fixes for tests after ms precision changes * A couple more occurrences * Removal of a bunch of code that's no longer necessary * Fix linter * Fix i18n * Fix * More fixing * Updates * Updates based on PR feedback --------- Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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<Props, State> {
|
||||
</Highlight>
|
||||
</div>
|
||||
{discardChangesModal}
|
||||
<DelinquencyModal/>
|
||||
<ModalController/>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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<AdminConfig>, 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)),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('CloudAnnualRenewalBanner', () => {
|
||||
};
|
||||
const {getByText} = renderWithContext(<CloudAnnualRenewalBanner/>, 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(<CloudAnnualRenewalBanner/>, 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(<CloudAnnualRenewalBanner/>, 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();
|
||||
|
||||
@@ -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 = (
|
||||
<button
|
||||
className='btn btn-primary'
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {DispatchFunc} from 'mattermost-redux/types/actions';
|
||||
|
||||
import {pageVisited} from 'actions/telemetry_actions';
|
||||
|
||||
import DeleteWorkspaceCTA from 'components/admin_console/billing//delete_workspace/delete_workspace_cta';
|
||||
import CloudTrialBanner from 'components/admin_console/billing/billing_subscriptions/cloud_trial_banner';
|
||||
import CloudFetchError from 'components/cloud_fetch_error';
|
||||
import useGetLimits from 'components/common/hooks/useGetLimits';
|
||||
@@ -28,8 +27,6 @@ import AdminHeader from 'components/widgets/admin_console/admin_header';
|
||||
|
||||
import {isCustomerCardExpired} from 'utils/cloud_utils';
|
||||
import {
|
||||
CloudProducts,
|
||||
RecurringIntervals,
|
||||
TrialPeriodDays,
|
||||
} from 'utils/constants';
|
||||
import {useQuery} from 'utils/http_utils';
|
||||
@@ -46,7 +43,6 @@ import ContactSalesCard from './contact_sales_card';
|
||||
import LimitReachedBanner from './limit_reached_banner';
|
||||
import Limits from './limits';
|
||||
import {ToPaidNudgeBanner} from './to_paid_plan_nudge_banner';
|
||||
import {ToYearlyNudgeBanner} from './to_yearly_nudge_banner';
|
||||
|
||||
import BillingSummary from '../billing_summary';
|
||||
import PlanDetails from '../plan_details';
|
||||
@@ -72,7 +68,6 @@ const BillingSubscriptions = () => {
|
||||
const actionQueryParam = query.get('action');
|
||||
|
||||
const product = useSelector(getSubscriptionProduct);
|
||||
const isAnnualProfessionalOrEnterprise = product?.sku === CloudProducts.ENTERPRISE || (product?.sku === CloudProducts.PROFESSIONAL && product?.recurring_interval === RecurringIntervals.YEAR);
|
||||
|
||||
const openPricingModal = useOpenPricingModal();
|
||||
|
||||
@@ -140,7 +135,6 @@ const BillingSubscriptions = () => {
|
||||
/>
|
||||
{shouldShowPaymentFailedBanner() && paymentFailedBanner()}
|
||||
{<CloudAnnualRenewalBanner/>}
|
||||
{<ToYearlyNudgeBanner/>}
|
||||
{<ToPaidNudgeBanner/>}
|
||||
{showCreditCardBanner &&
|
||||
isCardExpired &&
|
||||
@@ -166,7 +160,7 @@ const BillingSubscriptions = () => {
|
||||
onUpgradeMattermostCloud={openPricingModal}
|
||||
/>
|
||||
)}
|
||||
{isAnnualProfessionalOrEnterprise && !isFreeTrial ? <CancelSubscription/> : <DeleteWorkspaceCTA/>}
|
||||
<CancelSubscription/>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
@import 'utils/mixins';
|
||||
|
||||
.ToYearlyNudgeBanner {
|
||||
&__actions {
|
||||
padding-top: 12px;
|
||||
}
|
||||
|
||||
&__primary {
|
||||
font-size: 12px;
|
||||
|
||||
@include primary-button;
|
||||
|
||||
&:hover {
|
||||
color: var(--button-color);
|
||||
}
|
||||
}
|
||||
|
||||
&__secondary {
|
||||
margin-left: 4px;
|
||||
font-size: 12px;
|
||||
|
||||
@include tertiary-button;
|
||||
|
||||
&:hover {
|
||||
color: var(--button-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,345 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {renderWithContext, screen, waitFor} from 'tests/react_testing_utils';
|
||||
import {CloudProducts, RecurringIntervals} from 'utils/constants';
|
||||
|
||||
import {ToYearlyNudgeBanner, ToYearlyNudgeBannerDismissable} from './to_yearly_nudge_banner';
|
||||
|
||||
const initialState = {
|
||||
views: {
|
||||
announcementBar: {
|
||||
announcementBarState: {
|
||||
announcementBarCount: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
entities: {
|
||||
general: {
|
||||
config: {
|
||||
CWSURL: '',
|
||||
},
|
||||
license: {
|
||||
IsLicensed: 'true',
|
||||
Cloud: 'true',
|
||||
},
|
||||
},
|
||||
users: {
|
||||
currentUserId: 'current_user_id',
|
||||
profiles: {
|
||||
current_user_id: {roles: 'system_user'},
|
||||
},
|
||||
},
|
||||
preferences: {
|
||||
myPreferences: {},
|
||||
},
|
||||
cloud: {},
|
||||
},
|
||||
};
|
||||
|
||||
describe('ToYearlyNudgeBannerDismissable', () => {
|
||||
test('should show for admins cloud professional monthly', () => {
|
||||
const state = JSON.parse(JSON.stringify(initialState));
|
||||
state.entities.users.profiles = {
|
||||
current_user_id: {roles: 'system_admin'},
|
||||
};
|
||||
state.entities.cloud = {
|
||||
subscription: {
|
||||
product_id: 'prod_professional',
|
||||
is_free_trial: 'false',
|
||||
trial_end_at: 1,
|
||||
},
|
||||
products: {
|
||||
prod_professional: {
|
||||
id: 'prod_professional',
|
||||
sku: CloudProducts.PROFESSIONAL,
|
||||
recurring_interval: RecurringIntervals.MONTH,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(<ToYearlyNudgeBannerDismissable/>, state, {useMockedStore: true});
|
||||
|
||||
screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar');
|
||||
});
|
||||
|
||||
test('should NOT show for NON admins', () => {
|
||||
const state = JSON.parse(JSON.stringify(initialState));
|
||||
state.entities.users.profiles = {
|
||||
current_user_id: {roles: 'system_user'},
|
||||
};
|
||||
state.entities.cloud = {
|
||||
subscription: {
|
||||
product_id: 'prod_professional',
|
||||
is_free_trial: 'false',
|
||||
trial_end_at: 1,
|
||||
},
|
||||
products: {
|
||||
prod_professional: {
|
||||
id: 'prod_professional',
|
||||
sku: CloudProducts.PROFESSIONAL,
|
||||
recurring_interval: RecurringIntervals.MONTH,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(<ToYearlyNudgeBannerDismissable/>, state, {useMockedStore: true});
|
||||
|
||||
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow();
|
||||
});
|
||||
|
||||
test('should NOT show for admins on cloud free', () => {
|
||||
const state = JSON.parse(JSON.stringify(initialState));
|
||||
state.entities.users.profiles = {
|
||||
current_user_id: {roles: 'system_admin'},
|
||||
};
|
||||
state.entities.cloud = {
|
||||
subscription: {
|
||||
product_id: 'prod_starter',
|
||||
is_free_trial: 'false',
|
||||
trial_end_at: 1,
|
||||
},
|
||||
products: {
|
||||
prod_starter: {
|
||||
id: 'prod_starter',
|
||||
sku: CloudProducts.STARTER,
|
||||
recurring_interval: RecurringIntervals.MONTH,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(<ToYearlyNudgeBannerDismissable/>, state, {useMockedStore: true});
|
||||
|
||||
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow();
|
||||
});
|
||||
|
||||
test('should NOT show for admins on cloud enterprise', () => {
|
||||
const state = JSON.parse(JSON.stringify(initialState));
|
||||
state.entities.users.profiles = {
|
||||
current_user_id: {roles: 'system_admin'},
|
||||
};
|
||||
state.entities.cloud = {
|
||||
subscription: {
|
||||
product_id: 'prod_enterprise',
|
||||
is_free_trial: 'false',
|
||||
trial_end_at: 1,
|
||||
},
|
||||
products: {
|
||||
prod_enterprise: {
|
||||
id: 'prod_enterprise',
|
||||
sku: CloudProducts.ENTERPRISE,
|
||||
recurring_interval: RecurringIntervals.MONTH,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(<ToYearlyNudgeBannerDismissable/>, state, {useMockedStore: true});
|
||||
|
||||
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow();
|
||||
});
|
||||
|
||||
test('should NOT show for admins on cloud pro annual', () => {
|
||||
const state = JSON.parse(JSON.stringify(initialState));
|
||||
state.entities.users.profiles = {
|
||||
current_user_id: {roles: 'system_admin'},
|
||||
};
|
||||
state.entities.cloud = {
|
||||
subscription: {
|
||||
product_id: 'prod_pro',
|
||||
is_free_trial: 'false',
|
||||
trial_end_at: 1,
|
||||
},
|
||||
products: {
|
||||
prod_pro: {
|
||||
id: 'prod_pro',
|
||||
sku: CloudProducts.PROFESSIONAL,
|
||||
recurring_interval: RecurringIntervals.YEAR,
|
||||
},
|
||||
},
|
||||
};
|
||||
renderWithContext(<ToYearlyNudgeBannerDismissable/>, state, {useMockedStore: true});
|
||||
|
||||
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow();
|
||||
});
|
||||
|
||||
test('should NOT show for admins when banner was dismissed in preferences', async () => {
|
||||
const state = JSON.parse(JSON.stringify(initialState));
|
||||
state.entities.users.profiles = {
|
||||
current_user_id: {roles: 'system_admin'},
|
||||
};
|
||||
state.entities.preferences = {
|
||||
myPreferences: {
|
||||
'to_cloud_yearly_plan_nudge--nudge_to_cloud_yearly_plan_snoozed': {
|
||||
category: 'to_cloud_yearly_plan_nudge',
|
||||
name: 'nudge_to_cloud_yearly_plan_snoozed',
|
||||
value: '{"range": 0, "show": false}',
|
||||
},
|
||||
},
|
||||
};
|
||||
state.entities.cloud = {
|
||||
subscription: {
|
||||
product_id: 'prod_professional',
|
||||
is_free_trial: 'false',
|
||||
trial_end_at: 1,
|
||||
},
|
||||
products: {
|
||||
prod_professional: {
|
||||
id: 'prod_professional',
|
||||
sku: CloudProducts.PROFESSIONAL,
|
||||
recurring_interval: RecurringIntervals.MONTH,
|
||||
},
|
||||
},
|
||||
};
|
||||
await waitFor(() => {
|
||||
renderWithContext(<ToYearlyNudgeBannerDismissable/>, state, {useMockedStore: true});
|
||||
});
|
||||
|
||||
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow();
|
||||
});
|
||||
|
||||
test('should NOT show when subscription has billing type of internal', () => {
|
||||
const state = JSON.parse(JSON.stringify(initialState));
|
||||
state.entities.users.profiles = {
|
||||
current_user_id: {roles: 'system_admin'},
|
||||
};
|
||||
state.entities.cloud = {
|
||||
subscription: {
|
||||
product_id: 'prod_professional',
|
||||
is_free_trial: 'false',
|
||||
trial_end_at: 1,
|
||||
billing_type: 'internal',
|
||||
},
|
||||
products: {
|
||||
prod_professional: {
|
||||
id: 'prod_professional',
|
||||
sku: CloudProducts.PROFESSIONAL,
|
||||
recurring_interval: RecurringIntervals.MONTH,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(<ToYearlyNudgeBannerDismissable/>, state, {useMockedStore: true});
|
||||
|
||||
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow();
|
||||
});
|
||||
|
||||
test('should NOT show when subscription has billing type of licensed', () => {
|
||||
const state = JSON.parse(JSON.stringify(initialState));
|
||||
state.entities.users.profiles = {
|
||||
current_user_id: {roles: 'system_admin'},
|
||||
};
|
||||
state.entities.cloud = {
|
||||
subscription: {
|
||||
product_id: 'prod_professional',
|
||||
is_free_trial: 'false',
|
||||
trial_end_at: 1,
|
||||
billing_type: 'licensed',
|
||||
},
|
||||
products: {
|
||||
prod_professional: {
|
||||
id: 'prod_professional',
|
||||
sku: CloudProducts.PROFESSIONAL,
|
||||
recurring_interval: RecurringIntervals.MONTH,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(<ToYearlyNudgeBannerDismissable/>, state, {useMockedStore: true});
|
||||
|
||||
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-announcement-bar')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ToYearlyNudgeBanner', () => {
|
||||
test('should show for cloud professional monthly', () => {
|
||||
const state = JSON.parse(JSON.stringify(initialState));
|
||||
state.entities.cloud = {
|
||||
subscription: {
|
||||
product_id: 'prod_professional',
|
||||
is_free_trial: 'false',
|
||||
trial_end_at: 1,
|
||||
},
|
||||
products: {
|
||||
prod_professional: {
|
||||
id: 'prod_professional',
|
||||
sku: CloudProducts.PROFESSIONAL,
|
||||
recurring_interval: RecurringIntervals.MONTH,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(<ToYearlyNudgeBanner/>, state, {useMockedStore: true});
|
||||
|
||||
screen.getByTestId('cloud-pro-monthly-deprecation-alert-banner');
|
||||
});
|
||||
|
||||
test('should NOT show for non cloud professional monthly', () => {
|
||||
const state = JSON.parse(JSON.stringify(initialState));
|
||||
state.entities.cloud = {
|
||||
subscription: {
|
||||
product_id: 'prod_starter',
|
||||
is_free_trial: 'false',
|
||||
trial_end_at: 1,
|
||||
},
|
||||
products: {
|
||||
prod_starter: {
|
||||
id: 'prod_starter',
|
||||
sku: CloudProducts.STARTER,
|
||||
recurring_interval: RecurringIntervals.MONTH,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(<ToYearlyNudgeBanner/>, state, {useMockedStore: true});
|
||||
|
||||
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-alert-banner')).toThrow();
|
||||
});
|
||||
test('should NOT show when subscription has billing type of internal', () => {
|
||||
const state = JSON.parse(JSON.stringify(initialState));
|
||||
state.entities.cloud = {
|
||||
subscription: {
|
||||
product_id: 'prod_professional',
|
||||
is_free_trial: 'false',
|
||||
trial_end_at: 1,
|
||||
billing_type: 'internal',
|
||||
},
|
||||
products: {
|
||||
prod_professional: {
|
||||
id: 'prod_professional',
|
||||
sku: CloudProducts.PROFESSIONAL,
|
||||
recurring_interval: RecurringIntervals.MONTH,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(<ToYearlyNudgeBanner/>, state, {useMockedStore: true});
|
||||
|
||||
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-alert-banner')).toThrow();
|
||||
});
|
||||
|
||||
test('should NOT show when subscription has billing type of licensed', () => {
|
||||
const state = JSON.parse(JSON.stringify(initialState));
|
||||
state.entities.cloud = {
|
||||
subscription: {
|
||||
product_id: 'prod_professional',
|
||||
is_free_trial: 'false',
|
||||
trial_end_at: 1,
|
||||
billing_type: 'licensed',
|
||||
},
|
||||
products: {
|
||||
prod_professional: {
|
||||
id: 'prod_professional',
|
||||
sku: CloudProducts.PROFESSIONAL,
|
||||
recurring_interval: RecurringIntervals.MONTH,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
renderWithContext(<ToYearlyNudgeBanner/>, state, {useMockedStore: true});
|
||||
|
||||
expect(() => screen.getByTestId('cloud-pro-monthly-deprecation-alert-banner')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import moment from 'moment';
|
||||
import React, {useEffect} from 'react';
|
||||
import {useIntl, FormattedMessage} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import type {GlobalState} from '@mattermost/types/store';
|
||||
|
||||
import {savePreferences} from 'mattermost-redux/actions/preferences';
|
||||
import {getSubscriptionProduct as selectSubscriptionProduct, getCloudSubscription as selectCloudSubscription} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {get as getPreference} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import {getCurrentUser, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users';
|
||||
|
||||
import AlertBanner from 'components/alert_banner';
|
||||
import AnnouncementBar from 'components/announcement_bar/default_announcement_bar';
|
||||
import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal';
|
||||
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
|
||||
|
||||
import {AnnouncementBarTypes, CloudBanners, CloudProducts, Preferences, RecurringIntervals, CloudBillingTypes} from 'utils/constants';
|
||||
import {t} from 'utils/i18n';
|
||||
|
||||
import './to_yearly_nudge_banner.scss';
|
||||
|
||||
enum DismissShowRange {
|
||||
GreaterThanEqual90 = '>=90',
|
||||
BetweenNinetyAnd60 = '89-61',
|
||||
SixtyTo31 = '60-31',
|
||||
ThirtyTo11 = '30-11',
|
||||
TenTo1 = '10-1',
|
||||
Zero = '0'
|
||||
}
|
||||
|
||||
const cloudProMonthlyCloseMoment = '20230727';
|
||||
|
||||
interface ToYearlyPlanDismissPreference {
|
||||
|
||||
// range represents the range for the days to the deprecation of cloud free e.g. in 30 to 10 days to deprecate cloud free
|
||||
// Incase of dismissing the banner, range represents the time (days) period when this banner was dismissed.
|
||||
// This is important because in case the banner was dismissed for a certain period, it helps us know that we should not show it again for that period.
|
||||
range: DismissShowRange;
|
||||
show: boolean;
|
||||
}
|
||||
|
||||
const ToYearlyNudgeBannerDismissable = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const openPurchaseModal = useOpenCloudPurchaseModal({});
|
||||
|
||||
const snoozePreferenceVal = useSelector((state: GlobalState) => getPreference(state, Preferences.TO_CLOUD_YEARLY_PLAN_NUDGE, CloudBanners.NUDGE_TO_CLOUD_YEARLY_PLAN_SNOOZED, '{"range": 0, "show": true}'));
|
||||
const snoozeInfo = JSON.parse(snoozePreferenceVal) as ToYearlyPlanDismissPreference;
|
||||
const show = snoozeInfo.show;
|
||||
|
||||
const currentUser = useSelector(getCurrentUser);
|
||||
const subscription = useSelector(selectCloudSubscription);
|
||||
const isAdmin = useSelector(isCurrentUserSystemAdmin);
|
||||
const product = useSelector(selectSubscriptionProduct);
|
||||
const currentProductProfessional = product?.sku === CloudProducts.PROFESSIONAL;
|
||||
const currentProductIsMonthly = product?.recurring_interval === RecurringIntervals.MONTH;
|
||||
const currentProductProMonthly = currentProductProfessional && currentProductIsMonthly;
|
||||
|
||||
const now = moment(Date.now());
|
||||
const proMonthlyEndDate = moment(cloudProMonthlyCloseMoment, 'YYYYMMDD');
|
||||
const daysToProMonthlyEnd = proMonthlyEndDate.diff(now, 'days');
|
||||
|
||||
const snoozedForRange = (range: DismissShowRange) => {
|
||||
return snoozeInfo.range === range;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!snoozeInfo.show) {
|
||||
if (daysToProMonthlyEnd >= 90 && !snoozedForRange(DismissShowRange.GreaterThanEqual90)) {
|
||||
showBanner(true);
|
||||
}
|
||||
|
||||
if (daysToProMonthlyEnd < 90 && daysToProMonthlyEnd > 60 && !snoozedForRange(DismissShowRange.BetweenNinetyAnd60)) {
|
||||
showBanner(true);
|
||||
}
|
||||
|
||||
if (daysToProMonthlyEnd <= 60 && daysToProMonthlyEnd > 30 && !snoozedForRange(DismissShowRange.SixtyTo31)) {
|
||||
showBanner(true);
|
||||
}
|
||||
|
||||
if (daysToProMonthlyEnd <= 30 && daysToProMonthlyEnd > 10 && !snoozedForRange(DismissShowRange.ThirtyTo11)) {
|
||||
showBanner(true);
|
||||
}
|
||||
|
||||
if (daysToProMonthlyEnd <= 10) {
|
||||
showBanner(true);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const showBanner = (show = false) => {
|
||||
let dRange = DismissShowRange.Zero;
|
||||
if (daysToProMonthlyEnd >= 90) {
|
||||
dRange = DismissShowRange.GreaterThanEqual90;
|
||||
}
|
||||
|
||||
if (daysToProMonthlyEnd < 90 && daysToProMonthlyEnd > 60) {
|
||||
dRange = DismissShowRange.BetweenNinetyAnd60;
|
||||
}
|
||||
|
||||
if (daysToProMonthlyEnd <= 60 && daysToProMonthlyEnd > 30) {
|
||||
dRange = DismissShowRange.SixtyTo31;
|
||||
}
|
||||
|
||||
if (daysToProMonthlyEnd <= 30 && daysToProMonthlyEnd > 10) {
|
||||
dRange = DismissShowRange.ThirtyTo11;
|
||||
}
|
||||
|
||||
// ideally this case should not happen because snooze button is not shown when TenTo1 days are remaining
|
||||
if (daysToProMonthlyEnd <= 10 && daysToProMonthlyEnd > 0) {
|
||||
dRange = DismissShowRange.TenTo1;
|
||||
}
|
||||
|
||||
const snoozeInfo: ToYearlyPlanDismissPreference = {
|
||||
range: dRange,
|
||||
show,
|
||||
};
|
||||
|
||||
dispatch(savePreferences(currentUser.id, [{
|
||||
category: Preferences.TO_CLOUD_YEARLY_PLAN_NUDGE,
|
||||
name: CloudBanners.NUDGE_TO_CLOUD_YEARLY_PLAN_SNOOZED,
|
||||
user_id: currentUser.id,
|
||||
value: JSON.stringify(snoozeInfo),
|
||||
}]));
|
||||
};
|
||||
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isAdmin) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!currentProductProMonthly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (subscription?.billing_type === CloudBillingTypes.INTERNAL || subscription?.billing_type === CloudBillingTypes.LICENSED) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const message = (
|
||||
<FormattedMessage
|
||||
id='cloud_billing.nudge_to_yearly.announcement_bar'
|
||||
defaultMessage='Monthly billing will be discontinued in {days} days . Switch to annual billing'
|
||||
values={{
|
||||
days: daysToProMonthlyEnd,
|
||||
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const announcementType = (daysToProMonthlyEnd <= 10) ? AnnouncementBarTypes.CRITICAL : AnnouncementBarTypes.ANNOUNCEMENT;
|
||||
|
||||
return (
|
||||
<AnnouncementBar
|
||||
id='cloud-pro-monthly-deprecation-announcement-bar'
|
||||
type={announcementType}
|
||||
showCloseButton={daysToProMonthlyEnd > 10}
|
||||
onButtonClick={() => openPurchaseModal({trackingLocation: 'to_yearly_nudge_annoucement_bar'})}
|
||||
modalButtonText={t('cloud_billing.nudge_to_yearly.update_billing')}
|
||||
modalButtonDefaultText='Update billing'
|
||||
message={message}
|
||||
showLinkAsButton={true}
|
||||
handleClose={showBanner}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const ToYearlyNudgeBanner = () => {
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
const [openSalesLink] = useOpenSalesLink();
|
||||
const openPurchaseModal = useOpenCloudPurchaseModal({});
|
||||
|
||||
const subscription = useSelector(selectCloudSubscription);
|
||||
const product = useSelector(selectSubscriptionProduct);
|
||||
const currentProductProfessional = product?.sku === CloudProducts.PROFESSIONAL;
|
||||
const currentProductIsMonthly = product?.recurring_interval === RecurringIntervals.MONTH;
|
||||
const currentProductProMonthly = currentProductProfessional && currentProductIsMonthly;
|
||||
|
||||
if (!currentProductProMonthly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (subscription?.billing_type === CloudBillingTypes.INTERNAL || subscription?.billing_type === CloudBillingTypes.LICENSED) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const now = moment(Date.now());
|
||||
const proMonthlyEndDate = moment(cloudProMonthlyCloseMoment, 'YYYYMMDD');
|
||||
const daysToProMonthlyEnd = proMonthlyEndDate.diff(now, 'days');
|
||||
|
||||
const title = (
|
||||
<FormattedMessage
|
||||
id='cloud_billing.nudge_to_yearly.title'
|
||||
defaultMessage='Action required: Switch to annual billing to keep your workspace.'
|
||||
/>
|
||||
);
|
||||
|
||||
const description = (
|
||||
<FormattedMessage
|
||||
id='cloud_billing.nudge_to_yearly.description'
|
||||
defaultMessage='Monthly billing will be discontinued on {date}. To keep your workspace, switch to annual billing.'
|
||||
values={{date: moment(cloudProMonthlyCloseMoment, 'YYYYMMDD').format('MMMM DD, YYYY')}}
|
||||
/>
|
||||
);
|
||||
|
||||
const viewPlansAction = (
|
||||
<button
|
||||
onClick={() => openPurchaseModal({trackingLocation: 'to_yearly_nudge_banner'})}
|
||||
className='btn ToYearlyNudgeBanner__primary'
|
||||
>
|
||||
{formatMessage({id: 'cloud_billing.nudge_to_yearly.learn_more', defaultMessage: 'Learn more'})}
|
||||
</button>
|
||||
);
|
||||
|
||||
const contactSalesAction = (
|
||||
<button
|
||||
onClick={openSalesLink}
|
||||
className='btn ToYearlyNudgeBanner__secondary'
|
||||
>
|
||||
{formatMessage({id: 'cloud_billing.nudge_to_yearly.contact_sales', defaultMessage: 'Contact sales'})}
|
||||
</button>
|
||||
);
|
||||
|
||||
const bannerMode = (daysToProMonthlyEnd <= 10) ? 'danger' : 'info';
|
||||
|
||||
return (
|
||||
<AlertBanner
|
||||
id='cloud-pro-monthly-deprecation-alert-banner'
|
||||
mode={bannerMode}
|
||||
title={title}
|
||||
message={description}
|
||||
className='ToYearlyNudgeBanner'
|
||||
actionButtonLeft={viewPlansAction}
|
||||
actionButtonRight={contactSalesAction}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export {
|
||||
ToYearlyNudgeBanner,
|
||||
ToYearlyNudgeBannerDismissable,
|
||||
};
|
||||
@@ -87,6 +87,10 @@
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
span {
|
||||
margin-left: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.BillingSummary__lastInvoice-date {
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
);
|
||||
|
||||
export const getPaymentStatus = (status: string) => {
|
||||
export const getPaymentStatus = (status: string, willRenew?: boolean) => {
|
||||
if (willRenew) {
|
||||
return (
|
||||
<div className='BillingSummary__lastInvoice-headerStatus paid'>
|
||||
<CheckIcon/> {' '}
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscriptions.billing_summary.lastInvoice.approved'
|
||||
defaultMessage='Approved'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
switch (status.toLowerCase()) {
|
||||
case 'failed':
|
||||
return (
|
||||
<div className='BillingSummary__lastInvoice-headerStatus failed'>
|
||||
<i className='icon icon-alert-outline'/> {' '}
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscriptions.billing_summary.lastInvoice.failed'
|
||||
defaultMessage='Failed'
|
||||
/>
|
||||
<i className='icon icon-alert-outline'/>
|
||||
</div>
|
||||
);
|
||||
case 'paid':
|
||||
return (
|
||||
<div className='BillingSummary__lastInvoice-headerStatus paid'>
|
||||
<CheckCircleOutlineIcon/> {' '}
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscriptions.billing_summary.lastInvoice.paid'
|
||||
defaultMessage='Paid'
|
||||
/>
|
||||
<CheckCircleOutlineIcon/>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className='BillingSummary__lastInvoice-headerStatus pending'>
|
||||
<ClockOutlineIcon/> {' '}
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscriptions.billing_summary.lastInvoice.pending'
|
||||
defaultMessage='Pending'
|
||||
/>
|
||||
<CheckCircleOutlineIcon/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
<div className='BillingSummary__lastInvoice-headerTitle'>
|
||||
{title()}
|
||||
</div>
|
||||
{getPaymentStatus(invoice.status)}
|
||||
{getPaymentStatus(invoice.status, willRenew)}
|
||||
</div>
|
||||
<div className='BillingSummary__lastInvoice-date'>
|
||||
<FormattedDate
|
||||
|
||||
@@ -7,6 +7,7 @@ import {useSelector} from 'react-redux';
|
||||
import {getSubscriptionProduct, checkHadPriorTrial, getCloudSubscription} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {cloudReverseTrial} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import {buildInvoiceSummaryPropsFromLineItems} from 'utils/cloud_utils';
|
||||
import {CloudProducts} from 'utils/constants';
|
||||
|
||||
import {
|
||||
@@ -59,16 +60,7 @@ const BillingSummary = ({isFreeTrial, daysLeftOnTrial, onUpgradeMattermostCloud}
|
||||
);
|
||||
} else if (subscription?.upcoming_invoice) {
|
||||
const invoice = subscription.upcoming_invoice;
|
||||
let fullCharges = invoice.line_items.filter((item) => 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 = (
|
||||
<InvoiceInfo
|
||||
@@ -76,7 +68,8 @@ const BillingSummary = ({isFreeTrial, daysLeftOnTrial, onUpgradeMattermostCloud}
|
||||
product={product}
|
||||
fullCharges={fullCharges}
|
||||
partialCharges={partialCharges}
|
||||
hasMore={hasMoreLineItems}
|
||||
hasMore={hasMore}
|
||||
willRenew={subscription?.will_renew === 'true'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ function makeInvoice(...lines: Array<[number, typeof InvoiceLineItemType[keyof t
|
||||
description: '',
|
||||
type,
|
||||
metadata: {} as Record<string, string>,
|
||||
period_end: 1642330466000,
|
||||
period_start: 1643540066000,
|
||||
};
|
||||
if (type === InvoiceLineItemType.Full || type === InvoiceLineItemType.Partial) {
|
||||
lineItem.metadata.type = type;
|
||||
|
||||
@@ -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<Props> {
|
||||
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<Props> {
|
||||
cloudRenewalAnnouncementBar = (
|
||||
<CloudAnnualRenewalAnnouncementBar/>
|
||||
);
|
||||
notifyAdminDowngradeDelinquencyBar = (
|
||||
<NotifyAdminDowngradeDelinquencyBar/>
|
||||
);
|
||||
toYearlyNudgeBannerDismissable = (<ToYearlyNudgeBannerDismissable/>);
|
||||
toPaidPlanNudgeBannerDismissable = (<ToPaidPlanBannerDismissable/>);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"', () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 = (
|
||||
<FormattedMessage
|
||||
id={t('cloud_delinquency.banner.end_user_notify_admin_title')}
|
||||
defaultMessage={'Your workspace has been downgraded. Notify your admin to fix billing issues'}
|
||||
/>);
|
||||
|
||||
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 (
|
||||
<AnnouncementBar
|
||||
type={AnnouncementBarTypes.CRITICAL}
|
||||
showCloseButton={true}
|
||||
onButtonClick={handleClick}
|
||||
modalButtonText={t('cloud_delinquency.banner.end_user_notify_admin_button')}
|
||||
modalButtonDefaultText={'Notify admin'}
|
||||
message={message}
|
||||
showLinkAsButton={true}
|
||||
isTallBanner={true}
|
||||
icon={<i className='icon icon-alert-outline'/>}
|
||||
handleClose={handleClose}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotifyAdminDowngradeDelinquencyBar;
|
||||
@@ -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(<NotifyAdminDowngradeDeliquencyBar/>, 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(<NotifyAdminDowngradeDeliquencyBar/>, 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(<NotifyAdminDowngradeDeliquencyBar/>, 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(<NotifyAdminDowngradeDeliquencyBar/>, 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(<NotifyAdminDowngradeDeliquencyBar/>, 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(<NotifyAdminDowngradeDeliquencyBar/>, 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(<NotifyAdminDowngradeDeliquencyBar/>, 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(<NotifyAdminDowngradeDeliquencyBar/>, 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(<NotifyAdminDowngradeDeliquencyBar/>, 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<typeof DelinquencyModal> = {
|
||||
closeModal: jest.fn(),
|
||||
onExited: jest.fn(),
|
||||
planName: 'planName',
|
||||
isAdminConsole: false,
|
||||
};
|
||||
|
||||
it('should save preferences and track stayOnFremium if admin click Stay on Free', () => {
|
||||
renderWithContext(<DelinquencyModal {...baseProps}/>, 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(<DelinquencyModal {...baseProps}/>, 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 = (
|
||||
<Modal
|
||||
className='DelinquencyModal'
|
||||
dialogClassName='a11y__modal'
|
||||
show={show}
|
||||
onHide={handleClose}
|
||||
onExited={handleClose}
|
||||
role='dialog'
|
||||
id='DelinquencyModal'
|
||||
aria-modal='true'
|
||||
>
|
||||
<Modal.Header className='DelinquencyModal__header '>
|
||||
<button
|
||||
id='closeIcon'
|
||||
className='icon icon-close'
|
||||
aria-label='Close'
|
||||
title='Close'
|
||||
onClick={handleClose}
|
||||
/>
|
||||
</Modal.Header>
|
||||
<Modal.Body className='DelinquencyModal__body'>
|
||||
<UpgradeSvg
|
||||
width={217}
|
||||
height={164}
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='cloud_delinquency.modal.workspace_downgraded'
|
||||
defaultMessage='Your workspace has been downgraded'
|
||||
>
|
||||
{(text) => <h3 className='DelinquencyModal__body__title'>{text}</h3>}
|
||||
</FormattedMessage>
|
||||
<FormattedMessage
|
||||
id='cloud_delinquency.modal.workspace_downgraded_description'
|
||||
defaultMessage='Due to payment issues on your {paidPlan}, your workspace has been downgraded to the free plan. To access {paidPlan} features again, update your billing information now.'
|
||||
values={{
|
||||
paidPlan: planName,
|
||||
}}
|
||||
>
|
||||
{(text) => <p className='DelinquencyModal__body__information'>{text}</p>}
|
||||
</FormattedMessage>
|
||||
</Modal.Body>
|
||||
<Modal.Footer className={'DelinquencyModal__footer '}>
|
||||
<button
|
||||
className={'DelinquencyModal__footer--secondary'}
|
||||
id={'stayOnFremium'}
|
||||
onClick={handleShowFremium}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='cloud_delinquency.modal.stay_on_freemium'
|
||||
defaultMessage='Stay on Free'
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={'DelinquencyModal__footer--primary'}
|
||||
id={'updanteBillingFromDeliquencyModal'}
|
||||
onClick={handleUpdateBilling}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='cloud_delinquency.modal.update_billing'
|
||||
defaultMessage='Update Billing'
|
||||
/>
|
||||
</button>
|
||||
</Modal.Footer>
|
||||
</Modal>);
|
||||
|
||||
if (!isAdminConsole) {
|
||||
return ModalJSX;
|
||||
}
|
||||
|
||||
return (<CompassThemeProvider theme={theme}>
|
||||
{ModalJSX}
|
||||
</CompassThemeProvider>);
|
||||
};
|
||||
|
||||
export default DelinquencyModal;
|
||||
@@ -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<GlobalState> = {
|
||||
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(
|
||||
<>
|
||||
<div id='root-portal'/>
|
||||
<ModalController/>
|
||||
<DelinquencyModalController/>
|
||||
</>,
|
||||
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(
|
||||
<>
|
||||
<div id='root-portal'/>
|
||||
<ModalController/>
|
||||
<DelinquencyModalController/>
|
||||
</>,
|
||||
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(
|
||||
<>
|
||||
<div id='root-portal'/>
|
||||
<ModalController/>
|
||||
<DelinquencyModalController/>
|
||||
</>,
|
||||
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(
|
||||
<>
|
||||
<div id='root-portal'/>
|
||||
<ModalController/>
|
||||
<DelinquencyModalController/>
|
||||
</>,
|
||||
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(
|
||||
<>
|
||||
<div id='root-portal'/>
|
||||
<ModalController/>
|
||||
<DelinquencyModalController/>
|
||||
</>,
|
||||
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(
|
||||
<>
|
||||
<div id='root-portal'/>
|
||||
<ModalController/>
|
||||
<DelinquencyModalController/>
|
||||
</>,
|
||||
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(
|
||||
<>
|
||||
<div id='root-portal'/>
|
||||
<ModalController/>
|
||||
<DelinquencyModalController/>
|
||||
</>,
|
||||
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(
|
||||
<>
|
||||
<div id='root-portal'/>
|
||||
<ModalController/>
|
||||
<DelinquencyModalController/>
|
||||
</>,
|
||||
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(
|
||||
<>
|
||||
<div id='root-portal'/>
|
||||
<ModalController/>
|
||||
<DelinquencyModalController/>
|
||||
</>,
|
||||
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(
|
||||
<>
|
||||
<div id='root-portal'/>
|
||||
<ModalController/>
|
||||
<DelinquencyModalController/>
|
||||
</>,
|
||||
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(
|
||||
<>
|
||||
<div id='root-portal'/>
|
||||
<ModalController/>
|
||||
<DelinquencyModalController/>
|
||||
</>,
|
||||
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(
|
||||
<>
|
||||
<div id='root-portal'/>
|
||||
<ModalController/>
|
||||
<DelinquencyModalController/>
|
||||
</>,
|
||||
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(
|
||||
<>
|
||||
<div id='root-portal'/>
|
||||
<ModalController/>
|
||||
<DelinquencyModalController/>
|
||||
</>,
|
||||
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(
|
||||
<>
|
||||
<div id='root-portal'/>
|
||||
<ModalController/>
|
||||
<DelinquencyModalController/>
|
||||
</>,
|
||||
newState,
|
||||
);
|
||||
|
||||
expect(getCloudProds).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
});
|
||||
@@ -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: <P>(modalData: ModalData<P>) => void;
|
||||
};
|
||||
delinquencyModalPreferencesConfirmed: PreferenceType[];
|
||||
}
|
||||
|
||||
const DelinquencyModalController = (props: DelinquencyModalControllerProps) => {
|
||||
useDelinquencyModalController(props);
|
||||
|
||||
return <></>;
|
||||
};
|
||||
|
||||
export default withGetCloudSubscription(DelinquencyModalController);
|
||||
@@ -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<GlobalState> = {
|
||||
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<typeof FreemiumModal> = {
|
||||
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(
|
||||
<FreemiumModal {...baseProps}/>,
|
||||
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(
|
||||
<FreemiumModal {...baseProps}/>,
|
||||
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(
|
||||
<FreemiumModal {...baseProps}/>,
|
||||
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(
|
||||
<FreemiumModal {...baseProps}/>,
|
||||
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(
|
||||
<FreemiumModal {...baseProps}/>,
|
||||
initialState,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(`Re-activate ${planName}`)).toBeInTheDocument();
|
||||
expect(screen.getByText('Your workspace has reached free plan limits. Upgrade to a paid plan.')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -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> = T[keyof T];
|
||||
|
||||
type DescriptionStatusKey = ValueOf<typeof LimitTypes> | 'noLimits' | 'multipleLimits';
|
||||
|
||||
const DescriptionMessages: Record<DescriptionStatusKey, JSX.Element> = {
|
||||
noLimits: (
|
||||
<FormattedMessage
|
||||
id='cloud_delinquency.modal.workspace_downgraded_freemium'
|
||||
defaultMessage='Cloud Free is restricted to 10,000 message history and 1GB file storage.'
|
||||
>
|
||||
{(text) => <p className='DelinquencyModal__body__limits-information'>{text}</p>}
|
||||
</FormattedMessage>
|
||||
),
|
||||
[LimitTypes.messageHistory]: (
|
||||
<FormattedMessage
|
||||
id='cloud_delinquency.modal.workspace_downgraded_messages_surpassed'
|
||||
defaultMessage={'Some of your workspace\'s message history are no longer accessible. Upgrade to a paid plan and get unlimited access to your message history.'}
|
||||
>
|
||||
{(text) => <p className='DelinquencyModal__body__limits-information'>{text}</p>}
|
||||
</FormattedMessage>
|
||||
),
|
||||
[LimitTypes.fileStorage]: (
|
||||
<FormattedMessage
|
||||
id='cloud_delinquency.modal.workspace_downgraded_storage_surpassed'
|
||||
defaultMessage={'Some of your workspace\'s files are no longer accessible. Upgrade to a paid plan and get unlimited access to your files.'}
|
||||
>
|
||||
{(text) => <p className='DelinquencyModal__body__limits-information'>{text}</p>}
|
||||
</FormattedMessage>
|
||||
),
|
||||
multipleLimits: (
|
||||
<FormattedMessage
|
||||
id='cloud_delinquency.modal.workspace_downgraded_multiples_limits_surpassed'
|
||||
defaultMessage='Your workspace has reached free plan limits. Upgrade to a paid plan.'
|
||||
>
|
||||
{(text) => <p className='DelinquencyModal__body__limits-information'>{text}</p>}
|
||||
</FormattedMessage>
|
||||
),
|
||||
};
|
||||
|
||||
const getDescriptionKey = (limits: Array<ValueOf<typeof LimitTypes>>): 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)]}
|
||||
<FormattedMessage
|
||||
id='cloud_delinquency.modal.workspace_downgraded_freemium_limits'
|
||||
defaultMessage='Free plan limits'
|
||||
>
|
||||
{(text) => <span className='DelinquencyModal__body__subheader'>{text}</span>}
|
||||
</FormattedMessage>
|
||||
</>);
|
||||
|
||||
if (limitsSurpassed.length === 0) {
|
||||
const secondaryAction = {
|
||||
message: {
|
||||
id: t('cloud_delinquency.modal.stay_on_freemium_close'),
|
||||
defaultMessage: 'View plans',
|
||||
},
|
||||
onClick: handleClose,
|
||||
};
|
||||
|
||||
return (
|
||||
<CloudUsageModal
|
||||
className='DelinquencyModal'
|
||||
secondaryAction={secondaryAction}
|
||||
title={title}
|
||||
description={description}
|
||||
onClose={handleClose}
|
||||
/>);
|
||||
}
|
||||
|
||||
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 (
|
||||
<CloudUsageModal
|
||||
className='DelinquencyModal'
|
||||
title={title}
|
||||
description={description}
|
||||
primaryAction={primaryAction}
|
||||
secondaryAction={secondaryAction}
|
||||
onClose={handleClose}
|
||||
needsTheme={isAdminConsole}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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<GenericAction>) {
|
||||
return {
|
||||
actions: bindActionCreators({
|
||||
getCloudSubscription,
|
||||
closeModal: () => closeModal(ModalIdentifiers.DELINQUENCY_MODAL_DOWNGRADE),
|
||||
openModal,
|
||||
}, dispatch),
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(makeMapStateToProps, mapDispatchToProps)(DeliquencyModalController);
|
||||
@@ -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: <P>(modalData: ModalData<P>) => 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]);
|
||||
};
|
||||
@@ -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<HTMLAnchorElement, MouseEvent>,
|
||||
) => {
|
||||
e.preventDefault();
|
||||
trackEvent(
|
||||
TELEMETRY_CATEGORIES.CLOUD_ADMIN,
|
||||
'click_see_how_billing_works',
|
||||
);
|
||||
window.open(CloudLinks.DELINQUENCY_DOCS, '_blank');
|
||||
};
|
||||
|
||||
const seeHowBillingWorks = (
|
||||
<a onClick={handleSeeHowBillingWorksClick}>
|
||||
<FormattedMessage
|
||||
defaultMessage={'See how billing works.'}
|
||||
id={
|
||||
'admin.billing.subscription.howItWorks'
|
||||
}
|
||||
/>
|
||||
</a>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='PlanCard'>
|
||||
<div
|
||||
className='top'
|
||||
style={{backgroundColor: props.topColor}}
|
||||
/>
|
||||
<div className='bottom delinquency'>
|
||||
<div className='delinquency_summary_section'>
|
||||
<div className={'summary-section'}>
|
||||
<div className='summary-title'>
|
||||
<FormattedMessage
|
||||
id={'cloud_delinquency.cc_modal.card.totalOwed'}
|
||||
defaultMessage={'Total Owed'}
|
||||
/>
|
||||
{':'}
|
||||
</div>
|
||||
<div className='summary-total'>{props.price}</div>
|
||||
<div
|
||||
onClick={props.onViewBreakdownClick}
|
||||
className='view-breakdown'
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage={'View Breakdown'}
|
||||
id={
|
||||
'cloud_delinquency.cc_modal.card.viewBreakdown'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
className={
|
||||
'plan_action_btn ' + props.buttonDetails.customClass
|
||||
}
|
||||
disabled={props.buttonDetails.disabled}
|
||||
onClick={props.buttonDetails.action}
|
||||
>
|
||||
{props.buttonDetails.text}
|
||||
</button>
|
||||
</div>
|
||||
<div className='plan_billing_cycle delinquency'>
|
||||
{Boolean(!props.isCloudDelinquencyGreaterThan90Days) && (
|
||||
<FormattedMessage
|
||||
defaultMessage={
|
||||
'When you reactivate your subscription, you\'ll be billed the total outstanding amount immediately. Your bill is calculated at the end of the billing cycle based on the number of active users. {seeHowBillingWorks}'
|
||||
}
|
||||
id={'cloud_delinquency.cc_modal.disclaimer'}
|
||||
values={{
|
||||
seeHowBillingWorks,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{Boolean(props.isCloudDelinquencyGreaterThan90Days) && (
|
||||
<FormattedMessage
|
||||
defaultMessage={
|
||||
'When you reactivate your subscription, you\'ll be billed the total outstanding amount immediately. You\'ll also be billed {cost} immediately for a 1 year subscription based on your current active user count of {users} users. {seeHowBillingWorks}'
|
||||
}
|
||||
id={
|
||||
'cloud_delinquency.cc_modal.disclaimer_with_upgrade_info'
|
||||
}
|
||||
values={{
|
||||
cost: `$${props.cost}`,
|
||||
users: props.users,
|
||||
seeHowBillingWorks,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Stripe | null>;
|
||||
|
||||
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<string, Product> | 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<string, Product> | 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<string, Product> | undefined,
|
||||
yearlyProducts: Record<string, Product>,
|
||||
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 = (
|
||||
<div className='PlanCard'>
|
||||
@@ -249,111 +193,6 @@ export function Card(props: CardProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function DelinquencyCard(props: DelinquencyCardProps) {
|
||||
const seeHowBillingWorks = (
|
||||
e: React.MouseEvent<HTMLAnchorElement, MouseEvent>,
|
||||
) => {
|
||||
e.preventDefault();
|
||||
trackEvent(
|
||||
TELEMETRY_CATEGORIES.CLOUD_ADMIN,
|
||||
'click_see_how_billing_works',
|
||||
);
|
||||
window.open(CloudLinks.DELINQUENCY_DOCS, '_blank');
|
||||
};
|
||||
return (
|
||||
<div className='PlanCard'>
|
||||
<div
|
||||
className='top'
|
||||
style={{backgroundColor: props.topColor}}
|
||||
/>
|
||||
<div className='bottom delinquency'>
|
||||
<div className='delinquency_summary_section'>
|
||||
<div className={'summary-section'}>
|
||||
<div className='summary-title'>
|
||||
<FormattedMessage
|
||||
id={'cloud_delinquency.cc_modal.card.totalOwed'}
|
||||
defaultMessage={'Total Owed'}
|
||||
/>
|
||||
{':'}
|
||||
</div>
|
||||
<div className='summary-total'>{props.price}</div>
|
||||
<div
|
||||
onClick={props.onViewBreakdownClick}
|
||||
className='view-breakdown'
|
||||
>
|
||||
<FormattedMessage
|
||||
defaultMessage={'View Breakdown'}
|
||||
id={
|
||||
'cloud_delinquency.cc_modal.card.viewBreakdown'
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
className={
|
||||
'plan_action_btn ' + props.buttonDetails.customClass
|
||||
}
|
||||
disabled={props.buttonDetails.disabled}
|
||||
onClick={props.buttonDetails.action}
|
||||
>
|
||||
{props.buttonDetails.text}
|
||||
</button>
|
||||
</div>
|
||||
<div className='plan_billing_cycle delinquency'>
|
||||
{Boolean(!props.isCloudDelinquencyGreaterThan90Days) && (
|
||||
<FormattedMessage
|
||||
defaultMessage={
|
||||
'When you reactivate your subscription, you\'ll be billed the total outstanding amount immediately. Your bill is calculated at the end of the billing cycle based on the number of active users. {seeHowBillingWorks}'
|
||||
}
|
||||
id={'cloud_delinquency.cc_modal.disclaimer'}
|
||||
values={{
|
||||
seeHowBillingWorks: (
|
||||
<a onClick={seeHowBillingWorks}>
|
||||
<FormattedMessage
|
||||
defaultMessage={
|
||||
'See how billing works.'
|
||||
}
|
||||
id={
|
||||
'admin.billing.subscription.howItWorks'
|
||||
}
|
||||
/>
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{Boolean(props.isCloudDelinquencyGreaterThan90Days) && (
|
||||
<FormattedMessage
|
||||
defaultMessage={
|
||||
'When you reactivate your subscription, you\'ll be billed the total outstanding amount immediately. You\'ll also be billed {cost} immediately for a 1 year subscription based on your current active user count of {users} users. {seeHowBillingWorks}'
|
||||
}
|
||||
id={
|
||||
'cloud_delinquency.cc_modal.disclaimer_with_upgrade_info'
|
||||
}
|
||||
values={{
|
||||
cost: `$${props.cost}`,
|
||||
users: props.users,
|
||||
seeHowBillingWorks: (
|
||||
<a onClick={seeHowBillingWorks}>
|
||||
<FormattedMessage
|
||||
defaultMessage={'See how billing works.'}
|
||||
id={
|
||||
'admin.billing.subscription.howItWorks'
|
||||
}
|
||||
/>
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
class PurchaseModal extends React.PureComponent<Props, State> {
|
||||
modal = React.createRef();
|
||||
|
||||
@@ -375,15 +214,11 @@ class PurchaseModal extends React.PureComponent<Props, State> {
|
||||
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<Props, State> {
|
||||
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<Props, State> {
|
||||
};
|
||||
|
||||
paymentFooterText = () => {
|
||||
const normalPaymentText = (
|
||||
return (
|
||||
<div className='plan_payment_commencement'>
|
||||
<FormattedMessage
|
||||
defaultMessage={'You\'ll be billed from: {beginDate}'}
|
||||
@@ -576,61 +411,6 @@ class PurchaseModal extends React.PureComponent<Props, State> {
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
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 = (
|
||||
<Tooltip
|
||||
id='proratedPayment__tooltip'
|
||||
className='proratedTooltip'
|
||||
>
|
||||
<div className='tooltipTitle'>
|
||||
<FormattedMessage
|
||||
defaultMessage={'Prorated Payments'}
|
||||
id={'admin.billing.subscription.proratedPayment.tooltipTitle'}
|
||||
/>
|
||||
</div>
|
||||
<div className='tooltipText'>
|
||||
<FormattedMessage
|
||||
defaultMessage={'If you upgrade to {selectedProductName} from {currentProductName} mid-month, you will be charged a prorated amount for both plans.'}
|
||||
id={'admin.billing.subscription.proratedPayment.tooltipText'}
|
||||
values={{
|
||||
beginDate: getNextBillingDate(),
|
||||
selectedProductName: this.state.selectedProduct?.name,
|
||||
currentProductName: this.state.currentProduct?.name,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const announcementIcon = (
|
||||
<OverlayTrigger
|
||||
delayShow={Constants.OVERLAY_TIME_DELAY}
|
||||
placement='top'
|
||||
overlay={announcementTooltip}
|
||||
>
|
||||
<div className='content__icon'>{'\uF5D6'}</div>
|
||||
</OverlayTrigger>
|
||||
|
||||
);
|
||||
const prorratedPaymentText = (
|
||||
<div className='prorrated-payment-text'>
|
||||
{announcementIcon}
|
||||
<FormattedMessage
|
||||
defaultMessage={'Prorated payment begins: {beginDate}. '}
|
||||
id={'admin.billing.subscription.proratedPaymentBegins'}
|
||||
values={{
|
||||
beginDate: getNextBillingDate(),
|
||||
}}
|
||||
/>
|
||||
{this.learnMoreLink()}
|
||||
</div>
|
||||
);
|
||||
payment = prorratedPaymentText;
|
||||
}
|
||||
return payment;
|
||||
};
|
||||
|
||||
getPlanNameFromProductName = (productName: string): string => {
|
||||
@@ -658,24 +438,30 @@ class PurchaseModal extends React.PureComponent<Props, State> {
|
||||
};
|
||||
|
||||
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 : (
|
||||
<div className='plan_comparison'>
|
||||
{this.comparePlan}
|
||||
</div>
|
||||
)}
|
||||
<div className='RHS'>
|
||||
<DelinquencyCard
|
||||
topColor='#4A69AC'
|
||||
price={this.getDelinquencyTotalString()}
|
||||
@@ -693,6 +479,44 @@ class PurchaseModal extends React.PureComponent<Props, State> {
|
||||
cost={parseInt(this.state.selectedProductPrice || '', 10) * this.props.usersCount}
|
||||
users={this.props.usersCount}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<RenewalCard
|
||||
invoice={invoice}
|
||||
product={this.state.selectedProduct || undefined}
|
||||
seats={this.state.seats}
|
||||
existingUsers={this.props.usersCount}
|
||||
onSeatChange={(seats: Seats) => 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<Props, State> {
|
||||
|
||||
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 (
|
||||
<>
|
||||
<div className='RHS'>
|
||||
<div
|
||||
className={showPlanLabel ? 'plan_comparison show_label' : 'plan_comparison'}
|
||||
>
|
||||
@@ -739,11 +559,7 @@ class PurchaseModal extends React.PureComponent<Props, State> {
|
||||
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<Props, State> {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -912,7 +728,7 @@ class PurchaseModal extends React.PureComponent<Props, State> {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className='RHS'>{this.purchaseScreenCard()}</div>
|
||||
{this.purchaseScreenCard()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
257
webapp/channels/src/components/purchase_modal/renewal_card.scss
Normal file
257
webapp/channels/src/components/purchase_modal/renewal_card.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
318
webapp/channels/src/components/purchase_modal/renewal_card.tsx
Normal file
318
webapp/channels/src/components/purchase_modal/renewal_card.tsx
Normal file
@@ -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<HTMLAnchorElement, MouseEvent>,
|
||||
) => {
|
||||
e.preventDefault();
|
||||
trackEvent(
|
||||
TELEMETRY_CATEGORIES.CLOUD_ADMIN,
|
||||
'click_see_how_billing_works',
|
||||
);
|
||||
window.open(CloudLinks.DELINQUENCY_DOCS, '_blank');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='RenewalRHS'>
|
||||
<div className='RenewalCard'>
|
||||
<div className='RenewalSummary__lastInvoice-header'>
|
||||
<div className='RenewalSummary__lastInvoice-headerTitle'>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.invoice.next'
|
||||
defaultMessage='Next Invoice'
|
||||
/>
|
||||
</div>
|
||||
{getPaymentStatus(invoice.status)}
|
||||
</div>
|
||||
<div className='RenewalSummary__upcomingInvoice-due-date'>
|
||||
<FormattedMessage
|
||||
id={'cloud.renewal.tobepaid'}
|
||||
defaultMessage={'To be paid on {date}'}
|
||||
values={{
|
||||
date: (
|
||||
<FormattedDate
|
||||
value={new Date(invoice.period_start)}
|
||||
month='short'
|
||||
year='numeric'
|
||||
day='numeric'
|
||||
timeZone='UTC'
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='RenewalSummary__lastInvoice-productName'>
|
||||
{product?.name}
|
||||
</div>
|
||||
<hr style={{marginTop: '12px'}}/>
|
||||
<SeatsCalculator
|
||||
price={product!.price_per_seat}
|
||||
seats={seats}
|
||||
onChange={(seats: Seats) => {
|
||||
onSeatChange(seats);
|
||||
}}
|
||||
isCloud={true}
|
||||
existingUsers={existingUsers}
|
||||
excludeTotal={true}
|
||||
/>
|
||||
{fullCharges.map((charge: any) => (
|
||||
<div
|
||||
key={charge.price_id}
|
||||
className='RenewalSummary__upcomingInvoice-charge'
|
||||
>
|
||||
<div className='RenewalSummary__upcomingInvoice-chargeDescription'>
|
||||
<>
|
||||
<FormattedNumber
|
||||
value={charge.price_per_unit / 100.0}
|
||||
// eslint-disable-next-line react/style-prop-object
|
||||
style='currency'
|
||||
currency='USD'
|
||||
/>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscriptions.billing_summary.lastInvoice.seatCount'
|
||||
defaultMessage=' x {seats} seats'
|
||||
values={{seats: charge.quantity}}
|
||||
/>
|
||||
{(' ')}
|
||||
{'('}
|
||||
<FormattedDate
|
||||
value={new Date(charge.period_start * 1000)}
|
||||
month='numeric'
|
||||
year='numeric'
|
||||
day='numeric'
|
||||
timeZone='UTC'
|
||||
/>
|
||||
{')'}
|
||||
|
||||
</>
|
||||
</div>
|
||||
<div className='RenewalSummary__lastInvoice-chargeAmount'>
|
||||
<FormattedNumber
|
||||
value={charge.total / 100.0}
|
||||
// eslint-disable-next-line react/style-prop-object
|
||||
style='currency'
|
||||
currency='USD'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{Boolean(hasMore) && (
|
||||
<div
|
||||
className='RenewalSummary__upcomingInvoice-hasMoreItems'
|
||||
>
|
||||
<div
|
||||
onClick={openInvoicePreview}
|
||||
className='RenewalSummary__upcomingInvoice-chargeDescription'
|
||||
>
|
||||
{product?.billing_scheme === BillingSchemes.FLAT_FEE ? (
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscriptions.billing_summary.lastInvoice.monthlyFlatFee'
|
||||
defaultMessage='Monthly Flat Fee'
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<FormattedMessage
|
||||
id='cloud.renewal.andMoreItems'
|
||||
defaultMessage='+ {count} more items'
|
||||
values={{count: hasMore}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(partialCharges.length) && (
|
||||
<>
|
||||
<div className='RenewalSummary__lastInvoice-partialCharges'>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscriptions.billing_summary.lastInvoice.partialCharges'
|
||||
defaultMessage='Partial charges'
|
||||
/>
|
||||
<OverlayTrigger
|
||||
delayShow={500}
|
||||
placement='bottom'
|
||||
overlay={
|
||||
<Tooltip
|
||||
id='BillingSubscriptions__seatOverageTooltip'
|
||||
className='BillingSubscriptions__tooltip BillingSubscriptions__tooltip-right'
|
||||
positionLeft={390}
|
||||
>
|
||||
<div className='BillingSubscriptions__tooltipTitle'>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscriptions.billing_summary.lastInvoice.whatArePartialCharges'
|
||||
defaultMessage='What are partial charges?'
|
||||
/>
|
||||
</div>
|
||||
<div className='BillingSubscriptions__tooltipMessage'>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscriptions.billing_summary.lastInvoice.whatArePartialCharges.message'
|
||||
defaultMessage='Users who have not been enabled for the full duration of the month are charged at a prorated monthly rate.'
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<i className='icon-information-outline'/>
|
||||
</OverlayTrigger>
|
||||
</div>
|
||||
{partialCharges.map((charge: any) => (
|
||||
<div
|
||||
key={charge.price_id}
|
||||
className='RenewalSummary__lastInvoice-charge'
|
||||
>
|
||||
<div className='RenewalSummary__lastInvoice-chargeDescription'>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscriptions.billing_summary.lastInvoice.seatCountPartial'
|
||||
defaultMessage='{seats} seats'
|
||||
values={{seats: charge.quantity}}
|
||||
/>
|
||||
</div>
|
||||
<div className='RenewalSummary__lastInvoice-chargeAmount'>
|
||||
<FormattedNumber
|
||||
value={charge.total / 100.0}
|
||||
// eslint-disable-next-line react/style-prop-object
|
||||
style='currency'
|
||||
currency='USD'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
{Boolean(hasMore) && (
|
||||
<div className='RenewalSummary__upcomingInvoice-ellipses'>
|
||||
<EllipsisHorizontalIcon width={'40px'}/>
|
||||
</div>
|
||||
)}
|
||||
{Boolean(invoice.tax) && (
|
||||
<div className='RenewalSummary__lastInvoice-charge'>
|
||||
<div className='RenewalSummary__lastInvoice-chargeDescription'>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscriptions.billing_summary.lastInvoice.taxes'
|
||||
defaultMessage='Taxes'
|
||||
/>
|
||||
</div>
|
||||
<div className='RenewalSummary__lastInvoice-chargeAmount'>
|
||||
<FormattedNumber
|
||||
value={invoice.tax / 100.0}
|
||||
// eslint-disable-next-line react/style-prop-object
|
||||
style='currency'
|
||||
currency='USD'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={openInvoicePreview}
|
||||
className='RenewalSummary__upcomingInvoice-viewInvoiceLink'
|
||||
>
|
||||
<FormattedMessage
|
||||
id='cloud.renewal.viewInvoice'
|
||||
defaultMessage='View Invoice'
|
||||
/>
|
||||
</button>
|
||||
<hr style={{marginTop: '0'}}/>
|
||||
<div className='RenewalSummary__upcomingInvoice-charge total'>
|
||||
<div className='RenewalSummary__upcomingInvoice-chargeDescription'>
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscriptions.billing_summary.lastInvoice.total'
|
||||
defaultMessage='Total'
|
||||
/>
|
||||
</div>
|
||||
<div className='RenewalSummary__lastInvoice-chargeAmount'>
|
||||
<FormattedNumber
|
||||
value={fullCharges.reduce((sum: number, item) => sum + item.total, 0) / 100.0}
|
||||
// eslint-disable-next-line react/style-prop-object
|
||||
style='currency'
|
||||
currency='USD'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onButtonClick}
|
||||
className='RenewalSummary__upcomingInvoice-renew-button'
|
||||
disabled={Boolean(buttonDisabled) || Boolean(seats.error)}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='cloud.renewal.renew'
|
||||
defaultMessage='Renew'
|
||||
/>
|
||||
</button>
|
||||
<div className='RenewalSummary__disclaimer'>
|
||||
<FormattedMessage
|
||||
defaultMessage={
|
||||
'Your bill is calculated at the end of the billing cycle based on the number of enabled users. {seeHowBillingWorks}'
|
||||
}
|
||||
id={
|
||||
'cloud_delinquency.cc_modal.disclaimer_with_upgrade_info'
|
||||
}
|
||||
values={{
|
||||
seeHowBillingWorks: (
|
||||
<a onClick={seeHowBillingWorks}>
|
||||
<FormattedMessage
|
||||
defaultMessage={'See how billing works.'}
|
||||
id={
|
||||
'admin.billing.subscription.howItWorks'
|
||||
}
|
||||
/>
|
||||
</a>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
42
webapp/channels/src/components/purchase_modal/utils.ts
Normal file
42
webapp/channels/src/components/purchase_modal/utils.ts
Normal file
@@ -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<string, Product> | 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<string, Product> | 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<string, Product>) {
|
||||
return findProductInDictionary(yearlyProducts, null, CloudProducts.PROFESSIONAL, RecurringIntervals.YEAR);
|
||||
}
|
||||
@@ -98,7 +98,6 @@ exports[`components/Root Routes Should mount public product routes 1`] = `
|
||||
<GlobalHeader />
|
||||
<CloudEffectsWrapper />
|
||||
<withRouter(Connect(TeamSidebar)) />
|
||||
<DelinquencyModalController />
|
||||
<Switch>
|
||||
<Route
|
||||
key="productwithpublic-public"
|
||||
|
||||
@@ -86,7 +86,6 @@ const LazyCreateTeam = React.lazy(() => 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<Props, State> {
|
||||
<GlobalHeader/>
|
||||
<CloudEffects/>
|
||||
<TeamSidebar/>
|
||||
<DelinquencyModalController/>
|
||||
<Switch>
|
||||
{this.props.products?.filter((product) => Boolean(product.publicComponent)).map((product) => (
|
||||
<Route
|
||||
|
||||
@@ -20,6 +20,7 @@ interface Props {
|
||||
existingUsers: number;
|
||||
isCloud: boolean;
|
||||
onChange: (seats: Seats) => void;
|
||||
excludeTotal?: boolean;
|
||||
}
|
||||
|
||||
export interface Seats {
|
||||
@@ -210,31 +211,35 @@ export default function SeatsCalculator(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='SeatsCalculator__seats-item'>
|
||||
<div className='SeatsCalculator__seats-label'>
|
||||
<FormattedMessage
|
||||
id='self_hosted_signup.line_item_subtotal'
|
||||
defaultMessage='{num} seats × 12 mo.'
|
||||
values={{
|
||||
num: props.seats.quantity || '0',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='SeatsCalculator__seats-value'>
|
||||
{total}
|
||||
</div>
|
||||
</div>
|
||||
<div className='SeatsCalculator__total'>
|
||||
<div className='SeatsCalculator__total-label'>
|
||||
<FormattedMessage
|
||||
id='self_hosted_signup.total'
|
||||
defaultMessage='Total'
|
||||
/>
|
||||
</div>
|
||||
<div className='SeatsCalculator__total-value'>
|
||||
{total}
|
||||
</div>
|
||||
</div>
|
||||
{!props.excludeTotal && (
|
||||
<>
|
||||
<div className='SeatsCalculator__seats-item'>
|
||||
<div className='SeatsCalculator__seats-label'>
|
||||
<FormattedMessage
|
||||
id='self_hosted_signup.line_item_subtotal'
|
||||
defaultMessage='{num} seats × 12 mo.'
|
||||
values={{
|
||||
num: props.seats.quantity || '0',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='SeatsCalculator__seats-value'>
|
||||
{total}
|
||||
</div>
|
||||
</div>
|
||||
<div className='SeatsCalculator__total'>
|
||||
<div className='SeatsCalculator__total-label'>
|
||||
<FormattedMessage
|
||||
id='self_hosted_signup.total'
|
||||
defaultMessage='Total'
|
||||
/>
|
||||
</div>
|
||||
<div className='SeatsCalculator__total-value'>
|
||||
{total}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,13 +4,18 @@
|
||||
import React from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
export default function EllipsisHorizontalIcon(props: React.HTMLAttributes<HTMLSpanElement>) {
|
||||
type Props = React.HTMLAttributes<HTMLSpanElement> & {
|
||||
width?: string;
|
||||
height?: string;
|
||||
};
|
||||
|
||||
export default function EllipsisHorizontalIcon(props: Props) {
|
||||
const {formatMessage} = useIntl();
|
||||
return (
|
||||
<span {...props}>
|
||||
<svg
|
||||
width='24px'
|
||||
height='24px'
|
||||
width={props.width || '24px'}
|
||||
height={props.width || '24px'}
|
||||
viewBox='0 0 24 24'
|
||||
role='img'
|
||||
aria-label={formatMessage({id: 'generic_icons.elipsisHorizontalIcon', defaultMessage: 'Ellipsis Horizontal Icon'})}
|
||||
|
||||
@@ -387,9 +387,6 @@
|
||||
"admin.billing.subscription.privateCloudCard.upgradeNow": "Upgrade Now",
|
||||
"admin.billing.subscription.proratedPayment.substitle": "Thank you for upgrading to {selectedProductName}. Check your workspace in a few minutes to access all the plan's features. You'll be charged a prorated amount for your {currentProductName} plan and {selectedProductName} plan based on the number of days left in the billing cycle and number of users you have.",
|
||||
"admin.billing.subscription.proratedPayment.title": "You are now subscribed to {selectedProductName}",
|
||||
"admin.billing.subscription.proratedPayment.tooltipText": "If you upgrade to {selectedProductName} from Cloud Free mid-month, you will be charged a prorated amount for both plans.",
|
||||
"admin.billing.subscription.proratedPayment.tooltipTitle": "Prorated Payments",
|
||||
"admin.billing.subscription.proratedPaymentBegins": "Prorated payment begins: {beginDate}. ",
|
||||
"admin.billing.subscription.providePaymentDetails": "Provide your payment details",
|
||||
"admin.billing.subscription.returnToTeam": "Return to {team}",
|
||||
"admin.billing.subscription.stateprovince": "State/Province",
|
||||
@@ -404,6 +401,7 @@
|
||||
"admin.billing.subscription.viewBilling": "View Billing",
|
||||
"admin.billing.subscriptions.billing_summary.explore_enterprise": "Explore Enterprise features",
|
||||
"admin.billing.subscriptions.billing_summary.explore_enterprise.cta": "View all features",
|
||||
"admin.billing.subscriptions.billing_summary.lastInvoice.approved": "Approved",
|
||||
"admin.billing.subscriptions.billing_summary.lastInvoice.failed": "Failed",
|
||||
"admin.billing.subscriptions.billing_summary.lastInvoice.monthlyFlatFee": "Monthly Flat Fee",
|
||||
"admin.billing.subscriptions.billing_summary.lastInvoice.paid": "Paid",
|
||||
@@ -3188,38 +3186,22 @@
|
||||
"cloud_billing.nudge_to_paid.learn_more": "Upgrade",
|
||||
"cloud_billing.nudge_to_paid.title": "Upgrade to paid plan to keep your workspace",
|
||||
"cloud_billing.nudge_to_paid.view_plans": "View plans",
|
||||
"cloud_billing.nudge_to_yearly.announcement_bar": "Monthly billing will be discontinued in {days} days . Switch to annual billing",
|
||||
"cloud_billing.nudge_to_yearly.contact_sales": "Contact sales",
|
||||
"cloud_billing.nudge_to_yearly.description": "Monthly billing will be discontinued on {date}. To keep your workspace, switch to annual billing.",
|
||||
"cloud_billing.nudge_to_yearly.learn_more": "Learn more",
|
||||
"cloud_billing.nudge_to_yearly.title": "Action required: Switch to annual billing to keep your workspace.",
|
||||
"cloud_billing.nudge_to_yearly.update_billing": "Update billing",
|
||||
"cloud_delinquency.banner.buttonText": "Update billing now",
|
||||
"cloud_delinquency.banner.end_user_notify_admin_button": "Notify admin",
|
||||
"cloud_delinquency.banner.end_user_notify_admin_title": "Your workspace has been downgraded. Notify your admin to fix billing issues",
|
||||
"cloud_delinquency.cc_modal.card.reactivate": "Reactivate",
|
||||
"cloud_delinquency.cc_modal.card.totalOwed": "Total owed",
|
||||
"cloud_delinquency.cc_modal.card.viewBreakdown": "View breakdown",
|
||||
"cloud_delinquency.cc_modal.disclaimer": "When you reactivate your subscription, you'll be billed the total outstanding amount immediately. Your bill is calculated at the end of the billing cycle based on the number of active users. {seeHowBillingWorks}",
|
||||
"cloud_delinquency.cc_modal.disclaimer_with_upgrade_info": "When you reactivate your subscription, you'll be billed the total outstanding amount immediately. You'll also be billed {cost} immediately for a 1 year subscription based on your current active user count of {users} users. {seeHowBillingWorks}",
|
||||
"cloud_delinquency.modal.re_activate_plan": "Reactivate {planName}",
|
||||
"cloud_delinquency.modal.stay_on_freemium": "Stay on Mattermost Free",
|
||||
"cloud_delinquency.modal.stay_on_freemium_close": "Close",
|
||||
"cloud_delinquency.modal.update_billing": "Update billing",
|
||||
"cloud_delinquency.modal.workspace_downgraded": "Your workspace has been downgraded",
|
||||
"cloud_delinquency.modal.workspace_downgraded_description": "Due to payment issues on your {paidPlan}, your workspace has been downgraded to the free plan. To access {paidPlan} features again, update your billing information now.",
|
||||
"cloud_delinquency.modal.workspace_downgraded_freemium": "Cloud Free is restricted to 10,000 message history and 1GB file storage.",
|
||||
"cloud_delinquency.modal.workspace_downgraded_freemium_limits": "Free plan limits",
|
||||
"cloud_delinquency.modal.workspace_downgraded_freemium_title": "You now have data limits on your plan",
|
||||
"cloud_delinquency.modal.workspace_downgraded_messages_surpassed": "Some of your workspace's message history are no longer accessible. Upgrade to a paid plan and get unlimited access to your message history.",
|
||||
"cloud_delinquency.modal.workspace_downgraded_multiples_limits_surpassed": "Your workspace has reached free plan limits. Upgrade to a paid plan.",
|
||||
"cloud_delinquency.modal.workspace_downgraded_storage_surpassed": "Some of your workspace's files are no longer accessible. Upgrade to a paid plan and get unlimited access to your files.",
|
||||
"cloud_signup.signup_consequences": "Your credit card will be charged today. <a>See how billing works.</a>",
|
||||
"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": "<downloadLink>Download</downloadLink> 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.",
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -187,6 +187,8 @@ export type InvoiceLineItem = {
|
||||
description: string;
|
||||
type: typeof InvoiceLineItemType[keyof typeof InvoiceLineItemType];
|
||||
metadata: Record<string, string>;
|
||||
period_start: number;
|
||||
period_end: number;
|
||||
}
|
||||
|
||||
export type Limits = {
|
||||
|
||||
Reference in New Issue
Block a user