@@ -768,7 +848,7 @@ exports[`components/MarketplaceItem MarketplaceItem should render wtih no homepa
className="more-modal__details"
>
name
@@ -777,6 +857,11 @@ exports[`components/MarketplaceItem MarketplaceItem should render wtih no homepa
>
(1.0.0)
+
+
diff --git a/components/plugin_marketplace/marketplace_item/index.js b/components/plugin_marketplace/marketplace_item/index.js
index 7cfbad863888..363bb761fb46 100644
--- a/components/plugin_marketplace/marketplace_item/index.js
+++ b/components/plugin_marketplace/marketplace_item/index.js
@@ -9,7 +9,7 @@ import {installPlugin} from 'actions/marketplace';
import {closeModal} from 'actions/views/modals';
import {ModalIdentifiers} from 'utils/constants';
import {getInstalling, getError} from 'selectors/views/marketplace';
-import {trackEvent} from 'actions/diagnostics_actions.jsx';
+import {trackEvent} from 'actions/telemetry_actions.jsx';
import MarketplaceItem from './marketplace_item';
diff --git a/components/plugin_marketplace/marketplace_item/marketplace_item.js b/components/plugin_marketplace/marketplace_item/marketplace_item.js
index 6fe7dd24353e..517ddc7cfbb3 100644
--- a/components/plugin_marketplace/marketplace_item/marketplace_item.js
+++ b/components/plugin_marketplace/marketplace_item/marketplace_item.js
@@ -270,7 +270,6 @@ export default class MarketplaceItem extends React.PureComponent {
name: PropTypes.string.isRequired,
description: PropTypes.string.isRequired,
version: PropTypes.string.isRequired,
- downloadUrl: PropTypes.string,
homepageUrl: PropTypes.string,
releaseNotesUrl: PropTypes.string,
labels: PropTypes.array,
@@ -386,7 +385,6 @@ export default class MarketplaceItem extends React.PureComponent {
}
render() {
- const ariaLabel = `${this.props.name}, ${this.props.description}`.toLowerCase();
let versionLabel = `(${this.props.version})`;
if (this.props.installedVersion !== '') {
versionLabel = `(${this.props.installedVersion})`;
@@ -421,34 +419,57 @@ export default class MarketplaceItem extends React.PureComponent {
<>
{this.props.name}
{versionLabel}
- {labels}
-
- {this.props.error ? this.props.error : this.props.description}
-
>
);
+ const description = (
+
+ {this.props.error ? this.props.error : this.props.description}
+
+ );
+
let pluginDetails;
if (this.props.homepageUrl) {
pluginDetails = (
-
- {pluginDetailsInner}
-
+ <>
+
+ {pluginDetailsInner}
+
+ {labels}
+
+ {description}
+
+ >
);
} else {
pluginDetails = (
-
- {pluginDetailsInner}
-
+ <>
+
+ {pluginDetailsInner}
+
+ {labels}
+
+ {description}
+
+ >
);
}
diff --git a/components/plugin_marketplace/marketplace_modal.js b/components/plugin_marketplace/marketplace_modal.js
index ee3736b35dcf..a56a726d1bf1 100644
--- a/components/plugin_marketplace/marketplace_modal.js
+++ b/components/plugin_marketplace/marketplace_modal.js
@@ -16,7 +16,7 @@ import PluginIcon from 'components/widgets/icons/plugin_icon.jsx';
import LoadingScreen from 'components/loading_screen';
import FormattedMarkdownMessage from 'components/formatted_markdown_message.jsx';
-import {trackEvent} from 'actions/diagnostics_actions.jsx';
+import {trackEvent} from 'actions/telemetry_actions.jsx';
import {t} from 'utils/i18n';
import {localizeMessage} from 'utils/utils';
diff --git a/components/plugin_marketplace/marketplace_modal.test.js b/components/plugin_marketplace/marketplace_modal.test.js
index 44e9951d45a1..c2495c04ed2b 100644
--- a/components/plugin_marketplace/marketplace_modal.test.js
+++ b/components/plugin_marketplace/marketplace_modal.test.js
@@ -4,12 +4,12 @@
import React from 'react';
import {shallow} from 'enzyme';
-import {trackEvent} from 'actions/diagnostics_actions.jsx';
+import {trackEvent} from 'actions/telemetry_actions.jsx';
import {AllPlugins, InstalledPlugins, MarketplaceModal} from './marketplace_modal';
-jest.mock('actions/diagnostics_actions.jsx', () => {
- const original = jest.requireActual('actions/diagnostics_actions.jsx');
+jest.mock('actions/telemetry_actions.jsx', () => {
+ const original = jest.requireActual('actions/telemetry_actions.jsx');
return {
...original,
trackEvent: jest.fn(),
diff --git a/components/post_markdown/index.js b/components/post_markdown/index.js
index 78a20d4a6cce..ddbf37e784cd 100644
--- a/components/post_markdown/index.js
+++ b/components/post_markdown/index.js
@@ -3,7 +3,7 @@
import {createSelector} from 'reselect';
import {connect} from 'react-redux';
import {getChannel} from 'mattermost-redux/selectors/entities/channels';
-import {getMyGroupMentionKeysForChannel} from 'mattermost-redux/selectors/entities/groups';
+import {getMyGroupMentionKeysForChannel, getMyGroupMentionKeys} from 'mattermost-redux/selectors/entities/groups';
import {getCurrentUserMentionKeys} from 'mattermost-redux/selectors/entities/users';
import {canManageMembers} from 'utils/channel_utils.jsx';
@@ -14,7 +14,7 @@ export function makeGetMentionKeysForPost() {
return createSelector(
getCurrentUserMentionKeys,
(state, post) => post,
- (state, post, channel) => getMyGroupMentionKeysForChannel(state, channel.team_id, channel.id),
+ (state, post, channel) => (channel ? getMyGroupMentionKeysForChannel(state, channel.team_id, channel.id) : getMyGroupMentionKeys(state)),
(mentionKeysWithoutGroups, post, groupMentionKeys) => {
let mentionKeys = mentionKeysWithoutGroups;
if (!post?.props?.disable_group_highlight) { // eslint-disable-line camelcase
diff --git a/components/post_markdown/post_markdown.jsx b/components/post_markdown/post_markdown.jsx
index 248629924323..26d0eeac10b5 100644
--- a/components/post_markdown/post_markdown.jsx
+++ b/components/post_markdown/post_markdown.jsx
@@ -34,7 +34,7 @@ export default class PostMarkdown extends React.PureComponent {
/*
* The id of the channel that this post is being rendered in
*/
- channelId: PropTypes.string.isRequired,
+ channelId: PropTypes.string,
channel: PropTypes.object,
diff --git a/components/post_view/combined_user_activity_post/index.js b/components/post_view/combined_user_activity_post/index.ts
similarity index 80%
rename from components/post_view/combined_user_activity_post/index.js
rename to components/post_view/combined_user_activity_post/index.ts
index 3a626f219750..928812883cf4 100644
--- a/components/post_view/combined_user_activity_post/index.js
+++ b/components/post_view/combined_user_activity_post/index.ts
@@ -5,12 +5,18 @@ import {connect} from 'react-redux';
import {makeGenerateCombinedPost} from 'mattermost-redux/utils/post_list';
+import {GlobalState} from 'mattermost-redux/types/store';
+
import Post from 'components/post_view/post';
+type Props = {
+ combinedId: string;
+}
+
export function makeMapStateToProps() {
const generateCombinedPost = makeGenerateCombinedPost();
- return (state, ownProps) => {
+ return (state: GlobalState, ownProps: Props) => {
return {
post: generateCombinedPost(state, ownProps.combinedId),
postId: ownProps.combinedId,
diff --git a/components/post_view/message_attachments/message_attachment/message_attachment.jsx b/components/post_view/message_attachments/message_attachment/message_attachment.jsx
index 5a158cb7b9f9..318404759659 100644
--- a/components/post_view/message_attachments/message_attachment/message_attachment.jsx
+++ b/components/post_view/message_attachments/message_attachment/message_attachment.jsx
@@ -19,7 +19,7 @@ import SizeAwareImage from 'components/size_aware_image';
import ActionButton from '../action_button';
import ActionMenu from '../action_menu';
-import {trackEvent} from 'actions/diagnostics_actions';
+import {trackEvent} from 'actions/telemetry_actions';
export default class MessageAttachment extends React.PureComponent {
static propTypes = {
diff --git a/components/post_view/post_list_row/__snapshots__/post_list_row.test.jsx.snap b/components/post_view/post_list_row/__snapshots__/post_list_row.test.tsx.snap
similarity index 95%
rename from components/post_view/post_list_row/__snapshots__/post_list_row.test.jsx.snap
rename to components/post_view/post_list_row/__snapshots__/post_list_row.test.tsx.snap
index d56aadf2ad15..74dba959fa9a 100644
--- a/components/post_view/post_list_row/__snapshots__/post_list_row.test.jsx.snap
+++ b/components/post_view/post_list_row/__snapshots__/post_list_row.test.tsx.snap
@@ -45,8 +45,10 @@ exports[`components/post_view/post_list_row should render channel intro message
exports[`components/post_view/post_list_row should render combined post 1`] = `
`;
@@ -97,8 +99,10 @@ exports[`components/post_view/post_list_row should render new messages line 1`]
exports[`components/post_view/post_list_row should render post 1`] = `
`;
diff --git a/components/post_view/post_list_row/index.js b/components/post_view/post_list_row/index.ts
similarity index 59%
rename from components/post_view/post_list_row/index.js
rename to components/post_view/post_list_row/index.ts
index 4596f5b5530b..57965c8716be 100644
--- a/components/post_view/post_list_row/index.js
+++ b/components/post_view/post_list_row/index.ts
@@ -2,14 +2,26 @@
// See LICENSE.txt for license information.
import {connect} from 'react-redux';
-import {bindActionCreators} from 'redux';
+import {bindActionCreators, Dispatch} from 'redux';
+
+import {GenericAction} from 'mattermost-redux/types/actions';
+
+import {Channel} from 'mattermost-redux/types/channels';
+import {Post} from 'mattermost-redux/types/posts';
import {getShortcutReactToLastPostEmittedFrom} from 'selectors/emojis';
import {emitShortcutReactToLastPostFrom} from 'actions/post_actions.jsx';
-import PostListRow from './post_list_row.jsx';
+import {GlobalState} from 'types/store';
+
+import PostListRow from './post_list_row';
+
+type Props = {
+ post: Post,
+ channel: Channel
+}
-function mapStateToProps(state, ownProps) {
+function mapStateToProps(state: GlobalState, ownProps: Props) {
const shortcutReactToLastPostEmittedFrom = getShortcutReactToLastPostEmittedFrom(state);
return {
@@ -19,7 +31,7 @@ function mapStateToProps(state, ownProps) {
};
}
-function mapDispatchToProps(dispatch) {
+function mapDispatchToProps(dispatch: Dispatch) {
return {
actions: bindActionCreators({
emitShortcutReactToLastPostFrom,
diff --git a/components/post_view/post_list_row/post_list_row.test.jsx b/components/post_view/post_list_row/post_list_row.test.tsx
similarity index 77%
rename from components/post_view/post_list_row/post_list_row.test.jsx
rename to components/post_view/post_list_row/post_list_row.test.tsx
index 19520cd87377..91bb9c0faa81 100644
--- a/components/post_view/post_list_row/post_list_row.test.jsx
+++ b/components/post_view/post_list_row/post_list_row.test.tsx
@@ -6,6 +6,8 @@ import React from 'react';
import * as PostListUtils from 'mattermost-redux/utils/post_list';
+import {ChannelType} from 'mattermost-redux/types/channels';
+
import CombinedUserActivityPost from 'components/post_view/combined_user_activity_post';
import Post from 'components/post_view/post';
import DateSeparator from 'components/post_view/date_separator';
@@ -14,12 +16,27 @@ import ChannelIntroMessage from 'components/post_view/channel_intro_message/';
import {PostListRowListIds} from 'utils/constants';
-import PostListRow from './post_list_row.jsx';
+import PostListRow from './post_list_row';
describe('components/post_view/post_list_row', () => {
+ const defaultProps = {
+ listId: '1234',
+ loadOlderPosts: jest.fn(),
+ loadNewerPosts: jest.fn(),
+ togglePostMenu: jest.fn(),
+ isLastPost: false,
+ shortcutReactToLastPostEmittedFrom: 'NO_WHERE',
+ loadingNewerPosts: false,
+ loadingOlderPosts: false,
+ actions: {
+ emitShortcutReactToLastPostFrom: jest.fn(),
+ },
+ };
+
test('should render more messages loading indicator', () => {
const listId = PostListRowListIds.OLDER_MESSAGES_LOADER;
const props = {
+ ...defaultProps,
listId,
loadingOlderPosts: true,
};
@@ -33,6 +50,7 @@ describe('components/post_view/post_list_row', () => {
const listId = PostListRowListIds.LOAD_OLDER_MESSAGES_TRIGGER;
const loadOlderPosts = jest.fn();
const props = {
+ ...defaultProps,
listId,
loadOlderPosts,
};
@@ -47,8 +65,24 @@ describe('components/post_view/post_list_row', () => {
test('should render channel intro message', () => {
const listId = PostListRowListIds.CHANNEL_INTRO_MESSAGE;
const props = {
+ ...defaultProps,
channel: {
id: '123',
+ name: 'test-channel-1',
+ display_name: 'Test Channel 1',
+ type: ('P' as ChannelType),
+ team_id: 'team-1',
+ header: '',
+ purpose: '',
+ creator_id: '',
+ scheme_id: '',
+ group_constrained: false,
+ create_at: 0,
+ update_at: 0,
+ delete_at: 0,
+ last_post_at: 0,
+ total_msg_count: 0,
+ extra_update_at: 0,
},
fullWidth: true,
listId,
@@ -64,6 +98,7 @@ describe('components/post_view/post_list_row', () => {
test('should render new messages line', () => {
const listId = PostListRowListIds.START_OF_NEW_MESSAGES;
const props = {
+ ...defaultProps,
listId,
};
const wrapper = shallow(
@@ -76,6 +111,7 @@ describe('components/post_view/post_list_row', () => {
test('should render date line', () => {
const listId = `${PostListRowListIds.DATE_LINE}1553106600000`;
const props = {
+ ...defaultProps,
listId,
};
const wrapper = shallow(
@@ -87,6 +123,7 @@ describe('components/post_view/post_list_row', () => {
test('should render combined post', () => {
const props = {
+ ...defaultProps,
shouldHighlight: false,
listId: `${PostListUtils.COMBINED_USER_ACTIVITY}1234-5678`,
previousListId: 'abcd',
@@ -100,6 +137,7 @@ describe('components/post_view/post_list_row', () => {
test('should render post', () => {
const props = {
+ ...defaultProps,
shouldHighlight: false,
listId: '1234',
previousListId: 'abcd',
@@ -114,6 +152,7 @@ describe('components/post_view/post_list_row', () => {
test('should have class hideAnimation for OLDER_MESSAGES_LOADER if loadingOlderPosts is false', () => {
const listId = PostListRowListIds.OLDER_MESSAGES_LOADER;
const props = {
+ ...defaultProps,
listId,
loadingOlderPosts: false,
};
@@ -126,6 +165,7 @@ describe('components/post_view/post_list_row', () => {
test('should have class hideAnimation for NEWER_MESSAGES_LOADER if loadingNewerPosts is false', () => {
const listId = PostListRowListIds.NEWER_MESSAGES_LOADER;
const props = {
+ ...defaultProps,
listId,
loadingNewerPosts: false,
};
diff --git a/components/post_view/post_list_row/post_list_row.jsx b/components/post_view/post_list_row/post_list_row.tsx
similarity index 79%
rename from components/post_view/post_list_row/post_list_row.jsx
rename to components/post_view/post_list_row/post_list_row.tsx
index 89e2ecc9d784..bceacb4a9135 100644
--- a/components/post_view/post_list_row/post_list_row.jsx
+++ b/components/post_view/post_list_row/post_list_row.tsx
@@ -1,13 +1,14 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
-import PropTypes from 'prop-types';
import React from 'react';
import {FormattedMessage} from 'react-intl';
import classNames from 'classnames';
import * as PostListUtils from 'mattermost-redux/utils/post_list';
+import {Channel} from 'mattermost-redux/types/channels';
+
import CombinedUserActivityPost from 'components/post_view/combined_user_activity_post';
import Post from 'components/post_view/post';
import DateSeparator from 'components/post_view/date_separator';
@@ -16,42 +17,44 @@ import ChannelIntroMessage from 'components/post_view/channel_intro_message/';
import {isIdNotPost} from 'utils/post_utils';
import {PostListRowListIds, Locations} from 'utils/constants';
-export default class PostListRow extends React.PureComponent {
- static propTypes = {
- listId: PropTypes.string.isRequired,
- previousListId: PropTypes.string,
- fullWidth: PropTypes.bool,
- shouldHighlight: PropTypes.bool,
- loadOlderPosts: PropTypes.func,
- loadNewerPosts: PropTypes.func,
- togglePostMenu: PropTypes.func,
-
- /**
- * To Check if the current post is last in the list
- */
- isLastPost: PropTypes.bool,
+export type PostListRowProps = {
+ channel?: Channel;
+ listId: string,
+ previousListId?: string,
+ fullWidth?: boolean,
+ shouldHighlight?: boolean,
+ loadOlderPosts: () => void,
+ loadNewerPosts: () => void,
+ togglePostMenu: () => void,
+
+ /**
+ * To Check if the current post is last in the list
+ */
+ isLastPost: boolean,
+
+ /**
+ * To check if the state of emoji for last message and from where it was emitted
+ */
+ shortcutReactToLastPostEmittedFrom: string,
+
+ /**
+ * is used for hiding animation of loader
+ */
+ loadingNewerPosts: boolean,
+ loadingOlderPosts: boolean,
+
+ actions: {
/**
- * To check if the state of emoji for last message and from where it was emitted
- */
- shortcutReactToLastPostEmittedFrom: PropTypes.string,
+ * Function to set or unset emoji picker for last message
+ */
+ emitShortcutReactToLastPostFrom: (location: string) => void
+ },
- /**
- * is used for hiding animation of loader
- */
- loadingNewerPosts: PropTypes.bool,
- loadingOlderPosts: PropTypes.bool,
-
- actions: PropTypes.shape({
-
- /**
- * Function to set or unset emoji picker for last message
- */
- emitShortcutReactToLastPostFrom: PropTypes.func,
- }),
- }
+}
- blockShortcutReactToLastPostForNonMessages(listId) {
+export default class PostListRow extends React.PureComponent {
+ blockShortcutReactToLastPostForNonMessages(listId: string) {
const {actions: {emitShortcutReactToLastPostFrom}} = this.props;
if (isIdNotPost(listId)) {
@@ -60,7 +63,7 @@ export default class PostListRow extends React.PureComponent {
}
}
- componentDidUpdate(prevProps) {
+ componentDidUpdate(prevProps: PostListRowProps) {
const {listId, isLastPost, shortcutReactToLastPostEmittedFrom} = this.props;
const shortcutReactToLastPostEmittedFromCenter = prevProps.shortcutReactToLastPostEmittedFrom !== shortcutReactToLastPostEmittedFrom &&
diff --git a/components/purchase_modal/icon_message.scss b/components/purchase_modal/icon_message.scss
new file mode 100644
index 000000000000..3016bbe75c12
--- /dev/null
+++ b/components/purchase_modal/icon_message.scss
@@ -0,0 +1,94 @@
+.IconMessage {
+ display: block;
+ text-align: center;
+ width: 100%;
+ height: 100%;
+ overflow: auto;
+ margin: auto;
+ position: absolute;
+ top: 0px; left: 0; bottom: 0; right: 0;
+ .content {
+ &.success {
+ padding-left: 7px;
+ padding-bottom: 2px;
+ }
+ margin: 0;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ position: absolute;
+ .IconMessage-h3 {
+ font-size: 24px;
+ line-height: 32px;
+ color: var(--center-channel-color);
+ margin-top: 43px;
+ font-weight: 600;
+
+ @media screen and (max-width: 600px) {
+ font-size: 36px;
+ line-height: 42px;
+ letter-spacing: -0.13px;
+ }
+ }
+
+ .IconMessage-sub {
+ &.error {
+ color: #DB3214;
+ }
+
+ margin-top: 16px;
+ font-size: 14px;
+ font-style: normal;
+ font-weight: normal;
+ line-height: 20px;
+ color: var(--center-channel-color);
+ }
+
+ .IconMessage-img {
+ }
+
+ .IconMessage-progress {
+ margin: 0 auto;
+ margin-top: 90px;
+ height: 4px;
+ width: 387px;
+ background: rgba(34, 64, 109, 0.12);
+ border-radius: 4px;
+ }
+
+ .IconMessage-progress-fill {
+ height: 100%;
+ background: #0058CC;
+ border-radius: 4px;
+ }
+
+ .IconMessage-button {
+ margin-left: auto;
+ margin-right: auto;
+ margin-top: 16px;
+ align-self: center;
+ text-align: center;
+ font-weight: 600;
+ font-size: 14px;
+ line-height: 14px;
+ height: 40px;
+ width: 95px;
+
+ &.error {
+ width: 176px;
+ }
+ }
+
+ .IconMessage-link {
+ margin-top: 21px;
+ font-weight: 600;
+ font-size: 14px;
+ line-height: 14px;
+ text-align: center;
+ color: #0058CC;
+ margin-left: -10px;
+ }
+
+ }
+}
+
diff --git a/components/purchase_modal/icon_message.tsx b/components/purchase_modal/icon_message.tsx
new file mode 100644
index 000000000000..6f3bfbf8aedc
--- /dev/null
+++ b/components/purchase_modal/icon_message.tsx
@@ -0,0 +1,107 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import classNames from 'classnames';
+import {FormattedMessage} from 'react-intl';
+
+import './icon_message.scss';
+
+type Props = {
+ icon: string;
+ title: string;
+ subtitle?: string;
+ date?: string;
+ error?: boolean;
+ buttonText?: string;
+ buttonHandler?: () => void;
+ linkText?: string;
+ linkURL?: string;
+ footer?: JSX.Element;
+ className?: string;
+}
+
+export default function IconMessage(props: Props) {
+ const {
+ icon,
+ title,
+ subtitle,
+ date,
+ error,
+ buttonText,
+ buttonHandler,
+ linkText,
+ linkURL,
+ footer,
+ className,
+ } = props;
+
+ let button = null;
+ if (buttonText && buttonHandler) {
+ button = (
+
+
+
+ );
+ }
+
+ let link = null;
+ if (linkText && linkURL) {
+ link = (
+
+ );
+ }
+ return (
+
+ );
+}
+
+IconMessage.defaultProps = {
+ error: false,
+ subtitle: '',
+ date: '',
+ className: '',
+};
diff --git a/components/purchase_modal/index.ts b/components/purchase_modal/index.ts
new file mode 100644
index 000000000000..5a5ab8b86851
--- /dev/null
+++ b/components/purchase_modal/index.ts
@@ -0,0 +1,57 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import {connect} from 'react-redux';
+import {bindActionCreators, Dispatch, ActionCreatorsMapObject} from 'redux';
+
+import {getConfig} from 'mattermost-redux/selectors/entities/general';
+import {GenericAction, ActionFunc} from 'mattermost-redux/types/actions';
+import {Stripe} from '@stripe/stripe-js';
+
+import {getCloudProducts} from 'mattermost-redux/actions/cloud';
+
+import {
+ getClientConfig,
+} from 'mattermost-redux/actions/general';
+
+import {BillingDetails} from 'types/cloud/sku';
+
+import {isModalOpen} from 'selectors/views/modals';
+import {ModalIdentifiers} from 'utils/constants';
+
+import {closeModal} from 'actions/views/modals';
+import {completeStripeAddPaymentMethod} from 'actions/cloud';
+
+import {GlobalState} from 'types/store';
+
+import PurchaseModal from './purchase_modal';
+
+function mapStateToProps(state: GlobalState) {
+ return {
+ show: isModalOpen(state, ModalIdentifiers.CLOUD_PURCHASE),
+ products: state.entities.cloud!.products,
+ isDevMode: getConfig(state).EnableDeveloper === 'true',
+ };
+}
+type Actions = {
+ closeModal: () => void;
+ getCloudProducts: () => void;
+ completeStripeAddPaymentMethod: (stripe: Stripe, billingDetails: BillingDetails, isDevMode: boolean) => Promise;
+ getClientConfig: () => void;
+}
+
+function mapDispatchToProps(dispatch: Dispatch) {
+ return {
+ actions: bindActionCreators, Actions>(
+ {
+ closeModal: () => closeModal(ModalIdentifiers.CLOUD_PURCHASE),
+ getCloudProducts,
+ completeStripeAddPaymentMethod,
+ getClientConfig,
+ },
+ dispatch,
+ ),
+ };
+}
+
+export default connect(mapStateToProps, mapDispatchToProps)(PurchaseModal);
diff --git a/components/purchase_modal/process_payment.css b/components/purchase_modal/process_payment.css
new file mode 100644
index 000000000000..a0deebd9fee6
--- /dev/null
+++ b/components/purchase_modal/process_payment.css
@@ -0,0 +1,19 @@
+.ProcessPayment-progress {
+ margin: 0 auto;
+ margin-top: 60px;
+ height: 4px;
+ width: 387px;
+ background: rgb(34,64,109, 0.12);
+ border-radius: 4px;
+}
+
+.ProcessPayment-progress-fill {
+ height: 100%;
+ background: #0058CC;
+ border-radius: 4px;
+}
+
+.ProcessPayment-body {
+ overflow-x: hidden;
+ overflow-y: hidden;
+}
diff --git a/components/purchase_modal/process_payment_setup.tsx b/components/purchase_modal/process_payment_setup.tsx
new file mode 100644
index 000000000000..bc2b526356fc
--- /dev/null
+++ b/components/purchase_modal/process_payment_setup.tsx
@@ -0,0 +1,170 @@
+// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
+// See LICENSE.txt for license information.
+
+import React from 'react';
+import {Stripe} from '@stripe/stripe-js';
+
+import {BillingDetails} from 'types/cloud/sku';
+
+import successSvg from 'images/cloud/payment_success.svg';
+import failedSvg from 'images/cloud/payment_fail.svg';
+import {t} from 'utils/i18n';
+import {getNextBillingDate} from 'utils/utils';
+
+import processSvg from 'images/cloud/processing_payment.svg';
+
+import './process_payment.css';
+
+import IconMessage from './icon_message';
+
+type Props = {
+ billingDetails: BillingDetails | null;
+ stripe: Promise;
+ isDevMode: boolean;
+ addPaymentMethod: (stripe: Stripe, billingDetails: BillingDetails, isDevMode: boolean) => Promise;
+ onBack: () => void;
+ onClose: () => void;
+}
+
+type State = {
+ progress: number;
+ error: boolean;
+ state: ProcessState;
+}
+
+enum ProcessState {
+ PROCESSING = 0,
+ SUCCESS,
+ FAILED
+}
+
+const MIN_PROCESSING_MILLISECONDS = 5000;
+const MAX_FAKE_PROGRESS = 95;
+
+export default class ProcessPaymentSetup extends React.PureComponent {
+ intervalId: NodeJS.Timeout;
+
+ public constructor(props: Props) {
+ super(props);
+
+ this.intervalId = {} as NodeJS.Timeout;
+
+ this.state = {
+ progress: 0,
+ error: false,
+ state: ProcessState.PROCESSING,
+ };
+ }
+
+ public componentDidMount() {
+ this.savePaymentMethod();
+
+ this.intervalId = setInterval(this.updateProgress, MIN_PROCESSING_MILLISECONDS / MAX_FAKE_PROGRESS);
+ }
+
+ public componentWillUnmount() {
+ clearInterval(this.intervalId);
+ }
+
+ private updateProgress = () => {
+ let {progress} = this.state;
+
+ if (progress >= MAX_FAKE_PROGRESS) {
+ clearInterval(this.intervalId);
+ return;
+ }
+
+ progress += 1;
+ this.setState({progress: progress > MAX_FAKE_PROGRESS ? MAX_FAKE_PROGRESS : progress});
+ }
+
+ private savePaymentMethod = async () => {
+ const start = new Date();
+ const {stripe, addPaymentMethod, billingDetails, isDevMode} = this.props;
+ const success = await addPaymentMethod((await stripe)!, billingDetails!, isDevMode);
+
+ if (!success) {
+ this.setState({
+ error: true,
+ state: ProcessState.FAILED});
+ return;
+ }
+
+ const end = new Date();
+ const millisecondsElapsed = end.valueOf() - start.valueOf();
+ if (millisecondsElapsed < MIN_PROCESSING_MILLISECONDS) {
+ setTimeout(this.completePayment, MIN_PROCESSING_MILLISECONDS - millisecondsElapsed);
+ return;
+ }
+
+ this.completePayment();
+ }
+
+ private completePayment = () => {
+ clearInterval(this.intervalId);
+ this.setState({state: ProcessState.SUCCESS, progress: 100});
+ }
+
+ private handleGoBack = () => {
+ clearInterval(this.intervalId);
+ this.setState({
+ progress: 0,
+ error: false,
+ state: ProcessState.PROCESSING,
+ });
+ this.props.onBack();
+ }
+
+ public render() {
+ const {state, progress, error} = this.state;
+
+ const progressBar: JSX.Element | null = (
+
+ );
+
+ switch (state) {
+ case ProcessState.PROCESSING:
+ return (
+
+ );
+ case ProcessState.SUCCESS:
+ return (
+
+ );
+ case ProcessState.FAILED:
+ return (
+
+ );
+ default:
+ return null;
+ }
+ }
+}
diff --git a/components/purchase_modal/purchase.scss b/components/purchase_modal/purchase.scss
new file mode 100644
index 000000000000..0e2fa21ef00e
--- /dev/null
+++ b/components/purchase_modal/purchase.scss
@@ -0,0 +1,153 @@
+.PurchaseModal {
+ height: 100%;
+ overflow: hidden;
+
+ >div {
+ &.processing {
+ display: none;
+ }
+ overflow: hidden;
+ color: var(--center-channel-color);
+ font-size: 16px;
+ font-family: 'Open Sans';
+ padding: 77px 107px;
+ font-weight: 600;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: wrap;
+ flex-grow: 1;
+ width: 100%;
+ height: 100%;
+
+ .footer-text {
+ font-size: 14px;
+ }
+
+ .fineprint-text {
+ margin-top: 24px;
+ font-size: 12px;
+ line-height: 16px;
+ }
+
+ .bold-text {
+ font-weight: 600;
+ }
+
+ .normal-text {
+ font-weight: normal;
+ }
+
+ .LHS {
+ width: 25%;
+
+ .title {
+ font-size: 24px;
+ }
+
+ .image {
+ padding: 32px 0px;
+ }
+ }
+
+ .central-panel {
+ width: 50%;
+ }
+
+ .full-width {
+ border-color: rgba(var(--center-channel-color-rgb), 0.16)
+ }
+
+ .RHS {
+ width: 25%;
+ position: sticky;
+
+ .price-container {
+ font-weight: normal;
+ padding: 24px;
+ border: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
+ box-sizing: border-box;
+ box-shadow: 0px 8px 24px rgba(0, 0, 0, 0.12);
+ border-radius: 4px;
+ background-color: var(--center-channel-bg);
+ margin-bottom: 24px;
+ min-width: 270px;
+
+ .footer-text {
+ font-size: 14px;
+ margin-bottom: 24px;
+ }
+ }
+
+ .price-text {
+ font-size: 32px;
+ font-weight: 600;
+ padding: 16px 0px;
+ line-height: 1;
+ }
+
+ .monthly-text {
+ font-size: 14px;
+ font-weight: normal;
+ }
+ }
+
+ .waves {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ top: 0;
+ z-index: -1;
+ width: 100%;
+ stop {
+ stop-color: var(--button-bg);
+ }
+ }
+
+ .blue-dots {
+ position: absolute;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ top: 0;
+ z-index: -1;
+ }
+
+ .lower-blue-dots {
+ position: absolute;
+ right: 0;
+ bottom: 100px;
+ z-index: -1;
+ }
+
+ .logo {
+ position: absolute;
+ bottom: 0px;
+ right: 0px;
+ }
+
+ button {
+ background: #0058CC;
+ width: 100%;
+ height: 40px;
+ font-weight: 600;
+ font-size: 14px;
+ border-radius: 4px;
+ background: var(--button-bg);
+ color: var(--button-color);
+ border: 0;
+
+ &:disabled {
+ background: rgba(var(--center-channel-color-rgb), 0.08);
+ color: rgba(var(--center-channel-color-rgb), 0.32);
+ }
+ }
+ }
+}
+
+.FullScreenModal {
+ .close-x {
+ top: 12px;
+ right: 12px;
+ }
+}
diff --git a/components/purchase_modal/purchase_modal.tsx b/components/purchase_modal/purchase_modal.tsx
new file mode 100644
index 000000000000..104b6ce5f1a1
--- /dev/null
+++ b/components/purchase_modal/purchase_modal.tsx
@@ -0,0 +1,252 @@
+// 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 {Stripe, loadStripe} from '@stripe/stripe-js';
+import {Elements} from '@stripe/react-stripe-js';
+
+import {Product} from 'mattermost-redux/types/cloud';
+import {Dictionary} from 'mattermost-redux/types/utilities';
+
+import upgradeImage from 'images/cloud/upgrade.svg';
+import wavesBackground from 'images/cloud/waves.svg';
+import blueDotes from 'images/cloud/blue.svg';
+import LowerBlueDots from 'images/cloud/blue-lower.svg';
+
+import cloudLogo from 'images/cloud/mattermost-cloud.svg';
+
+import RootPortal from 'components/root_portal';
+import FullScreenModal from 'components/widgets/modals/full_screen_modal';
+import {areBillingDetailsValid, BillingDetails} from 'types/cloud/sku';
+import {getNextBillingDate} from 'utils/utils';
+
+import PaymentForm from '../payment_form/payment_form';
+
+import './purchase.scss';
+import 'components/payment_form/payment_form.scss';
+import ProcessPaymentSetup from './process_payment_setup';
+
+const STRIPE_CSS_SRC = 'https://fonts.googleapis.com/css?family=Open+Sans:400,400i,600,600i&display=swap';
+const STRIPE_PUBLIC_KEY = 'pk_test_ttEpW6dCHksKyfAFzh6MvgBj';
+
+const stripePromise = loadStripe(STRIPE_PUBLIC_KEY);
+
+type Props = {
+ show: boolean;
+ isDevMode: boolean;
+ products?: Dictionary;
+ actions: {
+ closeModal: () => void;
+ getCloudProducts: () => void;
+ completeStripeAddPaymentMethod: (stripe: Stripe, billingDetails: BillingDetails, isDevMode: boolean) => Promise;
+ getClientConfig: () => void;
+ };
+}
+
+type State = {
+ paymentInfoIsValid: boolean;
+ productPrice: number;
+ billingDetails: BillingDetails | null;
+ processing: boolean;
+}
+export default class PurchaseModal extends React.PureComponent {
+ modal = React.createRef();
+
+ public constructor(props: Props) {
+ super(props);
+
+ this.state = {
+ paymentInfoIsValid: false,
+ productPrice: 0,
+ billingDetails: null,
+ processing: false,
+ };
+ }
+
+ static getDerivedStateFromProps(props: Props, state: State) {
+ let productPrice = 0;
+ if (props.products) {
+ const keys = Object.keys(props.products);
+ if (keys.length > 0) {
+ // Assuming the first and only one for now.
+ productPrice = props.products[keys[0]].price_per_seat;
+ }
+ }
+
+ return {...state, productPrice};
+ }
+
+ componentDidMount() {
+ this.props.actions.getCloudProducts();
+
+ // this.fetchProductPrice();
+ this.props.actions.getClientConfig();
+ }
+
+ onPaymentInput = (billing: BillingDetails) => {
+ this.setState({paymentInfoIsValid: areBillingDetailsValid(billing)});
+ this.setState({billingDetails: billing});
+ }
+
+ handleSubmitClick = async () => {
+ this.setState({processing: true, paymentInfoIsValid: false});
+ }
+
+ purchaseScreen = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {`$${this.state.productPrice || 0}`}
+
+
+
+
+
{`Payment begins: ${getNextBillingDate()}`}
+
+
+
+
+
+ {'\u00A0'}
+
+
+
+
+
+
{'Need other billing options?'}
+
+
+
+
+
+
+
+
+
+ );
+ }
+
+ render() {
+ return (
+
+
+
+
+ {this.state.processing ? (
+
+
{
+ this.setState({processing: false});
+ }}
+ />
+
+ ) : null}
+ {this.purchaseScreen()}
+
+
+
+
+
+ );
+ }
+}
diff --git a/components/quick_switch_modal/__snapshots__/quick_switch_modal.test.jsx.snap b/components/quick_switch_modal/__snapshots__/quick_switch_modal.test.jsx.snap
index 8ab3a5a13015..e96ff89d100e 100644
--- a/components/quick_switch_modal/__snapshots__/quick_switch_modal.test.jsx.snap
+++ b/components/quick_switch_modal/__snapshots__/quick_switch_modal.test.jsx.snap
@@ -3,6 +3,7 @@
exports[`components/QuickSwitchModal should match snapshot 1`] = `