Migrate to mono-repo
Этот коммит содержится в:
@@ -6,7 +6,7 @@ import {Stripe} from '@stripe/stripe-js';
|
||||
import {getCode} from 'country-list';
|
||||
|
||||
import {CreateSubscriptionRequest} from '@mattermost/types/cloud';
|
||||
import {SelfHostedSignupProgress} from '@mattermost/types/hosted_customer';
|
||||
import {SelfHostedExpansionRequest, SelfHostedSignupProgress} from '@mattermost/types/hosted_customer';
|
||||
import {ValueOf} from '@mattermost/types/utilities';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
@@ -198,3 +198,89 @@ export function getTrueUpReviewStatus(): ActionFunc {
|
||||
onRequest: HostedCustomerTypes.TRUE_UP_REVIEW_STATUS_REQUEST,
|
||||
});
|
||||
}
|
||||
|
||||
export function confirmSelfHostedExpansion(
|
||||
stripe: Stripe,
|
||||
stripeSetupIntent: StripeSetupIntent,
|
||||
isDevMode: boolean,
|
||||
billingDetails: BillingDetails,
|
||||
initialProgress: ValueOf<typeof SelfHostedSignupProgress>,
|
||||
expansionRequest: SelfHostedExpansionRequest,
|
||||
): ActionFunc {
|
||||
return async (dispatch: DispatchFunc) => {
|
||||
const cardSetupFunction = getConfirmCardSetup(isDevMode);
|
||||
const confirmCardSetup = cardSetupFunction(stripe.confirmCardSetup);
|
||||
|
||||
const shouldConfirmCard = selfHostedNeedsConfirmation(initialProgress);
|
||||
if (shouldConfirmCard) {
|
||||
const result = await confirmCardSetup(
|
||||
stripeSetupIntent.client_secret,
|
||||
{
|
||||
payment_method: {
|
||||
card: billingDetails.card,
|
||||
billing_details: {
|
||||
name: billingDetails.name,
|
||||
address: {
|
||||
line1: billingDetails.address,
|
||||
line2: billingDetails.address2,
|
||||
city: billingDetails.city,
|
||||
state: billingDetails.state,
|
||||
country: getCode(billingDetails.country),
|
||||
postal_code: billingDetails.postalCode,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return {data: false, error: 'failed to confirm card with Stripe'};
|
||||
}
|
||||
|
||||
const {setupIntent, error: stripeError} = result;
|
||||
|
||||
if (stripeError) {
|
||||
if (stripeError.code === STRIPE_UNEXPECTED_STATE && stripeError.message === STRIPE_ALREADY_SUCCEEDED && stripeError.setup_intent?.status === 'succeeded') {
|
||||
dispatch({
|
||||
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
|
||||
data: SelfHostedSignupProgress.CONFIRMED_INTENT,
|
||||
});
|
||||
} else {
|
||||
return {data: false, error: stripeError.message || 'Stripe failed to confirm payment method'};
|
||||
}
|
||||
} else {
|
||||
if (setupIntent === null || setupIntent === undefined) {
|
||||
return {data: false, error: 'Stripe did not return successful setup intent'};
|
||||
}
|
||||
|
||||
if (setupIntent.status !== 'succeeded') {
|
||||
return {data: false, error: `Stripe setup intent status was: ${setupIntent.status}`};
|
||||
}
|
||||
dispatch({
|
||||
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
|
||||
data: SelfHostedSignupProgress.CONFIRMED_INTENT,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let confirmResult;
|
||||
try {
|
||||
confirmResult = await Client4.confirmSelfHostedExpansion(stripeSetupIntent.id, expansionRequest);
|
||||
dispatch({
|
||||
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
|
||||
data: confirmResult.progress,
|
||||
});
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
|
||||
// unprocessable entity, e.g. failed export compliance
|
||||
if (error.status_code === 422) {
|
||||
return {data: false, error: error.status_code};
|
||||
}
|
||||
return {data: false, error};
|
||||
}
|
||||
|
||||
return {data: confirmResult.license};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
|
||||
.license-details-top {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
line-height: 24px;
|
||||
@@ -114,10 +114,11 @@
|
||||
color: #3f4350;
|
||||
}
|
||||
|
||||
span.expiration-days {
|
||||
margin-left: auto;
|
||||
color: var(--denim-status-online);
|
||||
.add-seats-button {
|
||||
border-radius: 4px;
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +158,20 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
span.expiration-days {
|
||||
margin-left: 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
|
||||
&-warning {
|
||||
color: var(--sys-away-indicator);
|
||||
}
|
||||
|
||||
&-danger {
|
||||
color: var(--dnd-indicator);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.add-new-licence-btn {
|
||||
@@ -194,4 +209,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,15 +6,20 @@ import {screen} from '@testing-library/react';
|
||||
|
||||
import {Provider} from 'react-redux';
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
|
||||
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
|
||||
import {renderWithIntl} from 'tests/react_testing_utils';
|
||||
import {OverActiveUserLimits} from 'utils/constants';
|
||||
import {OverActiveUserLimits, SelfHostedProducts} from 'utils/constants';
|
||||
import {TestHelper} from 'utils/test_helper';
|
||||
|
||||
import {General} from 'mattermost-redux/constants';
|
||||
import {DeepPartial} from '@mattermost/types/utilities';
|
||||
import {GlobalState} from '@mattermost/types/store';
|
||||
import mockStore from 'tests/test_store';
|
||||
|
||||
import * as useCanSelfHostedExpand from 'components/common/hooks/useCanSelfHostedExpand';
|
||||
|
||||
import EnterpriseEditionLeftPanel, {EnterpriseEditionProps} from './enterprise_edition_left_panel';
|
||||
|
||||
describe('components/admin_console/license_settings/enterprise_edition/enterprise_edition_left_panel', () => {
|
||||
@@ -26,7 +31,7 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
|
||||
SkuShortName: 'Enterprise',
|
||||
Name: 'LicenseName',
|
||||
Company: 'Mattermost Inc.',
|
||||
Users: '1000000',
|
||||
Users: '1000',
|
||||
};
|
||||
|
||||
const initialState: DeepPartial<GlobalState> = {
|
||||
@@ -45,10 +50,36 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
|
||||
},
|
||||
general: {
|
||||
license,
|
||||
config: {
|
||||
BuildEnterpriseReady: 'true',
|
||||
},
|
||||
},
|
||||
preferences: {
|
||||
myPreferences: {},
|
||||
},
|
||||
admin: {
|
||||
config: {
|
||||
ServiceSettings: {
|
||||
SelfHostedExpansion: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
cloud: {
|
||||
subscription: undefined,
|
||||
},
|
||||
hostedCustomer: {
|
||||
products: {
|
||||
products: {
|
||||
prod_professional: TestHelper.getProductMock({
|
||||
id: 'prod_professional',
|
||||
name: 'Professional',
|
||||
sku: SelfHostedProducts.PROFESSIONAL,
|
||||
price_per_seat: 7.5,
|
||||
}),
|
||||
},
|
||||
productsLoaded: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -80,12 +111,12 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
|
||||
|
||||
const item = wrapper.find('.item-element').filterWhere((n) => {
|
||||
return n.children().length === 2 &&
|
||||
n.childAt(0).type() === 'span' &&
|
||||
!n.childAt(0).text().includes('ACTIVE') &&
|
||||
n.childAt(0).text().includes('USERS');
|
||||
n.childAt(0).type() === 'span' &&
|
||||
!n.childAt(0).text().includes('ACTIVE') &&
|
||||
n.childAt(0).text().includes('USERS');
|
||||
});
|
||||
|
||||
expect(item.text()).toContain('1,000,000');
|
||||
expect(item.text()).toContain('1,000');
|
||||
});
|
||||
|
||||
test('should not add any class if active users is lower than the minimal', async () => {
|
||||
@@ -146,4 +177,47 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
|
||||
expect(screen.getByText('ACTIVE USERS:')).not.toHaveClass('legend--warning-over-seats-purchased');
|
||||
expect(screen.getByText('ACTIVE USERS:')).toHaveClass('legend--over-seats-purchased');
|
||||
});
|
||||
|
||||
test('should add warning class to days expired indicator when there are more than 5 days until expiry', async () => {
|
||||
license.ExpiresAt = moment().add(6, 'days').valueOf().toString();
|
||||
const store = await mockStore(initialState);
|
||||
renderWithIntl(
|
||||
<Provider store={store}>
|
||||
<EnterpriseEditionLeftPanel
|
||||
{...props}
|
||||
/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Expires in 6 days')).toHaveClass('expiration-days-warning');
|
||||
});
|
||||
|
||||
test('should add danger class to days expired indicator when there are at least 5 days until expiry', async () => {
|
||||
license.ExpiresAt = moment().add(5, 'days').valueOf().toString();
|
||||
const store = await mockStore(initialState);
|
||||
renderWithIntl(
|
||||
<Provider store={store}>
|
||||
<EnterpriseEditionLeftPanel
|
||||
{...props}
|
||||
/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Expires in 5 days')).toHaveClass('expiration-days-danger');
|
||||
});
|
||||
|
||||
test('should display add seats button when there are more than 60 days until expiry and self hosted expansion is available', async () => {
|
||||
license.ExpiresAt = moment().add(61, 'days').valueOf().toString();
|
||||
const store = await mockStore(initialState);
|
||||
jest.spyOn(useCanSelfHostedExpand, 'default').mockImplementation(() => true);
|
||||
renderWithIntl(
|
||||
<Provider store={store}>
|
||||
<EnterpriseEditionLeftPanel
|
||||
{...props}
|
||||
/>
|
||||
</Provider>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('+ Add seats')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import React, {RefObject, useEffect, useState} from 'react';
|
||||
import classNames from 'classnames';
|
||||
import {FormattedDate, FormattedMessage, FormattedNumber, FormattedTime, useIntl} from 'react-intl';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import Tag from 'components/widgets/tag/tag';
|
||||
|
||||
@@ -15,9 +16,16 @@ import {getRemainingDaysFromFutureTimestamp, toTitleCase} from 'utils/utils';
|
||||
import {FileTypes} from 'utils/constants';
|
||||
import {getSkuDisplayName} from 'utils/subscription';
|
||||
import {calculateOverageUserActivated} from 'utils/overage_team';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/admin';
|
||||
|
||||
import './enterprise_edition.scss';
|
||||
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
|
||||
import useCanSelfHostedExpand from 'components/common/hooks/useCanSelfHostedExpand';
|
||||
import {getExpandSeatsLink} from 'selectors/cloud';
|
||||
import useControlSelfHostedExpansionModal from 'components/common/hooks/useControlSelfHostedExpansionModal';
|
||||
|
||||
const DAYS_UNTIL_EXPIRY_WARNING_DISPLAY_THRESHOLD = 30;
|
||||
const DAYS_UNTIL_EXPIRY_DANGER_DISPLAY_THRESHOLD = 5;
|
||||
|
||||
export interface EnterpriseEditionProps {
|
||||
openEELicenseModal: () => void;
|
||||
@@ -47,10 +55,12 @@ 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);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchUnSanitizedLicense() {
|
||||
// This solves this the issue reported here: https://mattermost.atlassian.net/browse/MM-42906
|
||||
try {
|
||||
const unsanitizedL = await Client4.getClientLicenseOld();
|
||||
setUnsanitizedLicense(unsanitizedL);
|
||||
@@ -63,6 +73,7 @@ const EnterpriseEditionLeftPanel = ({
|
||||
|
||||
const skuName = getSkuDisplayName(unsanitizedLicense.SkuShortName, unsanitizedLicense.IsGovSku === 'true');
|
||||
const expirationDays = getRemainingDaysFromFutureTimestamp(parseInt(unsanitizedLicense.ExpiresAt, 10));
|
||||
const isSelfHostedExpansionEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedExpansion;
|
||||
|
||||
const viewPlansButton = (
|
||||
<button
|
||||
@@ -77,6 +88,14 @@ const EnterpriseEditionLeftPanel = ({
|
||||
</button>
|
||||
);
|
||||
|
||||
const handleClickAddSeats = () => {
|
||||
if (!isSelfHostedExpansionEnabled && !canExpand) {
|
||||
window.open(expandableLink(unsanitizedLicense.Id), '_blank');
|
||||
} else {
|
||||
selfHostedExpansionModal.open();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className='EnterpriseEditionLeftPanel'
|
||||
@@ -117,10 +136,16 @@ const EnterpriseEditionLeftPanel = ({
|
||||
<div className='licenseInformation'>
|
||||
<div className='license-details-top'>
|
||||
<span className='title'>{'License details'}</span>
|
||||
{(expirationDays <= 30) &&
|
||||
<span className='expiration-days'>
|
||||
{`Expires in ${expirationDays} day${expirationDays > 1 ? 's' : ''}`}
|
||||
</span>
|
||||
{canExpand &&
|
||||
<button
|
||||
className='add-seats-button btn btn-primary'
|
||||
onClick={handleClickAddSeats}
|
||||
>
|
||||
<FormattedMessage
|
||||
id={'admin.license.enterpriseEdition.add.seats'}
|
||||
defaultMessage='+ Add seats'
|
||||
/>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
{
|
||||
@@ -134,6 +159,7 @@ const EnterpriseEditionLeftPanel = ({
|
||||
fileInputRef,
|
||||
handleChange,
|
||||
statsActiveUsers,
|
||||
expirationDays,
|
||||
)
|
||||
}
|
||||
</div>
|
||||
@@ -162,7 +188,7 @@ const EnterpriseEditionLeftPanel = ({
|
||||
|
||||
type LegendValues = 'START DATE:' | 'EXPIRES:' | 'USERS:' | 'ACTIVE USERS:' | 'EDITION:' | 'LICENSE ISSUED:' | 'NAME:' | 'COMPANY / ORG:'
|
||||
|
||||
const renderLicenseValues = (activeUsers: number, seatsPurchased: number) => ({legend, value}: {legend: LegendValues; value: string | JSX.Element | null}, index: number): React.ReactNode => {
|
||||
const renderLicenseValues = (activeUsers: number, seatsPurchased: number, expirationDays: number) => ({legend, value}: {legend: LegendValues; value: string | JSX.Element | null}, index: number): React.ReactNode => {
|
||||
if (legend === 'ACTIVE USERS:') {
|
||||
const {isBetween5PercerntAnd10PercentPurchasedSeats, isOver10PercerntPurchasedSeats} = calculateOverageUserActivated({activeUsers, seatsPurchased});
|
||||
return (
|
||||
@@ -186,6 +212,26 @@ const renderLicenseValues = (activeUsers: number, seatsPurchased: number) => ({l
|
||||
>{value}</span>
|
||||
</div>
|
||||
);
|
||||
} else if (legend === 'EXPIRES:') {
|
||||
return (
|
||||
<div
|
||||
className='item-element'
|
||||
key={value + index.toString()}
|
||||
>
|
||||
<span className='legend'>{legend}</span>
|
||||
<span className='value'>{value}</span>
|
||||
{(expirationDays <= DAYS_UNTIL_EXPIRY_WARNING_DISPLAY_THRESHOLD) &&
|
||||
<span
|
||||
className={classNames('expiration-days', {
|
||||
'expiration-days-warning': expirationDays <= DAYS_UNTIL_EXPIRY_WARNING_DISPLAY_THRESHOLD,
|
||||
'expiration-days-danger': expirationDays <= DAYS_UNTIL_EXPIRY_DANGER_DISPLAY_THRESHOLD,
|
||||
})}
|
||||
>
|
||||
{`Expires in ${expirationDays} day${expirationDays > 1 ? 's' : ''}`}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -209,6 +255,7 @@ const renderLicenseContent = (
|
||||
fileInputRef: RefObject<HTMLInputElement>,
|
||||
handleChange: () => void,
|
||||
statsActiveUsers: number,
|
||||
expirationDays: number,
|
||||
) => {
|
||||
// Note: DO NOT LOCALISE THESE STRINGS. Legally we can not since the license is in English.
|
||||
|
||||
@@ -246,7 +293,7 @@ const renderLicenseContent = (
|
||||
|
||||
return (
|
||||
<div className='licenseElements'>
|
||||
{licenseValues.map(renderLicenseValues(statsActiveUsers, parseInt(license.Users, 10)))}
|
||||
{licenseValues.map(renderLicenseValues(statsActiveUsers, parseInt(license.Users, 10), expirationDays))}
|
||||
<hr/>
|
||||
{renderAddNewLicenseButton(fileInputRef, handleChange)}
|
||||
{renderRemoveButton(handleRemove, isDisabled, removing)}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useEffect, useState} from 'react';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {getSubscriptionProduct} from 'mattermost-redux/selectors/entities/cloud';
|
||||
import {BillingSchemes, SelfHostedProducts} from 'utils/constants';
|
||||
|
||||
import {isCloudLicense} from 'utils/license_utils';
|
||||
|
||||
import {findSelfHostedProductBySku} from 'utils/hosted_customer';
|
||||
|
||||
import useGetSelfHostedProducts from './useGetSelfHostedProducts';
|
||||
|
||||
export default function useCanSelfHostedExpand() {
|
||||
// NOTE: This is a basic implementation to get things up and running, more details to come later.
|
||||
const [expansionAvailable, setExpansionAvailable] = useState(false);
|
||||
const config = useSelector(getConfig);
|
||||
const isEnterpriseReady = config.BuildEnterpriseReady === 'true';
|
||||
const isSalesServeOnly = useSelector(getSubscriptionProduct)?.billing_scheme === BillingSchemes.SALES_SERVE;
|
||||
const license = useSelector(getLicense);
|
||||
const isCloud = isCloudLicense(license);
|
||||
const [products] = useGetSelfHostedProducts();
|
||||
const currentProduct = findSelfHostedProductBySku(products, license.SkuShortName);
|
||||
|
||||
// Self Hosted Products never contains a product for starter, additional check is done out of caution.
|
||||
const isSelfHostedStarter = currentProduct === null || currentProduct?.sku === SelfHostedProducts.STARTER;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEnterpriseReady) {
|
||||
return;
|
||||
}
|
||||
Client4.getLicenseSelfServeStatus().
|
||||
then((res) => {
|
||||
setExpansionAvailable(res.is_expandable ?? false);
|
||||
}).
|
||||
catch(() => {
|
||||
setExpansionAvailable(false);
|
||||
});
|
||||
}, [isEnterpriseReady]);
|
||||
|
||||
return !isCloud && !isSelfHostedStarter && !isSalesServeOnly && expansionAvailable;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {useMemo} from 'react';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import {trackEvent} from 'actions/telemetry_actions';
|
||||
import {openModal} from 'actions/views/modals';
|
||||
import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants';
|
||||
import PurchaseInProgressModal from 'components/purchase_in_progress_modal';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {getCurrentUser} from 'mattermost-redux/selectors/entities/common';
|
||||
import {HostedCustomerTypes} from 'mattermost-redux/action_types';
|
||||
|
||||
import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from 'components/self_hosted_expansion_modal/constants';
|
||||
import SelfHostedExpansionModal from 'components/self_hosted_expansion_modal';
|
||||
|
||||
import {useControlModal, ControlModal} from './useControlModal';
|
||||
|
||||
interface HookOptions{
|
||||
onClick?: () => void;
|
||||
trackingLocation: string;
|
||||
}
|
||||
|
||||
export default function useControlSelfHostedExpansionModal(options: HookOptions): ControlModal {
|
||||
const dispatch = useDispatch();
|
||||
const currentUser = useSelector(getCurrentUser);
|
||||
const controlModal = useControlModal({
|
||||
modalId: ModalIdentifiers.SELF_HOSTED_EXPANSION,
|
||||
dialogType: SelfHostedExpansionModal,
|
||||
});
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
...controlModal,
|
||||
open: async () => {
|
||||
const purchaseInProgress = localStorage.getItem(STORAGE_KEY_EXPANSION_IN_PROGRESS) === 'true';
|
||||
|
||||
// check if user already has an open purchase modal in current browser.
|
||||
if (purchaseInProgress) {
|
||||
// User within the same browser session
|
||||
// is already trying to purchase. Notify them of this
|
||||
// and request the exit that purchase flow before attempting again.
|
||||
dispatch(openModal({
|
||||
modalId: ModalIdentifiers.PURCHASE_IN_PROGRESS,
|
||||
dialogType: PurchaseInProgressModal,
|
||||
dialogProps: {
|
||||
purchaserEmail: currentUser.email,
|
||||
storageKey: STORAGE_KEY_EXPANSION_IN_PROGRESS,
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
trackEvent(TELEMETRY_CATEGORIES.SELF_HOSTED_EXPANSION, 'click_open_expansion_modal', {
|
||||
callerInfo: options.trackingLocation,
|
||||
});
|
||||
|
||||
if (options.onClick) {
|
||||
options.onClick();
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await Client4.bootstrapSelfHostedSignup();
|
||||
|
||||
if (result.email !== currentUser.email) {
|
||||
// Token already exists and was created by another admin.
|
||||
// Notify user of this and do not allow them to try to expand concurrently.
|
||||
dispatch(openModal({
|
||||
modalId: ModalIdentifiers.EXPANSION_IN_PROGRESS,
|
||||
dialogType: PurchaseInProgressModal,
|
||||
dialogProps: {
|
||||
purchaserEmail: result.email,
|
||||
storageKey: STORAGE_KEY_EXPANSION_IN_PROGRESS,
|
||||
},
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch({
|
||||
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
|
||||
data: result.progress,
|
||||
});
|
||||
|
||||
controlModal.open();
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('error bootstrapping self hosted purchase modal', e);
|
||||
}
|
||||
},
|
||||
};
|
||||
}, [controlModal, options.onClick, options.trackingLocation]);
|
||||
}
|
||||
@@ -63,6 +63,7 @@ export default function useControlSelfHostedPurchaseModal(options: HookOptions):
|
||||
dialogType: PurchaseInProgressModal,
|
||||
dialogProps: {
|
||||
purchaserEmail: currentUser.email,
|
||||
storageKey: STORAGE_KEY_PURCHASE_IN_PROGRESS,
|
||||
},
|
||||
}));
|
||||
return;
|
||||
@@ -86,6 +87,7 @@ export default function useControlSelfHostedPurchaseModal(options: HookOptions):
|
||||
dialogType: PurchaseInProgressModal,
|
||||
dialogProps: {
|
||||
purchaserEmail: result.email,
|
||||
storageKey: STORAGE_KEY_PURCHASE_IN_PROGRESS,
|
||||
},
|
||||
}));
|
||||
return;
|
||||
|
||||
@@ -11,6 +11,8 @@ import {GlobalState} from 'types/store';
|
||||
import {TestHelper as TH} from 'utils/test_helper';
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
|
||||
import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from 'components/self_hosted_purchase_modal/constants';
|
||||
|
||||
import PurchaseInProgressModal from './';
|
||||
|
||||
jest.mock('mattermost-redux/client', () => {
|
||||
@@ -56,13 +58,27 @@ describe('PurchaseInProgressModal', () => {
|
||||
it('when purchaser and user emails are different, user is instructed to wait', () => {
|
||||
const stateOverride: DeepPartial<GlobalState> = JSON.parse(JSON.stringify(initialState));
|
||||
stateOverride.entities!.users!.currentUserId = 'otherUserId';
|
||||
renderWithIntlAndStore(<div id='root-portal'><PurchaseInProgressModal purchaserEmail={'admin@example.com'}/></div>, stateOverride);
|
||||
renderWithIntlAndStore(
|
||||
<div id='root-portal'>
|
||||
<PurchaseInProgressModal
|
||||
purchaserEmail={'admin@example.com'}
|
||||
storageKey={STORAGE_KEY_PURCHASE_IN_PROGRESS}
|
||||
/>
|
||||
</div>, stateOverride,
|
||||
);
|
||||
|
||||
screen.getByText('@UserAdmin is currently attempting to purchase a paid license.');
|
||||
});
|
||||
|
||||
it('when purchaser and user emails are same, allows user to reset purchase flow', () => {
|
||||
renderWithIntlAndStore(<div id='root-portal'><PurchaseInProgressModal purchaserEmail={'admin@example.com'}/></div>, initialState);
|
||||
renderWithIntlAndStore(
|
||||
<div id='root-portal'>
|
||||
<PurchaseInProgressModal
|
||||
purchaserEmail={'admin@example.com'}
|
||||
storageKey={STORAGE_KEY_PURCHASE_IN_PROGRESS}
|
||||
/>
|
||||
</div>, initialState,
|
||||
);
|
||||
|
||||
expect(Client4.bootstrapSelfHostedSignup).not.toHaveBeenCalled();
|
||||
screen.getByText('Reset purchase flow').click();
|
||||
|
||||
@@ -13,13 +13,13 @@ import {Client4} from 'mattermost-redux/client';
|
||||
|
||||
import CreditCardSvg from 'components/common/svg_images_components/credit_card_svg';
|
||||
import {useControlPurchaseInProgressModal} from 'components/common/hooks/useControlModal';
|
||||
import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from 'components/self_hosted_purchase_modal/constants';
|
||||
|
||||
import './index.scss';
|
||||
import {GlobalState} from '@mattermost/types/store';
|
||||
|
||||
interface Props {
|
||||
purchaserEmail: string;
|
||||
storageKey: string;
|
||||
}
|
||||
|
||||
export default function PurchaseInProgressModal(props: Props) {
|
||||
@@ -64,7 +64,7 @@ export default function PurchaseInProgressModal(props: Props) {
|
||||
);
|
||||
|
||||
genericModalProps.handleConfirm = () => {
|
||||
localStorage.removeItem(STORAGE_KEY_PURCHASE_IN_PROGRESS);
|
||||
localStorage.removeItem(props.storageKey);
|
||||
Client4.bootstrapSelfHostedSignup(true);
|
||||
close();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export const STORAGE_KEY_EXPANSION_IN_PROGRESS = 'EXPANSION_IN_PROGRESS';
|
||||
@@ -0,0 +1,3 @@
|
||||
.self_hosted_expansion_failed {
|
||||
margin-top: 163px;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
// 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 {getCloudContactUsLink, InquiryType} from 'selectors/cloud';
|
||||
|
||||
import PaymentFailedSvg from 'components/common/svg_images_components/payment_failed_svg';
|
||||
import IconMessage from 'components/purchase_modal/icon_message';
|
||||
|
||||
import './error_page.scss';
|
||||
|
||||
export default function SelfHostedExpansionErrorPage() {
|
||||
const contactSupportLink = useSelector(getCloudContactUsLink)(InquiryType.Technical);
|
||||
|
||||
const formattedTitle = (
|
||||
<FormattedMessage
|
||||
id='admin.billing.subscription.paymentVerificationFailed'
|
||||
defaultMessage='Sorry, the payment verification failed'
|
||||
/>
|
||||
);
|
||||
|
||||
const formattedButtonText = (
|
||||
<FormattedMessage
|
||||
id='error_modal.try_again'
|
||||
defaultMessage='Try again'
|
||||
/>
|
||||
);
|
||||
|
||||
const formattedSubtitle = (
|
||||
<FormattedMessage
|
||||
id='self_hosted_expansion.paymentFailed'
|
||||
defaultMessage='Payment failed. Please try again or contact support.'
|
||||
/>
|
||||
);
|
||||
|
||||
const tertiaryButtonText = (
|
||||
<FormattedMessage
|
||||
id='self_hosted_expansion.contact_support'
|
||||
defaultMessage={'Contact Support'}
|
||||
/>
|
||||
);
|
||||
|
||||
const icon = (
|
||||
<PaymentFailedSvg
|
||||
width={444}
|
||||
height={313}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='self_hosted_expansion_failed'>
|
||||
<IconMessage
|
||||
formattedTitle={formattedTitle}
|
||||
formattedSubtitle={formattedSubtitle}
|
||||
icon={icon}
|
||||
error={true}
|
||||
formattedButtonText={formattedButtonText}
|
||||
buttonHandler={() => {
|
||||
//TODO: Open self hosted expansion modal
|
||||
}}
|
||||
formattedTertiaryButonText={tertiaryButtonText}
|
||||
tertiaryButtonHandler={() => window.open(contactSupportLink, '_blank', 'noreferrer')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
.SelfHostedExpansionRHSCard {
|
||||
display: flex;
|
||||
max-width: 280px;
|
||||
flex-direction: column;
|
||||
|
||||
&__Content {
|
||||
padding: 24px;
|
||||
border: 1px solid;
|
||||
border-color: rgba(var(--sys-denim-center-channel-text-rgb), 0.16);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__RHSCardTitle {
|
||||
display: block;
|
||||
margin-bottom: 12px;
|
||||
color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72);
|
||||
font-family: 'Open Sans';
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.seatsInput {
|
||||
width: 73px;
|
||||
margin-left: auto;
|
||||
font-family: 'Open Sans';
|
||||
font-size: 14px;
|
||||
font-weight: 400;
|
||||
|
||||
input[type="number"] {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
input[type="number"]::-webkit-inner-spin-button,
|
||||
input[type="number"]::-webkit-outer-spin-button {
|
||||
margin: 0;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__PlanDetails {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
|
||||
.planName {
|
||||
color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72);
|
||||
font-family: 'Metropolis';
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.usage {
|
||||
color: rgba(var(--sys-denim-center-channel-text-rgb), 0.56);
|
||||
font-family: 'Open Sans';
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
|
||||
:first-child {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
width: 90%;
|
||||
height: 2px;
|
||||
background-color: rgba(var(--sys-denim-center-channel-text-rgb), 0.16);
|
||||
}
|
||||
|
||||
&__seatInput,
|
||||
&__cost_breakdown {
|
||||
display: grid;
|
||||
font-weight: 400;
|
||||
gap: 10px;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
.costPerUser > span:first-child {
|
||||
font-family: 'Open Sans';
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.costPerUser > span:last-child {
|
||||
color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72);
|
||||
font-family: 'Open Sans';
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.totalCost {
|
||||
width: 141px;
|
||||
}
|
||||
|
||||
.totalCost > span:first-child {
|
||||
color: var(--sys-denim-center-channel-text);
|
||||
font-family: 'Open Sans';
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.totalCost > span:last-child {
|
||||
color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72);
|
||||
font-family: 'Open Sans';
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.costAmount {
|
||||
margin-right: 0;
|
||||
margin-left: auto;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
&__AddSeatsWarning {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 35px;
|
||||
margin-bottom: 15px;
|
||||
color: var(--dnd-indicator);
|
||||
font-family: 'Open Sans';
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&__CompletePurchaseButton {
|
||||
width: 100%;
|
||||
margin-top: 10px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
&__ChargedTodayDisclaimer {
|
||||
color: rgba(var(--sys-denim-center-channel-text-rgb), 0.72);
|
||||
font-family: 'Open Sans';
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {OutlinedInput} from '@mui/material';
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
import React, {Fragment, useState} from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {DocLinks, RecurringIntervals} from 'utils/constants';
|
||||
import WarningIcon from 'components/widgets/icons/fa_warning_icon';
|
||||
|
||||
import './expansion_card.scss';
|
||||
import useGetSelfHostedProducts from 'components/common/hooks/useGetSelfHostedProducts';
|
||||
import {findSelfHostedProductBySku} from 'utils/hosted_customer';
|
||||
import ExternalLink from 'components/external_link';
|
||||
|
||||
const MONTHS_IN_YEAR = 12;
|
||||
const MILLISECONDS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
const MAX_TRANSACTION_VALUE = 1_000_000 - 1;
|
||||
|
||||
interface Props {
|
||||
canSubmit: boolean;
|
||||
licensedSeats: number;
|
||||
initialSeats: number;
|
||||
submit: () => void;
|
||||
updateSeats: (seats: number) => void;
|
||||
}
|
||||
|
||||
export default function SelfHostedExpansionCard(props: Props) {
|
||||
const license = useSelector(getLicense);
|
||||
const startsAt = moment(parseInt(license.StartsAt, 10)).format('MMM. D, YYYY');
|
||||
const endsAt = moment(parseInt(license.ExpiresAt, 10)).format('MMM. D, YYYY');
|
||||
const [additionalSeats, setAdditionalSeats] = useState(props.initialSeats);
|
||||
const [overMaxSeats, setOverMaxSeats] = useState(false);
|
||||
const licenseExpiry = parseInt(license.ExpiresAt, 10);
|
||||
const invalidAdditionalSeats = additionalSeats === 0 || isNaN(additionalSeats);
|
||||
const [products] = useGetSelfHostedProducts();
|
||||
const currentProduct = findSelfHostedProductBySku(products, license.SkuShortName);
|
||||
|
||||
const getMonthsUntilExpiry = () => {
|
||||
const now = new Date();
|
||||
return Math.ceil((licenseExpiry - now.getTime()) / MILLISECONDS_PER_DAY / 30);
|
||||
};
|
||||
|
||||
const getMonthlyPrice = () => {
|
||||
if (currentProduct === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (currentProduct?.recurring_interval === RecurringIntervals.MONTH) {
|
||||
return currentProduct.price_per_seat;
|
||||
}
|
||||
|
||||
const costPerMonth = (currentProduct.price_per_seat / MONTHS_IN_YEAR);
|
||||
|
||||
// Only display 2 decimal places if the cost per month is not evenly divisible over 12 months.
|
||||
if (!Number.isInteger(costPerMonth)) {
|
||||
// Keep the return value as a number.
|
||||
return costPerMonth;
|
||||
}
|
||||
|
||||
return costPerMonth;
|
||||
};
|
||||
|
||||
const getCostPerUser = () => {
|
||||
if (isNaN(additionalSeats)) {
|
||||
return 0;
|
||||
}
|
||||
const monthlyPrice = getMonthlyPrice();
|
||||
const monthsUntilExpiry = getMonthsUntilExpiry();
|
||||
return monthlyPrice * monthsUntilExpiry;
|
||||
};
|
||||
|
||||
const getTotal = () => {
|
||||
if (isNaN(additionalSeats)) {
|
||||
return 0;
|
||||
}
|
||||
const monthlyPrice = getMonthlyPrice();
|
||||
const monthsUntilExpiry = getMonthsUntilExpiry();
|
||||
return additionalSeats * monthlyPrice * monthsUntilExpiry;
|
||||
};
|
||||
|
||||
// Finds the maximum number of additional seats that is possible, taking into account
|
||||
// the stripe transaction limit. The maximum number of seats will follow the formula:
|
||||
// (StripeTransaction Limit - (Current_Seats * Price Per Seat)) / price_per_seat
|
||||
const getMaximumAdditionalSeats = () => {
|
||||
if (currentProduct === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
let recurringCost = 0;
|
||||
|
||||
// if monthly
|
||||
if (currentProduct.recurring_interval === RecurringIntervals.MONTH) {
|
||||
recurringCost = getMonthlyPrice();
|
||||
} else { // if yearly
|
||||
recurringCost = currentProduct.price_per_seat;
|
||||
}
|
||||
|
||||
const currentPaymentPrice = recurringCost * props.licensedSeats;
|
||||
const remainingTransactionLimit = MAX_TRANSACTION_VALUE - currentPaymentPrice;
|
||||
const remainingSeats = Math.floor(remainingTransactionLimit / recurringCost);
|
||||
return Math.max(0, remainingSeats);
|
||||
};
|
||||
|
||||
const maxAdditionalSeats = getMaximumAdditionalSeats();
|
||||
|
||||
const handleNewSeatsInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setOverMaxSeats(false);
|
||||
|
||||
const requestedSeats = parseInt(e.target.value, 10);
|
||||
|
||||
const overMaxAdditionalSeats = requestedSeats > maxAdditionalSeats;
|
||||
setOverMaxSeats(overMaxAdditionalSeats);
|
||||
|
||||
const finalSeatCount = overMaxAdditionalSeats ? maxAdditionalSeats : requestedSeats;
|
||||
setAdditionalSeats(finalSeatCount);
|
||||
|
||||
props.updateSeats(finalSeatCount);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className='SelfHostedExpansionRHSCard'>
|
||||
<div className='SelfHostedExpansionRHSCard__RHSCardTitle'>
|
||||
<FormattedMessage
|
||||
id='self_hosted_expansion_rhs_license_summary_title'
|
||||
defaultMessage='License Summary'
|
||||
/>
|
||||
</div>
|
||||
<div className='SelfHostedExpansionRHSCard__Content'>
|
||||
<div className='SelfHostedExpansionRHSCard__PlanDetails'>
|
||||
<span className='planName'>{license.SkuShortName}</span>
|
||||
<div className='usage'>
|
||||
<FormattedMessage
|
||||
id='self_hosted_expansion_rhs_card_license_date'
|
||||
defaultMessage='{startsAt} - {endsAt}'
|
||||
values={{
|
||||
startsAt,
|
||||
endsAt,
|
||||
}}
|
||||
/>
|
||||
<br/>
|
||||
<FormattedMessage
|
||||
id='self_hosted_expansion_rhs_card_licensed_seats'
|
||||
defaultMessage='{licensedSeats} LICENSES SEATS'
|
||||
values={{
|
||||
licensedSeats: props.licensedSeats,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<hr/>
|
||||
<div className='SelfHostedExpansionRHSCard__seatInput'>
|
||||
<FormattedMessage
|
||||
id='self_hosted_expansion_rhs_card_add_new_seats'
|
||||
defaultMessage='Add new seats'
|
||||
/>
|
||||
<OutlinedInput
|
||||
data-testid='seatsInput'
|
||||
className='seatsInput'
|
||||
size='small'
|
||||
type='number'
|
||||
value={additionalSeats}
|
||||
onChange={handleNewSeatsInputChange}
|
||||
error={invalidAdditionalSeats}
|
||||
disabled={maxAdditionalSeats === 0}
|
||||
/>
|
||||
</div>
|
||||
<div className='SelfHostedExpansionRHSCard__AddSeatsWarning'>
|
||||
{invalidAdditionalSeats && !overMaxSeats &&
|
||||
<FormattedMessage
|
||||
id='self_hosted_expansion_rhs_card_must_add_seats_warning'
|
||||
defaultMessage='{warningIcon} You must add a seat to continue'
|
||||
values={{
|
||||
warningIcon: <WarningIcon additionalClassName={'SelfHostedExpansionRHSCard__warning'}/>,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
{overMaxSeats && maxAdditionalSeats > 0 &&
|
||||
<FormattedMessage
|
||||
id='self_hosted_expansion_rhs_card_maximum_seats_warning'
|
||||
defaultMessage='{warningIcon} You may only expand by an additional {maxAdditionalSeats} seats'
|
||||
values={{
|
||||
maxAdditionalSeats,
|
||||
warningIcon: <WarningIcon additionalClassName={'SelfHostedExpansionRHSCard__warning'}/>,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
{maxAdditionalSeats === 0 &&
|
||||
<FormattedMessage
|
||||
id='self_hosted_expansion_rhs_card_additional_seats_limit_warning'
|
||||
defaultMessage='{warningIcon} Transaction amount limit reached.{break}Please contact sales'
|
||||
values={{
|
||||
break: <br/>,
|
||||
warningIcon: <WarningIcon additionalClassName={'SelfHostedExpansionRHSCard__warning'}/>,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
<div className='SelfHostedExpansionRHSCard__cost_breakdown'>
|
||||
<div className='costPerUser'>
|
||||
<FormattedMessage
|
||||
id='self_hosted_expansion_rhs_card_cost_per_user_title'
|
||||
defaultMessage='Cost per user'
|
||||
/>
|
||||
<br/>
|
||||
<FormattedMessage
|
||||
id='self_hosted_expansion_rhs_card_cost_per_user_breakdown'
|
||||
defaultMessage='{costPerUser} x {monthsUntilExpiry} months'
|
||||
values={{
|
||||
costPerUser: getMonthlyPrice().toFixed(2),
|
||||
monthsUntilExpiry: getMonthsUntilExpiry(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='costAmount'>
|
||||
<span>{'$' + getCostPerUser().toFixed(2)}</span>
|
||||
</div>
|
||||
<div className='totalCost'>
|
||||
<FormattedMessage
|
||||
id='self_hosted_expansion_rhs_card_total_title'
|
||||
defaultMessage='Total'
|
||||
/>
|
||||
<br/>
|
||||
<FormattedMessage
|
||||
id='self_hosted_expansion_rhs_card_total_prorated_warning'
|
||||
defaultMessage='The total will be prorated'
|
||||
/>
|
||||
</div>
|
||||
<span className='costAmount'>
|
||||
<span>{'$' + getTotal().toFixed(2)}</span>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
className='btn btn-primary SelfHostedExpansionRHSCard__CompletePurchaseButton'
|
||||
disabled={!props.canSubmit || maxAdditionalSeats === 0}
|
||||
onClick={props.submit}
|
||||
>
|
||||
<FormattedMessage
|
||||
id='self_hosted_expansion_rhs_complete_button'
|
||||
defaultMessage='Complete purchase'
|
||||
/>
|
||||
</button>
|
||||
<div className='SelfHostedExpansionRHSCard__ChargedTodayDisclaimer'>
|
||||
<FormattedMessage
|
||||
id='self_hosted_expansion_rhs_credit_card_charge_today_warning'
|
||||
defaultMessage='Your credit card will be charged today.<see_how_billing_works>See how billing works.</see_how_billing_works>'
|
||||
values={{
|
||||
see_how_billing_works: (text: string) => (
|
||||
<Fragment>
|
||||
<br/>
|
||||
<ExternalLink
|
||||
href={DocLinks.SELF_HOSTED_BILLING}
|
||||
>
|
||||
{text}
|
||||
</ExternalLink>
|
||||
</Fragment>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,418 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {screen, fireEvent} from '@testing-library/react';
|
||||
|
||||
import {GlobalState} from 'types/store';
|
||||
|
||||
import {SelfHostedSignupForm, SelfHostedSignupProgress} from '@mattermost/types/hosted_customer';
|
||||
|
||||
import {renderWithIntlAndStore} from 'tests/react_testing_utils';
|
||||
import {TestHelper as TH} from 'utils/test_helper';
|
||||
import {SelfHostedProducts, ModalIdentifiers} from 'utils/constants';
|
||||
|
||||
import {DeepPartial} from '@mattermost/types/utilities';
|
||||
|
||||
import SelfHostedExpansionModal, {makeInitialState, canSubmit, FormState} from './';
|
||||
|
||||
interface MockCardInputProps {
|
||||
onCardInputChange: (event: {complete: boolean}) => void;
|
||||
forwardedRef: React.MutableRefObject<any>;
|
||||
}
|
||||
|
||||
// number borrowed from stripe
|
||||
const successCardNumber = '4242424242424242';
|
||||
function MockCardInput(props: MockCardInputProps) {
|
||||
props.forwardedRef.current = {
|
||||
getCard: () => ({}),
|
||||
};
|
||||
return (
|
||||
<input
|
||||
placeholder='Card number'
|
||||
type='text'
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (e.target.value === successCardNumber) {
|
||||
props.onCardInputChange({complete: true});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
jest.mock('components/payment_form/card_input', () => {
|
||||
const original = jest.requireActual('components/payment_form/card_input');
|
||||
return {
|
||||
...original,
|
||||
__esModule: true,
|
||||
default: MockCardInput,
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('components/self_hosted_purchase_modal/stripe_provider', () => {
|
||||
return function(props: {children: React.ReactNode | React.ReactNodeArray}) {
|
||||
return props.children;
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('components/common/hooks/useLoadStripe', () => {
|
||||
return function() {
|
||||
return {current: {
|
||||
stripe: {},
|
||||
|
||||
}};
|
||||
};
|
||||
});
|
||||
|
||||
const mockCreatedIntent = SelfHostedSignupProgress.CREATED_INTENT;
|
||||
const mockCreatedLicense = SelfHostedSignupProgress.CREATED_LICENSE;
|
||||
const failOrg = 'failorg';
|
||||
|
||||
const existingUsers = 10;
|
||||
|
||||
const mockProfessionalProduct = TH.getProductMock({
|
||||
id: 'prod_professional',
|
||||
name: 'Professional',
|
||||
sku: SelfHostedProducts.PROFESSIONAL,
|
||||
price_per_seat: 7.5,
|
||||
});
|
||||
|
||||
jest.mock('mattermost-redux/client', () => {
|
||||
const original = jest.requireActual('mattermost-redux/client');
|
||||
return {
|
||||
__esModule: true,
|
||||
...original,
|
||||
Client4: {
|
||||
...original.Client4,
|
||||
pageVisited: jest.fn(),
|
||||
setAcceptLanguage: jest.fn(),
|
||||
trackEvent: jest.fn(),
|
||||
createCustomerSelfHostedSignup: (form: SelfHostedSignupForm) => {
|
||||
if (form.organization === failOrg) {
|
||||
throw new Error('error creating customer');
|
||||
}
|
||||
return Promise.resolve({
|
||||
progress: mockCreatedIntent,
|
||||
});
|
||||
},
|
||||
confirmSelfHostedSignup: () => Promise.resolve({
|
||||
progress: mockCreatedLicense,
|
||||
license: {Users: existingUsers * 2},
|
||||
}),
|
||||
getClientLicenseOld: () => Promise.resolve({
|
||||
data: {Sku: 'Enterprise'},
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('components/payment_form/stripe', () => {
|
||||
const original = jest.requireActual('components/payment_form/stripe');
|
||||
return {
|
||||
__esModule: true,
|
||||
...original,
|
||||
getConfirmCardSetup: () => () => () => ({setupIntent: {status: 'succeeded'}, error: null}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('utils/hosted_customer', () => {
|
||||
const original = jest.requireActual('utils/hosted_customer');
|
||||
return {
|
||||
__esModule: true,
|
||||
...original,
|
||||
findSelfHostedProductBySku: () => {
|
||||
return mockProfessionalProduct;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const productName = SelfHostedProducts.PROFESSIONAL;
|
||||
|
||||
const initialState: DeepPartial<GlobalState> = {
|
||||
views: {
|
||||
modals: {
|
||||
modalState: {
|
||||
[ModalIdentifiers.SELF_HOSTED_EXPANSION]: {
|
||||
open: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
storage: {
|
||||
storage: {},
|
||||
},
|
||||
entities: {
|
||||
admin: {
|
||||
analytics: {
|
||||
TOTAL_USERS: existingUsers,
|
||||
},
|
||||
},
|
||||
teams: {
|
||||
currentTeamId: '',
|
||||
},
|
||||
preferences: {
|
||||
myPreferences: {
|
||||
theme: {},
|
||||
},
|
||||
},
|
||||
general: {
|
||||
config: {
|
||||
EnableDeveloper: 'false',
|
||||
},
|
||||
license: {
|
||||
Sku: productName,
|
||||
Users: '50',
|
||||
},
|
||||
},
|
||||
cloud: {
|
||||
subscription: {},
|
||||
},
|
||||
users: {
|
||||
currentUserId: 'adminUserId',
|
||||
profiles: {
|
||||
adminUserId: TH.getUserMock({
|
||||
id: 'adminUserId',
|
||||
roles: 'admin',
|
||||
first_name: 'first',
|
||||
last_name: 'admin',
|
||||
}),
|
||||
otherUserId: TH.getUserMock({
|
||||
id: 'otherUserId',
|
||||
roles: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
}),
|
||||
},
|
||||
filteredStats: {
|
||||
total_users_count: 100,
|
||||
},
|
||||
},
|
||||
hostedCustomer: {
|
||||
products: {
|
||||
productsLoaded: true,
|
||||
products: {
|
||||
prod_professional: mockProfessionalProduct,
|
||||
},
|
||||
},
|
||||
signupProgress: SelfHostedSignupProgress.START,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const valueEvent = (value: any) => ({target: {value}});
|
||||
function changeByPlaceholder(sel: string, val: any) {
|
||||
fireEvent.change(screen.getByPlaceholderText(sel), valueEvent(val));
|
||||
}
|
||||
|
||||
function selectDropdownValue(testId: string, value: string) {
|
||||
fireEvent.change(screen.getByTestId(testId).querySelector('input') as any, valueEvent(value));
|
||||
fireEvent.click(screen.getByTestId(testId).querySelector('.DropDown__option--is-focused') as any);
|
||||
}
|
||||
|
||||
function changeByTestId(testId: string, value: string) {
|
||||
fireEvent.change(screen.getByTestId(testId).querySelector('input') as any, valueEvent(value));
|
||||
}
|
||||
|
||||
interface PurchaseForm {
|
||||
card: string;
|
||||
org: string;
|
||||
name: string;
|
||||
country: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
zip: string;
|
||||
seats: string;
|
||||
}
|
||||
|
||||
const defaultSuccessForm: PurchaseForm = {
|
||||
card: successCardNumber,
|
||||
org: 'My org',
|
||||
name: 'The Cardholder',
|
||||
country: 'United States of America',
|
||||
address: '123 Main Street',
|
||||
city: 'Minneapolis',
|
||||
state: 'MN',
|
||||
zip: '55423',
|
||||
seats: '10',
|
||||
};
|
||||
|
||||
function fillForm(form: PurchaseForm) {
|
||||
changeByPlaceholder('Card number', form.card);
|
||||
changeByPlaceholder('Organization Name', form.org);
|
||||
changeByPlaceholder('Name on Card', form.name);
|
||||
selectDropdownValue('selfHostedExpansionCountrySelector', form.country);
|
||||
changeByPlaceholder('Address', form.address);
|
||||
changeByPlaceholder('City', form.city);
|
||||
selectDropdownValue('selfHostedExpansionStateSelector', form.state);
|
||||
changeByPlaceholder('Zip/Postal Code', form.zip);
|
||||
changeByTestId('seatsInput', form.seats);
|
||||
|
||||
expect(document.getElementsByClassName('SelfHostedExpansionRHSCard__AddSeatsWarning')[0] as HTMLElement).toBeEnabled();
|
||||
|
||||
// not changing the license seats number,
|
||||
// because it is expected to be pre-filled with the correct number of seats.
|
||||
|
||||
const completeButton = screen.getByText('Complete purchase');
|
||||
|
||||
if (form === defaultSuccessForm) {
|
||||
expect(completeButton).toBeEnabled();
|
||||
}
|
||||
|
||||
return completeButton;
|
||||
}
|
||||
|
||||
describe('SelfHostedExpansionModal', () => {
|
||||
it('renders the form', () => {
|
||||
renderWithIntlAndStore(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
|
||||
|
||||
screen.getByText('Provide your payment details');
|
||||
screen.getByText('Add new seats');
|
||||
screen.getByText('Contact Sales');
|
||||
screen.getByText('Cost per user', {exact: false});
|
||||
|
||||
// screen.getByText(productName, {normalizer: (val) => {return val.charAt(0).toUpperCase() + val.slice(1)}});
|
||||
screen.getByText('Your credit card will be charged today.');
|
||||
screen.getByText('See how billing works', {exact: false});
|
||||
});
|
||||
|
||||
it('filling the form enables expansion', () => {
|
||||
renderWithIntlAndStore(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
|
||||
expect(screen.getByText('Complete purchase')).toBeDisabled();
|
||||
fillForm(defaultSuccessForm);
|
||||
});
|
||||
|
||||
it('disables expansion if too few seats or no seats entered', () => {
|
||||
renderWithIntlAndStore(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
|
||||
fillForm(defaultSuccessForm);
|
||||
|
||||
// 0 seats entered.
|
||||
const tooFewSeats = 0;
|
||||
fireEvent.change(screen.getByTestId('seatsInput').querySelector('input') as HTMLElement, valueEvent(tooFewSeats.toString()));
|
||||
expect(screen.getByText('Complete purchase')).toBeDisabled();
|
||||
expect(screen.getByText('You must add a seat to continue')).toBeVisible();
|
||||
|
||||
// No seats value entered.
|
||||
fireEvent.change(screen.getByTestId('seatsInput').querySelector('input') as HTMLElement, undefined);
|
||||
expect(screen.getByText('Complete purchase')).toBeDisabled();
|
||||
expect(screen.getByText('You must add a seat to continue')).toBeVisible();
|
||||
});
|
||||
|
||||
// it('happy path submit shows success screen', async () => {
|
||||
// renderWithIntlAndStore(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
|
||||
// expect(screen.getByText('Complete purchase')).toBeDisabled();
|
||||
// const upgradeButton = fillForm(defaultSuccessForm);
|
||||
|
||||
// upgradeButton.click();
|
||||
// await waitFor(() => expect(screen.getByText(`You're now subscribed to ${productName}`)).toBeTruthy(), {timeout: 1234});
|
||||
// });
|
||||
|
||||
// it('sad path submit shows error screen', async () => {
|
||||
// renderWithIntlAndStore(<div id='root-portal'><SelfHostedExpansionModal/></div>, initialState);
|
||||
// expect(screen.getByText('Complete purchase')).toBeDisabled();
|
||||
// fillForm(defaultSuccessForm);
|
||||
// changeByPlaceholder('Organization Name', failOrg);
|
||||
|
||||
// const upgradeButton = screen.getByText('Complete purchase');
|
||||
// expect(upgradeButton).toBeEnabled();
|
||||
// upgradeButton.click();
|
||||
// await waitFor(() => expect(screen.getByText('Sorry, the payment verification failed')).toBeTruthy(), {timeout: 1234});
|
||||
// });
|
||||
});
|
||||
|
||||
describe('SelfHostedExpansionModal :: canSubmit', () => {
|
||||
function makeHappyPathState(): FormState {
|
||||
return {
|
||||
address: 'string',
|
||||
address2: 'string',
|
||||
city: 'string',
|
||||
state: 'string',
|
||||
country: 'string',
|
||||
postalCode: '12345',
|
||||
cardName: 'string',
|
||||
organization: 'string',
|
||||
cardFilled: true,
|
||||
seats: 1,
|
||||
submitting: false,
|
||||
succeeded: false,
|
||||
progressBar: 0,
|
||||
error: '',
|
||||
};
|
||||
}
|
||||
it('if submitting, can not submit again', () => {
|
||||
const state = makeHappyPathState();
|
||||
state.submitting = true;
|
||||
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_LICENSE)).toBe(false);
|
||||
});
|
||||
|
||||
it('if created license, can submit', () => {
|
||||
const state = makeInitialState(1);
|
||||
state.submitting = false;
|
||||
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_LICENSE)).toBe(true);
|
||||
});
|
||||
|
||||
it('if paid, can submit', () => {
|
||||
const state = makeInitialState(1);
|
||||
state.submitting = false;
|
||||
expect(canSubmit(state, SelfHostedSignupProgress.PAID)).toBe(true);
|
||||
});
|
||||
|
||||
// TODO: Needed?
|
||||
it('if created subscription, can submit', () => {
|
||||
const state = makeInitialState(1);
|
||||
state.submitting = false;
|
||||
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_SUBSCRIPTION)).toBe(true);
|
||||
});
|
||||
|
||||
it('if all details filled and card has not been confirmed, can submit', () => {
|
||||
const state = makeHappyPathState();
|
||||
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(true);
|
||||
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(true);
|
||||
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(true);
|
||||
});
|
||||
|
||||
it('if card name missing and card has not been confirmed, can not submit', () => {
|
||||
const state = makeHappyPathState();
|
||||
state.cardName = '';
|
||||
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false);
|
||||
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false);
|
||||
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false);
|
||||
});
|
||||
|
||||
it('if card number missing and card has not been confirmed, can not submit', () => {
|
||||
const state = makeHappyPathState();
|
||||
state.cardFilled = false;
|
||||
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false);
|
||||
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false);
|
||||
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false);
|
||||
});
|
||||
|
||||
it('if address not filled and card has not been confirmed, can not submit', () => {
|
||||
const state = makeHappyPathState();
|
||||
state.address = '';
|
||||
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false);
|
||||
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false);
|
||||
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false);
|
||||
});
|
||||
|
||||
it('if seats not valid and card has not been confirmed, can not submit', () => {
|
||||
const state = makeHappyPathState();
|
||||
state.seats = 0;
|
||||
expect(canSubmit(state, SelfHostedSignupProgress.START)).toBe(false);
|
||||
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_CUSTOMER)).toBe(false);
|
||||
expect(canSubmit(state, SelfHostedSignupProgress.CREATED_INTENT)).toBe(false);
|
||||
});
|
||||
|
||||
it('if card confirmed, card not required for submission', () => {
|
||||
const state = makeHappyPathState();
|
||||
state.cardFilled = false;
|
||||
state.cardName = '';
|
||||
expect(canSubmit(state, SelfHostedSignupProgress.CONFIRMED_INTENT)).toBe(true);
|
||||
});
|
||||
|
||||
it('if passed unknown progress status, can not submit', () => {
|
||||
const state = makeHappyPathState();
|
||||
expect(canSubmit(state, 'unknown status' as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
503
webapp/channels/src/components/self_hosted_expansion_modal/index.tsx
Обычный файл
503
webapp/channels/src/components/self_hosted_expansion_modal/index.tsx
Обычный файл
@@ -0,0 +1,503 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useEffect, useRef, useState} from 'react';
|
||||
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import {StripeCardElementChangeEvent} from '@stripe/stripe-js';
|
||||
|
||||
import UpgradeSvg from 'components/common/svg_images_components/upgrade_svg';
|
||||
import RootPortal from 'components/root_portal';
|
||||
import ContactSalesLink from 'components/self_hosted_purchase_modal/contact_sales_link';
|
||||
|
||||
import useLoadStripe from 'components/common/hooks/useLoadStripe';
|
||||
import CardInput, {CardInputType} from 'components/payment_form/card_input';
|
||||
import FullScreenModal from 'components/widgets/modals/full_screen_modal';
|
||||
import Input from 'components/widgets/inputs/input/input';
|
||||
|
||||
import BackgroundSvg from 'components/common/svg_images_components/background_svg';
|
||||
import {COUNTRIES} from 'utils/countries';
|
||||
import StateSelector from 'components/payment_form/state_selector';
|
||||
import {getTheme} from 'mattermost-redux/selectors/entities/preferences';
|
||||
import DropdownInput from 'components/dropdown_input';
|
||||
import StripeProvider from '../self_hosted_purchase_modal/stripe_provider';
|
||||
|
||||
import {closeModal} from 'actions/views/modals';
|
||||
import {ModalIdentifiers, TELEMETRY_CATEGORIES} from 'utils/constants';
|
||||
import {getLicense} from 'mattermost-redux/selectors/entities/general';
|
||||
import {getCurrentUser, getFilteredUsersStats} from 'mattermost-redux/selectors/entities/users';
|
||||
import {pageVisited} from 'actions/telemetry_actions';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
import {HostedCustomerTypes} from 'mattermost-redux/action_types';
|
||||
import {getSelfHostedSignupProgress} from 'mattermost-redux/selectors/entities/hosted_customer';
|
||||
import {inferNames} from 'utils/hosted_customer';
|
||||
import {SelfHostedSignupCustomerResponse, SelfHostedSignupProgress} from '@mattermost/types/hosted_customer';
|
||||
import {isDevModeEnabled} from 'selectors/general';
|
||||
import {getLicenseConfig} from 'mattermost-redux/actions/general';
|
||||
import {confirmSelfHostedExpansion} from 'actions/hosted_customer';
|
||||
import {DispatchFunc} from 'mattermost-redux/types/actions';
|
||||
import {ValueOf} from '@mattermost/types/utilities';
|
||||
|
||||
import SelfHostedExpansionCard from './expansion_card';
|
||||
|
||||
import './self_hosted_expansion_modal.scss';
|
||||
|
||||
import {STORAGE_KEY_EXPANSION_IN_PROGRESS} from './constants';
|
||||
|
||||
export interface FormState {
|
||||
address: string;
|
||||
address2: string;
|
||||
city: string;
|
||||
state: string;
|
||||
country: string;
|
||||
postalCode: string;
|
||||
cardName: string;
|
||||
organization: string;
|
||||
cardFilled: boolean;
|
||||
seats: number;
|
||||
submitting: boolean;
|
||||
succeeded: boolean;
|
||||
progressBar: number;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export function makeInitialState(seats: number): FormState {
|
||||
return {
|
||||
address: '',
|
||||
address2: '',
|
||||
city: '',
|
||||
state: '',
|
||||
country: '',
|
||||
postalCode: '',
|
||||
cardName: '',
|
||||
organization: '',
|
||||
cardFilled: false,
|
||||
seats,
|
||||
submitting: false,
|
||||
succeeded: false,
|
||||
progressBar: 0,
|
||||
error: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function canSubmit(formState: FormState, progress: ValueOf<typeof SelfHostedSignupProgress>) {
|
||||
if (formState.submitting) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const validAddress = Boolean(
|
||||
formState.organization &&
|
||||
formState.address &&
|
||||
formState.city &&
|
||||
formState.state &&
|
||||
formState.postalCode &&
|
||||
formState.country,
|
||||
);
|
||||
const validCard = Boolean(
|
||||
formState.cardName &&
|
||||
formState.cardFilled,
|
||||
);
|
||||
const validSeats = formState.seats > 0;
|
||||
|
||||
switch (progress) {
|
||||
case SelfHostedSignupProgress.PAID:
|
||||
case SelfHostedSignupProgress.CREATED_LICENSE:
|
||||
case SelfHostedSignupProgress.CREATED_SUBSCRIPTION:
|
||||
return true;
|
||||
case SelfHostedSignupProgress.CONFIRMED_INTENT: {
|
||||
return Boolean(
|
||||
validAddress &&
|
||||
validSeats,
|
||||
);
|
||||
}
|
||||
case SelfHostedSignupProgress.START:
|
||||
case SelfHostedSignupProgress.CREATED_CUSTOMER:
|
||||
case SelfHostedSignupProgress.CREATED_INTENT:
|
||||
return Boolean(
|
||||
validCard &&
|
||||
validAddress &&
|
||||
validSeats,
|
||||
);
|
||||
default: {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function SelfHostedExpansionModal() {
|
||||
const dispatch = useDispatch<DispatchFunc>();
|
||||
const intl = useIntl();
|
||||
const cardRef = useRef<CardInputType | null>(null);
|
||||
const theme = useSelector(getTheme);
|
||||
const progress = useSelector(getSelfHostedSignupProgress);
|
||||
const user = useSelector(getCurrentUser);
|
||||
const isDevMode = useSelector(isDevModeEnabled);
|
||||
|
||||
const license = useSelector(getLicense);
|
||||
const licensedSeats = parseInt(license.Users, 10);
|
||||
const activeUsers = useSelector(getFilteredUsersStats)?.total_users_count || 0;
|
||||
const [additionalSeats, setAdditionalSeats] = useState(activeUsers <= licensedSeats ? 1 : activeUsers - licensedSeats);
|
||||
|
||||
const [stripeLoadHint, setStripeLoadHint] = useState(Math.random());
|
||||
const stripeRef = useLoadStripe(stripeLoadHint);
|
||||
|
||||
const initialState = makeInitialState(additionalSeats);
|
||||
const [formState, setFormState] = useState<FormState>(initialState);
|
||||
const [show] = useState(true);
|
||||
|
||||
const title = intl.formatMessage({
|
||||
id: 'self_hosted_expansion.expansion_modal.title',
|
||||
defaultMessage: 'Provide your payment details',
|
||||
});
|
||||
|
||||
const canSubmitForm = canSubmit(formState, progress);
|
||||
|
||||
const submit = async () => {
|
||||
let submitProgress = progress;
|
||||
let signupCustomerResult: SelfHostedSignupCustomerResponse | null = null;
|
||||
try {
|
||||
const [firstName, lastName] = inferNames(user, formState.cardName);
|
||||
|
||||
signupCustomerResult = await Client4.createCustomerSelfHostedSignup({
|
||||
first_name: firstName,
|
||||
last_name: lastName,
|
||||
billing_address: {
|
||||
city: formState.city,
|
||||
country: formState.country,
|
||||
line1: formState.address,
|
||||
line2: formState.address2,
|
||||
postal_code: formState.postalCode,
|
||||
state: formState.state,
|
||||
},
|
||||
organization: formState.organization,
|
||||
});
|
||||
} catch {
|
||||
setFormState({...formState, error: 'Failed to submit payment information'});
|
||||
return;
|
||||
}
|
||||
|
||||
if (signupCustomerResult === null) {
|
||||
setStripeLoadHint(Math.random());
|
||||
setFormState({...formState, submitting: false});
|
||||
return;
|
||||
}
|
||||
|
||||
if (progress === SelfHostedSignupProgress.START || progress === SelfHostedSignupProgress.CREATED_CUSTOMER) {
|
||||
dispatch({
|
||||
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
|
||||
data: signupCustomerResult.progress,
|
||||
});
|
||||
submitProgress = signupCustomerResult.progress;
|
||||
}
|
||||
if (stripeRef.current === null) {
|
||||
setStripeLoadHint(Math.random());
|
||||
setFormState({...formState, submitting: false});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const card = cardRef.current?.getCard();
|
||||
if (!card) {
|
||||
const message = 'Failed to get card when it was expected';
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(message);
|
||||
setFormState({...formState, error: message});
|
||||
return;
|
||||
}
|
||||
const finished = await dispatch(confirmSelfHostedExpansion(
|
||||
stripeRef.current,
|
||||
{
|
||||
id: signupCustomerResult.setup_intent_id,
|
||||
client_secret: signupCustomerResult.setup_intent_secret,
|
||||
},
|
||||
isDevMode,
|
||||
{
|
||||
address: formState.address,
|
||||
address2: formState.address2,
|
||||
city: formState.city,
|
||||
state: formState.state,
|
||||
country: formState.country,
|
||||
postalCode: formState.postalCode,
|
||||
name: formState.cardName,
|
||||
card,
|
||||
},
|
||||
submitProgress,
|
||||
{
|
||||
seats: formState.seats,
|
||||
},
|
||||
));
|
||||
|
||||
if (finished.data) {
|
||||
setFormState({...formState, succeeded: true});
|
||||
|
||||
dispatch({
|
||||
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
|
||||
data: SelfHostedSignupProgress.CREATED_LICENSE,
|
||||
});
|
||||
|
||||
// Reload license in background.
|
||||
// Needed if this was completed while on the Edition and License page.
|
||||
dispatch(getLicenseConfig());
|
||||
} else if (finished.error) {
|
||||
let errorData = finished.error;
|
||||
if (finished.error === 422) {
|
||||
errorData = finished.error.toString();
|
||||
}
|
||||
setFormState({...formState, error: errorData});
|
||||
return;
|
||||
}
|
||||
setFormState({...formState, submitting: false});
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('could not complete setup', e);
|
||||
setFormState({...formState, error: 'unable to complete signup'});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
pageVisited(
|
||||
TELEMETRY_CATEGORIES.SELF_HOSTED_EXPANSION,
|
||||
'pageview_self_hosted_expansion',
|
||||
);
|
||||
|
||||
localStorage.setItem(STORAGE_KEY_EXPANSION_IN_PROGRESS, 'true');
|
||||
return () => {
|
||||
localStorage.removeItem(STORAGE_KEY_EXPANSION_IN_PROGRESS);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resetToken = () => {
|
||||
try {
|
||||
Client4.bootstrapSelfHostedSignup(true).
|
||||
then((data) => {
|
||||
dispatch({
|
||||
type: HostedCustomerTypes.RECEIVED_SELF_HOSTED_SIGNUP_PROGRESS,
|
||||
data: data.progress,
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// swallow error ok here
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<StripeProvider
|
||||
stripeRef={stripeRef}
|
||||
>
|
||||
<RootPortal>
|
||||
<FullScreenModal
|
||||
show={show}
|
||||
ariaLabelledBy='self_hosted_expansion_modal_title'
|
||||
onClose={() => {
|
||||
dispatch(closeModal(ModalIdentifiers.SELF_HOSTED_EXPANSION));
|
||||
resetToken();
|
||||
}}
|
||||
>
|
||||
<div className='SelfHostedExpansionModal'>
|
||||
<div className='form-view'>
|
||||
<div className='lhs'>
|
||||
<h2 className='title'>{title}</h2>
|
||||
<UpgradeSvg
|
||||
width={267}
|
||||
height={227}
|
||||
/>
|
||||
<div className='footer-text'>{'Questions?'}</div>
|
||||
<ContactSalesLink/>
|
||||
</div>
|
||||
<div className='center'>
|
||||
<div
|
||||
className='form'
|
||||
data-testid='shpm-form'
|
||||
>
|
||||
<span className='section-title'>
|
||||
{intl.formatMessage({
|
||||
id: 'payment_form.credit_card',
|
||||
defaultMessage: 'Credit Card',
|
||||
})}
|
||||
</span>
|
||||
<div className='form-row'>
|
||||
<CardInput
|
||||
forwardedRef={cardRef}
|
||||
required={true}
|
||||
onCardInputChange={(event: StripeCardElementChangeEvent) => {
|
||||
setFormState({...formState, cardFilled: event.complete});
|
||||
}}
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-row'>
|
||||
<Input
|
||||
name='organization'
|
||||
type='text'
|
||||
value={formState.organization}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormState({...formState, organization: e.target.value});
|
||||
}}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'self_hosted_signup.organization',
|
||||
defaultMessage: 'Organization Name',
|
||||
})}
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-row'>
|
||||
<Input
|
||||
name='name'
|
||||
type='text'
|
||||
value={formState.cardName}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormState({...formState, cardName: e.target.value});
|
||||
}}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'payment_form.name_on_card',
|
||||
defaultMessage: 'Name on Card',
|
||||
})}
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
<span className='section-title'>
|
||||
{intl.formatMessage({
|
||||
id: 'payment_form.billing_address',
|
||||
defaultMessage: 'Billing address',
|
||||
})}
|
||||
</span>
|
||||
<DropdownInput
|
||||
testId='selfHostedExpansionCountrySelector'
|
||||
onChange={(option: {value: string}) => {
|
||||
setFormState({...formState, country: option.value});
|
||||
}}
|
||||
value={
|
||||
formState.country ? {value: formState.country, label: formState.country} : undefined
|
||||
}
|
||||
options={COUNTRIES.map((country) => ({
|
||||
value: country.name,
|
||||
label: country.name,
|
||||
}))}
|
||||
legend={intl.formatMessage({
|
||||
id: 'payment_form.country',
|
||||
defaultMessage: 'Country',
|
||||
})}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'payment_form.country',
|
||||
defaultMessage: 'Country',
|
||||
})}
|
||||
name={'billing_dropdown'}
|
||||
/>
|
||||
<div className='form-row'>
|
||||
<Input
|
||||
name='address'
|
||||
type='text'
|
||||
value={formState.address}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormState({...formState, address: e.target.value});
|
||||
}}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'payment_form.address',
|
||||
defaultMessage: 'Address',
|
||||
})}
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-row'>
|
||||
<Input
|
||||
name='address2'
|
||||
type='text'
|
||||
value={formState.address2}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormState({...formState, address2: e.target.value});
|
||||
}}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'payment_form.address_2',
|
||||
defaultMessage: 'Address 2',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-row'>
|
||||
<Input
|
||||
name='city'
|
||||
type='text'
|
||||
value={formState.city}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormState({...formState, city: e.target.value});
|
||||
}}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'payment_form.city',
|
||||
defaultMessage: 'City',
|
||||
})}
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-row'>
|
||||
<div className='form-row-third-1'>
|
||||
<StateSelector
|
||||
testId='selfHostedExpansionStateSelector'
|
||||
country={formState.country}
|
||||
state={formState.state}
|
||||
onChange={(state: string) => {
|
||||
setFormState({...formState, state});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className='form-row-third-2'>
|
||||
<Input
|
||||
name='postalCode'
|
||||
type='text'
|
||||
value={formState.postalCode}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setFormState({...formState, postalCode: e.target.value});
|
||||
}}
|
||||
placeholder={intl.formatMessage({
|
||||
id: 'payment_form.zipcode',
|
||||
defaultMessage: 'Zip/Postal Code',
|
||||
})}
|
||||
required={true}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='rhs'>
|
||||
<SelfHostedExpansionCard
|
||||
updateSeats={(seats: number) => {
|
||||
setFormState({...formState, seats});
|
||||
setAdditionalSeats(seats);
|
||||
}}
|
||||
canSubmit={canSubmitForm}
|
||||
submit={submit}
|
||||
licensedSeats={licensedSeats}
|
||||
initialSeats={additionalSeats}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* {((formState.succeeded || progress === SelfHostedSignupProgress.CREATED_LICENSE) && hasLicense) && !formState.error && !formState.submitting && (
|
||||
<SuccessPage
|
||||
onClose={controlModal.close}
|
||||
planName={desiredPlanName}
|
||||
/>
|
||||
)}
|
||||
{formState.submitting && (
|
||||
<Submitting
|
||||
desiredPlanName={desiredPlanName}
|
||||
progressBar={formState.progressBar}
|
||||
/>
|
||||
)}
|
||||
{formState.error && (
|
||||
<ErrorPage
|
||||
nextAction={errorAction}
|
||||
canRetry={canRetry}
|
||||
errorType={canRetry ? 'generic' : 'failed_export'}
|
||||
/>
|
||||
)} */}
|
||||
<div className='background-svg'>
|
||||
<BackgroundSvg/>
|
||||
</div>
|
||||
</div>
|
||||
</FullScreenModal>
|
||||
</RootPortal>
|
||||
</StripeProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
.SelfHostedExpansionModal {
|
||||
height: 100%;
|
||||
|
||||
.form-view {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex-direction: row;
|
||||
flex-grow: 1;
|
||||
flex-wrap: wrap;
|
||||
align-content: top;
|
||||
justify-content: center;
|
||||
padding: 77px 107px;
|
||||
color: var(--center-channel-color);
|
||||
font-family: "Open Sans";
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
|
||||
.title {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form {
|
||||
padding: 0 96px;
|
||||
margin: 0 auto;
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.form-row-third-1 {
|
||||
width: 66%;
|
||||
max-width: 288px;
|
||||
margin-right: 16px;
|
||||
|
||||
.DropdownInput {
|
||||
z-index: 99999;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.DropdownInput {
|
||||
position: relative;
|
||||
z-index: 999999;
|
||||
height: 36px;
|
||||
margin-bottom: 24px;
|
||||
|
||||
.Input_fieldset {
|
||||
height: 43px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-row-third-2 {
|
||||
width: 34%;
|
||||
max-width: 144px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin-bottom: 24px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.Input_fieldset {
|
||||
height: 40px;
|
||||
padding: 2px 1px;
|
||||
background: var(--center-channel-bg);
|
||||
|
||||
.Input {
|
||||
height: 32px;
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
.Input_wrapper {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
>.lhs {
|
||||
width: 25%;
|
||||
}
|
||||
|
||||
>.center {
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
>.rhs {
|
||||
position: sticky;
|
||||
display: flex;
|
||||
width: 25%;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.submitting,
|
||||
.success,
|
||||
.failed {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex-direction: row;
|
||||
flex-grow: 1;
|
||||
flex-wrap: wrap;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
padding: 77px 107px;
|
||||
color: var(--center-channel-color);
|
||||
font-family: "Open Sans";
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
|
||||
.IconMessage .content .IconMessage-link {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.background-svg {
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
>div {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.self-hosted-agreed-terms {
|
||||
label {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
input[type=checkbox] {
|
||||
margin-right: 12px;
|
||||
}
|
||||
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1020px) {
|
||||
.SelfHostedExpansionModal {
|
||||
.form-view {
|
||||
>.lhs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
>.center {
|
||||
width: 66%;
|
||||
}
|
||||
|
||||
>.rhs {
|
||||
width: 33%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.FullScreenModal {
|
||||
.close-x {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
.SelfHostedPurchaseModal__success {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
flex-direction: row;
|
||||
flex-grow: 1;
|
||||
flex-wrap: wrap;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
padding: 77px 107px;
|
||||
color: var(--center-channel-color);
|
||||
font-family: "Open Sans";
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.self_hosted_expansion_success {
|
||||
margin-top: 163px;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// 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 {NavLink} from 'react-router-dom';
|
||||
|
||||
import {useDispatch} from 'react-redux';
|
||||
|
||||
import IconMessage from 'components/purchase_modal/icon_message';
|
||||
import PaymentSuccessStandardSvg from 'components/common/svg_images_components/payment_success_standard_svg';
|
||||
import {ConsolePages, ModalIdentifiers} from 'utils/constants';
|
||||
import BackgroundSvg from 'components/common/svg_images_components/background_svg';
|
||||
import {closeModal} from 'actions/views/modals';
|
||||
|
||||
import './success_page.scss';
|
||||
|
||||
export default function SelfHostedExpansionSuccessPage() {
|
||||
const dispatch = useDispatch();
|
||||
const titleText = (
|
||||
<FormattedMessage
|
||||
id={'self_hosted_expansion.expand_success'}
|
||||
defaultMessage={'You\'ve successfully updated your license seat count'}
|
||||
/>
|
||||
);
|
||||
|
||||
const formattedSubtitleText = (
|
||||
<FormattedMessage
|
||||
id={'self_hosted_expansion.license_applied'}
|
||||
defaultMessage={'The license has been automatically applied to your Mattermost instance. Your updated invoice will be visible in the <billing>Billing section</billing> of the system console.'}
|
||||
values={{
|
||||
billing: (billingText: React.ReactNode) => (
|
||||
<NavLink
|
||||
to={ConsolePages.BILLING_HISTORY}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
{billingText}
|
||||
</NavLink>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
const formattedButtonText = (
|
||||
<FormattedMessage
|
||||
id={'self_hosted_expansion.close'}
|
||||
defaultMessage={'Close'}
|
||||
/>
|
||||
);
|
||||
|
||||
const icon = (
|
||||
<PaymentSuccessStandardSvg
|
||||
width={444}
|
||||
height={313}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className='self_hosted_expansion_success'>
|
||||
<IconMessage
|
||||
className={'selfHostedExpansionModal__success'}
|
||||
formattedTitle={titleText}
|
||||
formattedSubtitle={formattedSubtitleText}
|
||||
testId='selfHostedExpansionSuccess'
|
||||
icon={icon}
|
||||
formattedButtonText={formattedButtonText}
|
||||
buttonHandler={() => dispatch(closeModal(ModalIdentifiers.SUCCESS_MODAL))}
|
||||
/>
|
||||
<div className='background-svg'>
|
||||
<BackgroundSvg/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {isModalOpen} from 'selectors/views/modals';
|
||||
import {isDevModeEnabled} from 'selectors/general';
|
||||
|
||||
import {COUNTRIES} from 'utils/countries';
|
||||
import {inferNames} from 'utils/hosted_customer';
|
||||
|
||||
import {
|
||||
ModalIdentifiers,
|
||||
@@ -49,7 +50,6 @@ import useControlSelfHostedPurchaseModal from 'components/common/hooks/useContro
|
||||
import useFetchStandardAnalytics from 'components/common/hooks/useFetchStandardAnalytics';
|
||||
|
||||
import {ValueOf} from '@mattermost/types/utilities';
|
||||
import {UserProfile} from '@mattermost/types/users';
|
||||
import {
|
||||
SelfHostedSignupProgress,
|
||||
SelfHostedSignupCustomerResponse,
|
||||
@@ -270,17 +270,6 @@ interface FakeProgress {
|
||||
intervalId?: NodeJS.Timeout;
|
||||
}
|
||||
|
||||
function inferNames(user: UserProfile, cardName: string): [string, string] {
|
||||
if (user.first_name) {
|
||||
return [user.first_name, user.last_name];
|
||||
}
|
||||
const names = cardName.split(' ');
|
||||
if (cardName.length === 2) {
|
||||
return [names[0], names[1]];
|
||||
}
|
||||
return [names[0], names.slice(1).join(' ')];
|
||||
}
|
||||
|
||||
export default function SelfHostedPurchaseModal(props: Props) {
|
||||
useFetchStandardAnalytics();
|
||||
useNoEscape();
|
||||
|
||||
@@ -459,6 +459,7 @@ export const ModalIdentifiers = {
|
||||
DELETE_WORKSPACE_RESULT: 'delete_workspace_result',
|
||||
SCREENING_IN_PROGRESS: 'screening_in_progress',
|
||||
CONFIRM_SWITCH_TO_YEARLY: 'confirm_switch_to_yearly',
|
||||
SELF_HOSTED_EXPANSION: 'self_hosted_expansion',
|
||||
};
|
||||
|
||||
export const UserStatuses = {
|
||||
@@ -738,6 +739,7 @@ export const TELEMETRY_CATEGORIES = {
|
||||
CLOUD_PURCHASING: 'cloud_purchasing',
|
||||
CLOUD_PRICING: 'cloud_pricing',
|
||||
SELF_HOSTED_PURCHASING: 'self_hosted_purchasing',
|
||||
SELF_HOSTED_EXPANSION: 'self_hosted_expansion',
|
||||
CLOUD_ADMIN: 'cloud_admin',
|
||||
CLOUD_DELINQUENCY: 'cloud_delinquency',
|
||||
SELF_HOSTED_ADMIN: 'self_hosted_admin',
|
||||
@@ -1068,6 +1070,7 @@ export const CloudLinks = {
|
||||
SELF_HOSTED_SIGNUP: 'https://customers.mattermost.com/signup',
|
||||
DELINQUENCY_DOCS: 'https://docs.mattermost.com/about/cloud-subscriptions.html#failed-or-late-payments',
|
||||
SELF_HOSTED_PRICING: 'https://mattermost.com/pricing/#self-hosted',
|
||||
SELF_HOSTED_BILLING: 'https://docs.mattermost.com/manage/self-hosted-billing.html',
|
||||
};
|
||||
|
||||
export const HostedCustomerLinks = {
|
||||
@@ -1998,6 +2001,7 @@ export const ConsolePages = {
|
||||
WEB_SERVER: '/admin_console/environment/web_server',
|
||||
PUSH_NOTIFICATION_CENTER: '/admin_console/environment/push_notification_server',
|
||||
SMTP: '/admin_console/environment/smtp',
|
||||
BILLING_HISTORY: 'admin_console/billing/billing_history',
|
||||
};
|
||||
|
||||
export const WindowSizes = {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Product} from '@mattermost/types/cloud';
|
||||
import {UserProfile} from '@mattermost/types/users';
|
||||
|
||||
// find a self-hosted product based on its SKU
|
||||
// This function should not be used for cloud products, because there are
|
||||
@@ -17,3 +18,13 @@ export const findSelfHostedProductBySku = (products: Record<string, Product>, sk
|
||||
return matches[0];
|
||||
};
|
||||
|
||||
export const inferNames = (user: UserProfile, cardName: string): [string, string] => {
|
||||
if (user.first_name) {
|
||||
return [user.first_name, user.last_name];
|
||||
}
|
||||
const names = cardName.split(' ');
|
||||
if (cardName.length === 2) {
|
||||
return [names[0], names[1]];
|
||||
}
|
||||
return [names[0], names.slice(1).join(' ')];
|
||||
};
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
SelfHostedSignupCustomerResponse,
|
||||
SelfHostedSignupSuccessResponse,
|
||||
SelfHostedSignupBootstrapResponse,
|
||||
SelfHostedExpansionRequest,
|
||||
} from '@mattermost/types/hosted_customer';
|
||||
import {ChannelCategory, OrderedChannelCategories} from '@mattermost/types/channel_categories';
|
||||
|
||||
@@ -3892,6 +3893,13 @@ export default class Client4 {
|
||||
);
|
||||
};
|
||||
|
||||
confirmSelfHostedExpansion = (setupIntentId: string, expandRequest: SelfHostedExpansionRequest) => {
|
||||
return this.doFetch<SelfHostedSignupSuccessResponse>(
|
||||
`${this.getHostedCustomerRoute()}/confirm?expand=true`,
|
||||
{method: 'post', body: JSON.stringify({stripe_setup_intent_id: setupIntentId, subscription: expandRequest})},
|
||||
);
|
||||
}
|
||||
|
||||
createPaymentMethod = async () => {
|
||||
return this.doFetch(
|
||||
`${this.getCloudRoute()}/payment`,
|
||||
|
||||
@@ -74,3 +74,7 @@ export interface TrueUpReviewProfileReducer extends TrueUpReviewProfile {
|
||||
export interface TrueUpReviewStatusReducer extends TrueUpReviewStatus {
|
||||
getRequestState: RequestState;
|
||||
}
|
||||
|
||||
export interface SelfHostedExpansionRequest {
|
||||
seats: number;
|
||||
}
|
||||
|
||||
Ссылка в новой задаче
Block a user