Migrate to mono-repo
Этот коммит содержится в:
@@ -6,7 +6,7 @@ import {Stripe} from '@stripe/stripe-js';
|
|||||||
import {getCode} from 'country-list';
|
import {getCode} from 'country-list';
|
||||||
|
|
||||||
import {CreateSubscriptionRequest} from '@mattermost/types/cloud';
|
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 {ValueOf} from '@mattermost/types/utilities';
|
||||||
|
|
||||||
import {Client4} from 'mattermost-redux/client';
|
import {Client4} from 'mattermost-redux/client';
|
||||||
@@ -198,3 +198,89 @@ export function getTrueUpReviewStatus(): ActionFunc {
|
|||||||
onRequest: HostedCustomerTypes.TRUE_UP_REVIEW_STATUS_REQUEST,
|
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 {
|
.license-details-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-start;
|
justify-content: space-between;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
line-height: 24px;
|
line-height: 24px;
|
||||||
@@ -114,10 +114,11 @@
|
|||||||
color: #3f4350;
|
color: #3f4350;
|
||||||
}
|
}
|
||||||
|
|
||||||
span.expiration-days {
|
.add-seats-button {
|
||||||
margin-left: auto;
|
border-radius: 4px;
|
||||||
color: var(--denim-status-online);
|
font-family: 'Open Sans', sans-serif;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +158,20 @@
|
|||||||
font-weight: 600;
|
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 {
|
.add-new-licence-btn {
|
||||||
@@ -194,4 +209,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,15 +6,20 @@ import {screen} from '@testing-library/react';
|
|||||||
|
|
||||||
import {Provider} from 'react-redux';
|
import {Provider} from 'react-redux';
|
||||||
|
|
||||||
|
import moment from 'moment-timezone';
|
||||||
|
|
||||||
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
|
import {mountWithIntl} from 'tests/helpers/intl-test-helper';
|
||||||
import {renderWithIntl} from 'tests/react_testing_utils';
|
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 {General} from 'mattermost-redux/constants';
|
||||||
import {DeepPartial} from '@mattermost/types/utilities';
|
import {DeepPartial} from '@mattermost/types/utilities';
|
||||||
import {GlobalState} from '@mattermost/types/store';
|
import {GlobalState} from '@mattermost/types/store';
|
||||||
import mockStore from 'tests/test_store';
|
import mockStore from 'tests/test_store';
|
||||||
|
|
||||||
|
import * as useCanSelfHostedExpand from 'components/common/hooks/useCanSelfHostedExpand';
|
||||||
|
|
||||||
import EnterpriseEditionLeftPanel, {EnterpriseEditionProps} from './enterprise_edition_left_panel';
|
import EnterpriseEditionLeftPanel, {EnterpriseEditionProps} from './enterprise_edition_left_panel';
|
||||||
|
|
||||||
describe('components/admin_console/license_settings/enterprise_edition/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',
|
SkuShortName: 'Enterprise',
|
||||||
Name: 'LicenseName',
|
Name: 'LicenseName',
|
||||||
Company: 'Mattermost Inc.',
|
Company: 'Mattermost Inc.',
|
||||||
Users: '1000000',
|
Users: '1000',
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialState: DeepPartial<GlobalState> = {
|
const initialState: DeepPartial<GlobalState> = {
|
||||||
@@ -45,10 +50,36 @@ describe('components/admin_console/license_settings/enterprise_edition/enterpris
|
|||||||
},
|
},
|
||||||
general: {
|
general: {
|
||||||
license,
|
license,
|
||||||
|
config: {
|
||||||
|
BuildEnterpriseReady: 'true',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
preferences: {
|
preferences: {
|
||||||
myPreferences: {},
|
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) => {
|
const item = wrapper.find('.item-element').filterWhere((n) => {
|
||||||
return n.children().length === 2 &&
|
return n.children().length === 2 &&
|
||||||
n.childAt(0).type() === 'span' &&
|
n.childAt(0).type() === 'span' &&
|
||||||
!n.childAt(0).text().includes('ACTIVE') &&
|
!n.childAt(0).text().includes('ACTIVE') &&
|
||||||
n.childAt(0).text().includes('USERS');
|
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 () => {
|
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:')).not.toHaveClass('legend--warning-over-seats-purchased');
|
||||||
expect(screen.getByText('ACTIVE USERS:')).toHaveClass('legend--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 React, {RefObject, useEffect, useState} from 'react';
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import {FormattedDate, FormattedMessage, FormattedNumber, FormattedTime, useIntl} from 'react-intl';
|
import {FormattedDate, FormattedMessage, FormattedNumber, FormattedTime, useIntl} from 'react-intl';
|
||||||
|
import {useSelector} from 'react-redux';
|
||||||
|
|
||||||
import Tag from 'components/widgets/tag/tag';
|
import Tag from 'components/widgets/tag/tag';
|
||||||
|
|
||||||
@@ -15,9 +16,16 @@ import {getRemainingDaysFromFutureTimestamp, toTitleCase} from 'utils/utils';
|
|||||||
import {FileTypes} from 'utils/constants';
|
import {FileTypes} from 'utils/constants';
|
||||||
import {getSkuDisplayName} from 'utils/subscription';
|
import {getSkuDisplayName} from 'utils/subscription';
|
||||||
import {calculateOverageUserActivated} from 'utils/overage_team';
|
import {calculateOverageUserActivated} from 'utils/overage_team';
|
||||||
|
import {getConfig} from 'mattermost-redux/selectors/entities/admin';
|
||||||
|
|
||||||
import './enterprise_edition.scss';
|
import './enterprise_edition.scss';
|
||||||
import useOpenPricingModal from 'components/common/hooks/useOpenPricingModal';
|
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 {
|
export interface EnterpriseEditionProps {
|
||||||
openEELicenseModal: () => void;
|
openEELicenseModal: () => void;
|
||||||
@@ -47,10 +55,12 @@ const EnterpriseEditionLeftPanel = ({
|
|||||||
const {formatMessage} = useIntl();
|
const {formatMessage} = useIntl();
|
||||||
const [unsanitizedLicense, setUnsanitizedLicense] = useState(license);
|
const [unsanitizedLicense, setUnsanitizedLicense] = useState(license);
|
||||||
const openPricingModal = useOpenPricingModal();
|
const openPricingModal = useOpenPricingModal();
|
||||||
|
const canExpand = useCanSelfHostedExpand();
|
||||||
|
const selfHostedExpansionModal = useControlSelfHostedExpansionModal({trackingLocation: 'license_settings_add_seats'});
|
||||||
|
const expandableLink = useSelector(getExpandSeatsLink);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchUnSanitizedLicense() {
|
async function fetchUnSanitizedLicense() {
|
||||||
// This solves this the issue reported here: https://mattermost.atlassian.net/browse/MM-42906
|
|
||||||
try {
|
try {
|
||||||
const unsanitizedL = await Client4.getClientLicenseOld();
|
const unsanitizedL = await Client4.getClientLicenseOld();
|
||||||
setUnsanitizedLicense(unsanitizedL);
|
setUnsanitizedLicense(unsanitizedL);
|
||||||
@@ -63,6 +73,7 @@ const EnterpriseEditionLeftPanel = ({
|
|||||||
|
|
||||||
const skuName = getSkuDisplayName(unsanitizedLicense.SkuShortName, unsanitizedLicense.IsGovSku === 'true');
|
const skuName = getSkuDisplayName(unsanitizedLicense.SkuShortName, unsanitizedLicense.IsGovSku === 'true');
|
||||||
const expirationDays = getRemainingDaysFromFutureTimestamp(parseInt(unsanitizedLicense.ExpiresAt, 10));
|
const expirationDays = getRemainingDaysFromFutureTimestamp(parseInt(unsanitizedLicense.ExpiresAt, 10));
|
||||||
|
const isSelfHostedExpansionEnabled = useSelector(getConfig)?.ServiceSettings?.SelfHostedExpansion;
|
||||||
|
|
||||||
const viewPlansButton = (
|
const viewPlansButton = (
|
||||||
<button
|
<button
|
||||||
@@ -77,6 +88,14 @@ const EnterpriseEditionLeftPanel = ({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleClickAddSeats = () => {
|
||||||
|
if (!isSelfHostedExpansionEnabled && !canExpand) {
|
||||||
|
window.open(expandableLink(unsanitizedLicense.Id), '_blank');
|
||||||
|
} else {
|
||||||
|
selfHostedExpansionModal.open();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className='EnterpriseEditionLeftPanel'
|
className='EnterpriseEditionLeftPanel'
|
||||||
@@ -117,10 +136,16 @@ const EnterpriseEditionLeftPanel = ({
|
|||||||
<div className='licenseInformation'>
|
<div className='licenseInformation'>
|
||||||
<div className='license-details-top'>
|
<div className='license-details-top'>
|
||||||
<span className='title'>{'License details'}</span>
|
<span className='title'>{'License details'}</span>
|
||||||
{(expirationDays <= 30) &&
|
{canExpand &&
|
||||||
<span className='expiration-days'>
|
<button
|
||||||
{`Expires in ${expirationDays} day${expirationDays > 1 ? 's' : ''}`}
|
className='add-seats-button btn btn-primary'
|
||||||
</span>
|
onClick={handleClickAddSeats}
|
||||||
|
>
|
||||||
|
<FormattedMessage
|
||||||
|
id={'admin.license.enterpriseEdition.add.seats'}
|
||||||
|
defaultMessage='+ Add seats'
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
{
|
{
|
||||||
@@ -134,6 +159,7 @@ const EnterpriseEditionLeftPanel = ({
|
|||||||
fileInputRef,
|
fileInputRef,
|
||||||
handleChange,
|
handleChange,
|
||||||
statsActiveUsers,
|
statsActiveUsers,
|
||||||
|
expirationDays,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -162,7 +188,7 @@ const EnterpriseEditionLeftPanel = ({
|
|||||||
|
|
||||||
type LegendValues = 'START DATE:' | 'EXPIRES:' | 'USERS:' | 'ACTIVE USERS:' | 'EDITION:' | 'LICENSE ISSUED:' | 'NAME:' | 'COMPANY / ORG:'
|
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:') {
|
if (legend === 'ACTIVE USERS:') {
|
||||||
const {isBetween5PercerntAnd10PercentPurchasedSeats, isOver10PercerntPurchasedSeats} = calculateOverageUserActivated({activeUsers, seatsPurchased});
|
const {isBetween5PercerntAnd10PercentPurchasedSeats, isOver10PercerntPurchasedSeats} = calculateOverageUserActivated({activeUsers, seatsPurchased});
|
||||||
return (
|
return (
|
||||||
@@ -186,6 +212,26 @@ const renderLicenseValues = (activeUsers: number, seatsPurchased: number) => ({l
|
|||||||
>{value}</span>
|
>{value}</span>
|
||||||
</div>
|
</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 (
|
return (
|
||||||
@@ -209,6 +255,7 @@ const renderLicenseContent = (
|
|||||||
fileInputRef: RefObject<HTMLInputElement>,
|
fileInputRef: RefObject<HTMLInputElement>,
|
||||||
handleChange: () => void,
|
handleChange: () => void,
|
||||||
statsActiveUsers: number,
|
statsActiveUsers: number,
|
||||||
|
expirationDays: number,
|
||||||
) => {
|
) => {
|
||||||
// Note: DO NOT LOCALISE THESE STRINGS. Legally we can not since the license is in English.
|
// Note: DO NOT LOCALISE THESE STRINGS. Legally we can not since the license is in English.
|
||||||
|
|
||||||
@@ -246,7 +293,7 @@ const renderLicenseContent = (
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='licenseElements'>
|
<div className='licenseElements'>
|
||||||
{licenseValues.map(renderLicenseValues(statsActiveUsers, parseInt(license.Users, 10)))}
|
{licenseValues.map(renderLicenseValues(statsActiveUsers, parseInt(license.Users, 10), expirationDays))}
|
||||||
<hr/>
|
<hr/>
|
||||||
{renderAddNewLicenseButton(fileInputRef, handleChange)}
|
{renderAddNewLicenseButton(fileInputRef, handleChange)}
|
||||||
{renderRemoveButton(handleRemove, isDisabled, removing)}
|
{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,
|
dialogType: PurchaseInProgressModal,
|
||||||
dialogProps: {
|
dialogProps: {
|
||||||
purchaserEmail: currentUser.email,
|
purchaserEmail: currentUser.email,
|
||||||
|
storageKey: STORAGE_KEY_PURCHASE_IN_PROGRESS,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
@@ -86,6 +87,7 @@ export default function useControlSelfHostedPurchaseModal(options: HookOptions):
|
|||||||
dialogType: PurchaseInProgressModal,
|
dialogType: PurchaseInProgressModal,
|
||||||
dialogProps: {
|
dialogProps: {
|
||||||
purchaserEmail: result.email,
|
purchaserEmail: result.email,
|
||||||
|
storageKey: STORAGE_KEY_PURCHASE_IN_PROGRESS,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import {GlobalState} from 'types/store';
|
|||||||
import {TestHelper as TH} from 'utils/test_helper';
|
import {TestHelper as TH} from 'utils/test_helper';
|
||||||
import {Client4} from 'mattermost-redux/client';
|
import {Client4} from 'mattermost-redux/client';
|
||||||
|
|
||||||
|
import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from 'components/self_hosted_purchase_modal/constants';
|
||||||
|
|
||||||
import PurchaseInProgressModal from './';
|
import PurchaseInProgressModal from './';
|
||||||
|
|
||||||
jest.mock('mattermost-redux/client', () => {
|
jest.mock('mattermost-redux/client', () => {
|
||||||
@@ -56,13 +58,27 @@ describe('PurchaseInProgressModal', () => {
|
|||||||
it('when purchaser and user emails are different, user is instructed to wait', () => {
|
it('when purchaser and user emails are different, user is instructed to wait', () => {
|
||||||
const stateOverride: DeepPartial<GlobalState> = JSON.parse(JSON.stringify(initialState));
|
const stateOverride: DeepPartial<GlobalState> = JSON.parse(JSON.stringify(initialState));
|
||||||
stateOverride.entities!.users!.currentUserId = 'otherUserId';
|
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.');
|
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', () => {
|
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();
|
expect(Client4.bootstrapSelfHostedSignup).not.toHaveBeenCalled();
|
||||||
screen.getByText('Reset purchase flow').click();
|
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 CreditCardSvg from 'components/common/svg_images_components/credit_card_svg';
|
||||||
import {useControlPurchaseInProgressModal} from 'components/common/hooks/useControlModal';
|
import {useControlPurchaseInProgressModal} from 'components/common/hooks/useControlModal';
|
||||||
import {STORAGE_KEY_PURCHASE_IN_PROGRESS} from 'components/self_hosted_purchase_modal/constants';
|
|
||||||
|
|
||||||
import './index.scss';
|
import './index.scss';
|
||||||
import {GlobalState} from '@mattermost/types/store';
|
import {GlobalState} from '@mattermost/types/store';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
purchaserEmail: string;
|
purchaserEmail: string;
|
||||||
|
storageKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PurchaseInProgressModal(props: Props) {
|
export default function PurchaseInProgressModal(props: Props) {
|
||||||
@@ -64,7 +64,7 @@ export default function PurchaseInProgressModal(props: Props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
genericModalProps.handleConfirm = () => {
|
genericModalProps.handleConfirm = () => {
|
||||||
localStorage.removeItem(STORAGE_KEY_PURCHASE_IN_PROGRESS);
|
localStorage.removeItem(props.storageKey);
|
||||||
Client4.bootstrapSelfHostedSignup(true);
|
Client4.bootstrapSelfHostedSignup(true);
|
||||||
close();
|
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 {isDevModeEnabled} from 'selectors/general';
|
||||||
|
|
||||||
import {COUNTRIES} from 'utils/countries';
|
import {COUNTRIES} from 'utils/countries';
|
||||||
|
import {inferNames} from 'utils/hosted_customer';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ModalIdentifiers,
|
ModalIdentifiers,
|
||||||
@@ -49,7 +50,6 @@ import useControlSelfHostedPurchaseModal from 'components/common/hooks/useContro
|
|||||||
import useFetchStandardAnalytics from 'components/common/hooks/useFetchStandardAnalytics';
|
import useFetchStandardAnalytics from 'components/common/hooks/useFetchStandardAnalytics';
|
||||||
|
|
||||||
import {ValueOf} from '@mattermost/types/utilities';
|
import {ValueOf} from '@mattermost/types/utilities';
|
||||||
import {UserProfile} from '@mattermost/types/users';
|
|
||||||
import {
|
import {
|
||||||
SelfHostedSignupProgress,
|
SelfHostedSignupProgress,
|
||||||
SelfHostedSignupCustomerResponse,
|
SelfHostedSignupCustomerResponse,
|
||||||
@@ -270,17 +270,6 @@ interface FakeProgress {
|
|||||||
intervalId?: NodeJS.Timeout;
|
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) {
|
export default function SelfHostedPurchaseModal(props: Props) {
|
||||||
useFetchStandardAnalytics();
|
useFetchStandardAnalytics();
|
||||||
useNoEscape();
|
useNoEscape();
|
||||||
|
|||||||
@@ -459,6 +459,7 @@ export const ModalIdentifiers = {
|
|||||||
DELETE_WORKSPACE_RESULT: 'delete_workspace_result',
|
DELETE_WORKSPACE_RESULT: 'delete_workspace_result',
|
||||||
SCREENING_IN_PROGRESS: 'screening_in_progress',
|
SCREENING_IN_PROGRESS: 'screening_in_progress',
|
||||||
CONFIRM_SWITCH_TO_YEARLY: 'confirm_switch_to_yearly',
|
CONFIRM_SWITCH_TO_YEARLY: 'confirm_switch_to_yearly',
|
||||||
|
SELF_HOSTED_EXPANSION: 'self_hosted_expansion',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const UserStatuses = {
|
export const UserStatuses = {
|
||||||
@@ -738,6 +739,7 @@ export const TELEMETRY_CATEGORIES = {
|
|||||||
CLOUD_PURCHASING: 'cloud_purchasing',
|
CLOUD_PURCHASING: 'cloud_purchasing',
|
||||||
CLOUD_PRICING: 'cloud_pricing',
|
CLOUD_PRICING: 'cloud_pricing',
|
||||||
SELF_HOSTED_PURCHASING: 'self_hosted_purchasing',
|
SELF_HOSTED_PURCHASING: 'self_hosted_purchasing',
|
||||||
|
SELF_HOSTED_EXPANSION: 'self_hosted_expansion',
|
||||||
CLOUD_ADMIN: 'cloud_admin',
|
CLOUD_ADMIN: 'cloud_admin',
|
||||||
CLOUD_DELINQUENCY: 'cloud_delinquency',
|
CLOUD_DELINQUENCY: 'cloud_delinquency',
|
||||||
SELF_HOSTED_ADMIN: 'self_hosted_admin',
|
SELF_HOSTED_ADMIN: 'self_hosted_admin',
|
||||||
@@ -1068,6 +1070,7 @@ export const CloudLinks = {
|
|||||||
SELF_HOSTED_SIGNUP: 'https://customers.mattermost.com/signup',
|
SELF_HOSTED_SIGNUP: 'https://customers.mattermost.com/signup',
|
||||||
DELINQUENCY_DOCS: 'https://docs.mattermost.com/about/cloud-subscriptions.html#failed-or-late-payments',
|
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_PRICING: 'https://mattermost.com/pricing/#self-hosted',
|
||||||
|
SELF_HOSTED_BILLING: 'https://docs.mattermost.com/manage/self-hosted-billing.html',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HostedCustomerLinks = {
|
export const HostedCustomerLinks = {
|
||||||
@@ -1998,6 +2001,7 @@ export const ConsolePages = {
|
|||||||
WEB_SERVER: '/admin_console/environment/web_server',
|
WEB_SERVER: '/admin_console/environment/web_server',
|
||||||
PUSH_NOTIFICATION_CENTER: '/admin_console/environment/push_notification_server',
|
PUSH_NOTIFICATION_CENTER: '/admin_console/environment/push_notification_server',
|
||||||
SMTP: '/admin_console/environment/smtp',
|
SMTP: '/admin_console/environment/smtp',
|
||||||
|
BILLING_HISTORY: 'admin_console/billing/billing_history',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const WindowSizes = {
|
export const WindowSizes = {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// See LICENSE.txt for license information.
|
// See LICENSE.txt for license information.
|
||||||
|
|
||||||
import {Product} from '@mattermost/types/cloud';
|
import {Product} from '@mattermost/types/cloud';
|
||||||
|
import {UserProfile} from '@mattermost/types/users';
|
||||||
|
|
||||||
// find a self-hosted product based on its SKU
|
// find a self-hosted product based on its SKU
|
||||||
// This function should not be used for cloud products, because there are
|
// 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];
|
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,
|
SelfHostedSignupCustomerResponse,
|
||||||
SelfHostedSignupSuccessResponse,
|
SelfHostedSignupSuccessResponse,
|
||||||
SelfHostedSignupBootstrapResponse,
|
SelfHostedSignupBootstrapResponse,
|
||||||
|
SelfHostedExpansionRequest,
|
||||||
} from '@mattermost/types/hosted_customer';
|
} from '@mattermost/types/hosted_customer';
|
||||||
import {ChannelCategory, OrderedChannelCategories} from '@mattermost/types/channel_categories';
|
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 () => {
|
createPaymentMethod = async () => {
|
||||||
return this.doFetch(
|
return this.doFetch(
|
||||||
`${this.getCloudRoute()}/payment`,
|
`${this.getCloudRoute()}/payment`,
|
||||||
|
|||||||
@@ -74,3 +74,7 @@ export interface TrueUpReviewProfileReducer extends TrueUpReviewProfile {
|
|||||||
export interface TrueUpReviewStatusReducer extends TrueUpReviewStatus {
|
export interface TrueUpReviewStatusReducer extends TrueUpReviewStatus {
|
||||||
getRequestState: RequestState;
|
getRequestState: RequestState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SelfHostedExpansionRequest {
|
||||||
|
seats: number;
|
||||||
|
}
|
||||||
|
|||||||
Ссылка в новой задаче
Block a user