[CLD-7421][CLD-7420] Deprecate Self Serve: First Pass (#26668)

* Deprecate Self Serve: First Pass

* Fix ci

* Fix more ci

* Remmove outdated server tests

* Fix a missed spot opening purchase modal in Self Hosted

* Fix i18n

* Clean up some more server code, fix webapp test

* Fix alignment of button

* Fix linter

* Fix i18n server side

* Add back translation

* Remove client functions

* Put back client functions

---------

Co-authored-by: Mattermost Build <build@mattermost.com>
Этот коммит содержится в:
Nick Misasi
2024-04-23 14:25:37 -04:00
коммит произвёл GitHub
родитель a40550136f
Коммит 437f90e184
92 изменённых файлов: 173 добавлений и 13211 удалений

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

@@ -45,8 +45,6 @@ import BillingHistory, {searchableStrings as billingHistorySearchableStrings} fr
import BillingSubscriptions, {searchableStrings as billingSubscriptionSearchableStrings} from './billing/billing_subscriptions';
import CompanyInfo, {searchableStrings as billingCompanyInfoSearchableStrings} from './billing/company_info';
import CompanyInfoEdit from './billing/company_info_edit';
import PaymentInfo, {searchableStrings as billingPaymentInfoSearchableStrings} from './billing/payment_info';
import PaymentInfoEdit from './billing/payment_info_edit';
import BleveSettings, {searchableStrings as bleveSearchableStrings} from './bleve_settings';
import BrandImageSetting from './brand_image_setting/brand_image_setting';
import ClusterSettings, {searchableStrings as clusterSearchableStrings} from './cluster_settings';
@@ -385,30 +383,6 @@ const AdminDefinition: AdminDefinitionType = {
isHidden: it.not(it.licensedForFeature('Cloud')),
isDisabled: it.not(it.userHasWritePermissionOnResource('billing')),
},
payment_info: {
url: 'billing/payment_info',
title: defineMessage({id: 'admin.sidebar.payment_info', defaultMessage: 'Payment Information'}),
isHidden: it.any(
it.hidePaymentInfo,
// cloud only view
it.not(it.licensedForFeature('Cloud')),
),
searchableStrings: billingPaymentInfoSearchableStrings,
schema: {
id: 'PaymentInfo',
component: PaymentInfo,
},
isDisabled: it.not(it.userHasWritePermissionOnResource('billing')),
},
payment_info_edit: {
url: 'billing/payment_info_edit',
schema: {
id: 'PaymentInfoEdit',
component: PaymentInfoEdit,
},
isDisabled: it.not(it.userHasWritePermissionOnResource('billing')),
},
},
},
reporting: {

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

@@ -1,108 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {unixTimestampFromNow} from 'tests/helpers/date';
import {renderWithContext} from 'tests/react_testing_utils';
import {CloudProducts} from 'utils/constants';
import {CloudAnnualRenewalBanner} from './billing_subscriptions';
describe('CloudAnnualRenewalBanner', () => {
const initialState = {
entities: {
general: {
license: {
IsLicensed: 'true',
Cloud: 'true',
},
},
users: {
currentUserId: 'current_user_id',
profiles: {
current_user_id: {roles: 'system_admin'},
},
},
cloud: {
subscription: {
product_id: 'test_prod_1',
trial_end_at: 1652807380,
is_free_trial: 'false',
cancel_at: 1652807380,
},
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,
},
},
},
},
};
it('should not render if subscription is not available', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = null;
const {queryByText} = renderWithContext(<CloudAnnualRenewalBanner/>, state);
expect(queryByText(/Your annual subscription expires in/)).not.toBeInTheDocument();
});
it('should render with correct title and buttons', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
end_at: unixTimestampFromNow(30),
};
const {getByText} = renderWithContext(<CloudAnnualRenewalBanner/>, state);
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();
const renewButton = getByText(/Renew/);
renewButton.click();
});
it('should render with danger mode if expiration is within 7 days', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
end_at: unixTimestampFromNow(4),
};
const {getByText, getByTestId} = renderWithContext(<CloudAnnualRenewalBanner/>, state);
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();
});
it('should render with with different title when end_at time has passed', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud.subscription = {
...state.entities.cloud.subscription,
end_at: unixTimestampFromNow(-5),
cancel_at: unixTimestampFromNow(5),
};
const {getByText, getByTestId} = renderWithContext(<CloudAnnualRenewalBanner/>, state);
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();
});
});

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

@@ -2,16 +2,10 @@
// See LICENSE.txt for license information.
import React from 'react';
import {FormattedMessage, useIntl} from 'react-intl';
import {FormattedMessage} from 'react-intl';
import BlockableLink from 'components/admin_console/blockable_link';
import type {ModeType} from 'components/alert_banner';
import AlertBanner from 'components/alert_banner';
import useGetSubscription from 'components/common/hooks/useGetSubscription';
import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import {daysToCancellation, daysToExpiration} from 'utils/cloud_utils';
export const creditCardExpiredBanner = (setShowCreditCardBanner: (value: boolean) => void) => {
return (
@@ -59,61 +53,3 @@ export const paymentFailedBanner = () => {
/>
);
};
export const CloudAnnualRenewalBanner = () => {
const openPurchaseModal = useOpenCloudPurchaseModal({});
const subscription = useGetSubscription();
const {formatMessage} = useIntl();
const [openSalesLink] = useOpenSalesLink();
if (!subscription || !subscription.cancel_at || (subscription.will_renew === 'true' && !subscription.delinquent_since)) {
return null;
}
const daysUntilExpiration = daysToExpiration(subscription);
const daysUntilCancelation = daysToCancellation(subscription);
const renewButton = (
<button
className='btn btn-primary'
onClick={() => openPurchaseModal({})}
>
{formatMessage({id: 'cloud_annual_renewal.banner.buttonText.renew', defaultMessage: 'Renew'})}
</button>
);
const contactSalesButton = (
<button
className='btn btn-tertiary'
onClick={openSalesLink}
>
{formatMessage({id: 'cloud_annual_renewal.banner.buttonText.contactSales', defaultMessage: 'Contact Sales'})}
</button>
);
const alertBannerProps = {
mode: 'info' as ModeType,
title: (<>{formatMessage({id: 'billing_subscriptions.cloud_annual_renewal_alert_banner_title', defaultMessage: 'Your annual subscription expires in {days} days. Please renew now to avoid any disruption'}, {days: daysUntilExpiration})}</>),
actionButtonLeft: renewButton,
actionButtonRight: contactSalesButton,
message: <></>,
};
// If outside the 60 day window or on a trial, don't show this banner.
if (daysUntilExpiration > 60 || subscription.is_free_trial === 'true') {
return null;
}
if (daysUntilExpiration <= 7) {
alertBannerProps.mode = 'danger';
}
if (daysUntilExpiration <= 0) {
alertBannerProps.title = <>{formatMessage({id: 'billing_subscriptions.cloud_annual_renewal_alert_banner_title_expired', defaultMessage: 'Your subscription has expired. Your workspace will be deleted in {days} days. Please renew now to avoid any disruption'}, {days: daysUntilCancelation})}</>;
}
return (
<AlertBanner
id={'cloud_annual_renewal_alert_banner_' + alertBannerProps.mode}
{...alertBannerProps}
/>
);
};

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

@@ -1,7 +1,7 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import React, {useEffect} from 'react';
import {FormattedMessage, defineMessages} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
@@ -11,7 +11,6 @@ import {getCloudSubscription, getCloudProducts, getCloudCustomer} from 'mattermo
import {
getSubscriptionProduct,
getCloudSubscription as selectCloudSubscription,
getCloudCustomer as selectCloudCustomer,
getCloudErrors,
} from 'mattermost-redux/selectors/entities/cloud';
@@ -19,29 +18,16 @@ import {pageVisited} from 'actions/telemetry_actions';
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';
import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal';
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
import AdminHeader from 'components/widgets/admin_console/admin_header';
import {isCustomerCardExpired} from 'utils/cloud_utils';
import {
TrialPeriodDays,
} from 'utils/constants';
import {useQuery} from 'utils/http_utils';
import {hasSomeLimits} from 'utils/limits';
import {getRemainingDaysFromFutureTimestamp} from 'utils/utils';
import {
CloudAnnualRenewalBanner,
creditCardExpiredBanner,
paymentFailedBanner,
} from './billing_subscriptions';
import CancelSubscription from './cancel_subscription';
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 BillingSummary from '../billing_summary';
import PlanDetails from '../plan_details';
@@ -59,18 +45,11 @@ export const searchableStrings = [
const BillingSubscriptions = () => {
const dispatch = useDispatch();
const subscription = useSelector(selectCloudSubscription);
const [cloudLimits] = useGetLimits();
const errorLoadingData = useSelector((state: GlobalState) => {
const errors = getCloudErrors(state);
return Boolean(errors.limits || errors.subscription || errors.customer || errors.products);
});
const isCardExpired = isCustomerCardExpired(useSelector(selectCloudCustomer));
const trialEndDate = subscription?.trial_end_at || 0;
const [showCreditCardBanner, setShowCreditCardBanner] = useState(true);
const query = useQuery();
const actionQueryParam = query.get('action');
@@ -78,13 +57,6 @@ const BillingSubscriptions = () => {
const openPricingModal = useOpenPricingModal();
const openCloudPurchaseModal = useOpenCloudPurchaseModal({});
// show the upgrade section when is a free tier customer
const onUpgradeMattermostCloud = (callerInfo: string) => {
openCloudPurchaseModal({trackingLocation: callerInfo});
};
let isFreeTrial = false;
let daysLeftOnTrial = 0;
if (subscription?.is_free_trial === 'true') {
@@ -103,23 +75,11 @@ const BillingSubscriptions = () => {
pageVisited('cloud_admin', 'pageview_billing_subscription');
if (actionQueryParam === 'show_purchase_modal') {
onUpgradeMattermostCloud('billing_subscriptions_external_direct_link');
}
if (actionQueryParam === 'show_pricing_modal') {
openPricingModal({trackingLocation: 'billing_subscriptions_external_direct_link'});
}
if (actionQueryParam === 'show_delinquency_modal') {
openCloudPurchaseModal({trackingLocation: 'billing_subscriptions_external_direct_link'});
}
}, []);
const shouldShowPaymentFailedBanner = () => {
return subscription?.last_invoice?.status === 'failed';
};
// handle not loaded yet here, failed to load handled below
if ((!subscription || !product) && !errorLoadingData) {
return null;
@@ -134,15 +94,6 @@ const BillingSubscriptions = () => {
<div className='admin-console__content'>
{errorLoadingData && <CloudFetchError/>}
{!errorLoadingData && <>
<LimitReachedBanner
product={product}
/>
{shouldShowPaymentFailedBanner() && paymentFailedBanner()}
{<CloudAnnualRenewalBanner/>}
{<ToPaidNudgeBanner/>}
{showCreditCardBanner &&
isCardExpired &&
creditCardExpiredBanner(setShowCreditCardBanner)}
{isFreeTrial && <CloudTrialBanner trialEndDate={trialEndDate}/>}
<div className='BillingSubscriptions__topWrapper'>
<PlanDetails
@@ -152,19 +103,13 @@ const BillingSubscriptions = () => {
<BillingSummary
isFreeTrial={isFreeTrial}
daysLeftOnTrial={daysLeftOnTrial}
onUpgradeMattermostCloud={onUpgradeMattermostCloud}
/>
</div>
{hasSomeLimits(cloudLimits) && !isFreeTrial ? (
<Limits/>
) : (
<ContactSalesCard
isFreeTrial={isFreeTrial}
subscriptionPlan={product?.sku}
onUpgradeMattermostCloud={openPricingModal}
/>
)}
<CancelSubscription/>
<ContactSalesCard
isFreeTrial={isFreeTrial}
subscriptionPlan={product?.sku}
onUpgradeMattermostCloud={openPricingModal}
/>
</>}
</div>
</div>

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

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

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

@@ -1,193 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {GlobalState} from '@mattermost/types/store';
import type {UserProfile, UsersState} from '@mattermost/types/users';
import {Preferences} from 'mattermost-redux/constants';
import {getPreferenceKey} from 'mattermost-redux/utils/preference_utils';
import * as useGetUsageDeltas from 'components/common/hooks/useGetUsageDeltas';
import * as useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal';
import * as useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
import * as useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import * as useSaveBool from 'components/common/hooks/useSavePreferences';
import {fireEvent, renderWithContext, screen} from 'tests/react_testing_utils';
import {CloudProducts} from 'utils/constants';
import LimitReachedBanner from './limit_reached_banner';
const upgradeCloudKey = getPreferenceKey(Preferences.CATEGORY_UPGRADE_CLOUD, Preferences.SYSTEM_CONSOLE_LIMIT_REACHED);
const state: GlobalState = {
entities: {
users: {
currentUserId: 'userid',
profiles: {
userid: {} as UserProfile,
},
} as unknown as UsersState,
preferences: {
myPreferences: {
[upgradeCloudKey]: {value: 'false'},
},
},
cloud: {
limits: {
},
products: {
},
},
general: {
license: {
},
config: {
},
},
},
} as GlobalState;
const base = {
id: '',
name: '',
description: '',
price_per_seat: 0,
add_ons: [],
product_family: '',
billing_scheme: '',
recurring_interval: '',
cross_sells_to: '',
};
const free = {...base, sku: CloudProducts.STARTER};
const enterprise = {...base, sku: CloudProducts.ENTERPRISE};
const noLimitReached = {
files: {
totalStorage: -1,
totalStorageLoaded: true,
},
messages: {
history: -1,
historyLoaded: true,
},
boards: {
cards: -1,
cardsLoaded: true,
},
teams: {
active: -1,
cloudArchived: -1,
teamsLoaded: true,
},
integrations: {
enabled: -1,
enabledLoaded: true,
},
};
const someLimitReached = {
...noLimitReached,
integrations: {
...noLimitReached.integrations,
enabled: 1,
},
};
const titleFree = /Upgrade to one of our paid plans to avoid/;
const titleProfessional = /Upgrade to Enterprise to avoid Professional plan/;
function makeSpies() {
const mockUseOpenSalesLink = jest.spyOn(useOpenSalesLink, 'default');
const mockUseGetUsageDeltas = jest.spyOn(useGetUsageDeltas, 'default');
const mockUseOpenCloudPurchaseModal = jest.spyOn(useOpenCloudPurchaseModal, 'default');
const mockUseOpenPricingModal = jest.spyOn(useOpenPricingModal, 'default');
const mockUseSaveBool = jest.spyOn(useSaveBool, 'useSaveBool');
return {
useOpenSalesLink: mockUseOpenSalesLink,
useGetUsageDeltas: mockUseGetUsageDeltas,
useOpenCloudPurchaseModal: mockUseOpenCloudPurchaseModal,
useOpenPricingModal: mockUseOpenPricingModal,
useSaveBool: mockUseSaveBool,
};
}
describe('limits_reached_banner', () => {
test('does not render when product is enterprise', () => {
const spies = makeSpies();
spies.useGetUsageDeltas.mockReturnValue(someLimitReached);
renderWithContext(<LimitReachedBanner product={enterprise}/>, state);
expect(screen.queryByText(titleFree)).not.toBeInTheDocument();
expect(screen.queryByText(titleProfessional)).not.toBeInTheDocument();
});
test('does not render when banner was dismissed', () => {
const myState = {
...state,
entities: {
...state.entities,
preferences: {
...state.entities.preferences,
myPreferences: {
...state.entities.preferences.myPreferences,
[upgradeCloudKey]: {value: 'true'},
},
},
},
};
const spies = makeSpies();
spies.useGetUsageDeltas.mockReturnValue(someLimitReached);
renderWithContext(<LimitReachedBanner product={enterprise}/>, myState);
expect(screen.queryByText(titleFree)).not.toBeInTheDocument();
expect(screen.queryByText(titleProfessional)).not.toBeInTheDocument();
});
test('does not render when no limit reached', () => {
const spies = makeSpies();
spies.useGetUsageDeltas.mockReturnValue(noLimitReached);
renderWithContext(<LimitReachedBanner product={free}/>, state);
expect(screen.queryByText(titleFree)).not.toBeInTheDocument();
expect(screen.queryByText(titleProfessional)).not.toBeInTheDocument();
});
test('renders free banner', () => {
const spies = makeSpies();
const mockOpenPricingModal = jest.fn();
spies.useOpenPricingModal.mockReturnValue(mockOpenPricingModal);
spies.useGetUsageDeltas.mockReturnValue(someLimitReached);
renderWithContext(<LimitReachedBanner product={free}/>, state);
screen.getByText(titleFree);
expect(screen.queryByText(titleProfessional)).not.toBeInTheDocument();
fireEvent.click(screen.getByText('View plans'));
expect(mockOpenPricingModal).toHaveBeenCalled();
});
test('clicking Contact Sales opens sales link', () => {
const spies = makeSpies();
const mockOpenSalesLink = jest.fn();
spies.useOpenSalesLink.mockReturnValue([mockOpenSalesLink, '']);
spies.useGetUsageDeltas.mockReturnValue(someLimitReached);
renderWithContext(<LimitReachedBanner product={free}/>, state);
screen.getByText(titleFree);
expect(screen.queryByText(titleProfessional)).not.toBeInTheDocument();
fireEvent.click(screen.getByText('Contact sales'));
expect(mockOpenSalesLink).toHaveBeenCalled();
});
});

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

@@ -1,105 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {useIntl, FormattedMessage} from 'react-intl';
import {useSelector} from 'react-redux';
import type {Product} from '@mattermost/types/cloud';
import {Preferences} from 'mattermost-redux/constants';
import {getHasDismissedSystemConsoleLimitReached} from 'mattermost-redux/selectors/entities/preferences';
import AlertBanner from 'components/alert_banner';
import useGetUsageDeltas from 'components/common/hooks/useGetUsageDeltas';
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import {useSaveBool} from 'components/common/hooks/useSavePreferences';
import {CloudProducts} from 'utils/constants';
import {anyUsageDeltaExceededLimit} from 'utils/limits';
import './limit_reached_banner.scss';
interface Props {
product?: Product;
}
const LimitReachedBanner = (props: Props) => {
const intl = useIntl();
const someLimitExceeded = anyUsageDeltaExceededLimit(useGetUsageDeltas());
const hasDismissedBanner = useSelector(getHasDismissedSystemConsoleLimitReached);
const [openSalesLink] = useOpenSalesLink();
const openPricingModal = useOpenPricingModal();
const saveBool = useSaveBool();
if (hasDismissedBanner || !someLimitExceeded || !props.product || (props.product.sku !== CloudProducts.STARTER)) {
return null;
}
const title = (
<FormattedMessage
id='workspace_limits.banner_upgrade.free'
defaultMessage='Upgrade to one of our paid plans to avoid {planName} plan data limits'
values={{
planName: props.product.name,
}}
/>
);
const description = (
<FormattedMessage
id='workspace_limits.banner_upgrade_reason.free'
defaultMessage='Your workspace has exceeded {planName} plan data limits. Upgrade to a paid plan for additional capacity.'
values={{
planName: props.product.name,
}}
/>
);
const upgradeMessage = {
id: 'workspace_limits.modals.view_plans',
defaultMessage: 'View plans',
};
const upgradeAction = () => openPricingModal({trackingLocation: 'limit_reached_banner'});
const onDismiss = () => {
saveBool({
category: Preferences.CATEGORY_UPGRADE_CLOUD,
name: Preferences.SYSTEM_CONSOLE_LIMIT_REACHED,
value: true,
});
};
return (
<AlertBanner
mode='danger'
title={title}
message={description}
onDismiss={onDismiss}
className='LimitReachedBanner'
>
<div className='LimitReachedBanner__actions'>
<button
onClick={upgradeAction}
className='btn LimitReachedBanner__primary'
>
{intl.formatMessage(upgradeMessage)}
</button>
<button
onClick={openSalesLink}
className='btn LimitReachedBanner__contact-sales'
>
{intl.formatMessage({
id: 'admin.license.trialCard.contactSales',
defaultMessage: 'Contact sales',
})}
</button>
</div>
</AlertBanner>
);
};
export default LimitReachedBanner;

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

@@ -6,7 +6,7 @@ import React from 'react';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import {CloudProducts} from 'utils/constants';
import {ToPaidNudgeBanner, ToPaidPlanBannerDismissable} from './to_paid_plan_nudge_banner';
import {ToPaidPlanBannerDismissable} from './to_paid_plan_nudge_banner';
const initialState = {
views: {
@@ -171,68 +171,3 @@ describe('ToPaidPlanBannerDismissable', () => {
expect(() => screen.getByTestId('cloud-free-deprecation-announcement-bar')).toThrow();
});
});
describe('ToPaidNudgeBanner', () => {
test('should show only for cloud free', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud = {
subscription: {
product_id: 'prod_starter',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_starter: {
id: 'prod_starter',
sku: CloudProducts.STARTER,
},
},
};
renderWithContext(<ToPaidNudgeBanner/>, state, {useMockedStore: true});
screen.getByTestId('cloud-free-deprecation-alert-banner');
});
test('should NOT show for cloud professional', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud = {
subscription: {
product_id: 'prod_pro',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_pro: {
id: 'prod_pro',
sku: CloudProducts.PROFESSIONAL,
},
},
};
renderWithContext(<ToPaidNudgeBanner/>, state, {useMockedStore: true});
expect(() => screen.getByTestId('cloud-free-deprecation-alert-banner')).toThrow();
});
test('should NOT show for cloud enterprise', () => {
const state = JSON.parse(JSON.stringify(initialState));
state.entities.cloud = {
subscription: {
product_id: 'prod_ent',
is_free_trial: 'false',
trial_end_at: 1,
},
products: {
prod_ent: {
id: 'prod_ent',
sku: CloudProducts.ENTERPRISE,
},
},
};
renderWithContext(<ToPaidNudgeBanner/>, state, {useMockedStore: true});
expect(() => screen.getByTestId('cloud-free-deprecation-alert-banner')).toThrow();
});
});

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

@@ -3,7 +3,7 @@
import moment from 'moment';
import React, {useEffect} from 'react';
import {useIntl, FormattedMessage} from 'react-intl';
import {FormattedMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import type {GlobalState} from '@mattermost/types/store';
@@ -13,11 +13,8 @@ import {getSubscriptionProduct as selectSubscriptionProduct} from 'mattermost-re
import {deprecateCloudFree, 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 useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import {AnnouncementBarTypes, CloudBanners, CloudProducts, Preferences} from 'utils/constants';
import {t} from 'utils/i18n';
@@ -174,73 +171,3 @@ export const ToPaidPlanBannerDismissable = () => {
/>
);
};
export const ToPaidNudgeBanner = () => {
const {formatMessage} = useIntl();
const [openSalesLink] = useOpenSalesLink();
const openPurchaseModal = useOpenCloudPurchaseModal({});
const product = useSelector(selectSubscriptionProduct);
const cloudFreeDeprecated = useSelector(deprecateCloudFree);
const currentProductStarter = product?.sku === CloudProducts.STARTER;
if (!cloudFreeDeprecated) {
return null;
}
if (!currentProductStarter) {
return null;
}
const now = moment(Date.now());
const cloudFreeEndDate = moment(cloudFreeCloseMoment, 'YYYYMMDD');
const daysToCloudFreeEnd = cloudFreeEndDate.diff(now, 'days');
const title = (
<FormattedMessage
id='cloud_billing.nudge_to_paid.title'
defaultMessage='Upgrade to paid plan to keep your workspace'
/>
);
const description = (
<FormattedMessage
id='cloud_billing.nudge_to_paid.description'
defaultMessage='Cloud Free will be deprecated in {days} days. Upgrade to a paid plan or contact sales.'
values={{days: daysToCloudFreeEnd < 0 ? 0 : daysToCloudFreeEnd}}
/>
);
const viewPlansAction = (
<button
onClick={() => openPurchaseModal({trackingLocation: 'to_paid_plan_nudge_banner'})}
className='btn ToPaidNudgeBanner__primary'
>
{formatMessage({id: 'cloud_billing.nudge_to_paid.learn_more', defaultMessage: 'Upgrade'})}
</button>
);
const contactSalesAction = (
<button
onClick={openSalesLink}
className='btn ToPaidNudgeBanner__secondary'
>
{formatMessage({id: 'cloud_billing.nudge_to_paid.contact_sales', defaultMessage: 'Contact sales'})}
</button>
);
const bannerMode = (daysToCloudFreeEnd <= 10) ? 'danger' : 'info';
return (
<AlertBanner
id='cloud-free-deprecation-alert-banner'
mode={bannerMode}
title={title}
message={description}
className='ToYearlyNudgeBanner'
actionButtonLeft={viewPlansAction}
actionButtonRight={contactSalesAction}
/>
);
};

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

@@ -5,7 +5,7 @@ import React from 'react';
import {FormattedDate, FormattedMessage, FormattedNumber, defineMessages} from 'react-intl';
import {useDispatch} from 'react-redux';
import {CheckCircleOutlineIcon, CheckIcon, ClockOutlineIcon} from '@mattermost/compass-icons/components';
import {CheckCircleOutlineIcon} from '@mattermost/compass-icons/components';
import type {Invoice, InvoiceLineItem, Product} from '@mattermost/types/cloud';
import {Client4} from 'mattermost-redux/client';
@@ -15,6 +15,7 @@ import {openModal} from 'actions/views/modals';
import BlockableLink from 'components/admin_console/blockable_link';
import CloudInvoicePreview from 'components/cloud_invoice_preview';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import EmptyBillingHistorySvg from 'components/common/svg_images_components/empty_billing_history_svg';
import UpgradeSvg from 'components/common/svg_images_components/upgrade_svg';
import ExternalLink from 'components/external_link';
@@ -65,118 +66,81 @@ export const noBillingHistory = (
</div>
);
export const freeTrial = (onUpgradeMattermostCloud: (callerInfo: string) => void, daysLeftOnTrial: number, reverseTrial: boolean) => (
<div className='UpgradeMattermostCloud'>
<div className='UpgradeMattermostCloud__image'>
<UpgradeSvg
height={167}
width={234}
type FreeTrialProps = {
daysLeftOnTrial: number;
}
export const FreeTrial = ({daysLeftOnTrial}: FreeTrialProps) => {
const [openSalesLink] = useOpenSalesLink();
return (
<div className='UpgradeMattermostCloud'>
<div className='UpgradeMattermostCloud__image'>
<UpgradeSvg
height={167}
width={234}
/>
</div>
<div className='UpgradeMattermostCloud__title'>
{daysLeftOnTrial > TrialPeriodDays.TRIAL_1_DAY &&
<FormattedMessage
id='admin.billing.subscription.freeTrial.title'
defaultMessage={'You\'re currently on a free trial'}
/>
}
{(daysLeftOnTrial === TrialPeriodDays.TRIAL_1_DAY || daysLeftOnTrial === TrialPeriodDays.TRIAL_0_DAYS) &&
<FormattedMessage
id='admin.billing.subscription.freeTrial.lastDay.title'
defaultMessage={'Your free trial ends today'}
/>
}
</div>
<div className='UpgradeMattermostCloud__description'>
{daysLeftOnTrial > TrialPeriodDays.TRIAL_WARNING_THRESHOLD &&
<FormattedMessage
id='admin.billing.subscription.freeTrial.description'
defaultMessage='Your free trial will expire in {daysLeftOnTrial} days. Add your payment information to continue after the trial ends.'
values={{daysLeftOnTrial}}
/>
}
{(daysLeftOnTrial > TrialPeriodDays.TRIAL_1_DAY && daysLeftOnTrial <= TrialPeriodDays.TRIAL_WARNING_THRESHOLD) &&
<FormattedMessage
id='admin.billing.subscription.freeTrial.lessThan3Days.description'
defaultMessage='Your free trial will end in {daysLeftOnTrial, number} {daysLeftOnTrial, plural, one {day} other {days}}. Add payment information to continue enjoying the benefits of Cloud Professional.'
values={{daysLeftOnTrial}}
/>
}
{(daysLeftOnTrial === TrialPeriodDays.TRIAL_1_DAY || daysLeftOnTrial === TrialPeriodDays.TRIAL_0_DAYS) &&
<FormattedMessage
id='admin.billing.subscription.freeTrial.lastDay.description'
defaultMessage='Your free trial has ended. Add payment information to continue enjoying the benefits of Cloud Professional.'
/>
}
</div>
<button
type='button'
onClick={() => openSalesLink()}
className='UpgradeMattermostCloud__upgradeButton'
>
<FormattedMessage
id='admin.billing.subscription.privateCloudCard.contactSales'
defaultMessage='Contact Sales'
/>
</button>
</div>);
};
export const getPaymentStatus = () => {
return (
<div className='BillingSummary__lastInvoice-headerStatus paid'>
<CheckCircleOutlineIcon/> {' '}
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.paid'
defaultMessage='Paid'
/>
</div>
<div className='UpgradeMattermostCloud__title'>
{daysLeftOnTrial > TrialPeriodDays.TRIAL_1_DAY &&
<FormattedMessage
id='admin.billing.subscription.freeTrial.title'
defaultMessage={'You\'re currently on a free trial'}
/>
}
{(daysLeftOnTrial === TrialPeriodDays.TRIAL_1_DAY || daysLeftOnTrial === TrialPeriodDays.TRIAL_0_DAYS) &&
<FormattedMessage
id='admin.billing.subscription.freeTrial.lastDay.title'
defaultMessage={'Your free trial ends today'}
/>
}
</div>
<div className='UpgradeMattermostCloud__description'>
{daysLeftOnTrial > TrialPeriodDays.TRIAL_WARNING_THRESHOLD &&
<FormattedMessage
id='admin.billing.subscription.freeTrial.description'
defaultMessage='Your free trial will expire in {daysLeftOnTrial} days. Add your payment information to continue after the trial ends.'
values={{daysLeftOnTrial}}
/>
}
{(daysLeftOnTrial > TrialPeriodDays.TRIAL_1_DAY && daysLeftOnTrial <= TrialPeriodDays.TRIAL_WARNING_THRESHOLD) &&
<FormattedMessage
id='admin.billing.subscription.freeTrial.lessThan3Days.description'
defaultMessage='Your free trial will end in {daysLeftOnTrial, number} {daysLeftOnTrial, plural, one {day} other {days}}. Add payment information to continue enjoying the benefits of Cloud Professional.'
values={{daysLeftOnTrial}}
/>
}
{(daysLeftOnTrial === TrialPeriodDays.TRIAL_1_DAY || daysLeftOnTrial === TrialPeriodDays.TRIAL_0_DAYS) &&
<FormattedMessage
id='admin.billing.subscription.freeTrial.lastDay.description'
defaultMessage='Your free trial has ended. Add payment information to continue enjoying the benefits of Cloud Professional.'
/>
}
</div>
<button
type='button'
onClick={() => onUpgradeMattermostCloud('billing_summary_free_trial_upgrade_button')}
className='UpgradeMattermostCloud__upgradeButton'
>
{
reverseTrial ? (
<FormattedMessage
id='admin.billing.subscription.cloudTrial.purchaseButton'
defaultMessage='Purchase Now'
/>
) : (
<FormattedMessage
id='admin.billing.subscription.cloudTrial.subscribeButton'
defaultMessage='Upgrade Now'
/>
)
}
</button>
</div>
);
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'
/>
</div>
);
case 'paid':
return (
<div className='BillingSummary__lastInvoice-headerStatus paid'>
<CheckCircleOutlineIcon/> {' '}
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.paid'
defaultMessage='Paid'
/>
</div>
);
default:
return (
<div className='BillingSummary__lastInvoice-headerStatus pending'>
<ClockOutlineIcon/> {' '}
<FormattedMessage
id='admin.billing.subscriptions.billing_summary.lastInvoice.pending'
defaultMessage='Pending'
/>
</div>
);
}
);
};
type InvoiceInfoProps = {
@@ -185,10 +149,9 @@ type InvoiceInfoProps = {
fullCharges: InvoiceLineItem[];
partialCharges: InvoiceLineItem[];
hasMore?: number;
willRenew?: boolean;
}
export const InvoiceInfo = ({invoice, product, fullCharges, partialCharges, hasMore, willRenew}: InvoiceInfoProps) => {
export const InvoiceInfo = ({invoice, product, fullCharges, partialCharges, hasMore}: InvoiceInfoProps) => {
const dispatch = useDispatch();
const isUpcomingInvoice = invoice?.status.toLowerCase() === 'upcoming';
@@ -225,7 +188,7 @@ export const InvoiceInfo = ({invoice, product, fullCharges, partialCharges, hasM
<div className='BillingSummary__lastInvoice-headerTitle'>
{title()}
</div>
{getPaymentStatus(invoice.status, willRenew)}
{getPaymentStatus()}
</div>
<div className='BillingSummary__lastInvoice-date'>
<FormattedDate

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

@@ -2,83 +2,30 @@
// See LICENSE.txt for license information.
import React from 'react';
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 {
noBillingHistory,
InvoiceInfo,
freeTrial,
FreeTrial,
} from './billing_summary';
import {tryEnterpriseCard, UpgradeToProfessionalCard} from './upsell_card';
import './billing_summary.scss';
type BillingSummaryProps = {
isFreeTrial: boolean;
daysLeftOnTrial: number;
onUpgradeMattermostCloud: (callerInfo: string) => void;
}
const BillingSummary = ({isFreeTrial, daysLeftOnTrial, onUpgradeMattermostCloud}: BillingSummaryProps) => {
const subscription = useSelector(getCloudSubscription);
const product = useSelector(getSubscriptionProduct);
const reverseTrial = useSelector(cloudReverseTrial);
export default function BillingSummary({isFreeTrial, daysLeftOnTrial}: BillingSummaryProps) {
let body = noBillingHistory;
const isPreTrial = subscription?.is_free_trial === 'false' && subscription?.trial_end_at === 0;
const hasPriorTrial = useSelector(checkHadPriorTrial);
const isStarterPreTrial = product?.sku === CloudProducts.STARTER && isPreTrial;
const isStarterPostTrial = product?.sku === CloudProducts.STARTER && hasPriorTrial;
if (isStarterPreTrial && reverseTrial) {
body = <UpgradeToProfessionalCard/>;
} else if (isStarterPreTrial) {
body = tryEnterpriseCard;
} else if (isStarterPostTrial) {
body = <UpgradeToProfessionalCard/>;
} else if (isFreeTrial) {
body = freeTrial(onUpgradeMattermostCloud, daysLeftOnTrial, reverseTrial);
} else if (subscription?.last_invoice && !subscription?.upcoming_invoice) {
const invoice = subscription.last_invoice;
const fullCharges = invoice.line_items.filter((item) => item.type === 'full');
const partialCharges = invoice.line_items.filter((item) => item.type === 'partial');
body = (
<InvoiceInfo
invoice={invoice}
product={product}
fullCharges={fullCharges}
partialCharges={partialCharges}
/>
);
} else if (subscription?.upcoming_invoice) {
const invoice = subscription.upcoming_invoice;
const {fullCharges, partialCharges, hasMore} = buildInvoiceSummaryPropsFromLineItems(invoice.line_items);
body = (
<InvoiceInfo
invoice={invoice}
product={product}
fullCharges={fullCharges}
partialCharges={partialCharges}
hasMore={hasMore}
willRenew={subscription?.will_renew === 'true'}
/>
);
if (isFreeTrial) {
// eslint-disable-next-line new-cap
body = FreeTrial({daysLeftOnTrial});
}
return (
<div className='BillingSummary'>
{body}
</div>
);
};
}
export default BillingSummary;

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

@@ -6,7 +6,6 @@ import React from 'react';
import {useIntl} from 'react-intl';
import CloudStartTrialButton from 'components/cloud_start_trial/cloud_start_trial_btn';
import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal';
import WomanUpArrowsAndCloudsSvg from 'components/common/svg_images_components/woman_up_arrows_and_clouds_svg';
import StartTrialCaution from 'components/pricing_modal/start_trial_caution';
@@ -31,9 +30,6 @@ const enterpriseAdvantages = [
},
];
// Currently, these are the same. In the future, they may diverge.
const professionalAdvantages = enterpriseAdvantages;
interface Props {
advantages: Message[];
title: Message;
@@ -149,26 +145,6 @@ export const tryEnterpriseCard = (
/>
);
export const UpgradeToProfessionalCard = () => {
const openPurchaseModal = useOpenCloudPurchaseModal({});
return (
<UpsellCard
title={{
id: t('admin.billing.subscriptions.billing_summary.upgrade_professional'),
defaultMessage: 'Upgrade to the Professional Plan',
}}
cta={{
id: t('admin.billing.subscriptions.billing_summary.upgrade_professional.cta'),
defaultMessage: 'Upgrade',
}}
ctaAction={() => openPurchaseModal({trackingLocation: 'billing_summary_upsell_professional_card'})}
ctaPrimary={true}
andMore={true}
advantages={professionalAdvantages}
/>
);
};
export const ExploreEnterpriseCard = () => {
return (
<UpsellCard

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

@@ -1,3 +0,0 @@
.PaymentInfo .AlertBanner {
margin-bottom: 20px;
}

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

@@ -1,98 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useState} from 'react';
import {FormattedMessage, defineMessages} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import type {GlobalState} from '@mattermost/types/store';
import {getCloudCustomer} from 'mattermost-redux/actions/cloud';
import {getCloudErrors} from 'mattermost-redux/selectors/entities/cloud';
import {pageVisited} from 'actions/telemetry_actions';
import AlertBanner from 'components/alert_banner';
import CloudFetchError from 'components/cloud_fetch_error';
import AdminHeader from 'components/widgets/admin_console/admin_header';
import PaymentInfoDisplay from './payment_info_display';
import './payment_info.scss';
type Props = Record<string, never>;
const messages = defineMessages({
title: {id: 'admin.billing.payment_info.title', defaultMessage: 'Payment Information'},
});
export const searchableStrings = [
messages.title,
];
const PaymentInfo: React.FC<Props> = () => {
const dispatch = useDispatch();
const {customer: customerError} = useSelector(getCloudErrors);
const isCardAboutToExpire = useSelector((state: GlobalState) => {
const {customer} = state.entities.cloud;
if (!customer) {
return false;
}
const expiryYear = customer.payment_method.exp_year;
// If not expiry year, or its 0, it's not expired (because it probably isn't set)
if (!expiryYear) {
return false;
}
// This works because we store the expiry month as the actual 1-12 base month, but Date uses a 0-11 base month
// But credit cards expire at the end of their expiry month, so we can just use that number.
const lastExpiryDate = new Date(expiryYear, customer.payment_method.exp_month, 1);
const currentDatePlus10Days = new Date();
currentDatePlus10Days.setDate(currentDatePlus10Days.getDate() + 10);
return lastExpiryDate <= currentDatePlus10Days;
});
const [showCreditCardBanner, setShowCreditCardBanner] = useState(true);
useEffect(() => {
dispatch(getCloudCustomer());
pageVisited('cloud_admin', 'pageview_billing_payment_info');
}, []);
return (
<div className='wrapper--fixed PaymentInfo'>
<AdminHeader>
<FormattedMessage {...messages.title}/>
</AdminHeader>
<div className='admin-console__wrapper'>
<div className='admin-console__content'>
{showCreditCardBanner && isCardAboutToExpire && (
<AlertBanner
mode='info'
title={
<FormattedMessage
id='admin.billing.payment_info.creditCardAboutToExpire'
defaultMessage='Your credit card is about to expire'
/>
}
message={
<FormattedMessage
id='admin.billing.payment_info.creditCardAboutToExpire.description'
defaultMessage='Please update your payment information to avoid any disruption.'
/>
}
onDismiss={() => setShowCreditCardBanner(false)}
/>
)}
{customerError ? <CloudFetchError/> : <PaymentInfoDisplay/>}
</div>
</div>
</div>
);
};
export default PaymentInfo;

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

@@ -1,149 +0,0 @@
.PaymentInfoDisplay {
border: 1px solid rgba(var(--sys-center-channel-color-rgb), 0.08);
border-radius: 4px;
background-color: var(--sys-center-channel-bg);
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.08);
color: var(--sys-center-channel-color);
}
.PaymentInfoDisplay__header {
display: flex;
align-items: center;
padding: 28px 32px 24px 32px;
border-bottom: 1px solid rgba(var(--sys-center-channel-color-rgb), 0.08);
}
.PaymentInfoDisplay__headerText-top {
font-size: 16px;
font-weight: 600;
line-height: 24px;
}
.PaymentInfoDisplay__headerText-bottom {
font-size: 14px;
line-height: 20px;
}
.PaymentInfoDisplay__addInfo {
margin-left: auto;
}
.PaymentInfoDisplay__addInfoButton {
display: flex;
align-items: center;
padding: 8px 16px;
border-radius: 4px;
background: var(--sys-button-bg);
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);
text-decoration: none;
}
&:active {
background: linear-gradient(0deg, rgba(var(--sys-center-channel-color-rgb), 0.32), rgba(var(--sys-center-channel-color-rgb), 0.32)), var(--sys-button-bg);
color: var(--sys-button-color);
text-decoration: none;
}
&:focus {
box-shadow: inset 0 0 0 2px var(--sys-sidebar-text-active-border);
color: var(--sys-button-color);
text-decoration: none;
}
&.disabled {
background: rgba(var(--sys-center-channel-color-rgb), 0.08);
color: rgba(var(--sys-center-channel-color-rgb), 0.32);
}
> i {
font-size: 14.4px;
line-height: 17px;
&::before {
margin: 0;
}
}
> span {
margin-left: 4px;
font-size: 12px;
font-weight: 600;
line-height: 9px;
}
}
.PaymentInfoDisplay__noPaymentInfo {
display: flex;
flex-direction: column;
align-items: center;
padding: 48px;
}
.PaymentInfoDisplay__noPaymentInfo-message {
margin-top: 24px;
color: var(--sys-center-channel-color);
font-size: 14px;
line-height: 20px;
}
.PaymentInfoDisplay__noPaymentInfo-link {
margin-top: 20px;
margin-bottom: 12px;
color: var(--sys-button-bg);
font-size: 14px;
font-weight: 600;
line-height: 14px;
}
.PaymentInfoDisplay__paymentInfo {
display: flex;
padding: 28px 45px 30px 32px;
color: var(--sys-center-channel-color);
font-size: 14px;
line-height: 20px;
.CardImage {
max-width: 55px;
max-height: 37px;
}
}
.PaymentInfoDisplay__paymentInfo-name {
font-weight: 600;
}
.PaymentInfoDisplay__paymentInfo-addressTitle {
margin-top: 16px;
font-weight: 600;
}
.PaymentInfoDisplay__paymentInfo-address > div {
margin-top: 4px;
}
.PaymentInfoDisplay__paymentInfo-edit {
margin-left: auto;
}
.PaymentInfoDisplay__paymentInfo-editButton {
padding: 2px;
border-radius: 4px;
margin-left: 12px;
color: rgba(var(--sys-center-channel-color-rgb), 0.75);
font-size: 18px;
&:hover,
&:focus {
background: rgba(var(--sys-center-channel-color-rgb), 0.08);
color: rgba(var(--sys-center-channel-color-rgb), 0.75);
text-decoration: none;
}
}
.PaymentInfoDisplay__paymentInfo-cardInfo::first-letter {
text-transform: capitalize;
}

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

@@ -1,123 +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 {trackEvent} from 'actions/telemetry_actions';
import BlockableLink from 'components/admin_console/blockable_link';
import useGetSubscription from 'components/common/hooks/useGetSubscription';
import useOpenCloudPurchaseModal from 'components/common/hooks/useOpenCloudPurchaseModal';
import CreditCardSvg from 'components/common/svg_images_components/credit_card_svg';
import type {GlobalState} from 'types/store';
import PaymentDetails from './payment_details';
import './payment_info_display.scss';
const addInfoButton = (
<div className='PaymentInfoDisplay__addInfo'>
<BlockableLink
to='/admin_console/billing/payment_info_edit'
className='PaymentInfoDisplay__addInfoButton'
onClick={() => trackEvent('cloud_admin', 'click_add_credit_card')}
>
<i className='icon icon-plus'/>
<FormattedMessage
id='admin.billing.payment_info.add'
defaultMessage='Add a Credit Card'
/>
</BlockableLink>
</div>
);
const noPaymentInfoSection = (
<div className='PaymentInfoDisplay__noPaymentInfo'>
<CreditCardSvg
width={280}
height={190}
/>
<div className='PaymentInfoDisplay__noPaymentInfo-message'>
<FormattedMessage
id='admin.billing.payment_info_display.noPaymentInfo'
defaultMessage='There are currently no credit cards on file.'
/>
</div>
<BlockableLink
to='/admin_console/billing/payment_info_edit'
className='PaymentInfoDisplay__noPaymentInfo-link'
onClick={() => trackEvent('cloud_admin', 'click_add_credit_card')}
>
<FormattedMessage
id='admin.billing.payment_info.add'
defaultMessage='Add a Credit Card'
/>
</BlockableLink>
</div>
);
const PaymentInfoDisplay: React.FC = () => {
const paymentInfo = useSelector((state: GlobalState) => state.entities.cloud.customer);
const subscription = useGetSubscription();
const openPurchaseModal = useOpenCloudPurchaseModal({});
if (!paymentInfo || !subscription) {
return null;
}
let body = noPaymentInfoSection;
if (paymentInfo?.payment_method && paymentInfo?.billing_address) {
body = (
<div className='PaymentInfoDisplay__paymentInfo'>
<PaymentDetails/>
<div className='PaymentInfoDisplay__paymentInfo-edit'>
{subscription.delinquent_since ? (
<div
className='PaymentInfoDisplay__paymentInfo-editButton'
onClick={() => openPurchaseModal({trackingLocation: 'edit_payment_info'})}
>
<i className='icon icon-pencil-outline'/>
</div>
) : (
<BlockableLink
to='/admin_console/billing/payment_info_edit'
className='PaymentInfoDisplay__paymentInfo-editButton'
>
<i className='icon icon-pencil-outline'/>
</BlockableLink>
)}
</div>
</div>
);
}
return (
<div className='PaymentInfoDisplay'>
<div className='PaymentInfoDisplay__header'>
<div className='PaymentInfoDisplay__headerText'>
<div className='PaymentInfoDisplay__headerText-top'>
<FormattedMessage
id='admin.billing.payment_info_display.savedPaymentDetails'
defaultMessage='Your saved payment details'
/>
</div>
<div className='PaymentInfoDisplay__headerText-bottom'>
<FormattedMessage
id='admin.billing.payment_info_display.allCardsAccepted'
defaultMessage='All major credit cards are accepted.'
/>
</div>
</div>
{!(paymentInfo?.payment_method && paymentInfo?.billing_address) && addInfoButton}
</div>
<div className='PaymentInfoDisplay__body'>
{body}
</div>
</div>
);
};
export default PaymentInfoDisplay;

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

@@ -1,57 +0,0 @@
.PaymentInfoEdit__card {
padding: 28px 32px;
border: 1px solid rgba(var(--sys-center-channel-color-rgb), 0.08);
border-radius: 4px;
margin-top: 20px;
background-color: var(--sys-center-channel-bg);
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.08);
}
.PaymentInfoEdit__paymentForm {
max-width: 480px;
padding: 0;
margin: 0;
.section-title:not(:first-child) {
font-size: 14px;
line-height: 20px;
}
.section-title {
color: var(--sys-center-channel-text);
}
.Input_fieldset {
background: var(--sys-center-channel-bg);
}
&.Input_fieldset:focus-within {
box-shadow: inset 0 0 0 2px var(--sys-button-bg);
}
&.Input_fieldset___error {
box-shadow: inset 0 0 0 1px var(--sys-error-text);
color: var(--sys-error-text);
}
&.Input_fieldset___error:focus-within {
box-shadow: inset 0 0 0 2px var(--sys-error-text);
color: var(--sys-error-text);
}
.Input::placeholder {
color: var(--sys-center-channel-color);
}
}
.PaymentInfoEdit__error {
display: flex;
align-items: center;
color: var(--sys-error-text);
font-size: 12px;
i {
margin-right: 4px;
font-size: 14.4px;
}
}

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

@@ -1,194 +0,0 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import {Elements} from '@stripe/react-stripe-js';
import type {Stripe} from '@stripe/stripe-js';
import {loadStripe} from '@stripe/stripe-js/pure'; // https://github.com/stripe/stripe-js#importing-loadstripe-without-side-effects
import React, {useEffect, useState} from 'react';
import {FormattedMessage} from 'react-intl';
import {useDispatch, useSelector} from 'react-redux';
import {useHistory} from 'react-router-dom';
import {getCloudCustomer} from 'mattermost-redux/actions/cloud';
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
import {completeStripeAddPaymentMethod} from 'actions/cloud';
import {isCwsMockMode} from 'selectors/cloud';
import BlockableLink from 'components/admin_console/blockable_link';
import AlertBanner from 'components/alert_banner';
import ExternalLink from 'components/external_link';
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
import PaymentForm from 'components/payment_form/payment_form';
import {STRIPE_CSS_SRC, getStripePublicKey} from 'components/payment_form/stripe';
import SaveButton from 'components/save_button';
import AdminHeader from 'components/widgets/admin_console/admin_header';
import {CloudLinks} from 'utils/constants';
import {areBillingDetailsValid} from 'types/cloud/sku';
import type {BillingDetails} from 'types/cloud/sku';
import type {GlobalState} from 'types/store';
import './payment_info_edit.scss';
let stripePromise: Promise<Stripe | null>;
const PaymentInfoEdit: React.FC = () => {
const dispatch = useDispatch();
const history = useHistory();
const cwsMockMode = useSelector(isCwsMockMode);
const paymentInfo = useSelector((state: GlobalState) => state.entities.cloud.customer);
const theme = useSelector(getTheme);
const [showCreditCardWarning, setShowCreditCardWarning] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isValid, setIsValid] = useState<boolean | undefined>(undefined);
const [isServerError, setIsServerError] = useState(false);
const [billingDetails, setBillingDetails] = useState<BillingDetails>({
address: paymentInfo?.billing_address?.line1 || '',
address2: paymentInfo?.billing_address?.line2 || '',
city: paymentInfo?.billing_address?.city || '',
state: paymentInfo?.billing_address?.state || '',
country: paymentInfo?.billing_address?.country || '',
postalCode: paymentInfo?.billing_address?.postal_code || '',
name: '',
card: {} as any,
});
const stripePublicKey = useSelector((state: GlobalState) => getStripePublicKey(state));
useEffect(() => {
dispatch(getCloudCustomer());
}, []);
const onPaymentInput = (billing: BillingDetails) => {
setIsServerError(false);
setIsValid(areBillingDetailsValid(billing));
setBillingDetails(billing);
};
const handleSubmit = async () => {
setIsSaving(true);
const setPaymentMethod = completeStripeAddPaymentMethod((await stripePromise)!, billingDetails!, cwsMockMode);
const success = await setPaymentMethod();
if (success) {
history.push('/admin_console/billing/payment_info');
} else {
setIsServerError(true);
}
setIsSaving(false);
};
if (!stripePromise) {
stripePromise = loadStripe(stripePublicKey);
}
return (
<div className='wrapper--fixed PaymentInfoEdit'>
<AdminHeader withBackButton={true}>
<div>
<BlockableLink
to='/admin_console/billing/payment_info'
className='fa fa-angle-left back'
/>
<FormattedMessage
id='admin.billing.payment_info_edit.title'
defaultMessage='Edit Payment Information'
/>
</div>
</AdminHeader>
<div className='admin-console__wrapper'>
<div className='admin-console__content'>
{showCreditCardWarning &&
<AlertBanner
mode='info'
title={
<FormattedMessage
id='admin.billing.payment_info_edit.creditCardWarningTitle'
defaultMessage='NOTE: Your card will not be charged at this time'
/>
}
message={
<>
<FormattedMarkdownMessage
id='admin.billing.payment_info_edit.creditCardWarningDescription'
defaultMessage='Your credit card will be charged based on the number of users you have at the end of the monthly billing cycle. '
/>
<ExternalLink
location='payment_info_edit'
href={CloudLinks.BILLING_DOCS}
>
<FormattedMessage
id='admin.billing.subscription.planDetails.howBillingWorks'
defaultMessage='See how billing works'
/>
</ExternalLink>
</>
}
onDismiss={() => setShowCreditCardWarning(false)}
/>
}
<div className='PaymentInfoEdit__card'>
<Elements
options={{fonts: [{cssSrc: STRIPE_CSS_SRC}]}}
stripe={stripePromise}
>
<PaymentForm
className='PaymentInfoEdit__paymentForm'
onInputChange={onPaymentInput}
initialBillingDetails={billingDetails}
theme={theme}
/>
</Elements>
</div>
</div>
</div>
<div className='admin-console-save'>
<SaveButton
saving={isSaving}
disabled={!billingDetails || !isValid}
onClick={handleSubmit}
defaultMessage={(
<FormattedMessage
id='admin.billing.payment_info_edit.save'
defaultMessage='Save credit card'
/>
)}
/>
<BlockableLink
className='cancel-button'
to='/admin_console/billing/payment_info'
>
<FormattedMessage
id='admin.billing.payment_info_edit.cancel'
defaultMessage='Cancel'
/>
</BlockableLink>
{isValid === false &&
<span className='PaymentInfoEdit__error'>
<i className='icon icon-alert-outline'/>
<FormattedMessage
id='admin.billing.payment_info_edit.formError'
defaultMessage='There are errors in the form above'
/>
</span>
}
{isServerError &&
<span className='PaymentInfoEdit__error'>
<i className='icon icon-alert-outline'/>
<FormattedMessage
id='admin.billing.payment_info_edit.serverError'
defaultMessage='Something went wrong while saving payment infomation'
/>
</span>
}
</div>
</div>
);
};
export default PaymentInfoEdit;

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

@@ -12,17 +12,15 @@ import {trackEvent} from 'actions/telemetry_actions';
import {EmbargoedEntityTrialError} from 'components/admin_console/license_settings/trial_banner/trial_banner';
import AlertBanner from 'components/alert_banner';
import ContactUsButton from 'components/announcement_bar/contact_sales/contact_us';
import PurchaseLink from 'components/announcement_bar/purchase_link/purchase_link';
import CloudStartTrialButton from 'components/cloud_start_trial/cloud_start_trial_btn';
import ExternalLink from 'components/external_link';
import FormattedMarkdownMessage from 'components/formatted_markdown_message';
import StartTrialBtn from 'components/learn_more_trial_modal/start_trial_btn';
import PurchaseModal from 'components/purchase_modal';
import LoadingSpinner from 'components/widgets/loading/loading_spinner';
import {FREEMIUM_TO_ENTERPRISE_TRIAL_LENGTH_DAYS} from 'utils/cloud_utils';
import {ModalIdentifiers, TELEMETRY_CATEGORIES, AboutLinks, LicenseLinks, LicenseSkus} from 'utils/constants';
import {TELEMETRY_CATEGORIES, AboutLinks, LicenseLinks, LicenseSkus} from 'utils/constants';
import {goToMattermostContactSalesForm} from 'utils/contact_support_sales';
import * as Utils from 'utils/utils';
@@ -82,23 +80,6 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
this.props.actions.getPrevTrialLicense();
}
openUpgradeModal = (e: React.MouseEvent) => {
e.preventDefault();
trackEvent(
TELEMETRY_CATEGORIES.CLOUD_ADMIN,
'click_subscribe_from_feature_discovery',
);
this.props.actions.openModal({
modalId: ModalIdentifiers.CLOUD_PURCHASE,
dialogType: PurchaseModal,
dialogProps: {
callerCTA: 'feature_discovery_subscribe_button',
},
});
};
contactSalesFunc = () => {
const {customer, isCloud} = this.props;
const customerEmail = customer?.email || '';
@@ -157,9 +138,6 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
/>
}
/>
<ContactUsButton
eventID='post_trial_contact_sales'
/>
</>
</div>
@@ -172,7 +150,6 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
isCloudTrial,
hadPrevCloudTrial,
isPaidSubscription,
minimumSKURequiredForFeature,
} = this.props;
const canRequestCloudFreeTrial = isCloud && !isCloudTrial && !hadPrevCloudTrial && !isPaidSubscription;
@@ -217,42 +194,6 @@ export default class FeatureDiscovery extends React.PureComponent<Props, State>
</button>
);
}
} else if (hadPrevCloudTrial) {
// if it is cloud, but this account already had a free trial, then the cta button must be Upgrade now
ctaPrimaryButton = (
<button
className='btn btn-primary'
data-testid='featureDiscovery_primaryCallToAction'
onClick={this.openUpgradeModal}
>
<FormattedMessage
id='admin.ldap_feature_discovery_cloud.call_to_action.primary'
defaultMessage='Upgrade now'
/>
</button>
);
if (minimumSKURequiredForFeature === LicenseSkus.Enterprise) {
ctaPrimaryButton = (
<button
className='btn btn-primary'
data-testid='featureDiscovery_primaryCallToAction'
onClick={() => {
if (isCloud) {
trackEvent(TELEMETRY_CATEGORIES.CLOUD_ADMIN, 'click_enterprise_contact_sales_feature_discovery');
} else {
trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_ADMIN, 'click_enterprise_contact_sales_feature_discovery');
}
this.contactSalesFunc();
}}
>
<FormattedMessage
id='admin.ldap_feature_discovery_cloud.call_to_action.primary_sales'
defaultMessage='Contact sales'
/>
</button>
);
}
}
}

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

@@ -5,23 +5,16 @@ import classNames from 'classnames';
import React, {useEffect, useState} from 'react';
import type {RefObject} from 'react';
import {FormattedDate, FormattedMessage, FormattedNumber, FormattedTime, defineMessages, useIntl} from 'react-intl';
import {useSelector} from 'react-redux';
import type {ClientLicense} from '@mattermost/types/config';
import {Client4} from 'mattermost-redux/client';
import {getConfig} from 'mattermost-redux/selectors/entities/admin';
import {trackEvent} from 'actions/telemetry_actions';
import {getExpandSeatsLink} from 'selectors/cloud';
import useCanSelfHostedExpand from 'components/common/hooks/useCanSelfHostedExpand';
import useControlSelfHostedExpansionModal from 'components/common/hooks/useControlSelfHostedExpansionModal';
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
import useOpenSalesLink from 'components/common/hooks/useOpenSalesLink';
import Tag from 'components/widgets/tag/tag';
import {FileTypes, TELEMETRY_CATEGORIES} from 'utils/constants';
import {useQuery} from 'utils/http_utils';
import {FileTypes} from 'utils/constants';
import {calculateOverageUserActivated} from 'utils/overage_team';
import {getSkuDisplayName} from 'utils/subscription';
import {getRemainingDaysFromFutureTimestamp, toTitleCase} from 'utils/utils';
@@ -63,20 +56,7 @@ const EnterpriseEditionLeftPanel = ({
const {formatMessage} = useIntl();
const [unsanitizedLicense, setUnsanitizedLicense] = useState(license);
const openPricingModal = useOpenPricingModal();
const canExpand = useCanSelfHostedExpand();
const selfHostedExpansionModal = useControlSelfHostedExpansionModal({trackingLocation: 'license_settings_add_seats'});
const expandableLink = useSelector(getExpandSeatsLink);
const isSelfHostedPurchaseEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedPurchase;
const query = useQuery();
const actionQueryParam = query.get('action');
useEffect(() => {
if (actionQueryParam === 'show_expansion_modal' && canExpand && isSelfHostedPurchaseEnabled) {
selfHostedExpansionModal.open();
query.set('action', '');
}
}, []);
const [openContactSales] = useOpenSalesLink();
useEffect(() => {
async function fetchUnSanitizedLicense() {
@@ -106,15 +86,6 @@ const EnterpriseEditionLeftPanel = ({
</button>
);
const handleClickAddSeats = () => {
trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_EXPANSION, 'add_seats_clicked');
if (!isSelfHostedPurchaseEnabled || !canExpand) {
window.open(expandableLink(unsanitizedLicense.Id), '_blank');
} else {
selfHostedExpansionModal.open();
}
};
return (
<div
className='EnterpriseEditionLeftPanel'
@@ -155,17 +126,15 @@ const EnterpriseEditionLeftPanel = ({
<div className='licenseInformation'>
<div className='license-details-top'>
<span className='title'>{'License details'}</span>
{canExpand &&
<button
className='add-seats-button btn btn-primary'
onClick={handleClickAddSeats}
>
<FormattedMessage
id={'admin.license.enterpriseEdition.add.seats'}
defaultMessage='+ Add seats'
/>
</button>
}
<button
className='add-seats-button btn btn-primary'
onClick={openContactSales}
>
<FormattedMessage
id={'admin.license.enterpriseEdition.add.seats'}
defaultMessage='+ Add seats'
/>
</button>
</div>
{
renderLicenseContent(

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

@@ -40,7 +40,7 @@
.purchase_buttons {
display: flex;
flex-direction: row;
justify-content: space-between;
justify-content: center;
margin-top: 20px;
button {
@@ -54,7 +54,7 @@
&.contact-us {
width: 130px;
margin-left: 15px;
margin-left: 0px !important;
}
}

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

@@ -5,7 +5,6 @@ import React, {memo} from 'react';
import {FormattedMessage} from 'react-intl';
import ContactUsButton from 'components/announcement_bar/contact_sales/contact_us';
import PurchaseLink from 'components/announcement_bar/purchase_link/purchase_link';
import WomanUpArrowsAndCloudsSvg from 'components/common/svg_images_components/woman_up_arrows_and_clouds_svg';
const StarterRightPanel = () => {
@@ -44,15 +43,6 @@ const StarterRightPanel = () => {
})}
</div>
<div className='purchase_buttons'>
<PurchaseLink
eventID='post_trial_purchase_license'
buttonTextElement={
<FormattedMessage
id='admin.license.trialCard.purchase'
defaultMessage='Purchase'
/>
}
/>
<ContactUsButton
eventID='post_trial_contact_sales'
/>