diff --git a/actions/cloud.tsx b/actions/cloud.tsx new file mode 100644 index 000000000000..ed7b22fd619f --- /dev/null +++ b/actions/cloud.tsx @@ -0,0 +1,71 @@ + +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import {Stripe} from '@stripe/stripe-js'; +import {getCode} from 'country-list'; + +import {Client4} from 'mattermost-redux/client'; + +import {getConfirmCardSetup} from 'components/payment_form/stripe'; + +import {StripeSetupIntent, BillingDetails} from 'types/cloud/sku'; + +// Returns true for success, and false for any error +export function completeStripeAddPaymentMethod(stripe: Stripe, billingDetails: BillingDetails, isDevMode: boolean) { + return async () => { + let paymentSetupIntent: StripeSetupIntent; + try { + paymentSetupIntent = await Client4.createPaymentMethod() as StripeSetupIntent; + } catch (error) { + return error; + } + const cardSetupFunction = getConfirmCardSetup(isDevMode); + const confirmCardSetup = cardSetupFunction(stripe.confirmCardSetup); + + const result = await confirmCardSetup( + paymentSetupIntent.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 false; + } + + const {setupIntent, error: stripeError} = result; + + if (stripeError) { + return false; + } + + if (setupIntent == null) { + return false; + } + + if (setupIntent.status !== 'succeeded') { + return false; + } + + try { + await Client4.confirmPaymentMethod(setupIntent.id); + } catch (error) { + return false; + } + + return true; + }; +} diff --git a/components/announcement_bar/cloud_announcement_bar/index.ts b/components/announcement_bar/cloud_announcement_bar/index.ts index d3d9da88f44e..5637345a9d64 100644 --- a/components/announcement_bar/cloud_announcement_bar/index.ts +++ b/components/announcement_bar/cloud_announcement_bar/index.ts @@ -12,6 +12,8 @@ import {makeGetCategory} from 'mattermost-redux/selectors/entities/preferences'; import {getCurrentUser, isCurrentUserSystemAdmin} from 'mattermost-redux/selectors/entities/users'; +import {openModal} from 'actions/views/modals'; + import {GlobalState} from 'types/store'; import {Preferences} from 'utils/constants'; @@ -36,6 +38,7 @@ function mapDispatchToProps(dispatch: Dispatch) { { savePreferences, getStandardAnalytics, + openModal, }, dispatch, ), diff --git a/components/announcement_bar/cloud_announcement_bar/user_limit_announcement_bar.tsx b/components/announcement_bar/cloud_announcement_bar/user_limit_announcement_bar.tsx index 5a3601c56048..a5f692d6a3dd 100644 --- a/components/announcement_bar/cloud_announcement_bar/user_limit_announcement_bar.tsx +++ b/components/announcement_bar/cloud_announcement_bar/user_limit_announcement_bar.tsx @@ -10,8 +10,14 @@ import {AnalyticsRow} from 'mattermost-redux/types/admin'; import {isEmpty} from 'lodash'; import {t} from 'utils/i18n'; +import PurchaseModal from 'components/purchase_modal'; -import {Preferences, CloudBanners, AnnouncementBarTypes} from 'utils/constants'; +import { + Preferences, + CloudBanners, + AnnouncementBarTypes, + ModalIdentifiers, +} from 'utils/constants'; import AnnouncementBar from '../default_announcement_bar'; @@ -25,6 +31,7 @@ type Props = { actions: { savePreferences: (userId: string, preferences: PreferenceType[]) => void; getStandardAnalytics: () => void; + openModal: (modalData: {modalId: string; dialogType: any; dialogProps?: any}) => void; }; }; @@ -95,7 +102,12 @@ export default class UserLimitAnnouncementBar extends React.PureComponent type={dismissable ? AnnouncementBarTypes.ADVISOR : AnnouncementBarTypes.CRITICAL_LIGHT} showCloseButton={dismissable} handleClose={this.handleClose} - showModal={() => {}} + showModal={() => + this.props.actions.openModal({ + modalId: ModalIdentifiers.CLOUD_PURCHASE, + dialogType: PurchaseModal, + }) + } modalButtonText={t('admin.billing.subscription.upgradeMattermostCloud.upgradeButton')} modalButtonDefaultText={'Upgrade Mattermost Cloud'} message={dismissable ? t('upgrade.cloud_banner_reached') : t('upgrade.cloud_banner_over')} diff --git a/components/dropdown_input.scss b/components/dropdown_input.scss index 0a201dc6f84f..0ed4a6076e27 100644 --- a/components/dropdown_input.scss +++ b/components/dropdown_input.scss @@ -1,4 +1,5 @@ .DropdownInput { + z-index: 999999; &.Input_container { margin-top: 20px; } @@ -38,6 +39,7 @@ padding: 10px 24px; line-height: 16px; cursor: pointer; + z-index: 999999; } .DropdownInput__option.selected > div { diff --git a/components/dropdown_input.tsx b/components/dropdown_input.tsx index 8819319552f3..608743443a03 100644 --- a/components/dropdown_input.tsx +++ b/components/dropdown_input.tsx @@ -49,6 +49,14 @@ const IndicatorsContainer = (props: any) => { ); }; +const Control = (props: any) => { + return ( +
+ +
+ ); +}; + const Option = (props: any) => { return (
(props: Props) => { components={{ IndicatorsContainer, Option, + Control, }} className={classNames('Input', className, {Input__focus: showLegend})} + classNamePrefix={'DropDown'} value={value} onChange={onChange as any} // types are not working correctly for multiselect styles={{...baseStyles, ...styles}} diff --git a/components/next_steps_view/next_steps_view.scss b/components/next_steps_view/next_steps_view.scss index b7b95ae94526..ba55f6d8e29e 100644 --- a/components/next_steps_view/next_steps_view.scss +++ b/components/next_steps_view/next_steps_view.scss @@ -137,18 +137,15 @@ background-color: var(--sidebar-text-active-border); } } - .Card.complete { border-color: transparent; box-shadow: none; transition-property: box-shadow, border-color; background-color: transparent; - &:hover { border-color: transparent; box-shadow: none; } - & + .Card { margin-top: 12px; transition-property: box-shadow, border-color, margin; @@ -175,7 +172,6 @@ line-height: 26px; color: var(--online-indicator); transition: all 0.3s ease-in-out; - &::before { margin: 0; } @@ -591,7 +587,6 @@ flex: 1 1 100%; padding: 0 12px; } - .NextStepsView__nextStepsCards, .NextStepsView__download, .NextStepsView__tipsMobileMessage { padding: 0 12px; } diff --git a/components/payment_form/card_image.css b/components/payment_form/card_image.css new file mode 100644 index 000000000000..53e5a145c8fe --- /dev/null +++ b/components/payment_form/card_image.css @@ -0,0 +1,8 @@ +.CardImage { + height: auto; + width: auto; + max-height: 30px; + max-width: 30px; + margin-right: 9px; + margin-top: -1px; +} diff --git a/components/payment_form/card_image.tsx b/components/payment_form/card_image.tsx new file mode 100644 index 000000000000..ca1227e0ddb5 --- /dev/null +++ b/components/payment_form/card_image.tsx @@ -0,0 +1,54 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; + +import amex from 'images/cloud/cards/amex.png'; + +import dinersclub from 'images/cloud/cards/dinersclub.png'; +import discover from 'images/cloud/cards/discover.jpg'; +import jcb from 'images/cloud/cards/jcb.png'; +import mastercard from 'images/cloud/cards/mastercard.png'; +import visa from 'images/cloud/cards/visa.jpg'; + +import './card_image.css'; + +type Props = { + brand: string; +} + +export default function CardImage(props: Props) { + const {brand} = props; + + const cardImageSrc = getCardImage(brand); + if (cardImageSrc) { + return ( + {brand} + ); + } + + return null; +} + +function getCardImage(brand: string): string { + switch (brand) { + case 'amex': + return amex; + case 'diners': + return dinersclub; + case 'discover': + return discover; + case 'jcb': + return jcb; + case 'mastercard': + return mastercard; + case 'visa': + return visa; + } + + return ''; +} diff --git a/components/payment_form/card_input.css b/components/payment_form/card_input.css new file mode 100644 index 000000000000..0efb2897a883 --- /dev/null +++ b/components/payment_form/card_input.css @@ -0,0 +1,38 @@ +.StripeElement { + font-family: 'Open Sans'; + font-size: 14px; + position: relative; + padding-top: 8px; + padding-left: 12px; + padding-bottom: 2px !important; + width: 100%; + height: 100%; + font-size: 14px; + line-height: 23px; + color: var(--center-channel-color); + background-color: var(--center-channel-bg); + background-image: none; + border-radius: 4px; + outline: none; + box-shadow: none; +} + +.StripeElement--invalid { + color: var(--error-text); + icon-color: var(--error-text); +} + +.StripeElement::placeholder { + color: rgba(var(--center-channel-color-rgb), 0.72); + opacity: 0.5; + font-size: 14px; +} + +.StripeElement:focus::placeholder { + color: 'transparent'; +} + +.StripeElement:focus { + border-color: transparent; + box-shadow: 0 0 0 2px var(--button-bg); +} diff --git a/components/payment_form/card_input.tsx b/components/payment_form/card_input.tsx new file mode 100644 index 000000000000..801492bbe7d1 --- /dev/null +++ b/components/payment_form/card_input.tsx @@ -0,0 +1,185 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {StripeElements, StripeCardElement, StripeCardElementChangeEvent} from '@stripe/stripe-js'; +import {ElementsConsumer, CardElement} from '@stripe/react-stripe-js'; + +import {FormattedMessage} from 'react-intl'; + +import './card_input.css'; +import 'components/input.css'; + +const CARD_ELEMENT_OPTIONS = { + hidePostalCode: true, + style: { + base: { + fontFamily: 'Open Sans', + fontSize: '14px', + opacity: '0.5', + fontSmoothing: 'antialiased', + }, + }, +}; + +type OwnProps = { + error?: string; + required?: boolean; + forwardedRef?: any; + + // Stripe doesn't give type exports + [propName: string]: any; //eslint-disable-line @typescript-eslint/no-explicit-any +} + +type Props = { + elements: StripeElements | null | undefined; +} & OwnProps; + +type State = { + focused: boolean; + error: string; + empty: boolean; + complete: boolean; +} + +const REQUIRED_FIELD_TEXT = 'This field is required'; +const VALID_CARD_TEXT = 'Please enter a valid credit card'; + +export interface CardInputType extends React.PureComponent { + getCard(): StripeCardElement | undefined; +} + +class CardInput extends React.PureComponent { + public constructor(props: Props) { + super(props); + + this.state = { + focused: false, + error: '', + empty: true, + complete: false, + }; + } + + private onFocus = () => { + const {onFocus} = this.props; + + this.setState({focused: true}); + + if (onFocus) { + onFocus(); + } + } + + private onBlur = () => { + const {onBlur} = this.props; + + this.setState({focused: false}); + this.validateInput(); + + if (onBlur) { + onBlur(); + } + } + + private onChange = (event: StripeCardElementChangeEvent) => { + this.setState({error: '', empty: event.empty, complete: event.complete}); + } + + private validateInput = () => { + const {required} = this.props; + const {empty, complete} = this.state; + let error = ''; + + this.setState({error: ''}); + if (required && empty) { + error = REQUIRED_FIELD_TEXT; + } else if (!complete) { + error = VALID_CARD_TEXT; + } + + this.setState({error}); + } + + private renderError(error: string) { + if (!error) { + return null; + } + + let errorMessage; + if (error === REQUIRED_FIELD_TEXT) { + errorMessage = ( + ); + } else if (error === VALID_CARD_TEXT) { + errorMessage = ( + ); + } + + return ( +
+ + {errorMessage} +
+ ); + } + + public getCard(): StripeCardElement | null | undefined { + return this.props.elements?.getElement(CardElement); + } + + public render() { + const {className, error: propError, ...otherProps} = this.props; + const {empty, focused, error: stateError} = this.state; + let fieldsetClass = className ? `Input_fieldset ${className}` : 'Input_fieldset'; + let fieldsetErrorClass = className ? `Input_fieldset Input_fieldset___error ${className}` : 'Input_fieldset Input_fieldset___error'; + const showLegend = Boolean(focused || !empty); + + fieldsetClass = showLegend ? fieldsetClass + ' Input_fieldset___legend' : fieldsetClass; + fieldsetErrorClass = showLegend ? fieldsetErrorClass + ' Input_fieldset___legend' : fieldsetErrorClass; + + const error = propError || stateError; + + return ( +
+
+ + + + +
+ {this.renderError(error)} +
+ ); + } +} + +const InjectedCardInput = (props: OwnProps) => { + return ( + + {({elements}) => ( + + )} + + ); +}; + +export default InjectedCardInput; diff --git a/components/payment_form/payment_form.scss b/components/payment_form/payment_form.scss new file mode 100644 index 000000000000..4dcbcde14d3c --- /dev/null +++ b/components/payment_form/payment_form.scss @@ -0,0 +1,154 @@ +.PaymentForm { + margin: 0 auto; + padding-left: 96px; + padding-right: 96px; + + .form-row { + width: 100%; + margin-bottom: 24px; + display: flex; + } + + .form-row-third-1 { + .DropdownInput { + margin-top: 0px; + z-index: 99999; + } + max-width: 288px; + width: 66%; + margin-right: 16px; + } + + .form-row-third-2 { + max-width: 144px; + width: 34%; + } + + .section-title{ + font-weight: 600; + font-size: 16px; + color: var(--center-channel-text); + text-align: left; + margin-bottom: 24px; + } + + .DropdownInput { + margin-bottom: 24px; + height: 36px; + position: relative; + + z-index: 999999; + + .DropDown__control { + min-height: 0px; + } + } + + .full-width { + width: 100% + } + + Input{ + font-size: 14px; + } + + .Input_fieldset { + background: var(--center-channel-bg); + height: 40px; + padding: 2px 1px; + + .Input_wrapper { + margin: 0; + } + + &.Input_fieldset___legend { + >legend { + margin-left: 11px; + } + } + + &.Input_fieldset:focus-within { + box-shadow: inset 0 0 0 2px var(--button-bg); + color: var(--button-bg); + padding-top: 2px + } + + + &.Input_fieldset___error { + color: var(--error-text); + box-shadow: inset 0 0 0 1px var(--error-text); + padding-top: 1px; + padding-bottom: 1px; + } + + &.Input_fieldset___error:focus-within { + color: var(--error-text); + box-shadow: inset 0 0 0 2px var(--error-text); + } + + .Input { + height: 32px; + &::placeholder{ + color: var(--center-channel-color); + opacity: 0.64; + } + } + } + + + .PaymentForm-saved { + width: 442px; + height: fit-content; + background: #FFFFFF; + border: 1px solid rgba(var(--center-channel-color-rgb), 0.08); + box-sizing: border-box; + border-radius: 4px; + padding: 24px; + margin-bottom: 16px; + } + + .PaymentForm-saved-title { + font-family: Metropolis; + font-weight: 600; + font-size: 14px; + line-height: 18px; + letter-spacing: 1.5px; + text-transform: uppercase; + color: #22406D; + opacity: 0.4; + margin-bottom: 16px; + } + + .PaymentForm-saved-card { + font-family: Source Sans Pro; + font-size: 16px; + line-height: 24px; + color: #22406D; + border-bottom: 1px solid rgba(0, 0, 0, 0.12); + padding-bottom: 16px; + } + + .PaymentForm-saved-address { + font-family: Source Sans Pro; + font-size: 16px; + line-height: 24px; + color: #22406D; + margin-top: 16px; + margin-bottom: 16px; + } + + .PaymentForm-change { + font-family: Source Sans Pro; + font-weight: 600; + font-size: 16px; + line-height: 20px; + color: #0058CC; + } +} + +input:-webkit-autofill, +input:-webkit-autofill:hover, +input:-webkit-autofill:focus, +input:-webkit-autofill:active { + -webkit-box-shadow: 0 0 0 30px var(--center-channel-bg) inset !important; +} diff --git a/components/payment_form/payment_form.tsx b/components/payment_form/payment_form.tsx new file mode 100644 index 000000000000..f6248c6d413d --- /dev/null +++ b/components/payment_form/payment_form.tsx @@ -0,0 +1,331 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React from 'react'; +import {getName} from 'country-list'; +import {FormattedMessage} from 'react-intl'; + +import {PaymentMethod} from 'mattermost-redux/types/cloud'; + +import {BillingDetails} from 'types/cloud/sku'; + +import DropdownInput from 'components/dropdown_input'; +import Input from 'components/input'; +import * as Utils from 'utils/utils'; +import {COUNTRIES} from 'utils/countries'; + +import StateSelector from './state_selector'; +import CardInput, {CardInputType} from './card_input'; +import CardImage from './card_image'; + +import './payment_form.scss'; +import 'components/input.css'; + +type Props = { + className: string; + initialBillingDetails?: BillingDetails; + paymentMethod?: PaymentMethod; + onInputChange?: (billing: BillingDetails) => void; + onInputBlur?: (billing: BillingDetails) => void; + buttonFooter?: JSX.Element; +} + +type State = { + address: string; + address2: string; + city: string; + state: string; + country: string; + postalCode: string; + name: string; + changePaymentMethod: boolean; +} + +export default class PaymentForm extends React.PureComponent { + static defaultProps = { + showSaveCard: false, + className: '', + }; + + cardRef: React.RefObject; + + public constructor(props: Props) { + super(props); + + this.cardRef = React.createRef(); + + this.state = this.getResetState(props); + } + + public componentDidUpdate(prevProps: Props) { + if (prevProps.paymentMethod == null && this.props.paymentMethod != null) { + this.resetState(); + return; + } + + if (prevProps.initialBillingDetails === undefined && this.props.initialBillingDetails !== undefined) { + this.resetState(); + } + } + + private resetState = () => { + this.setState(this.getResetState()); + } + + private getResetState = (props = this.props) => { + const {initialBillingDetails, paymentMethod} = props; + + const billingDetails = initialBillingDetails || {} as BillingDetails; + + return { + address: billingDetails.address, + address2: billingDetails.address2, + city: billingDetails.city, + state: billingDetails.state, + country: getName(billingDetails.country || '') || getName('US') || '', + postalCode: billingDetails.postalCode, + name: billingDetails.name, + changePaymentMethod: paymentMethod == null, + }; + } + + private handleInputChange = (event: React.ChangeEvent | React.ChangeEvent) => { + const target = event.target; + const name = target.name; + const value = target.value; + + const newStateValue = { + [name]: value, + } as unknown as Pick; + + this.setState(newStateValue); + + const {onInputChange} = this.props; + if (onInputChange) { + onInputChange({...this.state, ...newStateValue, card: this.cardRef.current?.getCard()} as BillingDetails); + } + } + + private handleStateChange = (stateValue: string) => { + const newStateValue = { + state: stateValue, + } as unknown as Pick; + this.setState(newStateValue); + + if (this.props.onInputChange) { + this.props.onInputChange({...this.state, ...newStateValue, card: this.cardRef.current?.getCard()} as BillingDetails); + } + } + + private handleCountryChange = (option: any) => { + const newStateValue = { + country: option.value, + } as unknown as Pick; + this.setState(newStateValue); + + if (this.props.onInputChange) { + this.props.onInputChange({...this.state, ...newStateValue, card: this.cardRef.current?.getCard()} as BillingDetails); + } + } + + private onBlur = () => { + const {onInputBlur} = this.props; + if (onInputBlur) { + onInputBlur({...this.state, card: this.cardRef.current?.getCard()} as BillingDetails); + } + } + + private changePaymentMethod = (event: React.MouseEvent) => { + event.preventDefault(); + this.setState({changePaymentMethod: true}); + } + + public render() { + const {className, paymentMethod, buttonFooter} = this.props; + const {changePaymentMethod} = this.state; + + let paymentDetails: JSX.Element; + if (changePaymentMethod) { + paymentDetails = ( + +
+ +
+
+ +
+
+ +
+ ({value: country.name, label: country.name}))} + legend={Utils.localizeMessage('payment_form.country', 'Country')} + placeholder={Utils.localizeMessage('payment_form.country', 'Country')} + name={'billing_dropdown'} + /> +
+ +
+
+ +
+
+ +
+
+
+ +
+
+ +
+
+ {changePaymentMethod ? buttonFooter : null} +
+ ); + } else { + let cardContent: JSX.Element | null = null; + + if (paymentMethod) { + let cardDetails = ( + + ); + if (paymentMethod.last_four) { + cardDetails = ( + + + {`Card ending in ${paymentMethod.last_four}`} +
+ {`Expires ${paymentMethod.exp_month}/${paymentMethod.exp_year}`} +
+ ); + } + let addressDetails = ( + + + ); + if (this.state.state) { + addressDetails = ( + + {this.state.address} + {this.state.address2} +
+ {`${this.state.city}, ${this.state.state}, ${this.state.country}`} +
+ {this.state.postalCode} +
+ ); + } + + cardContent = ( + +
+ {cardDetails} +
+
+ {addressDetails} +
+
+ ); + } + + paymentDetails = ( +
+
+ +
+ {cardContent} + +
+ ); + } + + return ( +
+
+ +
+ {paymentDetails} +
+ ); + } +} diff --git a/components/payment_form/state_selector.tsx b/components/payment_form/state_selector.tsx new file mode 100644 index 000000000000..e293ad05a675 --- /dev/null +++ b/components/payment_form/state_selector.tsx @@ -0,0 +1,66 @@ + +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. +import React from 'react'; +import {useIntl} from 'react-intl'; +import {getName} from 'country-list'; + +import DropdownInput from 'components/dropdown_input'; + +import Input from 'components/input'; + +import {US_STATES, CA_PROVINCES, StateCode} from 'utils/states'; + +type Props = { + country: string; + state: string; + onChange: (newValue: string) => void; + onBlur?: () => void; +} + +// StateSelector will display a state dropdown for US and Canada. +// Will display a open text input for any other country. +export default function StateSelector(props: Props) { + // Making TS happy here with the react-select event handler + const {formatMessage} = useIntl(); + const onStateSelected = (option: any) => { + props.onChange(option.value); + }; + + let stateList = [] as StateCode[]; + if (props.country === getName('US')) { + stateList = US_STATES; + } else if (props.country === getName('CA')) { + stateList = CA_PROVINCES; + } + + if (stateList.length > 0) { + return ( + ({ + value: stateCode.code, + label: stateCode.name, + }))} + legend={formatMessage({id: 'admin.billing.subscription.stateprovince', defaultMessage: 'State/Province'})} + placeholder={formatMessage({id: 'admin.billing.subscription.stateprovince', defaultMessage: 'State/Province'})} + name={'billing_dropdown'} + /> + ); + } + + return ( + { + props.onChange(e.target.value); + }} + onBlur={props.onBlur} + placeholder={formatMessage({id: 'admin.billing.subscription.stateprovince', defaultMessage: 'State/Province'})} + required={true} + />); +} + diff --git a/components/payment_form/stripe.ts b/components/payment_form/stripe.ts new file mode 100644 index 000000000000..1c4795b869bb --- /dev/null +++ b/components/payment_form/stripe.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +/* eslint-disable no-process-env */ +/* eslint-disable @typescript-eslint/no-unused-vars */ + +import { + StripeError, + ConfirmCardSetupData, + ConfirmCardSetupOptions, + SetupIntent, +} from '@stripe/stripe-js'; + +type ConfirmCardSetupType = (clientSecret: string, data?: ConfirmCardSetupData | undefined, options?: ConfirmCardSetupOptions | undefined) => Promise<{ setupIntent?: SetupIntent | undefined; error?: StripeError | undefined }> | undefined; + +function prodConfirmCardSetup(confirmCardSetup: ConfirmCardSetupType): ConfirmCardSetupType { + return confirmCardSetup; +} + +function devConfirmCardSetup(confirmCardSetup: ConfirmCardSetupType): ConfirmCardSetupType { + return async (clientSecret: string, data?: ConfirmCardSetupData | undefined, options?: ConfirmCardSetupOptions | undefined) => { + return {setupIntent: {id: 'testid', status: 'succeeded'} as SetupIntent}; + }; +} + +export const getConfirmCardSetup = (isDevMode: boolean) => (isDevMode ? devConfirmCardSetup : prodConfirmCardSetup); + 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 ( +
+
+ Payment icon +

+ +

+
+ {subtitle ? ( + + ) : null} +
+ {button} + {link} + {footer} +
+
+ ); +} + +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 ( +
+
+
+ +
+ upgrade +
+ +
+ + + +
+
+ +
+
+
+
+ +
+
+ {`$${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/user_limit_modal/user_limit_modal.test.tsx b/components/user_limit_modal/user_limit_modal.test.tsx index a8a6fcfa8b0d..0897db0a07b3 100644 --- a/components/user_limit_modal/user_limit_modal.test.tsx +++ b/components/user_limit_modal/user_limit_modal.test.tsx @@ -11,6 +11,7 @@ describe('component/UserLimitModal', () => { show: true, actions: { closeModal: () => { }, + openModal: jest.fn(), }, }; diff --git a/components/user_limit_modal/user_limit_modal.tsx b/components/user_limit_modal/user_limit_modal.tsx index d53edaef319d..c929e2fa7ce8 100644 --- a/components/user_limit_modal/user_limit_modal.tsx +++ b/components/user_limit_modal/user_limit_modal.tsx @@ -5,6 +5,9 @@ import React from 'react'; import {Modal, Button} from 'react-bootstrap'; import {FormattedMessage} from 'react-intl'; +import {ModalIdentifiers} from 'utils/constants'; +import PurchaseModal from 'components/purchase_modal'; + import UpgradeUserLimitModalSvg from './user_limit_upgrade_svg'; import './user_limit_modal.scss'; @@ -12,13 +15,17 @@ type Props = { show: boolean; actions: { closeModal: () => void; + openModal: (modalData: {modalId: string; dialogType: any; dialogProps?: any}) => void; }; }; export default function UserLimitModal(props: Props) { const onSubmit = () => { - // This does nothing until implementation of the upgrade tier page is complete - // Eventually, we will dispatch props.actions.openModal here + props.actions.closeModal(); + props.actions.openModal({ + modalId: ModalIdentifiers.CLOUD_PURCHASE, + dialogType: PurchaseModal, + }); }; const close = () => { diff --git a/i18n/en.json b/i18n/en.json index 53344410a9cd..ca6217741aa4 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -229,6 +229,15 @@ "admin.billing.payment_info_edit.title": "Edit Payment Information", "admin.billing.payment_info.add": "Add Payment Information", "admin.billing.payment_info.title": "Payment Information", + "admin.billing.subscription.disclaimer": "Your total is calculated at the end of the billing cycle based on the number of enabled users. You’ll only be charged if you exceed the free tier limits.", + "admin.billing.subscription.goBackTryAgain": "Go back and try again", + "admin.billing.subscription.howItWorks": "See how billing works", + "admin.billing.subscription.letsGo": "Lets go!", + "admin.billing.subscription.mattermostCloud": "Mattermost Cloud", + "admin.billing.subscription.nextBillingDate": "Starting {date} you will be charged based on the number of enabled users", + "admin.billing.subscription.paymentFailed": "Payment failed. Please try again or contact support.", + "admin.billing.subscription.paymentVerificationFailed": "Sorry, the payment verification failed", + "admin.billing.subscription.perUserPerMonth": " /user/month", "admin.billing.subscription.planDetails.currentPlan": "Current Plan", "admin.billing.subscription.planDetails.endDate": "End Date: ", "admin.billing.subscription.planDetails.features.10GBstoragePerUser": "10 GB storage per user", @@ -249,12 +258,19 @@ "admin.billing.subscription.planDetails.userCount": "{userCount} users", "admin.billing.subscription.planDetails.userCountWithLimit": "{userCount} / {userLimit} users", "admin.billing.subscription.privateCloudCard.contactSales": "Contact Sales", + "admin.billing.subscription.privateCloudCard.contactSupport": "Contact Support", "admin.billing.subscription.privateCloudCard.description": "If you need software with dedicated, single-tenant architecture, Mattermost Private Cloud (Beta) is the solution for high-trust collaboration.", "admin.billing.subscription.privateCloudCard.title": "Looking for a high-trust private cloud?", + "admin.billing.subscription.questions": "Questions?", + "admin.billing.subscription.stateprovince": "State/Province", "admin.billing.subscription.title": "Subscriptions", + "admin.billing.subscription.upgrade": "Upgrade", + "admin.billing.subscription.upgradeCloudSubscription": "Upgrade your Mattermost Cloud Subscription", + "admin.billing.subscription.upgradedSuccess": "Great! You're now upgraded", "admin.billing.subscription.upgradeMattermostCloud.description": "The free tier is **limited to 10 users.** Get access to more users, teams and other great features", "admin.billing.subscription.upgradeMattermostCloud.title": "Need more users?", "admin.billing.subscription.upgradeMattermostCloud.upgradeButton": "Upgrade Mattermost Cloud", + "admin.billing.subscription.verifyPaymentInformation": "Verifying your payment information", "admin.billing.subscriptions.billing_summary.noBillingHistory.description": "In the future, this is where your most recent bill summary will show.", "admin.billing.subscriptions.billing_summary.noBillingHistory.link": "See how billing works", "admin.billing.subscriptions.billing_summary.noBillingHistory.title": "No billing history yet", @@ -3305,6 +3321,21 @@ "password_send.reset": "Reset my password", "password_send.title": "Password Reset", "passwordRequirements": "Password Requirements:", + "payment_form.address": "Address", + "payment_form.address_2": "Address 2", + "payment_form.billing_address": "Billing address", + "payment_form.change_payment_method": "Change Payment Method", + "payment_form.city": "City", + "payment_form.country": "Country", + "payment_form.credit_card": "Credit Card", + "payment_form.name_on_card": "Name on Card", + "payment_form.no_billing_address": "No billing address added", + "payment_form.no_credit_card": "No credit card added", + "payment_form.saved_payment_method": "Saved Payment Method", + "payment_form.zipcode": "Zip/Postal Code", + "payment.card_number": "Card Number", + "payment.field_required": "This field is required", + "payment.invalid_card_number": "Please enter a valid credit card", "pdf_preview.max_pages": "Download to read more pages", "pending_post_actions.cancel": "Cancel", "pending_post_actions.retry": "Retry", diff --git a/images/cloud-logos/professional.svg b/images/cloud-logos/professional.svg new file mode 100644 index 000000000000..1dc55f32f30e --- /dev/null +++ b/images/cloud-logos/professional.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/cloud/blue-lower.svg b/images/cloud/blue-lower.svg new file mode 100644 index 000000000000..ef3fec6b8f28 --- /dev/null +++ b/images/cloud/blue-lower.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/cloud/blue.svg b/images/cloud/blue.svg new file mode 100644 index 000000000000..294411c0e293 --- /dev/null +++ b/images/cloud/blue.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/cloud/cards/amex.png b/images/cloud/cards/amex.png new file mode 100644 index 000000000000..e225b899a4a9 Binary files /dev/null and b/images/cloud/cards/amex.png differ diff --git a/images/cloud/cards/dinersclub.png b/images/cloud/cards/dinersclub.png new file mode 100644 index 000000000000..49b7dfba76fb Binary files /dev/null and b/images/cloud/cards/dinersclub.png differ diff --git a/images/cloud/cards/discover.jpg b/images/cloud/cards/discover.jpg new file mode 100644 index 000000000000..969b1e8611c7 Binary files /dev/null and b/images/cloud/cards/discover.jpg differ diff --git a/images/cloud/cards/jcb.png b/images/cloud/cards/jcb.png new file mode 100644 index 000000000000..16e7978c7d6a Binary files /dev/null and b/images/cloud/cards/jcb.png differ diff --git a/images/cloud/cards/mastercard.png b/images/cloud/cards/mastercard.png new file mode 100644 index 000000000000..4ae2259a080d Binary files /dev/null and b/images/cloud/cards/mastercard.png differ diff --git a/images/cloud/cards/visa.jpg b/images/cloud/cards/visa.jpg new file mode 100644 index 000000000000..e7aa924e6955 Binary files /dev/null and b/images/cloud/cards/visa.jpg differ diff --git a/images/cloud/mattermost-cloud.svg b/images/cloud/mattermost-cloud.svg new file mode 100644 index 000000000000..bf223e7463b1 --- /dev/null +++ b/images/cloud/mattermost-cloud.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/images/cloud/payment_fail.svg b/images/cloud/payment_fail.svg new file mode 100755 index 000000000000..5fc847baf10c --- /dev/null +++ b/images/cloud/payment_fail.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/cloud/payment_success.svg b/images/cloud/payment_success.svg new file mode 100755 index 000000000000..9cd241ff6f36 --- /dev/null +++ b/images/cloud/payment_success.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/cloud/processing_payment.svg b/images/cloud/processing_payment.svg new file mode 100755 index 000000000000..bc122b182d5b --- /dev/null +++ b/images/cloud/processing_payment.svg @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/cloud/upgrade.svg b/images/cloud/upgrade.svg new file mode 100644 index 000000000000..3a6da21bc939 --- /dev/null +++ b/images/cloud/upgrade.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/cloud/waves.svg b/images/cloud/waves.svg new file mode 100644 index 000000000000..a52e47dba86e --- /dev/null +++ b/images/cloud/waves.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/images/getting-started.svg b/images/getting-started.svg new file mode 100644 index 000000000000..e406c900bd18 --- /dev/null +++ b/images/getting-started.svg @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/onboarding-bg.svg b/images/onboarding-bg.svg new file mode 100644 index 000000000000..c6b036cd3fc7 --- /dev/null +++ b/images/onboarding-bg.svg @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/onboarding-success.svg b/images/onboarding-success.svg new file mode 100644 index 000000000000..042be4962e4b --- /dev/null +++ b/images/onboarding-success.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/package-lock.json b/package-lock.json index a55ae0df9377..2295f208fec8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4146,6 +4146,19 @@ } } }, + "@stripe/react-stripe-js": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-1.1.2.tgz", + "integrity": "sha512-07hu8RJXwWKGbvdvd1yt1cYvGtDB8jFX+q10f7FQuItUt9rlSo0am3WIx845iMHANiYgxyRb1PS201Yle9xxPQ==", + "requires": { + "prop-types": "^15.7.2" + } + }, + "@stripe/stripe-js": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-1.9.0.tgz", + "integrity": "sha512-/8+zfeRHlsEsxj0qmq9qbrnyF3fx+r97sDxfv7kqOyZFUFzC7DBwQwZlmss6XiV3ez5vz1G1QIjEW29PmLmsAw==" + }, "@svgr/babel-plugin-add-jsx-attribute": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-4.2.0.tgz", @@ -4613,6 +4626,11 @@ "integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==", "dev": true }, + "@types/country-list": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/country-list/-/country-list-2.1.0.tgz", + "integrity": "sha512-aCE6SP9WQJkq9kNFDRdxpUfWhazvreNSPtm1BQG1B4/xSpKbRwfweVAChYx6MMLyTguCKJExGZ1C52Z8DlFSGg==" + }, "@types/enzyme": { "version": "3.10.5", "resolved": "https://registry.npmjs.org/@types/enzyme/-/enzyme-3.10.5.tgz", @@ -8879,6 +8897,11 @@ "yaml": "^1.7.2" } }, + "country-list": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/country-list/-/country-list-2.2.0.tgz", + "integrity": "sha512-AS21pllCp72LmUztqXPVKXK3TRyap1XlohGLqN04cXH2rFB9vo7SnH5sMnqltZPnu9ie/x1se3UuCZJbGXErfw==" + }, "create-ecdh": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz", @@ -12559,11 +12582,15 @@ "resolved": "https://registry.npmjs.org/full-icu/-/full-icu-1.3.1.tgz", "integrity": "sha512-VMtK//85QJomhk3cXOCksNwOYaw1KWnYTS37GYGgyf7A3ajdBoPGhaJuJWAH2S2kq8GZeXkdKn+3Mfmgy11cVw==", "dev": true, + "requires": { + "icu4c-data": "0.64.2" + }, "dependencies": { "icu4c-data": { "version": "0.64.2", "resolved": "https://registry.npmjs.org/icu4c-data/-/icu4c-data-0.64.2.tgz", - "integrity": "sha512-BPuTfkRTkplmK1pNrqgyOLJ0qB2UcQ12EotVLwiWh4ErtZR1tEYoRZk/LBLmlDfK5v574/lQYLB4jT9vApBiBQ==" + "integrity": "sha512-BPuTfkRTkplmK1pNrqgyOLJ0qB2UcQ12EotVLwiWh4ErtZR1tEYoRZk/LBLmlDfK5v574/lQYLB4jT9vApBiBQ==", + "dev": true } } }, @@ -13464,12 +13491,6 @@ "postcss": "^7.0.14" } }, - "icu4c-data": { - "version": "0.67.2", - "resolved": "https://registry.npmjs.org/icu4c-data/-/icu4c-data-0.67.2.tgz", - "integrity": "sha512-OIRiop+k1IVf4TBLEOj910duoO9NKwtJLwp++qWT6KT5gRziHNt+5gwhcGuTqRy++RTK2gLoAIbk8KYCNxW++g==", - "dev": true - }, "identity-obj-proxy": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz", diff --git a/package.json b/package.json index b9b510c0f436..db7d701ad473 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,14 @@ "dependencies": { "@formatjs/intl-pluralrules": "3.2.3", "@formatjs/intl-relativetimeformat": "6.2.3", + "@stripe/react-stripe-js": "1.1.2", + "@stripe/stripe-js": "1.9.0", + "@types/country-list": "2.1.0", "bootstrap": "3.4.1", "chart.js": "2.9.3", "compass-mixins": "0.12.10", "core-js": "3.6.5", + "country-list": "2.2.0", "css-vars-ponyfill": "2.3.2", "custom-protocol-detection": "github:ismailhabib/custom-protocol-detection", "dynamic-virtualized-list": "github:mattermost/dynamic-virtualized-list#119db968c96643c7106d4d2c965f05b2e251bc83", @@ -233,7 +237,7 @@ "fix": "eslint --ext .js,.jsx,.tsx,.ts . --quiet --fix --cache", "build": "cross-env NODE_ENV=production webpack --display-error-details --verbose", "run": "webpack --progress --watch", - "dev-server": "webpack-dev-server", + "dev-server": "webpack-dev-server -d", "test": "cross-env NODE_ICU_DATA=node_modules/full-icu TZ=Etc/UTC jest --maxWorkers=50%", "test-ci": "cross-env NODE_ICU_DATA=node_modules/full-icu TZ=Etc/UTC jest --ci --maxWorkers=8", "stats": "cross-env NODE_ENV=production webpack --profile --json > webpack_stats.json", diff --git a/sass/components/_announcement-bar.scss b/sass/components/_announcement-bar.scss index 2a5a3bb408e7..414ea8b12dc5 100644 --- a/sass/components/_announcement-bar.scss +++ b/sass/components/_announcement-bar.scss @@ -35,6 +35,7 @@ vertical-align: middle; opacity: 0.56; } + z-index: 1029; } background-color: #113166; diff --git a/types/cloud/sku.ts b/types/cloud/sku.ts new file mode 100644 index 000000000000..8a13bbe9fc4f --- /dev/null +++ b/types/cloud/sku.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {StripeCardElement} from '@stripe/stripe-js'; + +export type StripeSetupIntent = { + id: string; + client_secret: string; +}; + +export type BillingDetails = { + address: string; + address2: string; + city: string; + state: string; + country: string; + postalCode: string; + name: string; + card: StripeCardElement; + agreedTerms?: boolean; +}; + +export const areBillingDetailsValid = ( + billingDetails: BillingDetails | null | undefined, +): boolean => { + if (billingDetails == null) { + return false; + } + + return Boolean( + billingDetails.address && + billingDetails.city && + billingDetails.state && + billingDetails.country && + billingDetails.postalCode && + billingDetails.name, + ); +}; diff --git a/utils/constants.jsx b/utils/constants.jsx index 03e643a33dd6..839e41d6a3b1 100644 --- a/utils/constants.jsx +++ b/utils/constants.jsx @@ -273,6 +273,7 @@ export const ModalIdentifiers = { REMOVE_NEXT_STEPS_MODAL: 'remove_next_steps_modal', MORE_CHANNELS: 'more_channels', NEW_CHANNEL_FLOW: 'new_channel_flow', + CLOUD_PURCHASE: 'cloud_purchase', }; export const UserStatuses = { @@ -1560,3 +1561,4 @@ t('suggestion.archive'); t('suggestion.mention.groups'); export default Constants; + diff --git a/utils/countries.ts b/utils/countries.ts new file mode 100644 index 000000000000..5aadd18af1c6 --- /dev/null +++ b/utils/countries.ts @@ -0,0 +1,11 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {getData} from 'country-list'; + +type Country = { + name: string; + code: string; +} + +export const COUNTRIES = getData().sort((a: Country, b: Country) => (a.name > b.name ? 1 : -1)); diff --git a/utils/states.ts b/utils/states.ts new file mode 100644 index 000000000000..091b4fe46795 --- /dev/null +++ b/utils/states.ts @@ -0,0 +1,78 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +export interface StateCode { + code: string; + name: string; +} + +export const US_STATES = [ + {code: 'AL', name: 'Alabama'}, + {code: 'AK', name: 'Alaska'}, + {code: 'AZ', name: 'Arizona'}, + {code: 'AR', name: 'Arkansas'}, + {code: 'CA', name: 'California'}, + {code: 'CO', name: 'Colorado'}, + {code: 'CT', name: 'Connecticut'}, + {code: 'DE', name: 'Delaware'}, + {code: 'DC', name: 'District of Columbia'}, + {code: 'FL', name: 'Florida'}, + {code: 'GA', name: 'Georgia'}, + {code: 'HI', name: 'Hawaii'}, + {code: 'ID', name: 'Idaho'}, + {code: 'IL', name: 'Illinois'}, + {code: 'IN', name: 'Indiana'}, + {code: 'IA', name: 'Iowa'}, + {code: 'KS', name: 'Kansas'}, + {code: 'KY', name: 'Kentucky'}, + {code: 'LA', name: 'Louisiana'}, + {code: 'ME', name: 'Maine'}, + {code: 'MD', name: 'Maryland'}, + {code: 'MA', name: 'Massachusetts'}, + {code: 'MI', name: 'Michigan'}, + {code: 'MN', name: 'Minnesota'}, + {code: 'MS', name: 'Mississippi'}, + {code: 'MO', name: 'Missouri'}, + {code: 'MT', name: 'Montana'}, + {code: 'NE', name: 'Nebraska'}, + {code: 'NV', name: 'Nevada'}, + {code: 'NH', name: 'New Hampshire'}, + {code: 'NJ', name: 'New Jersey'}, + {code: 'NM', name: 'New Mexico'}, + {code: 'NY', name: 'New York'}, + {code: 'NC', name: 'North Carolina'}, + {code: 'ND', name: 'North Dakota'}, + {code: 'OH', name: 'Ohio'}, + {code: 'OK', name: 'Oklahoma'}, + {code: 'OR', name: 'Oregon'}, + {code: 'PA', name: 'Pennsylvania'}, + {code: 'PR', name: 'Puerto Rico'}, + {code: 'RI', name: 'Rhode Island'}, + {code: 'SC', name: 'South Carolina'}, + {code: 'SD', name: 'South Dakota'}, + {code: 'TN', name: 'Tennessee'}, + {code: 'TX', name: 'Texas'}, + {code: 'UT', name: 'Utah'}, + {code: 'VT', name: 'Vermont'}, + {code: 'VA', name: 'Virginia'}, + {code: 'WA', name: 'Washington'}, + {code: 'WV', name: 'West Virginia'}, + {code: 'WI', name: 'Wisconsin'}, + {code: 'WY', name: 'Wyoming'}, +] as StateCode[]; + +export const CA_PROVINCES = [ + {code: 'AB', name: 'Alberta'}, + {code: 'BC', name: 'British Columbia'}, + {code: 'MB', name: 'Manitoba'}, + {code: 'NB', name: 'New Brunswick'}, + {code: 'NL', name: 'Newfoundland and Labrador'}, + {code: 'NT', name: 'Northwest Territories'}, + {code: 'NS', name: 'Nova Scotia'}, + {code: 'NU', name: 'Nunavut'}, + {code: 'ON', name: 'Ontario'}, + {code: 'PE', name: 'Prince Edward Island'}, + {code: 'QC', name: 'Quebec'}, + {code: 'SK', name: 'Saskatchewan'}, + {code: 'YT', name: 'Yukon Territory'}, +] as StateCode[]; diff --git a/utils/utils.jsx b/utils/utils.jsx index 64f6a7626a93..00492a675e4f 100644 --- a/utils/utils.jsx +++ b/utils/utils.jsx @@ -19,6 +19,8 @@ import {displayUsername} from 'mattermost-redux/utils/user_utils'; import {getCurrentTeamId, getCurrentRelativeTeamUrl, getTeam} from 'mattermost-redux/selectors/entities/teams'; import cssVars from 'css-vars-ponyfill'; +import moment from 'moment'; + import {browserHistory} from 'utils/browser_history'; import {searchForTerm} from 'actions/post_actions'; import Constants, {FileTypes, UserStatuses} from 'utils/constants.jsx'; @@ -1895,3 +1897,8 @@ export function adjustSelection(inputBox, e) { setSelectionRange(inputBox, selectionStart + 1, selectionEnd - 1); } } + +export function getNextBillingDate() { + const nextBillingDate = moment().add(1, 'months').startOf('month'); + return nextBillingDate.format('MMM D, YYYY'); +} diff --git a/webpack.config.js b/webpack.config.js index 4c0f91078795..dd3203509392 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -275,7 +275,7 @@ var config = { meta: { csp: { 'http-equiv': 'Content-Security-Policy', - content: 'script-src \'self\' cdn.rudderlabs.com/' + CSP_UNSAFE_EVAL_IF_DEV, + content: 'script-src \'self\' cdn.rudderlabs.com/ js.stripe.com/v3 ' + CSP_UNSAFE_EVAL_IF_DEV, }, }, }),